feat: 削除された投稿はレコードを完全に削除せず、返信などの情報を維持したまま削除されるように

This commit is contained in:
usbharu 2023-12-13 15:02:17 +09:00
parent 8d1f339a5d
commit 8a18780963
12 changed files with 213 additions and 11 deletions

View File

@ -8,15 +8,15 @@ import dev.usbharu.hideout.activitypub.service.common.ActivityPubProcessContext
import dev.usbharu.hideout.activitypub.service.common.ActivityType import dev.usbharu.hideout.activitypub.service.common.ActivityType
import dev.usbharu.hideout.application.external.Transaction import dev.usbharu.hideout.application.external.Transaction
import dev.usbharu.hideout.core.domain.exception.FailedToGetResourcesException import dev.usbharu.hideout.core.domain.exception.FailedToGetResourcesException
import dev.usbharu.hideout.core.domain.model.post.PostRepository
import dev.usbharu.hideout.core.query.PostQueryService import dev.usbharu.hideout.core.query.PostQueryService
import dev.usbharu.hideout.core.service.post.PostService
import org.springframework.stereotype.Service import org.springframework.stereotype.Service
@Service @Service
class APDeleteProcessor( class APDeleteProcessor(
transaction: Transaction, transaction: Transaction,
private val postQueryService: PostQueryService, private val postQueryService: PostQueryService,
private val postRepository: PostRepository private val postService: PostService
) : ) :
AbstractActivityPubProcessor<Delete>(transaction) { AbstractActivityPubProcessor<Delete>(transaction) {
override suspend fun internalProcess(activity: ActivityPubProcessContext<Delete>) { override suspend fun internalProcess(activity: ActivityPubProcessContext<Delete>) {
@ -33,7 +33,7 @@ class APDeleteProcessor(
return return
} }
postRepository.delete(post.id) postService.deleteRemote(post)
} }
override fun isSupported(activityType: ActivityType): Boolean = activityType == ActivityType.Delete override fun isSupported(activityType: ActivityType): Boolean = activityType == ActivityType.Delete

View File

@ -0,0 +1,23 @@
package dev.usbharu.hideout.activitypub.service.activity.delete
import dev.usbharu.hideout.activitypub.service.common.APRequestService
import dev.usbharu.hideout.application.external.Transaction
import dev.usbharu.hideout.core.external.job.DeliverDeleteJob
import dev.usbharu.hideout.core.external.job.DeliverDeleteJobParam
import dev.usbharu.hideout.core.query.ActorQueryService
import dev.usbharu.hideout.core.service.job.JobProcessor
import org.springframework.stereotype.Service
@Service
class APDeliverDeleteJobProcessor(
private val apRequestService: APRequestService,
private val actorQueryService: ActorQueryService,
private val transaction: Transaction,
private val deliverDeleteJob: DeliverDeleteJob
) : JobProcessor<DeliverDeleteJobParam, DeliverDeleteJob> {
override suspend fun process(param: DeliverDeleteJobParam): Unit = transaction.transaction {
apRequestService.apPost(param.inbox, param.delete, actorQueryService.findById(param.signer))
}
override fun job(): DeliverDeleteJob = deliverDeleteJob
}

View File

@ -0,0 +1,50 @@
package dev.usbharu.hideout.activitypub.service.activity.delete
import dev.usbharu.hideout.activitypub.domain.model.Delete
import dev.usbharu.hideout.activitypub.domain.model.Tombstone
import dev.usbharu.hideout.application.config.ApplicationConfig
import dev.usbharu.hideout.core.domain.model.post.Post
import dev.usbharu.hideout.core.external.job.DeliverDeleteJob
import dev.usbharu.hideout.core.external.job.DeliverDeleteJobParam
import dev.usbharu.hideout.core.query.ActorQueryService
import dev.usbharu.hideout.core.query.FollowerQueryService
import dev.usbharu.hideout.core.service.job.JobQueueParentService
import org.springframework.stereotype.Service
import java.time.Instant
interface APSendDeleteService {
suspend fun sendDeleteNote(deletedPost: Post)
}
@Service
class APSendDeleteServiceImpl(
private val jobQueueParentService: JobQueueParentService,
private val delverDeleteJob: DeliverDeleteJob,
private val followerQueryService: FollowerQueryService,
private val actorQueryService: ActorQueryService,
private val applicationConfig: ApplicationConfig
) : APSendDeleteService {
override suspend fun sendDeleteNote(deletedPost: Post) {
val actor = actorQueryService.findById(deletedPost.actorId)
val followersById = followerQueryService.findFollowersById(deletedPost.actorId)
val delete = Delete(
actor = actor.url,
id = "${applicationConfig.url}/delete/note/${deletedPost.id}",
published = Instant.now().toString(),
`object` = Tombstone(id = deletedPost.apId)
)
followersById.forEach {
val jobProps = DeliverDeleteJobParam(
delete,
it.inbox,
actor.id
)
jobQueueParentService.scheduleTypeSafe(delverDeleteJob, jobProps)
}
}
}

View File

@ -2,6 +2,7 @@ package dev.usbharu.hideout.core.domain.model.post
import dev.usbharu.hideout.application.config.CharacterLimit import dev.usbharu.hideout.application.config.CharacterLimit
import org.springframework.stereotype.Component import org.springframework.stereotype.Component
import java.time.Instant
data class Post private constructor( data class Post private constructor(
val id: Long, val id: Long,
@ -15,7 +16,8 @@ data class Post private constructor(
val replyId: Long? = null, val replyId: Long? = null,
val sensitive: Boolean = false, val sensitive: Boolean = false,
val apId: String = url, val apId: String = url,
val mediaIds: List<Long> = emptyList() val mediaIds: List<Long> = emptyList(),
val delted: Boolean = false
) { ) {
@Component @Component
@ -71,8 +73,52 @@ data class Post private constructor(
replyId = replyId, replyId = replyId,
sensitive = sensitive, sensitive = sensitive,
apId = apId, apId = apId,
mediaIds = mediaIds mediaIds = mediaIds,
delted = false
)
}
fun deleteOf(
id: Long,
visibility: Visibility,
url: String,
repostId: Long?,
replyId: Long?,
apId: String
): Post {
return Post(
id = id,
actorId = 0,
overview = null,
text = "",
createdAt = Instant.EPOCH.toEpochMilli(),
visibility = visibility,
url = url,
repostId = repostId,
replyId = replyId,
sensitive = false,
apId = apId,
mediaIds = emptyList(),
delted = true
) )
} }
} }
fun delete(): Post {
return Post(
id = this.id,
actorId = 0,
overview = null,
text = "",
createdAt = Instant.EPOCH.toEpochMilli(),
visibility = visibility,
url = url,
repostId = repostId,
replyId = replyId,
sensitive = false,
apId = apId,
mediaIds = emptyList(),
delted = true
)
}
} }

View File

@ -7,4 +7,6 @@ interface ReactionRepository {
suspend fun generateId(): Long suspend fun generateId(): Long
suspend fun save(reaction: Reaction): Reaction suspend fun save(reaction: Reaction): Reaction
suspend fun delete(reaction: Reaction): Reaction suspend fun delete(reaction: Reaction): Reaction
suspend fun deleteByPostId(postId: Long): Int
suspend fun deleteByActorId(actorId: Long): Int
} }

View File

@ -0,0 +1,36 @@
package dev.usbharu.hideout.core.external.job
import com.fasterxml.jackson.databind.ObjectMapper
import com.fasterxml.jackson.module.kotlin.readValue
import dev.usbharu.hideout.activitypub.domain.model.Delete
import kjob.core.dsl.ScheduleContext
import kjob.core.job.JobProps
import org.springframework.beans.factory.annotation.Qualifier
import org.springframework.stereotype.Component
data class DeliverDeleteJobParam(
val delete: Delete,
val inbox: String,
val signer: Long
)
@Component
class DeliverDeleteJob(@Qualifier("activitypub") private val objectMapper: ObjectMapper) :
HideoutJob<DeliverDeleteJobParam, DeliverDeleteJob>("DeliverDeleteJob") {
val delete = string("delete")
val inbox = string("inbox")
val signer = long("signer")
override fun convert(value: DeliverDeleteJobParam): ScheduleContext<DeliverDeleteJob>.(DeliverDeleteJob) -> Unit = {
props[delete] = objectMapper.writeValueAsString(value.delete)
props[inbox] = value.inbox
props[signer] = value.signer
}
override fun convert(props: JobProps<DeliverDeleteJob>): DeliverDeleteJobParam = DeliverDeleteJobParam(
objectMapper.readValue(props[delete]),
props[inbox],
props[signer]
)
}

View File

@ -10,6 +10,17 @@ import org.springframework.stereotype.Component
@Component @Component
class PostResultRowMapper(private val postBuilder: Post.PostBuilder) : ResultRowMapper<Post> { class PostResultRowMapper(private val postBuilder: Post.PostBuilder) : ResultRowMapper<Post> {
override fun map(resultRow: ResultRow): Post { override fun map(resultRow: ResultRow): Post {
if (resultRow[Posts.deleted]) {
return postBuilder.deleteOf(
resultRow[Posts.id],
Visibility.values().first { it.ordinal == resultRow[Posts.visibility] },
url = resultRow[Posts.url],
repostId = resultRow[Posts.repostId],
replyId = resultRow[Posts.replyId],
apId = resultRow[Posts.apId]
)
}
return postBuilder.of( return postBuilder.of(
id = resultRow[Posts.id], id = resultRow[Posts.id],
actorId = resultRow[Posts.actorId], actorId = resultRow[Posts.actorId],

View File

@ -85,6 +85,7 @@ object Posts : Table() {
val replyId: Column<Long?> = long("reply_id").references(id).nullable() val replyId: Column<Long?> = long("reply_id").references(id).nullable()
val sensitive: Column<Boolean> = bool("sensitive").default(false) val sensitive: Column<Boolean> = bool("sensitive").default(false)
val apId: Column<String> = varchar("ap_id", 100).uniqueIndex() val apId: Column<String> = varchar("ap_id", 100).uniqueIndex()
val deleted = bool("deleted").default(false)
override val primaryKey: PrimaryKey = PrimaryKey(id) override val primaryKey: PrimaryKey = PrimaryKey(id)
} }

View File

@ -42,6 +42,18 @@ class ReactionRepositoryImpl(
} }
return reaction return reaction
} }
override suspend fun deleteByPostId(postId: Long): Int {
return Reactions.deleteWhere {
Reactions.postId eq postId
}
}
override suspend fun deleteByActorId(actorId: Long): Int {
return Reactions.deleteWhere {
Reactions.actorId eq actorId
}
}
} }
fun ResultRow.toReaction(): Reaction { fun ResultRow.toReaction(): Reaction {
@ -55,8 +67,10 @@ fun ResultRow.toReaction(): Reaction {
object Reactions : LongIdTable("reactions") { object Reactions : LongIdTable("reactions") {
val emojiId: Column<Long> = long("emoji_id") val emojiId: Column<Long> = long("emoji_id")
val postId: Column<Long> = long("post_id").references(Posts.id) val postId: Column<Long> = long("post_id")
val actorId: Column<Long> = long("actor_id").references(Actors.id) .references(Posts.id, onDelete = ReferenceOption.CASCADE, onUpdate = ReferenceOption.CASCADE)
val actorId: Column<Long> = long("actor_id")
.references(Actors.id, onDelete = ReferenceOption.CASCADE, onUpdate = ReferenceOption.CASCADE)
init { init {
uniqueIndex(emojiId, postId, actorId) uniqueIndex(emojiId, postId, actorId)

View File

@ -7,4 +7,6 @@ import org.springframework.stereotype.Service
interface PostService { interface PostService {
suspend fun createLocal(post: PostCreateDto): Post suspend fun createLocal(post: PostCreateDto): Post
suspend fun createRemote(post: Post): Post suspend fun createRemote(post: Post): Post
suspend fun deleteLocal(post: Post)
suspend fun deleteRemote(post: Post)
} }

View File

@ -5,6 +5,7 @@ import dev.usbharu.hideout.core.domain.exception.UserNotFoundException
import dev.usbharu.hideout.core.domain.model.actor.ActorRepository import dev.usbharu.hideout.core.domain.model.actor.ActorRepository
import dev.usbharu.hideout.core.domain.model.post.Post import dev.usbharu.hideout.core.domain.model.post.Post
import dev.usbharu.hideout.core.domain.model.post.PostRepository import dev.usbharu.hideout.core.domain.model.post.PostRepository
import dev.usbharu.hideout.core.domain.model.reaction.ReactionRepository
import dev.usbharu.hideout.core.query.PostQueryService import dev.usbharu.hideout.core.query.PostQueryService
import dev.usbharu.hideout.core.service.timeline.TimelineService import dev.usbharu.hideout.core.service.timeline.TimelineService
import org.jetbrains.exposed.exceptions.ExposedSQLException import org.jetbrains.exposed.exceptions.ExposedSQLException
@ -20,7 +21,8 @@ class PostServiceImpl(
private val timelineService: TimelineService, private val timelineService: TimelineService,
private val postQueryService: PostQueryService, private val postQueryService: PostQueryService,
private val postBuilder: Post.PostBuilder, private val postBuilder: Post.PostBuilder,
private val apSendCreateService: ApSendCreateService private val apSendCreateService: ApSendCreateService,
private val reactionRepository: ReactionRepository
) : PostService { ) : PostService {
override suspend fun createLocal(post: PostCreateDto): Post { override suspend fun createLocal(post: PostCreateDto): Post {
@ -38,6 +40,16 @@ class PostServiceImpl(
return createdPost return createdPost
} }
override suspend fun deleteLocal(post: Post) {
reactionRepository.deleteByPostId(post.id)
postRepository.save(post.delete())
}
override suspend fun deleteRemote(post: Post) {
reactionRepository.deleteByPostId(post.id)
postRepository.save(post.delete())
}
private suspend fun internalCreate(post: Post, isLocal: Boolean): Post { private suspend fun internalCreate(post: Post, isLocal: Boolean): Post {
return try { return try {
if (postRepository.save(post)) { if (postRepository.save(post)) {

View File

@ -76,7 +76,8 @@ create table if not exists posts
repost_id bigint null, repost_id bigint null,
reply_id bigint null, reply_id bigint null,
"sensitive" boolean default false not null, "sensitive" boolean default false not null,
ap_id varchar(100) not null unique ap_id varchar(100) not null unique,
deleted boolean default false not null
); );
alter table posts alter table posts
add constraint fk_posts_actor_id__id foreign key (actor_id) references actors (id) on delete restrict on update restrict; add constraint fk_posts_actor_id__id foreign key (actor_id) references actors (id) on delete restrict on update restrict;
@ -196,4 +197,8 @@ create table if not exists relationships
constraint fk_relationships_actor_id__id foreign key (actor_id) references actors (id) on delete restrict on update restrict, constraint fk_relationships_actor_id__id foreign key (actor_id) references actors (id) on delete restrict on update restrict,
constraint fk_relationships_target_actor_id__id foreign key (target_actor_id) references actors (id) on delete restrict on update restrict, constraint fk_relationships_target_actor_id__id foreign key (target_actor_id) references actors (id) on delete restrict on update restrict,
unique (actor_id, target_actor_id) unique (actor_id, target_actor_id)
) );
insert into actors (id, name, domain, screen_name, description, inbox, outbox, url, public_key, private_key, created_at,
key_id, following, followers, instance, locked)
values (0, 'ghost', '', '', '', '', '', '', '', null, 0, '', '', '', null, true);