P11 | 收藏与行程:用户行为类接口的设计模式
💰 付费文章 | 第二阶段:后端开发
用户行为类接口的特点
「收藏」「点赞」「关注」「加入购物车」------这类接口有共同特征:
- 幂等性:重复操作结果一致(收藏已收藏的 = 无效果)
- 用户隔离:每个用户只能看到自己的数据
- 关联查询:返回数据时需要关联业务实体(景点名称、封面图等)
- 计数更新:有时需要更新关联实体的计数字段(收藏数、点赞数)
收藏功能实现
数据库表
CREATE TABLE `TOWN_FAVORITE` (
`COMM_ID` VARCHAR(32) NOT NULL PRIMARY KEY,
`USER_ID` VARCHAR(32) NOT NULL COMMENT '用户ID',
`ATTRACTION_ID` VARCHAR(32) NOT NULL COMMENT '景点ID',
`FLAG` INT DEFAULT 1 COMMENT '1有效 0取消',
`CREATE_TIME` DATETIME DEFAULT CURRENT_TIMESTAMP,
UNIQUE KEY `uk_user_attraction` (`USER_ID`, `ATTRACTION_ID`)
) COMMENT '用户收藏';
Service 实现
@Service
class FavoriteServiceImpl(
private val favoriteMapper: TownFavoriteMapper,
private val attractionMapper: TownAttractionMapper
) : FavoriteService {
/**
* 切换收藏状态(幂等)
*/
override fun toggle(userId: String, attractionId: String): Map<String, Any> {
// 查询是否已有记录
val existing = favoriteMapper.selectOne(
LambdaQueryWrapper<TownFavoriteEntity>()
.eq(TownFavoriteEntity::userId, userId)
.eq(TownFavoriteEntity::attractionId, attractionId)
)
return if (existing == null) {
// 首次收藏
val entity = TownFavoriteEntity().apply {
commId = IdGenerator.generate("FAV")
this.userId = userId
this.attractionId = attractionId
flag = 1
}
favoriteMapper.insert(entity)
mapOf("favorited" to true, "msg" to "已收藏")
} else if (existing.flag == 1) {
// 已收藏 → 取消
existing.flag = 0
favoriteMapper.updateById(existing)
mapOf("favorited" to false, "msg" to "已取消收藏")
} else {
// 之前取消过 → 重新收藏
existing.flag = 1
favoriteMapper.updateById(existing)
mapOf("favorited" to true, "msg" to "已收藏")
}
}
/**
* 查询收藏列表(关联景点数据)
*/
override fun getPageList(userId: String, page: Int, pageSize: Int): PageResult<Map<String, Any?>> {
val favorites = favoriteMapper.selectPage(
Page(page.toLong(), pageSize.toLong()),
LambdaQueryWrapper<TownFavoriteEntity>()
.eq(TownFavoriteEntity::userId, userId)
.eq(TownFavoriteEntity::flag, 1)
.orderByDesc(TownFavoriteEntity::createTime)
)
// 批量查询景点信息(避免 N+1 查询)
val attractionIds = favorites.records.map { it.attractionId }
val attractionMap = if (attractionIds.isNotEmpty()) {
attractionMapper.selectBatchIds(attractionIds).associateBy { it.commId }
} else emptyMap()
val result = favorites.records.mapNotNull { fav ->
val attraction = attractionMap[fav.attractionId] ?: return@mapNotNull null
mapOf(
"favoriteId" to fav.commId,
"attractionId" to attraction.commId,
"attractionName" to attraction.attName,
"coverImage" to attraction.coverImage,
"tags" to parseJsonList(attraction.tags),
"description" to (attraction.highlight ?: ""),
"createTime" to fav.createTime
)
}
return PageResult(result, favorites.total, page.toLong(), pageSize.toLong(), favorites.pages)
}
}
行程功能实现
数据库表
-- 行程主表
CREATE TABLE `TOWN_ITINERARY` (
`COMM_ID` VARCHAR(32) NOT NULL PRIMARY KEY,
`USER_ID` VARCHAR(32) NOT NULL COMMENT '创建者',
`CITY_ID` VARCHAR(32) COMMENT '城市ID',
`TITLE` VARCHAR(128) NOT NULL COMMENT '行程名称',
`START_DATE` DATE COMMENT '开始日期',
`END_DATE` DATE COMMENT '结束日期',
`IS_PUBLIC` INT DEFAULT 0 COMMENT '是否公开 0否 1是',
`ATTRACTION_COUNT` INT DEFAULT 0 COMMENT '景点数量(冗余)',
`FLAG` INT DEFAULT 1,
`CREATE_TIME` DATETIME DEFAULT CURRENT_TIMESTAMP,
`UPDATE_TIME` DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
) COMMENT '行程表';
-- 行程-景点关联表
CREATE TABLE `TOWN_ITINERARY_ITEM` (
`COMM_ID` VARCHAR(32) NOT NULL PRIMARY KEY,
`ITINERARY_ID` VARCHAR(32) NOT NULL COMMENT '行程ID',
`ATTRACTION_ID` VARCHAR(32) NOT NULL COMMENT '景点ID',
`DAY_NUM` INT DEFAULT 1 COMMENT '第几天',
`SORT` INT DEFAULT 0 COMMENT '当天顺序',
`REMARK` VARCHAR(256) COMMENT '备注',
`FLAG` INT DEFAULT 1,
`CREATE_TIME` DATETIME DEFAULT CURRENT_TIMESTAMP
) COMMENT '行程景点明细';
行程保存(创建/更新)
@Transactional
override fun saveItinerary(userId: String, params: Map<String, Any>): String {
val itineraryId = params["id"] as? String
// 1. 保存行程主体
val itinerary = if (itineraryId != null) {
itineraryMapper.selectById(itineraryId)?.also { entity ->
// 验证是当前用户的行程
require(entity.userId == userId) { "无权修改他人行程" }
} ?: throw BusinessException("行程不存在")
} else {
TownItineraryEntity().apply { commId = IdGenerator.generate("ITI"); this.userId = userId; flag = 1 }
}
itinerary.title = params["title"] as? String ?: "我的行程"
itinerary.cityId = params["cityId"] as? String
itinerary.startDate = parseDate(params["startDate"] as? String)
itinerary.endDate = parseDate(params["endDate"] as? String)
itinerary.isPublic = (params["isPublic"] as? Boolean)?.let { if (it) 1 else 0 } ?: 0
itineraryMapper.saveOrUpdate(itinerary)
// 2. 保存景点明细(先删后插)
@Suppress("UNCHECKED_CAST")
val items = params["items"] as? List<Map<String, Any>> ?: emptyList()
itineraryItemMapper.delete(
LambdaQueryWrapper<TownItineraryItemEntity>()
.eq(TownItineraryItemEntity::itineraryId, itinerary.commId)
)
items.forEachIndexed { index, item ->
val itemEntity = TownItineraryItemEntity().apply {
commId = IdGenerator.generate("ITM")
itineraryId = itinerary.commId
attractionId = item["attractionId"] as? String
dayNum = item["dayNum"] as? Int ?: 1
sort = index
remark = item["remark"] as? String
flag = 1
}
itineraryItemMapper.insert(itemEntity)
}
// 3. 更新景点数量
itinerary.attractionCount = items.size
itineraryMapper.updateById(itinerary)
return itinerary.commId!!
}