feat: #193 #194 ユーザー詳細画面で使われるAPIを実装

This commit is contained in:
usbharu 2023-12-06 21:11:59 +09:00
parent e40e600547
commit e722ddd910
5 changed files with 345 additions and 10 deletions

View File

@ -4,9 +4,11 @@ import dev.usbharu.hideout.core.domain.model.media.toMediaAttachments
import dev.usbharu.hideout.core.infrastructure.exposedrepository.*
import dev.usbharu.hideout.domain.mastodon.model.generated.Account
import dev.usbharu.hideout.domain.mastodon.model.generated.Status
import dev.usbharu.hideout.domain.mastodon.model.generated.Status.Visibility.*
import dev.usbharu.hideout.mastodon.interfaces.api.status.StatusQuery
import dev.usbharu.hideout.mastodon.query.StatusQueryService
import org.jetbrains.exposed.sql.ResultRow
import org.jetbrains.exposed.sql.andWhere
import org.jetbrains.exposed.sql.select
import org.springframework.stereotype.Repository
import java.time.Instant
@ -40,6 +42,62 @@ class StatusQueryServiceImpl : StatusQueryService {
}
}
override suspend fun accountsStatus(
accountId: Long,
maxId: Long?,
sinceId: Long?,
minId: Long?,
limit: Int,
onlyMedia: Boolean,
excludeReplies: Boolean,
excludeReblogs: Boolean,
pinned: Boolean,
tagged: String?,
includeFollowers: Boolean
): List<Status> {
val query = Posts
.leftJoin(PostsMedia)
.leftJoin(Users)
.leftJoin(Media)
.select { Posts.userId eq accountId }.limit(20)
if (maxId != null) {
query.andWhere { Posts.id eq maxId }
}
if (sinceId != null) {
query.andWhere { Posts.id eq sinceId }
}
if (minId != null) {
query.andWhere { Posts.id eq minId }
}
if (onlyMedia) {
query.andWhere { PostsMedia.mediaId.isNotNull() }
}
if (excludeReplies) {
query.andWhere { Posts.replyId.isNotNull() }
}
if (excludeReblogs) {
query.andWhere { Posts.repostId.isNotNull() }
}
if (includeFollowers) {
query.andWhere { Posts.visibility inList listOf(public.ordinal, unlisted.ordinal, private.ordinal) }
} else {
query.andWhere { Posts.visibility inList listOf(public.ordinal, unlisted.ordinal) }
}
val pairs = query.groupBy { it[Posts.id] }
.map { it.value }
.map {
toStatus(it.first()).copy(
mediaAttachments = it.mapNotNull { resultRow ->
resultRow.toMediaOrNull()?.toMediaAttachments()
}
) to it.first()[Posts.repostId]
}
return resolveReplyAndRepost(pairs)
}
private fun resolveReplyAndRepost(pairs: List<Pair<Status, Long?>>): List<Status> {
val statuses = pairs.map { it.first }
return pairs
@ -111,11 +169,11 @@ private fun toStatus(it: ResultRow) = Status(
),
content = it[Posts.text],
visibility = when (it[Posts.visibility]) {
0 -> Status.Visibility.public
1 -> Status.Visibility.unlisted
2 -> Status.Visibility.private
3 -> Status.Visibility.direct
else -> Status.Visibility.public
0 -> public
1 -> unlisted
2 -> private
3 -> direct
else -> public
},
sensitive = it[Posts.sensitive],
spoilerText = it[Posts.overview].orEmpty(),

View File

@ -3,11 +3,11 @@ package dev.usbharu.hideout.mastodon.interfaces.api.account
import dev.usbharu.hideout.application.external.Transaction
import dev.usbharu.hideout.controller.mastodon.generated.AccountApi
import dev.usbharu.hideout.core.service.user.UserCreateDto
import dev.usbharu.hideout.domain.mastodon.model.generated.Account
import dev.usbharu.hideout.domain.mastodon.model.generated.CredentialAccount
import dev.usbharu.hideout.domain.mastodon.model.generated.FollowRequestBody
import dev.usbharu.hideout.domain.mastodon.model.generated.Relationship
import dev.usbharu.hideout.domain.mastodon.model.generated.*
import dev.usbharu.hideout.mastodon.service.account.AccountApiService
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.asFlow
import kotlinx.coroutines.runBlocking
import org.springframework.http.HttpHeaders
import org.springframework.http.HttpStatus
import org.springframework.http.ResponseEntity
@ -58,4 +58,49 @@ class MastodonAccountApiController(
httpHeaders.location = URI("/users/$username")
return ResponseEntity(Unit, httpHeaders, HttpStatus.FOUND)
}
override fun apiV1AccountsIdStatusesGet(
id: String,
maxId: String?,
sinceId: String?,
minId: String?,
limit: Int,
onlyMedia: Boolean,
excludeReplies: Boolean,
excludeReblogs: Boolean,
pinned: Boolean,
tagged: String?
): ResponseEntity<Flow<Status>> = runBlocking {
val principal = SecurityContextHolder.getContext().getAuthentication().principal as Jwt
val userid = principal.getClaim<String>("uid").toLong()
val statusFlow = accountApiService.accountsStatuses(
id.toLong(),
maxId?.toLongOrNull(),
sinceId?.toLongOrNull(),
minId?.toLongOrNull(),
limit,
onlyMedia,
excludeReplies,
excludeReblogs,
pinned,
tagged,
userid
).asFlow()
ResponseEntity.ok(statusFlow)
}
override fun apiV1AccountsRelationshipsGet(
id: List<String>?,
withSuspended: Boolean
): ResponseEntity<Flow<Relationship>> = runBlocking {
val principal = SecurityContextHolder.getContext().getAuthentication().principal as Jwt
val userid = principal.getClaim<String>("uid").toLong()
ResponseEntity.ok(
accountApiService.relationships(userid, id.orEmpty().mapNotNull { it.toLongOrNull() }, withSuspended)
.asFlow()
)
}
}

View File

@ -6,4 +6,34 @@ import dev.usbharu.hideout.mastodon.interfaces.api.status.StatusQuery
interface StatusQueryService {
suspend fun findByPostIds(ids: List<Long>): List<Status>
suspend fun findByPostIdsWithMediaIds(statusQueries: List<StatusQuery>): List<Status>
/**
* アカウントの投稿一覧を取得します
*
* @param accountId 対象アカウントのid
* @param maxId 投稿の最大id
* @param sinceId 投稿の最小id
* @param minId 不明
* @param limit 投稿の最大件数
* @param onlyMedia メディア付き投稿のみ
* @param excludeReplies 返信を除外
* @param excludeReblogs リブログを除外
* @param pinned ピン止め投稿のみ
* @param tagged タグ付き?
* @param includeFollowers フォロワー限定投稿を含める
*/
@Suppress("LongParameterList")
suspend fun accountsStatus(
accountId: Long,
maxId: Long? = null,
sinceId: Long? = null,
minId: Long? = null,
limit: Int,
onlyMedia: Boolean = false,
excludeReplies: Boolean = false,
excludeReblogs: Boolean = false,
pinned: Boolean = false,
tagged: String? = null,
includeFollowers: Boolean = false
): List<Status>
}

View File

@ -6,14 +6,31 @@ import dev.usbharu.hideout.core.query.FollowerQueryService
import dev.usbharu.hideout.core.service.user.UserCreateDto
import dev.usbharu.hideout.core.service.user.UserService
import dev.usbharu.hideout.domain.mastodon.model.generated.*
import dev.usbharu.hideout.mastodon.query.StatusQueryService
import org.slf4j.LoggerFactory
import org.springframework.stereotype.Service
@Service
interface AccountApiService {
suspend fun accountsStatuses(
userid: Long,
maxId: Long?,
sinceId: Long?,
minId: Long?,
limit: Int,
onlyMedia: Boolean,
excludeReplies: Boolean,
excludeReblogs: Boolean,
pinned: Boolean,
tagged: String?,
loginUser: Long?
): List<Status>
suspend fun verifyCredentials(userid: Long): CredentialAccount
suspend fun registerAccount(userCreateDto: UserCreateDto): Unit
suspend fun follow(userid: Long, followeeId: Long): Relationship
suspend fun account(id: Long): Account
suspend fun relationships(userid: Long, id: List<Long>, withSuspended: Boolean): List<Relationship>
}
@Service
@ -22,9 +39,48 @@ class AccountApiServiceImpl(
private val transaction: Transaction,
private val userService: UserService,
private val followerQueryService: FollowerQueryService,
private val userRepository: UserRepository
private val userRepository: UserRepository,
private val statusQueryService: StatusQueryService
) :
AccountApiService {
override suspend fun accountsStatuses(
userid: Long,
maxId: Long?,
sinceId: Long?,
minId: Long?,
limit: Int,
onlyMedia: Boolean,
excludeReplies: Boolean,
excludeReblogs: Boolean,
pinned: Boolean,
tagged: String?,
loginUser: Long?
): List<Status> {
val canViewFollowers = if (loginUser == null) {
false
} else {
transaction.transaction {
followerQueryService.alreadyFollow(userid, loginUser)
}
}
return transaction.transaction {
statusQueryService.accountsStatus(
userid,
maxId,
sinceId,
minId,
limit,
onlyMedia,
excludeReplies,
excludeReblogs,
pinned,
tagged,
canViewFollowers
)
}
}
override suspend fun verifyCredentials(userid: Long): CredentialAccount = transaction.transaction {
val account = accountService.findById(userid)
from(account)
@ -70,6 +126,42 @@ class AccountApiServiceImpl(
return@transaction accountService.findById(id)
}
override suspend fun relationships(userid: Long, id: List<Long>, withSuspended: Boolean): List<Relationship> {
if (id.isEmpty()) {
return emptyList()
}
logger.warn("id is too long! ({}) truncate to 20", id.size)
val subList = id.subList(0, 20)
return subList.map {
val alreadyFollow = followerQueryService.alreadyFollow(userid, it)
val followed = followerQueryService.alreadyFollow(it, userid)
val requested = userRepository.findFollowRequestsById(it, userid)
Relationship(
id = it.toString(),
following = alreadyFollow,
showingReblogs = true,
notifying = false,
followedBy = followed,
blocking = false,
blockedBy = false,
muting = false,
mutingNotifications = false,
requested = requested,
domainBlocking = false,
endorsed = false,
note = ""
)
}
}
private fun from(account: Account): CredentialAccount {
return CredentialAccount(
id = account.id,
@ -107,4 +199,8 @@ class AccountApiServiceImpl(
role = Role(0, "Admin", "", 32)
)
}
companion object {
private val logger = LoggerFactory.getLogger(AccountApiServiceImpl::class.java)
}
}

View File

@ -206,6 +206,37 @@ paths:
200:
description: 成功
/api/v1/accounts/relationships:
get:
tags:
- account
security:
- OAuth2:
- "read:follows"
parameters:
- in: query
name: id
required: false
schema:
type: array
items:
type: string
- in: query
name: with_suspended
required: false
schema:
type: boolean
default: false
responses:
200:
description: 成功
content:
application/json:
schema:
type: array
items:
$ref: "#/components/schemas/Relationship"
/api/v1/accounts/{id}:
get:
tags:
@ -255,6 +286,81 @@ paths:
application/json:
schema:
$ref: "#/components/schemas/Relationship"
/api/v1/accounts/{id}/statuses:
get:
tags:
- account
security:
- OAuth2:
- "read:statuses"
parameters:
- in: path
name: id
required: true
schema:
type: string
- in: query
name: max_id
required: false
schema:
type: string
- in: query
name: since_id
required: false
schema:
type: string
- in: query
name: min_id
required: false
schema:
type: string
- in: query
name: limit
required: false
schema:
type: integer
default: 20
- in: query
name: only_media
required: false
schema:
type: boolean
default: false
- in: query
name: exclude_replies
required: false
schema:
type: boolean
default: false
- in: query
name: exclude_reblogs
required: false
schema:
type: boolean
default: false
- in: query
name: pinned
required: false
schema:
type: boolean
default: false
- in: query
required: false
name: tagged
schema:
type: string
responses:
200:
description: 成功
content:
application/json:
schema:
type: array
items:
$ref: "#/components/schemas/Status"
/api/v1/timelines/public:
get:
tags: