商城业务与BUG记录
三层菜单
后端核心步骤
三层菜单数据
@Override
public List<CategoryEntity> listWithTree() {
//1.查出所有分类
List<CategoryEntity> entities = baseMapper.selectList(null);
//2.组装成三级结构
//2.1 找到所有一级分类
List<CategoryEntity> levelOne = entities.stream()
.filter(categoryEntity -> categoryEntity.getParentCid() == 0)
.peek((menu) -> menu.setChildren(getChildrens(menu, entities)))
.sorted(Comparator.comparingInt(o -> (o.getSort() == null ? 0 : o.getSort())))
.collect(Collectors.toList());
return levelOne;
}
/**
* 递归查找参数一的子菜单
*
* @param root
* @param total
* @return
*/
private List<CategoryEntity> getChildrens(CategoryEntity root, List<CategoryEntity> total) {
List<CategoryEntity> children = total.stream()
.filter(categoryEntity -> categoryEntity.getParentCid() == root.getCatId())
.peek(categoryEntity -> categoryEntity.setChildren(getChildrens(categoryEntity, total)))
.sorted(Comparator.comparingInt(o -> (o.getSort() == null ? 0 : o.getSort())))
.collect(Collectors.toList());
return children;
}
网关改造
- 路径重写

- 配置网关跨域

package com.example.gateway.config;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.cors.CorsConfiguration;
import org.springframework.web.cors.reactive.UrlBasedCorsConfigurationSource;
import org.springframework.web.cors.reactive.CorsWebFilter;
@Configuration
public class CorsConfig {
@Bean
public CorsWebFilter corsWebFilter(){
UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
CorsConfiguration corsConfiguration = new CorsConfiguration();
corsConfiguration.addAllowedHeader("*"); // 任意请求头
corsConfiguration.addAllowedMethod("*"); // 任意请求方式
corsConfiguration.addAllowedOrigin("*"); // 任意请求来源
corsConfiguration.setAllowCredentials(true); // 可以携带 cookie
// 对所有请求执行以上配置
source.registerCorsConfiguration("/**",corsConfiguration);
return new CorsWebFilter(source);
}
}
vue路径改造
static/config/index.js 修改项目路径前缀使其访问网关
状态码规范 & 统一异常处理


@Slf4j
@RestControllerAdvice(basePackages = "org.june.product.controller")
public class MallExceptionControllerAdvice {
@ExceptionHandler(value = MethodArgumentNotValidException.class)
public R handleValidException(MethodArgumentNotValidException e) {
// log.error("数据校验出现问题:{},异常类型:{},异常原因:{}", e.getMessage(), e.getClass(), e.getCause());
BindingResult bindingResult = e.getBindingResult();
Map<String, String> errorMap = new HashMap<>();
bindingResult.getFieldErrors().forEach(result -> {
errorMap.put(result.getField(), result.getDefaultMessage());
});
return R.error(StatusCode.VALID_EXCEPTION.getCode(), StatusCode.VALID_EXCEPTION.getMsg()).put("data", errorMap);
}
@ExceptionHandler(value = Throwable.class)
public R handleException(Throwable e) {
return R.error(StatusCode.UNKNOW_EXCEPTION.getCode(),StatusCode.UNKNOW_EXCEPTION.getMsg());
}
}
SPU&SKU&基本属性与销售属性
SPU: Standard Product Unit(标准化产品单元)
SPU是商品信息聚合的最小单位,是一组可复用、易检索的标准化信息的集合,该集合描述了一个产品的特性;通俗点讲,属性值、特性相同的商品就可以称为一个SPU。
SKU: Stock Keeping Unit(库存量单位)
SKU即库存进出计量的单位, 可以是以件、盒、托盘等为单位。
SKU是物理上不可分割的最小存货单元。在使用时要根据不同业态,不同管理模式来处理。在服装、鞋类商品中使用最多最普遍。
基本属性(规格参数)与销售属性:每个分类下的商品共享规格参数与销售属性,只是有些商品不一定要用这个分类下的全部属性;


属性分组

父子组件消息传递



商品上架
依托于elasticsearch实现
es 数据分析

前者空间耗费大,但属性值完全,利于条件查询
java 实体类
@Data
public class SkuEsModel {
private Long skuId;
private Long spuId;
private String skuTitle;
private BigDecimal skuPrice;
private String skuImg;
private Boolean hasStock;
private Long hotScore;
private Long catalogId;
private String brandName;
private String brandImg;
private String catalogName;
private List<Attrs> attrs;
@Data
public static class Attrs{
private Long attrId;
private String attrName;
private String attrValue;
}
}
首页显示
引入 thymeleaf
- 项目结构
- 命名空间
<html lang="en" xmlns:th="http://www.thymeleaf.org">
- 配置静态资源位置(如可用则不用配置)
- 页面热刷新
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-devtools</artifactId>
<optional>true</optional>
</dependency>

- thymeleaf 语法引入
thymeleaf 语法简介
- 简单遍历
- 值替换
三级分类显示
查询思路
首先查出所有分类,再从中找出一级分类,对其进行遍历,根据其id找出所有对应的二级分类,再根据二级分类id查出所有三级分类,封装在 vo 中

nginx 代理

linux 域名映射文件在 /etc/hosts
.png)
反向代理
# conf/nginx.conf
user nginx;
worker_processes 1;
error_log /var/log/nginx/error.log warn;
pid /var/run/nginx.pid;
events {
worker_connections 1024;
}
http {
include /etc/nginx/mime.types;
default_type application/octet-stream;
log_format main '$remote_addr - $remote_user [$time_local] "$request" '
'$status $body_bytes_sent "$http_referer" '
'"$http_user_agent" "$http_x_forwarded_for"';
access_log /var/log/nginx/access.log main;
sendfile on;
keepalive_timeout 65;
#### 仅修改这一出配置 ####
upstream mall {
#写成外网ip!
server 124.222.22.217:888;
}
error_page 500 502 503 504 /50x.html;
location = /50x.html {
root /usr/share/nginx/html;
}
}
include /etc/nginx/conf.d/*.conf;
}
# conf.d/default.conf
server {
listen 80;
listen [::]:80;
server_name projectdemo.top;
location / {
proxy_set_header Host $host;
proxy_pass http://mall;
}
error_page 500 502 503 504 /50x.html;
#location = /50x.html {
# root /usr/share/nginx/html;
#}
}

location /static/ {
root /user/share/nginx/html;
}
搜索页
ES版本选择
- 2022.3.1日
high-level-client已经不再维护,新版本是elasticsearch-java-api-client elasticsearch-java:7.16.3版本有bug,在Nested Aggregation查询时无法返回buckets
- 通过抓包查看,发送的数据正确,接收的数据也正确,但似乎这个版本的api无法解析结果
- 最终解决方案是升级到
8.0.0,如下图所示,出现了聚合结果,点下去会出现目标buckets
- 另外要说的一点是,这个官方给的依赖非常烂,以下是不报错的依赖引入
<dependency>
<groupId>co.elastic.clients</groupId>
<artifactId>elasticsearch-java</artifactId>
<version>8.0.0</version>
</dependency>
<dependency>
<groupId>com.fasterxml.jackson.core</groupId>
<artifactId>jackson-databind</artifactId>
<version>2.12.3</version>
</dependency>
<dependency>
<groupId>com.fasterxml.jackson.core</groupId>
<artifactId>jackson-core</artifactId>
<version>2.13.0</version>
</dependency>
<dependency>
<groupId>com.fasterxml.jackson.core</groupId>
<artifactId>jackson-annotations</artifactId>
<version>2.13.0</version>
</dependency>
<dependency>
<groupId>jakarta.json</groupId>
<artifactId>jakarta.json-api</artifactId>
<version>2.0.1</version>
</dependency>
ES-Mapping
PUT product
{
"mappings": {
"properties": {
"skuId": { "type": "long" },
"spuId": { "type": "keyword" },
"skuTitle": {
"type": "text",
"analyzer": "ik_smart"
},
"skuPrice": { "type": "double" },
"skuImg": {
"type": "keyword"
},
"saleCount":{ "type":"long" },
"hasStock": { "type": "boolean" },
"hotScore": { "type": "long" },
"brandId": { "type": "long" },
"catalogId": { "type": "long" },
"brandName": {
"type": "keyword"
},
"brandImg":{
"type": "keyword"
},
"catalogName": {
"type": "keyword"
},
"attrs": {
"type": "nested",
"properties": {
"attrId": {"type": "long" },
"attrName": {
"type": "keyword"
},
"attrValue": { "type": "keyword" }
}
}
}
}
}
ES-DSL
{
"query": {
"bool": {
"must": [
{
"match": {
"skuTitle": "Apple"
}
}
],
"filter": [
{
"term": {
"catalogId": "225"
}
},
{
"terms": {
"brandId": [
"2",
"3",
"9"
]
}
},
{
"nested": {
"path": "attrs",
"query": {
"bool": {
"must": [
{
"term": {
"attrs.attrId": {
"value": "11"
}
}
},
{
"terms": {
"attrs.attrValue": [
"1月",
"7月"
]
}
}
]
}
}
}
},
{
"term": {
"hasStock": false
}
},
{
"range": {
"skuPrice": {
"gte": 0,
"lte": 6000
}
}
}
]
}
},
"aggs": {
"brand_agg": {
"terms": {
"field": "brandId",
"size": 1
},
"aggs": {
"brand_img_agg": {
"terms": {
"field": "brandImg",
"size": 10
}
}
}
},
"catalog_agg": {
"terms": {
"field": "catalogId",
"size": 10
}
},
"attr_agg": {
"nested": {
"path": "attrs"
},
"aggs": {
"attr_id_agg": {
"terms": {
"field": "attrs.attrId",
"size": 10
},
"aggs": {
"attr_name_agg": {
"terms": {
"field": "attrs.attrName",
"size": 10
}
},
"attr_value_agg": {
"terms": {
"field": "attrs.attrValue",
"size": 10
}
}
}
}
}
}
},
"sort": [
{
"skuPrice": {
"order": "desc"
}
}
],
"from": 17,
"size": 5,
"highlight": {
"fields": {
"skuTitle": {}
},
"pre_tags": "<b style='color:red'>",
"post_tags": "</b>"
}
}
面包屑导航

商品详情
自定义线程池

@ConfigurationProperties(prefix = "mall.thread")
@Component
@Data
public class ThreadPoolConfigProperties {
private Integer coreSize;
private Integer maxSize;
private Integer keepAliveTime;
}
@Configuration
public class MyThreadConfig {
@Bean
public ThreadPoolExecutor threadPoolExecutor(MyThreadPoolConfigProperties pool) {
return new ThreadPoolExecutor(pool.getCoreSize(),
pool.getMaxSize(),
pool.getKeepAliveTime(),
TimeUnit.SECONDS,
new LinkedBlockingDeque<>(100000),
Executors.defaultThreadFactory(),
new ThreadPoolExecutor.AbortPolicy());
}
}
异步编排商品查询
public SkuItemVo item(Long skuId) {
SkuItemVo skuItemVo = new SkuItemVo();
CompletableFuture<SkuInfoEntity> infoFuture = CompletableFuture.supplyAsync(() -> {
// 1.sku基本信息获取 pms_sku_info
SkuInfoEntity info = getById(skuId);
skuItemVo.setInfo(info);
return info;
}, executor);
CompletableFuture<Void> saleAttrFuture = infoFuture.thenAcceptAsync(info -> {
// 2.获取 spu 销售属性组合
skuItemVo.setSaleAttrs(skuSaleAttrValueService.getSaleAttrsBySpuId(info.getSpuId()));
}, executor);
CompletableFuture<Void> spuDescFuture = infoFuture.thenAcceptAsync(info -> {
// 2.获取spu介绍
Long spuId = info.getSpuId();
skuItemVo.setDesp(spuInfoDescService.getById(spuId));
}, executor);
CompletableFuture<Void> baseAttrFuture = infoFuture.thenAcceptAsync(info -> {
// 2.获取spu规格参数信息
skuItemVo.setBaseAttrs(attrGroupService.getAttrGroupWithAttrsBySpuId(info.getSpuId(), info.getCatalogId()));
}, executor);
CompletableFuture<Void> imgFuture = CompletableFuture.runAsync(() -> {
// 1.sku图片信息 pms_sku_images
skuItemVo.setImages(skuImagesService.getImagesBySkuId(skuId));
}, executor);
try {
// 等待所有任务完成
CompletableFuture.allOf(infoFuture, saleAttrFuture, spuDescFuture, baseAttrFuture, imgFuture).get();
} catch (InterruptedException | ExecutionException e) {
e.printStackTrace();
}
return skuItemVo;
}
登录&注册
验证码组件
@Data
@Component
@ConfigurationProperties(prefix = "spring.cloud.alicloud.sms")
public class SmsComponent {
private String host;
private String path;
private String template;
private String sign;
private String appcode;
public HttpResponse sendSmsCode(String phone,String code){
String host = this.host;
String path = this.path;
String method = "POST";
String appcode = this.appcode;
Map<String, String> headers = new HashMap<String, String>();
headers.put("Authorization", "APPCODE " + appcode);
Map<String, String> querys = new HashMap<String, String>();
querys.put("mobile", phone);
querys.put("param", "**code**:"+code+",**minute**:1");
querys.put("smsSignId", this.sign);
querys.put("templateId", this.template);
Map<String, String> bodys = new HashMap<String, String>();
try {
return HttpUtils.doPost(host, path, method, headers, querys, bodys);
} catch (Exception e) {
e.printStackTrace();
return null;
}
}
}
密码加密

new BCryptPasswordEncoder().encode(password)

社交登录
OAuth是一个开放标准,允许用户授权第三方网站访问他们存储在另外的服务提供者上的信息,而不需要将用户密码提供给第三方网站或分享他们数据的所有内容OAuth2.0对于用户相关的OpenAPI(例如获取用户信息,动态同步,照片,日志,分享等),为了保护用户数据的安全和隐私,第三方网站访问用户数据前都需要显式的向用户征求授权


微信
如图所示


注:微信登录需要获取微信平台的 appid和appsecret,同时指定回调地址(这个回调地址是自己调用的,不需要内网穿透,代码中的回调地址必须与微信平台设置的相同)
/**
* 准备必要的参数,再重定向到微信平台获取登录二维码
* wx.open.app_id=xxx
* wx.open.app_secret=bbb
* wx.open.redirect_url=http://localhost:8160/oauth2/wx/callback * 固定值
*/
@GetMapping("/login")
public String getWxCode(HttpSession session) {
if(session.getAttribute(AuthenticCommonConstant.LOGIN_USER)!=null){
// 已经有用户登录
return "redirect:http://";
}
// 微信开放平台授权baseUrl
String baseUrl = "https://open.weixin.qq.com/connect/qrconnect" +
"?appid=%s" +
"&redirect_uri=%s" +
"&response_type=code" +
"&scope=snsapi_login" +
"&state=%s" +
"#wechat_redirect";
// 回调地址
String redirectUrl = AuthenticationConstant.WX_OPEN_REDIRECT_URL; //获取业务服务器重定向地址
try {
redirectUrl = URLEncoder.encode(redirectUrl, "UTF-8"); // url编码
} catch (UnsupportedEncodingException e) {
e.printStackTrace();
}
String state = UUID.randomUUID().toString().replaceAll("-", "");
String qrcodeUrl = String.format(
baseUrl,
AuthenticationConstant.WX_OPEN_APP_ID,
redirectUrl,
state);
return "redirect:" + qrcodeUrl;
}
/**
* 微信平台检测到用户登录且确认,调用以下方法(本机调用)
* 方法逻辑简单来说就是通过两次get请求微信API获取用户信息
* 第一次:code(由上一步用户扫码后平台返回,封装在请求参数中)、appid、secret、
* 第二次:微信返回 access_token、openid(用户id),拿着这两个参数去最终获取用户信息
*/
@GetMapping("/callback")
public String callback(String code, String state) {
...
}
单点登录
[单点登录(SSO)解决方案介绍 - 冰湖一角 - 博客园 (cnblogs.com)](https://www.cnblogs.com/bingyimeiling/p/11698468.html#:~:text=单点登录
(Single Sign On),简称为,SSO,是目前比较流行的企业业务整合的解决方案之一。. SSO的定义是在多个应用系统中,用户只需要登录一次就可以访问所有相互信任的应用系统。. 例如:百度旗下有很多的产品,比如百度贴吧、百度知道、百度文库等,只要登录百度账号,在任何一个地方都是已登录状态,不需要重新登录。. 当用户第一次访问应用系统的时候,因为还没有登录,会被引导到认证系统中进行登录;根据用户提供的登录信息,认证系统进行身份校验,如果通过校验,应该返回给用户一个认证的凭据--ticket;用户再访问别的应用的时候,就会将这个ticket带上,作为自己认证的凭据,应用系统接受到请求之后会把ticket送到认证系统进行校验,检查ticket的合法性。.)
同域下的单点登录
- session广播机制,缺点是资源消耗大,tomcat原生支持
- 使用 cookie + redis
- redis:key(唯一随机值),value(用户数据)
- cookie:把redis里面生成的key值放到cookie里面
- 访问项目其他模块,发送请求带着cookie进行发送,根据cookie(与key值相关)查询value,存在则为登录
- springsession 就是这种实现
- 使用 token
- 前端请求发出->后端验证成功后,按照规则生成字符串,把登陆之后的用户信息保存到其中(token)并返回(JWT工具类)
- 前端保存请求返回的值(token),存入cookie。
- 接着添加前端拦截器,如果cookie存在第二步的cookie,则在请求头添加header,值为cookie中的对应字符串
- 后端能从请求头中获取这个token且有效就可以判定为已登录
- 其实2、3两种都是后端校验成功后给了前端一个登录凭证,这个凭证可以使cookie,也可以是token(放在请求参数位置),然后后端将登录信息存到共享的数据库中,一般是redis。这里有一个问题,如果用户的cookie或者token是伪造的怎么办?这个伪造的cookie或token值一般需要大量尝试才能得出真正的有用值,后台可以增加对于重试的监测机制
不同域名下的单点登录
如图所示,这是CAS(统一身份认证)流程

解释如下:
- 用户访问app系统,app系统是需要登录的,但用户现在没有登录。
- 跳转到CAS server,即SSO登录系统,以后图中的CAS Server我们统一叫做SSO系统。 SSO系统也没有登录,弹出用户登录页。
- 用户填写用户名、密码,SSO系统进行认证后,将登录状态写入SSO的session,浏览器(Browser)中写入SSO域下的Cookie。
- SSO系统登录完成后会生成一个ST(Service Ticket),然后跳转到app系统,同时将ST作为参数传递给app系统。
- app系统拿到ST后,从后台向SSO发送请求,验证ST是否有效。
- 验证通过后,app系统将登录状态写入session并设置app域下的Cookie。
至此,跨域单点登录就完成了。以后我们再访问app系统时,app就是登录的。接下来,我们再看看访问app2系统时的流程。
- 用户访问app2系统,app2系统没有登录,跳转到SSO。
- 由于SSO已经登录了[有相应的cookie],不需要重新登录认证。
- SSO生成ST,浏览器跳转到app2系统,并将ST作为参数传递给app2。
- app2拿到ST,后台访问SSO,验证ST是否有效。
- 验证成功后,app2将登录状态写入session,并在app2域下写入Cookie。
这样,app2系统不需要走登录流程,就已经是登录了。SSO,app和app2在不同的域,它们之间的session不共享也是没问题的。
有的同学问我,SSO系统登录后,跳回原业务系统时,带了个参数ST,业务系统还要拿ST再次访问SSO进行验证,觉得这个步骤有点多余。他想SSO登录认证通过后,通过回调地址将用户信息返回给原业务系统,原业务系统直接设置登录状态,这样流程简单,也完成了登录,不是很好吗?
其实这样问题时很严重的,如果我在SSO没有登录,而是直接在浏览器中敲入回调的地址,并带上伪造的用户信息,是不是业务系统也认为登录了呢?这是很可怕的。
总结
单点登录,资源都在各个业务系统这边,不在SSO那一方。 用户在给SSO服务器提供了用户名密码后,作为业务系统并不知道这件事。 SSO随便给业务系统一个ST,那么业务系统是不能确定这个ST是用户伪造的,还是真的有效,所以要拿着这个ST去SSO服务器再问一下,这个用户给我的ST是否有效,是有效的我才能让这个用户访问。
购物车
代码逻辑
/**
* Controller
* auth:login
* 添加购物车
*/
@GetMapping("/addToCart")
public String addToCart(@RequestParam("skuId") Long skuId, @RequestParam("num") Integer num) {
cartService.addToCart(skuId, num, CartInterceptor.loginUser.get());
return "redirect:http://cart.projectdemo.top/cart/getItem.html?skuId=" + skuId;
}
/**
* Service
* 添加到购物车,但实际可能是修改操作
*/
@Override
public CartItem addToCart(Long skuId, Integer num, Long memberId) {
// key prefix-memberId -> cart:8 | hash-key -> skuId hash-value -> cartItem-Json
BoundHashOperations<String, String, String> bound =
redisTemplate.boundHashOps(CartConstant.CART_MEMBER_PREFIX + memberId);
CartItem cartItem = new CartItem();
String o = bound.get(skuId.toString());
if (StringUtils.isEmpty(o)) {
// 添加,fillItem方法就是查询数据库并封装 cartItem(需要数据库的商品info等信息)
fillItem(skuId, num, cartItem);
bound.put(skuId.toString(), JSON.toJSONString(cartItem));
} else {
// 修改,把redis中的json取出来改一遍就行了
cartItem = JSON.parseObject(o, CartItem.class);
int i = cartItem.getCount() + num;
cartItem.setCount(i);
bound.put(skuId.toString(), JSON.toJSONString(cartItem));
}
return cartItem;
}

订单

订单
RabbitMQ-概念设计



RabbitMQ-代码设计
/**
* Order
*/
@Bean
public Queue orderDelayQueue() {
Map<String, Object> args = new HashMap<>();
args.put("x-dead-letter-exchange", "order-event-exchange");
args.put("x-dead-letter-routing-key", "order.release.order");
args.put("x-message-ttl", 60000);
return new Queue("order.delay.queue",
true,
false,
false,
args);
}
@Bean
public Queue orderReleaseQueue() {
return new Queue("order.release.order.queue",
true,
false,
false
);
}
@Bean
public Exchange orderEventExchange() {
return new TopicExchange("order-event-exchange",
true,
false
);
}
@Bean
public Binding orderCreateOrder() {
// 交换机根据routing-key绑定队列
return new Binding("order.delay.queue",
Binding.DestinationType.QUEUE,
"order-event-exchange",
"order.create.order",null);
}
@Bean
public Binding orderReleaseOrder() {
return new Binding("order.release.order.queue",
Binding.DestinationType.QUEUE,
"order-event-exchange",
"order.release.order",null);
}
/**
* Ware(Stock)
*/
@Configuration
public class MyRabbitConfig {
@Bean
public MessageConverter messageConverter(){
return new Jackson2JsonMessageConverter();
}
@Bean
public Exchange stockEventExchange(){
return new TopicExchange("stock-event-exchange",
true,false);
}
@Bean
public Queue stockReleaseStockQueue(){
return new Queue("stock.release.stock.queue",
true,false,false);
}
@Bean
public Queue stockDelayQueue(){
Map<String, Object> args = new HashMap<>();
args.put("x-dead-letter-exchange", "stock-event-exchange");
args.put("x-dead-letter-routing-key", "stock.release.stock");
args.put("x-message-ttl", 120000);
return new Queue("stock.delay.queue",
true,
false,
false,
args);
}
@Bean
public Binding stockLockBinding(){
return new Binding("stock.delay.queue",
Binding.DestinationType.QUEUE,
"stock-event-exchange",
"stock.lock.stock",
null);
}
@Bean
public Binding stockReleaseBinding(){
return new Binding("stock.release.stock.queue",
Binding.DestinationType.QUEUE,
"stock-event-exchange",
"stock.release.#",
null);
}
}
/**
* Listener1(Controller)
*/
@RabbitListener(queues = "order.release.order.queue")
public void releaseOrder(OrderEntity o, Channel channel, Message message) throws IOException {
try {
orderService.closeOrder(o);
// 手动调用支付宝收单,防止用户再去支付 TODO
//https://opendocs.alipay.com/apis/api_1/alipay.trade.close
channel.basicAck(message.getMessageProperties().getDeliveryTag(),false);
} catch (Exception e) {
e.printStackTrace();
channel.basicReject(message.getMessageProperties().getDeliveryTag(),true);
}
}
/**
* Service
* Order服务收到MQ关闭订单消息
*/
@Override
public void closeOrder(OrderEntity o) {
log.error("MQ消息-尝试关闭订单!");
OrderEntity byId = this.getById(o.getId());
// 只有新建状态的订单才能关闭
if(Objects.equals(byId.getStatus(), OrderStatusEnum.CREATE_NEW.getCode())){
OrderEntity orderEntity = new OrderEntity();
orderEntity.setId(o.getId());
orderEntity.setStatus(OrderStatusEnum.CANCELED.getCode());
// 订单服务数据库更改订单数据
this.updateById(orderEntity);
OrderTo order = new OrderTo();
BeanUtils.copyProperties(byId,order);
// 主动发消息给库存使其库存回滚
try {
// 订单服务给 ware 服务发送rabbitmq消息,使其回滚库存
rabbitTemplate.convertAndSend("order-event-exchange",
"order.release.other",order);
} catch (Exception e) {
e.printStackTrace();
}
}
}
@Component
@Slf4j
@RabbitListener(queues = "stock.release.stock.queue")
public class StockReleaseListener {
@Autowired
WareOrderTaskDetailService wareOrderTaskDetailService;
@Autowired
WareOrderTaskService wareOrderTaskService;
@Autowired
OrderFeignService orderFeignService;
@Autowired
WareSkuDao wareSkuDao;
@Autowired
WareSkuService wareSkuService;
/**
* Listener2(Controller)
* 库存服务解锁库存
*/
@RabbitHandler
public void handleStockLockedRelease(StockLockedTo to, Message message, Channel channel) throws IOException {
log.error("库存服务解锁库存!");
try {
StockDetailTo detail = to.getDetail();
Long detailId = detail.getId();
WareOrderTaskDetailEntity byId = wareOrderTaskDetailService.getById(detailId);
// WareOrderTaskDetailEntity 不为空-说明库存已经扣减,
// 需要检查订单是否有效,否则回滚订单锁定的库存
if (byId != null) {
// 检查确认订单状态
Long id = to.getId();
WareOrderTaskEntity taskEntity = wareOrderTaskService.getById(id);
String orderSn = taskEntity.getOrderSn();
R r1 = orderFeignService.getOrderStatus(orderSn);
if (r1.getCode() == 0) {
OrderVo data = r1.getData(new TypeReference<OrderVo>() {});
// 订单已经【取消】或【不存在】,必须解锁库存
if (data == null || Objects.equals(data.getStatus(), OrderStatusEnum.CANCELED.getCode())) {
if (byId.getLockStatus() == 1) {
// 锁定状态必须是1-锁定
wareSkuService.unlockStock(detail.getSkuId(), detail.getWareId(), detail.getSkuNum(), detailId);
channel.basicAck(message.getMessageProperties().getDeliveryTag(), false);
}
}
} else {
// 远程调用出问题,拒收消息,重新放回队列
channel.basicReject(message.getMessageProperties().getDeliveryTag(), true);
}
} else {
// mq中有消息,但数据库中没有记录,可能是锁单有异常发生,数据库数据回滚,无需解锁
channel.basicReject(message.getMessageProperties().getDeliveryTag(), false);
}
// 查不到库存工作单,无需解锁
} catch (IOException e) {
// 未知异常,消息重新入队
e.printStackTrace();
channel.basicReject(message.getMessageProperties().getDeliveryTag(), true);
}
}
/**
* Listener1(Controller)
* 来源于order.release.order.queue->listener1->order.release.other(OrderTo)
*/
@RabbitHandler
@Transactional
public void handleOrderCloseRelease(OrderTo to, Message message, Channel channel){
log.error("订单主动回滚库存!");
// 查询订单状态
WareOrderTaskEntity w = wareOrderTaskService.getOrderTaskByOrderSn(to.getOrderSn());
Long id = w.getId();
List<WareOrderTaskDetailEntity> list = wareOrderTaskDetailService.list(new QueryWrapper<WareOrderTaskDetailEntity>().
eq("task_id", id).eq("lock_status", 1));
for (WareOrderTaskDetailEntity entity : list) {
wareSkuService.unlockStock(entity.getSkuId(), entity.getWareId(),
entity.getSkuNum(),entity.getId());
}
try {
channel.basicAck(message.getMessageProperties().getDeliveryTag(), false);
} catch (IOException e) {
e.printStackTrace();
}
}
}
/**
* 提交订单
*/
@Override
@Transactional
public SubmitOrderResponseVo submitOrder(OrderSubmitVo vo) {
orderSubmitVoThreadLocal.set(vo);
SubmitOrderResponseVo response = new SubmitOrderResponseVo();
// token是做幂等性处理的参数,由 confirmOrder 接口生成(上一步的接口)
String voToken = vo.getOrderToken();
Long userId = LoginUserInterceptor.loginUser.get();
// 查-比-删,全部成功返回1,这里是有并发问题的,必须要原子操作
String script = "if redis.call('get',KEYS[1]) == ARGV[1] then return redis.call('del',KEYS[1]) else return 0 end";
Long result = redisTemplate.execute(new DefaultRedisScript<Long>(script, Long.class),
Collections.singletonList(OrderConstant.USER_ORDER_TOKEN_PREFIX + userId), voToken);
if (result == 0L) {
// 失败,幂等性对比失败,用户可能修改了token或者点太快
response.setCode(1);
return response;
} else {
// 生成订单基础信息,如订单号、收货人、价格、积分等,这个数据是可信赖的
OrderCreateTo order = createOrder();
BigDecimal payAmount = order.getOrder().getPayAmount();
BigDecimal payPrice = vo.getPayPrice();
// 对比前后端的价格,以后端为准
if (payAmount.subtract(payPrice).abs().doubleValue() < 0.01) {
// 金额对比容错成功 ...
// 保存订单且锁定库存,有异常就回滚 Transactional
WareSkuLockVo wareSkuLockVo = new WareSkuLockVo();
wareSkuLockVo.setOrderSn(order.getOrder().getOrderSn());
wareSkuLockVo.setLocks(order.getItems().stream().map(item -> {
OrderItemVo orderItemVo = new OrderItemVo();
orderItemVo.setSkuId(item.getSkuId());
orderItemVo.setCount(item.getSkuQuantity());
return orderItemVo;
}).collect(Collectors.toList()));
// 远程锁库存(创建库存工作单,发送rabbitmq死信队列消息)
R r = wareFeignService.lockStock(wareSkuLockVo);
if (r.getCode() == 0) {
// 🔐成功
// 1.保存订单数据
saveOrder(order);
// 2.锁库存
response.setOrder(order.getOrder());
// 发送rabbitmq消息(1m后关闭订单,回滚库存)
rabbitTemplate.convertAndSend("order-event-exchange",
"order.create.order",order.getOrder());
return response;
} else {
// 有异常-无库存
response.setCode(3);
return response;
}
} else {
// 价格校验失败
response.setCode(2);
return response;
}
}
}

@Override
@Transactional(rollbackFor = NoStockException.class)
public boolean lockStock(WareSkuLockVo vo) {
// 保存库存工作单 WareOrderTaskEntity
WareOrderTaskEntity wareOrderTaskEntity = new WareOrderTaskEntity();
wareOrderTaskEntity.setOrderSn(vo.getOrderSn());
wareOrderTaskService.save(wareOrderTaskEntity);
List<OrderItemVo> locks = vo.getLocks();
List<SkuWareHasStock> collect = locks.stream().map(item -> {
SkuWareHasStock stock = new SkuWareHasStock();
Long skuId = item.getSkuId();
stock.setSkuId(skuId);
// 查询并设置有库存的仓库id
stock.setWareId(wareSkuDao.listWareIdHasSkuStock(skuId));
stock.setNum(item.getCount());
return stock;
}).collect(Collectors.toList());
// 如果所有个商品都锁定成功,将当前商品锁定了几件的工作单记录发给MQ
// 有商品锁定失败,前面保存的工作单信息就回滚了
// 发送出去的消息,即使想要解锁记录,由于去数据库查不到id,所以就不用解锁
for (SkuWareHasStock s : collect) {
boolean skuStocked = false;
Long skuId = s.getSkuId();
Integer num = s.getNum();
List<Long> wareIds = s.getWareId();
if (CollectionUtils.isNotEmpty(wareIds)) {
for (Long wareId : wareIds) {
// 当前遍历的仓库尝试锁单
Long success = wareSkuDao.lockSkuStock(skuId, wareId, num);
if (success == 1) {
// 当前仓库锁定成功
skuStocked = true;
WareOrderTaskDetailEntity detail =
new WareOrderTaskDetailEntity(null, skuId, "", s.getNum(),
wareOrderTaskEntity.getId(), wareId, 1);
wareOrderTaskDetailService.save(detail);
StockLockedTo stockLockedTo = new StockLockedTo();
stockLockedTo.setId(wareOrderTaskEntity.getId());
StockDetailTo stockDetailTo = new StockDetailTo();
BeanUtils.copyProperties(detail, stockDetailTo);
stockLockedTo.setDetail(stockDetailTo);
// 锁单成功,添加锁单死信队列消息
rabbitTemplate.convertAndSend("stock-event-exchange",
"stock.lock.stock", stockLockedTo);
break;
}
// 失败,重试下一个仓库
}
if (!skuStocked) {
// 这个商品没有库存 - 报异常
throw new NoStockException(skuId);
}
} else {
throw new NoStockException(skuId);
}
}
return true;
}
支付
支付宝

有几个点需要注意:
- 公钥加密,私钥解密;私钥加签,公钥验签
- 同步回调地址
return_url不需要内网穿透,异步回调地址notify_url需要指定为 外网地址 - 本项目设计为异步回调至本机80端口,nginx根据其Host进行匹配转发到网关,同时修改掉原本的Host值为
order.projectdemo.top - 由于内网穿透软件特性,每次启动更改内网穿透的对应外网地址,故每次启动需要更改
- order项目配置文件
notify_url - nginx
server_name匹配路径
- order项目配置文件
收单
- 订单在支付页,不支付,一直刷新,订单过期了才支付,订单状态已经改为已支付了,但是库存解锁了
- 设置支付宝支付超时时间解决,过期无法支付
- 由于时延问题,订单已支付完成,但解锁库存之后,支付宝异步通知才到
- 订单解锁,手动收单
- 网络阻塞问题,订单支付成功的异步通知一直不到达
- 查询订单列表时,ajax获取当前未支付的订单状态,查询订单状态时,再获取支付宝此订单状态
- 其他各种问题
- 每天服务器闲时下载支付宝账单,一一对账
秒杀
要点:限流+异步+缓存(页面静态化)+独立部署
限流方式:
- 前端限流,一些高并发的网站直接在前端页面开始限流
- nginx 限流,直接负载部分请求到错误的静态页面
- 网关限流,限流的过滤器
- 代码中使用分布式信号量
- rabbitmq 限流( 能者多劳:channel.basicQos(1) ),保证发挥所有服务器的性能


思路设计
1. 秒杀流程一(加入购物车秒杀-弃用)
优点:加入购物车实现天然的流量错峰,与正常购物流程一致只是价格为秒杀价格,数据模型与正常下单兼容性好
缺点:秒杀服务与其他服务关联性提高,比如这里秒杀服务会与购物车服务关联,秒杀服务高并发情况下,可能会把购物车服务连同压垮,导致正常商品,正常购物也无法加入购物车下单

2. 秒杀流程二(独立秒杀业务来处理)
优点:从用户下单到返回没有对数据库进行任何操作,只是做了一些条件校验,校验通过后也只是生成一个单号,再发送一条消息
缺点:如果订单服务全挂掉了,没有服务来处理消息,就会导致用户一直不能付款
解决方案:不使用订单服务处理秒杀消息,需要一套独立的业务来处理

3. 创建秒杀队列

项目设计
Redis 设计


上架&查询
/**
* 找出start_time在今天0:00到三天后0:00区间内的session(秒杀场次)
* 并将上架秒杀商品信息和库存数放入redis
* 此方法采用redisson分布式锁,固定加锁时间为20s
*/
@Async
// 秒 分 时 日 月 周 年
// @Scheduled(cron = "0 0 3 * * ?") // 每天3:00AM上架秒杀商品
@Scheduled(cron = "*/10 * * * * ?")
public void uploadSeckillSkuLatest3Days() {
log.info("上架秒杀!");
RLock lock = redissonClient.getLock(upload_lock);
lock.lock(20, TimeUnit.SECONDS);
try {
seckillService.uploadSeckillSkuLatest3Days();
}finally {
lock.unlock();
}
}
/**
* 扫描需要参与秒杀的活动,并缓存商品信息
*/
@Override
public void uploadSeckillSkuLatest3Days() {
R r = couponFeignService.getLatestNDaySession(3);
if (r.getCode() == 0) {
// 上架商品
List<SeckillSessionWithSkus> data = r.getData(new TypeReference<List<SeckillSessionWithSkus>>() {
});
// 缓存活动信息和关联商品信息
saveSessionInfos(data);
}
}
/**
* 该方法缓存商品信息
* 有两个要点:随机码、信号量(库存数)
* 随机码是用于商品加密,只有在秒杀时间段才会返回给前台,该值作为库存的key
* 使用redisson将库存数作为信号量
*/
private void saveSessionInfos(List<SeckillSessionWithSkus> data) {
BoundHashOperations<String, String, String> hashOps = redisTemplate.boundHashOps(SeckillConstant.SECKILL_SKUS_PREFIX);
data.forEach(session -> {
// 秒杀场次信息缓存,采用list数据结构存储,可以有相同的键,不能有相同的值
long start = session.getStartTime().getTime();
long end = session.getEndTime().getTime();
String redisKey = SeckillConstant.SECKILL_SESSION_PREFIX + start + "-" + end + "-" + session.getId();
if (Boolean.FALSE.equals(redisTemplate.hasKey(redisKey))) {
List<String> ids = session.getRelationSkus().stream().map(i ->
i.getSkuId().toString()).collect(Collectors.toList());
redisTemplate.opsForList().leftPushAll(redisKey, ids);
}
// 每一场秒杀商品缓存如下
session.getRelationSkus().forEach(item -> {
// 使用 sessionId-skuId 为SeckillConstant.SECKILL_SKUS_PREFIX Hash下的键,值为商品信息
String skuKey = item.getSkuId().toString() + "-" + session.getId();
if (Boolean.FALSE.equals(hashOps.hasKey(skuKey))) {
SeckillSkuRedisTo to = new SeckillSkuRedisTo();
// 1.SKUs基本信息
R r = productFeignService.skuInfo(item.getSkuId());
if (r.getCode() == 0) {
SkuInfoVo skuInfo = r.getData("skuInfo", new TypeReference<SkuInfoVo>() {
});
to.setSkuInfoVo(skuInfo);
}
// 2.SKUs秒杀信息,当场商品秒杀信息是一样的
BeanUtils.copyProperties(item, to);
to.setSeckillLimit(item.getSeckillLimit());
// 3.设置当前商品的秒杀时间信息
to.setStartTime(session.getStartTime().getTime());
to.setEndTime(session.getEndTime().getTime());
// 4.随机码
String token = UUID.randomUUID().toString().replace("-", "");
to.setRandomCode(token);
hashOps.put(skuKey, JSON.toJSONString(to));
// 5.引入redisson分布式信号量作为库存——起限流作用
RSemaphore semaphore = redissonClient.getSemaphore(SeckillConstant.SECKILL_SKU_STOCK_PREFIX + token);
// 商品可以秒杀的数量作为信号量
semaphore.trySetPermits(item.getSeckillCount());
}
});
});
}
秒杀逻辑
/**
* 秒杀方法 url:kill?killId=31-2&randomCode=bc8114eff5f64c1cba576f6ae2e649fd&num=2
*/
@GetMapping("/kill")
public String seckill(@RequestParam("killId")String killId,
@RequestParam("randomCode")String randomCode,
@RequestParam("num")Integer num,
Model model){
String orderSn = seckillService.kill(killId,randomCode,num);
model.addAttribute("orderSn",orderSn);
return "success";
}
/**
* 秒杀逻辑
*/
@Override
public String kill(String killId, String randomCode, Integer num) {
BoundHashOperations<String, String, String> hashOps = redisTemplate.boundHashOps(SeckillConstant.SECKILL_SKUS_PREFIX);
String s = hashOps.get(killId); // killId:31-2
if (StringUtils.isNotEmpty(s)) {
// 校验参数
SeckillSkuRedisTo redisTo = JSON.parseObject(s, SeckillSkuRedisTo.class);
Long startTime = redisTo.getStartTime();
Long endTime = redisTo.getEndTime();
long now = new Date().getTime();
// 1.校验时间
if (now >= startTime && now <= endTime) {
// 2.校验随机码和商品ID
String redisToRandomCode = redisTo.getRandomCode();
String redisKey = redisTo.getSkuId() + "-" + redisTo.getPromotionSessionId();
if (redisToRandomCode.equals(randomCode) && killId.equals(redisKey)) {
// 3.校验购物数量
if (num <= redisTo.getSeckillLimit()) {
// 4.校验是否购买过
Long userID = LoginUserInterceptor.loginUser.get();
// 格式:prefix-userId-skuId-sessionId
String redisUserKey = SeckillConstant.SECKILL_USER_MARK_PREFIX + redisToRandomCode +
userID + "-" + redisTo.getSkuId() + "-" + redisTo.getPromotionSessionId();
Boolean mark = redisTemplate.opsForValue().setIfAbsent(redisUserKey, String.valueOf(num),
Duration.ofMillis(endTime - now));
if (Boolean.TRUE.equals(mark)) {
// 占位成功,未购买过
RSemaphore semaphore = redissonClient.getSemaphore(SeckillConstant.SECKILL_SKU_STOCK_PREFIX + randomCode);
// semaphore.acquire(num); // 该方法阻塞
try {
if (semaphore.tryAcquire(num, 500, TimeUnit.MILLISECONDS)) {
// 秒杀成功
String timeId = IdWorker.getTimeId();
// 对象构造
QuickOrderTo quickOrderTo = new QuickOrderTo();
quickOrderTo.setOrderSn(timeId);
quickOrderTo.setNum(num);
quickOrderTo.setMemberId(userID);
quickOrderTo.setPromotionSessionId(redisTo.getPromotionSessionId());
quickOrderTo.setSkuId(redisTo.getSkuId());
quickOrderTo.setSeckillPrice(redisTo.getSeckillPrice());
// 秒杀成功,发送mq订单消息,并返回订单号通知前台秒杀成功
rabbitTemplate.convertAndSend("order-event-exchange",
"order.seckill.order", quickOrderTo);
return timeId;
}
} catch (InterruptedException e) {
return null;
}
}
}
}
}
}
return null;
}
/**
* 监听已经秒杀成功的订单,削峰创建订单
*/
@RabbitListener(queues = "order.seckill.order.queue")
public void seckillOrder(QuickOrderTo to, Channel channel, Message message) throws IOException {
try {
log.info("创建秒杀订单!");
orderService.createSeckillOrder(to);
// 手动调用支付宝收单,防止用户再去支付 TODO
// https://opendocs.alipay.com/apis/api_1/alipay.trade.close
channel.basicAck(message.getMessageProperties().getDeliveryTag(),false);
} catch (Exception e) {
e.printStackTrace();
channel.basicReject(message.getMessageProperties().getDeliveryTag(),true);
}
}
/**
* 削峰建单
*/
@Override
public void createSeckillOrder(QuickOrderTo to) {
OrderEntity orderEntity = new OrderEntity();
orderEntity.setOrderSn(to.getOrderSn());
orderEntity.setMemberId(to.getMemberId());
orderEntity.setStatus(OrderStatusEnum.CREATE_NEW.getCode());
orderEntity.setPayAmount(to.getSeckillPrice().multiply(BigDecimal.valueOf(to.getNum())));
this.save(orderEntity);
OrderItemEntity orderItemEntity = new OrderItemEntity();
orderItemEntity.setOrderSn(to.getOrderSn());
orderItemEntity.setRealAmount(orderEntity.getPayAmount());
orderItemEntity.setSkuQuantity(to.getNum());
orderItemEntity.setSkuId(to.getSkuId());
// TODO 查询SKU详细信息并保存
orderItemService.save(orderItemEntity);
}
重大 BUG 解决方法
微信登录-session 作用域
微信登录调用回调函数时,由于谷粒学院APP写死回调地址,必须是 localhost:8160/... ,导致产生了额外的会话,而domain都是demoproject.top,回调的cookie(MALLSESSION)则直接失效,也就没有传递到项目主地址,就没了session,这个问题叫做跨域名cookie失效
// 当前回调函数环境为 localhost:8160... session是不能共享的,通过请求参数传递cookie
// return "redirect:http://projectdemo.top"; // 域名不同session丢失
// return "redirect:http://localhost:9000"; // 域名相同有session但逻辑不正确
// 解决方案是再写一个接力接口通过请求参数共享cookie
return "redirect:http://auth.projectdemo.top/oauth2/wx/success.html?token=" + JwtUtils.getJwtToken(String.valueOf(u.getId()), u.getNickname());

远程调用Session丢失
// Openfeign 调用配置拦截器加上cookie
@Bean("requestInterceptor")
public RequestInterceptor requestInterceptor(){
return template -> {
ServletRequestAttributes attributes = (ServletRequestAttributes) RequestContextHolder.getRequestAttributes();
HttpServletRequest request = attributes.getRequest();
String cookie = request.getHeader("Cookie");
template.header("Cookie",cookie);
};
}
Feign远程调用丢失上下文



浙公网安备 33010602011771号