前端 + 后端:商品搜索

This commit is contained in:
YunaiV 2019-04-24 21:47:43 +08:00
parent 68dadab873
commit b98e21e157
29 changed files with 770 additions and 125 deletions

View File

@ -9,6 +9,10 @@ import java.util.List;
public class StringUtil {
public static boolean hasText(String str) {
return StringUtils.hasText(str);
}
public static String join(Collection<?> coll, String delim) {
return StringUtils.collectionToDelimitedString(coll, delim);
}
@ -31,4 +35,4 @@ public class StringUtil {
return org.apache.commons.lang3.StringUtils.substring(str, start);
}
}
}

View File

@ -0,0 +1,41 @@
package cn.iocoder.common.framework.vo;
/**
* 排序字段 DTO
*
* 类名加了 ing 的原因是避免和 ES SortField 重名
*/
public class SortingField {
/**
* 字段
*/
private String field;
/**
* 排序
*/
private String order;
public SortingField(String field, String order) {
this.field = field;
this.order = order;
}
public String getField() {
return field;
}
public SortingField setField(String field) {
this.field = field;
return this;
}
public String getOrder() {
return order;
}
public SortingField setOrder(String order) {
this.order = order;
return this;
}
}

View File

@ -0,0 +1,16 @@
import request from "../config/request";
export function getProductPage({cid, keyword, pageNo, pageSize, sortField, sortOrder}) {
return request({
url: '/search-api/users/product/page',
method: 'get',
params: {
cid,
keyword,
pageNo: pageNo || 1,
pageSize: pageSize || 10,
sortField: sortField,
sortOrder: sortOrder,
}
});
}

View File

@ -8,17 +8,20 @@
style="background:#fff"
>
<template slot="thumb">
<img :src="product.picUrls[0]"/>
<img :src="product.picUrls && product.picUrls ? product.picUrls[0] : ''"/>
<!-- TODO 芋艿 暂时去掉 -->
<!-- <p v-if="product.imageTag!=null&&product.imageTag!=''" class="image_tag">{{product.imageTag}}</p>-->
</template>
<template slot="tags">
<p class="price" v-if="product.price!=null && product.price !== ''">
<span>{{product.price / 100.00}}</span>
<p class="price" v-if="product.buyPrice || product.price">
<span>{{product.buyPrice ? product.buyPrice / 100.00 : product.price / 100.00}}</span>
<!-- TODO 芋艿 暂时去掉 -->
<!-- <van-tag v-if="product.tags!=null" v-for="tag in product.tags" :key="tag" plain type="danger">-->
<!-- {{tag}}-->
<!-- </van-tag>-->
<van-tag v-if="product.promotionActivityTitle" plain type="danger">
{{ product.promotionActivityTitle }}
</van-tag>
</p>
<!-- TODO 芋艿 暂时去掉 -->

View File

@ -8,13 +8,13 @@
placeholder="请输入搜索关键词"
background="#fff"
show-action
@search="onSearch"
@search="onSearchClick"
slot="title"
>
<div slot="action" @click="onSearch">搜索</div>
<div slot="action" @click="onSearchClick">搜索</div>
</van-search>
</van-nav-bar>
</div>
</template>
@ -27,14 +27,19 @@ export default {
components:{
[Search.name]:Search,
},
props: {
// keyword: String,
// onSearch: Function,
},
data(){
return{
value:'',
}
},
methods:{
onSearch() {
console.log(this.value);
onSearchClick() {
// this.props.onSearch(this.keyword);
this.$emit('onSearch', this.value);
},
onBack() {
history.back();

View File

@ -28,6 +28,10 @@ const serviceRouter = function(requestUrl) {
prefix: '/pay-api',
target: 'http://127.0.0.1:18084/pay-api',
},
'/search-api': {
prefix: '/search-api',
target: 'http://127.0.0.1:18086/search-api',
},
};
const configProd = {
@ -51,6 +55,10 @@ const serviceRouter = function(requestUrl) {
prefix: '/pay-api',
target: 'http://api.shop.iocoder.cn:18099/pay-api',
},
'/search-api': {
prefix: '/search-api',
target: 'http://api.shop.iocoder.cn:18099/search-api',
},
};
if (process.env.NODE_ENV == 'development') {

View File

@ -153,13 +153,6 @@ const routes = [
title: '进度详情'
}
},
{
path: '/product/:id',
component: () => import('../page/product/detail'),
meta: {
title: '商品详情'
}
},
{
path: '/product/search',
component: () => import('../page/product/search'),
@ -167,6 +160,13 @@ const routes = [
title: '商品搜索'
}
},
{
path: '/product/:id',
component: () => import('../page/product/detail'),
meta: {
title: '商品详情'
}
},
{
path: '/products/list',
component: () => import('../page/product/list'),

View File

@ -1,21 +1,21 @@
<template>
<div class="product-list">
<searchtop/>
<searchtop @onSearch="onSearch" />
<div class="filterbar">
<ul :class="filtersort?'show':''">
<li :class="filterindex==0?'selected':''" v-on:click="onFilterBar(0)"><span>{{filterindex==11?'价格最低':(filterindex==12?'价格最高':'综合')}}</span><van-icon name="arrow" class="down" /></li>
<li :class="filterindex==1?'selected':''" v-on:click="onFilterBar(1)"><span>销量</span></li>
<li :class="filterindex==2?'selected':''" v-on:click="onFilterBar(2)"><span>上新</span></li>
<li :class="filterindex==3?'selected':''" v-on:click="onFilterBar(3)"><span>筛选</span></li>
<ul :class="filterSort?'show':''">
<li :class="filterIndex===0?'selected':''" v-on:click="onFilterBar(0)"><span>{{filterIndex==11?'价格最低':(filterIndex==12?'价格最高':'综合')}}</span><van-icon name="arrow" class="down" /></li>
<li :class="filterIndex===1?'selected':''" v-on:click="onFilterBar(1)"><span>销量</span></li>
<li :class="filterIndex===2?'selected':''" v-on:click="onFilterBar(2)"><span>上新</span></li>
<li :class="filterIndex===3?'selected':''" v-on:click="onFilterBar(3)"><span>筛选</span></li>
</ul>
<div :class="'item_options '+(filtersort?'show':'')">
<div :class="'item_options '+(filterSort?'show':'')">
<ul>
<li :class="filterindex==10?'selected':''" v-on:click="onFilterBar(10)">综合</li>
<li :class="filterindex==11?'selected':''" v-on:click="onFilterBar(11)">价格最低</li>
<li :class="filterindex==12?'selected':''" v-on:click="onFilterBar(12)">价格最高</li>
<li :class="filterIndex==10?'selected':''" v-on:click="onFilterBar(10)">综合</li>
<li :class="filterIndex==11?'selected':''" v-on:click="onFilterBar(11)">价格降序</li>
<li :class="filterIndex==12?'selected':''" v-on:click="onFilterBar(12)">价格最高</li>
</ul>
</div>
<van-popup v-model="filtershow" position="right" class="filterlayer" >
<van-popup v-model="filterShow" position="right" class="filterlayer" >
<div class="filterInner" style="overflow-y: scroll;max-height: 100%;">
<ul>
<li>
@ -163,14 +163,23 @@
</van-popup>
</div>
<div v-for="(product,i) in products" :key="i">
<product-card :product='product' @click="showProduct(product)" />
</div>
<van-list
v-model="loading"
:finished="finished"
finished-text="没有更多了"
@load="onLoad"
>
<div v-for="(product,i) in products" :key="i">
<product-card :product='product' @click="showProduct(product)" />
</div>
</van-list>
</div>
</template>
<script>
import searchtop from "../../components/search/searchtop";
import {getProductPage} from "../../api/search";
export default {
components: {
@ -178,70 +187,120 @@ export default {
},
data() {
return {
value: "",
filterindex: 0,
filtersort: false,
filtershow: false,
products:[
{
id:1,
imageURL:'https://pop.nosdn.127.net/19e33c9b-6c22-4a4b-96da-1cb7afb32712',
title:'BEYOND博洋家纺 床上套件 秋冬保暖纯棉床单被套 双人被罩 磨毛全棉印花床品四件套',
price:'13.00',
},
{
id:1,
imageURL:'https://pop.nosdn.127.net/19e33c9b-6c22-4a4b-96da-1cb7afb32712',
title:'BEYOND博洋家纺 床上套件 秋冬保暖纯棉床单被套 双人被罩 磨毛全棉印花床品四件套',
price:'499.00',
tags:['满199减100','2件起购'],
},
{
id:1,
imageURL:'https://pop.nosdn.127.net/19e33c9b-6c22-4a4b-96da-1cb7afb32712',
title:'BEYOND博洋家纺 床上套件 秋冬保暖纯棉床单被套 双人被罩 磨毛全棉印花床品四件套',
price:'499.00',
tags:['新品'],
imageTag:'仅剩1件',
},
{
id:1,
imageURL:'https://pop.nosdn.127.net/19e33c9b-6c22-4a4b-96da-1cb7afb32712',
title:'BEYOND博洋家纺 床上套件 秋冬保暖纯棉床单被套 双人被罩 磨毛全棉印花床品四件套',
price:'499.00',
tags:['赠'],
imageTag:'预约',
},
{
id:1,
imageURL:'https://pop.nosdn.127.net/19e33c9b-6c22-4a4b-96da-1cb7afb32712',
title:'BEYOND博洋家纺 床上套件 秋冬保暖纯棉床单被套 双人被罩 磨毛全棉印花床品四件套',
price:'15.00',
},
{
id:1,
imageURL:'https://pop.nosdn.127.net/19e33c9b-6c22-4a4b-96da-1cb7afb32712',
title:'BEYOND博洋家纺 床上套件 秋冬保暖纯棉床单被套 双人被罩 磨毛全棉印花床品四件套',
price:'125.50',
},
]
page: 0,
pageSize: 10,
loading: false,
finished: false,
keyword: "",
filterIndex: 0,
filterSort: false, //
filterShow: false, //
sortField: undefined,
sortOrder: undefined,
products:[]
};
},
methods: {
onFilterBar(value) {
if (value == 0) {
this.filtersort = !this.filtersort;
} else if (value == 3) {
this.filtershow = !this.filtershow;
if (value === 0) {
this.filterSort = !this.filterSort;
} else if (value === 3) {
this.filterShow = !this.filterShow;
} else {
this.filtersort = false;
this.filterindex = value;
// 101112
if (value !== 10
&& value !== 11
&& value !== 12) {
alert('暂不支持');
return;
}
// filterSort filterIndex
this.filterSort = false;
this.filterIndex = value;
//
this.loading = true;
// value sortFieldsortOrder
switch (value) {
case 10:
this.sortField = undefined;
this.sortOrder = undefined;
break;
case 11:
this.sortField = 'buyPrice';
this.sortOrder = 'desc';
break;
case 12:
this.sortField = 'buyPrice';
this.sortOrder = 'asc';
break;
}
//
let page = 1;
getProductPage({
pageNo: page,
pageSize: this.pageSize,
keyword: this.keyword,
sortField: this.sortField,
sortOrder: this.sortOrder,
}).then(data => {
this.products = [];
this.handleData(page, data);
});
}
},
showProduct(product){
this.$router.push('/product/'+product.id);
},
onSearch(keyword) {
this.loading = true;
// keyword
this.keyword = keyword;
//
this.filterIndex = 0;
this.filterSort = false;
this.filterShow = false;
this.sortField = undefined;
this.sortOrder = undefined;
//
let page = 1;
getProductPage({
pageNo: page,
pageSize: this.pageSize,
keyword: keyword,
}).then(data => {
this.products = [];
this.handleData(page, data);
});
},
onLoad() {
//
let page = this.page + 1;
getProductPage({
pageNo: page,
pageSize: this.pageSize,
}).then(data => {
this.handleData(page, data);
});
},
handleData(page, data) {
this.loading = true;
//
this.page = page;
// list
this.products.push(...data.list);
//
if (this.products.length >= data.total) {
this.finished = true;
}
//
this.loading = false;
}
},
mounted() {
}
};
</script>

View File

@ -4,12 +4,14 @@ import cn.iocoder.mall.promotion.api.bo.PromotionActivityBO;
import lombok.Data;
import lombok.experimental.Accessors;
import java.io.Serializable;
/**
* 计算商品 SKU 价格结果 BO
*/
@Data
@Accessors(chain = true)
public class CalcSkuPriceBO {
public class CalcSkuPriceBO implements Serializable {
/**
* 满减送促销活动

View File

@ -33,10 +33,10 @@
FROM product_spu
<where>
<if test="id != null">
id >= #{id}
id > #{id}
</if>
AND deleted = 0
</where>
AND deleted = 0
ORDER BY id ASC
LIMIT #{limit}
</select>

View File

@ -11,5 +11,79 @@
<artifactId>search-application</artifactId>
<dependencies>
<dependency>
<groupId>cn.iocoder.mall</groupId>
<artifactId>common-framework</artifactId>
<version>1.0-SNAPSHOT</version>
</dependency>
<dependency>
<groupId>cn.iocoder.mall</groupId>
<artifactId>user-sdk</artifactId>
<version>1.0-SNAPSHOT</version>
</dependency>
<dependency>
<groupId>cn.iocoder.mall</groupId>
<artifactId>search-service-api</artifactId>
<version>1.0-SNAPSHOT</version>
</dependency>
<dependency>
<groupId>cn.iocoder.mall</groupId>
<artifactId>search-service-impl</artifactId>
<version>1.0-SNAPSHOT</version>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>com.alibaba</groupId>
<artifactId>dubbo</artifactId>
</dependency>
<dependency>
<groupId>com.alibaba.boot</groupId>
<artifactId>dubbo-spring-boot-starter</artifactId>
</dependency>
<dependency>
<groupId>org.apache.curator</groupId>
<artifactId>curator-framework</artifactId>
</dependency>
<dependency>
<groupId>io.springfox</groupId>
<artifactId>springfox-swagger2</artifactId>
</dependency>
<dependency>
<groupId>io.springfox</groupId>
<artifactId>springfox-swagger-ui</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
</dependencies>
<build>
<plugins>
<!-- 提供给 mapstruct 使用 -->
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-compiler-plugin</artifactId>
</plugin>
<!-- 打包 -->
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
</plugin>
</plugins>
</build>
</project>

View File

@ -0,0 +1,13 @@
package cn.iocoder.mall.search.application;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
@SpringBootApplication(scanBasePackages = {"cn.iocoder.mall.search"})
public class SearchApplication {
public static void main(String[] args) {
SpringApplication.run(SearchApplication.class, args);
}
}

View File

@ -0,0 +1,59 @@
package cn.iocoder.mall.search.application.config;
import cn.iocoder.common.framework.config.GlobalExceptionHandler;
import cn.iocoder.common.framework.servlet.CorsFilter;
import org.springframework.boot.web.servlet.FilterRegistrationBean;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.context.annotation.Import;
import org.springframework.web.servlet.config.annotation.EnableWebMvc;
import org.springframework.web.servlet.config.annotation.InterceptorRegistry;
import org.springframework.web.servlet.config.annotation.ResourceHandlerRegistry;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
@EnableWebMvc
@Configuration
@Import(value = {GlobalExceptionHandler.class, // 统一全局返回
// AdminSecurityInterceptor.class, UserAccessLogInterceptor.class,
// UserSecurityInterceptor.class, AdminAccessLogInterceptor.class,
})
public class MVCConfiguration implements WebMvcConfigurer {
// @Autowired
// private UserSecurityInterceptor securityInterceptor;
// @Autowired
// private UserSecurityInterceptor userSecurityInterceptor;
// @Autowired
// private UserAccessLogInterceptor userAccessLogInterceptor;
// @Autowired
// private AdminSecurityInterceptor adminSecurityInterceptor;
// @Autowired
// private AdminAccessLogInterceptor adminAccessLogInterceptor;
//
@Override
public void addInterceptors(InterceptorRegistry registry) {
// // 用户
// registry.addInterceptor(userAccessLogInterceptor).addPathPatterns("/users/**");
// registry.addInterceptor(userSecurityInterceptor).addPathPatterns("/users/**"); // 只拦截我们定义的接口
// // 管理员
// registry.addInterceptor(adminAccessLogInterceptor).addPathPatterns("/admins/**");
// registry.addInterceptor(adminSecurityInterceptor).addPathPatterns("/admins/**");
}
@Override
public void addResourceHandlers(ResourceHandlerRegistry registry) {
// 解决 swagger-ui.html 的访问参考自 https://stackoverflow.com/questions/43545540/swagger-ui-no-mapping-found-for-http-request 解决
registry.addResourceHandler("swagger-ui.html**").addResourceLocations("classpath:/META-INF/resources/swagger-ui.html");
registry.addResourceHandler("webjars/**").addResourceLocations("classpath:/META-INF/resources/webjars/");
}
@Bean
public FilterRegistrationBean<CorsFilter> corsFilter() {
FilterRegistrationBean<CorsFilter> registrationBean = new FilterRegistrationBean<>();
registrationBean.setFilter(new CorsFilter());
registrationBean.addUrlPatterns("/*");
return registrationBean;
}
}

View File

@ -0,0 +1,36 @@
package cn.iocoder.mall.search.application.config;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import springfox.documentation.builders.ApiInfoBuilder;
import springfox.documentation.builders.PathSelectors;
import springfox.documentation.builders.RequestHandlerSelectors;
import springfox.documentation.service.ApiInfo;
import springfox.documentation.spi.DocumentationType;
import springfox.documentation.spring.web.plugins.Docket;
import springfox.documentation.swagger2.annotations.EnableSwagger2;
@Configuration
@EnableSwagger2 // TODO 生产环境时禁用掉
public class SwaggerConfiguration {
@Bean
public Docket createRestApi() {
return new Docket(DocumentationType.SWAGGER_2)
.apiInfo(apiInfo())
.select()
.apis(RequestHandlerSelectors.basePackage("cn.iocoder.mall.search.application.controller"))
.paths(PathSelectors.any())
.build();
}
private ApiInfo apiInfo() {
return new ApiInfoBuilder()
.title("搜索子系统")
.description("搜索子系统")
.termsOfServiceUrl("http://www.iocoder.cn")
.version("1.0.0")
.build();
}
}

View File

@ -0,0 +1,43 @@
package cn.iocoder.mall.search.application.controller.users;
import cn.iocoder.common.framework.util.StringUtil;
import cn.iocoder.common.framework.vo.CommonResult;
import cn.iocoder.common.framework.vo.SortingField;
import cn.iocoder.mall.search.api.ProductSearchService;
import cn.iocoder.mall.search.api.bo.ESProductPageBO;
import cn.iocoder.mall.search.api.dto.ProductSearchPageDTO;
import com.alibaba.dubbo.config.annotation.Reference;
import io.swagger.annotations.Api;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import java.util.Collections;
@RestController
@RequestMapping("users/product")
@Api("商品搜索")
public class UsersProductSearchController {
@Reference(validation = "true")
private ProductSearchService productSearchService;
@GetMapping("/page") // TODO 芋艿后面把 BO 改成 VO
public CommonResult<ESProductPageBO> page(@RequestParam(value = "cid", required = false) Integer cid,
@RequestParam(value = "keyword", required = false) String keyword,
@RequestParam(value = "pageNo", required = false) Integer pageNo,
@RequestParam(value = "pageSize", required = false) Integer pageSize,
@RequestParam(value = "sortField", required = false) String sortField,
@RequestParam(value = "sortOrder", required = false) String sortOrder) {
// 创建 ProductSearchPageDTO 对象
ProductSearchPageDTO productSearchPageDTO = new ProductSearchPageDTO().setCid(cid).setKeyword(keyword)
.setPageNo(pageNo).setPageSize(pageSize);
if (StringUtil.hasText(sortField) && StringUtil.hasText(sortOrder)) {
productSearchPageDTO.setSorts(Collections.singletonList(new SortingField(sortField, sortOrder)));
}
// 执行搜索
return productSearchService.searchPage(productSearchPageDTO);
}
}

View File

@ -0,0 +1,9 @@
spring:
application:
name: search-application
# server
server:
port: 18086
servlet:
context-path: /search-api/

View File

@ -1,12 +1,13 @@
package cn.iocoder.mall.search.api;
import cn.iocoder.common.framework.vo.CommonResult;
import cn.iocoder.mall.search.api.bo.ESProductPageBO;
import cn.iocoder.mall.search.api.dto.ProductSearchPageDTO;
public interface ProductSearchService {
CommonResult<Integer> rebuild();
CommonResult searchPage(ProductSearchPageDTO searchPageDTO);
CommonResult<ESProductPageBO> searchPage(ProductSearchPageDTO searchPageDTO);
}

View File

@ -0,0 +1,86 @@
package cn.iocoder.mall.search.api.bo;
import lombok.Data;
import lombok.experimental.Accessors;
import java.io.Serializable;
import java.util.List;
/**
* 商品 ES BO
*/
@Data
@Accessors(chain = true)
public class ESProductBO implements Serializable {
private Integer id;
// ========== 基本信息 =========
/**
* SPU 名字
*/
private String name;
/**
* 卖点
*/
private String sellPoint;
/**
* 描述
*/
private String description;
/**
* 分类编号
*/
private Integer cid;
/**
* 分类名
*/
private String categoryName;
/**
* 商品主图地数组
*/
private List<String> picUrls;
// ========== 其他信息 =========
/**
* 是否上架商品是否可见
*
* true 为已上架
* false 为已下架
*/
private Boolean visible;
/**
* 排序字段
*/
private Integer sort;
// ========== Sku 相关字段 =========
/**
* 原价格单位
*/
private Integer originalPrice;
/**
* 购买价格单位
*/
private Integer buyPrice;
/**
* 库存数量
*/
private Integer quantity;
// ========== 促销活动相关字段 =========
// 目前只促销单体商品促销目前仅限制折扣
/**
* 促销活动编号
*/
private Integer promotionActivityId;
/**
* 促销活动标题
*/
private String promotionActivityTitle;
/**
* 促销活动类型
*/
private Integer promotionActivityType;
}

View File

@ -0,0 +1,22 @@
package cn.iocoder.mall.search.api.bo;
import lombok.Data;
import lombok.experimental.Accessors;
import java.io.Serializable;
import java.util.List;
@Data
@Accessors(chain = true)
public class ESProductPageBO implements Serializable {
/**
* 管理员数组
*/
private List<ESProductBO> list;
/**
* 总量
*/
private Integer total;
}

View File

@ -1,9 +1,12 @@
package cn.iocoder.mall.search.api.dto;
import cn.iocoder.common.framework.util.CollectionUtil;
import cn.iocoder.common.framework.vo.SortingField;
import lombok.Data;
import lombok.experimental.Accessors;
import java.util.List;
import java.util.Set;
/**
* 商品检索分页 DTO
@ -12,6 +15,8 @@ import java.util.List;
@Accessors(chain = true)
public class ProductSearchPageDTO {
public static final Set<String> SORT_FIELDS = CollectionUtil.asSet("buyPrice");
/**
* 分类编号
*/
@ -33,6 +38,6 @@ public class ProductSearchPageDTO {
/**
* 排序字段数组
*/
private List<SortFieldDTO> sorts;
private List<SortingField> sorts;
}

View File

@ -1,17 +0,0 @@
package cn.iocoder.mall.search.api.dto;
/**
* 排序字段 DTO
*/
public class SortFieldDTO {
/**
* 字段
*/
private String field;
/**
* 排序
*/
private String order;
}

View File

@ -49,6 +49,18 @@
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>com.alibaba.boot</groupId> <!-- 引入该包,为了写单元测试用 -->
<artifactId>dubbo-spring-boot-starter</artifactId>
</dependency>
<dependency>
<groupId>org.apache.curator</groupId> <!-- 引入该包,为了写单元测试用 -->
<artifactId>curator-framework</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId> <!-- 引入该包,为了写单元测试用 -->
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
</dependencies>
<build>

View File

@ -0,0 +1,9 @@
package cn.iocoder.mall.search.biz.config;
import org.springframework.context.annotation.Configuration;
import org.springframework.data.elasticsearch.repository.config.EnableElasticsearchRepositories;
@Configuration
@EnableElasticsearchRepositories(basePackages = "cn.iocoder.mall.search.biz.dao")
public class JPAConfiguration {
}

View File

@ -0,0 +1,39 @@
package cn.iocoder.mall.search.biz.convert;
import cn.iocoder.mall.order.api.bo.CalcSkuPriceBO;
import cn.iocoder.mall.product.api.bo.ProductSpuDetailBO;
import cn.iocoder.mall.promotion.api.bo.PromotionActivityBO;
import cn.iocoder.mall.search.api.bo.ESProductBO;
import cn.iocoder.mall.search.biz.dataobject.ESProductDO;
import org.mapstruct.Mapper;
import org.mapstruct.Mappings;
import org.mapstruct.factory.Mappers;
import java.util.List;
@Mapper
public interface ProductSearchConvert {
ProductSearchConvert INSTANCE = Mappers.getMapper(ProductSearchConvert.class);
@Mappings({})
ESProductDO convert(ProductSpuDetailBO spu);
@Mappings({})
default ESProductDO convert(ProductSpuDetailBO spu, CalcSkuPriceBO calcSkuPrice) {
// Spu 的基础数据
ESProductDO product = this.convert(spu);
product.setOriginalPrice(calcSkuPrice.getOriginalPrice()).setBuyPrice(calcSkuPrice.getBuyPrice());
// 设置促销活动相关字段
if (calcSkuPrice.getTimeLimitedDiscount() != null) {
PromotionActivityBO activity = calcSkuPrice.getTimeLimitedDiscount();
product.setPromotionActivityId(activity.getId()).setPromotionActivityTitle(activity.getTitle())
.setPromotionActivityType(activity.getActivityType());
}
// 返回
return product;
}
List<ESProductBO> convert(List<ESProductDO> list);
}

View File

@ -1,13 +1,67 @@
package cn.iocoder.mall.search.biz.dao;
import cn.iocoder.common.framework.util.CollectionUtil;
import cn.iocoder.common.framework.util.StringUtil;
import cn.iocoder.common.framework.vo.SortingField;
import cn.iocoder.mall.search.biz.dataobject.ESProductDO;
import org.elasticsearch.common.lucene.search.function.FunctionScoreQuery;
import org.elasticsearch.index.query.QueryBuilders;
import org.elasticsearch.index.query.functionscore.FunctionScoreQueryBuilder;
import org.elasticsearch.index.query.functionscore.ScoreFunctionBuilders;
import org.elasticsearch.search.sort.SortBuilders;
import org.elasticsearch.search.sort.SortOrder;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.PageRequest;
import org.springframework.data.elasticsearch.core.query.NativeSearchQueryBuilder;
import org.springframework.data.elasticsearch.repository.ElasticsearchRepository;
import org.springframework.stereotype.Repository;
import java.util.List;
import static org.elasticsearch.index.query.QueryBuilders.matchQuery;
@Repository
public interface ProductRepository extends ElasticsearchRepository<ESProductDO, Integer> {
@Deprecated
ESProductDO findByName(String name);
default Page<ESProductDO> search(Integer cid, String keyword, Integer pageNo, Integer pageSize,
List<SortingField> sortFields) {
NativeSearchQueryBuilder nativeSearchQueryBuilder = new NativeSearchQueryBuilder()
.withPageable(PageRequest.of(pageNo - 1, pageSize));
// 筛选条件 cid
if (cid != null) {
nativeSearchQueryBuilder.withFilter(QueryBuilders.termQuery("cid", cid));
}
// 筛选
if (StringUtil.hasText(keyword)) {
FunctionScoreQueryBuilder.FilterFunctionBuilder[] functions = { // TODO 芋艿分值随便打的
new FunctionScoreQueryBuilder.FilterFunctionBuilder(matchQuery("name", keyword),
ScoreFunctionBuilders.weightFactorFunction(10)),
new FunctionScoreQueryBuilder.FilterFunctionBuilder(matchQuery("sellPoint", keyword),
ScoreFunctionBuilders.weightFactorFunction(2)),
new FunctionScoreQueryBuilder.FilterFunctionBuilder(matchQuery("categoryName", keyword),
ScoreFunctionBuilders.weightFactorFunction(3)),
// new FunctionScoreQueryBuilder.FilterFunctionBuilder(matchQuery("description", keyword),
// ScoreFunctionBuilders.weightFactorFunction(2)), // TODO 芋艿目前这么做如果商品描述很长在按照价格降序会命中超级多的关键字
};
FunctionScoreQueryBuilder functionScoreQueryBuilder = QueryBuilders.functionScoreQuery(functions)
.scoreMode(FunctionScoreQuery.ScoreMode.SUM)
.setMinScore(2F); // TODO 芋艿需要考虑下 score
nativeSearchQueryBuilder.withQuery(functionScoreQueryBuilder);
} else {
nativeSearchQueryBuilder.withQuery(QueryBuilders.matchAllQuery());
}
// 排序
if (CollectionUtil.isEmpty(sortFields)) {
nativeSearchQueryBuilder.withSort(SortBuilders.scoreSort().order(SortOrder.DESC));
} else {
sortFields.forEach(sortField -> nativeSearchQueryBuilder.withSort(SortBuilders.fieldSort(sortField.getField())
.order(SortOrder.fromString(sortField.getOrder()))));
}
// 执行查询
return search(nativeSearchQueryBuilder.build());
}
}

View File

@ -1,35 +1,40 @@
package cn.iocoder.mall.search.biz.service;
import cn.iocoder.common.framework.util.CollectionUtil;
import cn.iocoder.common.framework.vo.CommonResult;
import cn.iocoder.common.framework.vo.SortingField;
import cn.iocoder.mall.order.api.CartService;
import cn.iocoder.mall.order.api.bo.CalcSkuPriceBO;
import cn.iocoder.mall.product.api.ProductSpuService;
import cn.iocoder.mall.product.api.bo.ProductSpuDetailBO;
import cn.iocoder.mall.search.api.ProductSearchService;
import cn.iocoder.mall.search.api.bo.ESProductPageBO;
import cn.iocoder.mall.search.api.dto.ProductSearchPageDTO;
import cn.iocoder.mall.search.biz.convert.ProductSearchConvert;
import cn.iocoder.mall.search.biz.dao.ProductRepository;
import cn.iocoder.mall.search.biz.dataobject.ESProductDO;
import com.alibaba.dubbo.config.annotation.Reference;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.data.domain.Page;
import org.springframework.stereotype.Service;
import org.springframework.util.Assert;
import java.util.Comparator;
import java.util.List;
import java.util.function.Function;
import java.util.stream.Collectors;
@Service
@com.alibaba.dubbo.config.annotation.Service(validation = "true")
public class ProductSearchServiceImpl implements ProductSearchService {
private static final Integer REBUILD_FETCH_PER_SIZE = 2;
private static final Integer REBUILD_FETCH_PER_SIZE = 100;
@Autowired
private ProductRepository productRepository;
@Autowired
@Reference(validation = "true")
private ProductSpuService productSpuService;
@Autowired
@Reference(validation = "true")
private CartService cartService;
@Override
@ -39,17 +44,12 @@ public class ProductSearchServiceImpl implements ProductSearchService {
int rebuildCounts = 0;
while (true) {
CommonResult<List<ProductSpuDetailBO>> result = productSpuService.getProductSpuDetailListForSync(lastId, REBUILD_FETCH_PER_SIZE);
Assert.isTrue(result.isError(), "获得商品列表必然成功");
Assert.isTrue(result.isSuccess(), "获得商品列表必然成功");
List<ProductSpuDetailBO> spus = result.getData();
rebuildCounts += spus.size();
// 存储到 ES
List<ESProductDO> products = spus.stream().map(new Function<ProductSpuDetailBO, ESProductDO>() {
@Override
public ESProductDO apply(ProductSpuDetailBO spu) {
return convert(spu);
}
}).collect(Collectors.toList());
List<ESProductDO> products = spus.stream().map(this::convert).collect(Collectors.toList());
productRepository.saveAll(products);
// 设置新的 lastId 或者结束
if (spus.size() < REBUILD_FETCH_PER_SIZE) {
break;
@ -66,14 +66,30 @@ public class ProductSearchServiceImpl implements ProductSearchService {
ProductSpuDetailBO.Sku sku = spu.getSkus().stream().min(Comparator.comparing(ProductSpuDetailBO.Sku::getPrice)).get();
// 价格计算
CommonResult<CalcSkuPriceBO> calSkuPriceResult = cartService.calcSkuPrice(sku.getId());
Assert.isTrue(calSkuPriceResult.isError(), String.format("SKU(%d) 价格计算不会出错", sku.getId()));
return new ESProductDO();
Assert.isTrue(calSkuPriceResult.isSuccess(), String.format("SKU(%d) 价格计算不会出错", sku.getId()));
// 拼装结果
return ProductSearchConvert.INSTANCE.convert(spu, calSkuPriceResult.getData());
}
@Override
public CommonResult searchPage(ProductSearchPageDTO searchPageDTO) {
return null;
public CommonResult<ESProductPageBO> searchPage(ProductSearchPageDTO searchPageDTO) {
checkSortFieldInvalid(searchPageDTO.getSorts());
// 执行查询
Page<ESProductDO> searchPage = productRepository.search(searchPageDTO.getCid(), searchPageDTO.getKeyword(),
searchPageDTO.getPageNo(), searchPageDTO.getPageSize(), searchPageDTO.getSorts());
// 转换结果
ESProductPageBO resultPage = new ESProductPageBO()
.setList(ProductSearchConvert.INSTANCE.convert(searchPage.getContent()))
.setTotal((int) searchPage.getTotalElements());
return CommonResult.success(resultPage);
}
private void checkSortFieldInvalid(List<SortingField> sorts) {
if (CollectionUtil.isEmpty(sorts)) {
return;
}
sorts.forEach(sortingField -> Assert.isTrue(ProductSearchPageDTO.SORT_FIELDS.contains(sortingField.getField()),
String.format("排序字段(%s) 不在允许范围内", sortingField.getField())));
}
}

View File

@ -1,4 +1,4 @@
#
# es
spring:
data:
elasticsearch:
@ -6,6 +6,7 @@ spring:
cluster-nodes: 192.168.88.10:9300
repositories:
enable: true
# dubbo
dubbo:
application:
@ -16,4 +17,4 @@ dubbo:
port: -1
name: dubbo
scan:
base-packages: cn.iocoder.mall.search.service.biz
base-packages: cn.iocoder.mall.search.biz.service

View File

@ -1,12 +1,15 @@
package cn.iocoder.mall.search.biz.dao;
import cn.iocoder.mall.search.biz.dataobject.ESProductDO;
import org.junit.Ignore;
import org.junit.Test;
import org.junit.runner.RunWith;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.test.context.junit4.SpringJUnit4ClassRunner;
import java.util.List;
@RunWith(SpringJUnit4ClassRunner.class)
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.NONE)
public class ProductRepositoryTest {
@ -15,6 +18,7 @@ public class ProductRepositoryTest {
private ProductRepository productRepository;
@Test
@Ignore
public void testSave() {
// productRepository.deleteById(1);
ESProductDO product = new ESProductDO()
@ -24,9 +28,23 @@ public class ProductRepositoryTest {
}
@Test
@Ignore
public void testFindByName() {
ESProductDO product = productRepository.findByName("锤子");
System.out.println(product);
}
@Test
public void testSearch() {
// Page<ESProductDO> page = productRepository.search(639, null, 1, 10);
// console(page.getContent());
// Page<ESProductDO> page = productRepository.search(null, "数据库Oracle", 1, 10);
// console(page.getContent());
}
private void console(List<ESProductDO> list) {
list.forEach(System.out::println);
}
}

View File

@ -0,0 +1,27 @@
package cn.iocoder.mall.search.biz.service;
import cn.iocoder.mall.search.biz.dao.ProductRepository;
import org.junit.Test;
import org.junit.runner.RunWith;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.test.context.junit4.SpringJUnit4ClassRunner;
@RunWith(SpringJUnit4ClassRunner.class)
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.NONE)
public class ProductSearchServiceImplTest {
@Autowired
private ProductSearchServiceImpl productSearchService;
@Autowired
private ProductRepository productRepository;
@Test
public void testRebuild() {
int counts = productSearchService.rebuild().getData();
System.out.println("重建数量:" + counts);
System.out.println(productRepository.count());
}
}