Commit 8aeef4e0 authored by gu-jinli1118's avatar gu-jinli1118
Browse files

20230831

parent 646116b0
Pipeline #31 failed with stages
in 0 seconds
## 商品分组
#### 商城应用
在mall4j精选商城首页中,可以看到有`每日上新``商城热卖``更多商品`等标签栏,在每一栏位中用来展示特定的商品列表,如下图:。
![1566266497255](.\img\小程序-分组商品.png)
在后台中,我们可以对分组标签进行管理
![1566358291520](.\img\后台分组位置.png)
![1566358576330](.\img\后台新增标签.png)
后台指定绑定商品所指定的标签![1566267024882](.\img\后台-商品分组.png)
店铺商品分组有**两种**分组类型:
- 系统内置
更多宝贝:在新增商品的时候,如果用户没有新增任何的分组标签,系统默认提供了一个默认标签。系统内置的标签不能够被删除。
- 商家自定义分组标签
用户可以通过自定义分组标签,在首页根据自定义的分组情况对商品的经行展示。
#### 数据库设计
整体实体类关系如下图:
![1566357194240](.\img\分组管理数据库设计2.png)
model 实体类
商品标签类:
```java
@Data
@TableName("tz_prod_tag")
public class ProdTag implements Serializable {
private static final long serialVersionUID = 1991508792679311621L;
/**
* 分组标签id
*/
@TableId
private Long id;
/**
* 店铺Id
*/
private Long shopId;
/**
* 分组标题
*/
private String title;
/**
* 状态(1为正常,0为删除)
*/
private Integer status;
/**
* 默认类型(0:商家自定义,1:系统默认类型)
*/
private Integer isDefault;
/**
* 商品数量
*/
private Long prodCount;
/**
* 排序
*/
private Integer seq;
/**
* 列表样式(0:一列一个,1:一列两个,2:一列三个)
*/
private Integer style;
/**
* 创建时间
*/
private Date createTime;
/**
* 修改时间
*/
private Date updateTime;
/**
* 删除时间
*/
private Date deleteTime;
}
```
- `id` ,商品分组编号,自增
- `shopId` ,店铺ID
​ 用于取分每个店铺,可扩展为B2B2C模式
- `status` ,删除时,1为正常,0为删除
- `title`, 分组标题
- `isDefault` 是否为默认类型
- 商家自定义:每日上新,商城热卖等
- 系统内置:更多宝贝,默认内置的标签不能被删除,在用户
- `prodCount`,商品数量统计
- `seq` 排序顺序
- `style`列表样式(0:一列一个,1:一列两个,2:一列三个) ,用于扩展开发,用户可以根据自己喜欢的排版布局,对商品布局进行排版
商品分组引用:商品分组**引用**。一个商品可以有多个商品分组。
```java
@Data
@TableName("tz_prod_tag_reference")
public class ProdTagReference implements Serializable{
private static final long serialVersionUID = 1L;
/**
* 分组引用id
*/
@TableId
private Long referenceId;
/**
* 店铺id
*/
private Long shopId;
/**
* 标签id
*/
private Long tagId;
/**
* 商品id
*/
private Long prodId;
/**
* 状态(1:正常,0:删除)
*/
private Integer status;
/**
* 创建时间
*/
private Date createTime;
}
```
- `referenceId` ,分组引用ID
- `shopId` , 标识所属的店铺,用于取分每个店铺
- `tagId`, 所指向的标签ID
- `prodId`,所指向的商品ID
- `createTime` 创建时间
# 1. 背景了解
在看具体的数据库实体设计之前,我们先一起了解下**电商的名词定义**
## 1.1 名词定义
参考 [《产品 SKU 是什么意思?与之相关的还有哪些?》](https://www.zhihu.com/question/19841574) 整理。
**SKU:Stock Keeping Unit**
中文翻译为库存单位。SKU 从**库存**视角,以库存进出为单位,可以是件、瓶、箱等等。
例如,iPhone 手机,按照规格( 颜色 + 内存 )可以组合出如下多个 SKU :
| SKU | 颜色 | 内存 |
| ---- | ---- | ---- |
| A | 白色 | 16G |
| B | 白色 | 64G |
| C | 黑色 | 16G |
| D | 黑色 | 64G |
可以看出,颜色(白色、黑色)与内存(16G、64G)组合排列出四种 iPhone SKU。
**SPU:Standard Product Unit**
中文翻译为标准产品单位。SPU 从**产品**视角,是产品信息聚合的**最小单位**,是一组可复用、易检索的标准化信息的集合,该集合描述了一个产品的特性。通俗点讲,属性值、特性相同的商品就可以被称为一个 SPU 。例如 iPhone 8 就是一个 SPU ,iPhone 8 Plus 也是一个 SPU ,这个与**商家**无关,与颜色、款式、套餐等**规格**无关。
**商品**
商家出售某个 SPU ,那么这就是一个商品。商品在 SPU 之上,增加了销售价格、促销活动、运费等等信息。另外,**一个商品可以包含多个 SKU**
**总结**
![img](./img/24.png)
------
现实的场景往往比定义复杂的多,在本文中,**SKU 代表销售的单元**。主要考虑如下两方面:
- 实际我们看到的商品详情页,购买的是一个**销售组合单元**。例如,很多商家会打包 【iPhone X :银色-64G-套餐三】,其中套餐三为赠送贴膜 + 保护壳等等,当然价格上会更贵。这明显就违背了我们上述提到 SKU 库存的概念,已经变成了多个 SKU 的销售组合单元。![img](./img/01.png)
- 一个商家会在不同平台销售商品,例如三只松鼠,其在天猫、京东等等平台都有官方旗舰店,同时也供货给其他渠道商,那么实际关系会变成如下图所示:![img](./img/02.png) 通过这样的方式,三只松鼠在不同的平台,定义不同的价格,设置不同的促销信息等等个性化的运营。
------
**那么注意了**!!!
**下文开始,SKU 代表销售的单元**
**下文开始,SKU 代表销售的单元**
**下文开始,SKU 代表销售的单元**
## 1.2 界面
1. 商城端-购买页
![购买页面](./img/购买页.png)
2. 运营后台-商品发布页 ![发布商品](./img/发布商品.png)
# 2. 数据库实体
整体实体类关系如下图:
![img](./img/规格关联关系.png)
全部实体可在数据库中查阅。
## 2.1 Product
Product 字段较多,我们进行简单的切块。
### 2.1.1 基础字段
```java
@Data
@TableName("tz_prod")
public class Product implements Serializable {
/**
* 商品ID
*/
@TableId
private Long prodId;
/**
* 店铺id
*/
private Long shopId;
/**
* 商品名称
*/
private String prodName;
/**
* 简要描述,卖点等
*/
private String brief;
/**
* 商品主图
*/
private String pic;
/**
* 商品图片
*/
private String imgs;
/**
* 默认是1,表示正常状态, -1表示删除, 0下架
*/
private Integer status;
/**
* 商品分类
*/
private Long categoryId;
/**
* 已经销售数量
*/
private Integer soldNum;
/**
* 录入时间
*/
private Date createTime;
/**
* 修改时间
*/
private Date updateTime;
/**
* 详细描述
*/
private String content;
/**
* 上架时间
*/
private Date putawayTime;
@Data
public static class DeliveryModeVO {
/**
* 用户自提
*/
private Boolean hasUserPickUp;
/**
* 店铺配送
*/
private Boolean hasShopDelivery;
}
}
```
- `prodId` ,商品id,数据库自增。
- `shopId` ,店铺编号,支持多商户( 店铺 )。
- `categoryId` ,商品所在分类id,每个商品都有自己所属的分类
### 2.1.2 价格库存
```java
/**
* 库存量
* 基于 sku 的库存数量累加
*/
private Integer totalStocks;
/**
* 原价
*/
private Double oriPrice;
/**
* 现价
*/
private Double price;
```
- 在我们的数据库中规定,所有的商品都是具有sku的,就算是只有一种规格的商品,所以`product`里面的库存数量为所有sku库存数量的总和
- `price` ,商品价格为元,这里使用`Double`而没有使用`BigDecimal `,而数据库中使用`decimal` 进行存储,所以在数据库中是可以进行直接进行运算的,而在java当中需要使用`com.yami.shop.common.util.Arith`进行运算 。
### 2.1.3 运费信息
```java
/**
* 配送方式json
*/
private String deliveryMode;
/**
* 运费模板id
*/
private Long deliveryTemplateId;
@Data
public static class DeliveryModeVO {
/**
* 用户自提
*/
private Boolean hasUserPickUp;
/**
* 店铺配送
*/
private Boolean hasShopDelivery;
}
```
- 根据` deliveryMode `标记所含有的配送方式进行配送。
- `deliveryTemplateId` 运费模板id,根据不同的运费模板设计不同的配送费
![img](./img/配送与运费模板.png)
运费模板的操作见 :运费模板的设计相关文章。
## 2.2 Sku
商品 SKU 。
![img](./img/sku.png)
```java
@Data
@TableName("tz_sku")
public class Sku implements Serializable {
/**
* 单品ID
*/
@TableId
private Long skuId;
/**
* 商品ID
*/
private Long prodId;
/**
* 销售属性组合字符串,格式是p1:v1;p2:v2
*/
private String properties;
/**
* 原价
*/
private Double oriPrice;
/**
* 价格
*/
private Double price;
/**
* 库存
*/
private Integer stocks;
/**
* 实际库存
*/
private Integer actualStocks;
/**
* 修改时间
*/
private Date updateTime;
/**
* 记录时间
*/
private Date recTime;
/**
* 商家编码
*/
private String partyCode;
/**
* 商品条形码
*/
private String modelId;
/**
* sku图片
*/
private String pic;
/**
* sku名称
*/
private String skuName;
/**
* 商品名称
*/
private String prodName;
/**
* 重量
*/
private Double weight;
/**
* 体积
*/
private Double volume;
/**
* 状态:0禁用 1 启用
*/
private Integer status;
/**
* 0 正常 1 已被删除
*/
private Integer isDelete;
}
```
- `skuId` ,SKU 编号,自增,唯一,参见分销场景。
- `prodId` ,商品编号,N:1 指向对应的 Product 。
- `status`,SKU 状态。编辑商品时,当禁用该sku时,前端将会将该sku置灰
- `stocks` ,库存数量。
- `properties`,商品规格,字符串拼接格式。
绝大多数情况下,数据库里的该字段,不存在检索的需求,更多的时候,是查询整体记录,在内存中解析使用。
少部分情况,灵活的检索,使用 Elasticsearch 进行解决。
因为我们的规格是直接保存字符串的,所以可以选择,或直接输入
![img](./img/规格选择.png)![img](./img/规格可以输入.png)
## 2.3 ProdProp
商品 SKU 规格属性,在数据库中保存的常用数据。不常用的数据可以直接手动输入即可。
![img](./img/规格属性.png)
```java
public class ProdProp implements Serializable {
/**
* 属性id
*/
@TableId
private Long propId;
/**
* 属性名称
*/
private String propName;
private Long shopId;
}
```
- `propId` ,属性编号。
- `propName` ,属性名称。
## 2.4 ProdPropValue
商品 SKU 规格属性,在数据库中保存的常用数据。
```java
public class ProdProp implements Serializable {
/**
* 属性值ID
*/
@TableId
private Long valueId;
/**
* 属性值名称
*/
private String propValue;
/**
* 属性ID
*/
private Long propId;
}
```
- `valueId` ,属性值ID。
- `propValue` ,属性值名称。
使用`mybatis plus` 进行分页的时候,是无法进行一对多、多对多的分页的。最主要的原因是因为,该框架无法清楚count的依据是什么,以哪个表算出来的行数为准,但是我们所有的分页格式已经统一好使用`IPage`对象了,那么该如何适配一对多、多对多分页呢?
## PageAdapter
使用分页时,前端传入的数据统一格式为`current`当前页,`size`每页大小。而我们在数据库中要将这两个数据变更为从第几行到第几行,所以我们需要简单的适配一下:
```java
@Data
public class PageAdapter{
private int begin;
private int end;
public PageAdapter(Page page) {
int[] startEnd = PageUtil.transToStartEnd((int) page.getCurrent(), (int) page.getSize());
this.begin = startEnd[0];
this.end = startEnd[1];
}
}
```
## Count
在使用`mybatis plus` 进行分页的时候,该工具会自动为我们编写count的sql,而一对多进行分页时如:
1个订单有5个订单项,在使用`mybatis plus` 生成的`count sql` 会认为每行都是一条数据,导致最后认为会有5条订单信息,实际上应该只有1条订单信息。这个时候我们必须自己手写`count sql`,并区分`records sql`
具体例子可以查看`OrderServiceImpl`
```java
@Override
public IPage<Order> pageOrdersDetialByOrderParam(Page<Order> page, OrderParam orderParam) {
page.setRecords(orderMapper.listOrdersDetialByOrderParam(new PageAdapter(page), orderParam));
page.setTotal(orderMapper.countOrderDetial(orderParam));
return page;
}
```
在小程序登陆的时候,在`MiniAppAuthenticationProvider`中我们看到这样一行代码
```java
yamiUserDetailsService.insertUserIfNecessary(appConnect);
```
这便是商城用户创建的代码,在`YamiUserServiceImpl#insertUserIfNecessary()`方法中,有一个这样的注解
```java
@RedisLock(lockName = "insertUser", key = "#appConnect.appId + ':' + #appConnect.bizUserId")
```
这里便用了分布式锁,为什么我们要在这里使用锁?分布式锁又是什么?
- 由于用户是通过登录直接注册的,如果一个用户在不刻意之间,又或者前端写的东西有点问题,这就会导致整个系统创建了两个相同的用户,这是非常危险的事情,所以创建用户这里必须加锁。
- 至于为什么使用分布式锁,是因为我们虽然没有用上spring cloud、dubbo之类的东西,实际上我们也是希望我们的商城可以多实例部署的,也就是可以搞分布式的。因此用了分布式锁
分布式锁,简单来说就是锁,而且还是适合分布式环境的。分布式说起来也很奇怪,要是有什么不能共享的东西,那就抽出来共享。比如本地数据缓存不能共享,那么就抽出一个如redis之类的东西,进行共享。session不能共享,那么就将session抽出来,丢到redis之类的东西,又能共享了。
锁不能共享,同样可以丢一个标记到redis,由于redis是单线程的,所以也不用担心redis的线程安全的问题。这个标记就是一个锁的标记,那样你就实现了分布式锁...
我们看回`@RedisLock` 该类,里面有个`expire()`方法
```java
/**
* 过期毫秒数,默认为5000毫秒
*
* @return 锁的时间
*/
int expire() default 5000;
```
由于网络稳定、宕机等各种原因,分布式锁,必须要有过期时间,否则锁无法释放的话,会阻塞一片的实例。
## 实现一个简单的分布式锁注解
由于自己去实现redis的分布式锁,是比较困难的问题,还要考虑redis复制,宕机之类的问题,所以我们使用一个比较优秀的开源项目 **redisson**来实现我们的分布式锁
`@RedisLock`所注解的方法,会被 `RedisLockAspect` 进行切面管理,代码如下:
```java
@Around("@annotation(redisLock)")
public Object around(ProceedingJoinPoint joinPoint, RedisLock redisLock) throws Throwable {
String spel = redisLock.key();
String lockName = redisLock.lockName();
// redissonClient 也就是通过redisson 进行对锁管理
RLock rLock = redissonClient.getLock(getRedisKey(joinPoint,lockName,spel));
rLock.lock(redisLock.expire(),redisLock.timeUnit());
Object result = null;
try {
//执行方法
result = joinPoint.proceed();
} finally {
rLock.unlock();
}
return result;
}
```
## 识别spel表达式
`@RedisLock(lockName = "insertUser", key = "#appConnect.appId + ':' + #appConnect.bizUserId")``#appConnect.appId` 也仅仅是表示一串字符串而已,而能将其变成表达式,需要一定的转换`SpelUtil.parse`
```java
/**
* 支持 #p0 参数索引的表达式解析
* @param rootObject 根对象,method 所在的对象
* @param spel 表达式
* @param method ,目标方法
* @param args 方法入参
* @return 解析后的字符串
*/
public static String parse(Object rootObject,String spel, Method method, Object[] args) {
if (StrUtil.isBlank(spel)) {
return StrUtil.EMPTY;
}
//获取被拦截方法参数名列表(使用Spring支持类库)
LocalVariableTableParameterNameDiscoverer u =
new LocalVariableTableParameterNameDiscoverer();
String[] paraNameArr = u.getParameterNames(method);
if (ArrayUtil.isEmpty(paraNameArr)) {
return spel;
}
//使用SPEL进行key的解析
ExpressionParser parser = new SpelExpressionParser();
//SPEL上下文
StandardEvaluationContext context = new MethodBasedEvaluationContext(rootObject,method,args,u);
//把方法参数放入SPEL上下文中
for (int i = 0; i < paraNameArr.length; i++) {
context.setVariable(paraNameArr[i], args[i]);
}
return parser.parseExpression(spel).getValue(context, String.class);
}
```
同时我们也害怕redis的key发生冲突,所以会对key加上一些统一的前缀:
redis 锁的key能够识别`spel` 表达式,并且不和其他方法的锁名称或缓存名称重复
```java
/**
* 将spel表达式转换为字符串
* @param joinPoint 切点
* @return redisKey
*/
private String getRedisKey(ProceedingJoinPoint joinPoint,String lockName,String spel) {
Signature signature = joinPoint.getSignature();
MethodSignature methodSignature = (MethodSignature) signature;
Method targetMethod = methodSignature.getMethod();
Object target = joinPoint.getTarget();
Object[] arguments = joinPoint.getArgs();
return REDISSON_LOCK_PREFIX + lockName + StrUtil.COLON + SpelUtil.parse(target,spel, targetMethod, arguments);
}
```
身为服务器的开发者,我们是无法相信用户输入的任何东西的。比如:金额不能从前端传过来,使用会失效的token等。当然,用户除了会传入一些假数据,也会传入一些假的脚本,比较出名的就是**xss攻击**
网上有很多说解决xss攻击的方法,有很多都是和前端有关,而实际上,在后台这最后一个防御当中,是最为重要的。
在mall4j这个项目里面,使用了一个过滤器 `XssFilter`
```
public class XssFilter implements Filter {
Logger logger = LoggerFactory.getLogger(getClass().getName());
@Override
public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) throws IOException, ServletException{
HttpServletRequest req = (HttpServletRequest) request;
HttpServletResponse resp = (HttpServletResponse) response;
logger.info("uri:{}",req.getRequestURI());
// xss 过滤
chain.doFilter(new XssWrapper(req), resp);
}
}
```
主要是通过 `new XssWrapper(req)` 这个对象进行一系列的过滤,而 `XssWrapper` 是通过`Jsoup`进行用户输入的一系列过滤。毕竟专业的事情要交给专业的人来搞定。就此,我们通过简单的设置就完成了对**xss攻击**的防御。
```java
public class XssWrapper extends HttpServletRequestWrapper {
/**
* Constructs a request object wrapping the given request.
*
* @param request The request to wrap
* @throws IllegalArgumentException if the request is null
*/
public XssWrapper(HttpServletRequest request) {
super(request);
}
/**
* 对数组参数进行特殊字符过滤
*/
@Override
public String[] getParameterValues(String name) {
String[] values = super.getParameterValues(name);
if (values == null) {
return null;
}
int count = values.length;
String[] encodedValues = new String[count];
for (int i = 0; i < count; i++) {
encodedValues[i] = cleanXSS(values[i]);
}
return encodedValues;
}
/**
* 对参数中特殊字符进行过滤
*/
@Override
public String getParameter(String name) {
String value = super.getParameter(name);
if (StrUtil.isBlank(value)) {
return value;
}
return cleanXSS(value);
}
/**
* 获取attribute,特殊字符过滤
*/
@Override
public Object getAttribute(String name) {
Object value = super.getAttribute(name);
if (value instanceof String && StrUtil.isNotBlank((String) value)) {
return cleanXSS((String) value);
}
return value;
}
/**
* 对请求头部进行特殊字符过滤
*/
@Override
public String getHeader(String name) {
String value = super.getHeader(name);
if (StrUtil.isBlank(value)) {
return value;
}
return cleanXSS(value);
}
private String cleanXSS(String value) {
return XssUtil.clean(value);
}
}
```
这里面最主要的方法就是`XssUtil.clean(value)` -> `Jsoup.clean(content, "", WHITE_LIST, OUTPUT_SETTINGS)` 这面最总要的是有个白名单列表 `WHITE_LIST` 来自,我们仔细观察白名单列表会发现这里面是部分携带html的部分标签进入,从而防止xss攻击
```java
new Whitelist().addTags(
"a", "b", "blockquote", "br", "caption", "cite", "code", "col",
"colgroup", "dd", "div", "dl", "dt", "em", "h1", "h2", "h3", "h4", "h5", "h6",
"i", "img", "li", "ol", "p", "pre", "q", "small", "span", "strike", "strong",
"sub", "sup", "table", "tbody", "td", "tfoot", "th", "thead", "tr", "u",
"ul")
.addAttributes("a", "href", "title")
.addAttributes("blockquote", "cite")
.addAttributes("col", "span", "width")
.addAttributes("colgroup", "span", "width")
.addAttributes("img", "align", "alt", "height", "src", "title", "width")
.addAttributes("ol", "start", "type")
.addAttributes("q", "cite")
.addAttributes("table", "summary", "width")
.addAttributes("td", "abbr", "axis", "colspan", "rowspan", "width")
.addAttributes(
"th", "abbr", "axis", "colspan", "rowspan", "scope",
"width")
.addAttributes("ul", "type")
.addProtocols("a", "href", "ftp", "http", "https", "mailto")
.addProtocols("blockquote", "cite", "http", "https")
.addProtocols("cite", "cite", "http", "https")
.addProtocols("img", "src", "http", "https")
.addProtocols("q", "cite", "http", "https")
```
# 上传下载
我们对文件上传进行了分别封装了多个组件:
- 单图片上传(替换图片):`src\components\pic-upload`
- 多图片上传:`src\components\mul-pic-upload`
- 文件上传:`src\components\file-upload`
上述这些文件上传,都是基于`el-upload`进行封装
## 单图片上传
在商品分类这个模块的弹框中可以找到单图片上传的例子,对应vue代码位置:`src\views\modules\category-add-or-update.vue`
html:
```html
<pic-upload v-model="dataForm.pic"></pic-upload>
```
js:
```javascript
import PicUpload from '@/components/pic-upload'
export default {
data () {
return {
dataForm: {
pic: ''
}
},
components: {
PicUpload
}
}
```
这里的文件上传使用起来非常简单,只需要将最终文件上传完成后的路径进行双向绑定即可
## 多图片上传
在商品发布这个模块的中可以找到多图片上传的例子,对应vue代码位置:`src\views\modules\category-add-or-update.vue`
html:
```html
<mul-pic-upload v-model="dataForm.imgs" />
```
js:
```javascript
import MulPicUpload from '@/components/mul-pic-upload'
export default {
data () {
return {
dataForm: {
imgs: ''
}
},
components: {
MulPicUpload
}
}
```
这里的文件上传使用起来也非常简单,最后返回的数据,为以逗号分隔的图片路径连接的字符串
## 服务端代码
直接的文件上传的例子与多图片上传的例子类似,这里便不一一举例了。
我们可以查看三个文件上传的源码,都有那么两句话`:action="$http.adornUrl('/admin/file/upload/element')"` `:headers="{Authorization: $cookie.get('Authorization')}"`,其中由于规定后台所有请求都需要通过 `spring security`的授权,所以需要携带通用请求头`headers`,而`action`则是对应后台服务器的路径
我们查看后台`FileController` 这里对文件上传的接口进行了统一的管理:
```java
@RestController
@RequestMapping("/admin/file")
public class FileController {
@Autowired
private AttachFileService attachFileService;
@PostMapping("/upload/element")
public ServerResponseEntity<String> uploadElementFile(@RequestParam("file") MultipartFile file) throws IOException{
if(file.isEmpty()){
return ServerResponseEntity.success();
}
String fileName = attachFileService.uploadFile(file.getBytes(),file.getOriginalFilename());
return ServerResponseEntity.success(fileName);
}
}
```
同时我们查看`attachFileService` 的实现类,可以知道该文件上传是通过七牛云进行实现的
```java
@Service
public class AttachFileServiceImpl extends ServiceImpl<AttachFileMapper, AttachFile> implements AttachFileService {
@Autowired
private AttachFileMapper attachFileMapper;
@Autowired
private UploadManager uploadManager;
@Autowired
private BucketManager bucketManager;
@Autowired
private Qiniu qiniu;
@Autowired
private Auth auth;
public final static String NORM_MONTH_PATTERN = "yyyy/MM/";
@Override
public String uploadFile(byte[] bytes,String originalName) throws QiniuException {
String extName = FileUtil.extName(originalName);
String fileName =DateUtil.format(new Date(), NORM_MONTH_PATTERN)+ IdUtil.simpleUUID() + "." + extName;
AttachFile attachFile = new AttachFile();
attachFile.setFilePath(fileName);
attachFile.setFileSize(bytes.length);
attachFile.setFileType(extName);
attachFile.setUploadTime(new Date());
attachFileMapper.insert(attachFile);
String upToken = auth.uploadToken(qiniu.getBucket(),fileName);
Response response = uploadManager.put(bytes, fileName, upToken);
Json.parseObject(response.bodyString(), DefaultPutRet.class);
return fileName;
}
}
```
在这里面注入了非常多的七牛云的配置,而配置文件的来源,来自
```java
@Configuration
public class FileUploadConfig {
@Autowired
private Qiniu qiniu;
/**
* 华南机房
*/
@Bean
public com.qiniu.storage.Configuration qiniuConfig() {
return new com.qiniu.storage.Configuration(Zone.zone2());
}
/**
* 构建一个七牛上传工具实例
*/
@Bean
public UploadManager uploadManager() {
return new UploadManager(qiniuConfig());
}
/**
* 认证信息实例
* @return
*/
@Bean
public Auth auth() {
return Auth.create(qiniu.getAccessKey(), qiniu.getSecretKey());
}
/**
* 构建七牛空间管理实例
*/
@Bean
public BucketManager bucketManager() {
return new BucketManager(auth(), qiniuConfig());
}
}
```
## 注册七牛云账号
现在已经9102年了,很少上传文件到本地了,一般都是上传到oss,我们这里选择[七牛云存储](https://www.qiniu.com/products/kodo) ,如果没有账号的可以注册一个,创建一个华南地区的云存储空间
![img](https://box.kancloud.cn/c72238c384fb43c2c0b3161162880056_1909x545.png)
### 修改后台配置
平台端(vue)修改文件`.env.production`(生产环境)/ `.env.development`(开发环境)
里面的`VUE_APP_BASE_API`为api接口请求地址, `VUE_APP_RESOURCES_URL`为静态资源文件url
// api接口请求地址
VUE_APP_BASE_API = 'http://127.0.0.1:8085'
// 静态资源文件url
VUE_APP_RESOURCES_URL = 'https://img.mall4j.com/'
### 更新于2023.03.27
-`shop.properties` 更新了本地上传的配置,vue中的`resourcesUrl`也配置对应的本地路径
- 全局搜索`/mall4j/img`,替换成你想要的图片路径,若按本代码默认的则前端的资源路径为`http://ip: + admin服务的端口号/mall4j/img/`
## 权限控制
#### 前端权限控制
在商城运营时,我们可能是多个人员共同操作我们的系统,但是每个操作人员所具备的权限应该不同,权限的不同主要表现在两个部分,即导航菜单的查看权限和页面增删改操作按钮的操作权限。我们的把页面导航菜单查看权限和页面操作按钮统一存储在菜单数据库表中,菜单类型页面资源的类型。类型包括目录 、菜单 、按钮。
#### 权限标识
权限标识用来进行权限控制的唯一标识,主要是进行增删改查的权限控制。
权限标识包括:新增 编辑 删除 查看等,格式结构类似**xxx:xxx:xxx** 如:**admin:user:update**
#### 导航菜单权限流程
用户登录之后,跳转至首页,前端发送请求到后台获取该用户下的所有菜单权限与认证权限数据,认证权限为约束用户增删改查操作,在路由导航守卫路由时加载用户导航菜单并存储到本地存储中。导航栏从本地存储读取菜单列表并进行渲染。
#### 页面按钮权限实现
用户登录系统之后,跳转到首页,在路由导航守卫路由时加载用户权限标识集合。返回结果是用户权限标识的集合,页面操作按钮提供权限标识,查询该权限标识是否在用户权限标识集合中,如有存在,则将按钮为可见状态,如不存在,则将按钮为不可见状态,根据需求,也可以设置成禁用状态。
#### 加载导航菜单权限与页面按钮权限数据
##### 动态路由与导航栏
`router/index.js`中,从后台加载导航菜单、页面按钮权限数据,并将数据保存到本地存储中,如下所示:
```javascript
router.beforeEach((to, from, next) => {
// 添加动态(菜单)路由
if (router.options.isAddDynamicMenuRoutes || fnCurrentRouteType(to, globalRoutes) === 'global') {
next()
} else {
http({
url: http.adornUrl('/sys/menu/nav'),
method: 'get',
params: http.adornParams()
}).then(({ data }) => {
sessionStorage.setItem('authorities', JSON.stringify(data.authorities || '[]'))
fnAddDynamicMenuRoutes(data.menuList)
router.options.isAddDynamicMenuRoutes = true
sessionStorage.setItem('menuList', JSON.stringify(data.menuList || '[]'))
next({ ...to, replace: true })
}).catch((e) => {
console.log(`%c${e} 请求菜单列表和权限失败,跳转至登录页!!`, 'color:blue')
router.push({ name: 'login' })
})
}
})
```
通过`fnAddDynamicMenuRoutes()`方法,动态加载菜单到路由中保存到本地存储`sessionStorage`中。但是现在只有路由,还需要将导航栏展示出来。在`main-sidebar.vue`中,我们将本地存储中菜单数据取出来,然后对导航栏动态渲染出来,并通过**menuId**与动态(菜单)路由进行匹配跳转至指定路由,这样,当我们点击菜单的时候,就会跳转至特定的路由。
```javascript
created () {
this.menuList = JSON.parse(sessionStorage.getItem('menuList') || '[]')
this.dynamicMenuRoutes = JSON.parse(sessionStorage.getItem('dynamicMenuRoutes') || '[]')
this.routeHandle(this.$route)
}
<sub-menu v-for="menu in menuList"
:key="menu.menuId"
:menu="menu"
:dynamicMenuRoutes="dynamicMenuRoutes">
</sub-menu>
```
`sub-menu`组件的部分代码
```html
<template>
<el-submenu
v-if="menu.list && menu.list.length >= 1"
:index="menu.menuId + ''"
:popper-class="'site-sidebar--' + sidebarLayoutSkin + '-popper'">
<template slot="title">
<icon-svg :name="menu.icon || ''" class="site-sidebar__menu-icon"></icon-svg>
<span>{{ menu.name }}</span>
</template>
<sub-menu
v-for="item in menu.list"
:key="item.menuId"
:menu="item"
:dynamicMenuRoutes="dynamicMenuRoutes">
</sub-menu>
</el-submenu>
<el-menu-item v-else :index="menu.menuId + ''" @click="gotoRouteHandle(menu)">
<icon-svg :name="menu.icon || ''" class="site-sidebar__menu-icon"></icon-svg>
<span>{{ menu.name }}</span>
</el-menu-item>
</template>
```
##### 按钮权限
在组件中根据外部方法传入的权限标识进行权限判断,如果权限存在,则显示为可见状态,否则不可见。
```html
<el-button type="primary"
icon="el-icon-plus"
size="small"
v-if="isAuth('admin:indexImg:save')"
@click.stop="addOrUpdateHandle()">新增</el-button>
```
通过`isAuth(“权限标识”)`,判断按钮是否有相同的标识,如果有则可见,否则不可见
```javascript
/**
* 是否有权限
* @param {*} key
*/
export function isAuth (key) {
let authorities = JSON.parse(sessionStorage.getItem('authorities') || '[]')
if (authorities.length) {
for (const i in authorities) {
const element = authorities[i]
if (element.authority === key) {
return true
}
}
}
return false
}
```
注:后台通过`@PreAuthorize("@pms.hasPermission('admin:user:update')")`来定义请求所需要的权限,如果用户没有该权限,后台就会抛出401未授权状态码,前端捕获到该状态码后,会登出当前的账号,让用户重新登陆。
![img](https://box.kancloud.cn/627e371fbaf45d74782a99fb888026ec_546x519.png)
#### 后台菜单管理、角色管理与管理员列表
##### 菜单管理
在【系统管理】-【菜单管理】中,我们可以通过类配置的方式,更直观的对菜单列表增删改查进行管理。
菜单类型包括目录 、菜单 、按钮。
目录为导航栏的大的分类,菜单为分类下的每一项,每个菜单需要绑定上级及填写对应跳转的路由,路由路径对应工程的目录如下图:
![img](https://box.kancloud.cn/fc23477a687599f9e01dee8f45f3b161_706x608.png)
在新增按钮权限时,注意授权标识要与后台一致,新增完之后需要重启刷新生效。
![img](https://box.kancloud.cn/6ffabbbbf2b4f641126dd946f5d79f99_699x432.png)
![img](https://box.kancloud.cn/0e22aebd4c42f3a48e2bf3a7b4e43548_770x82.png)
##### 角色管理
在【系统管理】-【角色管理】中,管理员可以新增角色,并且赋予该角色可以访问的权限项。
![img](https://box.kancloud.cn/2da280c4cb37ac290875afba404c15be_1374x959.png)
##### 管理员管理
在【系统管理】- 【管理员列表】中,拥有该权限的管理员可以对其进行管理,该管理员可添加或修改管理权限,并可分配列表中的用户角色。
![img](https://box.kancloud.cn/6e086f97d7d3adfa80cb1d25ff078c9e_677x429.png)
\ No newline at end of file
## 后台异常处理
在开发过程中,不可避免的是需要处理各种异常,异常处理方法随处可见,所以代码中就会出现大量的`try {...} catch {...} finally {...}` 代码块,不仅会造成大量的冗余代码,而且还影响代码的可读性,所以对异常统一处理非常有必要。为此,我们定义了一个统一的异常类`YamiShopBindException` 与异常管理类 `DefaultExceptionHandlerConfig`
我们先来看下 `YamiShopBindException`的代码
```java
@Getter
public class YamiShopBindException extends RuntimeException{
/**
*
*/
private static final long serialVersionUID = -4137688758944857209L;
/**
* http状态码
*/
private String code;
private Object object;
private ServerResponseEntity<?> serverResponseEntity;
public YamiShopBindException(ResponseEnum responseEnum) {
super(responseEnum.getMsg());
this.code = responseEnum.value();
}
/**
* @param responseEnum
*/
public YamiShopBindException(ResponseEnum responseEnum, String msg) {
super(msg);
this.code = responseEnum.value();
}
public YamiShopBindException(ServerResponseEntity<?> serverResponseEntity) {
this.serverResponseEntity = serverResponseEntity;
}
public YamiShopBindException(String msg) {
super(msg);
this.code = ResponseEnum.SHOW_FAIL.value();
}
public YamiShopBindException(String msg, Object object) {
super(msg);
this.code = ResponseEnum.SHOW_FAIL.value();
this.object = object;
}
}
```
`ResponseEnum`为我们自定义的返回状态码的枚举类,定义为一个枚举类,更直观处理异常返回的状态码及异常内容,以后每增加一种异常情况,只需增加一个枚举实例即可,不用每一种异常都定义一个异常类。
```java
public enum ResponseEnum {
/**
* ok
*/
OK("00000", "ok"),
SHOW_FAIL("A00001", ""),
/**
* 用于直接显示提示用户的错误,内容由输入内容决定
*/
/**
* 用于直接显示提示系统的成功,内容由输入内容决定
*/
SHOW_SUCCESS("A00002", ""),
/**
* 未授权
*/
UNAUTHORIZED("A00004", "Unauthorized"),
/**
* 服务器出了点小差
*/
EXCEPTION("A00005", "服务器出了点小差"),
/**
* 方法参数没有校验,内容由输入内容决定
*/
METHOD_ARGUMENT_NOT_VALID("A00014", "方法参数没有校验");
private final String code;
private final String msg;
public String value() {
return code;
}
public String getMsg() {
return msg;
}
ResponseEnum(String code, String msg) {
this.code = code;
this.msg = msg;
}
@Override
public String toString() {
return "ResponseEnum{" + "code='" + code + '\'' + ", msg='" + msg + '\'' + "} " + super.toString();
}
}
```
再来看看 `DefaultExceptionHandlerConfig`
```java
@Slf4j
@RestController
@RestControllerAdvice
public class DefaultExceptionHandlerConfig {
@ExceptionHandler({ MethodArgumentNotValidException.class, BindException.class })
public ResponseEntity<ServerResponseEntity<List<String>>> methodArgumentNotValidExceptionHandler(Exception e) {
log.error("methodArgumentNotValidExceptionHandler", e);
List<FieldError> fieldErrors = null;
if (e instanceof MethodArgumentNotValidException) {
fieldErrors = ((MethodArgumentNotValidException) e).getBindingResult().getFieldErrors();
}
if (e instanceof BindException) {
fieldErrors = ((BindException) e).getBindingResult().getFieldErrors();
}
if (fieldErrors == null) {
return ResponseEntity.status(HttpStatus.OK)
.body(ServerResponseEntity.fail(ResponseEnum.METHOD_ARGUMENT_NOT_VALID));
}
List<String> defaultMessages = new ArrayList<>(fieldErrors.size());
for (FieldError fieldError : fieldErrors) {
defaultMessages.add(fieldError.getField() + ":" + fieldError.getDefaultMessage());
}
return ResponseEntity.status(HttpStatus.OK)
.body(ServerResponseEntity.fail(ResponseEnum.METHOD_ARGUMENT_NOT_VALID, defaultMessages));
}
@ExceptionHandler(YamiShopBindException.class)
public ResponseEntity<ServerResponseEntity<?>> unauthorizedExceptionHandler(YamiShopBindException e){
log.error("mall4jExceptionHandler", e);
ServerResponseEntity<?> serverResponseEntity = e.getServerResponseEntity();
if (serverResponseEntity!=null) {
return ResponseEntity.status(HttpStatus.OK).body(serverResponseEntity);
}
// 失败返回消息 状态码固定为直接显示消息的状态码
return ResponseEntity.status(HttpStatus.OK).body(ServerResponseEntity.fail(e.getCode(),e.getMessage()));
}
@ExceptionHandler(Exception.class)
public ResponseEntity<ServerResponseEntity<Object>> exceptionHandler(Exception e){
log.error("exceptionHandler", e);
return ResponseEntity.status(HttpStatus.OK).body(ServerResponseEntity.fail(ResponseEnum.EXCEPTION));
}
}
```
## 前台异常处理
前端请求与相应做了封装,请求响应的内容会被拦截器所拦截,当后台返回给前台特定的状态码,前台将显示不同报错信息。请求响应非常常见,我们查看在`src\utils\httpRequest.js`里面的其中一段代码
```javascript
http.interceptors.response.use(response => {
return response
}, error => {
switch (error.response.status) {
case 400:
Message.error(error.response.data)
break
case 401:
clearLoginInfo()
router.push({ name: 'login' })
break
case 405:
Message.error('http请求方式有误')
break
case 500:
Message.error('服务器出了点小差,请稍后再试')
break
case 501:
Message.error('服务器不支持当前请求所需要的某个功能')
break
}
return Promise.reject(error)
})
```
这里将会统一拦截返回的状态码如`400`,进行错误提示。
## RESTful 风格
我们的上述代码使用http状态码对请求进行统一响应,其中最大的
RESTful架构,就是目前最流行的一种互联网软件架构。它结构清晰、符合标准、易于理解、扩展方便,所以正得到越来越多网站的采用。
[RESTful概述](https://blog.igevin.info/posts/restful-architecture-in-general/)
## 系统日志
利用`spring`框架中`aop`,我们可以实现业务代码与系统级服务进行解耦,例如日志记录、事务及其他安全业务等,可以使得我们的工程更加容易维护、优雅。如何在系统中添加相应的日志呢?
##### 添加依赖
```
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-aop</artifactId>
</dependency>
```
##### 自定义注解
```java
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
@Documented
public @interface SysLog {
String value() default "";
}
```
##### 配置切面
```java
@Aspect
@Component
public class SysLogAspect {
@Autowired
private SysLogService sysLogService;
private static Logger logger = LoggerFactory.getLogger(SysLogAspect.class);
@Around("@annotation(sysLog)")
public Object around(ProceedingJoinPoint joinPoint,com.yami.shop.common.annotation.SysLog sysLog) throws Throwable {
long beginTime = SystemClock.now();
//执行方法
Object result = joinPoint.proceed();
//执行时长(毫秒)
long time = SystemClock.now() - beginTime;
SysLog sysLogEntity = new SysLog();
if(sysLog != null){
//注解上的描述
sysLogEntity.setOperation(sysLog.value());
}
//请求的方法名
String className = joinPoint.getTarget().getClass().getName();
String methodName = joinPoint.getSignature().getName();
sysLogEntity.setMethod(className + "." + methodName + "()");
//请求的参数
Object[] args = joinPoint.getArgs();
String params = Json.toJsonString(args[0]);
sysLogEntity.setParams(params);
//设置IP地址
sysLogEntity.setIp(IPHelper.getIpAddr());
//用户名
String username = SecurityUtils.getSysUser().getUsername();
sysLogEntity.setUsername(username);
sysLogEntity.setTime(time);
sysLogEntity.setCreateDate(new Date());
//保存系统日志
sysLogService.save(sysLogEntity);
return result;
}
}
```
将自定义的注解作为切入点,参数是`ProceedingJoinPoint``sysLog``ProceedingJoinPoint`用来获取当前执行的方法,`syslog`用来获取注解里面的值。
#### 在需要记录日志的方法上,添加注解`@SysLog(value)`
```java
@SysLog("修改角色")
@PutMapping
@PreAuthorize("@pms.hasPermission('sys:role:update')")
public ServerResponseEntity<Void> update(@RequestBody SysRole role){
sysRoleService.updateRoleAndRoleMenu(role);
return ServerResponseEntity.success();
}
```
当操作这个方法时,将会被记录到数据库中,在日志管理中能看到相应操作的内容。
![img](https://box.kancloud.cn/4ff625398e31974b7de6fe9e06c2b847_1373x202.png)
我们后台使用`spring` 为我们提供好的统一校验的工具`spring-boot-starter-validation`对请求进行校验。
```xml
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-validation</artifactId>
</dependency>
```
这里通过注解封装了几种常用的校验
- `@NotNull` 不能为null
- `@NotEmpty` 不能为null、空字符串、空集合
- `@NotBlank` 不能为null、空字符串、纯空格的字符串
- `@Min` 数字最小值不能小于x
- `@Max` 数字最大值不能大于x
- `@Email` 字符串为邮件格式
- `@Max` 数字最大值不能大于x
- `@Size` 字符串长度最小为x、集合长度最小为x
- `@Pattern` 正则表达式
我们以`SysUser`为例,看看怎么使用
```java
public class SysUser implements Serializable {
private static final long serialVersionUID = 1L;
/**
* 用户ID
*
*/
@TableId
private Long userId;
/**
* 用户名
*/
@NotBlank(message="用户名不能为空")
@Size(min = 2,max = 20,message = "用户名长度要在2-20之间")
private String username;
/**
* 密码
*/
@JsonProperty(access = JsonProperty.Access.WRITE_ONLY)
private String password;
/**
* 邮箱
*/
@NotBlank(message="邮箱不能为空")
@Email(message="邮箱格式不正确")
private String email;
/**
* 手机号
*/
@Pattern(regexp="0?1[0-9]{10}",message = "请输入正确的手机号")
private String mobile;
/**
* 状态 0:禁用 1:正常
*/
private Integer status;
/**
* 用户所在店铺id
*/
private Long shopId;
/**
* 角色ID列表
*/
@TableField(exist=false)
private List<Long> roleIdList;
/**
* 创建时间
*/
private Date createTime;
}
```
我们在Controller层使用该bean,并使用`@Valid`注解,使校验的注解生效,如`SysUserController`
```java
@RestController
@RequestMapping("/sys/user")
public class SysUserController {
/**
* 保存用户
*/
@SysLog("保存用户")
@PostMapping
@PreAuthorize("@pms.hasPermission('sys:user:save')")
public ServerResponseEntity<String> save(@Valid @RequestBody SysUser user){
String username = user.getUsername();
SysUser dbUser = sysUserService.getOne(new LambdaQueryWrapper<SysUser>()
.eq(SysUser::getUsername, username));
if (dbUser!=null) {
return ServerResponseEntity.showFailMsg("该用户已存在");
}
user.setShopId(SecurityUtils.getSysUser().getShopId());
user.setPassword(passwordEncoder.encode(user.getPassword()));
sysUserService.saveUserAndUserRole(user);
return ServerResponseEntity.success();
}
}
```
并且在`DefaultExceptionHandlerConfig` 拦截由`@Valid` 触发的异常信息并返回:
```java
@RestController
@RestControllerAdvice
public class DefaultExceptionHandlerConfig {
@ExceptionHandler(BindException.class)
public ServerResponseEntity<String> bindExceptionHandler(BindException e){
e.printStackTrace();
return ResponseEntity.status(HttpStatus.BAD_REQUEST).body(e.getBindingResult().getFieldErrors().get(0).getDefaultMessage());
}
@ExceptionHandler(MethodArgumentNotValidException.class)
public ServerResponseEntity<String> methodArgumentNotValidExceptionHandler(MethodArgumentNotValidException e){
e.printStackTrace();
return ResponseEntity.status(HttpStatus.BAD_REQUEST).body(e.getBindingResult().getFieldErrors().get(0).getDefaultMessage());
}
}
```
## 通用分页表格实现
前端基于VUE的轻量级表格插件 `avue`
后端分页组件使用Mybatis分页插件 `MybatisPlus`
> 分页实现流程,以【系统管理-管理员列表】为例
后台vue文件位置目录 `\src\views\modules\sys\user.vue`
1、`avue`组件的几个通用配置
```html
<avue-crud ref="crud"
:page="page"
:data="dataList"
:option="tableOption"
@search-change="searchChange"
@selection-change="selectionChange"
@on-load="getDataList">
</avue-crud>
```
`avue`定义了很多的事件,其中一个为 `@on-load`当该组件加载的时候,将会调用该方法。同时也对很多数据进行了双向绑定如:`:page="page"` 分页参数、`:data="dataList"` 分页的具体列表数据、`:option="tableOption"` 表格显示的列
2、通用的列表、搜索
`avue`规定,表格的构建,是通过JS对象,进行配置的,而不是通过dom,类似于传统的layui,还有一个主要的原因是这个表格,可以同时生成搜索、分页。
```javascript
import { tableOption } from '@/crud/sys/user'
```
我们查看下该类的代码:
```javascript
export const tableOption = {
border: true,
selection: true,
index: false,
indexLabel: '序号',
stripe: true,
menuAlign: 'center',
menuWidth: 350,
align: 'center',
refreshBtn: true,
searchSize: 'mini',
addBtn: false,
editBtn: false,
delBtn: false,
viewBtn: false,
props: {
label: 'label',
value: 'value'
},
column: [{
label: '用户名',
prop: 'username',
search: true
}, {
label: '邮箱',
prop: 'email'
}, {
label: '手机号',
prop: 'mobile'
}, {
label: '创建时间',
prop: 'createTime'
}, {
label: '状态',
prop: 'status',
type: 'select',
dicData: [
{
label: '禁用',
value: 0
}, {
label: '正常',
value: 1
}
]
}]
}
```
这里的 `search: true` 也就是搜索框出现用户名搜索
```javascript
{
label: '用户名',
prop: 'username',
search: true
}
```
具体可以通过[avue官网-crud文档](https://avuejs.com/doc/crud/crud-doc)获取文档进行查询
3、 通用的搜索和加载
```javascript
getDataList (page, params) {
this.dataListLoading = true
this.$http({
url: this.$http.adornUrl('/sys/user/page'),
method: 'get',
params: this.$http.adornParams(
Object.assign(
{
current: page == null ? this.page.currentPage : page.currentPage,
size: page == null ? this.page.pageSize : page.pageSize
},
params
)
)
}).then(({ data }) => {
this.dataList = data.records
this.page.total = data.total
this.dataListLoading = false
})
}
```
4、服务端`SysUserController`
```java
@RestController
@RequestMapping("/sys/user")
public class SysUserController {
@Autowired
private SysUserService sysUserService;
/**
* 所有用户列表
*/
@GetMapping("/page")
@PreAuthorize("@pms.hasPermission('sys:user:page')")
public ServerResponseEntity<IPage<SysUser>> page(String username,PageParam<SysUser> page){
IPage<SysUser> sysUserPage = sysUserService.page(page, new LambdaQueryWrapper<SysUser>()
.eq(SysUser::getShopId, SecurityUtils.getSysUser().getShopId())
.like(StrUtil.isNotBlank(username), SysUser::getUsername, username));
return ServerResponseEntity.success(sysUserPage);
}
}
```
# 目录结构
~~~
yami-shops
├── mall4m -- 小程序代码
├── mall4v -- 后台vue代码
├── yami-shop-admin -- 后台(vue)接口工程[8085]
├── yami-shop-api -- 前端(小程序)接口工程[8086]
├── yami-shop-bean -- 所有公共的实体类,商城基本流程所需的实体类
├── yami-shop-common -- 前后台需要用到的公共配置,工具类等的集合地
├── yami-shop-security -- oauth2.0 授权认证模块
├── yami-shop-service -- 前后台需要用到的公共的、商城基本流程所需的service,dao的集合地
├── yami-shop-sys -- 后台用户角色权限管理模块
~~~
这里整理了一些经常会被问到的问题:
1. 为什么vue打包之后,或者修改url之后,无法登录?
答:你用chrome按f12看看console提示的信息如:`Access-Control-Allow-Origin` 那就是跨域了,再看看network的请求方法是不是`options`,但是返回不是200,这也是跨域了。
2. 跨域了怎么办?
跨域产生的原因是因为浏览器的同源策略,也就是说这个是浏览器的问题,你用`postman`去请求,都是没有问题,返回200的,浏览器才会出现这种奇怪的问题。要解决这个问题,就要清楚同源策略是啥,也就是浏览器认为:域名、协议、端口相同才是相同的源,也就是要想办法让前端的域名、协议、端口和接口的相同。而实际上前端和服务器怎么可以在一个端口呢?那就需要一些转发的工具,将同一个端口,不同路径的请求,转发到不同的端口,具体操作可以看 【生产环境nginx安装与跨域配置】
建议阅读前,先阅读《商城表设计-购物车》相关文档
我们的购物车只有一个表:`tz_basket` 非常简单,但是关联了非常多的表。比如:
- 购物车有商品,关联商品表
- 每个商品都有sku,关联sku表
- 一个购物车有多个店铺的商品,关联店铺表
- 一个购物车肯定是和用户有关的,关联用户表
我们对商品进行添加,修改,其实都很简单,最为让人难以理解的是如何将这些字段进行组合,关联满减满折等一系列的活动。
我们先来看下是如何获取商品信息的
```java
@PostMapping("/info")
@Operation(summary = "获取用户购物车信息" , description = "获取用户购物车信息,参数为用户选中的活动项数组,以购物车id为key")
public ServerResponseEntity<List<ShopCartDto>> info(@RequestBody Map<Long, ShopCartParam> basketIdShopCartParamMap) {
String userId = SecurityUtils.getUser().getUserId();
// 更新购物车信息,
if (MapUtil.isNotEmpty(basketIdShopCartParamMap)) {
basketService.updateBasketByShopCartParam(userId, basketIdShopCartParamMap);
}
// 拿到购物车的所有item
List<ShopCartItemDto> shopCartItems = basketService.getShopCartItems(userId);
return ServerResponseEntity.success(basketService.getShopCarts(shopCartItems));
}
```
这里面传了一个参数:`Map<Long, ShopCartParam> basketIdShopCartParamMap` 这里是当用户改变了某件商品的满减满折活动时,重新改变满减满折信息以后计算加个的一个方法。当然在开源是没有这个满减模块的,只有思路,具体实现需要靠自己了。
我们继续往下看,这里面`basketService.getShopCartItems(userId)`使用的直接是从数据库中获取的数据,而真正对满减满折、店铺等进行排列组合的,在于`basketService.getShopCarts(shopCartItems)` 这个方法。
我们进到`getShopCarts`方法内部,可以查看到一行代码`applicationContext.publishEvent(new ShopCartEvent(shopCart, shopCartItemDtoList));`,这里使用的事件的模式。这个事件的主要作用是用于对模块之间的解耦,比如我们清楚的知道当购物车需要计算价格的时候,需要满减模块的配合,进行“装饰”。最后将装饰回来的东西,返回给前端。
我们现在看看购物车返回的数据`ServerResponseEntity<List<ShopCartDto>>`,我们清楚一个购物车是分多个店铺的,每一个店铺就是一个`ShopCartDto`,我们看下这个`bean`
```java
@Data
public class ShopCartDto implements Serializable {
@Schema(description = "店铺ID" , required = true)
private Long shopId;
@Schema(description = "店铺名称" , required = true)
private String shopName;
@Schema(description = "购物车满减活动携带的商品" , required = true)
private List<ShopCartItemDiscountDto> shopCartItemDiscounts;
}
```
其实一个店铺下面是有多个商品的,但是根据京东的划分,每当有满减之类的活动时,满减活动的商品总是要归到一类的,所以,每个店铺下面是多个满减活动(`List<ShopCartItemDiscountDto>`),满减活动下面是多个商品(购物项`List<ShopCartItemDto>`),到此你就能明白了`ShopCartItemDiscountDto` 里面的`ChooseDiscountItemDto` 是什么东西了,这个是选中的满减项。
```java
public class ShopCartItemDiscountDto implements Serializable {
@Schema(description = "已选满减项" , required = true)
private ChooseDiscountItemDto chooseDiscountItemDto;
@Schema(description = "商品列表" )
private List<ShopCartItemDto> shopCartItems;
}
```
我们再留意`ShopCartItemDto` 这个`bean` ,发现还有这个东西:
```java
@Schema(description = "参与满减活动列表" )
private List<DiscountDto> discounts = new ArrayList<>();
```
其实购物车的每个购物项,都是有很多个满减的活动的,可以自主选择满减活动,然后进行组合,生成新的优惠。而在这选择新的活动类型时,就需要购物车就行新的价格计算。这也就是为什么获取用户购物车信息,也就是`/info`接口需要一个这个参数的原因了`Map<Long, ShopCartParam> basketIdShopCartParamMap`
下单简单的分成几个步骤:
1. 用户点击“立即购买”或“购物车-结算”进入到“确认订单”页面
2. 在“确认订单”页面选择收货地址,优惠券等,重新计算运费、订单价格
3. 提交订单,选择支付方式进行支付
4. 支付完毕
## 第一步:
1. 用户点击“立即购买”或“购物车-结算”进入到“确认订单”页面,相关url`/p/order/confirm`
我们希望能够有个统一下单的接口,不太希望“立即购买”和“购物车-结算”两个不同的接口影响到后面所有的流程,毕竟谁也不想一个差不多一样的接口,要写两遍,所以我们看下我们的系统是如何做的。
```java
public class OrderParam {
@Schema(description = "购物车id 数组" )
private List<Long> basketIds;
@Schema(description = "立即购买时提交的商品项" )
private OrderItemParam orderItem;
}
```
这里使用了两种情况:
- 假设`basketIds` 不为空,则说明是从购物车进入
- 假设`orderItem` 不为空,则说明是从立即购买进入
通过`basketService.getShopCartItemsByOrderItems(orderParam.getBasketIds(),orderParam.getOrderItem(),userId)` 这个方法对两种情况进行组合,此时并不能将购物车商品删除,因为删除购物车中的商品,是在第三步提交订单的时候进行的,不然用户点击返回键,看到购物车里面的东西还没提交订单,东西就消失了,会感觉很奇怪。
我们重新回到`controller`层,我们看到了一行熟悉的代码`basketService.getShopCarts`
```java
@PostMapping("/confirm")
@Operation(summary = "结算,生成订单信息" , description = "传入下单所需要的参数进行下单")
public ServerResponseEntity<ShopCartOrderMergerDto> confirm(@Valid @RequestBody OrderParam orderParam) {
// 根据店铺组装购车中的商品信息,返回每个店铺中的购物车商品信息
List<ShopCartDto> shopCarts = basketService.getShopCarts(shopCartItems);
}
```
这行代码我们再《购物车的设计》这篇已经着重讲过了,但是我们在这为什么还需要这个东西呢?
很简单,无论是点击“立即购买”或“购物车-结算”,事实上都是通过用户计算过一遍金额了,而且甚至有满减满折之类的活动,都是通过了统一的计算的。而这一套计算的流程,我们并不希望重新写一遍。所以当然是能够使用之前计算的金额,那是最好的咯。
## 第二步:
2. 在“确认订单”页面选择收货地址,优惠券等,重新计算运费、订单价格
我们知道无论是在第一步还是第二步,本质上还是在确认订单的页面,其中订单页面的数据结构并没有发生任何的变化,所以其实第一步第二步是可以写在一起的。所以我们可以看到`OrderParam` 还多了两个参数
```java
public class OrderParam {
@Schema(description = "地址ID,0为默认地址" ,required=true)
@NotNull(message = "地址不能为空")
private Long addrId;
@Schema(description = "用户是否改变了优惠券的选择,如果用户改变了优惠券的选择,则完全根据传入参数进行优惠券的选择" )
private Integer userChangeCoupon;
@Schema(description = "优惠券id数组" )
private List<Long> couponIds;
}
```
但是有个问题,就是在于用户点击立即购买的时候,没有地址,那样如何计算运费呢?答案就是使用默认地址进行计算呀~
我们看下计算订单的事件,事实上有很多营销活动的时候,订单的计算也是非常的复杂的,所以我们和购物车一样,采用事件的驱动,一个接一个的对订单进行“装饰”,最后生成`ShopCartOrderMergerDto`一个合并的对象
```java
@PostMapping("/confirm")
@Operation(summary = "结算,生成订单信息" , description = "传入下单所需要的参数进行下单")
public ServerResponseEntity<ShopCartOrderMergerDto> confirm(@Valid @RequestBody OrderParam orderParam) {
for (ShopCartDto shopCart : shopCarts) {
applicationContext.publishEvent(new ConfirmOrderEvent(shopCartOrder,orderParam,shopAllShopCartItems));
}
}
```
我们看下`ConfirmOrderListener` 这个事件里面的默认监听器,这里
```java
public class ConfirmOrderListener {
@EventListener(ConfirmOrderEvent.class)
@Order(ConfirmOrderOrder.DEFAULT)
public void defaultConfirmOrderEvent(ConfirmOrderEvent event) {
ShopCartOrderDto shopCartOrderDto = event.getShopCartOrderDto();
OrderParam orderParam = event.getOrderParam();
String userId = SecurityUtils.getUser().getUserId();
// 订单的地址信息
UserAddr userAddr = userAddrService.getUserAddrByUserId(orderParam.getAddrId(), userId);
double total = 0.0;
int totalCount = 0;
double transfee = 0.0;
for (ShopCartItemDto shopCartItem : event.getShopCartItems()) {
// 获取商品信息
Product product = productService.getProductByProdId(shopCartItem.getProdId());
// 获取sku信息
Sku sku = skuService.getSkuBySkuId(shopCartItem.getSkuId());
if (product == null || sku == null) {
throw new YamiShopBindException("购物车包含无法识别的商品");
}
if (product.getStatus() != 1 || sku.getStatus() != 1) {
throw new YamiShopBindException("商品[" + sku.getProdName() + "]已下架");
}
totalCount = shopCartItem.getProdCount() + totalCount;
total = Arith.add(shopCartItem.getProductTotalAmount(), total);
// 用户地址如果为空,则表示该用户从未设置过任何地址相关信息
if (userAddr != null) {
// 每个产品的运费相加
transfee = Arith.add(transfee, transportManagerService.calculateTransfee(shopCartItem, userAddr));
}
shopCartItem.setActualTotal(shopCartItem.getProductTotalAmount());
shopCartOrderDto.setActualTotal(Arith.sub(total, transfee));
shopCartOrderDto.setTotal(total);
shopCartOrderDto.setTotalCount(totalCount);
shopCartOrderDto.setTransfee(transfee);
}
}
}
```
值得留意的是,有那么一行代码
```java
// 用户地址如果为空,则表示该用户从未设置过任何地址相关信息
if (userAddr != null) {
// 每个产品的运费相加
transfee = Arith.add(transfee, transportManagerService.calculateTransfee(shopCartItem, userAddr));
}
```
运费是根据用户地址进行计算,当然还包括运费模板啦,想了解运费模板的,可以参考运费模板相关的章节。
那么有人就问了,那么优惠券呢?优惠券是有另一个监听器进行监听计算价格啦,购买了专业版或以上的版本就能看到源码咯~
我们看看返回给前端的订单信息:
```java
@Data
public class ShopCartOrderMergerDto implements Serializable{
@Schema(description = "实际总值" , required = true)
private Double actualTotal;
@Schema(description = "商品总值" , required = true)
private Double total;
@Schema(description = "商品总数" , required = true)
private Integer totalCount;
@Schema(description = "订单优惠金额(所有店铺优惠金额相加)" , required = true)
private Double orderReduce;
@Schema(description = "地址Dto" , required = true)
private UserAddrDto userAddr;
@Schema(description = "每个店铺的购物车信息" , required = true)
private List<ShopCartOrderDto> shopCartOrders;
@Schema(description = "整个订单可以使用的优惠券列表" , required = true)
private List<CouponOrderDto> coupons;
}
```
这里又有一段我们熟悉的代码:
```java
@Schema(description = "每个店铺的购物车信息" , required = true)
private List<ShopCartOrderDto> shopCartOrders;
```
没错这里返回的数据格式,和购物车的格式是一样的,因为第一步当中已经说明,订单来自于购物车的计算,所以会在基础上条件新的数据,基本上就是返回给前端的数据了。
> 首先我们在这里严重的批评一些,在接口订单的接口中,直接传订单金额,而不是使用下单是已经计算好金额的人,这些接口岂不是使用0.01就能将全部的商品都买下来了?
我们回到订单设计这一个模块,首先我们在确认订单的时候就已经将价格计算完成了,那么我们肯定是想将计算结果给保留下来的,至于计算的过程,我们并不希望这个过程还要进行一遍的计算。
我们返回确认订单的接口,看到这样一行代码:
```java
@Operation(summary = "结算,生成订单信息" , description = "传入下单所需要的参数进行下单")
public ServerResponseEntity<ShopCartOrderMergerDto> confirm(@Valid @RequestBody OrderParam orderParam) {
orderService.putConfirmOrderCache(userId,shopCartOrderMergerDto);
}
```
这里每经过一次计算,就将整个订单通过`userId`进行了保存,而这个缓存的时间为30分钟,当用户使用
```java
@PostMapping("/submit")
@Operation(summary = "提交订单,返回支付流水号" , description = "根据传入的参数判断是否为购物车提交订单,同时对购物车进行删除,用户开始进行支付")
public ServerResponseEntity<OrderNumbersDto> submitOrders(@Valid @RequestBody SubmitOrderParam submitOrderParam) {
ShopCartOrderMergerDto mergerOrder = orderService.getConfirmOrderCache(userId);
if (mergerOrder == null) {
throw new YamiShopBindException("订单已过期,请重新下单");
}
// 省略中间一大段。。。
orderService.removeConfirmOrderCache(userId);
}
```
当无法获取缓存的时候告知用户订单过期,当订单进行提交完毕的时候,将之前的缓存给清除。
我们又回到提交订单中间这几行代码:
```java
List<Order> orders = orderService.submit(userId,mergerOrder);
```
这行代码也就是提交订单的核心代码
```java
eventPublisher.publishEvent(new SubmitOrderEvent(mergerOrder, orderList));
```
其中这里依旧是使用时间的方式,将订单进行提交,看下这个`SubmitOrderEvent`的默认监听事件。
```java
@Component("defaultSubmitOrderListener")
@AllArgsConstructor
public class SubmitOrderListener {
public void defaultSubmitOrderListener(SubmitOrderEvent event) {
// ...
}
}
```
这里有几段值得注意的地方:
- 这里是`UserAddrOrder` 并不是`UserAddr`
```java
// 把订单地址保存到数据库
UserAddrOrder userAddrOrder = BeanUtil.copyProperties(mergerOrder.getUserAddr(), UserAddrOrder.class);
if (userAddrOrder == null) {
throw new YamiShopBindException("请填写收货地址");
}
userAddrOrder.setUserId(userId);
userAddrOrder.setCreateTime(now);
userAddrOrderService.save(userAddrOrder);
```
这里是将订单的收货地址进行了保存入库的操作,这里是绝对不能只保存用户的地址id在订单中的,要将地址入库,原因是如果用户在订单中设置了一个地址,如果用户在订单还没配送的时候,将自己的地址改了的话。如果仅采用关联的地址,就会出现问题。
- 为每个店铺生成一个订单
```java
// 每个店铺生成一个订单
for (ShopCartOrderDto shopCartOrderDto : shopCartOrders) {
}
```
这里为每个店铺创建一个订单,是为了,以后平台结算给商家时,每个商家的订单单独结算。用户确认收货时,也可以为每家店铺单独确认收货。
- 使用雪花算法生成订单id, 如果对雪花算法感兴趣的,可以去搜索下相关内容:
```java
String orderNumber = String.valueOf(snowflake.nextId());
```
我们不想单多台服务器生成的id冲突,也不想生成uuid这样的很奇怪的字符串id,更不想直接使用数据库主键这种东西时,雪花算法就出现咯。
- 当用户提交订单的时候,购物车里面勾选的商品,理所当然的要清空掉
```java
// 删除购物车的商品信息
if (!basketIds.isEmpty()) {
basketMapper.deleteShopCartItemsByBasketIds(userId, basketIds);
}
```
- 使用数据库的乐观锁,防止超卖:
```java
if (skuMapper.updateStocks(sku) == 0) {
skuService.removeSkuCacheBySkuId(key, sku.getProdId());
throw new YamiShopBindException("商品:[" + sku.getProdName() + "]库存不足");
}
```
```sql
update tz_sku set stocks = stocks - #{sku.stocks}, version = version + 1,update_time = NOW() where sku_id = #{sku.skuId} and #{sku.stocks} &lt;= stocks
```
超卖一直是一件非常令人头疼的事情,如果对订单直接加悲观锁的话,那么下单的性能将会很差。商城最重要的就是下单啦,要是性能很差,那人家还下个鬼的单哟,所以我们采用数据库的乐观锁进行下单。
所谓乐观锁,就是在 where 条件下加上极限的条件,比如在这里就是更新的库存小于或等于商品的库存,在这种情况下可以对库存更新成功,则更新完成了,否则抛异常(真正的定义肯定不是这样的啦,你可以百度下 “乐观锁更新库存”)。注意这里在抛异常以前,应该将缓存也更新了,不然无法及时更新。
最后我们回到`controller`
```java
return ServerResponseEntity.success(new OrderNumbersDto(orderNumbers.toString()));
```
这里面返回了多个订单项,这里就变成了并单支付咯,在多个店铺一起进行支付的时候需要进行并单支付的操作,一个店铺的时候,又要变成一个订单支付的操作,可是我们只希望有一个统一支付的接口进行调用,所以我们的支付接口要进行一点点的设计咯。
Supports Markdown
0% or .
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment