diff --git a/src/main/kotlin/plus/maa/backend/controller/UserFollowController.kt b/src/main/kotlin/plus/maa/backend/controller/UserFollowController.kt new file mode 100644 index 00000000..1f2d8f48 --- /dev/null +++ b/src/main/kotlin/plus/maa/backend/controller/UserFollowController.kt @@ -0,0 +1,86 @@ +package plus.maa.backend.controller + +import io.swagger.v3.oas.annotations.Operation +import io.swagger.v3.oas.annotations.responses.ApiResponse +import io.swagger.v3.oas.annotations.tags.Tag +import jakarta.validation.constraints.Max +import org.springframework.data.domain.Page +import org.springframework.data.domain.PageRequest +import org.springframework.web.bind.annotation.GetMapping +import org.springframework.web.bind.annotation.PathVariable +import org.springframework.web.bind.annotation.PostMapping +import org.springframework.web.bind.annotation.RequestMapping +import org.springframework.web.bind.annotation.RequestParam +import org.springframework.web.bind.annotation.RestController +import plus.maa.backend.config.doc.RequireJwt +import plus.maa.backend.config.security.AuthenticationHelper +import plus.maa.backend.controller.response.MaaResult +import plus.maa.backend.controller.response.MaaResult.Companion.fail +import plus.maa.backend.controller.response.MaaResult.Companion.success +import plus.maa.backend.controller.response.user.MaaUserInfo +import plus.maa.backend.service.UserFollowService + +@RestController +@RequestMapping("/follow") +@Tag(name = "UserFollow", description = "用户关注管理接口") +class UserFollowController( + private val userFollowService: UserFollowService, + private val helper: AuthenticationHelper, +) { + + @Operation(summary = "关注用户") + @ApiResponse(description = "关注结果") + @RequireJwt + @PostMapping("/follow/{followUserId}") + fun follow(@PathVariable followUserId: String): MaaResult = success( + userFollowService.follow(helper.requireUserId(), followUserId), + ) + + @Operation(summary = "取消关注") + @ApiResponse(description = "取消关注结果") + @RequireJwt + @PostMapping("/unfollow/{followUserId}") + fun unfollow(@PathVariable followUserId: String): MaaResult = success( + userFollowService.unfollow(helper.requireUserId(), followUserId), + ) + + @Operation(summary = "获取关注列表") + @ApiResponse(description = "关注列表") + @RequireJwt + @GetMapping("/followingList") + fun getFollowingList( + @RequestParam page: Int = 1, + @RequestParam @Max(value = 50, message = "单页大小不得超过50") size: Int = 10): MaaResult> { + // 之前的API约定分页从1开始,与Spring默认约定不同,在此转换 + if (page < 1) { + return fail(422, "页数请从1开始") + } + val realPageable = PageRequest.of( + page - 1, + size, + ) + return success( + userFollowService.getFollowingList(helper.requireUserId(), realPageable), + ) + } + + @Operation(summary = "获取粉丝列表") + @ApiResponse(description = "粉丝列表") + @RequireJwt + @GetMapping("/fansList") + fun getFansList( + @RequestParam page: Int = 1, + @RequestParam @Max(value = 50, message = "单页大小不得超过50") size: Int = 10): MaaResult> { + // 之前的API约定分页从1开始,与Spring默认约定不同,在此转换 + if (page < 1) { + return fail(422, "页数请从1开始") + } + val realPageable = PageRequest.of( + page - 1, + size, + ) + return success( + userFollowService.getFansList(helper.requireUserId(), realPageable), + ) + } +} diff --git a/src/main/kotlin/plus/maa/backend/controller/request/copilot/CopilotQueriesRequest.kt b/src/main/kotlin/plus/maa/backend/controller/request/copilot/CopilotQueriesRequest.kt index 96d5e866..4ddeac43 100644 --- a/src/main/kotlin/plus/maa/backend/controller/request/copilot/CopilotQueriesRequest.kt +++ b/src/main/kotlin/plus/maa/backend/controller/request/copilot/CopilotQueriesRequest.kt @@ -6,7 +6,7 @@ import plus.maa.backend.service.model.CopilotSetStatus /** * @author LoMu - * Date 2022-12-26 2:48 + * Date 2022-12-26 2:48 */ data class CopilotQueriesRequest( val page: Int = 0, @@ -22,4 +22,5 @@ data class CopilotQueriesRequest( val language: String? = null, @BindParam("copilot_ids") var copilotIds: List? = null, val status: CopilotSetStatus? = null, + val onlyFollowing: Boolean = false, ) diff --git a/src/main/kotlin/plus/maa/backend/controller/request/copilotset/CopilotSetQuery.kt b/src/main/kotlin/plus/maa/backend/controller/request/copilotset/CopilotSetQuery.kt index ae6a0ce9..dce7e1ab 100644 --- a/src/main/kotlin/plus/maa/backend/controller/request/copilotset/CopilotSetQuery.kt +++ b/src/main/kotlin/plus/maa/backend/controller/request/copilotset/CopilotSetQuery.kt @@ -22,6 +22,8 @@ data class CopilotSetQuery( val keyword: String? = null, @Schema(title = "创建者id") val creatorId: String? = null, + @Schema(title = "仅查询关注者的作业集") + var onlyFollowing: Boolean = false, @Schema(title = "需要包含的作业id列表") val copilotIds: List? = null, ) diff --git a/src/main/kotlin/plus/maa/backend/controller/response/user/MaaUserInfo.kt b/src/main/kotlin/plus/maa/backend/controller/response/user/MaaUserInfo.kt index 3c4a2dd2..a272927a 100644 --- a/src/main/kotlin/plus/maa/backend/controller/response/user/MaaUserInfo.kt +++ b/src/main/kotlin/plus/maa/backend/controller/response/user/MaaUserInfo.kt @@ -11,6 +11,14 @@ data class MaaUserInfo( val id: String, val userName: String, val activated: Boolean = false, + val followingCount: Int = 0, + val fansCount: Int = 0, ) { - constructor(user: MaaUser) : this(user.userId!!, user.userName, user.status == 1) + constructor(user: MaaUser) : this( + id = user.userId!!, + userName = user.userName, + activated = user.status == 1, + followingCount = user.followingCount, + fansCount = user.fansCount, + ) } diff --git a/src/main/kotlin/plus/maa/backend/repository/UserFansRepository.kt b/src/main/kotlin/plus/maa/backend/repository/UserFansRepository.kt new file mode 100644 index 00000000..3b59b111 --- /dev/null +++ b/src/main/kotlin/plus/maa/backend/repository/UserFansRepository.kt @@ -0,0 +1,8 @@ +package plus.maa.backend.repository + +import org.springframework.data.mongodb.repository.MongoRepository +import plus.maa.backend.repository.entity.UserFans + +interface UserFansRepository : MongoRepository { + fun findByUserId(userId: String): UserFans? +} diff --git a/src/main/kotlin/plus/maa/backend/repository/UserFollowingRepository.kt b/src/main/kotlin/plus/maa/backend/repository/UserFollowingRepository.kt new file mode 100644 index 00000000..26d8ef02 --- /dev/null +++ b/src/main/kotlin/plus/maa/backend/repository/UserFollowingRepository.kt @@ -0,0 +1,8 @@ +package plus.maa.backend.repository + +import org.springframework.data.mongodb.repository.MongoRepository +import plus.maa.backend.repository.entity.UserFollowing + +interface UserFollowingRepository : MongoRepository { + fun findByUserId(userId: String): UserFollowing? +} diff --git a/src/main/kotlin/plus/maa/backend/repository/entity/MaaUser.kt b/src/main/kotlin/plus/maa/backend/repository/entity/MaaUser.kt index 3dd15612..1d8db785 100644 --- a/src/main/kotlin/plus/maa/backend/repository/entity/MaaUser.kt +++ b/src/main/kotlin/plus/maa/backend/repository/entity/MaaUser.kt @@ -23,6 +23,8 @@ data class MaaUser( var password: String, var status: Int = 0, var pwdUpdateTime: Instant = Instant.MIN, + var followingCount: Int = 0, + var fansCount: Int = 0, ) : Serializable { companion object { diff --git a/src/main/kotlin/plus/maa/backend/repository/entity/UserFans.kt b/src/main/kotlin/plus/maa/backend/repository/entity/UserFans.kt new file mode 100644 index 00000000..deba11e4 --- /dev/null +++ b/src/main/kotlin/plus/maa/backend/repository/entity/UserFans.kt @@ -0,0 +1,14 @@ +package plus.maa.backend.repository.entity + +import org.springframework.data.annotation.Id +import org.springframework.data.mongodb.core.mapping.Document +import java.time.Instant + +@Document("user_fans") +data class UserFans( + @Id + val id: String? = null, + val userId: String, + val fansList: MutableList = mutableListOf(), + var updatedAt: Instant = Instant.now(), +) diff --git a/src/main/kotlin/plus/maa/backend/repository/entity/UserFollowing.kt b/src/main/kotlin/plus/maa/backend/repository/entity/UserFollowing.kt new file mode 100644 index 00000000..3b32f202 --- /dev/null +++ b/src/main/kotlin/plus/maa/backend/repository/entity/UserFollowing.kt @@ -0,0 +1,14 @@ +package plus.maa.backend.repository.entity + +import org.springframework.data.annotation.Id +import org.springframework.data.mongodb.core.mapping.Document +import java.time.Instant + +@Document("user_following") +data class UserFollowing( + @Id + val id: String? = null, + val userId: String, + val followList: MutableList = mutableListOf(), + var updatedAt: Instant = Instant.now(), +) diff --git a/src/main/kotlin/plus/maa/backend/service/CopilotService.kt b/src/main/kotlin/plus/maa/backend/service/CopilotService.kt index 60d15934..5fbe8e50 100644 --- a/src/main/kotlin/plus/maa/backend/service/CopilotService.kt +++ b/src/main/kotlin/plus/maa/backend/service/CopilotService.kt @@ -30,6 +30,7 @@ import plus.maa.backend.controller.response.copilot.CopilotPageInfo import plus.maa.backend.repository.CommentsAreaRepository import plus.maa.backend.repository.CopilotRepository import plus.maa.backend.repository.RedisCache +import plus.maa.backend.repository.UserFollowingRepository import plus.maa.backend.repository.entity.Copilot import plus.maa.backend.repository.entity.Copilot.OperationGroup import plus.maa.backend.repository.entity.MaaUser @@ -70,6 +71,7 @@ class CopilotService( private val copilotConverter: CopilotConverter, private val sensitiveWordService: SensitiveWordService, private val segmentService: SegmentService, + private val userFollowingRepository: UserFollowingRepository, ) { private val log = KotlinLogging.logger { } @@ -198,7 +200,8 @@ class CopilotService( request.levelKeyword.isNullOrBlank() && request.uploaderId.isNullOrBlank() && request.operator.isNullOrBlank() && - request.copilotIds.isNullOrEmpty() + request.copilotIds.isNullOrEmpty() && + !request.onlyFollowing ) { request.orderBy?.blankAsNull() ?.let { key -> HOME_PAGE_CACHE_CONFIG[key] } @@ -234,6 +237,16 @@ class CopilotService( andQueries.add(Criteria.where("delete").`is`(false)) + if (request.onlyFollowing && userId != null) { + val userFollowing = userFollowingRepository.findByUserId(userId) + val followingIds = userFollowing?.followList ?: emptyList() + + if (followingIds.isEmpty()) { + return CopilotPageInfo(false, 0, 0, emptyList()) + } + // 添加查询范围为关注者 + andQueries.add(Criteria.where("uploaderId").`in`(followingIds)) + } // 仅查询自己的作业时才展示所有数据,否则只查询公开作业 if (request.uploaderId == "me" && userId != null) { if (request.status != null) { diff --git a/src/main/kotlin/plus/maa/backend/service/CopilotSetService.kt b/src/main/kotlin/plus/maa/backend/service/CopilotSetService.kt index c9b78b12..0e068e1f 100644 --- a/src/main/kotlin/plus/maa/backend/service/CopilotSetService.kt +++ b/src/main/kotlin/plus/maa/backend/service/CopilotSetService.kt @@ -18,6 +18,7 @@ import plus.maa.backend.controller.request.copilotset.CopilotSetUpdateReq import plus.maa.backend.controller.response.copilotset.CopilotSetPageRes import plus.maa.backend.controller.response.copilotset.CopilotSetRes import plus.maa.backend.repository.CopilotSetRepository +import plus.maa.backend.repository.UserFollowingRepository import plus.maa.backend.repository.entity.CopilotSet import plus.maa.backend.service.model.CopilotSetStatus import java.time.LocalDateTime @@ -31,6 +32,7 @@ import java.util.regex.Pattern class CopilotSetService( private val idComponent: IdComponent, private val converter: CopilotSetConverter, + private val userFollowingRepository: UserFollowingRepository, private val repository: CopilotSetRepository, private val userService: UserService, private val mongoTemplate: MongoTemplate, @@ -123,6 +125,17 @@ class CopilotSetService( } andList.add(permissionCriterion) andList.add(Criteria.where("delete").`is`(false)) + + if (req.onlyFollowing == true && userId != null) { + val userFollowing = userFollowingRepository.findByUserId(userId) + val followingIds = userFollowing?.followList ?: emptyList() + if (followingIds.isEmpty()) { + return CopilotSetPageRes(false, 0, 0, mutableListOf()) + } + + andList.add(Criteria.where("creatorId").`in`(followingIds)) + } + if (!req.copilotIds.isNullOrEmpty()) { andList.add(Criteria.where("copilotIds").all(req.copilotIds)) } diff --git a/src/main/kotlin/plus/maa/backend/service/UserFollowService.kt b/src/main/kotlin/plus/maa/backend/service/UserFollowService.kt new file mode 100644 index 00000000..4c088bdf --- /dev/null +++ b/src/main/kotlin/plus/maa/backend/service/UserFollowService.kt @@ -0,0 +1,194 @@ +package plus.maa.backend.service + +import org.springframework.data.domain.Page +import org.springframework.data.domain.PageImpl +import org.springframework.data.domain.Pageable +import org.springframework.data.mongodb.core.MongoTemplate +import org.springframework.data.mongodb.core.query.Criteria +import org.springframework.data.mongodb.core.query.Query +import org.springframework.data.mongodb.core.query.Update +import org.springframework.stereotype.Service +import org.springframework.transaction.annotation.Transactional +import plus.maa.backend.controller.response.MaaResultException +import plus.maa.backend.controller.response.user.MaaUserInfo +import plus.maa.backend.repository.UserFansRepository +import plus.maa.backend.repository.UserFollowingRepository +import plus.maa.backend.repository.entity.MaaUser +import plus.maa.backend.repository.entity.UserFans +import plus.maa.backend.repository.entity.UserFollowing +import java.time.Instant + +@Service +class UserFollowService( + private val userFollowingRepository: UserFollowingRepository, + private val userFansRepository: UserFansRepository, + private val userService: UserService, + private val mongoTemplate: MongoTemplate, +) { + @Transactional + fun follow(userId: String, followUserId: String) { + require(userId != followUserId) { "不能关注自己" } + + // 检查被关注用户是否存在 + val targetUser = userService.findByUserIdOrDefault(followUserId) + if (targetUser == MaaUser.UNKNOWN) { + throw MaaResultException(404, "目标用户不存在") + } + + // 检查是否已经关注 + val followQuery = Query.query( + Criteria.where("userId").`is`(userId) + .and("followList").`in`(followUserId), + ) + if (mongoTemplate.exists(followQuery, UserFollowing::class.java)) { + print("已关注 不可重复关注!") + return + } + + // 更新关注列表 + val followUpdate = Update() + .addToSet("followList", followUserId) + .set("updatedAt", Instant.now()) + + mongoTemplate.upsert( + Query.query(Criteria.where("userId").`is`(userId)), + followUpdate, + UserFollowing::class.java, + ) + + // 更新粉丝列表 + val fansUpdate = Update() + .addToSet("fansList", userId) + .set("updatedAt", Instant.now()) + + mongoTemplate.upsert( + Query.query(Criteria.where("userId").`is`(followUserId)), + fansUpdate, + UserFans::class.java, + ) + + // 更新关注数量和粉丝数量 + + val followingCount = userFollowingRepository.findByUserId(userId)?.followList?.size ?: 0 + val fansCount = userFansRepository.findByUserId(followUserId)?.fansList?.size ?: 0 + + mongoTemplate.updateFirst( + Query.query(Criteria.where("userId").`is`(userId)), + Update().set("followingCount", followingCount), + MaaUser::class.java, + ) + + mongoTemplate.updateFirst( + Query.query(Criteria.where("userId").`is`(followUserId)), + Update().set("fansCount", fansCount), + MaaUser::class.java, + ) + } + + @Transactional + fun unfollow(userId: String, followUserId: String) { + require(userId != followUserId) { "不能取关自己" } + + // 检查是否已经关注 + val followQuery = Query.query( + Criteria.where("userId").`is`(userId) + .and("followList").`in`(followUserId), + ) + if (!mongoTemplate.exists(followQuery, UserFollowing::class.java)) { + return + } + + // 更新关注列表 + val followUpdate = Update() + .pull("followList", followUserId) + .set("updatedAt", Instant.now()) + + mongoTemplate.updateFirst( + Query.query(Criteria.where("userId").`is`(userId)), + followUpdate, + UserFollowing::class.java, + ) + + // 更新粉丝列表 + val fansUpdate = Update() + .pull("fansList", userId) + .set("updatedAt", Instant.now()) + + mongoTemplate.updateFirst( + Query.query(Criteria.where("userId").`is`(followUserId)), + fansUpdate, + UserFans::class.java, + ) + + // 更新关注数量和粉丝数量 + + val followingCount = userFollowingRepository.findByUserId(userId)?.followList?.size ?: 0 + val fansCount = userFansRepository.findByUserId(followUserId)?.fansList?.size ?: 0 + + mongoTemplate.updateFirst( + Query.query(Criteria.where("userId").`is`(userId)), + Update().set("followingCount", followingCount), + MaaUser::class.java, + ) + + mongoTemplate.updateFirst( + Query.query(Criteria.where("userId").`is`(followUserId)), + Update().set("fansCount", fansCount), + MaaUser::class.java, + ) + } + + fun getFollowingList(userId: String, pageable: Pageable): Page { + val following = userFollowingRepository.findByUserId(userId) + ?: return Page.empty(pageable) + + val followIds = following.followList + val total = followIds.size.toLong() + val start = pageable.offset.coerceAtMost(total) + val end = (start + pageable.pageSize).coerceAtMost(total) + + if (start >= total) { + return Page.empty(pageable) + } + + val pageIds = followIds.subList(start.toInt(), end.toInt()) + val users = mongoTemplate.find( + Query.query(Criteria.where("userId").`in`(pageIds)), // 注意这里用 userId 字段查询 + MaaUser::class.java, + ) + + val userMap = users.associateBy { it.userId } + val userInfos = pageIds.mapNotNull { id -> + userMap[id]?.let { MaaUserInfo(it) } + } + + return PageImpl(userInfos, pageable, total) + } + + fun getFansList(userId: String, pageable: Pageable): Page { + val fans = userFansRepository.findByUserId(userId) + ?: return Page.empty(pageable) + + val fanIds = fans.fansList + val total = fanIds.size.toLong() + val start = pageable.offset.coerceAtMost(total) + val end = (start + pageable.pageSize).coerceAtMost(total) + + if (start >= total) { + return Page.empty(pageable) + } + + val pageIds = fanIds.subList(start.toInt(), end.toInt()) + val users = mongoTemplate.find( + Query.query(Criteria.where("userId").`in`(pageIds)), + MaaUser::class.java, + ) + + val userMap = users.associateBy { it.userId } + val userInfos = pageIds.mapNotNull { id -> + userMap[id]?.let { MaaUserInfo(it) } + } + + return PageImpl(userInfos, pageable, total) + } +}