[TOC]
用户在商品详细页点击加入购物车,提交商品 SKU 编号和购买数量,添加到购物车。当用户在未登录的情况下,将此购物车存入 cookies , 在用户登陆的情况下,将购物车数据存入 redis 。如果用户登陆时,cookies 中存在购物车,需要将 cookies 的购物车合并到 redis 中存储.
购物车实体类:
public class Cart implements Serializable{ // 对每一个商家购物车的封装
private String sellerId; // 商家ID
private String sellerName; // 商家名称
private List<TbOrderItem> orderItemList; // 购物车明细
// setter getter方法
}Cookie 存储购物车
服务实现层:向购物车列表中添加物品
@Override
public List<Cart> addGoodsToCartList(List<Cart> cartList, Long itemId, Integer num) {
// 1. 根据skuID (itemId)查询商品明细sku的对象
TbItem item = itemMapper.selectByPrimaryKey(itemId);
if (item == null) throw new RuntimeException("商品不存在");
// 时间差,比如在添加提交订单时,商品下架了
if (!"1".equals(item.getStatus())) throw new RuntimeException("商品不存在");
// 2. 根据sku对象获取商家ID
String sellerId = item.getSellerId();
// 3. 根据商家ID在购物车列表中查询购物车对象
Cart cart = searchCartBySellerId(cartList, sellerId);
// 4.如果购物车列表中不存在该商家ID对应的购物车对象
if (cart == null) {
// 4.1创建该商家的购物车对象
cart = new Cart();
cart.setSellerId(sellerId);
cart.setSellerName(item.getSeller());
// 创建购物车明细对象
List<TbOrderItem> orderItemList = new ArrayList<>();
TbOrderItem orderItem = createOrderItem(item, num);
orderItemList.add(orderItem);
cart.setOrderItemList(orderItemList);
// 4.2将该购物车对象添加到购物车列表中
cartList.add(cart);
}else { // 5. 如果购物车列表中存在该商家ID对应的购物车对象
// 然后判断购物车对象中是否存在该商品的明细对象
TbOrderItem orderItem = searchOrderItemByItemId(cart.getOrderItemList(), itemId);
if (orderItem == null) { // 5.1 如果明细列表中不存在,创建明细对象添加到购物车对象中
// 创建购物车明细对象
orderItem = createOrderItem(item, num);
cart.getOrderItemList().add(orderItem);
}else { // 5.2 如果明细列表中存在,则增加对应的数量
orderItem.setNum(orderItem.getNum()+num); // 更改数量
orderItem.setTotalFee( new BigDecimal(orderItem.getPrice().doubleValue()*orderItem.getNum()) ); // 更改价格
if(orderItem.getNum()<1) cart.getOrderItemList().remove(orderItem); // 当明细的数量小于1时移除
if (cart.getOrderItemList().size()<1) cartList.remove(cart); // 当购物车的明细项数为0时,移除购物车列表该对象
}
}
return cartList;
}
/**
* 根据商家ID在购物车列表中查询该商家的购物车
* @param cartList
* @param sellerId
* @return
*/
private Cart searchCartBySellerId(List<Cart> cartList, String sellerId) {
for(Cart cart :cartList) {
if (sellerId.equals(cart.getSellerId())) {
return cart;
}
}
return null;
}
// 创建新的购物明细对象
private TbOrderItem createOrderItem(TbItem item, Integer num) {
if(num<1) throw new RuntimeException("非法数量");
TbOrderItem order = new TbOrderItem();
order.setGoodsId(item.getGoodsId());
order.setItemId(item.getId());
order.setNum(num);
order.setPicPath(item.getImage());
order.setPrice(item.getPrice());
order.setSellerId(item.getSellerId());
order.setTitle(item.getTitle());
order.setTotalFee(new BigDecimal( item.getPrice().doubleValue()*num ));
return order;
}
/**
* 在购物车明细列表中,根据SKUID查询购物车明细对象
* @param orderItemList
* @param itemId
* @return
*/
private TbOrderItem searchOrderItemByItemId(List<TbOrderItem> orderItemList, Long itemId) {
for(TbOrderItem orderItem : orderItemList) {
if (orderItem.getItemId().longValue()==itemId.longValue()) {
return orderItem;
}
}
return null;
}控制层:
/**
* 购物车列表
*
* @param request
* @return
*/
@RequestMapping("/findCartList")
public List<Cart> findCartList() {
String cartListString = util.CookieUtil.getCookieValue(request, "cartList", "UTF-8");
if (cartListString == null || cartListString.equals("")) {
cartListString = "[]";
}
List<Cart> cartList_cookie = JSON.parseArray(cartListString, Cart.class);
return cartList_cookie;
}
/**
* 添加商品到购物车
*
* @param request
* @param response
* @param itemId
* @param num
* @return
*/
@RequestMapping("/addGoodsToCartList")
public Result addGoodsToCartList(Long itemId, Integer num) {
try {
List<Cart> cartList = findCartList();// 获取购物车列表
cartList = cartService.addGoodsToCartList(cartList, itemId, num);
util.CookieUtil.setCookie(request, response, "cartList", JSON.toJSONString(cartList), 3600 * 24, "UTF-8");
return new Result(true, "添加成功");
} catch (Exception e) {
e.printStackTrace();
return new Result(false, "添加失败");
}
}前端服务层:将逻辑实现放在服务层,以便重用。
// 购物车明细求和
this.sum=function(cartList){
var total = {totalNum:0, totalMoney:0};
for(var i=0;i<cartList.length;i++){
var cart = cartList[i];
for(var j=0;j<cart.orderItemList.length;j++){
var item=cart.orderItemList[j]; // 购物车明细
total.totalNum +=item.num;
total.totalMoney += item.totalFee;
}
}
return total;
}Redis 存储购物车
判断当前用户是否登陆,如果未登录采用 Cookie 存储,如果登录则采用 Redis 存储。登录后要进行 Cookie 购物车与 Redis 购物车的合并操作,并清除 Cookie 购物车。
- 首先修改 spring-security 配置文件
<!-- <http pattern="/cart/*.do" security="none"></http> --> <!-- 这种方式会导致不会经过登录认证操作,无法获取登录用户名 -->
<!-- entry-point-ref 入口点引用 -->
<http use-expressions="false" entry-point-ref="casProcessingFilterEntryPoint">
<intercept-url pattern="/cart/*.do" access="IS_AUTHENTICATED_ANONYMOUSLY"/>
<intercept-url pattern="/**" access="ROLE_USER"/>
<csrf disabled="true"/>
<!-- custom-filter为过滤器, position 表示将过滤器放在指定的位置上,before表示放在指定位置之前 ,after表示放在指定的位置之后 -->
<custom-filter ref="casAuthenticationFilter" position="CAS_FILTER" />
<custom-filter ref="requestSingleLogoutFilter" before="LOGOUT_FILTER"/>
<custom-filter ref="singleLogoutFilter" before="CAS_FILTER"/>
</http>之前配置的过滤购物车拦截的方式,会导致该逻辑不会经过spring security的生命周期。那么在 CartController 中就无法通过 SecurityContextHolder.getContext().getAuthentication().getName() 的方式来获取当前登录用户名。报空指针异常。
access="IS_AUTHENTICATED_ANONYMOUSLY" 用于设置资源可以在不登陆时可以访问。此 配 置 与 security="none" 的 区 别 在 于 当 用 户 未 登 陆 时 获 取 登 陆 人 账 号 的 值 为 anonymousUser ,而 security="none"的话,无论是否登陆都不能获取登录人账号的值。
- 服务层实现
@Override
public List<Cart> findCartListFromRedis(String username) {
// System.out.println("从redis中获取购物车数据");
List<Cart> cartList = (List<Cart>) redisTemplate.boundHashOps("cartList").get(username);
if(cartList == null) cartList = new ArrayList<>();
return cartList;
}
@Override
public void addCartListToRedis(String username, List<Cart> cartList) {
// System.out.println("向Redis中存储购物车数据");
redisTemplate.boundHashOps("cartList").put(username, cartList);
}- 控制层实现,通过判断用户是否登录来选择从cookie还是redis中读取数据
@RequestMapping("/findCartList")
public List<Cart> findCartList() {
// 获取当前登录用户名
String username = SecurityContextHolder.getContext().getAuthentication().getName();
// 从cookie中读取购物车数据
String cookieValue = util.CookieUtil.getCookieValue(request, "cartList", "UTF-8");
if (cookieValue == null || "".equals(cookieValue)) cookieValue = "[]";
List<Cart> cartList_cookie = JSON.parseArray(cookieValue, Cart.class);
if (username.equals("anonymousUser")) { // 如果未登录 从cookie中读取
// System.out.println("从cookie中读取");
return cartList_cookie;
}else { // 用户已登录 从redis中读取
List<Cart> cartList_redis = cartService.findCartListFromRedis(username);
if (cartList_cookie.size()>0) { // 本地购物车未合并
// System.out.println("合并本地和redis购物车数据");
// 合并本地和redis购物车数据
cartList_redis = cartService.mergeCartList(cartList_cookie, cartList_redis);
// 在存储到redis中
cartService.addCartListToRedis( username, cartList_redis);
// 清空本地缓存购物车
util.CookieUtil.deleteCookie(request, response, "cartList");
}
return cartList_redis;
}
}合并购物车
当用户登录时,需要将存储在cookie中的购物车数据添加到redis服务器中。同时清空本地cookie中的数据。已达到合并的效果。
@Override
public List<Cart> mergeCartList(List<Cart> cartList1, List<Cart> cartList2) {
if(cartList1==null && cartList2 == null) return new ArrayList<Cart>();
for(Cart cart : cartList2) {
for(TbOrderItem orderItem : cart.getOrderItemList()) {
cartList1 = addGoodsToCartList(cartList1, orderItem.getItemId(), orderItem.getNum());
}
}
return cartList1;
}循环一个 cartList 中的 orderItem 数据,然后逐个添加到另一个 cartList 中。然后控制层在 findCartList 中调用,因为用户登录之后,进入购物车页面时必定经过查询购物车数据的方法。在前面的控制层实现中已经给出代码实现。
跳板页的思想
用户添加购物车完成之后,如果需要购买那么必须先完成登录。该系统使用cas实现的单点登录。如果我们通过直接跳转到 http://localhost:9100/cas/login cas服务器地址来完成登录,这样会出现登录完成之后页面会跳转到cas服务器的登录成功的提示页面,而不是我们想要的购物车页面。
我们可以采用一种跳板页的方法来实现。点击登录然后跳转到跳板页 login.html。但是由于当前状态为未登录,该页面会被Spring Security拦截然后重定向到cas服务器的登录页,登录成功后会重新回到 login.html 页面。然后在 login.html 页面中执行 js 回跳到购物车页面即可。
<!-- login.html中添加跳转页面 -->
<script type="text/javascript">
location.href="cart.html";
</script>JS 跨域请求
通过 js 在不同的域之间进行数据传输或通信,比如用 ajax 向一个不同的域请求数据,或者通过 js 获取页面中不同域的框架中(iframe)的数据。只要协议、域名、端口有任何一个不同,都被当作是不同的域。这也是面试中经常会问到的一个问题。
商品详情页面点击提交订单,就会异步调用购物车模块的添加商品到购物车的逻辑。该过程就会用到跨域操作。如果不考虑跨域问题会出现如下问题。点击添加购物车没有响应。
跨域解决方案 CORS
CORS 是一个 W3C 标准,全称是"跨域资源共享"(Cross-origin resource sharing)。CORS 需要浏览器和服务器同时支持。除了 IE10 以前的浏览器之外其它浏览器都支持。(IE并未实现W3C标准)
CORS 可以解决跨越问题,允许浏览器向跨源服务器发出 XMLHttpRequest 请求。当存在跨域请求时,浏览器会自动添加附加的头信息,有时候会多一次附加请求,但是用户不会察觉。实现 CORS 通信的关键是服务器。只要服务器实现了 CORS 接口,就可以跨源通信。
请求过程:首先浏览器向服务器发送一个预请求,服务器返回一个 Preflight Response,如果服务器同意跨域请求,那么浏览器才能继续发送跨域请求。
// 服务器只需要配置 response 响应头信息即可
response.setHeader("Access-Control-Allow-Origin", "http://localhost:9105"); // 允许跨域请求
response.setHeader("Access-Control-Allow-Credentials", "true"); // 允许携带cookie (方法中如果会操作cookie的话,必须添加该配置)Access-Control-Allow-Origin 的配置表示服务器支持跨域请求的地址。此时也就是允许 http://localhost:9105 该地址的跨域请求。
另外 Spring 4.2 版本之后支持注解式跨域请求@CrossOrigin(origins="http://localhost:9105",allowCredentials="true") // spring 4.2版本以上支持注解的方式,allowCredentials="true"可以缺省
CORS 请求默认不发送 Cookie 和 HTTP 认证信息。如果要把 Cookie 发到服务器,一方面要服务器同意,指定 Access-Control-Allow-Credentials 字段。另一方面,开发者必须在 AJAX 请求中打开 withCredentials 属性。否则,即使服务器同意发送 Cookie,浏览器也不会发送(点击添加购物车,登录之后,cookie中的购物车数据不会合并到用户的购物车中)。或者,服务器要求设置 Cookie,浏览器也不会处理。
// 添加到购物车
$scope.addToCart=function(){
// alert('sku_id:'+ $scope.sku.id);
// 执行跨域请求
$http.get("http://localhost:9107/cart/addGoodsToCartList.do?itemId="+ $scope.sku.id +"&num="
+ $scope.num, {'withCredentials':true}).success(
function(response){
if (response.success) {
location.href="http://localhost:9107/cart.html"; // 跳转到购物车页面
}else{
alert(response.message);
}
}
);
}结算页信息显示
收件人的的选择
点击购物车进入结算页,首先会让用户选择收货地址以及收货人信息。而收获人信息是个用户进行关联的,所以将该模块放置在user模块中,在 AddressService 中新增一个通过用户ID查询收货人的信息的方法。然后在controller层,通过SpringSecurityu获取当前登录用户的ID,然后通过服务方法从数据库中取出用户的收获信息列表。前端以列表的信息显示出来。
// 查询用户的收货地址信息
$scope.findAddress=function(){
cartService.findAddress().success(
function(response){
$scope.addressList = response;
// 查找默认地址
for(var i=0;i<$scope.addressList.length;i++){
if($scope.addressList[i].isDefault=='1'){
$scope.address=$scope.addressList[i];break;
}
}
}
);
}
// 选择地址
$scope.selectAddress=function(address){
$scope.address = address;
}
// 该地址是否被选
$scope.isSelectedAddress=function(address){
return ($scope.address == address);
}支付方式
通过 $scope.order={paymentType:'1'}; 中的paymentType字段来绑定页面的支付方式,1表示微信支付,2表示货到付款 。
商品清单与金额显示
cartController.js 中之前实现了获取购物车的信息。此处的商品清单也可以通过该方法,从redis中获取用户购买的信息。然后通过 ng-repeat 循环遍历显示即可。合计金额也可以通过 sum 方法计算求和。
分布式 ID 生成器
snowflake 算法。由 Twitter 推出的一款开源的分布式自增ID生成算法。
结构:
0 - 0000000000 0000000000 0000000000 0000000000 0 - 00000 - 00000 - 000000000000
第一位为未使用,接下来的41位为毫秒级时间(41位的长度可以使用69年),然后是5位datacenterId和5位workerId(10位的长度最多支持部署1024个节点),最后12位是毫秒内的计数(12位的计数顺序号支持每个节点每毫秒产生4096个ID序号)
一共加起来刚好64位,为一个Long型。(转换成字符串后长度最多19)
snowflake生成的ID整体上按照时间自增排序,并且整个分布式系统内不会产生ID碰撞(由datacenter和workerId作区分不同机器之间也就不会出现重复ID的情况),并且效率较高。经测试snowflake每秒能够产生26万个ID。
注:UUID 存在的问题:128位(16字节)较占内存,无法生成有序的ID。
IdWorker 生成器工具类位于 common 工程。创建一个 IdWorker 对象,然后调用 nextId() 即可生成一个全局唯一ID。在Spring工程中,我们可以通过配置的方式来构造bean。
<bean id="idWorker" class="util.IdWorker">
<!-- 进程 ID -->
<constructor-arg index="0" value="0"></constructor-arg>
<!-- 数据中心 ID -->
<constructor-arg index="1" value="0"></constructor-arg>
</bean>保存订单
取出 redis 中存储的用户购物车数据,生成对应的订单项,采用 snowflake 算法生成唯一的订单ID,然后根据商品的SKU信息生成多个订单项。
二维码生成插件 qrious qrious 是一款基于 HTML5 Canvas 的纯 JS 二维码生成插件。通过 qrious.js 可以快速生成各种二维码,你可以控制二维码的尺寸颜色,还可以将生成的二维码进行 Base64 编码。
微信扫码支付
商户系统按微信支付协议生成支付二维码,用户再用微信“扫一扫”完成支付的模式。该模式适用于 PC 网站支付、实体店单品或订单支付、媒体广告支付等场景。
具体开发文档:https://pay.weixin.qq.com/wiki/doc/api/index.html 主要了解Native支付的统一下单和查询订单API。
实现原理:首先引入微信支付的sdk依赖。使用 HttpClient 工具类,来模拟浏览器行为,去调用微信支付的 api 接口,向该地址提交相应的数据,然后获取结果。
由于使用微信扫码支付的申请条件比较苛刻,所以这一模块无法完成测试。提供的公共号、商户号、秘钥信息等,无法完成签名。返回的信息为签名错误。
服务接口层
pay-interface
服务实现层
pay-service 依赖 pay 接口,和common模块(将 httpclient 工具类放在公共层、以及微信支付的基本信息配置),spring dubbo 依赖以及微信的 SDK。实现生成二维码和查询订单状态的方法。
控制层
cart-web 模块依赖支付服务,生成订单之后进入支付页面,调用服务层的生成二维码的方法,得到返回的 code_url ,然后让前端 qrious 插件根据该 url 去生成二维码即可。然后后端定时调用查询订单状态的方法,每隔3秒钟去查询订单支付状态,同时每隔五分钟之后就提示二维码支付超时,然后前端就收到超时信息之后,可以将其显示出来或者重新生成二维码信息。同时如果前端页面被关闭,后端会在五分钟之后返回超时的提示。
支付日志
(1)在用户下订单时,判断如果为微信支付,就向支付日志表添加一条记录,信息包括支付总金额、订单 ID(一个支付日志对应多个订单)、用户 ID 、下单时间等信息,支付状态为 0(未支付) (2)生成的支付日志对象放入 redis 中,以用户 ID 作为 key,这样在生成支付二维码时就可以从 redis 中提取支付日志对象中的金额和订单号。 (3)当用户支付成功后,修改支付日志的支付状态为 1(已支付),并记录微信传递给我们的交易流水号。根据订单 ID(多个)修改订单的状态为 2(已付款)。同时删除缓存中的支付日志。
秒杀的特点就是在特定的时间对限量的商品进行抢购。在该时间可能存在很高的并发请求,而造成对后端数据库的巨大压力。此时可以采用缓存机制,来避免用户直接与数据库的交互。秒杀时说有数据都存储在缓存中,只有当商品抢购完或者时间到期时才将缓存中的数据一次性存入数据库中。
模块需求:
(1)商家提交秒杀商品申请,录入秒杀商品数据,主要包括:商品标题、原价、秒杀价、商品图片、介绍等信息
(2)运营商审核秒杀申请
(3)秒杀频道首页列出正在秒杀的商品,用户点击秒杀商品图片跳转到秒杀商品详细页。
(4)商品详细页显示秒杀商品信息,点击立即抢购实现秒杀下单,下单时扣减库存。当库存为 0 或不在活动期范围内时无法秒杀。
(5)秒杀下单成功,直接跳转到支付页面(微信扫码),支付成功,跳转到成功页,填写收货地址、电话、收件人等信息,完成订单。
(6)当用户秒杀下单 5 分钟内未支付,取消预订单,调用微信支付的关闭订单接口,恢复库存。
秒杀频道首页
seckill 模块服务层
/**
* 返回当前正在参与秒杀的商品
*/
@Override
public List<TbSeckillGoods> findList() {
List<TbSeckillGoods> seckillGoodsList = redisTemplate.boundHashOps("seckillGoods").values();
if (seckillGoodsList==null || seckillGoodsList.size()==0) { // 当前缓存还没有数据
TbSeckillGoodsExample example = new TbSeckillGoodsExample();
Criteria criteria = example.createCriteria();
criteria.andStatusEqualTo("1"); // 已审核状态
criteria.andStockCountGreaterThan(0); // 库存量>0
criteria.andStartTimeLessThanOrEqualTo(new Date()); // 当前时间大于等于开始时间
criteria.andEndTimeGreaterThanOrEqualTo(new Date());// 当前时间晚于结束时间
seckillGoodsList = seckillGoodsMapper.selectByExample(example ); // 从数据库中读取数据
for(TbSeckillGoods seckillGood : seckillGoodsList) { // 将当前时间的秒杀商品按商品ID存入缓存
redisTemplate.boundHashOps("seckillGoods").put(seckillGood.getId(), seckillGood);
}
System.out.println("从数据库中读取秒杀商品并放入缓存");
}else {
System.out.println("从缓存中读取秒杀商品数据");
}
return seckillGoodsList;
}
@Override
public TbSeckillGoods findOneFromRedis(Long id) {
return (TbSeckillGoods) redisTemplate.boundHashOps("seckillGoods").get(id);
}控制层
/**
* 从数据库中查询当前秒杀的商品
* @return
*/
@RequestMapping("/findList")
public List<TbSeckillGoods> findList() {
return seckillGoodsService.findList();
}
@RequestMapping("/findOneFromRedis")
public TbSeckillGoods findOneFromRedis(Long id) {
return seckillGoodsService.findOneFromRedis(id);
}前端服务层
app.service('seckillGoodsService',function($http){
//读取列表数据绑定到表单中
this.findList=function(){
return $http.get('seckillGoods/findList.do');
}
this.findOne=function(id){
return $http.get('seckillGoods/findOneFromRedis.do?id='+id);
}
this.submitOrder=function(seckillId){
return $http.get('seckillOrder/submitOrder.do?seckillId='+seckillId);
}
});前端控制层
app.controller('seckillGoodsController', function($scope, $location, $interval, seckillGoodsService){
// 读取列表数据绑定到表单中
$scope.findList=function(){
seckillGoodsService.findList().success(
function(response){
$scope.list=response;
}
);
}
//查询实体 点击秒杀首页商品的详情页时,跳转到详情页,传递商品的ID信息
$scope.findOne=function(){
seckillGoodsService.findOne($location.search()['id']).success(
function(response){
$scope.entity = response;
totalSecond = Math.floor((new Date($scope.entity.endTime).getTime() - (new Date().getTime()))/1000);
time = $interval(function(){ // 倒计时的实现
if (totalSecond>0) {
$scope.timeString = convertSecondToTime(totalSecond);
--totalSecond;
}else{
alert("秒杀已结束");
$interval.cancel(time);
}
}, 1000);
}
);
}
convertSecondToTime=function(totalSecond){
var sec = totalSecond%60;
totalSecond = Math.floor(totalSecond/60);
var min = totalSecond%60;
totalSecond = Math.floor(totalSecond/60);
var hour = totalSecond%24;
totalSecond = Math.floor(totalSecond/24);
var day = totalSecond;
return day==0? hour+":"+min+":"+sec : day+"天 "+hour+":"+min+":"+sec;
}
$scope.submitOrder=function(){
seckillGoodsService.submitOrder($scope.entity.id).success(
function(response){
if (response.success) {
alert("抢购成功,请在五分钟内付款");
location.href="pay.html";
}else{
alert(response.message);
}
}
);
}
});秒杀倒计时效果
$interval 服务用来间歇性处理某事务
格式:$interval(执行的函数,间隔的毫秒数,运行次数);
time = $interval(function(){ // 倒计时的实现
if (totalSecond>0) {
$scope.timeString = convertSecondToTime(totalSecond);// 格式转换
--totalSecond;
}else{
alert("秒杀已结束");
$interval.cancel(time); // 退出定时任务
}
}, 1000);秒杀提交订单
服务层逻辑
@Override
public void submitOrder(Long seckillId, String userId) {
// 从缓存中查询商品
TbSeckillGoods seckillGoods = (TbSeckillGoods) redisTemplate.boundHashOps("seckillGoods").get(seckillId);
// 判断商品状态
if (seckillGoods == null) {
throw new RuntimeException("商品不存在");
}else if(seckillGoods.getStockCount()<=0){
throw new RuntimeException("商品已抢购一空");
}
// 商品库存 -1
seckillGoods.setStockCount(seckillGoods.getStockCount()-1);
if (seckillGoods.getStockCount() == 0) { // 商品被抢空
redisTemplate.boundHashOps("seckillGoods").delete(seckillId); // 删除缓存中该商品
seckillGoodsMapper.updateByPrimaryKey(seckillGoods); // 同步到数据库
}else {
// 更新秒杀商品中数据
redisTemplate.boundHashOps("seckillGoods").put(seckillId, seckillGoods);
}
// 生成订单信息
TbSeckillOrder order = new TbSeckillOrder();
order.setId(idWorker.nextId()); // 生成订单ID
order.setSeckillId(seckillId); // 秒杀商品ID
order.setMoney(seckillGoods.getCostPrice()); // 秒杀价格
order.setUserId(userId);
order.setSellerId(seckillGoods.getSellerId()); // 商家ID
order.setCreateTime(new Date());
order.setStatus("0"); // 状态
redisTemplate.boundHashOps("seckillOrder").put(userId, order);
}控制层
@RequestMapping("/submitOrder")
public Result submitOrder(Long seckillId) {
String username = SecurityContextHolder.getContext().getAuthentication().getName();
if ("anonymousUser".equals(username)) { // 如果未登录
return new Result(false, "用户未登录");
}
try {
seckillOrderService.submitOrder(seckillId, username);
return new Result(true, "订单提交成功");
} catch (RuntimeException e) {
e.printStackTrace();
return new Result(false, e.getMessage());
} catch (Exception e) {
e.printStackTrace();
return new Result(false, "订单提交失败");
}
}秒杀支付
用户抢购成功之后跳转到支付页面。调用微信二维码支付接口,生成二维码,用户支付完成之后跳转到收获人地址信息填写页面。超过五分钟未付款就取消订单,恢复redis中的库存量,同时关闭微信订单,防止用户支付无效的订单。由于微信支付接口无法测试该功能预留。
实现上述秒杀功能之后,我想你一定也有和我一样的疑惑,那就是秒杀的商品什么时候从数据库中实时更新了,先前实现的逻辑只有在第一次访问秒杀系统时,即判断 redis 缓存中是否存在对应的商品信息,如果不存在就从数据库中取出数据同时还需要全量添加到缓存中。但是之后就一直从缓存中取数据。
此时就可以使用 SpringTask 任务调度来实现计划任务,即在某个时间点执行某件事。实现每秒钟去增量更新redis数据库中的秒杀商品信息。
Cron 表达式格式 Cron 表达式是一个字符串,字符串以 5 或 6 个空格隔开,分为 6 或 7 个域,每一个域代表一个含义,Cron 有如下两种语法格式: (1)Seconds Minutes Hours DayofMonth Month DayofWeek Year (2)Seconds Minutes Hours DayofMonth Month DayofWeek
每一个域可出现的字符如下:
- Seconds: 可出现", - * /"四个字符,有效范围为 0-59 的整数
- Minutes: 可出现", - * /"四个字符,有效范围为 0-59 的整数
- Hours: 可出现", - * /"四个字符,有效范围为 0-23 的整数
- DayofMonth: 可出现", - * / ? L W C"八个字符,有效范围为 1-31 的整数
- Month: 可出现", - * /"四个字符,有效范围为 1-12 的整数或 JAN-DEC(英文单词的前三个字母)
- DayofWeek: 可出现", - * / ? L C #"四个字符,有效范围为 1-7 的整数或 SUN-SAT 两个范围。1表示星期天,2 表示星期一, 依次类推
- Year: 可出现", - * /"四个字符,有效范围为 1970-2099 年
每一个域都使用数字,但还可以出现如下特殊字符,它们的含义是:
(1)*: 表示匹配该域的任意值,假如在 Minutes 域使用, 即表示每分钟都会触发事件。
(2)?: 只能用在 DayofMonth 和 DayofWeek 两个域。它也匹配域的任意值,但实际不会。因为DayofMonth 和 DayofWeek 会相互影响。例如想在每月的 20 日触发调度,不管 20 日到底是星期几,则只能使用如下写法: 13 13 15 20 * ?, 其中最后一位只能用?,而不能使用,如果使用表示不管星期几都会触发,实际上并不是这样。
(3)-: 表示范围,例如在 Minutes 域使用 5-20,表示从 5 分到 20 分钟每分钟触发一次
(4)/: 表示起始时间开始触发,然后每隔固定时间触发一次,例如在 Minutes 域使用 5/20,则意味着 5 分钟触发一次,而 25,45 等分别触发一次.
(5),: 表示列出枚举值值。例如:在 Minutes 域使用 5,20,则意味着在 5 和 20 分每分钟触发一次。
(6)L: 表示最后,只能出现在 DayofWeek 和 DayofMonth 域,如果在 DayofWeek 域使用 5L,意味着在最后的一个星期四触发。
(7)W: 表示有效工作日(周一到周五),只能出现在 DayofMonth 域,系统将在离指定日期的最近的有效工作日触发事件。例如:在 DayofMonth 使用 5W,如果 5 号是星期六,则将在最近的工作日:星期五,即 4 号触发。如果 5 号是星期天,则在 6 号(周一)触发;如果 5 号在星期一到星期五中的一天,则就在 5 号触发。另外一点,W 的最近寻找不会跨过月份。
(8)LW: 这两个字符可以连用,表示在某个月最后一个工作日,即最后一个星期五。
(9)#: 用于确定每个月第几个星期几,只能出现在 DayofMonth 域。例如在 4#2,表示某月的第二个星期三。
Cron 表达式例子:
0 0 10,14,16 * * ? 每天上午 10 点,下午 2 点,4 点
0 0/30 9-17 * * ? 每天上午九点到下午五点每半小时
0 0 12 ? * WED 表示每个星期三中午 12 点
"0 0 12 * * ?" 每天中午 12 点触发
"0 15 10 ? * *" 每天上午 10:15 触发
"0 15 10 * * ?" 每天上午 10:15 触发
"0 15 10 * * ? *" 每天上午 10:15 触发
"0 15 10 * * ? 2005" 2005 年的每天上午 10:15 触发
"0 * 14 * * ?" 在每天下午 2 点到下午 2:59 期间的每 1 分钟触发
"0 0/5 14 * * ?" 在每天下午 2 点到下午 2:55 期间的每 5 分钟触发
"0 0/5 14,18 * * ?" 在每天下午 2 点到 2:55 期间和下午 6 点到 6:55 期间的每 5 分钟触发
"0 0-5 14 * * ?" 在每天下午 2 点到下午 2:05 期间的每 1 分钟触发
"0 10,44 14 ? 3 WED" 每年三月的星期三的下午 2:10 和 2:44 触发
"0 15 10 ? * MON-FRI" 周一至周五的上午 10:15 触发
"0 15 10 15 * ?" 每月 15 日上午 10:15 触发
"0 15 10 L * ?" 每月最后一日的上午 10:15 触发
"0 15 10 ? * 6L" 每月的最后一个星期五上午 10:15 触发
"0 15 10 ? * 6L 2002-2005" 2002 年至 2005 年的每月的最后一个星期五上午 10:15 触发
"0 15 10 ? * 6#3" 每月的第三个星期五上午 10:15 触发
| 表达式 | 表示含义 |
|---|---|
| 0 0 10,14,16 * * ? | 每天上午 10 点,下午 2 点,4 点 |
| 0 0/30 9-17 * * ? | 每天上午九点到下午五点每半小时 |
| 0 0 12 ? * WED | 每个星期三中午 12 点 |
| 0 0 12 * * ? | 每天中午 12 点触发 |
| 0 15 10 ? * * | 每天上午 10:15 触发 |
| 0 15 10 * * ? | 每天上午 10:15 触发 |
| 0 15 10 * * ? * | 每天上午 10:15 触发 |
| 0 15 10 * * ? 2018 | 2018 年的每天上午 10:15 触发 |
| 0 * 14 * * ? | 在每天下午 2 点到下午 2:59 期间的每 1 分钟触发 |
| 0 0/5 14 * * ? | 在每天下午 2 点到下午 2:55 期间的每 5 分钟触发 |
| 0 0-5 14 * * ? | 每天下午 2 点到下午 2:05 期间的每 1 分钟触发 |
| 0 10,44 14 ? 3 WED | 每年三月的星期三的下午 2:10 和 2:44 触发 |
| 0 15 10 ? * MON-FRI | 周一至周五的上午 10:15 触发 |
| 0 15 10 ? * 6L | 每月的最后一个星期五上午 10:15 触发 |
| 0 15 10 ? * 6L 2002-2005 | 2002 年至 2005 年的每月的最后一个星期五上午 10:15 触发 |
| 0 15 10 ? * 6#3 | 每月的第三个星期五上午 10:15 触发 |
秒杀商品的增量更新和过期删除
@Component
public class SeckillTask {
@Autowired
private RedisTemplate redisTemplate;
@Autowired
private TbSeckillGoodsMapper seckillGoodsMapper;
/**
* 定时刷新秒杀商品
*/
@Scheduled(cron="0/5 * * * * ?") // 每分钟执行一次
public void refreshSeckillGoods(){
System.out.println("执行了增量更新任务调度"+new Date());
// 查询 Redis 中所有商品键集合
List ids = new ArrayList<>(redisTemplate.boundHashOps("seckillGoods").keys()); // 第一次执行为 []
// 查询正在秒杀的商品列表
TbSeckillGoodsExample example = new TbSeckillGoodsExample();
Criteria criteria = example.createCriteria();
criteria.andStatusEqualTo("1"); // 已审核状态
criteria.andStockCountGreaterThan(0); // 库存量>0
criteria.andStartTimeLessThanOrEqualTo(new Date()); // 当前时间大于等于开始时间
criteria.andEndTimeGreaterThanOrEqualTo(new Date());// 当前时间晚于结束时间
if(ids.size()>0)
criteria.andIdNotIn(ids); // 排除已存在的商品,实现增量更新
List<TbSeckillGoods> seckillGoodsList = seckillGoodsMapper.selectByExample(example ); // 从数据库中读取数据
// 装入缓存
for( TbSeckillGoods seckill:seckillGoodsList ){
redisTemplate.boundHashOps("seckillGoods").put(seckill.getId(), seckill);
System.out.println("添加商品:" + seckill.getId());
}
System.out.println("将"+seckillGoodsList.size()+"条商品装入缓存");
}
// 每秒钟在缓存中查询已过期的商品,发现过期的秒杀商品后同步到数据库,并在缓存中移除该秒杀商品
@Scheduled(cron="* * * * * ?") // 每秒钟执行一次
public void removeSeckillGoods() {
System.out.println("执行了删除过期商品任务调度"+new Date());
List<TbSeckillGoods> seckillGoodsList = redisTemplate.boundHashOps("seckillGoods").values();
for( TbSeckillGoods seckillGood : seckillGoodsList) {
if (seckillGood.getEndTime().getTime()<new Date().getTime()) {
redisTemplate.boundHashOps("seckillGoods").delete(seckillGood.getId()); // 删除缓存数据
seckillGoodsMapper.updateByPrimaryKey(seckillGood); // 向数据库保存记录
System.out.println("移除秒杀商品:"+seckillGood.getId());
}
}
}
}Maven Profile
项目开发中会用到很多配置文件,比如 mysql 、redis 以及其他很多的 properties 配置文件。而在我们开发和部署的时候(开发环境、测试环境、生产环境),这些配置文件往往是不同的。而如果需要我们每次去切换也是非常麻烦的。而 Maven Profile 就可以帮我们完成动态选择配置文件的工作。profile 可以让我们定义一系列的配置信息,然后指定其激活条件。
- 动态切换模块的端口号,比如对于 page-web 模块来说,默认端口号为9105.
<!-- 配置默认属性 方式一
<properties>
<port>9105</port>
</properties>
-->
<profiles>
<profile>
<id>dev</id>
<properties>
<port>9105</port>
</properties>
<activation> <!-- 配置默认属性 方式二 -->
<activeByDefault>true</activeByDefault>
</activation>
</profile>
<profile>
<id>pro</id>
<properties>
<port>9205</port>
</properties>
</profile>
</profiles>
<build>
<plugins>
<plugin>
<groupId>org.apache.tomcat.maven</groupId>
<artifactId>tomcat7-maven-plugin</artifactId>
<version>2.2</version>
<configuration>
<!-- 指定端口 -->
<port>${port}</port>
<!-- 请求路径 -->
<path>/</path>
</configuration>
</plugin>
</plugins>
</build>当我们执行命令 tomcat7:run -P pro 发现以 9205 端口启动,执行命令 tomcat7:run -P dev 发现以 9105 端口启动。 (-P 后边为 profile 的 id)而当我们不指定环境时,默认环境为开发环境。上述代码提供两种配置方式。
- 切换数据库连接配置
对于数据库操作模块(dao)在 src/main/resources/properties/目录下有 db.properties 配置文件。其中配置了连接数据库的相关配置,数据库驱动、url、用户名、密码等。
(1) 首先编写不同的环境配置文件,在资源文件夹目录下创建 filter 文件夹,添加多个不同环境下配置文件。
# 生成环境下的配置文件
env.jdbc.driver=com.mysql.jdbc.Driver
env.jdbc.url=jdbc:mysql://localhost:3306/pinyougoudb?characterEncoding=utf-8
env.jdbc.username=root
env.jdbc.password=123456将原来的 db.properties 文件修改为:
jdbc.driver=${env.jdbc.driver}
jdbc.url=${env.jdbc.url}
jdbc.username=${env.jdbc.username}
jdbc.password=${env.jdbc.password}(2) 定义 Profile
<profiles>
<profile>
<id>dev</id>
<properties>
<env>dev</env>
</properties>
<activation>
<activeByDefault>true</activeByDefault>
</activation>
</profile>
<profile>
<id>pro</id>
<properties>
<env>pro</env>
</properties>
</profile>
</profiles>(3) 资源过滤与变量替换
<build>
<!-- 资源过滤与变量替换 -->
<filters>
<filter>src/main/resources/filters/db_${env}.properties</filter>
</filters>
<resources>
<resource>
<directory>src/main/resources</directory>
<filtering>true</filtering>
</resource>
</resources>
</build>maven filter可利用指定的xxx.properties文件中对应的key=value对资源文件中的${key}进行替换,最终把你的资源文件中的 username=${key}替换成 username=value
即可完成对配置文件的动态选择。打包:对 dao 工程执行 package -P pro打包为jar文件。解压 jar 文件后可以发现 db.properties 配置中被替换为生产环境。测试运行时通过 install -P pro 执令,将生产环境添加到本地仓库中。
切换注册中心连接配置
在每一个项目中都存在对注册中心的配置,而这个 IP 地址信息在生产环境下肯定是需要进行修改的。
- 首先集中配置注册中心地址。
(1) 在 common 模块下的src/main/resources/properties目录中创建 dubbox.properties 配置文件并配置全局dubbox的地址:address=192.168.25.130:2181
(2) Spring 目录下创建 spring 配置文件 applicationContext-dubbox.xml 配置如下:
<?xml version="1.0" encoding="UTF-8"?>
<beans xmlns="http://www.springframework.org/schema/beans"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xmlns:p="http://www.springframework.org/schema/p"
xmlns:context="http://www.springframework.org/schema/context"
xmlns:dubbo="http://code.alibabatech.com/schema/dubbo" xmlns:mvc="http://www.springframework.org/schema/mvc"
xsi:schemaLocation="http://www.springframework.org/schema/beans http://www.springframework.org/schema/beans/spring-beans.xsd
http://www.springframework.org/schema/mvc http://www.springframework.org/schema/mvc/spring-mvc.xsd
http://code.alibabatech.com/schema/dubbo http://code.alibabatech.com/schema/dubbo/dubbo.xsd
http://www.springframework.org/schema/context http://www.springframework.org/schema/context/spring-context.xsd">
<context:property-placeholder location="classpath*:properties/*.properties" />
<dubbo:registry protocol="zookeeper" address="${address}"/>
</beans>(3) 所有的服务工程与 web 工程都要依赖 pinyougou-common 。 并删除每个工程中关于注册中心地址的配置 (4) 安装 pinyougou-common 到本地仓库,然后测试运行。
- MavenProfile 配置
(1) 创建 filters 文件夹,建立多个不同环境的 dubbox 配置。env.address=192.168.25.130:2181
(2) 修改 properties/dubbox.properties 文件。address=${env.address}
(3) 定义 profile
<profiles>
<profile>
<id>dev</id>
<properties>
<env>dev</env>
</properties>
<activation>
<activeByDefault>true</activeByDefault>
</activation>
</profile>
<profile>
<id>pro</id>
<properties>
<env>pro</env>
</properties>
</profile>
</profiles>(4) 资源配置与变量替换
<build>
<filters>
<filter>src/main/resources/filters/dubbox_${env}.properties</filter>
</filters>
<resources>
<resource>
<directory>src/main/resources</directory>
<filtering>true</filtering>
</resource>
</resources>
</build>遇到的问题:
所有的 web 工程在依赖 common 工程的情况下,如果不配置 <dubbo:registry > 的话会报错。
原因是 web 工程中无法依赖的 common 公共模块中的 applicationContext-dubbox.xml文件中的配置。存在两种问题: 1.该web工程为依赖 common 模块 2. 加载 spring 容器时,未添加 common 中applicationContext-dubbox.xml文件地址
<context-param>
<param-name>contextConfigLocation</param-name>
<param-value>classpath:spring/spring-security.xml,classpath*:spring/applicationContext*.xml</param-value>
</context-param>
<listener>
<listener-class>org.springframework.web.context.ContextLoaderListener</listener-class>
</listener>param-value 标签中如果有配置,则用逗号分隔即可。classpath*:不仅包含class路径,还包括jar文件中(class路径)进行查找。有时候会用模糊匹配的方式去配置多个配置文件。 但是如果配置文件是在jar包里,模糊匹配就找不到了。可以用逗号隔开的方式配置多个配置文件。
**MongoDB **
一个跨平台的,面向文档的数据库。它介于关系数据库和非关系数据库之间,是非关系数据库当中功能最丰富,最像关系数据库的产品。它支持的数据结构非常松散,是类似 JSON 的 BSON 格式,因此可以存储比较复杂的数据类型。
特性:
- 面向集合存储、易于存储对象类型的数据
- 模式自由,对象的字段没有固定的限制
- 支持动态查询,查询语言强大
- 支持完全索引
- 支持复制和故障恢复
- 使用高效的二进制数据存储,可存放图片、视频等大文件
- 支持多种语言
使用场景: 数据量较大时,数据价值不高时。比如评论系统、商品足迹信息。
其他业务功能分析
用户中心
订单中心:订单信息、商品收获、商品评价、物流信息跟踪
秒杀订单中心:同用户中心
我的收藏:商品收藏信息的管理
我的足迹:足迹信息管理
个人信息管理:信息完善、收货地址信息、密码管理、手机认证
商家后台
订单管理: 查询、发货、退货
秒杀订单管理:秒杀中的商品在redis中、已完成的秒杀商品在数据库中
运营商后台
订单管理:所有商家订单的信息管理
秒杀订单管理:查询
评价系统
数据访问层:对 mongodb 数据库的操作
服务层:逻辑实现
评价系统在工程中的调用: (1)在商品详细页显示该商品的所有评论信息(CORS 跨域) (2)用户中心 web 工程引用评价服务 可以对已收货的订单追加评价。 (3)商家后台 web 工程引用评价服务 可以查看订单的评价 (4)运营商后台 web 工程引用评价服务 可以查看订单的评价 (5)任务服务 pinyougou-task-service 引用评价服务和搜索服务,统计每个商品的评价更新到 solr 索引库中。
商家首页
商家商品信息的显示、用户也可以直接进入商家的首页选择该商家的商品
资金结算
平台作为第三方,用户付款到平台,然后平台定时给商家结算。存在佣金的问题,平台可能按照不同商品类型收取不同比例的销售提成。




