数据存储形式

Redis

按照商户类型做分组,类型相同的作为一组,以typeId为key存入同一个GEO集合中

Key Value Score
shop:gep:美食 麻辣香锅
海底捞火锅
4054135079882095
4054135064014509
shop:geo:KTV 星聚会KTV
开乐迪 KTV
4054135063424376
4054134341336239

MySQL

CREATE TABLE `tb_shop` (
`id` bigint unsigned NOT NULL AUTO_INCREMENT COMMENT '主键',
`name` varchar(128) NOT NULL COMMENT '商铺名称',
`type_id` bigint unsigned NOT NULL COMMENT '商铺类型的id',
`images` varchar(1024) NOT NULL COMMENT '商铺图片,多个图片以'',''隔开',
`area` varchar(128) DEFAULT NULL COMMENT '商圈,例如陆家嘴',
`address` varchar(255) NOT NULL COMMENT '地址',
`x` double unsigned NOT NULL COMMENT '经度',
`y` double unsigned NOT NULL COMMENT '维度',
`avg_price` bigint unsigned DEFAULT NULL COMMENT '均价,取整数',
`sold` int(10) unsigned zerofill NOT NULL COMMENT '销量',
`comments` int(10) unsigned zerofill NOT NULL COMMENT '评论数量',
`score` int(2) unsigned zerofill NOT NULL COMMENT '评分,1~5分,乘10保存,避免小数',
`open_hours` varchar(32) DEFAULT NULL COMMENT '营业时间,例如 10:00-22:00',
`create_time` timestamp NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
`update_time` timestamp NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
PRIMARY KEY (`id`) USING BTREE,
KEY `foreign_key_type` (`type_id`) USING BTREE
) ENGINE=InnoDB AUTO_INCREMENT=15 ROW_FORMAT=COMPACT;

数据准备

先将MySQL中的店铺信息存储到Redis中

@Test
void loadShopData() {
List<Shop> list = shopService.list();
Map<Long, List<Shop>> map = list.stream().collect(Collectors.groupingBy(Shop::getTypeId));
for (Map.Entry<Long, List<Shop>> entry : map.entrySet()) {
Long typeId = entry.getKey();
String key = "shop:geo:" + typeId;
List<Shop> value = entry.getValue();
List<RedisGeoCommands.GeoLocation<String>> locations = new ArrayList<>();
for (Shop shop : value) {
locations.add(new RedisGeoCommands.GeoLocation<>(String.valueOf(shop.getId()),
new Point(shop.getX(), shop.getY())));
}
stringRedisTemplate.opsForGeo().add(key, locations);
}
}

实现附近商户功能

接口说明

说明
请求方式 GET
请求路径 /shop/of/type
请求参数 typeId:商户类型
current:页码,滑动刷新
x:经度
y:纬度
返回值 List:符合要求的商户信息

版本说明

SpringDataReids的2.3.9版本不支持Redis6.2提供的GEOSEARCH命令,因此需要修改版本。

<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-redis</artifactId>
<exclusions>
<exclusion>
<artifactId>lettuce-core</artifactId>
<groupId>io.lettuce</groupId>
</exclusion>
<exclusion>
<artifactId>spring-data-redis</artifactId>
<groupId>org.springframework.data</groupId>
</exclusion>
</exclusions>
</dependency>
<dependency>
<groupId>org.springframework.data</groupId>
<artifactId>spring-data-redis</artifactId>
<version>2.6.2</version>
</dependency>
<dependency>
<groupId>io.lettuce</groupId>
<artifactId>lettuce-core</artifactId>
<version>6.1.6.RELEASE</version>
</dependency>

更多GEOSEARCH操作见:

实体类

点击查看实体类
@Data
@EqualsAndHashCode(callSuper = false)
@Accessors(chain = true)
@TableName("tb_shop")
public class Shop implements Serializable {

private static final long serialVersionUID = 1L;

/**
* 主键
*/
@TableId(value = "id", type = IdType.AUTO)
private Long id;

/**
* 商铺名称
*/
private String name;

/**
* 商铺类型的id
*/
private Long typeId;

/**
* 商铺图片,多个图片以','隔开
*/
private String images;

/**
* 商圈,例如陆家嘴
*/
private String area;

/**
* 地址
*/
private String address;

/**
* 经度
*/
private Double x;

/**
* 维度
*/
private Double y;

/**
* 均价,取整数
*/
private Long avgPrice;

/**
* 销量
*/
private Integer sold;

/**
* 评论数量
*/
private Integer comments;

/**
* 评分,1~5分,乘10保存,避免小数
*/
private Integer score;

/**
* 营业时间,例如 10:00-22:00
*/
private String openHours;

/**
* 创建时间
*/
private LocalDateTime createTime;

/**
* 更新时间
*/
private LocalDateTime updateTime;


@TableField(exist = false)
private Double distance;
}

Controller

其中x, y可以不传入

/**
* 根据商铺类型分页查询商铺信息
* @param typeId 商铺类型
* @param current 页码
* @return 商铺列表
*/
@GetMapping("/of/type")
public Result queryShopByType(
@RequestParam("typeId") Integer typeId,
@RequestParam(value = "current", defaultValue = "1") Integer current,
@RequestParam(value = "x", required = false) Double x,
@RequestParam(value = "y", required = false) Double y
) {
List<Shop> records = shopService.queryShopByType(typeId, current, x, y);
// 返回数据
return Result.ok(records);
}

ServiceImpl

@Resource
private StringRedisTemplate stringRedisTemplate;

public static final int DEFAULT_PAGE_SIZE = 5;
public static final String SHOP_GEO_KEY = "shop:geo:";

@Override
public List<Shop> queryShopByType(Integer typeId, Integer current, Double x, Double y) {
// 判断是否需要根据坐标查询
if (x == null || y == null) {
// 不需要坐标查询, 根据类型分页查询
Page<Shop> page = query()
.eq("type_id", typeId)
.page(new Page<>(current, DEFAULT_PAGE_SIZE));
return page.getRecords();
}
// 计算分页参数
int from = (current - 1) * DEFAULT_PAGE_SIZE;
int end = current * DEFAULT_PAGE_SIZE;
// 查询redis
String key = SHOP_GEO_KEY + typeId;
GeoResults<RedisGeoCommands.GeoLocation<String>> results = stringRedisTemplate
.opsForGeo()
.search(key,
GeoReference.fromCoordinate(x, y),
new Distance(5000),
RedisGeoCommands.GeoSearchCommandArgs.newGeoSearchArgs().includeDistance().limit(end));
if (results == null) {
return Collections.emptyList();
}
// 逻辑分页
List<GeoResult<RedisGeoCommands.GeoLocation<String>>> list = results.getContent();
if (list.size() <= from) {
// 数据不足,直接返回空集合
return Collections.emptyList();
}
Map<String, Distance> distanceMap = new HashMap<>(list.size());
ArrayList<Long> ids = new ArrayList<>();
list.stream().skip(from).forEach(result -> {
String shopIdStr = result.getContent().getName();
ids.add(Long.valueOf(shopIdStr));
Distance distance = result.getDistance();
distanceMap.put(shopIdStr, distance);
});
// 使用in查询,并按顺序返回查询结果
String idStr = StrUtil.join(",", ids);
List<Shop> shops = query().in("id", ids).last("ORDER BY FIELD(id," + idStr + ")").list();
// 填入Distance
for (Shop shop : shops) {
shop.setDistance(distanceMap.get(shop.getId().toString()).getValue());
}
return shops;
}

由于limit()只能传入查询数量,所以需要做逻辑分页,使用stream().skip(from)可以跳过from以及之前的。