You cannot select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
wayn 1ddd86ed34 feat(商城): 升级pom依赖 2 years ago
nginx-conf feat(商城): 升级pom依赖 2 years ago
waynboot-admin-api feat(商城): 升级pom依赖 2 years ago
waynboot-common feat(商城): 升级pom依赖 2 years ago
waynboot-data feat(商城): 升级pom依赖 2 years ago
waynboot-generator feat(商城): 升级pom依赖 2 years ago
waynboot-job feat(商城): 添加xxl-job使用 3 years ago
waynboot-message-consumer feat(商城): 升级pom依赖 2 years ago
waynboot-message-core feat(商城): 优化rabbitMQ配置 3 years ago
waynboot-mobile-api feat(商城): 升级pom依赖 2 years ago
waynboot-monitor feat(商城): 升级pom依赖 2 years ago
.gitignore feat(商城): 首页 4 years ago
Dockerfile feat(商城): 升级pom依赖 2 years ago
LICENSE feat(商城): 升级pom依赖 2 years ago
docker-compose.yml feat(商城): 代码优化 3 years ago
docker_install.sh refactor(商城): 技术架构 3 years ago
pom.xml feat(商城): 升级pom依赖 2 years ago
readme.md feat(商城): 升级pom依赖 2 years ago
wayn_shop_2021年10月19日.sql fix(商城): 修复最新sql脚本错误 3 years ago

readme.md

waynboot-mall项目

waynboot-mall是一套全部开源的微商城项目包含一个运营后台、h5商城和后台接口。 实现了一个商城所需的首页展示、商品分类、商品详情、sku详情、商品搜索、加入购物车、结算下单、订单状态流转、商品评论等一系列功能。 技术上基于Springboot2.0整合了Redis、RabbitMQ、ElasticSearch等常用中间件 贴近生产环境实际经验开发而来不断完善、优化、改进中。

h5商城项目 运营后台项目
后台接口项目

技术特点

  1. 商城接口代码清晰、注释完善、模块拆分合理
  2. 使用Spring-Security进行访问权限控制
  3. 使用jwt进行接口授权验证
  4. ORM层使用Mybatis Plus提升开发效率
  5. 添加全局异常处理器,统一异常处理
  6. 使用springboot admin进行服务监控
  7. 集成七牛云存储配置,上传文件至七牛
  8. 集成常用邮箱配置,方便发送邮件
  9. 商城前台使用hikari连接池提升性能后台使用druid连接池进行sql监控
  10. 使用knife4j增强swagger管理接口文档
  11. 添加策略模式使用示例,优化首页金刚区跳转逻辑
  12. 拆分出通用的数据访问模块统一redis & elastic配置与访问
  13. 使用elasticsearch-rest-high-level-client客户端对elasticsearch进行操作
  14. 支持商品数据同步elasticsearch操作以及elasticsearch商品搜索
  15. RabbitMQ生产者发送消息采用异步confirm模式消费者消费消息时需手动确认
  16. 下单处理过程引入rabbitMQ异步生成订单记录提高系统下单处理能力
  17. 引入google jib加速和简化构建Docker应用镜像
  18. ...

问题整理

1. 库存扣减操作是在下单操作扣减还是在支付成功时扣减ps扣减库存使用乐观锁机制 where goods_num - num >= 0

  1. 下单时扣减,这个方案属于实时扣减,当有大量下单请求时,由于订单数小于请求数,会发生下单失败,但是无法防止短时间大量恶意请求占用库存, 造成普通用户无法下单
  2. 支付成功扣减,这个方案可以预防恶意请求占用库存,但是会存在多个请求同时下单后,在支付回调中扣减库存失败,导致订单还是下单失败并且还要退还订单金额(这种请求就是订单数超过了库存数,无法发货,影响用户体验)
  3. 还是下单时扣减但是对于未支付订单设置一个超时过期机制比如下单时库存减一生成订单后对于未在15分钟内完成支付的订单 自动取消超期未支付订单并将库存加一,该方案基本满足了大部分使用场景
  4. 针对大流量下单场景比如一分钟内五十万次下单请求可以通过设置虚拟库存的方式减少下单接口对数据库的访问。具体来说就是把商品库存缓存到redis中 下单时配合lua脚本原子的get和decr商品库存数量这一步就拦截了大部分请求执行成功后在扣减实际库存

2. 首页商品展示接口利用多线程技术进行查询优化将多个sql语句的排队查询变成异步查询接口时长只跟查询时长最大的sql查询挂钩

# 使用CompletableFuture异步查询
List<CompletableFuture<Void>> list = new ArrayList<>();
CompletableFuture<Void> f1 = CompletableFuture.supplyAsync(() -> iBannerService.list(Wrappers.lambdaQuery(Banner.class).eq(Banner::getStatus, 0).orderByAsc(Banner::getSort)), homeThreadPoolTaskExecutor).thenAccept(data -> {
    String key = "bannerList";
    redisCache.setCacheMapValue(SHOP_HOME_INDEX_HASH, key, data);
    success.add(key, data);
});
CompletableFuture<Void> f2 = CompletableFuture.supplyAsync(() -> iDiamondService.list(Wrappers.lambdaQuery(Diamond.class).orderByAsc(Diamond::getSort).last("limit 10")), homeThreadPoolTaskExecutor).thenAccept(data -> {
    String key = "categoryList";
    redisCache.setCacheMapValue(SHOP_HOME_INDEX_HASH, key, data);
    success.add(key, data);
});
list.add(f1);
list.add(f2);
# 主线程等待子线程执行完毕
CompletableFuture.allOf(list.toArray(new CompletableFuture[0])).join();

3. ElasticSearch搜索查询,查询包含搜索关键字并且是上架中的商品,在根据指定字段进行排序,最后分页返回

SearchSourceBuilder searchSourceBuilder = new SearchSourceBuilder();
BoolQueryBuilder boolQueryBuilder = QueryBuilders.boolQuery();
MatchQueryBuilder matchFiler = QueryBuilders.matchQuery("isOnSale", true);
MatchQueryBuilder matchQuery = QueryBuilders.matchQuery("name", keyword);
MatchPhraseQueryBuilder matchPhraseQueryBuilder = QueryBuilders.matchPhraseQuery("keyword", keyword);
boolQueryBuilder.filter(matchFiler).should(matchQuery).should(matchPhraseQueryBuilder).minimumShouldMatch(1);
searchSourceBuilder.timeout(new TimeValue(10, TimeUnit.SECONDS));
// 按是否新品排序
if (isNew) { 
    searchSourceBuilder.sort(new FieldSortBuilder("isNew").order(SortOrder.DESC));
}
// 按是否热品排序
if (isHot) {
    searchSourceBuilder.sort(new FieldSortBuilder("isHot").order(SortOrder.DESC));
}
// 按价格高低排序
if (isPrice) {
    searchSourceBuilder.sort(new FieldSortBuilder("retailPrice").order("asc".equals(orderBy) ? SortOrder.ASC : SortOrder.DESC));
}
// 按销量排序
if (isSales) {
    searchSourceBuilder.sort(new FieldSortBuilder("sales").order(SortOrder.DESC));
}
// 筛选新品
if (filterNew) {
    MatchQueryBuilder filterQuery = QueryBuilders.matchQuery("isNew", true);
    boolQueryBuilder.filter(filterQuery);
}
// 筛选热品
if (filterHot) {
    MatchQueryBuilder filterQuery = QueryBuilders.matchQuery("isHot", true);
    boolQueryBuilder.filter(filterQuery);
}

searchSourceBuilder.query(boolQueryBuilder);
searchSourceBuilder.from((int) (page.getCurrent() - 1) * (int) page.getSize());
searchSourceBuilder.size((int) page.getSize());
List<JSONObject> list = elasticDocument.search("goods", searchSourceBuilder, JSONObject.class);

4. 订单编号生成规则:秒级时间戳 + 加密用户ID + 今日第几次下单

  1. 秒级时间戳:时间递增保证唯一性
  2. 加密用户ID加密处理返回用户ID6位数字可以防并发访问同一秒用户不会产生2个订单
  3. 今日第几次下单:便于运营查询处理用户当日订单
/**
 * 返回订单编号,生成规则:秒级时间戳 + 加密用户ID + 今日第几次下单
 *
 * @param userId 用户ID
 * @return 订单编号
 */
public static String generateOrderSn(Long userId) {
        long now = LocalDateTime.now().toEpochSecond(ZoneOffset.of("+8"));
        return now + encryptUserId(String.valueOf(userId), 6) + countByOrderSn(userId);
        }

/**
 * 计算该用户今日内第几次下单
 *
 * @param userId 用户ID
 * @return 该用户今日第几次下单
 */
public static int countByOrderSn(Long userId) {
        IOrderService orderService = SpringContextUtil.getBean(IOrderService.class);
        return orderService.count(new QueryWrapper<Order>().eq("user_id", userId)
        .gt("create_time", LocalDate.now())
        .lt("create_time", LocalDate.now().plusDays(1)));
        }

/**
 * 加密用户ID返回num位字符串
 *
 * @param userId 用户ID
 * @param num    长度
 * @return num位加密字符串
 */
private static String encryptUserId(String userId, int num) {
        return String.format("%0" + num + "d", Integer.parseInt(userId) + 1);
        }

5. 下单流程处理过程通过rabbitMQ异步生成订单提高系统下单处理能力

  1. 用户点击提交订单按钮后台生成订单编号和订单金额跳转到订单支付页面并将订单编号等信息发送rabbitMQ消息生成订单编号还未生成订单
  2. 订单消费者接受到订单消息后,获取订单编号生成订单记录(订单创建成功,用户待支付)
  3. 下单页面,前端根据订单编号轮询订单接口,订单已创建则跳转支付页面,否则提示下单失败(订单创建失败)
  4. 支付页面,用户点击支付按钮时,后台调用微信/支付宝下单接口后,前端唤醒微信/支付宝支付,用户输入密码
  5. 用户支付完成后在微信/支付宝下回调通知里更新订单状态为已支付(订单已支付)
  6. 用户支付完成后,返回支付状态查看页面。

6. 金刚区跳转使用策略模式

# 1. 定义金刚位跳转策略接口以及跳转枚举类
public interface DiamondJumpType {

    List<Goods> getGoods(Page<Goods> page, Diamond diamond);

    Integer getType();
}
// 金刚位跳转类型枚举
public enum JumpTypeEnum {
    COLUMN(0),
    CATEGORY(1);

    private Integer type;

    JumpTypeEnum(Integer type) {
        this.type = type;
    }

    public Integer getType() {
        return type;
    }

    public JumpTypeEnum setType(Integer type) {
        this.type = type;
        return this;
    }
}

# 2. 定义策略实现类,并使用@Component注解注入spring
    
// 分类策略实现
@Component
public class CategoryStrategy implements DiamondJumpType {

    @Autowired
    private GoodsMapper goodsMapper;

    @Override
    public List<Goods> getGoods(Page<Goods> page, Diamond diamond) {
        List<Long> cateList = Arrays.asList(diamond.getValueId());
        return goodsMapper.selectGoodsListPageByl2CateId(page, cateList).getRecords();
    }

    @Override
    public Integer getType() {
        return JumpTypeEnum.CATEGORY.getType();
    }
}

// 栏目策略实现
@Component
public class ColumnStrategy implements DiamondJumpType {

    @Autowired
    private IColumnGoodsRelationService iColumnGoodsRelationService;

    @Autowired
    private IGoodsService iGoodsService;

    @Override
    public List<Goods> getGoods(Page<Goods> page, Diamond diamond) {
        List<ColumnGoodsRelation> goodsRelationList = iColumnGoodsRelationService.list(new QueryWrapper<ColumnGoodsRelation>()
                .eq("column_id", diamond.getValueId()));
        List<Long> goodsIdList = goodsRelationList.stream().map(ColumnGoodsRelation::getGoodsId).collect(Collectors.toList());
        Page<Goods> goodsPage = iGoodsService.page(page, new QueryWrapper<Goods>().in("id", goodsIdList).eq("is_on_sale", true));
        return goodsPage.getRecords();
    }

    @Override
    public Integer getType() {
        return JumpTypeEnum.COLUMN.getType();
    }
}

# 3. 定义策略上下文,通过构造器注入spring,定义map属性,通过key获取对应策略实现类
@Component
public class DiamondJumpContext {

    private Map<Integer, DiamondJumpType> map = new HashMap<>();

    /**
     * 由spring自动注入DiamondJumpType子类
     *
     * @param diamondJumpTypes 金刚位跳转类型集合
     */
    public DiamondJumpContext(List<DiamondJumpType> diamondJumpTypes) {
        for (DiamondJumpType diamondJumpType : diamondJumpTypes) {
            map.put(diamondJumpType.getType(), diamondJumpType);
        }
    }

    public DiamondJumpType getInstance(Integer jumpType) {
        return map.get(jumpType);
    }
}

# 4.使用,注入DiamondJumpContext对象,调用getInstance方法传入枚举类型
@Autowired
private DiamondJumpContext diamondJumpContext;

@Test
public void test(){
    DiamondJumpType diamondJumpType = diamondJumpContext.getInstance(JumpTypeEnum.COLUMN.getType());
}

7. google jib加速和简化docker镜像构建

		<plugins>
            <plugin>
                <groupId>com.google.cloud.tools</groupId>
                <artifactId>jib-maven-plugin</artifactId>
                <version>3.0.0</version>
                <configuration>
                    <!-- 1. 配置基本镜像-->
                    <from>
                        <image>adoptopenjdk:11-jre-openj9</image>
                    </from>
                    <!-- 2. 配置最终推送的地址仓库名镜像名默认是docker hub这里配置的是阿里云容器服务地址-->
                    <to>
                        <image>registry.cn-shanghai.aliyuncs.com/${aliyun-docker-namespace}/${project.artifactId}
                        </image>
                        <tags>
                            <!-- 3. 配置镜像标签,这里使用项目版本号-->
                            <tag>${project.version}</tag>
                        </tags>
                        <auth>
                            <!-- 4. 配置docker镜像仓库的认证信息-->
                            <username>填写你的阿里云账号</username>
                            <password>填写你的密码 site:https://cr.console.aliyun.com/cn-shanghai/instance/credentials</password>
                        </auth>
                    </to>
                    <container>
                        <!-- 6. 配置项目启动类以及jvm参数-->
                        <mainClass>填写项目启动类路劲 eg:com.wayn.AdminApplication</mainClass>
                        <jvmFlags>
                            <jvmFlag>-Xms812m</jvmFlag>
                            <jvmFlag>-Xmx812m</jvmFlag>
                            <jvmFlag>-Xss512k</jvmFlag>
                            <jvmFlag>-XX:+HeapDumpOnOutOfMemoryError</jvmFlag>
                            <jvmFlag>-XX:HeapDumpPath=./</jvmFlag>
                        </jvmFlags>
                    </container>
                </configuration>

                <!-- 绑定到maven lifecicle-->
                <executions>
                    <execution>
                        <phase>package</phase>
                        <goals>
                            <goal>build</goal>
                        </goals>
                    </execution>
                </executions>
            </plugin>
        </plugins>

8. 生产环境Redis连接长时间无响应被服务器断开问题通过lettuceConnectionFactory.resetConnection();重置redis连接

    @Autowired
    private LettuceConnectionFactory lettuceConnectionFactory;
    /**
     * 获得缓存的基本对象。
     *
     * @param key 缓存键值
     * @return 缓存键值对应的数据
     */
    public <T> T getCacheObject(final String key) {
        try {
            ValueOperations<String, T> operation = redisTemplate.opsForValue();
            return operation.get(key);
        } catch (Exception e) {
            log.error(e.getMessage(), e);
            return retryGetCacheObject(key, 1);
        }
    }

    public <T> T retryGetCacheObject(final String key, int retryCount) {
        try {
            log.info("retryGetCacheObject, key:{}, retryCount:{}", key, retryCount);
            if (retryCount <= 0) {
                return null;
            }
            lettuceConnectionFactory.resetConnection();
            Thread.sleep(200L);
            retryCount--;
            ValueOperations<String, T> operation = redisTemplate.opsForValue();
            return operation.get(key);
        } catch (Exception e) {
            log.error(e.getMessage(), e);
            return retryGetCacheObject(key, retryCount);
        }
    }
  • todo

文件目录

|-- waynboot-monitor               // 监控模块
|-- waynboot-admin-api             // 运营后台api模块提供后台项目api接口
|-- waynboot-common                // 通用模块,包含项目核心基础类
|-- waynboot-data                  // 数据模块,通用中间件数据访问
|   |-- waynboot-data-redis        // redis访问配置模块
|   |-- waynboot-data-elastic      // elastic访问配置模块
|-- waynboot-generator             // 代码生成模块
|-- waynboot-message-consumer      // 消费者模块,处理订单消息和邮件消息
|-- waynboot-message-core          // 消费者核心模块,队列、交换机配置
|-- waynboot-mobile-api            // h5商城api模块提供h5商城api接口
|-- pom.xml                        // maven父项目依赖定义子项目依赖版本
|-- ...

开发部署

# 1. 克隆项目
git clone git@github.com:wayn111/waynboot-mall.git

# 2. 导入项目依赖
将waynboot-mall目录用idea打开导入maven依赖

# 3. 安装Mysql8.0+、Redis3.0+、RabbitMQ3.0+、ElasticSearch7.0+到本地

# 4. 导入sql文件
在项目根目录下,找到`wayn_shop_*.sql`文件新建mysql数据库wayn_shop导入其中

# 5. 修改Mysql、Redis、RabbitMQ、Elasticsearch连接配置
修改`application-dev.yml`以及`application.yml`文件中数据连接配置相关信息

# 6. 启动项目
后台api
    进入waynboot-admin-api子项目找到AdminApplication文件右键`run AdminApplication`,启动后台项目
h5商城api:
    进入waynboot-mobile-api子项目找到MobileApplication文件右键`run MobileApplication`启动h5商城项目

在线体验

  • 注册一个账号
  • 然后登陆

演示地址:http://82.157.141.70/mall

演示图

商城登陆 商城注册
商城首页 商城搜索
搜索结果展示 金刚位跳转
商品分类 商品详情
商品sku选择 购物车查看
确认下单 选择支付方式
商城我的页面 我的订单列表
添加商品评论 查看商品评论
后台登陆 后台首页
后台会员管理 后台评论管理
后台地址管理 后台添加商品
后台商品管理 后台banner管理
后台订单管理 后台分类管理
后台金刚区管理 后台栏目管理

waynboot-mall交流群

QQ群waynboot-mall交流群 有问题可以先提issue😁

todo

  • dockers镜像部署(使用google jib部署方式)
  • 订单详情页面
  • 商城资讯流
  • 联系客服
  • 秒杀专区
  • 支持多店铺
  • 优惠卷使用
  • 团购下单
  • ...

感谢