P10 | 景点管理:分页查询与全文搜索实现

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, "保存成功")
}

下一篇

P11 → 收藏与行程:用户行为类接口的设计模式

相关推荐
ZHENGZJM8 小时前
文档解析器:支持 PDF、DOCX、Markdown
react.js·pdf·全栈开发
ZHENGZJM8 小时前
前端流式通信 Hook:useBlogStream 详解
前端·全栈开发
ZHENGZJM7 天前
架构总览:Monorepo 结构与容器化部署
架构·go·react·全栈开发
一只小阿乐13 天前
vue前端处理流式数据
前端·javascript·ai·大模型·全栈开发·agentai
念念不忘 必有回响20 天前
架构演进实录:从“分布式接力”到“全栈合一
全栈开发·netxjs
AAA阿giao21 天前
从零到精通 NestJS:深度剖析待办事项(Todos)项目,全面解析 Nest 架构、模块与数据流
架构·typescript·node.js·nestjs·全栈开发·后端框架
奋斗的小鸟11111 个月前
文件格式转换新革命:智能编辑与高效工作流
aigc·openai·ai开发·访答
EXI-小洲2 个月前
2025年度总结 EXI-小洲:技术与生活两手抓
java·python·生活·年度总结·ai开发
TGITCIC2 个月前
RAG不是万能的,但没有RAG是万万不能的:8种主流架构全景解析
rag·ai agent·ai智能体·ai开发·ai agent开发·rag增强检索·rag架构