feat: ミュートの基本部分を実装

This commit is contained in:
usbharu 2024-02-09 18:10:22 +09:00
parent 20c4d3f4e6
commit 827edb2d44
19 changed files with 738 additions and 0 deletions

View File

@ -0,0 +1,9 @@
package dev.usbharu.hideout.core.domain.model.filter
data class Filter(
val id: Long,
val userId: Long,
val name: String,
val context: List<FilterType>,
val filterAction: FilterAction,
)

View File

@ -0,0 +1,6 @@
package dev.usbharu.hideout.core.domain.model.filter
enum class FilterAction {
warn,
hide
}

View File

@ -0,0 +1,7 @@
package dev.usbharu.hideout.core.domain.model.filter
enum class FilterMode {
WHOLE_WORD,
REGEX,
NONE
}

View File

@ -0,0 +1,11 @@
package dev.usbharu.hideout.core.domain.model.filter
interface FilterRepository {
suspend fun generateId(): Long
suspend fun save(filter: Filter): Filter
suspend fun findById(id: Long): Filter?
suspend fun findByUserIdAndType(userId: Long, types: List<FilterType>): List<Filter>
suspend fun deleteById(id: Long)
}

View File

@ -0,0 +1,9 @@
package dev.usbharu.hideout.core.domain.model.filter
enum class FilterType {
home,
notifications,
public,
thread,
account
}

View File

@ -0,0 +1,10 @@
package dev.usbharu.hideout.core.domain.model.filterkeyword
import dev.usbharu.hideout.core.domain.model.filter.FilterMode
data class FilterKeyword(
val id: Long,
val filterId: Long,
val keyword: String,
val mode: FilterMode
)

View File

@ -0,0 +1,10 @@
package dev.usbharu.hideout.core.domain.model.filterkeyword
interface FilterKeywordRepository {
suspend fun generateId(): Long
suspend fun save(filterKeyword: FilterKeyword): FilterKeyword
suspend fun saveAll(filterKeywordList: List<FilterKeyword>)
suspend fun findById(id: Long): FilterKeyword?
suspend fun deleteById(id: Long)
suspend fun deleteByFilterId(filterId: Long)
}

View File

@ -0,0 +1,82 @@
package dev.usbharu.hideout.core.infrastructure.exposedrepository
import dev.usbharu.hideout.application.service.id.IdGenerateService
import dev.usbharu.hideout.core.domain.model.filter.FilterMode
import dev.usbharu.hideout.core.domain.model.filterkeyword.FilterKeyword
import dev.usbharu.hideout.core.domain.model.filterkeyword.FilterKeywordRepository
import org.jetbrains.exposed.sql.*
import org.jetbrains.exposed.sql.SqlExpressionBuilder.eq
import org.slf4j.Logger
import org.slf4j.LoggerFactory
import org.springframework.stereotype.Repository
@Repository
class ExposedFilterKeywordRepository(private val idGenerateService: IdGenerateService) : FilterKeywordRepository,
AbstractRepository() {
override val logger: Logger
get() = Companion.logger
override suspend fun generateId(): Long = idGenerateService.generateId()
override suspend fun save(filterKeyword: FilterKeyword): FilterKeyword = query {
val empty = FilterKeywords.selectAll().where { FilterKeywords.id eq filterKeyword.id }.empty()
if (empty) {
FilterKeywords.insert {
it[id] = filterKeyword.id
it[filterId] = filterKeyword.filterId
it[keyword] = filterKeyword.keyword
it[mode] = filterKeyword.mode.name
}
} else {
FilterKeywords.update({ FilterKeywords.id eq filterKeyword.id }) {
it[filterId] = filterKeyword.filterId
it[keyword] = filterKeyword.keyword
it[mode] = filterKeyword.mode.name
}
}
filterKeyword
}
override suspend fun saveAll(filterKeywordList: List<FilterKeyword>): Unit = query {
FilterKeywords.batchInsert(filterKeywordList, ignore = true) {
this[FilterKeywords.id] = it.id
this[FilterKeywords.filterId] = it.filterId
this[FilterKeywords.keyword] = it.keyword
this[FilterKeywords.mode] = it.mode.name
}
}
override suspend fun findById(id: Long): FilterKeyword? = query {
return@query FilterKeywords.selectAll().where { FilterKeywords.id eq id }.singleOrNull()?.toFilterKeyword()
}
override suspend fun deleteById(id: Long): Unit = query {
FilterKeywords.deleteWhere { FilterKeywords.id eq id }
}
override suspend fun deleteByFilterId(filterId: Long): Unit = query {
FilterKeywords.deleteWhere { FilterKeywords.filterId eq filterId }
}
companion object {
private val logger = LoggerFactory.getLogger(ExposedFilterKeywordRepository::class.java)
}
}
fun ResultRow.toFilterKeyword(): FilterKeyword {
return FilterKeyword(
this[FilterKeywords.id],
this[FilterKeywords.filterId],
this[FilterKeywords.keyword],
this[FilterKeywords.mode].let { FilterMode.valueOf(it) }
)
}
object FilterKeywords : Table() {
val id = long("id")
val filterId = long("filter_id").references(Filters.id)
val keyword = varchar("keyword", 1000)
val mode = varchar("mode", 100)
override val primaryKey: PrimaryKey = PrimaryKey(id)
}

View File

@ -0,0 +1,79 @@
package dev.usbharu.hideout.core.infrastructure.exposedrepository
import dev.usbharu.hideout.application.service.id.IdGenerateService
import dev.usbharu.hideout.core.domain.model.filter.Filter
import dev.usbharu.hideout.core.domain.model.filter.FilterAction
import dev.usbharu.hideout.core.domain.model.filter.FilterRepository
import dev.usbharu.hideout.core.domain.model.filter.FilterType
import org.jetbrains.exposed.sql.*
import org.jetbrains.exposed.sql.SqlExpressionBuilder.eq
import org.slf4j.Logger
import org.slf4j.LoggerFactory
import org.springframework.stereotype.Repository
@Repository
class ExposedFilterRepository(private val idGenerateService: IdGenerateService) : FilterRepository,
AbstractRepository() {
override val logger: Logger
get() = Companion.logger
override suspend fun generateId(): Long = idGenerateService.generateId()
override suspend fun save(filter: Filter): Filter = query {
val empty = Filters.selectAll().where {
Filters.id eq filter.id
}.forUpdate().empty()
if (empty) {
Filters.insert {
it[id] = filter.id
it[userId] = filter.userId
it[name] = filter.name
it[context] = filter.context.joinToString(",") { it.name }
it[filterAction] = filter.filterAction.name
}
} else {
Filters.update({ Filters.id eq filter.id }) {
it[userId] = filter.userId
it[name] = filter.name
it[context] = filter.context.joinToString(",") { it.name }
it[filterAction] = filter.filterAction.name
}
}
filter
}
override suspend fun findById(id: Long): Filter? = query {
return@query Filters.selectAll().where { Filters.id eq id }.singleOrNull()?.toFilter()
}
override suspend fun findByUserIdAndType(userId: Long, types: List<FilterType>): List<Filter> = query {
return@query Filters.selectAll().where { Filters.userId eq userId }.map { it.toFilter() }
.filter { it.context.containsAll(types) }
}
override suspend fun deleteById(id: Long): Unit = query {
Filters.deleteWhere { Filters.id eq id }
}
companion object {
private val logger = LoggerFactory.getLogger(ExposedFilterRepository::class.java)
}
}
fun ResultRow.toFilter(): Filter = Filter(
this[Filters.id],
this[Filters.userId],
this[Filters.name],
this[Filters.context].split(",").filterNot(String::isEmpty).map { FilterType.valueOf(it) },
this[Filters.filterAction].let { FilterAction.valueOf(it) }
)
object Filters : Table() {
val id = long("id")
val userId = long("user_id").references(Actors.id)
val name = varchar("name", 255)
val context = varchar("context", 500)
val filterAction = varchar("action", 255)
override val primaryKey: PrimaryKey = PrimaryKey(id)
}

View File

@ -0,0 +1,33 @@
package dev.usbharu.hideout.core.query.model
import dev.usbharu.hideout.core.domain.model.filter.FilterType
import dev.usbharu.hideout.core.infrastructure.exposedrepository.FilterKeywords
import dev.usbharu.hideout.core.infrastructure.exposedrepository.Filters
import dev.usbharu.hideout.core.infrastructure.exposedrepository.toFilter
import dev.usbharu.hideout.core.infrastructure.exposedrepository.toFilterKeyword
import org.jetbrains.exposed.sql.Query
import org.jetbrains.exposed.sql.selectAll
import org.springframework.stereotype.Repository
@Repository
class ExposedFilterQueryService : FilterQueryService {
override suspend fun findByUserIdAndType(userId: Long, types: List<FilterType>): List<FilterQueryModel> {
return Filters
.rightJoin(FilterKeywords)
.selectAll()
.where { Filters.userId eq userId }
.toFilterQueryModel()
}
private fun Query.toFilterQueryModel(): List<FilterQueryModel> {
return this
.groupBy { it[Filters.id] }
.map { it.value }
.map {
FilterQueryModel.of(
it.first().toFilter(),
it.map { it.toFilterKeyword() }
)
}
}
}

View File

@ -0,0 +1,26 @@
package dev.usbharu.hideout.core.query.model
import dev.usbharu.hideout.core.domain.model.filter.Filter
import dev.usbharu.hideout.core.domain.model.filter.FilterAction
import dev.usbharu.hideout.core.domain.model.filter.FilterType
import dev.usbharu.hideout.core.domain.model.filterkeyword.FilterKeyword
data class FilterQueryModel(
val id: Long,
val userId: Long,
val name: String,
val context: List<FilterType>,
val filterAction: FilterAction,
val keywords: List<FilterKeyword>
) {
companion object {
fun of(filter: Filter, keywords: List<FilterKeyword>): FilterQueryModel = FilterQueryModel(
filter.id,
filter.userId,
filter.name,
filter.context,
filter.filterAction,
keywords
)
}
}

View File

@ -0,0 +1,7 @@
package dev.usbharu.hideout.core.query.model
import dev.usbharu.hideout.core.domain.model.filter.FilterType
interface FilterQueryService {
suspend fun findByUserIdAndType(userId: Long, types: List<FilterType>): List<FilterQueryModel>
}

View File

@ -0,0 +1,8 @@
package dev.usbharu.hideout.core.service.filter
import dev.usbharu.hideout.core.domain.model.filter.FilterMode
data class FilterKeyword(
val keyword: String,
val mode: FilterMode
)

View File

@ -0,0 +1,8 @@
package dev.usbharu.hideout.core.service.filter
import dev.usbharu.hideout.core.query.model.FilterQueryModel
data class FilterResult(
val filter: FilterQueryModel,
val keyword: String,
)

View File

@ -0,0 +1,14 @@
package dev.usbharu.hideout.core.service.filter
import dev.usbharu.hideout.core.domain.model.filter.FilterType
import dev.usbharu.hideout.core.domain.model.post.Post
import dev.usbharu.hideout.core.query.model.FilterQueryModel
interface MuteProcessService {
suspend fun processMute(post: Post, context: List<FilterType>, filters: List<FilterQueryModel>): FilterResult?
suspend fun processMutes(
posts: List<Post>,
context: List<FilterType>,
filters: List<FilterQueryModel>
): Map<Post, FilterResult>
}

View File

@ -0,0 +1,126 @@
package dev.usbharu.hideout.core.service.filter
import dev.usbharu.hideout.core.domain.model.filter.FilterMode.*
import dev.usbharu.hideout.core.domain.model.filter.FilterType
import dev.usbharu.hideout.core.domain.model.post.Post
import dev.usbharu.hideout.core.query.model.FilterQueryModel
import org.slf4j.LoggerFactory
import org.springframework.stereotype.Service
@Service
class MuteProcessServiceImpl : MuteProcessService {
override suspend fun processMute(
post: Post,
context: List<FilterType>,
filters: List<FilterQueryModel>
): FilterResult? {
val preprocess = preprocess(context, filters)
return processMute(post, preprocess)
}
private suspend fun processMute(
post: Post,
preprocess: List<PreProcessedFilter>
): FilterResult? {
logger.trace("process mute post: {}", post)
if (post.overview != null) {
val processMute = processMute(post.overview, preprocess)
if (processMute != null) {
return processMute
}
}
val processMute = processMute(post.text, preprocess)
if (processMute != null) {
return processMute
}
return null
}
override suspend fun processMutes(
posts: List<Post>,
context: List<FilterType>,
filters: List<FilterQueryModel>
): Map<Post, FilterResult> {
val preprocess = preprocess(context, filters)
return posts.mapNotNull { it to (processMute(it, preprocess) ?: return@mapNotNull null) }.toMap()
}
private suspend fun processMute(string: String, filters: List<PreProcessedFilter>): FilterResult? {
for (filter in filters) {
val matchEntire = filter.regex.find(string)
if (matchEntire != null) {
return FilterResult(filter.filter, matchEntire.value)
}
}
return null
}
private fun preprocess(context: List<FilterType>, filters: List<FilterQueryModel>): List<PreProcessedFilter> {
val filterQueryModelList = filters
.filter { it.context.any(context::contains) }
.map {
PreProcessedFilter(
it,
precompileRegex(it)
)
}
return filterQueryModelList
}
private fun precompileRegex(filter: FilterQueryModel): Regex {
logger.trace("precompile regex. filter: {}", filter)
val regexList = mutableListOf<Regex>()
val noneRegexStrings = mutableListOf<String>()
val wholeRegexStrings = mutableListOf<String>()
for (keyword in filter.keywords) {
when (keyword.mode) {
WHOLE_WORD -> wholeRegexStrings.add(keyword.keyword)
REGEX -> regexList.add(Regex(keyword.keyword))
NONE -> noneRegexStrings.add(keyword.keyword)
}
}
val noneRegex = noneRegexStrings.joinToString("|", "(", ")")
val wholeRegex = wholeRegexStrings.joinToString("|", "\\b(", ")\\b")
val regex = if (noneRegexStrings.isNotEmpty() && wholeRegexStrings.isNotEmpty()) {
Regex("$noneRegex|$wholeRegex")
} else if (noneRegexStrings.isNotEmpty()) {
noneRegex.toRegex()
} else if (wholeRegexStrings.isNotEmpty()) {
wholeRegex.toRegex()
} else {
null
}
if (regex != null) {
regexList.add(regex)
}
val pattern = regexList.joinToString(")|(", "(", ")")
logger.trace("precompiled regex {}", pattern)
return Regex(pattern)
}
data class PreProcessedFilter(val filter: FilterQueryModel, val regex: Regex)
companion object {
private val logger = LoggerFactory.getLogger(MuteProcessServiceImpl::class.java)
}
}

View File

@ -0,0 +1,21 @@
package dev.usbharu.hideout.core.service.filter
import dev.usbharu.hideout.core.domain.model.filter.Filter
import dev.usbharu.hideout.core.domain.model.filter.FilterAction
import dev.usbharu.hideout.core.domain.model.filter.FilterType
import dev.usbharu.hideout.core.query.model.FilterQueryModel
interface MuteService {
suspend fun createFilter(
title: String,
name: String,
context: List<FilterType>,
action: FilterAction,
keywords: List<FilterKeyword>,
loginUser: Long
): Filter
suspend fun getFilters(userId: Long, types: List<FilterType> = emptyList()): List<FilterQueryModel>
suspend fun deleteFilter(filterId: Long)
}

View File

@ -0,0 +1,55 @@
package dev.usbharu.hideout.core.service.filter
import dev.usbharu.hideout.core.domain.model.filter.Filter
import dev.usbharu.hideout.core.domain.model.filter.FilterAction
import dev.usbharu.hideout.core.domain.model.filter.FilterRepository
import dev.usbharu.hideout.core.domain.model.filter.FilterType
import dev.usbharu.hideout.core.domain.model.filterkeyword.FilterKeywordRepository
import dev.usbharu.hideout.core.query.model.FilterQueryModel
import dev.usbharu.hideout.core.query.model.FilterQueryService
import org.springframework.stereotype.Service
@Service
class MuteServiceImpl(
private val filterRepository: FilterRepository,
private val filterKeywordRepository: FilterKeywordRepository,
private val filterQueryService: FilterQueryService
) : MuteService {
override suspend fun createFilter(
title: String,
name: String,
context: List<FilterType>,
action: FilterAction,
keywords: List<FilterKeyword>,
loginUser: Long
): Filter {
val filter = Filter(
filterRepository.generateId(),
loginUser,
name,
context,
action
)
val filterKeywordList = keywords.map {
dev.usbharu.hideout.core.domain.model.filterkeyword.FilterKeyword(
filterRepository.generateId(),
filter.id,
it.keyword,
it.mode
)
}
filterKeywordRepository.saveAll(filterKeywordList)
return filterRepository.save(filter)
}
override suspend fun getFilters(userId: Long, types: List<FilterType>): List<FilterQueryModel> =
filterQueryService.findByUserIdAndType(userId, types)
override suspend fun deleteFilter(filterId: Long) {
filterKeywordRepository.deleteByFilterId(filterId)
filterRepository.deleteById(filterId)
}
}

View File

@ -0,0 +1,217 @@
package dev.usbharu.hideout.core.service.filter
import dev.usbharu.hideout.core.domain.model.filter.FilterAction
import dev.usbharu.hideout.core.domain.model.filter.FilterMode
import dev.usbharu.hideout.core.domain.model.filter.FilterType
import dev.usbharu.hideout.core.domain.model.filterkeyword.FilterKeyword
import dev.usbharu.hideout.core.query.model.FilterQueryModel
import kotlinx.coroutines.test.runTest
import org.assertj.core.api.Assertions.assertThat
import org.junit.jupiter.api.Test
import utils.PostBuilder
class MuteProcessServiceImplTest {
@Test
fun 単純な文字列にマッチする() = runTest {
val muteProcessServiceImpl = MuteProcessServiceImpl()
val post = PostBuilder.of(text = "mute test")
val filterQueryModel = FilterQueryModel(
1,
2,
"mute test",
FilterType.entries,
FilterAction.warn,
listOf(
FilterKeyword(
1,
1,
"mute",
FilterMode.NONE
)
)
)
val actual = muteProcessServiceImpl.processMute(
post, FilterType.entries.toList(), listOf(
filterQueryModel
)
)
assertThat(actual).isEqualTo(FilterResult(filterQueryModel, "mute"))
}
@Test
fun 複数の文字列でマッチする() = runTest {
val muteProcessServiceImpl = MuteProcessServiceImpl()
val post = PostBuilder.of(text = "mute test")
val filterQueryModel = FilterQueryModel(
1,
2,
"mute test",
FilterType.entries,
FilterAction.warn,
listOf(
FilterKeyword(
1,
1,
"mate",
FilterMode.NONE
),
FilterKeyword(
1,
1,
"mata",
FilterMode.NONE
),
FilterKeyword(
1,
1,
"mute",
FilterMode.NONE
)
)
)
val actual = muteProcessServiceImpl.processMute(
post, FilterType.entries.toList(), listOf(
filterQueryModel
)
)
assertThat(actual).isEqualTo(FilterResult(filterQueryModel, "mute"))
}
@Test
fun 単語にマッチする() = runTest {
val muteProcessServiceImpl = MuteProcessServiceImpl()
val post = PostBuilder.of(text = "mute test")
val filterQueryModel = FilterQueryModel(
1,
2,
"mute test",
FilterType.entries,
FilterAction.warn,
listOf(
FilterKeyword(
1,
1,
"mute",
FilterMode.WHOLE_WORD
)
)
)
val actual = muteProcessServiceImpl.processMute(
post, FilterType.entries.toList(), listOf(
filterQueryModel
)
)
assertThat(actual).isEqualTo(FilterResult(filterQueryModel, "mute"))
}
@Test
fun 単語以外にはマッチしない() = runTest {
val muteProcessServiceImpl = MuteProcessServiceImpl()
val post = PostBuilder.of(text = "mutetest")
val filterQueryModel = FilterQueryModel(
1,
2,
"mute test",
FilterType.entries,
FilterAction.warn,
listOf(
FilterKeyword(
1,
1,
"mute",
FilterMode.WHOLE_WORD
)
)
)
val actual = muteProcessServiceImpl.processMute(
post, FilterType.entries.toList(), listOf(
filterQueryModel
)
)
assertThat(actual).isNull()
}
@Test
fun 複数の単語にマッチする() = runTest {
val muteProcessServiceImpl = MuteProcessServiceImpl()
val post = PostBuilder.of(text = "mute test")
val filterQueryModel = FilterQueryModel(
1,
2,
"mute test",
FilterType.entries,
FilterAction.warn,
listOf(
FilterKeyword(
1,
1,
"mate",
FilterMode.WHOLE_WORD
),
FilterKeyword(
1,
1,
"mata",
FilterMode.WHOLE_WORD
),
FilterKeyword(
1,
1,
"mute",
FilterMode.WHOLE_WORD
)
)
)
val actual = muteProcessServiceImpl.processMute(
post, FilterType.entries.toList(), listOf(
filterQueryModel
)
)
assertThat(actual).isEqualTo(FilterResult(filterQueryModel, "mute"))
}
@Test
fun 正規表現も使える() = runTest {
val muteProcessServiceImpl = MuteProcessServiceImpl()
val post = PostBuilder.of(text = "mute test")
val filterQueryModel = FilterQueryModel(
1,
2,
"mute test",
FilterType.entries,
FilterAction.warn,
listOf(
FilterKeyword(
1,
1,
"e\\st",
FilterMode.REGEX
)
)
)
val actual = muteProcessServiceImpl.processMute(
post, FilterType.entries.toList(), listOf(
filterQueryModel
)
)
assertThat(actual).isEqualTo(FilterResult(filterQueryModel, "e t"))
}
}