Merge pull request #61 from usbharu/feature/timeline

Feature/timeline
This commit is contained in:
usbharu 2023-09-30 11:28:50 +09:00 committed by GitHub
commit 171a5ad26c
19 changed files with 595 additions and 19 deletions

View File

@ -110,6 +110,7 @@ dependencies {
implementation("com.fasterxml.jackson.datatype:jackson-datatype-jsr310")
implementation("com.fasterxml.jackson.module:jackson-module-kotlin")
implementation("org.springframework.security:spring-security-oauth2-jose")
implementation("org.springframework.boot:spring-boot-starter-data-mongodb")
implementation("io.ktor:ktor-client-logging-jvm:$ktor_version")

View File

@ -22,5 +22,5 @@ class MastodonStatusesApiContoller(private val statusesApiService: StatusesApiSe
statusesApiService.postStatus(statusesRequest, jwt.getClaim<String>("uid").toLong()),
HttpStatus.OK
)
}
}
}

View File

@ -0,0 +1,52 @@
package dev.usbharu.hideout.controller.mastodon
import dev.usbharu.hideout.controller.mastodon.generated.TimelineApi
import dev.usbharu.hideout.domain.mastodon.model.generated.Status
import dev.usbharu.hideout.service.api.mastodon.TimelineApiService
import kotlinx.coroutines.runBlocking
import org.springframework.http.HttpStatus
import org.springframework.http.ResponseEntity
import org.springframework.security.core.context.SecurityContextHolder
import org.springframework.security.oauth2.jwt.Jwt
import org.springframework.stereotype.Controller
@Controller
class MastodonTimelineApiController(private val timelineApiService: TimelineApiService) : TimelineApi {
override fun apiV1TimelinesHomeGet(
maxId: String?,
sinceId: String?,
minId: String?,
limit: Int?
): ResponseEntity<List<Status>> = runBlocking {
val jwt = SecurityContextHolder.getContext().authentication.principal as Jwt
val homeTimeline = timelineApiService.homeTimeline(
userId = jwt.getClaim<String>("uid").toLong(),
maxId = maxId?.toLongOrNull(),
minId = minId?.toLongOrNull(),
sinceId = sinceId?.toLongOrNull(),
limit = limit ?: 20
)
ResponseEntity(homeTimeline, HttpStatus.OK)
}
override fun apiV1TimelinesPublicGet(
local: Boolean?,
remote: Boolean?,
onlyMedia: Boolean?,
maxId: String?,
sinceId: String?,
minId: String?,
limit: Int?
): ResponseEntity<List<Status>> = runBlocking {
val publicTimeline = timelineApiService.publicTimeline(
localOnly = local ?: false,
remoteOnly = remote ?: false,
mediaOnly = onlyMedia ?: false,
maxId = maxId?.toLongOrNull(),
minId = minId?.toLongOrNull(),
sinceId = sinceId?.toLongOrNull(),
limit = limit ?: 20
)
ResponseEntity(publicTimeline, HttpStatus.OK)
}
}

View File

@ -0,0 +1,18 @@
package dev.usbharu.hideout.domain.model.hideout.entity
import org.springframework.data.annotation.Id
data class Timeline(
@Id
val id: Long,
val userId: Long,
val timelineId: Long,
val postId: Long,
val postUserId: Long,
val createdAt: Long,
val replyId: Long?,
val repostId: Long?,
val visibility: Visibility,
val sensitive: Boolean,
val isLocal: Boolean
)

View File

@ -0,0 +1,7 @@
package dev.usbharu.hideout.query.mastodon
import dev.usbharu.hideout.domain.mastodon.model.generated.Status
interface StatusQueryService {
suspend fun findByPostIds(ids: List<Long>): List<Status>
}

View File

@ -0,0 +1,102 @@
package dev.usbharu.hideout.query.mastodon
import dev.usbharu.hideout.domain.mastodon.model.generated.Account
import dev.usbharu.hideout.domain.mastodon.model.generated.Status
import dev.usbharu.hideout.repository.Posts
import dev.usbharu.hideout.repository.Users
import org.jetbrains.exposed.sql.innerJoin
import org.jetbrains.exposed.sql.select
import org.springframework.stereotype.Repository
import java.time.Instant
@Repository
class StatusQueryServiceImpl : StatusQueryService {
@Suppress("LongMethod")
override suspend fun findByPostIds(ids: List<Long>): List<Status> {
val pairs = Posts.innerJoin(Users, onColumn = { userId }, otherColumn = { id })
.select { Posts.id inList ids }
.map {
Status(
id = it[Posts.id].toString(),
uri = it[Posts.apId],
createdAt = Instant.ofEpochMilli(it[Posts.createdAt]).toString(),
account = Account(
id = it[Users.id].toString(),
username = it[Users.name],
acct = "${it[Users.name]}@${it[Users.domain]}",
url = it[Users.url],
displayName = it[Users.screenName],
note = it[Users.description],
avatar = it[Users.url] + "/icon.jpg",
avatarStatic = it[Users.url] + "/icon.jpg",
header = it[Users.url] + "/header.jpg",
headerStatic = it[Users.url] + "/header.jpg",
locked = false,
fields = emptyList(),
emojis = emptyList(),
bot = false,
group = false,
discoverable = true,
createdAt = Instant.ofEpochMilli(it[Users.createdAt]).toString(),
lastStatusAt = Instant.ofEpochMilli(it[Users.createdAt]).toString(),
statusesCount = 0,
followersCount = 0,
followingCount = 0,
noindex = false,
moved = false,
suspendex = false,
limited = false
),
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
},
sensitive = it[Posts.sensitive],
spoilerText = it[Posts.overview].orEmpty(),
mediaAttachments = emptyList(),
mentions = emptyList(),
tags = emptyList(),
emojis = emptyList(),
reblogsCount = 0,
favouritesCount = 0,
repliesCount = 0,
url = it[Posts.apId],
inReplyToId = it[Posts.replyId].toString(),
inReplyToAccountId = null,
language = null,
text = it[Posts.text],
editedAt = null,
application = null,
poll = null,
card = null,
favourited = null,
reblogged = null,
muted = null,
bookmarked = null,
pinned = null,
filtered = null
) to it[Posts.repostId]
}
val statuses = pairs.map { it.first }
return pairs
.map {
if (it.second != null) {
it.first.copy(reblog = statuses.find { status -> status.id == it.second.toString() })
} else {
it.first
}
}
.map {
if (it.inReplyToId != null) {
it.copy(inReplyToAccountId = statuses.find { status -> status.id == it.inReplyToId }?.id)
} else {
it
}
}
}
}

View File

@ -0,0 +1,19 @@
package dev.usbharu.hideout.repository
import dev.usbharu.hideout.domain.model.hideout.entity.Timeline
import org.springframework.data.domain.Pageable
import org.springframework.data.mongodb.repository.MongoRepository
@Suppress("LongParameterList", "FunctionMaxLength")
interface MongoTimelineRepository : MongoRepository<Timeline, Long> {
fun findByUserId(id: Long): List<Timeline>
fun findByUserIdAndTimelineId(userId: Long, timelineId: Long): List<Timeline>
fun findByUserIdAndTimelineIdAndPostIdBetweenAndIsLocal(
userId: Long?,
timelineId: Long?,
postIdMin: Long?,
postIdMax: Long?,
isLocal: Boolean?,
pageable: Pageable
): List<Timeline>
}

View File

@ -0,0 +1,38 @@
package dev.usbharu.hideout.repository
import dev.usbharu.hideout.domain.model.hideout.entity.Timeline
import dev.usbharu.hideout.service.core.IdGenerateService
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import org.springframework.stereotype.Repository
@Repository
@Suppress("InjectDispatcher")
class MongoTimelineRepositoryWrapper(
private val mongoTimelineRepository: MongoTimelineRepository,
private val idGenerateService: IdGenerateService
) :
TimelineRepository {
override suspend fun generateId(): Long = idGenerateService.generateId()
override suspend fun save(timeline: Timeline): Timeline {
return withContext(Dispatchers.IO) {
mongoTimelineRepository.save(timeline)
}
}
override suspend fun saveAll(timelines: List<Timeline>): List<Timeline> =
mongoTimelineRepository.saveAll(timelines)
override suspend fun findByUserId(id: Long): List<Timeline> {
return withContext(Dispatchers.IO) {
mongoTimelineRepository.findByUserId(id)
}
}
override suspend fun findByUserIdAndTimelineId(userId: Long, timelineId: Long): List<Timeline> {
return withContext(Dispatchers.IO) {
mongoTimelineRepository.findByUserIdAndTimelineId(userId, timelineId)
}
}
}

View File

@ -0,0 +1,11 @@
package dev.usbharu.hideout.repository
import dev.usbharu.hideout.domain.model.hideout.entity.Timeline
interface TimelineRepository {
suspend fun generateId(): Long
suspend fun save(timeline: Timeline): Timeline
suspend fun saveAll(timelines: List<Timeline>): List<Timeline>
suspend fun findByUserId(id: Long): List<Timeline>
suspend fun findByUserIdAndTimelineId(userId: Long, timelineId: Long): List<Timeline>
}

View File

@ -17,6 +17,8 @@ import dev.usbharu.hideout.query.PostQueryService
import dev.usbharu.hideout.query.UserQueryService
import dev.usbharu.hideout.repository.PostRepository
import dev.usbharu.hideout.service.job.JobQueueParentService
import dev.usbharu.hideout.service.post.PostCreateInterceptor
import dev.usbharu.hideout.service.post.PostService
import io.ktor.client.*
import io.ktor.client.statement.*
import kjob.core.job.JobProps
@ -36,6 +38,7 @@ interface APNoteService {
}
@Service
@Suppress("LongParameterList")
class APNoteServiceImpl(
private val httpClient: HttpClient,
private val jobQueueParentService: JobQueueParentService,
@ -45,9 +48,14 @@ class APNoteServiceImpl(
private val followerQueryService: FollowerQueryService,
private val postQueryService: PostQueryService,
@Qualifier("activitypub") private val objectMapper: ObjectMapper,
private val applicationConfig: ApplicationConfig
private val applicationConfig: ApplicationConfig,
private val postService: PostService
) : APNoteService {
) : APNoteService, PostCreateInterceptor {
init {
postService.addInterceptor(this)
}
private val logger = LoggerFactory.getLogger(this::class.java)
@ -161,7 +169,7 @@ class APNoteServiceImpl(
postQueryService.findByUrl(it)
}
postRepository.save(
postService.createRemote(
Post.of(
id = postRepository.generateId(),
userId = person.second.id,
@ -182,6 +190,10 @@ class APNoteServiceImpl(
override suspend fun fetchNote(note: Note, targetActor: String?): Note =
note(note, targetActor, note.id ?: throw IllegalArgumentException("note.id is null"))
override suspend fun run(post: Post) {
createNote(post)
}
companion object {
const val public: String = "https://www.w3.org/ns/activitystreams#Public"
}

View File

@ -0,0 +1,71 @@
package dev.usbharu.hideout.service.api.mastodon
import dev.usbharu.hideout.domain.mastodon.model.generated.Status
import dev.usbharu.hideout.service.core.Transaction
import dev.usbharu.hideout.service.post.GenerateTimelineService
import org.springframework.stereotype.Service
@Suppress("LongParameterList")
interface TimelineApiService {
suspend fun publicTimeline(
localOnly: Boolean = false,
remoteOnly: Boolean = false,
mediaOnly: Boolean = false,
maxId: Long?,
minId: Long?,
sinceId: Long?,
limit: Int = 20
): List<Status>
suspend fun homeTimeline(
userId: Long,
maxId: Long?,
minId: Long?,
sinceId: Long?,
limit: Int = 20
): List<Status>
}
@Service
class TimelineApiServiceImpl(
private val generateTimelineService: GenerateTimelineService,
private val transaction: Transaction
) : TimelineApiService {
override suspend fun publicTimeline(
localOnly: Boolean,
remoteOnly: Boolean,
mediaOnly: Boolean,
maxId: Long?,
minId: Long?,
sinceId: Long?,
limit: Int
): List<Status> = transaction.transaction {
generateTimelineService.getTimeline(
forUserId = 0,
localOnly = localOnly,
mediaOnly = mediaOnly,
maxId = maxId,
minId = minId,
sinceId = sinceId,
limit = limit
)
}
override suspend fun homeTimeline(
userId: Long,
maxId: Long?,
minId: Long?,
sinceId: Long?,
limit: Int
): List<Status> = transaction.transaction {
generateTimelineService.getTimeline(
forUserId = userId,
localOnly = false,
mediaOnly = false,
maxId = maxId,
minId = minId,
sinceId = sinceId,
limit = limit
)
}
}

View File

@ -0,0 +1,18 @@
package dev.usbharu.hideout.service.post
import dev.usbharu.hideout.domain.mastodon.model.generated.Status
import org.springframework.stereotype.Service
@Service
@Suppress("LongParameterList")
interface GenerateTimelineService {
suspend fun getTimeline(
forUserId: Long? = null,
localOnly: Boolean = false,
mediaOnly: Boolean = false,
maxId: Long? = null,
minId: Long? = null,
sinceId: Long? = null,
limit: Int = 20
): List<Status>
}

View File

@ -0,0 +1,48 @@
package dev.usbharu.hideout.service.post
import dev.usbharu.hideout.domain.mastodon.model.generated.Status
import dev.usbharu.hideout.domain.model.hideout.entity.Timeline
import dev.usbharu.hideout.query.mastodon.StatusQueryService
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.stereotype.Service
@Service
class MongoGenerateTimelineService(
private val statusQueryService: StatusQueryService,
private val mongoTemplate: MongoTemplate
) :
GenerateTimelineService {
override suspend fun getTimeline(
forUserId: Long?,
localOnly: Boolean,
mediaOnly: Boolean,
maxId: Long?,
minId: Long?,
sinceId: Long?,
limit: Int
): List<Status> {
val query = Query()
if (forUserId != null) {
val criteria = Criteria.where("userId").`is`(forUserId)
query.addCriteria(criteria)
}
if (localOnly) {
val criteria = Criteria.where("isLocal").`is`(true)
query.addCriteria(criteria)
}
if (maxId != null) {
val criteria = Criteria.where("postId").lt(maxId)
query.addCriteria(criteria)
}
if (minId != null) {
val criteria = Criteria.where("postId").gt(minId)
query.addCriteria(criteria)
}
val timelines = mongoTemplate.find(query.limit(limit), Timeline::class.java)
return statusQueryService.findByPostIds(timelines.flatMap { setOfNotNull(it.postId, it.replyId, it.repostId) })
}
}

View File

@ -7,4 +7,10 @@ import org.springframework.stereotype.Service
@Service
interface PostService {
suspend fun createLocal(post: PostCreateDto): Post
suspend fun createRemote(post: Post): Post
fun addInterceptor(postCreateInterceptor: PostCreateInterceptor)
}
interface PostCreateInterceptor {
suspend fun run(post: Post)
}

View File

@ -5,17 +5,36 @@ import dev.usbharu.hideout.domain.model.hideout.entity.Post
import dev.usbharu.hideout.exception.UserNotFoundException
import dev.usbharu.hideout.repository.PostRepository
import dev.usbharu.hideout.repository.UserRepository
import dev.usbharu.hideout.service.ap.APNoteService
import org.springframework.stereotype.Service
import java.time.Instant
import java.util.*
@Service
class PostServiceImpl(
private val postRepository: PostRepository,
private val userRepository: UserRepository,
private val apNoteService: APNoteService
private val timelineService: TimelineService
) : PostService {
private val interceptors = Collections.synchronizedList(mutableListOf<PostCreateInterceptor>())
override suspend fun createLocal(post: PostCreateDto): Post {
val create = internalCreate(post, true)
interceptors.forEach { it.run(create) }
return create
}
override suspend fun createRemote(post: Post): Post = internalCreate(post, false)
override fun addInterceptor(postCreateInterceptor: PostCreateInterceptor) {
interceptors.add(postCreateInterceptor)
}
private suspend fun internalCreate(post: Post, isLocal: Boolean): Post {
timelineService.publishTimeline(post, isLocal)
return postRepository.save(post)
}
private suspend fun internalCreate(post: PostCreateDto, isLocal: Boolean): Post {
val user = userRepository.findById(post.userId) ?: throw UserNotFoundException("${post.userId} was not found")
val id = postRepository.generateId()
val createPost = Post.of(
@ -29,9 +48,6 @@ class PostServiceImpl(
repostId = null,
replyId = null
)
apNoteService.createNote(createPost)
return internalCreate(createPost)
return internalCreate(createPost, isLocal)
}
private suspend fun internalCreate(post: Post): Post = postRepository.save(post)
}

View File

@ -0,0 +1,58 @@
package dev.usbharu.hideout.service.post
import dev.usbharu.hideout.domain.model.hideout.entity.Post
import dev.usbharu.hideout.domain.model.hideout.entity.Timeline
import dev.usbharu.hideout.domain.model.hideout.entity.Visibility
import dev.usbharu.hideout.query.FollowerQueryService
import dev.usbharu.hideout.query.UserQueryService
import dev.usbharu.hideout.repository.TimelineRepository
import org.springframework.stereotype.Service
@Service
class TimelineService(
private val followerQueryService: FollowerQueryService,
private val userQueryService: UserQueryService,
private val timelineRepository: TimelineRepository
) {
suspend fun publishTimeline(post: Post, isLocal: Boolean) {
val findFollowersById = followerQueryService.findFollowersById(post.userId).toMutableList()
if (isLocal) {
// 自分自身も含める必要がある
val user = userQueryService.findById(post.userId)
findFollowersById.add(user)
}
val timelines = findFollowersById.map {
Timeline(
id = timelineRepository.generateId(),
userId = it.id,
timelineId = 0,
postId = post.id,
postUserId = post.userId,
createdAt = post.createdAt,
replyId = post.replyId,
repostId = post.repostId,
visibility = post.visibility,
sensitive = post.sensitive,
isLocal = isLocal
)
}.toMutableList()
if (post.visibility == Visibility.PUBLIC) {
timelines.add(
Timeline(
id = timelineRepository.generateId(),
userId = 0,
timelineId = 0,
postId = post.id,
postUserId = post.userId,
createdAt = post.createdAt,
replyId = post.replyId,
repostId = post.repostId,
visibility = post.visibility,
sensitive = post.sensitive,
isLocal = isLocal
)
)
}
timelineRepository.saveAll(timelines)
}
}

View File

@ -22,6 +22,13 @@ spring:
url: "jdbc:h2:./test-dev2;MODE=POSTGRESQL"
username: ""
password: ""
data:
mongodb:
host: localhost
port: 27017
database: hideout
# username: hideoutuser
# password: hideoutpass
h2:
console:

View File

@ -15,6 +15,8 @@ tags:
description: app
- name: instance
description: instance
- name: timeline
description: timeline
paths:
/api/v2/instance:
@ -202,6 +204,94 @@ paths:
200:
description: 成功
/api/v1/timelines/public:
get:
tags:
- timeline
parameters:
- in: query
name: local
required: false
schema:
type: boolean
- in: query
name: remote
required: false
schema:
type: boolean
- in: query
name: only_media
required: false
schema:
type: boolean
- 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
responses:
200:
description: 成功
content:
application/json:
schema:
type: array
items:
$ref: "#/components/schemas/Status"
/api/v1/timelines/home:
get:
tags:
- timeline
security:
- OAuth2:
- "read:statuses"
parameters:
- 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
responses:
200:
description: 成功
content:
application/json:
schema:
type: array
items:
$ref: "#/components/schemas/Status"
components:
schemas:
AccountsCreateRequest:

View File

@ -80,15 +80,16 @@ class APNoteServiceImplTest {
val jobQueueParentService = mock<JobQueueParentService>()
val activityPubNoteService =
APNoteServiceImpl(
mock(),
jobQueueParentService,
mock(),
mock(),
userQueryService,
followerQueryService,
mock(),
httpClient = mock(),
jobQueueParentService = jobQueueParentService,
postRepository = mock(),
apUserService = mock(),
userQueryService = userQueryService,
followerQueryService = followerQueryService,
postQueryService = mock(),
objectMapper = objectMapper,
applicationConfig = testApplicationConfig
applicationConfig = testApplicationConfig,
postService = mock()
)
val postEntity = Post.of(
1L,
@ -121,7 +122,8 @@ class APNoteServiceImplTest {
followerQueryService = mock(),
postQueryService = mock(),
objectMapper = objectMapper,
applicationConfig = testApplicationConfig
applicationConfig = testApplicationConfig,
postService = mock()
)
activityPubNoteService.createNoteJob(
JobProps(