P10 | 景点管理:分页查询与全文搜索实现
💰 付费文章 | 第二阶段:后端开发
景点模块的接口清单
| 接口 | 说明 | 权限 |
|---|---|---|
| POST /web/attraction/list | 分页列表 | 无需登录 |
| POST /web/attraction/detail | 详情 | 无需登录 |
| POST /web/attraction/search | 搜索(关键词+标签+城市) | 无需登录 |
| POST /web/attraction/nearby | 附近景点(经纬度) | 无需登录 |
| POST /plat/attraction/save | 新增/编辑(管理台) | 管理员 |
| POST /plat/attraction/remove | 删除(管理台) | 管理员 |
| POST /plat/attraction/page | 管理台分页 | 管理员 |
分页查询实现
Service 层
@Service
class AttractionServiceImpl(
private val attractionMapper: TownAttractionMapper,
private val cityMapper: TownCityMapper
) : IService<TownAttractionEntity>, AttractionService {
override fun getPageList(params: Map<String, Any>): PageResult<Map<String, Any?>> {
val keyword = params["keyword"] as? String
val cityId = params["cityId"] as? String
val tags = params["tags"] as? List<*>
val sort = params["sort"] as? String ?: "sort" // sort/rating/default
val page = (params["page"] as? Int) ?: 1
val pageSize = (params["pageSize"] as? Int) ?: 10
val wrapper = LambdaQueryWrapper<TownAttractionEntity>()
.eq(TownAttractionEntity::flag, 1)
.eq(cityId != null, TownAttractionEntity::cityId, cityId)
.and(keyword != null) { w ->
w.like(TownAttractionEntity::attName, keyword)
.or().like(TownAttractionEntity::highlight, keyword)
.or().like(TownAttractionEntity::description, keyword)
}
// 排序
when (sort) {
"rating" -> wrapper.orderByDesc(TownAttractionEntity::kidsFriendly)
else -> wrapper.orderByDesc(TownAttractionEntity::sort)
}
val pageResult = attractionMapper.selectPage(
Page(page.toLong(), pageSize.toLong()), wrapper
)
// 标签过滤(因为 tags 存 JSON,数据库层不好过滤,在内存中过滤)
var records = pageResult.records
if (!tags.isNullOrEmpty()) {
records = records.filter { attraction ->
val attractionTags = parseJsonList(attraction.tags)
tags.any { it in attractionTags }
}
}
// 组装返回数据
val result = records.map { assembleAttractionCard(it) }
return PageResult(result, pageResult.total, page.toLong(), pageSize.toLong(), pageResult.pages)
}
// 组装卡片数据(字段映射:后端字段 → 前端展示字段)
private fun assembleAttractionCard(entity: TownAttractionEntity): Map<String, Any?> {
return mapOf(
"id" to entity.commId,
"name" to entity.attName,
"image" to entity.coverImage, // 前端用 image,后端存 coverImage
"images" to parseJsonList(entity.images),
"tags" to parseJsonList(entity.tags),
"rating" to "${entity.kidsFriendly ?: 0}星", // 亲子评分 → 前端 rating
"desc" to (entity.highlight ?: entity.description?.take(100)),
"address" to entity.address,
"ticketInfo" to entity.ticketInfo,
"openTime" to entity.openTimeDesc,
"duration" to entity.suggestDuration,
"latitude" to entity.latitude,
"longitude" to entity.longitude
)
}
private fun parseJsonList(json: String?): List<String> {
if (json.isNullOrBlank()) return emptyList()
return try {
ObjectMapper().readValue(json, List::class.java) as List<String>
} catch (e: Exception) {
emptyList()
}
}
}
搜索实现(关键词 + 标签 + 城市)
override fun search(params: Map<String, Any>): PageResult<Map<String, Any?>> {
val keyword = params["keyword"] as? String
val city = params["city"] as? String
val tags = params["tags"] as? List<*>
val page = (params["page"] as? Int) ?: 1
val pageSize = (params["pageSize"] as? Int) ?: 10
val wrapper = LambdaQueryWrapper<TownAttractionEntity>()
.eq(TownAttractionEntity::flag, 1)
// 城市过滤(支持城市名,不只是 cityId)
if (!city.isNullOrBlank()) {
// 先查城市ID
val cityEntity = cityMapper.selectOne(
LambdaQueryWrapper<TownCityEntity>().eq(TownCityEntity::cityName, city)
)
if (cityEntity != null) {
wrapper.eq(TownAttractionEntity::cityId, cityEntity.commId)
}
}
// 关键词搜索(景点名 + 亮点 + 描述)
if (!keyword.isNullOrBlank()) {
wrapper.and { w ->
w.like(TownAttractionEntity::attName, keyword)
.or().like(TownAttractionEntity::highlight, keyword)
.or().like(TownAttractionEntity::address, keyword)
}
}
wrapper.orderByDesc(TownAttractionEntity::sort)
val pageResult = attractionMapper.selectPage(Page(page.toLong(), pageSize.toLong()), wrapper)
// 标签过滤
var records = pageResult.records
if (!tags.isNullOrEmpty()) {
records = records.filter { attraction ->
val attractionTags = parseJsonList(attraction.tags)
tags.any { tag -> tag.toString() in attractionTags }
}
}
val result = records.map { assembleAttractionCard(it) }
return PageResult(result, pageResult.total, page.toLong(), pageSize.toLong(), pageResult.pages)
}
附近景点(地理距离计算)
override fun getNearby(latitude: Double, longitude: Double, radiusKm: Double): List<Map<String, Any?>> {
// 查出所有有坐标的景点
val attractions = attractionMapper.selectList(
LambdaQueryWrapper<TownAttractionEntity>()
.eq(TownAttractionEntity::flag, 1)
.isNotNull(TownAttractionEntity::latitude)
.isNotNull(TownAttractionEntity::longitude)
)
// 计算距离,过滤并排序
return attractions
.map { attraction ->
val distance = calculateDistance(
latitude, longitude,
attraction.latitude!!.toDouble(),
attraction.longitude!!.toDouble()
)
Pair(attraction, distance)
}
.filter { (_, distance) -> distance <= radiusKm }
.sortedBy { (_, distance) -> distance }
.take(20) // 最多返回20个
.map { (attraction, distance) ->
assembleAttractionCard(attraction) + mapOf(
"distance" to formatDistance(distance)
)
}
}
// Haversine 公式计算两点距离(km)
private fun calculateDistance(lat1: Double, lon1: Double, lat2: Double, lon2: Double): Double {
val R = 6371.0 // 地球半径(km)
val dLat = Math.toRadians(lat2 - lat1)
val dLon = Math.toRadians(lon2 - lon1)
val a = sin(dLat / 2).pow(2) + cos(Math.toRadians(lat1)) *
cos(Math.toRadians(lat2)) * sin(dLon / 2).pow(2)
return R * 2 * atan2(sqrt(a), sqrt(1 - a))
}
private fun formatDistance(km: Double): String {
return if (km < 1) "${(km * 1000).toInt()}m" else "${"%.1f".format(km)}km"
}
管理台接口
// POST /plat/attraction/save
@PostMapping("/save")
fun save(@RequestBody entity: TownAttractionEntity): Result<*> {
if (entity.commId.isNullOrBlank()) {
entity.commId = IdGenerator.generate("ATT")
entity.flag = 1
}
// tags 和 images 自动转 JSON 存储
attractionService.saveOrUpdate(entity)
return Result.ok(entity.commId, "保存成功")
}