From 74ce5e71018d6bbb6c5a1098a764bca9da161bf8 Mon Sep 17 00:00:00 2001 From: nullaqua Date: Sun, 8 Sep 2024 23:48:54 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E5=A2=9E=E5=8A=A0=E5=88=92=E8=AF=8D?= =?UTF-8?q?=E8=AF=84=E8=AE=BA=E5=8D=B3=E5=B8=96=E5=AD=90=E5=8E=86=E5=8F=B2?= =?UTF-8?q?=E7=89=88=E6=9C=AC=E5=8A=9F=E8=83=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 0. 启动时配置文件加载流程变化 1. 评论不在有单独的表而是通过树形帖子实现 2. 增加记录帖子历史版本 3. 增加划词评论功能 4. 修复bug/接口调整若干 BREAKING CHANGE: 0. 数据库结构更改 1. 诸多接口改变 --- build.gradle.kts | 5 +- gradle.properties | 2 +- src/main/kotlin/subit/ForumBackend.kt | 23 +- src/main/kotlin/subit/Loader.kt | 34 + src/main/kotlin/subit/config/SystemConfig.kt | 2 +- .../subit/console/command/CommandSet.kt | 2 +- .../subit/console/command/TestDatabase.kt | 13 +- src/main/kotlin/subit/dataClasses/Aliases.kt | 22 +- .../dataClasses/{BlockFull.kt => Block.kt} | 4 +- src/main/kotlin/subit/dataClasses/Comment.kt | 38 -- src/main/kotlin/subit/dataClasses/Notice.kt | 7 +- src/main/kotlin/subit/dataClasses/Post.kt | 214 ++++++ src/main/kotlin/subit/dataClasses/PostInfo.kt | 100 --- src/main/kotlin/subit/dataClasses/Slice.kt | 13 +- .../dataClasses/{UserFull.kt => User.kt} | 0 .../kotlin/subit/dataClasses/WordMarking.kt | 39 ++ src/main/kotlin/subit/database/Blocks.kt | 2 +- src/main/kotlin/subit/database/Comments.kt | 25 - src/main/kotlin/subit/database/Database.kt | 4 + src/main/kotlin/subit/database/Likes.kt | 13 +- src/main/kotlin/subit/database/Permissions.kt | 55 +- .../kotlin/subit/database/PostVersions.kt | 38 ++ src/main/kotlin/subit/database/Posts.kt | 62 +- src/main/kotlin/subit/database/Users.kt | 7 +- .../kotlin/subit/database/WordMarkings.kt | 20 + .../subit/database/memoryImpl/BlocksImpl.kt | 6 +- .../subit/database/memoryImpl/CommentsImpl.kt | 46 -- .../subit/database/memoryImpl/LikesImpl.kt | 10 +- .../database/memoryImpl/MemoryDatabaseImpl.kt | 3 +- .../database/memoryImpl/PostVersionsImpl.kt | 69 ++ .../subit/database/memoryImpl/PostsImpl.kt | 260 ++++++-- .../database/memoryImpl/WordMarkingsImpl.kt | 36 ++ .../subit/database/sqlImpl/BlocksImpl.kt | 10 +- .../subit/database/sqlImpl/CommentsImpl.kt | 75 --- .../subit/database/sqlImpl/LikesImpl.kt | 30 +- .../subit/database/sqlImpl/NoticesImpl.kt | 2 +- .../subit/database/sqlImpl/OperationsImpl.kt | 8 +- .../subit/database/sqlImpl/PermissionsImpl.kt | 12 +- .../database/sqlImpl/PostVersionsImpl.kt | 70 ++ .../subit/database/sqlImpl/PostsImpl.kt | 610 +++++++++++++----- .../database/sqlImpl/PrivateChatsImpl.kt | 4 +- .../subit/database/sqlImpl/ProhibitsImpl.kt | 4 +- .../subit/database/sqlImpl/ReportsImpl.kt | 30 +- .../subit/database/sqlImpl/SqlDatabaseImpl.kt | 67 +- .../subit/database/sqlImpl/StarsImpl.kt | 15 +- .../subit/database/sqlImpl/UsersImpl.kt | 30 +- .../database/sqlImpl/WordMarkingsImpl.kt | 78 +++ src/main/kotlin/subit/plugin/ApiDocs.kt | 3 - src/main/kotlin/subit/plugin/RateLimit.kt | 5 +- src/main/kotlin/subit/router/Block.kt | 2 +- src/main/kotlin/subit/router/Comment.kt | 205 +++--- src/main/kotlin/subit/router/Home.kt | 5 +- src/main/kotlin/subit/router/Posts.kt | 412 ++++++++---- src/main/kotlin/subit/router/Router.kt | 8 +- src/main/kotlin/subit/router/User.kt | 17 +- src/main/kotlin/subit/router/WordMarkings.kt | 111 ++++ src/main/kotlin/subit/utils/Locks.kt | 86 +++ src/main/kotlin/subit/utils/SSO.kt | 20 +- src/main/kotlin/subit/utils/Utils.kt | 22 +- src/main/resources/default_config.yaml | 4 + 60 files changed, 2107 insertions(+), 1012 deletions(-) rename src/main/kotlin/subit/dataClasses/{BlockFull.kt => Block.kt} (94%) delete mode 100644 src/main/kotlin/subit/dataClasses/Comment.kt create mode 100644 src/main/kotlin/subit/dataClasses/Post.kt delete mode 100644 src/main/kotlin/subit/dataClasses/PostInfo.kt rename src/main/kotlin/subit/dataClasses/{UserFull.kt => User.kt} (100%) create mode 100644 src/main/kotlin/subit/dataClasses/WordMarking.kt delete mode 100644 src/main/kotlin/subit/database/Comments.kt create mode 100644 src/main/kotlin/subit/database/PostVersions.kt create mode 100644 src/main/kotlin/subit/database/WordMarkings.kt delete mode 100644 src/main/kotlin/subit/database/memoryImpl/CommentsImpl.kt create mode 100644 src/main/kotlin/subit/database/memoryImpl/PostVersionsImpl.kt create mode 100644 src/main/kotlin/subit/database/memoryImpl/WordMarkingsImpl.kt delete mode 100644 src/main/kotlin/subit/database/sqlImpl/CommentsImpl.kt create mode 100644 src/main/kotlin/subit/database/sqlImpl/PostVersionsImpl.kt create mode 100644 src/main/kotlin/subit/database/sqlImpl/WordMarkingsImpl.kt create mode 100644 src/main/kotlin/subit/router/WordMarkings.kt create mode 100644 src/main/kotlin/subit/utils/Locks.kt diff --git a/build.gradle.kts b/build.gradle.kts index 97a9586..8909ff8 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -12,8 +12,8 @@ val swagger_ui_version: String by project val schema_kenerator_version: String by project plugins { - kotlin("jvm") version "2.0.0" - kotlin("plugin.serialization") version "2.0.0" + kotlin("jvm") version "2.0.10" + kotlin("plugin.serialization") version "2.0.10" id("io.ktor.plugin") version "2.3.11" } @@ -55,6 +55,7 @@ dependencies { //postgresql val pg_version: String by project implementation("com.impossibl.pgjdbc-ng:pgjdbc-ng:$pg_version") + implementation("org.postgresql:postgresql:42.7.3") //h2 val h2_version: String by project implementation("com.h2database:h2:$h2_version") diff --git a/gradle.properties b/gradle.properties index ac05c84..d21b1bc 100644 --- a/gradle.properties +++ b/gradle.properties @@ -2,7 +2,7 @@ ktor_version=2.3.11 kotlin_version=2.0.0 logback_version=1.5.6 kotlin.code.style=official -exposed_version=0.51.1 +exposed_version=0.53.0 hikaricp_version = 5.1.0 koin_version=3.5.6 jline_version=3.26.2 diff --git a/src/main/kotlin/subit/ForumBackend.kt b/src/main/kotlin/subit/ForumBackend.kt index 23f79ed..1822c2b 100644 --- a/src/main/kotlin/subit/ForumBackend.kt +++ b/src/main/kotlin/subit/ForumBackend.kt @@ -5,6 +5,7 @@ import io.ktor.server.config.* import io.ktor.server.config.ConfigLoader.Companion.load import io.ktor.server.engine.* import io.ktor.server.netty.* +import net.mamoe.yamlkt.Yaml import subit.console.command.CommandSet.startCommandThread import subit.database.loadDatabaseImpl import subit.logger.ForumLogger @@ -42,13 +43,13 @@ private fun parseCommandLineArgs(args: Array): Pair, File> workDir.mkdirs() // 是否开启debug模式 - debug = argsMap["-debug"]?.toBoolean() ?: false + debug = argsMap["-debug"].toBoolean() System.setProperty("io.ktor.development", "$debug") // 去除命令行中的-config参数, 因为ktor会解析此参数进而不加载打包的application.yaml // 其余参数还原为字符串数组 val resArgs = argsMap.entries - .filterNot { it.key == "-config" || it.key == "-workDir" || it.key == "-debug" } + .filterNot { it.key == "-config" || it.key == "-workDir" || it.key == "-debug" || it.key == "-youthwrite" } .map { (k, v) -> "$k=$v" } .toTypedArray() // 命令行中输入的自定义配置文件 @@ -81,14 +82,22 @@ fun main(args: Array) return } - // 加载主配置文件 - val customConfig = ConfigLoader.load(configFile.path) + val defaultConfig = Loader.getResource("application.yaml") ?: error("application.yaml not found") + val customConfig = configFile.inputStream() + + val resConfig = Loader.mergeConfigs(defaultConfig, customConfig) + // 创建一个临时文件, 用于存储合并后的配置文件 + val tempFile = File.createTempFile("resConfig", ".yaml") + tempFile.writeText(Yaml.encodeToString(resConfig)) + println(tempFile.readText()) + + val resArgs = args1 + "-config=${tempFile.absolutePath}" // 生成环境 - val environment = commandLineEnvironment(args = args1) + val environment = commandLineEnvironment(args = resArgs) { - // 将打包的application.yaml与命令行中提供的配置文件(没提供某人config.yaml)合并 - this.config = this.config.withFallback(customConfig) + ForumLogger.getLogger().info("rootPath: ${this.rootPath}") + ForumLogger.getLogger().info("port: ${this.config}") } // 启动服务器 embeddedServer(Netty, environment).start(wait = true) diff --git a/src/main/kotlin/subit/Loader.kt b/src/main/kotlin/subit/Loader.kt index 0f5885c..0bb0dcc 100644 --- a/src/main/kotlin/subit/Loader.kt +++ b/src/main/kotlin/subit/Loader.kt @@ -1,5 +1,9 @@ package subit +import net.mamoe.yamlkt.Yaml +import net.mamoe.yamlkt.YamlElement +import net.mamoe.yamlkt.YamlMap +import java.io.File import java.io.InputStream object Loader @@ -14,4 +18,34 @@ object Loader if (path.startsWith("/")) return Loader::class.java.getResource(path)?.openStream() return Loader::class.java.getResource("/$path")?.openStream() } + + fun mergeConfigs(vararg configs: File) = + configs.map { it.readText() } + .map { Yaml.decodeYamlFromString(it) } + .reduce { acc, element -> mergeConfig(acc, element) } + /** + * 合并多个配置文件(yaml)若有冲突以前者为准 + */ + fun mergeConfigs(vararg configs: InputStream) = + configs.map { it.readAllBytes() } + .map { Yaml.decodeYamlFromString(it.decodeToString()) } + .reduce { acc, element -> mergeConfig(acc, element) } + + private fun mergeConfig(a: YamlElement, b: YamlElement): YamlElement + { + if (a is YamlMap && b is YamlMap) + { + val map = mutableMapOf() + a.entries.forEach { (k, v) -> + val bk = b[k] + if (bk != null) map[k] = mergeConfig(v, bk) + else map[k] = v + } + b.entries.forEach { (k, v) -> + if (a[k] == null) map[k] = v + } + return YamlMap(map) + } + return a + } } \ No newline at end of file diff --git a/src/main/kotlin/subit/config/SystemConfig.kt b/src/main/kotlin/subit/config/SystemConfig.kt index a50ec3e..3b4abae 100644 --- a/src/main/kotlin/subit/config/SystemConfig.kt +++ b/src/main/kotlin/subit/config/SystemConfig.kt @@ -14,5 +14,5 @@ data class SystemConfig( var systemConfig: SystemConfig by config( "system.yml", - SystemConfig(false, "https://ssubito.subit.org.cn/api") + SystemConfig(false, "https://ssubito.subit.org.cn/api"), ) \ No newline at end of file diff --git a/src/main/kotlin/subit/console/command/CommandSet.kt b/src/main/kotlin/subit/console/command/CommandSet.kt index 8dedcdd..cd29011 100644 --- a/src/main/kotlin/subit/console/command/CommandSet.kt +++ b/src/main/kotlin/subit/console/command/CommandSet.kt @@ -31,7 +31,7 @@ object CommandSet: TreeCommand( Shell, Color, Maintain, - TestDatabase + TestDatabase, ) { private val logger = ForumLogger.getLogger() diff --git a/src/main/kotlin/subit/console/command/TestDatabase.kt b/src/main/kotlin/subit/console/command/TestDatabase.kt index 454ee4d..181e66d 100644 --- a/src/main/kotlin/subit/console/command/TestDatabase.kt +++ b/src/main/kotlin/subit/console/command/TestDatabase.kt @@ -25,17 +25,18 @@ object TestDatabase: Command, KoinComponent mapOf( "BannedWords" to dao(), "Blocks" to dao(), - "Comments" to dao(), "Likes" to dao(), "Notices" to dao(), "Operations" to dao(), "Permissions" to dao(), "Posts" to dao(), + "PostVersions" to dao(), "PrivateChats" to dao(), "Prohibits" to dao(), "Reports" to dao(), "Stars" to dao(), "Users" to dao(), + "WordMarkings" to dao(), ) } @@ -151,7 +152,15 @@ object TestDatabase: Command, KoinComponent return emptyList() // 注意计算, 例如第一个参数, 此时args.size是3, 而我们需要读取method.parameters[1](这里排除掉了this所以是1), 所以这里是-2 - listOf("<${method.parameters[args.size - 2].name}>") + val param = method.parameters[args.size - 2] + + if (param.type.classifier is KClass<*> && (param.type.classifier as KClass<*>).isSubclassOf(Enum::class)) + { + @Suppress("UNCHECKED_CAST") + return (param.type.classifier as KClass>).java.enumConstants.map { Candidate(it.name) } + } + + listOf("<${param.name}>") } }.map { Candidate(it) } } diff --git a/src/main/kotlin/subit/dataClasses/Aliases.kt b/src/main/kotlin/subit/dataClasses/Aliases.kt index 73f759c..6f31fe3 100644 --- a/src/main/kotlin/subit/dataClasses/Aliases.kt +++ b/src/main/kotlin/subit/dataClasses/Aliases.kt @@ -68,15 +68,29 @@ value class PostId(override val value: Long): Id @JvmInline @Serializable -value class CommentId(override val value: Long): Id +value class PostVersionId(override val value: Long): Id { override fun toString(): String = value.toString() companion object { - fun String.toCommentId() = CommentId(toLong()) - fun String.toCommentIdOrNull() = toLongOrNull()?.let(::CommentId) - fun Number.toCommentId() = CommentId(toLong()) + fun String.toPostVersionId() = PostVersionId(toLong()) + fun String.toPostVersionIdOrNull() = toLongOrNull()?.let(::PostVersionId) + fun Number.toPostVersionId() = PostVersionId(toLong()) + } +} + +@JvmInline +@Serializable +value class WordMarkingId(override val value: Long): Id +{ + override fun toString(): String = value.toString() + + companion object + { + fun String.toWordMarkingId() = WordMarkingId(toLong()) + fun String.toWordMarkingIdOrNull() = toLongOrNull()?.let(::WordMarkingId) + fun Number.toWordMarkingId() = WordMarkingId(toLong()) } } diff --git a/src/main/kotlin/subit/dataClasses/BlockFull.kt b/src/main/kotlin/subit/dataClasses/Block.kt similarity index 94% rename from src/main/kotlin/subit/dataClasses/BlockFull.kt rename to src/main/kotlin/subit/dataClasses/Block.kt index 89bd194..dadf6df 100644 --- a/src/main/kotlin/subit/dataClasses/BlockFull.kt +++ b/src/main/kotlin/subit/dataClasses/Block.kt @@ -14,7 +14,7 @@ import kotlinx.serialization.Serializable * @property reading 阅读权限 */ @Serializable -data class BlockFull( +data class Block( val id: BlockId, val name: String, val description: String, @@ -29,7 +29,7 @@ data class BlockFull( { companion object { - val example = BlockFull( + val example = Block( BlockId(1), "板块名称", "板块描述", diff --git a/src/main/kotlin/subit/dataClasses/Comment.kt b/src/main/kotlin/subit/dataClasses/Comment.kt deleted file mode 100644 index 5d362b3..0000000 --- a/src/main/kotlin/subit/dataClasses/Comment.kt +++ /dev/null @@ -1,38 +0,0 @@ -package subit.dataClasses - -import kotlinx.serialization.Serializable - -/** - * 评论信息 - * @property id 评论ID - * @property post 所属帖子ID - * @property parent 父评论ID - * @property author 作者ID - * @property content 内容 - * @property create 创建时间 - * @property state 状态 - */ -@Serializable -data class Comment( - val id: CommentId, - val post: PostId, - val parent: CommentId?, - val author: UserId, - val content: String, - val create: Long, - val state: State -) -{ - companion object - { - val example = Comment( - CommentId(1), - PostId(1), - null, - UserId(1), - "评论内容", - System.currentTimeMillis(), - State.NORMAL - ) - } -} \ No newline at end of file diff --git a/src/main/kotlin/subit/dataClasses/Notice.kt b/src/main/kotlin/subit/dataClasses/Notice.kt index 06eab0c..627bf7f 100644 --- a/src/main/kotlin/subit/dataClasses/Notice.kt +++ b/src/main/kotlin/subit/dataClasses/Notice.kt @@ -1,7 +1,6 @@ package subit.dataClasses import kotlinx.serialization.Serializable -import subit.dataClasses.CommentId.Companion.toCommentId import subit.dataClasses.PostId.Companion.toPostId /** @@ -55,7 +54,7 @@ sealed interface Notice ): ObjectNotice = when (type) { Type.POST_COMMENT -> PostCommentNotice(id, user, obj.value.toPostId(), count) - Type.COMMENT_REPLY -> CommentReplyNotice(id, user, obj.value.toCommentId(), count) + Type.COMMENT_REPLY -> CommentReplyNotice(id, user, obj.value.toPostId(), count) Type.LIKE -> LikeNotice(id, user, obj.value.toPostId(), count) Type.STAR -> StarNotice(id, user, obj.value.toPostId(), count) else -> throw IllegalArgumentException("Invalid type: $type") @@ -118,7 +117,7 @@ sealed interface Notice data class CommentReplyNotice( override val id: NoticeId, override val user: UserId, - val comment: CommentId, + val comment: PostId, override val count: Long ): ObjectNotice { @@ -126,7 +125,7 @@ sealed interface Notice override val obj: Id<*, *> get() = comment companion object { - val example = CommentReplyNotice(NoticeId(1), UserId(1), CommentId(1), 1) + val example = CommentReplyNotice(NoticeId(1), UserId(1), PostId(1), 1) } } diff --git a/src/main/kotlin/subit/dataClasses/Post.kt b/src/main/kotlin/subit/dataClasses/Post.kt new file mode 100644 index 0000000..2160fb3 --- /dev/null +++ b/src/main/kotlin/subit/dataClasses/Post.kt @@ -0,0 +1,214 @@ +package subit.dataClasses + +import kotlinx.serialization.Serializable +import org.koin.core.component.KoinComponent + +@Serializable +data class PostVersionInfo( + val id: PostVersionId, + val post: PostId, + val title: String, + val content: String, + val time: Long, +) +{ + companion object + { + val example = PostVersionInfo( + PostVersionId(1), + PostId(1), + "标题", + "内容", + System.currentTimeMillis() + ) + } + + fun toPostVersionBasicInfo(): PostVersionBasicInfo = + PostVersionBasicInfo(id, post, title, time) +} + +@Serializable +data class PostVersionBasicInfo( + val id: PostVersionId, + val post: PostId, + val title: String, + val time: Long, +) +{ + companion object + { + val example = PostVersionBasicInfo( + PostVersionId(1), + PostId(1), + "标题", + System.currentTimeMillis() + ) + } +} + +/** + * 帖子信息 + * @property id 帖子ID + * @property author 帖子作者 + * @property anonymous 此贴作者匿名 + * @property view 帖子浏览量 + * @property block 帖子所属板块 + * @property state 帖子当前状态 + */ +@Serializable +data class PostInfo( + val id: PostId, + val author: UserId, + val anonymous: Boolean, + val view: Long, + val block: BlockId, + val state: State, + val parent: PostId?, + val root: PostId? +) +{ + companion object: KoinComponent + { + val example = PostInfo( + PostId(1), + UserId(1), + false, + 0, + BlockId(1), + State.NORMAL, + PostId(1), + PostId(1) + ) + } + + fun toPostFull( + title: String, + content: String, + create: Long, + lastModified: Long, + lastVersionId: PostVersionId, + like: Long, + star: Long, + ): PostFull = + PostFull( + id, + title, + content, + author, + anonymous, + create, + lastModified, + lastVersionId, + view, + block, + state, + like, + star, + parent, + root + ) +} + +/** + * 完整帖子信息, 包含由[PostInfo]的信息和点赞数, 点踩数, 收藏数 + */ +@Serializable +data class PostFull( + val id: PostId, + val title: String, + val content: String, + val author: UserId, + val anonymous: Boolean, + val create: Long, + val lastModified: Long, + val lastVersionId: PostVersionId, + val view: Long, + val block: BlockId, + val state: State, + val like: Long, + val star: Long, + val parent: PostId?, + val root: PostId? +) +{ + fun toPostInfo(): PostInfo = + PostInfo(id, author, anonymous, create, block, state, parent, root) + + fun toPostFullBasicInfo(): PostFullBasicInfo = + PostFullBasicInfo( + id, + title, + author, + anonymous, + create, + lastModified, + lastVersionId, + view, + block, + state, + like, + star, + parent, + root + ) + + companion object + { + val example = PostFull( + PostId(1), + "帖子标题", + "帖子内容", + UserId(1), + false, + System.currentTimeMillis(), + System.currentTimeMillis(), + PostVersionId(0), + 0, + BlockId(1), + State.NORMAL, + 0, + 0, + PostId(1), + PostId(1) + ) + } +} + +@Serializable +data class PostFullBasicInfo( + val id: PostId, + val title: String, + val author: UserId, + val anonymous: Boolean, + val create: Long, + val lastModified: Long, + val lastVersionId: PostVersionId, + val view: Long, + val block: BlockId, + val state: State, + val like: Long, + val star: Long, + val parent: PostId?, + val root: PostId? +) +{ + companion object + { + val example = PostFullBasicInfo( + PostId(1), + "帖子标题", + UserId(1), + false, + System.currentTimeMillis(), + System.currentTimeMillis(), + PostVersionId(0), + 0, + BlockId(1), + State.NORMAL, + 0, + 0, + PostId(1), + PostId(1) + ) + } +} \ No newline at end of file diff --git a/src/main/kotlin/subit/dataClasses/PostInfo.kt b/src/main/kotlin/subit/dataClasses/PostInfo.kt deleted file mode 100644 index 67bf18d..0000000 --- a/src/main/kotlin/subit/dataClasses/PostInfo.kt +++ /dev/null @@ -1,100 +0,0 @@ -package subit.dataClasses - -import kotlinx.serialization.Serializable -import org.koin.core.component.KoinComponent -import org.koin.core.component.inject -import subit.database.Likes -import subit.database.Stars - -/** - * 帖子信息 - * @property id 帖子ID - * @property title 帖子标题 - * @property content 帖子内容 - * @property author 帖子作者 - * @property anonymous 此贴作者匿名 - * @property create 帖子创建时间 - * @property lastModified 帖子最后修改时间 - * @property view 帖子浏览量 - * @property block 帖子所属板块 - * @property state 帖子当前状态 - */ -@Serializable -data class PostInfo( - val id: PostId, - val title: String, - val content: String, - val author: UserId, - val anonymous: Boolean, - val create: Long, - val lastModified: Long, - val view: Long, - val block: BlockId, - val state: State, -) -{ - companion object: KoinComponent - { - val example = PostInfo( - PostId(1), - "帖子标题", - "帖子内容", - UserId(1), - false, - System.currentTimeMillis(), - System.currentTimeMillis(), - 0, - BlockId(1), - State.NORMAL - ) - private val likes: Likes by inject() - private val stars: Stars by inject() - } - - suspend fun toPostFull(): PostFull - { - val (like,dislike) = likes.getLikes(id) - val star = stars.getStarsCount(id) - return PostFull(id,title,content,author,anonymous,create,lastModified,view,block,state,like,dislike,star) - } -} - -/** - * 完整帖子信息, 包含由[PostInfo]的信息和点赞数, 点踩数, 收藏数 - */ -@Serializable -data class PostFull( - val id: PostId, - val title: String, - val content: String, - val author: UserId, - val anonymous: Boolean, - val create: Long, - val lastModified: Long, - val view: Long, - val block: BlockId, - val state: State, - val like: Long, - val dislike: Long, - val star: Long, -) -{ - companion object - { - val example = PostFull( - PostId(1), - "帖子标题", - "帖子内容", - UserId(1), - false, - System.currentTimeMillis(), - System.currentTimeMillis(), - 0, - BlockId(1), - State.NORMAL, - 0, - 0, - 0 - ) - } -} \ No newline at end of file diff --git a/src/main/kotlin/subit/dataClasses/Slice.kt b/src/main/kotlin/subit/dataClasses/Slice.kt index b4fcb94..5349a9b 100644 --- a/src/main/kotlin/subit/dataClasses/Slice.kt +++ b/src/main/kotlin/subit/dataClasses/Slice.kt @@ -2,10 +2,13 @@ package subit.dataClasses import kotlinx.serialization.Serializable import org.jetbrains.exposed.sql.Query +import org.jetbrains.exposed.sql.QueryBuilder import org.jetbrains.exposed.sql.ResultRow import subit.dataClasses.Slice.Companion.asSlice import subit.dataClasses.Slice.Companion.fromSequence import subit.database.sqlImpl.utils.WindowFunctionQuery +import subit.debug +import subit.logger.ForumLogger import kotlin.contracts.ExperimentalContracts import kotlin.contracts.contract @@ -28,6 +31,7 @@ data class Slice( @Suppress("unused") companion object { + private val logger = ForumLogger.getLogger() /** * 生成一个空切片 */ @@ -45,8 +49,13 @@ data class Slice( */ fun Query.asSlice(begin: Long, limit: Int): Slice { - val list = WindowFunctionQuery(this, begin, limit).toList() - val totalSize = list.firstOrNull()?.getOrNull(WindowFunctionQuery.totalCount) ?: 0 + val query = WindowFunctionQuery(this, begin, limit) + if (debug) + { + logger.config(QueryBuilder(false).apply { query.prepareSQL(this) }.toString()) + } + val list = query.toList() + val totalSize = list.firstOrNull()?.get(WindowFunctionQuery.totalCount) ?: this.count() return Slice(totalSize, begin, list) } diff --git a/src/main/kotlin/subit/dataClasses/UserFull.kt b/src/main/kotlin/subit/dataClasses/User.kt similarity index 100% rename from src/main/kotlin/subit/dataClasses/UserFull.kt rename to src/main/kotlin/subit/dataClasses/User.kt diff --git a/src/main/kotlin/subit/dataClasses/WordMarking.kt b/src/main/kotlin/subit/dataClasses/WordMarking.kt new file mode 100644 index 0000000..e61c25b --- /dev/null +++ b/src/main/kotlin/subit/dataClasses/WordMarking.kt @@ -0,0 +1,39 @@ +package subit.dataClasses + +import kotlinx.serialization.Serializable + +@Serializable +data class WordMarkingInfo( + val id: WordMarkingId, + val postVersion: PostVersionId, + val comment: PostId, + val start: Int, + val end: Int, + val state: WordMarkingState, +) +{ + companion object + { + val example = WordMarkingInfo( + WordMarkingId(1), + PostVersionId(1), + PostId(1), + 0, + 1, + WordMarkingState.NORMAL + ) + } +} + +@Serializable +enum class WordMarkingState +{ + /** + * 表示标记位置已经不存在 + */ + DELETED, + /** + * 表示正常标记 + */ + NORMAL, +} \ No newline at end of file diff --git a/src/main/kotlin/subit/database/Blocks.kt b/src/main/kotlin/subit/database/Blocks.kt index 5337045..c53efae 100644 --- a/src/main/kotlin/subit/database/Blocks.kt +++ b/src/main/kotlin/subit/database/Blocks.kt @@ -23,7 +23,7 @@ interface Blocks anonymous: PermissionLevel? ) - suspend fun getBlock(block: BlockId): BlockFull? + suspend fun getBlock(block: BlockId): Block? suspend fun setState(block: BlockId, state: State) suspend fun getChildren(loginUser: UserId?, parent: BlockId?, begin: Long, count: Int): Slice suspend fun searchBlock(loginUser: UserId?, key: String, begin: Long, count: Int): Slice diff --git a/src/main/kotlin/subit/database/Comments.kt b/src/main/kotlin/subit/database/Comments.kt deleted file mode 100644 index 8cf043e..0000000 --- a/src/main/kotlin/subit/database/Comments.kt +++ /dev/null @@ -1,25 +0,0 @@ -package subit.database - -import subit.dataClasses.* - -interface Comments -{ - /** - * 创建评论 - * @param post 评论所属的帖子, 若为null要求[parent]不是null - * @param parent 如果是二级评论, 则为父评论的id, 否则为null - * @return 评论的id, null表示[post]或[parent]未找到 - */ - suspend fun createComment( - post: PostId?, - parent: CommentId?, - author: UserId, - content: String - ): CommentId? - suspend fun getComment(id: CommentId): Comment? - suspend fun setCommentState(id: CommentId, state: State) - suspend fun getComments( - post: PostId? = null, - parent: CommentId? = null, - ): List? -} \ No newline at end of file diff --git a/src/main/kotlin/subit/database/Database.kt b/src/main/kotlin/subit/database/Database.kt index 62b57cc..1822c0f 100644 --- a/src/main/kotlin/subit/database/Database.kt +++ b/src/main/kotlin/subit/database/Database.kt @@ -9,6 +9,8 @@ import subit.database.memoryImpl.MemoryDatabaseImpl import subit.database.sqlImpl.SqlDatabaseImpl import subit.logger.ForumLogger import subit.utils.Power.shutdown +import kotlin.reflect.KClass +import kotlin.reflect.KType val databaseImpls: List = listOf( SqlDatabaseImpl, @@ -45,6 +47,8 @@ fun Application.loadDatabaseImpl() shutdown(1, "Database implementation not found") } +data class DaoImpl(val constructor: () -> T, val type: KClass) + interface IDatabase { val name: String diff --git a/src/main/kotlin/subit/database/Likes.kt b/src/main/kotlin/subit/database/Likes.kt index d07192f..70ace9b 100644 --- a/src/main/kotlin/subit/database/Likes.kt +++ b/src/main/kotlin/subit/database/Likes.kt @@ -12,9 +12,8 @@ interface Likes * 点赞/点踩 * @param uid 用户ID * @param pid 帖子ID - * @param like true为点赞, false为点踩 */ - suspend fun like(uid: UserId, pid: PostId, like: Boolean) + suspend fun like(uid: UserId, pid: PostId) /** * 取消点赞/点踩 @@ -26,14 +25,14 @@ interface Likes * 获取用户对帖子的点赞状态 * @param uid 用户ID * @param pid 帖子ID - * @return true为点赞, false为点踩, null为未点赞/点踩 + * @return true为点赞, false为没有点赞 */ - suspend fun getLike(uid: UserId, pid: PostId): Boolean? + suspend fun getLike(uid: UserId, pid: PostId): Boolean /** - * 获取帖子的点赞数和点踩数 + * 获取帖子的点赞数 * @param post 帖子ID - * @return 点赞数和点踩数 + * @return 点赞数 */ - suspend fun getLikes(post: PostId): Pair + suspend fun getLikes(post: PostId): Long } \ No newline at end of file diff --git a/src/main/kotlin/subit/database/Permissions.kt b/src/main/kotlin/subit/database/Permissions.kt index 9cb9b3f..20107c3 100644 --- a/src/main/kotlin/subit/database/Permissions.kt +++ b/src/main/kotlin/subit/database/Permissions.kt @@ -51,44 +51,39 @@ open class CheckPermissionScope @PublishedApi internal constructor(val user: Use /// 可以看 /// - suspend fun canRead(block: BlockFull): Boolean = when (block.state) + suspend fun canRead(block: Block): Boolean = when (block.state) { NORMAL -> getPermission(block.id) >= block.reading DELETED -> (getPermission(block.id) >= block.reading) && user.hasGlobalAdmin() } - suspend fun canRead(post: PostInfo): Boolean = when (post.state) + suspend fun canRead(post: PostInfo): Boolean { - NORMAL -> blocks.getBlock(post.block)?.let { canRead(it) } ?: false - DELETED -> hasAdminIn(post.block) - } - - suspend fun canRead(comment: Comment): Boolean = when (comment.state) - { - NORMAL -> posts.getPost(comment.post)?.let { canRead(it) } ?: false - DELETED -> posts.getPost(comment.post)?.let { hasAdminIn(it.block) } ?: false + val blockInfo = blocks.getBlock(post.block) ?: return false + if (!canRead(blockInfo)) return false + val root = post.root?.let { posts.getPostInfo(it) } + if (root != null && !canRead(root)) return false + return when (post.state) + { + NORMAL -> true + DELETED -> hasGlobalAdmin() + } } /// 可以删除 /// suspend fun canDelete(post: PostInfo): Boolean = when (post.state) { - NORMAL -> post.author == user?.id || hasAdminIn(post.block) + NORMAL -> canRead(post) && (post.author == user?.id || hasAdminIn(post.block)) DELETED -> false } - suspend fun canDelete(block: BlockFull): Boolean = when (block.state) + suspend fun canDelete(block: Block): Boolean = when (block.state) { NORMAL -> hasAdminIn(block.id) DELETED -> false } - suspend fun canDelete(comment: Comment): Boolean = when (comment.state) - { - NORMAL -> comment.author == user?.id || posts.getPost(comment.post)?.let { hasAdminIn(it.block) } ?: false - DELETED -> false - } - /// 可以评论 /// suspend fun canComment(post: PostInfo): Boolean = when (post.state) @@ -99,18 +94,18 @@ open class CheckPermissionScope @PublishedApi internal constructor(val user: Use /// 可以发贴 /// - suspend fun canPost(block: BlockFull): Boolean = when (block.state) + suspend fun canPost(block: Block): Boolean = when (block.state) { NORMAL -> getPermission(block.id) >= block.posting - DELETED -> (getPermission(block.id) >= block.posting) && user.hasGlobalAdmin() + DELETED -> false } /// 可以匿名 /// - suspend fun canAnonymous(block: BlockFull): Boolean = when (block.state) + suspend fun canAnonymous(block: Block): Boolean = when (block.state) { NORMAL -> getPermission(block.id) >= block.anonymous - DELETED -> (getPermission(block.id) >= block.anonymous) && user.hasGlobalAdmin() + DELETED -> false } /// 修改他人权限 /// @@ -121,7 +116,7 @@ open class CheckPermissionScope @PublishedApi internal constructor(val user: Use * @param other 被修改权限的用户, 可以是自己 * @param permission 目标权限(修改后的权限) */ - suspend fun canChangePermission(block: BlockFull?, other: UserFull, permission: PermissionLevel): Boolean + suspend fun canChangePermission(block: Block?, other: UserFull, permission: PermissionLevel): Boolean { // 如果在尝试修改自己的权限 if (other.id == user?.id) @@ -171,7 +166,7 @@ class CheckPermissionInContextScope @PublishedApi internal constructor(val conte /// 可以看 /// - suspend fun checkCanRead(block: BlockFull) + suspend fun checkCanRead(block: Block) { if (!canRead(block)) finish(HttpStatus.Forbidden) @@ -183,12 +178,6 @@ class CheckPermissionInContextScope @PublishedApi internal constructor(val conte finish(HttpStatus.Forbidden) } - suspend fun checkCanRead(comment: Comment) - { - if (!canRead(comment)) - finish(HttpStatus.Forbidden) - } - /// 可以删除 /// suspend fun checkCanDelete(post: PostInfo) @@ -207,7 +196,7 @@ class CheckPermissionInContextScope @PublishedApi internal constructor(val conte /// 可以发贴 /// - suspend fun checkCanPost(block: BlockFull) + suspend fun checkCanPost(block: Block) { if (!canPost(block)) finish(HttpStatus.Forbidden) @@ -215,13 +204,13 @@ class CheckPermissionInContextScope @PublishedApi internal constructor(val conte /// 可以匿名 /// - suspend fun checkCanAnonymous(block: BlockFull) + suspend fun checkCanAnonymous(block: Block) { if (!canAnonymous(block)) finish(HttpStatus.Forbidden) } - suspend fun checkChangePermission(block: BlockFull?, other: UserFull, permission: PermissionLevel) + suspend fun checkChangePermission(block: Block?, other: UserFull, permission: PermissionLevel) { /** * 详见[CheckPermissionScope.canChangePermission] diff --git a/src/main/kotlin/subit/database/PostVersions.kt b/src/main/kotlin/subit/database/PostVersions.kt new file mode 100644 index 0000000..10e095c --- /dev/null +++ b/src/main/kotlin/subit/database/PostVersions.kt @@ -0,0 +1,38 @@ +package subit.database + +import subit.dataClasses.* + +interface PostVersions +{ + /** + * 创建新的帖子版本, 时间为当前时间. + * @param post 帖子ID + * @param title 标题 + * @param content 内容 + * @return 帖子版本ID + */ + suspend fun createPostVersion( + post: PostId, + title: String, + content: String + ): PostVersionId + + /** + * 获取帖子版本信息 + * @param pid 帖子版本ID + * @return 帖子版本信息, 不存在返回null + */ + suspend fun getPostVersion(pid: PostVersionId): PostVersionInfo? + + /** + * 获取帖子的所有版本, 按时间倒序排列, 即最新的版本在前. + * @param post 帖子ID + * @return 帖子版本ID列表 + */ + suspend fun getPostVersions(post: PostId, begin: Long, count: Int): Slice + + /** + * 获取最新的帖子版本 + */ + suspend fun getLatestPostVersion(post: PostId): PostVersionId? +} \ No newline at end of file diff --git a/src/main/kotlin/subit/database/Posts.kt b/src/main/kotlin/subit/database/Posts.kt index 8d17573..c72c388 100644 --- a/src/main/kotlin/subit/database/Posts.kt +++ b/src/main/kotlin/subit/database/Posts.kt @@ -15,47 +15,79 @@ interface Posts MORE_LIKE, MORE_STAR, MORE_COMMENT, - LAST_COMMENT } + /** + * 创建新的帖子 + * @param author 作者 + * @param anonymous 是否匿名 + * @param block 所属板块 + * @param parent 父帖子, 为null表示没有父帖子 + * @param top 是否置顶 + * @return 帖子ID, 当父帖子不为null时, 返回null表示父帖子不存在 + */ suspend fun createPost( - title: String, - content: String, author: UserId, anonymous: Boolean, block: BlockId, + parent: PostId?, top: Boolean = false - ): PostId + ): PostId? + + /** + * 判断两个帖子是否有父子关系(包括祖先后代关系) + */ + suspend fun isAncestor(parent: PostId, child: PostId): Boolean + + /** + * 获取一个节点的所有子孙节点(即所有后代, 不包括自己) + */ + suspend fun getDescendants(pid: PostId, sortBy: PostListSort, begin: Long, count: Int): Slice - suspend fun editPost(pid: PostId, title: String? = null, content: String? = null, top: Boolean? = null) + /** + * 获取一个节点的所有子节点(即所有直接子节点) + */ + suspend fun getChildPosts(pid: PostId, sortBy: PostListSort, begin: Long, count: Int): Slice + + suspend fun setTop(pid: PostId, top: Boolean): Boolean suspend fun setPostState(pid: PostId, state: State) - suspend fun getPost(pid: PostId): PostInfo? + suspend fun getPostInfo(pid: PostId): PostInfo? + suspend fun getPostFull(pid: PostId): PostFull? + suspend fun getPostFullBasicInfo(pid: PostId): PostFullBasicInfo? /** * 获取用户发布的帖子 * @param loginUser 当前操作用户, null表示未登录, 返回的帖子应是该用户可见的. */ suspend fun getUserPosts( - loginUser: UserId? = null, + loginUser: DatabaseUser? = null, author: UserId, - begin: Long = 1, - limit: Int = Int.MAX_VALUE, - ): Slice + sortBy: PostListSort, + begin: Long, + limit: Int, + ): Slice suspend fun getBlockPosts( block: BlockId, - type: PostListSort, + sortBy: PostListSort, + begin: Long, + count: Int + ): Slice + + suspend fun getBlockTopPosts(block: BlockId, begin: Long, count: Int): Slice + suspend fun searchPosts( + loginUser: DatabaseUser?, + key: String, + advancedSearchData: AdvancedSearchData, begin: Long, count: Int - ): Slice + ): Slice - suspend fun getBlockTopPosts(block: BlockId, begin: Long, count: Int): Slice - suspend fun searchPosts(loginUser: UserId?, key: String, advancedSearchData: AdvancedSearchData, begin: Long, count: Int): Slice suspend fun addView(pid: PostId) /** * 获取首页推荐, 应按照时间/浏览量/点赞等参数随机, 即越新/点赞越高/浏览量越高...随机到的几率越大. * @param count 推荐数量 */ - suspend fun getRecommendPosts(count: Int): Slice + suspend fun getRecommendPosts(loginUser: UserId?, count: Int): Slice } \ No newline at end of file diff --git a/src/main/kotlin/subit/database/Users.kt b/src/main/kotlin/subit/database/Users.kt index 051881e..a73fab8 100644 --- a/src/main/kotlin/subit/database/Users.kt +++ b/src/main/kotlin/subit/database/Users.kt @@ -1,9 +1,8 @@ package subit.database -import subit.JWTAuth -import subit.dataClasses.* -import subit.database.sqlImpl.UsersImpl -import subit.utils.SSO +import subit.dataClasses.DatabaseUser +import subit.dataClasses.PermissionLevel +import subit.dataClasses.UserId interface Users { diff --git a/src/main/kotlin/subit/database/WordMarkings.kt b/src/main/kotlin/subit/database/WordMarkings.kt new file mode 100644 index 0000000..c9c5cb4 --- /dev/null +++ b/src/main/kotlin/subit/database/WordMarkings.kt @@ -0,0 +1,20 @@ +package subit.database + +import subit.dataClasses.* + +interface WordMarkings +{ + suspend fun getWordMarking(wid: WordMarkingId): WordMarkingInfo? + suspend fun getWordMarking(postVersion: PostVersionId, comment: PostId): WordMarkingInfo? + suspend fun getWordMarkings(postVersion: PostVersionId): List + suspend fun addWordMarking( + postVersion: PostVersionId, + comment: PostId, + start: Int, + end: Int, + state: WordMarkingState = WordMarkingState.NORMAL + ): WordMarkingId + + suspend fun batchAddWordMarking(wordMarkings: List): List = + wordMarkings.map { addWordMarking(it.postVersion, it.comment, it.start, it.end, it.state) } +} \ No newline at end of file diff --git a/src/main/kotlin/subit/database/memoryImpl/BlocksImpl.kt b/src/main/kotlin/subit/database/memoryImpl/BlocksImpl.kt index 3ac01e5..e9b02a1 100644 --- a/src/main/kotlin/subit/database/memoryImpl/BlocksImpl.kt +++ b/src/main/kotlin/subit/database/memoryImpl/BlocksImpl.kt @@ -8,7 +8,7 @@ import java.util.* class BlocksImpl: Blocks { - private val map = Collections.synchronizedMap(hashMapOf()) + private val map = Collections.synchronizedMap(hashMapOf()) override suspend fun createBlock( name: String, description: String, @@ -21,7 +21,7 @@ class BlocksImpl: Blocks ): BlockId { val id = (map.size+1).toBlockId() - map[id] = BlockFull( + map[id] = Block( id = id, name = name, description = description, @@ -53,7 +53,7 @@ class BlocksImpl: Blocks ) } - override suspend fun getBlock(block: BlockId): BlockFull? = map[block] + override suspend fun getBlock(block: BlockId): Block? = map[block] override suspend fun setState(block: BlockId, state: State) { val b = map[block] ?: return diff --git a/src/main/kotlin/subit/database/memoryImpl/CommentsImpl.kt b/src/main/kotlin/subit/database/memoryImpl/CommentsImpl.kt deleted file mode 100644 index 631be84..0000000 --- a/src/main/kotlin/subit/database/memoryImpl/CommentsImpl.kt +++ /dev/null @@ -1,46 +0,0 @@ -package subit.database.memoryImpl - -import subit.dataClasses.* -import subit.dataClasses.CommentId.Companion.toCommentId -import subit.database.Comments -import java.util.* - -class CommentsImpl: Comments -{ - private val map = Collections.synchronizedMap(hashMapOf()) - override suspend fun createComment(post: PostId?, parent: CommentId?, author: UserId, content: String): CommentId? - { - if (post == null && parent == null) return null - val post1 = post ?: parent?.let { getComment(it)?.post } ?: return null - val id = (map.size+1).toCommentId() - map[id] = Comment( - id = id, - post = post1, - parent = parent, - author = author, - content = content, - create = System.currentTimeMillis(), - state = State.NORMAL - ) - return id - } - - override suspend fun getComment(id: CommentId): Comment? = map[id] - override suspend fun setCommentState(id: CommentId, state: State) - { - val c = map[id] ?: return - map[id] = c.copy(state = state) - } - - override suspend fun getComments(post: PostId?, parent: CommentId?): List? - { - if (post == null && parent == null) return null - val post0 = post ?: parent?.let { getComment(it)?.post } ?: return null - return map.values.filter { it.post == post0 && it.parent == parent } - } - - fun getLastComment(post: PostId): Date = - map.values.filter { it.post == post }.maxOfOrNull(Comment::create)?.let(::Date) ?: Date(0) - - fun getCommentCount(post: PostId): Int = map.values.count { it.post == post } -} \ No newline at end of file diff --git a/src/main/kotlin/subit/database/memoryImpl/LikesImpl.kt b/src/main/kotlin/subit/database/memoryImpl/LikesImpl.kt index 22bd0b8..cdfba4a 100644 --- a/src/main/kotlin/subit/database/memoryImpl/LikesImpl.kt +++ b/src/main/kotlin/subit/database/memoryImpl/LikesImpl.kt @@ -9,18 +9,18 @@ class LikesImpl: Likes { private val map = Collections.synchronizedMap(hashMapOf,Boolean>()) - override suspend fun like(uid: UserId, pid: PostId, like: Boolean) + override suspend fun like(uid: UserId, pid: PostId) { - map[uid to pid] = like + map[uid to pid] = true } override suspend fun unlike(uid: UserId, pid: PostId) { map.remove(uid to pid) } - override suspend fun getLike(uid: UserId, pid: PostId): Boolean? = map[uid to pid] - override suspend fun getLikes(post: PostId): Pair + override suspend fun getLike(uid: UserId, pid: PostId): Boolean = map[uid to pid] != null + override suspend fun getLikes(post: PostId): Long { val likes = map.entries.filter { it.key.second == post } - return likes.count { it.value }.toLong() to likes.count { !it.value }.toLong() + return likes.count { it.value }.toLong() } } \ No newline at end of file diff --git a/src/main/kotlin/subit/database/memoryImpl/MemoryDatabaseImpl.kt b/src/main/kotlin/subit/database/memoryImpl/MemoryDatabaseImpl.kt index 16d645a..4198662 100644 --- a/src/main/kotlin/subit/database/memoryImpl/MemoryDatabaseImpl.kt +++ b/src/main/kotlin/subit/database/memoryImpl/MemoryDatabaseImpl.kt @@ -42,17 +42,18 @@ object MemoryDatabaseImpl: IDatabase, KoinComponent singleOf(::BannedWordsImpl).bind() singleOf(::BlocksImpl).bind() - singleOf(::CommentsImpl).bind() singleOf(::LikesImpl).bind() singleOf(::NoticesImpl).bind() singleOf(::OperationsImpl).bind() singleOf(::PermissionsImpl).bind() singleOf(::PostsImpl).bind() + singleOf(::PostVersionsImpl).bind() singleOf(::PrivateChatsImpl).bind() singleOf(::ProhibitsImpl).bind() singleOf(::ReportsImpl).bind() singleOf(::StarsImpl).bind() singleOf(::UsersImpl).bind() + singleOf(::WordMarkingsImpl).bind() } getKoin().loadModules(listOf(module)) } diff --git a/src/main/kotlin/subit/database/memoryImpl/PostVersionsImpl.kt b/src/main/kotlin/subit/database/memoryImpl/PostVersionsImpl.kt new file mode 100644 index 0000000..e73809f --- /dev/null +++ b/src/main/kotlin/subit/database/memoryImpl/PostVersionsImpl.kt @@ -0,0 +1,69 @@ +package subit.database.memoryImpl + +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import kotlinx.datetime.Instant +import org.koin.core.component.KoinComponent +import org.koin.core.component.inject +import subit.dataClasses.* +import subit.database.PostVersions +import subit.database.Posts +import subit.dataClasses.Slice.Companion.asSlice +import subit.utils.toInstant + +class PostVersionsImpl: PostVersions, KoinComponent +{ + private val posts: Posts by inject() + + private val lock = Mutex() + internal val list: MutableList = mutableListOf() + private val versionMap: MutableMap> = hashMapOf() + + override suspend fun createPostVersion(post: PostId, title: String, content: String): PostVersionId = lock.withLock() + { + val postVersionInfo = PostVersionInfo( + PostVersionId(list.size + 1L), + post, + title, + content, + System.currentTimeMillis() + ) + list += postVersionInfo + versionMap.getOrDefault(post, mutableListOf()).add(postVersionInfo.id) + postVersionInfo.id + } + + override suspend fun getPostVersion(pid: PostVersionId): PostVersionInfo? + { + return list.find { it.id == pid } + } + + override suspend fun getPostVersions(post: PostId, begin: Long, count: Int): Slice + { + val versions = versionMap[post] ?: return Slice.empty() + return versions + .asSequence() + .asSlice(begin, count) + .map { list[it.value.toInt()] } + .map { it.toPostVersionBasicInfo() } + } + + override suspend fun getLatestPostVersion(post: PostId): PostVersionId? + { + return versionMap[post]?.lastOrNull() + } + + internal fun getCreate(post: PostId): Instant + { + val version = versionMap[post]?.first() ?: return 0L.toInstant() + val time = list[version.value.toInt()].time + return time.toInstant() + } + + internal fun getLastModified(post: PostId): Instant + { + val version = versionMap[post]?.last() ?: return 0L.toInstant() + val time = list[version.value.toInt()].time + return time.toInstant() + } +} \ No newline at end of file diff --git a/src/main/kotlin/subit/database/memoryImpl/PostsImpl.kt b/src/main/kotlin/subit/database/memoryImpl/PostsImpl.kt index 8c414f0..f537999 100644 --- a/src/main/kotlin/subit/database/memoryImpl/PostsImpl.kt +++ b/src/main/kotlin/subit/database/memoryImpl/PostsImpl.kt @@ -17,38 +17,107 @@ class PostsImpl: Posts, KoinComponent private val blocks: Blocks by inject() private val permissions: Permissions by inject() private val likes: Likes by inject() - private val comments: Comments by inject() private val stars: Stars by inject() + private val postVersions: PostVersions by inject() override suspend fun createPost( - title: String, - content: String, author: UserId, anonymous: Boolean, block: BlockId, + parent: PostId?, top: Boolean - ): PostId + ): PostId? { - val id = (map.size+1).toPostId() + val id = (map.size + 1).toPostId() map[id] = PostInfo( id = id, - title = title, - content = content, author = author, anonymous = anonymous, block = block, state = State.NORMAL, - create = System.currentTimeMillis(), - lastModified = System.currentTimeMillis(), - view = 0 + view = 0, + parent = parent, + root = parent?.let { getPostInfo(it)?.root ?: it } ?: id + ) to top return id } - override suspend fun editPost(pid: PostId, title: String?, content: String?, top: Boolean?) + override suspend fun isAncestor(parent: PostId, child: PostId): Boolean { - val post = map[pid] ?: return - map[pid] = - (post.first.copy(title = title ?: post.first.title, content = content ?: post.first.content, lastModified = System.currentTimeMillis())) to (top ?: post.second) + var current = child + while (current != parent) + { + val post = map[current] ?: return false + current = post.first.parent ?: return false + } + return true + } + + private fun sortBy(sortBy: Posts.PostListSort): (PostFull)->Long = { + when (sortBy) + { + Posts.PostListSort.NEW -> -it.create + Posts.PostListSort.OLD -> it.create + Posts.PostListSort.MORE_VIEW -> -it.view + Posts.PostListSort.MORE_LIKE -> runBlocking { -stars.getStarsCount(it.id) } + Posts.PostListSort.MORE_STAR -> runBlocking { -likes.getLikes(it.id) } + Posts.PostListSort.MORE_COMMENT -> map.values.count { post -> post.first.root == it.id }.toLong() + } + } + + override suspend fun getDescendants( + pid: PostId, + sortBy: Posts.PostListSort, + begin: Long, + count: Int + ): Slice + { + val post = map[pid]?.first ?: return Slice.empty() + val descendants = map.values + .filter { isAncestor(pid, it.first.id) } + .filter { it.first.state == State.NORMAL } + .filter { + val blockFull = blocks.getBlock(it.first.block) ?: return@filter false + val permission = permissions.getPermission(blockFull.id, post.author) + permission >= blockFull.reading + } + .map { it.first } + .map { getPostFull(it.id)!! } + .sortedBy(sortBy(sortBy)) + .asSequence() + .asSlice(begin, count) + return descendants + } + + override suspend fun getChildPosts( + pid: PostId, + sortBy: Posts.PostListSort, + begin: Long, + count: Int + ): Slice + { + val post = map[pid]?.first ?: return Slice.empty() + val children = map.values + .filter { it.first.parent == pid } + .filter { it.first.state == State.NORMAL } + .filter { + val blockFull = blocks.getBlock(it.first.block) ?: return@filter false + val permission = permissions.getPermission(blockFull.id, post.author) + permission >= blockFull.reading + } + .map { it.first } + .map { getPostFull(it.id)!! } + .sortedBy(sortBy(sortBy)) + .asSequence() + .asSlice(begin, count) + return children + } + + override suspend fun setTop(pid: PostId, top: Boolean): Boolean + { + val post = map[pid] ?: return false + map[pid] = post.first to top + return true } override suspend fun setPostState(pid: PostId, state: State) @@ -57,93 +126,150 @@ class PostsImpl: Posts, KoinComponent map[pid] = post.first.copy(state = state) to post.second } - override suspend fun getPost(pid: PostId): PostInfo? = map[pid]?.first + override suspend fun getPostInfo(pid: PostId): PostInfo? + { + return map[pid]?.first + } + + override suspend fun getPostFull(pid: PostId): PostFull? + { + val post = map[pid]?.first ?: return null + val lastVersionId = postVersions.getPostVersions(pid, 0, 1).list.firstOrNull()?.id ?: return null + val lastVersion = postVersions.getPostVersion(lastVersionId) ?: return null + + return post.toPostFull( + title = lastVersion.title, + content = lastVersion.content, + lastModified = (postVersions as PostVersionsImpl).getLastModified(pid).toEpochMilliseconds(), + create = (postVersions as PostVersionsImpl).getCreate(pid).toEpochMilliseconds(), + like = likes.getLikes(pid), + star = stars.getStarsCount(pid), + lastVersionId = lastVersionId + ) + } - override suspend fun getUserPosts(loginUser: UserId?, author: UserId, begin: Long, limit: Int): Slice = - map.values.filter { it.first.author == author } + override suspend fun getPostFullBasicInfo(pid: PostId): PostFullBasicInfo? = getPostFull(pid)?.toPostFullBasicInfo() + + @Suppress("ConvertCallChainIntoSequence") + override suspend fun getUserPosts( + loginUser: DatabaseUser?, + author: UserId, + sortBy: Posts.PostListSort, + begin: Long, + limit: Int + ): Slice = + map.values + .filter { it.first.author == author } .filter { val blockFull = blocks.getBlock(it.first.block) ?: return@filter false - val permission = loginUser?.let { permissions.getPermission(blockFull.id, loginUser) } + val permission = loginUser?.let { permissions.getPermission(blockFull.id, loginUser.id) } ?: PermissionLevel.NORMAL permission >= blockFull.reading && (it.first.state == State.NORMAL) } .map { it.first } + .map { getPostFull(it.id)!! } + .sortedBy(sortBy(sortBy)) .asSequence() .asSlice(begin, limit) - .map { it.id } + .map { it.toPostFullBasicInfo() } + @Suppress("ConvertCallChainIntoSequence") override suspend fun getBlockPosts( block: BlockId, - type: Posts.PostListSort, + sortBy: Posts.PostListSort, begin: Long, count: Int - ): Slice = map.values + ): Slice = map.values .filter { it.first.block == block } .map { it.first } - .sortedBy { - when (type) - { - Posts.PostListSort.NEW -> -it.create - Posts.PostListSort.OLD -> it.create - Posts.PostListSort.MORE_VIEW -> -it.view - Posts.PostListSort.MORE_LIKE -> runBlocking { -stars.getStarsCount(it.id) } - Posts.PostListSort.MORE_STAR -> runBlocking { -likes.getLikes(it.id).first } - Posts.PostListSort.MORE_COMMENT -> (comments as CommentsImpl).getCommentCount(it.id).toLong() - Posts.PostListSort.LAST_COMMENT -> -(comments as CommentsImpl).getLastComment(it.id).time - } - } + .map { getPostFull(it.id)!! } + .sortedBy(sortBy(sortBy)) .asSequence() .asSlice(begin, count) - .map { it.id } + .map { it.toPostFullBasicInfo() } - override suspend fun getBlockTopPosts(block: BlockId, begin: Long, count: Int): Slice = map.values - .filter { it.first.block == block && it.second } - .map { it.first } - .asSequence() - .asSlice(begin, count) - .map { it.id } + override suspend fun getBlockTopPosts(block: BlockId, begin: Long, count: Int): Slice = + map.values + .filter { it.first.block == block && it.second } + .map { it.first } + .map { getPostFull(it.id)!! } + .asSequence() + .asSlice(begin, count) + .map { it.toPostFullBasicInfo() } - override suspend fun searchPosts(loginUser: UserId?, key: String, advancedSearchData: AdvancedSearchData, begin: Long, count: Int): Slice = map.values - .filter { it.first.title.contains(key) || it.first.content.contains(key) } - .filter { - val blockFull = blocks.getBlock(it.first.block) ?: return@filter false - val permission = loginUser?.let { permissions.getPermission(blockFull.id, loginUser) } - ?: PermissionLevel.NORMAL - permission >= blockFull.reading - } - .filter{ - val post = it.first - val blockConstraint = if(advancedSearchData.blockIdList != null) (post.block in advancedSearchData.blockIdList) else true - val userConstraint = if(advancedSearchData.authorIdList != null) (post.author in advancedSearchData.authorIdList) else true - val contentConstraint = if(advancedSearchData.isOnlyTitle == true)( post.title.contains(key) ) else ( (post.title.contains(key)) || (post.content.contains(key)) ) - val lastModifiedConstraint = if(advancedSearchData.lastModifiedAfter != null) (post.lastModified >= advancedSearchData.lastModifiedAfter) else true - val createTimeConstraint = if(advancedSearchData.createTime != null)(post.create >= advancedSearchData.createTime.first && post.create <= advancedSearchData.createTime.second ) else true - blockConstraint && userConstraint && contentConstraint && lastModifiedConstraint && createTimeConstraint - } - .asSequence() - .asSlice(begin, count) - .map { it.first.id } + override suspend fun searchPosts( + loginUser: DatabaseUser?, + key: String, + advancedSearchData: AdvancedSearchData, + begin: Long, + count: Int + ): Slice = + map.values + .map { it.first } + .map { getPostFull(it.id)!! } + .filter { it.title.contains(key) || it.content.contains(key) } + .filter { + val blockFull = blocks.getBlock(it.block) ?: return@filter false + val permission = + loginUser?.let { permissions.getPermission(blockFull.id, loginUser.id) } + ?: PermissionLevel.NORMAL + permission >= blockFull.reading + } + .filter { + val post = it + val blockConstraint = + if (advancedSearchData.blockIdList != null) (post.block in advancedSearchData.blockIdList) + else true + val userConstraint = + if (advancedSearchData.authorIdList != null) (post.author in advancedSearchData.authorIdList) + else true + val contentConstraint = + if (advancedSearchData.isOnlyTitle == true) (post.title.contains(key)) + else ((post.title.contains(key)) || (post.content.contains(key))) + val lastModifiedConstraint = + if (advancedSearchData.lastModifiedAfter != null) + (post.lastModified >= advancedSearchData.lastModifiedAfter) + else true + val createTimeConstraint = + if (advancedSearchData.createTime != null) + (post.create >= advancedSearchData.createTime.first && post.create <= advancedSearchData.createTime.second) + else true + blockConstraint && userConstraint && contentConstraint && lastModifiedConstraint && createTimeConstraint + } + .asSequence() + .asSlice(begin, count) + .map { it.toPostFullBasicInfo() } override suspend fun addView(pid: PostId) { val post = map[pid] ?: return - map[pid] = post.first.copy(view = post.first.view+1) to post.second + map[pid] = post.first.copy(view = post.first.view + 1) to post.second } private fun getHotScore(pid: PostId): Double { val post = map[pid]?.first ?: return 0.0 - val likesCount = runBlocking { likes.getLikes(pid).first } + val likesCount = runBlocking { likes.getLikes(pid) } val starsCount = runBlocking { stars.getStarsCount(pid) } - val commentsCount = (comments as CommentsImpl).getCommentCount(pid) - val time = (System.currentTimeMillis()-post.create).toDouble()/1000/*s*//60/*m*//60/*h*/ - return (post.view+likesCount*3+starsCount*5+commentsCount*2)/time.pow(1.8) + val commentsCount: Int = map.values.count { it.first.root == pid } + val createTime = (postVersions as PostVersionsImpl).getCreate(pid).toEpochMilliseconds() + val time = (System.currentTimeMillis() - createTime).toDouble() /1000/*s*/ /60/*m*/ /60/*h*/ + return (post.view + likesCount * 3 + starsCount * 5 + commentsCount * 2) / time.pow(1.8) } - override suspend fun getRecommendPosts(count: Int): Slice = map.values + @Suppress("ConvertCallChainIntoSequence") + override suspend fun getRecommendPosts(loginUser: UserId?, count: Int): Slice = map.values .filter { it.first.state == State.NORMAL } + .filter { + val blockFull = blocks.getBlock(it.first.block) ?: return@filter false + val permission = loginUser?.let { permissions.getPermission(blockFull.id, loginUser) } + ?: PermissionLevel.NORMAL + permission >= blockFull.reading + } .sortedByDescending { getHotScore(it.first.id) } + .map { it.first } + .map { getPostFull(it.id)!! } + .map { it.toPostFullBasicInfo() } .asSequence() .asSlice(1, count) - .map { it.first.id } } diff --git a/src/main/kotlin/subit/database/memoryImpl/WordMarkingsImpl.kt b/src/main/kotlin/subit/database/memoryImpl/WordMarkingsImpl.kt new file mode 100644 index 0000000..ae0a7c8 --- /dev/null +++ b/src/main/kotlin/subit/database/memoryImpl/WordMarkingsImpl.kt @@ -0,0 +1,36 @@ +package subit.database.memoryImpl + +import subit.dataClasses.* +import subit.dataClasses.WordMarkingId.Companion.toWordMarkingId +import subit.database.WordMarkings +import java.util.Collections + +class WordMarkingsImpl: WordMarkings +{ + private val map = Collections.synchronizedMap(hashMapOf()) + private val map1 = Collections.synchronizedMap(hashMapOf>()) + + override suspend fun getWordMarking(wid: WordMarkingId): WordMarkingInfo? = map[wid] + override suspend fun getWordMarking(postVersion: PostVersionId, comment: PostId): WordMarkingInfo? + { + val list = map1[postVersion] ?: return null + return list.mapNotNull { map[it] }.firstOrNull { it.comment == comment } + } + + override suspend fun getWordMarkings(postVersion: PostVersionId): List = + map1[postVersion]?.mapNotNull { map[it] } ?: emptyList() + + override suspend fun addWordMarking( + postVersion: PostVersionId, + comment: PostId, + start: Int, + end: Int, + state: WordMarkingState + ): WordMarkingId + { + val id = (map.size + 1).toWordMarkingId() + map[id] = WordMarkingInfo(id, postVersion, comment, start, end, state) + map1.getOrPut(postVersion) { mutableListOf() }.add(id) + return id + } +} \ No newline at end of file diff --git a/src/main/kotlin/subit/database/sqlImpl/BlocksImpl.kt b/src/main/kotlin/subit/database/sqlImpl/BlocksImpl.kt index 7b3de94..7b34eb4 100644 --- a/src/main/kotlin/subit/database/sqlImpl/BlocksImpl.kt +++ b/src/main/kotlin/subit/database/sqlImpl/BlocksImpl.kt @@ -26,7 +26,7 @@ class BlocksImpl: DaoSqlImpl(BlocksTable), Blocks, KoinC val parent = reference("parent", BlocksTable, ReferenceOption.CASCADE, ReferenceOption.CASCADE).nullable() .default(null) .index() - val creator = reference("creator", UsersImpl.UserTable).index() + val creator = reference("creator", UsersImpl.UsersTable).index() val state = enumerationByName("state", 20).default(State.NORMAL) val posting = enumeration("posting").default(PermissionLevel.NORMAL) val commenting = enumeration("commenting").default(PermissionLevel.NORMAL) @@ -35,7 +35,7 @@ class BlocksImpl: DaoSqlImpl(BlocksTable), Blocks, KoinC override val primaryKey: PrimaryKey = PrimaryKey(id) } - private fun deserializeBlock(row: ResultRow): BlockFull = BlockFull( + private fun deserializeBlock(row: ResultRow): Block = Block( id = row[BlocksTable.id].value, name = row[BlocksTable.name], description = row[BlocksTable.description], @@ -88,7 +88,7 @@ class BlocksImpl: DaoSqlImpl(BlocksTable), Blocks, KoinC } } - override suspend fun getBlock(block: BlockId): BlockFull? = query() + override suspend fun getBlock(block: BlockId): Block? = query() { selectAll().where { id eq block }.singleOrNull()?.let(::deserializeBlock) } @@ -107,7 +107,7 @@ class BlocksImpl: DaoSqlImpl(BlocksTable), Blocks, KoinC val additionalConstraint: (SqlExpressionBuilder.()->Op)? = if (loginUser != null) ({ permissionTable.user eq loginUser }) else null - BlocksTable.join(permissionTable, JoinType.LEFT, id, permissionTable.block, additionalConstraint) + BlocksTable.join(permissionTable, JoinType.LEFT, id, permissionTable.block, additionalConstraint = additionalConstraint) .select(id) .where { BlocksTable.parent eq parent } .andWhere { state eq State.NORMAL } @@ -124,7 +124,7 @@ class BlocksImpl: DaoSqlImpl(BlocksTable), Blocks, KoinC val additionalConstraint: (SqlExpressionBuilder.()->Op)? = if (loginUser != null) ({ permissionTable.user eq loginUser }) else null - BlocksTable.join(permissionTable, JoinType.LEFT, id, permissionTable.block, additionalConstraint) + BlocksTable.join(permissionTable, JoinType.LEFT, id, permissionTable.block, additionalConstraint = additionalConstraint) .select(BlocksTable.columns) .where { (name like "%$key%") or (description like "%$key%") } .andWhere { state eq State.NORMAL } diff --git a/src/main/kotlin/subit/database/sqlImpl/CommentsImpl.kt b/src/main/kotlin/subit/database/sqlImpl/CommentsImpl.kt deleted file mode 100644 index ab95c9f..0000000 --- a/src/main/kotlin/subit/database/sqlImpl/CommentsImpl.kt +++ /dev/null @@ -1,75 +0,0 @@ -package subit.database.sqlImpl - -import org.jetbrains.exposed.dao.id.IdTable -import org.jetbrains.exposed.sql.* -import org.jetbrains.exposed.sql.kotlin.datetime.CurrentTimestamp -import org.jetbrains.exposed.sql.kotlin.datetime.timestamp -import org.koin.core.component.KoinComponent -import subit.dataClasses.* -import subit.dataClasses.Slice.Companion.singleOrNull -import subit.database.Comments - -class CommentsImpl: DaoSqlImpl(CommentsTable), Comments, KoinComponent -{ - object CommentsTable: IdTable("comments") - { - override val id = commentId("id").autoIncrement().entityId() - val post = reference("post", PostsImpl.PostsTable).index() - val parent = reference("parent", CommentsTable).nullable().index() - val author = reference("author", UsersImpl.UserTable).index() - val content = text("content") - val create = timestamp("create").defaultExpression(CurrentTimestamp).index() - val state = enumerationByName("state", 20).default(State.NORMAL) - override val primaryKey = PrimaryKey(id) - } - - private fun deserialize(row: ResultRow) = Comment( - id = row[CommentsTable.id].value, - post = row[CommentsTable.post].value, - parent = row[CommentsTable.parent]?.value, - author = row[CommentsTable.author].value, - content = row[CommentsTable.content], - create = row[CommentsTable.create].toEpochMilliseconds(), - state = row[CommentsTable.state] - ) - - override suspend fun createComment( - post: PostId?, - parent: CommentId?, - author: UserId, - content: String - ): CommentId? = query() - { - if (post == null && parent == null) return@query null - val post1 = post ?: parent?.let { getComment(it)?.post } ?: return@query null - insertAndGetId { - it[this.post] = post1 - it[this.parent] = parent - it[this.author] = author - it[this.content] = content - }.value - } - - override suspend fun getComment(id: CommentId): Comment? = query() - { - selectAll().where { CommentsTable.id eq id }.singleOrNull()?.let(::deserialize) - } - - override suspend fun setCommentState(id: CommentId, state: State): Unit = query() - { - update({ CommentsTable.id eq id }) - { - it[CommentsTable.state] = state - } - } - - override suspend fun getComments( - post: PostId?, - parent: CommentId?, - ): List? = query() - { - if (post == null && parent == null) return@query null - val post0 = post ?: parent?.let { getComment(it)?.post } ?: return@query null - selectAll().where { (CommentsTable.post eq post0) and (CommentsTable.parent eq parent) }.map(::deserialize) - } -} \ No newline at end of file diff --git a/src/main/kotlin/subit/database/sqlImpl/LikesImpl.kt b/src/main/kotlin/subit/database/sqlImpl/LikesImpl.kt index 3dd0f35..047a93e 100644 --- a/src/main/kotlin/subit/database/sqlImpl/LikesImpl.kt +++ b/src/main/kotlin/subit/database/sqlImpl/LikesImpl.kt @@ -6,23 +6,27 @@ import org.koin.core.component.KoinComponent import subit.dataClasses.PostId import subit.dataClasses.UserId import subit.database.Likes +import subit.utils.Locks class LikesImpl: DaoSqlImpl(LikesTable), Likes, KoinComponent { object LikesTable: Table("likes") { - val user = reference("user", UsersImpl.UserTable).index() + val user = reference("user", UsersImpl.UsersTable).index() val post = reference("post", PostsImpl.PostsTable).index() - // true为点赞, false为点踩 - val like = bool("like").index() } - override suspend fun like(uid: UserId, pid: PostId, like: Boolean): Unit = query() + private val locks = Locks>() + + override suspend fun like(uid: UserId, pid: PostId): Unit = query() { - insert { - it[user] = uid - it[post] = pid - it[table.like] = like + locks.withLock(uid to pid) + { + if (getLike(uid, pid)) return@query + insert { + it[user] = uid + it[post] = pid + } } } @@ -35,15 +39,11 @@ class LikesImpl: DaoSqlImpl(LikesTable), Likes, KoinCompon override suspend fun getLike(uid: UserId, pid: PostId): Boolean = query() { - select(like).where { - (user eq uid) and (post eq pid) - }.singleOrNull()?.get(like) ?: false + selectAll().where { (user eq uid) and (post eq pid) }.count() > 0 } - override suspend fun getLikes(post: PostId):Pair = query() + override suspend fun getLikes(post: PostId): Long = query() { - val likes = selectAll().where { (LikesTable.post eq post) and (like eq true) }.count() - val dislikes = selectAll().where { (LikesTable.post eq post) and (like eq false) }.count() - likes to dislikes + selectAll().where { table.post eq post }.count() } } \ No newline at end of file diff --git a/src/main/kotlin/subit/database/sqlImpl/NoticesImpl.kt b/src/main/kotlin/subit/database/sqlImpl/NoticesImpl.kt index 6aac1f9..3eb6052 100644 --- a/src/main/kotlin/subit/database/sqlImpl/NoticesImpl.kt +++ b/src/main/kotlin/subit/database/sqlImpl/NoticesImpl.kt @@ -17,7 +17,7 @@ class NoticesImpl: DaoSqlImpl(NoticesTable), Notices, object NoticesTable: IdTable("notices") { override val id = noticeId("id").autoIncrement().entityId() - val user = reference("user", UsersImpl.UserTable).index() + val user = reference("user", UsersImpl.UsersTable).index() val type = enumerationByName("type", 20).index() val obj = long("object").nullable() val content = text("content") diff --git a/src/main/kotlin/subit/database/sqlImpl/OperationsImpl.kt b/src/main/kotlin/subit/database/sqlImpl/OperationsImpl.kt index caa793f..8c9d083 100644 --- a/src/main/kotlin/subit/database/sqlImpl/OperationsImpl.kt +++ b/src/main/kotlin/subit/database/sqlImpl/OperationsImpl.kt @@ -4,8 +4,8 @@ import kotlinx.serialization.json.Json import kotlinx.serialization.serializer import org.jetbrains.exposed.sql.Table import org.jetbrains.exposed.sql.insert -import org.jetbrains.exposed.sql.kotlin.datetime.CurrentTimestamp -import org.jetbrains.exposed.sql.kotlin.datetime.timestamp +import org.jetbrains.exposed.sql.kotlin.datetime.CurrentTimestampWithTimeZone +import org.jetbrains.exposed.sql.kotlin.datetime.timestampWithTimeZone import org.koin.core.component.KoinComponent import subit.dataClasses.UserId import subit.database.Operations @@ -15,10 +15,10 @@ class OperationsImpl: DaoSqlImpl(OperationsTable { object OperationsTable: Table("operations") { - val admin = reference("operator", UsersImpl.UserTable).index() + val admin = reference("operator", UsersImpl.UsersTable).index() val operationType = varchar("operation_type", 255) val operation = text("operation") - val time = timestamp("time").defaultExpression(CurrentTimestamp).index() + val time = timestampWithTimeZone("time").defaultExpression(CurrentTimestampWithTimeZone).index() } private val operationSerializer = Json() diff --git a/src/main/kotlin/subit/database/sqlImpl/PermissionsImpl.kt b/src/main/kotlin/subit/database/sqlImpl/PermissionsImpl.kt index 7d36812..b6af8e4 100644 --- a/src/main/kotlin/subit/database/sqlImpl/PermissionsImpl.kt +++ b/src/main/kotlin/subit/database/sqlImpl/PermissionsImpl.kt @@ -9,11 +9,11 @@ import subit.dataClasses.Slice.Companion.singleOrNull import subit.dataClasses.UserId import subit.database.Permissions -class PermissionsImpl: DaoSqlImpl(PermissionTable), Permissions, KoinComponent +class PermissionsImpl: DaoSqlImpl(PermissionsTable), Permissions, KoinComponent { - object PermissionTable: Table("permissions") + object PermissionsTable: Table("permissions") { - val user = reference("user", UsersImpl.UserTable).index() + val user = reference("user", UsersImpl.UsersTable).index() val block = reference("block", BlocksImpl.BlocksTable).index() val permission = enumeration("permission", PermissionLevel::class).default(PermissionLevel.NORMAL) } @@ -30,7 +30,7 @@ class PermissionsImpl: DaoSqlImpl(PermissionTab } val count = update({ (user eq uid) and (block eq bid) }) { - it[PermissionTable.permission] = permission + it[PermissionsTable.permission] = permission } if (count > 0) return@query @@ -40,14 +40,14 @@ class PermissionsImpl: DaoSqlImpl(PermissionTab { it[user] = uid it[block] = bid - it[PermissionTable.permission] = permission + it[PermissionsTable.permission] = permission } } override suspend fun getPermission(block: BlockId, user: UserId): PermissionLevel = query() { select(permission).where { - (PermissionTable.user eq user) and (PermissionTable.block eq block) + (PermissionsTable.user eq user) and (PermissionsTable.block eq block) }.singleOrNull()?.getOrNull(permission) ?: PermissionLevel.NORMAL } } \ No newline at end of file diff --git a/src/main/kotlin/subit/database/sqlImpl/PostVersionsImpl.kt b/src/main/kotlin/subit/database/sqlImpl/PostVersionsImpl.kt new file mode 100644 index 0000000..820d10f --- /dev/null +++ b/src/main/kotlin/subit/database/sqlImpl/PostVersionsImpl.kt @@ -0,0 +1,70 @@ +package subit.database.sqlImpl + +import org.jetbrains.exposed.dao.id.IdTable +import org.jetbrains.exposed.sql.ResultRow +import org.jetbrains.exposed.sql.SortOrder +import org.jetbrains.exposed.sql.insert +import org.jetbrains.exposed.sql.kotlin.datetime.CurrentTimestamp +import org.jetbrains.exposed.sql.kotlin.datetime.timestamp +import org.jetbrains.exposed.sql.selectAll +import org.koin.core.component.KoinComponent +import subit.dataClasses.* +import subit.dataClasses.Slice.Companion.asSlice +import subit.dataClasses.Slice.Companion.singleOrNull +import subit.database.PostVersions + +class PostVersionsImpl: DaoSqlImpl(PostVersionsTable), PostVersions, KoinComponent +{ + object PostVersionsTable: IdTable("post_versions") + { + override val id = postVersionId("id").autoIncrement().entityId() + val post = reference("post", PostsImpl.PostsTable) + val title = varchar("title", 255) + val content = text("content") + val time = timestamp("time").defaultExpression(CurrentTimestamp).index() + override val primaryKey = PrimaryKey(id) + } + + private fun deserializePostVersion(row: ResultRow): PostVersionInfo = PostVersionInfo( + id = row[PostVersionsTable.id].value, + post = row[PostVersionsTable.post].value, + title = row[PostVersionsTable.title], + content = row[PostVersionsTable.content], + time = row[PostVersionsTable.time].toEpochMilliseconds() + ) + + private fun deserializePostVersionBasicInfo(row: ResultRow): PostVersionBasicInfo = PostVersionBasicInfo( + id = row[PostVersionsTable.id].value, + post = row[PostVersionsTable.post].value, + title = row[PostVersionsTable.title], + time = row[PostVersionsTable.time].toEpochMilliseconds() + ) + + override suspend fun createPostVersion(post: PostId, title: String, content: String): PostVersionId = query() + { + insert { + it[this.post] = post + it[this.title] = title + it[this.content] = content + }[id].value + } + + override suspend fun getPostVersion(pid: PostVersionId): PostVersionInfo? = query() + { + selectAll().where { id eq pid }.singleOrNull()?.let(::deserializePostVersion) + } + + override suspend fun getPostVersions(post: PostId, begin: Long, count: Int): Slice = query() + { + select(id, table.post, title, time) + .where { PostVersionsTable.post eq post } + .orderBy(time, SortOrder.DESC) + .asSlice(begin, count) + .map { deserializePostVersionBasicInfo(it) } + } + + override suspend fun getLatestPostVersion(post: PostId): PostVersionId? = query() + { + select(id).where { PostVersionsTable.post eq post }.orderBy(time, SortOrder.DESC).singleOrNull()?.let { it[id].value } + } +} \ No newline at end of file diff --git a/src/main/kotlin/subit/database/sqlImpl/PostsImpl.kt b/src/main/kotlin/subit/database/sqlImpl/PostsImpl.kt index ee5d621..a448122 100644 --- a/src/main/kotlin/subit/database/sqlImpl/PostsImpl.kt +++ b/src/main/kotlin/subit/database/sqlImpl/PostsImpl.kt @@ -1,28 +1,33 @@ +@file:Suppress("RemoveRedundantQualifierName") + package subit.database.sqlImpl -import kotlinx.datetime.Instant +import org.intellij.lang.annotations.Language import org.jetbrains.exposed.dao.id.IdTable import org.jetbrains.exposed.sql.* -import org.jetbrains.exposed.sql.Function +import org.jetbrains.exposed.sql.SqlExpressionBuilder.coalesce import org.jetbrains.exposed.sql.SqlExpressionBuilder.div -import org.jetbrains.exposed.sql.SqlExpressionBuilder.eq import org.jetbrains.exposed.sql.SqlExpressionBuilder.minus import org.jetbrains.exposed.sql.SqlExpressionBuilder.plus -import org.jetbrains.exposed.sql.SqlExpressionBuilder.times +import org.jetbrains.exposed.sql.functions.math.PowerFunction import org.jetbrains.exposed.sql.kotlin.datetime.CurrentTimestamp -import org.jetbrains.exposed.sql.kotlin.datetime.KotlinInstantColumnType import org.jetbrains.exposed.sql.kotlin.datetime.Minute -import org.jetbrains.exposed.sql.kotlin.datetime.timestamp +import org.jetbrains.exposed.sql.statements.Statement import org.koin.core.component.KoinComponent import org.koin.core.component.inject import subit.dataClasses.* import subit.dataClasses.Slice import subit.dataClasses.Slice.Companion.asSlice +import subit.dataClasses.Slice.Companion.singleOrNull import subit.database.* import subit.database.Posts.PostListSort.* -import subit.database.sqlImpl.PostsImpl.PostsTable.create +import subit.database.sqlImpl.PostVersionsImpl.PostVersionsTable import subit.database.sqlImpl.PostsImpl.PostsTable.view import subit.router.home.AdvancedSearchData +import subit.utils.toTimestamp +import java.sql.ResultSet +import java.time.OffsetDateTime +import kotlin.reflect.typeOf /** * 帖子数据库交互类 @@ -32,88 +37,364 @@ class PostsImpl: DaoSqlImpl(PostsTable), Posts, KoinCompon private val blocks: Blocks by inject() private val likes: Likes by inject() private val stars: Stars by inject() - private val comments: Comments by inject() private val permissions: Permissions by inject() + private val postVersions: PostVersions by inject() object PostsTable: IdTable("posts") { override val id = postId("id").autoIncrement().entityId() - val title = varchar("title", 100).index() - val content = text("content") - val author = reference("author", UsersImpl.UserTable).index() + val author = reference("author", UsersImpl.UsersTable).index() val anonymous = bool("anonymous").default(false) - val block = reference("block", BlocksImpl.BlocksTable, ReferenceOption.CASCADE, ReferenceOption.CASCADE).index() - val create = timestamp("create").defaultExpression(CurrentTimestamp).index() - val lastModified = timestamp("last_modified").defaultExpression(CurrentTimestamp).index() + val block = reference("block", BlocksImpl.BlocksTable).index() val view = long("view").default(0L) val state = enumerationByName("state", 20).default(State.NORMAL) val top = bool("top").default(false) + // 父帖子, 为null表示是根帖子 + val parent = reference("parent", this).nullable().index() + // 根帖子, 为null表示是根帖子 + val rootPost = reference("rootPost", this).nullable().index() override val primaryKey = PrimaryKey(id) } - @Suppress("RemoveRedundantQualifierName") - private fun deserializePost(row: ResultRow): PostInfo = PostInfo( - id = row[PostsTable.id].value, - title = row[PostsTable.title], - content = row[PostsTable.content], - author = row[PostsTable.author].value, - anonymous = row[PostsTable.anonymous], - create = row[PostsTable.create].toEpochMilliseconds(), - lastModified = row[PostsTable.lastModified].toEpochMilliseconds(), - view = row[PostsTable.view], - block = row[PostsTable.block].value, - state = row[PostsTable.state] + /** + * 创建时间, 为最早的版本的时间, 若不存在任何版本(理论上不可能)视为[OffsetDateTime.MIN], 创建alias为create + * + * 效果类似于: + * ```sql + * COALESCE(MIN(post_versions.time), '0001-01-01T00:00:00Z') AS create + * ``` + */ + private val create = coalesce( + PostVersionsTable.time.min(), + 0L.toTimestamp() + ).alias("createTime") + + /** + * 最后修改时间, 为最新的版本的时间, 若不存在任何版本(理论上不可能)视为[OffsetDateTime.MIN], 创建alias为lastModified + * + * 效果类似于: + * ```sql + * COALESCE(MAX(post_versions.time), '0001-01-01T00:00:00Z') AS lastModified + * ``` + */ + private val lastModified = coalesce( + PostVersionsTable.time.max(), + 0L.toTimestamp() + ).alias("lastModifiedTime") + + private val lastVersionId = PostVersionsTable.id.max().alias("lastVersionId") + + /** + * 一篇帖子的点赞数 + * + * 效果类似于: + * ```sql + * COUNT(likes.id) AS like + * ``` + */ + private val like = LikesImpl.LikesTable.user.count().alias("likeCount") + + /** + * 一篇帖子的收藏数 + * + * 效果类似于: + * ```sql + * COUNT(stars.id) AS star + * ``` + */ + private val star = StarsImpl.StarsTable.post.count().alias("starCount") + + /** + * 一篇帖子的评论数 + * + * 效果类似于: + * ```sql + * COUNT(comments.id) AS comment + * ``` + */ + private val comment = PostsTable.alias("comments")[PostsTable.id].count().alias("commentCount") + + /** + * 进行反序列化到data class, 考虑到此处有3种可能的反序列化目标([PostFullBasicInfo], [PostInfo], [PostFull]), + * 分别定义函数不美观, 故使用inline函数进行统一处理, 通过泛型参数[T]进行区分. + * + * 无论类型都先转为[PostFull], 因为[PostFull]包含了所有可能的字段, 再根据类型进行转换. + * + * 注意不存在的字段不要去[row]中取, 否则会抛出异常. + */ + private inline fun deserializePost(row: ResultRow): T + { + val type = typeOf() + val postFullBasicInfoType = typeOf() + val postInfoType = typeOf() + val postFullType = typeOf() + val postFull = PostFull( + id = row[PostsTable.id].value, + title = if (type != postInfoType) row[PostVersionsTable.title] else "", + content = if (type == postFullType) row[PostVersionsTable.content] else "", + author = row[PostsTable.author].value, + anonymous = row[PostsTable.anonymous], + create = if (type != postInfoType) row[create.aliasOnlyExpression()].toEpochMilliseconds() else 0, + lastModified = if (type != postInfoType) row[lastModified.aliasOnlyExpression()].toEpochMilliseconds() else 0, + lastVersionId = if (type != postInfoType) row[lastVersionId.aliasOnlyExpression()]?.value ?: PostVersionId(0) + else PostVersionId(0), + view = row[PostsTable.view], + block = row[PostsTable.block].value, + state = row[PostsTable.state], + like = if (type != postInfoType) row[like] else 0, + star = if (type != postInfoType) row[star] else 0, + parent = row[PostsTable.parent]?.value, + root = row[PostsTable.rootPost]?.value + ) + + return when (type) + { + postFullBasicInfoType -> postFull.toPostFullBasicInfo() as T + postInfoType -> postFull.toPostInfo() as T + postFullType -> postFull as T + else -> throw IllegalArgumentException("Unknown type $type") + } + } + + /** + * [PostFullBasicInfo]中包含的列 + */ + private val postFullBasicInfoColumns = listOf( + PostsTable.id, + PostVersionsTable.title, + PostsTable.author, + PostsTable.anonymous, + create.aliasOnlyExpression(), + lastModified.aliasOnlyExpression(), + lastVersionId.aliasOnlyExpression(), + PostsTable.view, + PostsTable.block, + PostsTable.state, + like, + star, + PostsTable.parent, + PostsTable.rootPost ) + /** + * [PostFull]中包含的列 + */ + private val postFullColumns = postFullBasicInfoColumns + PostVersionsTable.content + + private fun Join.joinPostFull(): Join + { + val likesTable = (likes as LikesImpl).table + val starsTable = (stars as StarsImpl).table + val commentsTable = PostsTable.alias("comments") + val postVersionsTable = (postVersions as PostVersionsImpl).table + + return this + .joinQuery( + on = { (it[postVersionsTable.post] as Expression<*>) eq PostsTable.id }, + joinType = JoinType.INNER, + joinPart = { + postVersionsTable + .select(postVersionsTable.post, lastVersionId, create, lastModified) + .groupBy(postVersionsTable.post) + } + ) + .join(postVersionsTable, JoinType.INNER, postVersionsTable.id, lastVersionId.aliasOnlyExpression()) + .join(likesTable, JoinType.LEFT, PostsTable.id, likesTable.post) + .join(starsTable, JoinType.LEFT, PostsTable.id, starsTable.post) + .join(commentsTable, JoinType.LEFT, PostsTable.id, commentsTable[PostsTable.rootPost]) + } + + private fun Query.groupPostFull() = groupBy(*(postFullColumns - star - like).toTypedArray()) + + private fun Table.joinPostFull() = Join(this).joinPostFull() + override suspend fun createPost( - title: String, - content: String, author: UserId, anonymous: Boolean, block: BlockId, + parent: PostId?, top: Boolean - ): PostId = query() + ): PostId? = query() { + // 若存在父帖子, 则获取根帖子 + val root = + if (parent != null) + { + val q = select(PostsTable.rootPost).where { PostsTable.id eq parent }.singleOrNull() ?: return@query null + q[PostsTable.rootPost]?.value ?: parent + } + else null + PostsTable.insertAndGetId { - it[PostsTable.title] = title - it[PostsTable.content] = content it[PostsTable.author] = author it[PostsTable.anonymous] = anonymous it[PostsTable.block] = block it[PostsTable.top] = top + it[PostsTable.parent] = parent + it[PostsTable.rootPost] = root }.value } - override suspend fun editPost(pid: PostId, title: String?, content: String?, top: Boolean?): Unit = query() + + /** + * 连续的空白字符(包括空格和换行)的匹配正则表达式 + */ + private val whiteSpaceRegex = Regex("\\s+") + + override suspend fun isAncestor(parent: PostId, child: PostId): Boolean = query() + { + @Language("SQL") + val query = """ + SELECT posts.id + FROM posts + INNER JOIN ( + WITH RECURSIVE Parent AS ( + SELECT id, parent + FROM posts + WHERE id = $child + UNION ALL + SELECT t.id, t.parent + FROM posts t + INNER JOIN Parent p ON t.id = p.parent) + SELECT * + FROM Parent + ) AS Parent ON posts.id = Parent.id + WHERE posts.id = $parent + """.trimIndent().replace(whiteSpaceRegex, " ") + it.exec(query) { res -> res.getLong("id") } == parent.value + } + + /** + * 一个查询, 可以获得以[rootId]为根的子树内的所有帖子的id, 也会包括[rootId]自己 + */ + private inner class GetDescendantIdsQuery( + val rootId: PostId + ): Query( + org.jetbrains.exposed.sql.Slice(PostsTable, listOf(PostsTable.id)), + null + ) { - update({ id eq pid }) - { postInfo -> - title?.let { postInfo[PostsTable.title] = title } - content?.let { postInfo[PostsTable.content] = content } - top?.let { postInfo[PostsTable.top] = top } - postInfo[lastModified] = CurrentTimestamp + @Language("SQL") + val sql = """ + WITH RECURSIVE SubTree AS ( + SELECT id, parent + FROM posts + WHERE id = ${rootId.value} + UNION ALL + SELECT n.id, n.parent + FROM posts n + INNER JOIN SubTree subTree ON n.parent = subTree.id + ) + SELECT SubTree.id AS id + FROM SubTree + """.trimIndent().replace(whiteSpaceRegex, " ") + + override val queryToExecute: Statement + get() = this + + override fun prepareSQL(builder: QueryBuilder): String + { + builder.append(sql) + return builder.toString() } } + override suspend fun getDescendants( + pid: PostId, + sortBy: Posts.PostListSort, + begin: Long, + count: Int + ): Slice = query() + { + val descendantIds = GetDescendantIdsQuery(pid).alias("descendantIds") + + table + .joinPostFull() + .join(descendantIds, JoinType.INNER, PostsTable.id, descendantIds[PostsTable.id]) + .select(postFullColumns) + .andWhere { PostsTable.id neq pid } + .groupPostFull() + .orderBy(sortBy.order) + .asSlice(begin, count) + .map { deserializePost(it) } + } + + override suspend fun getChildPosts( + pid: PostId, + sortBy: Posts.PostListSort, + begin: Long, + count: Int + ): Slice = query() + { + table + .joinPostFull() + .select(postFullColumns) + .andWhere { PostsTable.parent eq pid } + .andWhere { PostsTable.state eq State.NORMAL } + .groupPostFull() + .orderBy(sortBy.order) + .asSlice(begin, count) + .map { deserializePost(it) } + } + + override suspend fun setTop(pid: PostId, top: Boolean) = query() + { + update({ id eq pid }) { it[PostsTable.top] = top } > 0 + } + override suspend fun setPostState(pid: PostId, state: State): Unit = query() { update({ id eq pid }) { it[PostsTable.state] = state } } - override suspend fun getPost(pid: PostId): PostInfo? = query() + override suspend fun getPostInfo(pid: PostId): PostInfo? = query() + { + selectAll().where { id eq pid }.firstOrNull()?.let { deserializePost(it) } + } + + override suspend fun getPostFull(pid: PostId): PostFull? = query() + { + table + .joinPostFull() + .select(postFullColumns) + .where { PostsTable.id eq pid } + .groupPostFull() + .singleOrNull() + ?.let { deserializePost(it) } + } + + override suspend fun getPostFullBasicInfo(pid: PostId): PostFullBasicInfo? = query() { - selectAll().where { id eq pid }.firstOrNull()?.let(::deserializePost) + table + .joinPostFull() + .select(postFullBasicInfoColumns) + .where { PostsTable.id eq pid } + .groupPostFull() + .singleOrNull() + ?.let { deserializePost(it) } } + private val Posts.PostListSort.order: Pair, SortOrder> + get() = when (this) + { + NEW -> create.aliasOnlyExpression() to SortOrder.DESC + OLD -> create.aliasOnlyExpression() to SortOrder.ASC + MORE_VIEW -> view to SortOrder.DESC + MORE_LIKE -> like.delegate to SortOrder.DESC + MORE_STAR -> star.delegate to SortOrder.DESC + MORE_COMMENT -> comment.delegate to SortOrder.DESC + } + /** * 获取帖子列表 */ override suspend fun getUserPosts( - loginUser: UserId?, + loginUser: DatabaseUser?, author: UserId, + sortBy: Posts.PostListSort, begin: Long, - limit: Int, - ): Slice = query() + limit: Int + ): Slice = query() { // 构建查询,联结 PostsTable, BlocksTable 和 PermissionsTable val permissionTable = (permissions as PermissionsImpl).table @@ -121,146 +402,138 @@ class PostsImpl: DaoSqlImpl(PostsTable), Posts, KoinCompon if (loginUser == null) { - return@query PostsTable.join(blockTable, JoinType.INNER, block, blockTable.id) - .select(id) - .where { (PostsTable.author eq author) and (state eq State.NORMAL) and (blockTable.reading lessEq PermissionLevel.NORMAL) } - .orderBy(create, SortOrder.DESC) + return@query PostsTable + .joinPostFull() + .join(blockTable, JoinType.INNER, block, blockTable.id) + .select(postFullBasicInfoColumns) + .andWhere { PostsTable.author eq author } + .andWhere { state eq State.NORMAL } + .andWhere { parent.isNull() } + .andWhere { blockTable.reading lessEq PermissionLevel.NORMAL } + .groupPostFull() + .orderBy(sortBy.order) .asSlice(begin, limit) - .map { it[id].value } + .map { deserializePost(it) } } - PostsTable.join(blockTable, JoinType.INNER, block, blockTable.id) - .join(permissionTable, JoinType.LEFT, block, permissionTable.block) { permissionTable.user eq loginUser } - .select(id) - .where { PostsTable.author eq author } - .andWhere { state eq State.NORMAL } + PostsTable + .joinPostFull() + .join(blockTable, JoinType.INNER, block, blockTable.id) + .join(permissionTable, JoinType.LEFT, block, permissionTable.block) { permissionTable.user eq loginUser.id } + .select(postFullBasicInfoColumns) + .andWhere { PostsTable.author eq author } + .andWhere { if (loginUser.hasGlobalAdmin()) Op.TRUE else state eq State.NORMAL } + .andWhere { parent.isNull() } .groupBy(id, create, blockTable.id, blockTable.reading) - .having { (permissionTable.permission.max() greaterEq blockTable.reading) or (blockTable.reading lessEq PermissionLevel.NORMAL) } - .orderBy(create, SortOrder.DESC) + .groupPostFull() + .orHaving { permissionTable.permission.max() greaterEq blockTable.reading } + .orHaving { blockTable.reading lessEq PermissionLevel.NORMAL } + .orderBy(sortBy.order) .asSlice(begin, limit) - .map { it[id].value } + .map { deserializePost(it) } } override suspend fun getBlockPosts( block: BlockId, - type: Posts.PostListSort, + sortBy: Posts.PostListSort, begin: Long, count: Int - ): Slice = query() + ): Slice = query() { - val op = (PostsTable.block eq block) and (state eq State.NORMAL) - val r: Query = when (type) - { - NEW -> select(id).where(op).orderBy(create, SortOrder.DESC) - OLD -> select(id).where(op).orderBy(create, SortOrder.ASC) - MORE_VIEW -> select(id).where(op).orderBy(view, SortOrder.DESC) - MORE_LIKE -> - { - val likesTable = (likes as LikesImpl).table - PostsTable.join(likesTable, JoinType.LEFT, id, LikesImpl.LikesTable.post) - .select(id) - .where(op) - .groupBy(id) - .orderBy(likesTable.like.sum(), SortOrder.DESC) - } - - MORE_STAR -> - { - val starsTable = (stars as StarsImpl).table - PostsTable.join(starsTable, JoinType.LEFT, id, StarsImpl.StarsTable.post) - .select(id) - .where(op) - .groupBy(id) - .orderBy(starsTable.post.count(), SortOrder.DESC) - } - - MORE_COMMENT -> - { - val commentsTable = (comments as CommentsImpl).table - PostsTable.join(commentsTable, JoinType.LEFT, id, CommentsImpl.CommentsTable.post) - .select(id) - .where(op) - .groupBy(id) - .orderBy(commentsTable.id.count(), SortOrder.DESC) - } - - LAST_COMMENT -> - { - val commentsTable = (comments as CommentsImpl).table - PostsTable.join(commentsTable, JoinType.LEFT, id, CommentsImpl.CommentsTable.post) - .select(id) - .where(op) - .groupBy(id) - .orderBy(commentsTable.create.max(), SortOrder.DESC) - } - } - - r.asSlice(begin, count).map { it[id].value } + return@query table + .joinPostFull() + .select(postFullBasicInfoColumns) + .andWhere { PostsTable.block eq block } + .andWhere { state eq State.NORMAL } + .andWhere { parent.isNull() } + .groupPostFull() + .orderBy(sortBy.order) + .asSlice(begin, count) + .map { deserializePost(it) } } - override suspend fun getBlockTopPosts(block: BlockId, begin: Long, count: Int): Slice = query() + override suspend fun getBlockTopPosts(block: BlockId, begin: Long, count: Int): Slice = query() { - PostsTable.select(id) - .where { PostsTable.block eq block } + PostsTable + .joinPostFull() + .select(postFullBasicInfoColumns) + .andWhere { PostsTable.block eq block } .andWhere { top eq true } .andWhere { state eq State.NORMAL } + .andWhere { parent.isNull() } + .groupPostFull() .asSlice(begin, count) - .map { it[id].value } + .map { deserializePost(it) } } override suspend fun searchPosts( - loginUser: UserId?, + loginUser: DatabaseUser?, key: String, advancedSearchData: AdvancedSearchData, begin: Long, count: Int - ): Slice = query() + ): Slice = query() { val permissionTable = (permissions as PermissionsImpl).table val blockTable = (blocks as BlocksImpl).table - val likesTable = (likes as LikesImpl).table - val starsTable = (stars as StarsImpl).table - val commentsTable = (comments as CommentsImpl).table + val postVersionsTable = (postVersions as PostVersionsImpl).table val additionalConstraint: (SqlExpressionBuilder.()->Op)? = - if (loginUser != null) ({ permissionTable.user eq loginUser }) + if (loginUser != null) ({ permissionTable.user eq loginUser.id }) else null - val blockConstraint: (SqlExpressionBuilder.()->Op) = - if(advancedSearchData.blockIdList != null)({ block inList advancedSearchData.blockIdList }) - else ({ Op.TRUE }) - val userConstraint: (SqlExpressionBuilder.()->Op) = - if(advancedSearchData.authorIdList != null)({ author inList advancedSearchData.authorIdList }) - else ({ Op.TRUE }) - val contentConstraint: (SqlExpressionBuilder.()->Op) = - if(advancedSearchData.isOnlyTitle == true) ({ title like "%$key%" }) - else ({ (title like "%$key%") or (content like "%$key%") }) - val lastModifiedConstraint: (SqlExpressionBuilder.()->Op) = - if(advancedSearchData.lastModifiedAfter != null)({ lastModified greaterEq Instant.fromEpochMilliseconds(advancedSearchData.lastModifiedAfter) }) - else ({ Op.TRUE }) - val createTimeConstraint: (SqlExpressionBuilder.()->Op) = - if(advancedSearchData.createTime != null)({ + fun Query.whereConstraint(): Query + { + var q = this + + if (advancedSearchData.blockIdList != null) + q = q.andWhere { block inList advancedSearchData.blockIdList } + + if (advancedSearchData.authorIdList != null) + q = q.andWhere { author inList advancedSearchData.authorIdList } + + if (advancedSearchData.isOnlyTitle == true) + q = q.andWhere { postVersionsTable.title like "%$key%" } + else + q = q.andWhere { (postVersionsTable.title like "%$key%") or (postVersionsTable.content like "%$key%") } + + if (advancedSearchData.lastModifiedAfter != null) + q = q.andWhere { lastModified.aliasOnlyExpression() greaterEq advancedSearchData.lastModifiedAfter.toTimestamp() } + + if (advancedSearchData.createTime != null) + { val (l, r) = advancedSearchData.createTime - (create greaterEq Instant.fromEpochMilliseconds(l)) and (create lessEq Instant.fromEpochMilliseconds(r)) - }) - else ({ Op.TRUE }) - - PostsTable.join(blockTable, JoinType.INNER, block, blockTable.id) - .join(permissionTable, JoinType.LEFT, block, permissionTable.block, additionalConstraint) - .join(likesTable, JoinType.LEFT, id, likesTable.post) - .join(starsTable, JoinType.LEFT, id, starsTable.post) - .join(commentsTable, JoinType.LEFT, id, commentsTable.post) - .select(id) - .where { state eq State.NORMAL } - .andWhere(blockConstraint) - .andWhere(userConstraint) - .andWhere(contentConstraint) - .andWhere(lastModifiedConstraint) - .andWhere(createTimeConstraint) - .groupBy(id, create, blockTable.id, blockTable.reading) - .having { (permissionTable.permission.max() greaterEq blockTable.reading) or (blockTable.reading lessEq PermissionLevel.NORMAL) } + q = q.andWhere { + val createTime = create.aliasOnlyExpression() + (createTime greaterEq l.toTimestamp()) and (createTime lessEq r.toTimestamp()) + } + } + + if (advancedSearchData.isOnlyPost == true) + q = q.andWhere { rootPost.isNull() } + + return q + } + + PostsTable + .joinPostFull() + .join(blockTable, JoinType.INNER, block, blockTable.id) + .join( + permissionTable, + JoinType.LEFT, + block, + permissionTable.block, + additionalConstraint = additionalConstraint + ) + .select(postFullBasicInfoColumns) + .andWhere { if (loginUser.hasGlobalAdmin()) Op.TRUE else state eq State.NORMAL } + .whereConstraint() + .groupBy(id, create.aliasOnlyExpression(), blockTable.id, blockTable.reading) + .groupPostFull() + .orHaving { permissionTable.permission.max() greaterEq blockTable.reading } + .orHaving { blockTable.reading lessEq PermissionLevel.NORMAL } .orderBy(hotScoreOrder, SortOrder.DESC) .asSlice(begin, count) - .map { it[id].value } + .map { deserializePost(it) } } override suspend fun addView(pid: PostId): Unit = query() @@ -270,39 +543,42 @@ class PostsImpl: DaoSqlImpl(PostsTable), Posts, KoinCompon private val hotScoreOrder by lazy { val x = - (view + LikesImpl.LikesTable.like.count() * 3 + StarsImpl.StarsTable.post.count() * 5 + CommentsImpl.CommentsTable.id.count() * 2 + 1) - val now = CustomFunction("NOW", KotlinInstantColumnType()) + (view + + TimesOp(like.delegate, longParam(3), LongColumnType()) + + TimesOp(star.delegate, longParam(5), LongColumnType()) + + TimesOp(comment.delegate, longParam(2), LongColumnType()) + + 1) + + + val minute = (Minute(CurrentTimestamp - create.aliasOnlyExpression()) + 1) / 1024 @Suppress("UNCHECKED_CAST") - val minute = (Minute(now - create) as Function + 1.0) / 1024.0 - val order = x / CustomFunction("POW", LongColumnType(), minute, doubleParam(1.8)) + val order = x / (PowerFunction(minute, doubleParam(1.8)) as Expression) order } - override suspend fun getRecommendPosts(count: Int): Slice = query() + override suspend fun getRecommendPosts(loginUser: UserId?, count: Int): Slice = query() { val blocksTable = (blocks as BlocksImpl).table - val likesTable = (likes as LikesImpl).table - val starsTable = (stars as StarsImpl).table - val commentsTable = (comments as CommentsImpl).table + val permissionTable = (permissions as PermissionsImpl).table /** * 选择所属板块的reading权限小于等于NORMAL的帖子 * * 按照 (浏览量+点赞数*3+收藏数*5+评论数*2)/(发帖到现在的时间(单位: 时间)的1.8次方) - * - * 计算发帖到现在的时间需要使用函数TIMESTAMPDIFF(MINUTE, create, NOW()) */ - table.join(blocksTable, JoinType.INNER, block, blocksTable.id) - .join(likesTable, JoinType.LEFT, id, likesTable.post) - .join(starsTable, JoinType.LEFT, id, starsTable.post) - .join(commentsTable, JoinType.LEFT, id, commentsTable.post) - .select(id) - .where { (blocksTable.reading lessEq PermissionLevel.NORMAL) and (state eq State.NORMAL) } - .groupBy(id) + table + .joinPostFull() + .join(blocksTable, JoinType.INNER, block, blocksTable.id) + .join(permissionTable, JoinType.LEFT, block, permissionTable.block) { permissionTable.user eq loginUser } + .select(postFullBasicInfoColumns) + .andWhere { blocksTable.reading lessEq PermissionLevel.NORMAL } + .andWhere { state eq State.NORMAL } + .andWhere { rootPost.isNull() } // 不是评论 + .groupPostFull() .orderBy(hotScoreOrder, SortOrder.DESC) .asSlice(0, count) - .map { it[id].value } + .map { deserializePost(it) } } } \ No newline at end of file diff --git a/src/main/kotlin/subit/database/sqlImpl/PrivateChatsImpl.kt b/src/main/kotlin/subit/database/sqlImpl/PrivateChatsImpl.kt index 3bf11d3..760fd75 100644 --- a/src/main/kotlin/subit/database/sqlImpl/PrivateChatsImpl.kt +++ b/src/main/kotlin/subit/database/sqlImpl/PrivateChatsImpl.kt @@ -17,8 +17,8 @@ class PrivateChatsImpl: DaoSqlImpl(PrivateCh { object PrivateChatsTable: Table("private_chats") { - val from = reference("from", UsersImpl.UserTable).index() - val to = reference("to", UsersImpl.UserTable).index() + val from = reference("from", UsersImpl.UsersTable).index() + val to = reference("to", UsersImpl.UsersTable).index() val time = timestamp("time").index().defaultExpression(CurrentTimestamp) val content = text("content") } diff --git a/src/main/kotlin/subit/database/sqlImpl/ProhibitsImpl.kt b/src/main/kotlin/subit/database/sqlImpl/ProhibitsImpl.kt index a5d60b7..dd0f8ab 100644 --- a/src/main/kotlin/subit/database/sqlImpl/ProhibitsImpl.kt +++ b/src/main/kotlin/subit/database/sqlImpl/ProhibitsImpl.kt @@ -20,10 +20,10 @@ class ProhibitsImpl: DaoSqlImpl(ProhibitsTable), P { object ProhibitsTable: IdTable("prohibits") { - val user = reference("user", UsersImpl.UserTable) + val user = reference("user", UsersImpl.UsersTable) val time = timestamp("time") val reason = text("reason") - val operator = reference("operator", UsersImpl.UserTable).index() + val operator = reference("operator", UsersImpl.UsersTable).index() override val id = user override val primaryKey = PrimaryKey(user) } diff --git a/src/main/kotlin/subit/database/sqlImpl/ReportsImpl.kt b/src/main/kotlin/subit/database/sqlImpl/ReportsImpl.kt index 18ecf5f..1727f06 100644 --- a/src/main/kotlin/subit/database/sqlImpl/ReportsImpl.kt +++ b/src/main/kotlin/subit/database/sqlImpl/ReportsImpl.kt @@ -10,45 +10,45 @@ import subit.dataClasses.Slice.Companion.asSlice import subit.dataClasses.Slice.Companion.singleOrNull import subit.database.Reports -class ReportsImpl: DaoSqlImpl(ReportTable), Reports +class ReportsImpl: DaoSqlImpl(ReportsTable), Reports { - object ReportTable: IdTable("reports") + object ReportsTable: IdTable("reports") { override val id = reportId("id").autoIncrement().entityId() - val reportBy = reference("user", UsersImpl.UserTable) + val reportBy = reference("user", UsersImpl.UsersTable) val objectType = enumerationByName("object_type", 16, ReportObject::class).index() val objectId = long("object_id").index() val reason = text("reason") - val handledBy = reference("handled_by", UsersImpl.UserTable).nullable().index() + val handledBy = reference("handled_by", UsersImpl.UsersTable).nullable().index() override val primaryKey: PrimaryKey = PrimaryKey(id) } private fun deserialize(row: ResultRow) = Report( - row[ReportTable.id].value, - row[ReportTable.objectType], - row[ReportTable.objectId], - row[ReportTable.reportBy].value, - row[ReportTable.reason] + row[ReportsTable.id].value, + row[ReportsTable.objectType], + row[ReportsTable.objectId], + row[ReportsTable.reportBy].value, + row[ReportsTable.reason] ) override suspend fun addReport(objectType: ReportObject, id: Long, user: UserId, reason: String): Unit = query() { insert { - it[ReportTable.objectType] = objectType + it[ReportsTable.objectType] = objectType it[objectId] = id it[reportBy] = user - it[ReportTable.reason] = reason + it[ReportsTable.reason] = reason } } override suspend fun getReport(id: ReportId): Report? = query() { - ReportTable.selectAll().where { ReportTable.id eq id }.singleOrNull()?.let(::deserialize) + ReportsTable.selectAll().where { ReportsTable.id eq id }.singleOrNull()?.let(::deserialize) } override suspend fun handleReport(id: ReportId, user: UserId): Unit = query() { - ReportTable.update({ ReportTable.id eq id }) { it[handledBy] = user } + ReportsTable.update({ ReportsTable.id eq id }) { it[handledBy] = user } } override suspend fun getReports( @@ -57,8 +57,8 @@ class ReportsImpl: DaoSqlImpl(ReportTable), Reports handled: Boolean? ):Slice = query() { - return@query (if (handled == null) ReportTable.selectAll() - else ReportTable.selectAll().where { + return@query (if (handled == null) ReportsTable.selectAll() + else ReportsTable.selectAll().where { if (handled) (handledBy neq null) else (handledBy eq null) }).asSlice(begin, count).map(::deserialize) diff --git a/src/main/kotlin/subit/database/sqlImpl/SqlDatabaseImpl.kt b/src/main/kotlin/subit/database/sqlImpl/SqlDatabaseImpl.kt index 382b02f..344e813 100644 --- a/src/main/kotlin/subit/database/sqlImpl/SqlDatabaseImpl.kt +++ b/src/main/kotlin/subit/database/sqlImpl/SqlDatabaseImpl.kt @@ -24,6 +24,7 @@ import subit.database.* import subit.logger.ForumLogger import subit.utils.Power.shutdown import java.sql.Driver +import kotlin.reflect.KClass /** * @param T 表类型 @@ -31,8 +32,8 @@ import java.sql.Driver */ abstract class DaoSqlImpl(table: T): KoinComponent { - suspend inline fun query(crossinline block: suspend T.()->R) = table.run { - newSuspendedTransaction(Dispatchers.IO) { block() } + suspend inline fun query(crossinline block: suspend T.(Transaction)->R) = table.run { + newSuspendedTransaction(Dispatchers.IO) { block(this) } } private val database: Database by inject() @@ -130,25 +131,35 @@ object SqlDatabaseImpl: IDatabase, KoinComponent else driver0 logger.info("Load database configuration. url: $url, driver: $driver, user: $user") + + val impls: List> = listOf( + DaoImpl(::BannedWordsImpl, BannedWords::class), + DaoImpl(::BlocksImpl, Blocks::class), + DaoImpl(::LikesImpl, Likes::class), + DaoImpl(::NoticesImpl, Notices::class), + DaoImpl(::OperationsImpl, Operations::class), + DaoImpl(::PermissionsImpl, Permissions::class), + DaoImpl(::PostsImpl, Posts::class), + DaoImpl(::PostVersionsImpl, PostVersions::class), + DaoImpl(::PrivateChatsImpl, PrivateChats::class), + DaoImpl(::ProhibitsImpl, Prohibits::class), + DaoImpl(::ReportsImpl, Reports::class), + DaoImpl(::StarsImpl, Stars::class), + DaoImpl(::UsersImpl, Users::class), + DaoImpl(::WordMarkingsImpl, WordMarkings::class) + ) + val module = module(!lazyInit) { named("sql-database-impl") single { Database.connect(createHikariDataSource(url, driver, user, password)) }.bind() - singleOf(::BannedWordsImpl).bind() - singleOf(::BlocksImpl).bind() - singleOf(::CommentsImpl).bind() - singleOf(::LikesImpl).bind() - singleOf(::NoticesImpl).bind() - singleOf(::OperationsImpl).bind() - singleOf(::PermissionsImpl).bind() - singleOf(::PostsImpl).bind() - singleOf(::PrivateChatsImpl).bind() - singleOf(::ProhibitsImpl).bind() - singleOf(::ReportsImpl).bind() - singleOf(::StarsImpl).bind() - singleOf(::UsersImpl).bind() + for (impl in impls) + { + @Suppress("UNCHECKED_CAST") + singleOf(impl.constructor).bind(impl.type as KClass) + } } getKoin().loadModules(listOf(module)) @@ -157,19 +168,7 @@ object SqlDatabaseImpl: IDatabase, KoinComponent logger.info("${CYAN}Using database implementation: ${RED}sql${CYAN}, and ${RED}lazyInit${CYAN} is ${GREEN}false.") logger.info("${CYAN}It may take a while to initialize the database. Please wait patiently.") - (get() as DaoSqlImpl<*>).table - (get() as DaoSqlImpl<*>).table - (get() as DaoSqlImpl<*>).table - (get() as DaoSqlImpl<*>).table - (get() as DaoSqlImpl<*>).table - (get() as DaoSqlImpl<*>).table - (get() as DaoSqlImpl<*>).table - (get() as DaoSqlImpl<*>).table - (get() as DaoSqlImpl<*>).table - (get() as DaoSqlImpl<*>).table - (get() as DaoSqlImpl<*>).table - (get() as DaoSqlImpl<*>).table - (get() as DaoSqlImpl<*>).table + impls.map { it.type }.map { getKoin().get(it) as DaoSqlImpl<*> }.forEach { it.table } } } } @@ -209,9 +208,9 @@ fun Table.userId(name: String) = registerColumn(name, UserIdColumnType()) class PostIdColumnType: WarpColumnType(LongColumnType(), ::PostId, PostId::value) fun Table.postId(name: String) = registerColumn(name, PostIdColumnType()) -// CommentId -class CommentIdColumnType: WarpColumnType(LongColumnType(), ::CommentId, CommentId::value) -fun Table.commentId(name: String) = registerColumn(name, CommentIdColumnType()) +// PostVersionId +class PostVersionIdColumnType: WarpColumnType(LongColumnType(), ::PostVersionId, PostVersionId::value) +fun Table.postVersionId(name: String) = registerColumn(name, PostVersionIdColumnType()) // ReportId class ReportIdColumnType: WarpColumnType(LongColumnType(), ::ReportId, ReportId::value) @@ -219,4 +218,8 @@ fun Table.reportId(name: String) = registerColumn(name, ReportIdColumnType()) // NoticeId class NoticeIdColumnType: WarpColumnType(LongColumnType(), ::NoticeId, NoticeId::value) -fun Table.noticeId(name: String) = registerColumn(name, NoticeIdColumnType()) \ No newline at end of file +fun Table.noticeId(name: String) = registerColumn(name, NoticeIdColumnType()) + +// WordMarkingId +class WordMarkingIdColumnType: WarpColumnType(LongColumnType(), ::WordMarkingId, WordMarkingId::value) +fun Table.wordMarkingId(name: String) = registerColumn(name, WordMarkingIdColumnType()) \ No newline at end of file diff --git a/src/main/kotlin/subit/database/sqlImpl/StarsImpl.kt b/src/main/kotlin/subit/database/sqlImpl/StarsImpl.kt index 5beafc5..e12442c 100644 --- a/src/main/kotlin/subit/database/sqlImpl/StarsImpl.kt +++ b/src/main/kotlin/subit/database/sqlImpl/StarsImpl.kt @@ -18,7 +18,7 @@ class StarsImpl: DaoSqlImpl(StarsTable), Stars { object StarsTable: Table("stars") { - val user = reference("user", UsersImpl.UserTable).index() + val user = reference("user", UsersImpl.UsersTable).index() val post = reference("post", PostsImpl.PostsTable).index() val time = timestamp("time").defaultExpression(CurrentTimestamp).index() } @@ -61,12 +61,11 @@ class StarsImpl: DaoSqlImpl(StarsTable), Stars limit: Int, ): Slice = query() { - selectAll().where() - { - var op: Op = Op.TRUE - if (user != null) op = op and (StarsTable.user eq user) - if (post != null) op = op and (StarsTable.post eq post) - op - }.asSlice(begin, limit).map(::deserialize) + var q = selectAll() + + if (user != null) q = q.andWhere { StarsTable.user eq user } + if (post != null) q = q.andWhere { StarsTable.post eq post } + + q.asSlice(begin, limit).map(::deserialize) } } \ No newline at end of file diff --git a/src/main/kotlin/subit/database/sqlImpl/UsersImpl.kt b/src/main/kotlin/subit/database/sqlImpl/UsersImpl.kt index 054d711..9582355 100644 --- a/src/main/kotlin/subit/database/sqlImpl/UsersImpl.kt +++ b/src/main/kotlin/subit/database/sqlImpl/UsersImpl.kt @@ -2,20 +2,16 @@ package subit.database.sqlImpl import org.jetbrains.exposed.dao.id.IdTable import org.jetbrains.exposed.sql.* -import org.jetbrains.exposed.sql.kotlin.datetime.CurrentTimestamp -import org.jetbrains.exposed.sql.kotlin.datetime.timestamp -import subit.JWTAuth import subit.dataClasses.* -import subit.dataClasses.Slice.Companion.asSlice import subit.dataClasses.Slice.Companion.single import subit.database.Users -class UsersImpl: DaoSqlImpl(UserTable), Users +class UsersImpl: DaoSqlImpl(UsersTable), Users { /** * 用户信息表 */ - object UserTable: IdTable("users") + object UsersTable: IdTable("users") { override val id = userId("id").entityId() val introduction = text("introduction").nullable().default(null) @@ -26,36 +22,36 @@ class UsersImpl: DaoSqlImpl(UserTable), Users } private fun deserialize(row: ResultRow) = DatabaseUser( - id = row[UserTable.id].value, - introduction = row[UserTable.introduction] ?: "", - showStars = row[UserTable.showStars], - permission = row[UserTable.permission], - filePermission = row[UserTable.filePermission] + id = row[UsersTable.id].value, + introduction = row[UsersTable.introduction] ?: "", + showStars = row[UsersTable.showStars], + permission = row[UsersTable.permission], + filePermission = row[UsersTable.filePermission] ) override suspend fun changeIntroduction(id: UserId, introduction: String): Boolean = query() { - update({ UserTable.id eq id }) { it[UserTable.introduction] = introduction } > 0 + update({ UsersTable.id eq id }) { it[UsersTable.introduction] = introduction } > 0 } override suspend fun changeShowStars(id: UserId, showStars: Boolean): Boolean = query() { - update({ UserTable.id eq id }) { it[UserTable.showStars] = showStars } > 0 + update({ UsersTable.id eq id }) { it[UsersTable.showStars] = showStars } > 0 } override suspend fun changePermission(id: UserId, permission: PermissionLevel): Boolean = query() { - update({ UserTable.id eq id }) { it[UserTable.permission] = permission } > 0 + update({ UsersTable.id eq id }) { it[UsersTable.permission] = permission } > 0 } override suspend fun changeFilePermission(id: UserId, permission: PermissionLevel): Boolean = query() { - update({ UserTable.id eq id }) { it[filePermission] = permission } > 0 + update({ UsersTable.id eq id }) { it[filePermission] = permission } > 0 } override suspend fun getOrCreateUser(id: UserId): DatabaseUser = query() { - insertIgnore { it[UserTable.id] = id } - selectAll().where { UserTable.id eq id }.single().let(::deserialize) + insertIgnore { it[UsersTable.id] = id } + selectAll().where { UsersTable.id eq id }.single().let(::deserialize) } } \ No newline at end of file diff --git a/src/main/kotlin/subit/database/sqlImpl/WordMarkingsImpl.kt b/src/main/kotlin/subit/database/sqlImpl/WordMarkingsImpl.kt new file mode 100644 index 0000000..634ea9b --- /dev/null +++ b/src/main/kotlin/subit/database/sqlImpl/WordMarkingsImpl.kt @@ -0,0 +1,78 @@ +package subit.database.sqlImpl + +import org.jetbrains.exposed.dao.id.IdTable +import org.jetbrains.exposed.sql.* +import org.koin.core.component.KoinComponent +import subit.dataClasses.* +import subit.database.WordMarkings + +class WordMarkingsImpl: DaoSqlImpl(WordMarkingsTable), WordMarkings, KoinComponent +{ + object WordMarkingsTable: IdTable("word_markings") + { + override val id = wordMarkingId("id").autoIncrement().entityId() + val postVersion = reference("post_version", PostVersionsImpl.PostVersionsTable) + val comment = reference("comment", PostsImpl.PostsTable) + val start = integer("start") + val end = integer("end") + val state = enumerationByName("state", 20).default(WordMarkingState.NORMAL) + override val primaryKey: PrimaryKey = PrimaryKey(id) + } + + private fun deserialize(row: ResultRow) = WordMarkingInfo( + id = row[WordMarkingsTable.id].value, + postVersion = row[WordMarkingsTable.postVersion].value, + comment = row[WordMarkingsTable.comment].value, + start = row[WordMarkingsTable.start], + end = row[WordMarkingsTable.end], + state = row[WordMarkingsTable.state] + ) + + override suspend fun getWordMarking(wid: WordMarkingId): WordMarkingInfo? = query() + { + selectAll().where { id eq wid }.singleOrNull()?.let { deserialize(it) } + } + + override suspend fun getWordMarking(postVersion: PostVersionId, comment: PostId): WordMarkingInfo? = query() + { + selectAll() + .andWhere { WordMarkingsTable.postVersion eq postVersion } + .andWhere { WordMarkingsTable.comment eq comment } + .singleOrNull() + ?.let { deserialize(it) } + } + + override suspend fun addWordMarking( + postVersion: PostVersionId, + comment: PostId, + start: Int, + end: Int, + state: WordMarkingState + ): WordMarkingId = query() + { + insertAndGetId { + it[this.postVersion] = postVersion + it[this.comment] = comment + it[this.start] = start + it[this.end] = end + it[this.state] = state + }.value + } + + override suspend fun batchAddWordMarking(wordMarkings: List): List = query() + { + batchInsert(wordMarkings) + { + this[postVersion] = it.postVersion + this[comment] = it.comment + this[start] = it.start + this[end] = it.end + this[state] = it.state + }.map { it[id].value } + } + + override suspend fun getWordMarkings(postVersion: PostVersionId): List = query() + { + selectAll().where { WordMarkingsTable.postVersion eq postVersion }.map(::deserialize) + } +} \ No newline at end of file diff --git a/src/main/kotlin/subit/plugin/ApiDocs.kt b/src/main/kotlin/subit/plugin/ApiDocs.kt index 46091b5..95c1e3a 100644 --- a/src/main/kotlin/subit/plugin/ApiDocs.kt +++ b/src/main/kotlin/subit/plugin/ApiDocs.kt @@ -20,9 +20,6 @@ fun Application.installApiDoc() = install(SwaggerUI) version = subit.version description = "SubIT论坛后端API文档" } - server { - url = "http://localhost:8080" - } this.ignoredRouteSelectors += RateLimitRouteSelector::class schemas { generator = { diff --git a/src/main/kotlin/subit/plugin/RateLimit.kt b/src/main/kotlin/subit/plugin/RateLimit.kt index b00b953..3e7ecb4 100644 --- a/src/main/kotlin/subit/plugin/RateLimit.kt +++ b/src/main/kotlin/subit/plugin/RateLimit.kt @@ -5,7 +5,8 @@ import io.ktor.server.application.* import io.ktor.server.plugins.ratelimit.* import io.ktor.server.request.* import io.ktor.server.response.* -import subit.router.posts.WarpPostId +import subit.dataClasses.PostId.Companion.toPostId +import subit.dataClasses.PostId.Companion.toPostIdOrNull import subit.utils.HttpStatus import subit.utils.respond import java.util.* @@ -77,7 +78,7 @@ sealed interface RateLimit override suspend fun getKey(call: ApplicationCall): Any { val auth = call.request.headers["Authorization"] ?: return UUID.randomUUID() - val postId = call.receive().post + val postId = call.parameters["id"]?.toPostId() return auth to postId } } diff --git a/src/main/kotlin/subit/router/Block.kt b/src/main/kotlin/subit/router/Block.kt index 426b6b1..db9bc75 100644 --- a/src/main/kotlin/subit/router/Block.kt +++ b/src/main/kotlin/subit/router/Block.kt @@ -78,7 +78,7 @@ fun Route.block() = route("/block", { } } response { - statuses(HttpStatus.OK) + statuses(HttpStatus.OK) statuses(HttpStatus.Forbidden, HttpStatus.Unauthorized) } }) { getBlockInfo() } diff --git a/src/main/kotlin/subit/router/Comment.kt b/src/main/kotlin/subit/router/Comment.kt index dd92cb0..c2f06c5 100644 --- a/src/main/kotlin/subit/router/Comment.kt +++ b/src/main/kotlin/subit/router/Comment.kt @@ -9,14 +9,10 @@ import io.ktor.server.routing.* import kotlinx.serialization.Serializable import subit.JWTAuth.getLoginUser import subit.dataClasses.* -import subit.dataClasses.CommentId.Companion.toCommentIdOrNull import subit.dataClasses.PostId.Companion.toPostIdOrNull import subit.database.* import subit.plugin.RateLimit -import subit.router.Context -import subit.router.authenticated -import subit.router.example -import subit.router.get +import subit.router.* import subit.utils.HttpStatus import subit.utils.respond import subit.utils.statuses @@ -27,93 +23,70 @@ fun Route.comment() = route("/comment", { { rateLimit(RateLimit.Post.rateLimitName) { - post("/post/{postId}", { - description = "评论一个帖子" + post("/{postId}", { + description = "评论一个帖子/回复一个评论" request { authenticated(true) pathParameter("postId") { required = true - description = "帖子id" + description = "帖子id/评论id" } - body + body { description = "评论内容" - example("example", CommentContent("评论内容")) + example("example", NewComment("评论内容", WordMarking(PostId(1), 0, 10), false)) } } response { - statuses(HttpStatus.OK, example = CommentIdResponse(CommentId(0))) + statuses(HttpStatus.OK, example = PostId(0), bodyDescription = "创建的评论的id") statuses(HttpStatus.Forbidden, HttpStatus.NotFound) } }) { commentPost() } - - post("/comment/{commentId}", { - description = "评论一个评论" - request { - authenticated(true) - pathParameter("commentId") - { - required = true - description = "评论id" - } - body - { - description = "评论内容" - example("example", CommentContent("评论内容")) - } - } - response { - statuses(HttpStatus.OK, example = CommentIdResponse(CommentId(0))) - statuses(HttpStatus.Forbidden, HttpStatus.NotFound) - } - }) { commentComment() } } - delete("/{commentId}", { - description = "删除一个评论, 需要板块管理员权限" - request { - authenticated(true) - pathParameter("commentId") - { - required = true - description = "评论id" - } - } - response { - statuses(HttpStatus.OK) - statuses(HttpStatus.Forbidden, HttpStatus.NotFound) - } - }) { deleteComment() } - get("/post/{postId}", { - description = "获取一个帖子的评论列表" + description = "获取一个帖子的评论列表(仅包含一级评论, 不包括回复即2~n级评论)" request { authenticated(false) + paged() pathParameter("postId") { required = true description = "帖子id" } + queryParameter("sort") + { + description = "排序方式" + required = false + example(Posts.PostListSort.NEW) + } } response { - statuses>(HttpStatus.OK, example = listOf(CommentId(0))) + statuses>(HttpStatus.OK, example = sliceOf(PostFull.example)) statuses(HttpStatus.NotFound) } }) { getPostComments() } get("/comment/{commentId}", { - description = "获取一个评论的评论列表" + description = "获取一个评论的回复列表, 即该评论下的所有回复, 包括2~n级评论" request { authenticated(false) - pathParameter("commentId") + paged() + pathParameter("commentId") { required = true description = "评论id" } + queryParameter("sort") + { + description = "排序方式" + required = false + example(Posts.PostListSort.NEW) + } } response { - statuses>(HttpStatus.OK, example = listOf(CommentId(0))) + statuses>(HttpStatus.OK, example = sliceOf(PostFull.example)) statuses(HttpStatus.NotFound) } }) { getCommentComments() } @@ -122,117 +95,97 @@ fun Route.comment() = route("/comment", { description = "获取一个评论的信息" request { authenticated(false) - pathParameter("commentId") + pathParameter("commentId") { required = true description = "评论id" } } response { - statuses(HttpStatus.OK, example = Comment.example) + statuses(HttpStatus.OK, example = PostFull.example) statuses(HttpStatus.NotFound) } }) { getComment() } } @Serializable -private data class CommentContent(val content: String) +private data class NewComment(val content: String, val wordMarking: WordMarking? = null, val anonymous: Boolean) @Serializable -private data class CommentIdResponse(val id: CommentId) +private data class WordMarking(val postId: PostId, val start: Int, val end: Int) private suspend fun Context.commentPost() { val postId = call.parameters["postId"]?.toPostIdOrNull() ?: return call.respond(HttpStatus.BadRequest) - val content = receiveAndCheckBody().content - val author = get().getPost(postId)?.let { postInfo -> - checkPermission { checkCanComment(postInfo) } - postInfo.author - } ?: return call.respond(HttpStatus.NotFound) + val newComment = receiveAndCheckBody() val loginUser = getLoginUser() ?: return call.respond(HttpStatus.Unauthorized) + val posts = get() - get().createComment(post = postId, parent = null, author = loginUser.id, content = content) - ?: return call.respond(HttpStatus.NotFound) - - if (loginUser.id != author) get().createNotice( - Notice.makeObjectMessage( - type = Notice.Type.POST_COMMENT, - user = author, - obj = postId, + val parent = posts.getPostInfo(postId) ?: return call.respond(HttpStatus.NotFound) + checkPermission { checkCanComment(parent) } + val commentId = posts.createPost(parent = postId, author = loginUser.id, block = parent.block, anonymous = newComment.anonymous) ?: return call.respond(HttpStatus.NotFound) + if (newComment.wordMarking != null) + { + val markingPost = posts.getPostFullBasicInfo(newComment.wordMarking.postId) ?: return call.respond(HttpStatus.NotFound) + get().addWordMarking( + postVersion = markingPost.lastVersionId, + comment = commentId, + start = newComment.wordMarking.start, + end = newComment.wordMarking.end, + state = WordMarkingState.NORMAL ) - ) - - call.respond(HttpStatus.OK) -} - -private suspend fun Context.commentComment() -{ - val commentId = call.parameters["commentId"]?.toCommentIdOrNull() ?: return call.respond(HttpStatus.BadRequest) - val content = receiveAndCheckBody().content - val author = get().getComment(commentId)?.let { comment -> - get().getPost(comment.post)?.let { postInfo -> - checkPermission { checkCanComment(postInfo) } - } - comment.author - } ?: return call.respond(HttpStatus.NotFound) - val loginUser = getLoginUser() ?: return call.respond(HttpStatus.Unauthorized) - - get().createComment(post = null, parent = commentId, author = loginUser.id, content = content) - ?: return call.respond(HttpStatus.NotFound) + } - if (loginUser.id != author) get().createNotice( + if (loginUser.id != parent.author) get().createNotice( Notice.makeObjectMessage( - type = Notice.Type.COMMENT_REPLY, - user = author, - obj = commentId, + type = if (postId == commentId) Notice.Type.POST_COMMENT else Notice.Type.COMMENT_REPLY, + user = parent.author, + obj = postId, ) ) call.respond(HttpStatus.OK) } -private suspend fun Context.deleteComment() -{ - val commentId = call.parameters["commentId"]?.toCommentIdOrNull() ?: return call.respond(HttpStatus.BadRequest) - get().getComment(commentId)?.let { comment -> - get().getPost(comment.post)?.let { postInfo -> - checkPermission { checkCanDelete(postInfo) } - } - } ?: return call.respond(HttpStatus.NotFound) - get().setCommentState(commentId, State.DELETED) - call.respond(HttpStatus.OK) -} - private suspend fun Context.getPostComments() { val postId = call.parameters["postId"]?.toPostIdOrNull() ?: return call.respond(HttpStatus.BadRequest) - get().getPost(postId)?.let { postInfo -> - checkPermission { checkCanRead(postInfo) } - } ?: return call.respond(HttpStatus.NotFound) - get().getComments(post = postId)?.map(Comment::id)?.let { call.respond(HttpStatus.OK, it) } - ?: call.respond(HttpStatus.NotFound) + val type = call.parameters["sort"] + ?.runCatching { Posts.PostListSort.valueOf(this) } + ?.getOrNull() ?: return call.respond(HttpStatus.BadRequest) + val (begin, count) = call.getPage() + val posts = get() + val post = posts.getPostInfo(postId) ?: return call.respond(HttpStatus.NotFound) + checkPermission { checkCanRead(post) } + val comments = posts.getChildPosts(postId, type, begin, count) + if (getLoginUser().hasGlobalAdmin()) + call.respond(HttpStatus.OK, comments) + else + call.respond(HttpStatus.OK, comments.map { if (it.anonymous) it.copy(author = UserId(0)) else it }) } private suspend fun Context.getCommentComments() { - val commentId = call.parameters["commentId"]?.toCommentIdOrNull() ?: return call.respond(HttpStatus.BadRequest) - get().getComment(commentId)?.let { comment -> - get().getPost(comment.post)?.let { postInfo -> - checkPermission { checkCanRead(postInfo) } - } - } ?: return call.respond(HttpStatus.NotFound) - get().getComments(parent = commentId) - ?.map(Comment::id) - ?.let { call.respond(HttpStatus.OK, it) } ?: call.respond(HttpStatus.NotFound) + val commentId = call.parameters["commentId"]?.toPostIdOrNull() ?: return call.respond(HttpStatus.BadRequest) + val type = call.parameters["sort"] + ?.runCatching { Posts.PostListSort.valueOf(this) } + ?.getOrNull() ?: return call.respond(HttpStatus.BadRequest) + val (begin, count) = call.getPage() + val posts = get() + val comment = posts.getPostInfo(commentId) ?: return call.respond(HttpStatus.NotFound) + checkPermission { checkCanRead(comment) } + val comments = posts.getDescendants(commentId, type, begin, count) + if (getLoginUser().hasGlobalAdmin()) + call.respond(HttpStatus.OK, comments) + else + call.respond(HttpStatus.OK, comments.map { if (it.anonymous) it.copy(author = UserId(0)) else it }) } private suspend fun Context.getComment() { - val commentId = call.parameters["commentId"]?.toCommentIdOrNull() ?: return call.respond(HttpStatus.BadRequest) - val comment = get().getComment(commentId) ?: return call.respond(HttpStatus.NotFound) - get().getPost(comment.post)?.let { postInfo -> - checkPermission { checkCanRead(postInfo) } - } ?: return call.respond(HttpStatus.NotFound) - if (comment.state != State.NORMAL) checkPermission { checkHasGlobalAdmin() } - call.respond(HttpStatus.OK, comment) + val commentId = call.parameters["commentId"]?.toPostIdOrNull() ?: return call.respond(HttpStatus.BadRequest) + val posts = get() + val comment = posts.getPostFull(commentId) ?: return call.respond(HttpStatus.NotFound) + checkPermission { checkCanRead(comment.toPostInfo()) } + call.respond(HttpStatus.OK, if (comment.anonymous) comment.copy(author = UserId(0)) else comment) } diff --git a/src/main/kotlin/subit/router/Home.kt b/src/main/kotlin/subit/router/Home.kt index ba8fce6..a1fe3ed 100644 --- a/src/main/kotlin/subit/router/Home.kt +++ b/src/main/kotlin/subit/router/Home.kt @@ -109,7 +109,7 @@ private suspend fun Context.getHotPosts() { val posts = get() val count = call.parameters["count"]?.toIntOrNull() ?: 10 - val result = posts.getRecommendPosts(count) + val result = posts.getRecommendPosts(getLoginUser()?.id, count) call.respond(HttpStatusCode.OK, result) } @@ -128,6 +128,7 @@ data class AdvancedSearchData( val isOnlyTitle: Boolean? = null, val lastModifiedAfter: Long? = null, val createTime: Pair? = null, + val isOnlyPost: Boolean? = null ) private suspend fun Context.searchPost() @@ -138,6 +139,6 @@ private suspend fun Context.searchPost() val advancedSearchData = if(openAdvancedSearch) receiveAndCheckBody() else AdvancedSearchData() - val posts = get().searchPosts(getLoginUser()?.id, key, advancedSearchData, begin, count) + val posts = get().searchPosts(getLoginUser()?.toDatabaseUser(), key, advancedSearchData, begin, count) call.respond(HttpStatus.OK, posts) } \ No newline at end of file diff --git a/src/main/kotlin/subit/router/Posts.kt b/src/main/kotlin/subit/router/Posts.kt index 7d405aa..6d30243 100644 --- a/src/main/kotlin/subit/router/Posts.kt +++ b/src/main/kotlin/subit/router/Posts.kt @@ -5,23 +5,27 @@ package subit.router.posts import io.github.smiley4.ktorswaggerui.dsl.routing.* import io.ktor.server.application.* import io.ktor.server.plugins.ratelimit.* +import io.ktor.server.request.* import io.ktor.server.routing.* import kotlinx.serialization.Serializable import subit.JWTAuth.getLoginUser import subit.dataClasses.* import subit.dataClasses.BlockId.Companion.toBlockIdOrNull import subit.dataClasses.PostId.Companion.toPostIdOrNull +import subit.dataClasses.PostVersionId.Companion.toPostVersionIdOrNull import subit.dataClasses.UserId.Companion.toUserIdOrNull import subit.database.* import subit.plugin.RateLimit import subit.router.* import subit.utils.HttpStatus +import subit.utils.Locks import subit.utils.respond import subit.utils.statuses fun Route.posts() = route("/post", { tags = listOf("帖子") -}) { +}) +{ rateLimit(RateLimit.Post.rateLimitName) { @@ -37,15 +41,77 @@ fun Route.posts() = route("/post", { } } response { - statuses(HttpStatus.OK, example = WarpPostId(PostId(0))) + statuses(HttpStatus.OK, example = PostId(0)) statuses(HttpStatus.BadRequest, HttpStatus.TooManyRequests) } }) { newPost() } + } + + get("/top/{block}", { + description = "获取板块置顶帖子列表" + request { + authenticated(false) + pathParameter("block") + { + required = true + description = "板块ID" + } + paged() + } + response { + statuses>(HttpStatus.OK, example = sliceOf(PostFullBasicInfo.example)) + } + }) { getBlockTopPosts() } + + id() + list() + version() +} + +private fun Route.id() = route("/{id}",{ + request { + pathParameter("id") + { + required = true + description = "帖子ID" + } + } + response { + statuses(HttpStatus.NotFound) + } +}) +{ + get("", { + description = "获取帖子信息" + request { + authenticated(false) + } + response { + statuses(HttpStatus.OK, example = PostFull.example) + } + }) { getPost() } - put("/{id}", { + delete("", { + description = "删除帖子" + request { + authenticated(true) + } + response { + statuses(HttpStatus.OK) + } + }) { deletePost() } + + rateLimit(RateLimit.Post.rateLimitName) + { + put("", { description = "编辑帖子(block及以上管理员可修改)" request { authenticated(true) + pathParameter("id") + { + required = true + description = "帖子ID" + } body { required = true @@ -60,170 +126,138 @@ fun Route.posts() = route("/post", { }) { editPost() } } - get("/{id}", { - description = "获取帖子信息" - request { - authenticated(false) - pathParameter("id") - { - required = true - description = "要获取的帖子的id, 若是匿名帖则author始终是null" - } - } - response { - statuses(HttpStatus.OK, example = PostFull.example) - statuses(HttpStatus.NotFound) - } - }) { getPost() } - - delete("/{id}", { - description = "删除帖子" + post("/like", { + description = "点赞/点踩/取消点赞/收藏/取消收藏 帖子" request { authenticated(true) - pathParameter("id") + body { required = true - description = "要删除的帖子的id" + description = "点赞/点踩/取消点赞/收藏/取消收藏" + example("example", LikePost(LikeType.LIKE)) } } response { statuses(HttpStatus.OK) - statuses(HttpStatus.BadRequest) } - }) { deletePost() } + }) { likePost() } - post("/{id}/like", { - description = "点赞/点踩/取消点赞/收藏/取消收藏 帖子" + rateLimit(RateLimit.AddView.rateLimitName) + { + post("/view", { + description = "增加帖子浏览量, 应在用户打开帖子时调用. 若未登陆将不会增加浏览量" + request { + authenticated(true) + queryParameter("id") + { + required = true + description = "帖子ID" + } + } + response { + statuses(HttpStatus.OK, HttpStatus.TooManyRequests, HttpStatus.Unauthorized) + } + }) { addView() } + } + + post("/{id}/setTop/{top}", { + description = "设置帖子是否置顶" request { authenticated(true) - pathParameter("id") - { - required = true - description = "帖子的id" - } - body + pathParameter("top") { required = true - description = "点赞/点踩/取消点赞/收藏/取消收藏" - example("example", LikePost(LikeType.LIKE)) + description = "是否置顶" + example(true) } } response { statuses(HttpStatus.OK) - statuses(HttpStatus.NotFound) } - }) { likePost() } + }) { setBlockTopPosts() } +} +private fun Route.list() = route("/list",{ + request { + authenticated(false) + paged() + queryParameter("sort") + { + required = true + description = "排序方式" + example(Posts.PostListSort.NEW) + } + } + response { + statuses>(HttpStatus.OK, example = sliceOf(PostFullBasicInfo.example)) + } +}) +{ get("/list/user/{user}", { description = "获取用户发送的帖子列表" request { - authenticated(false) pathParameter("user") { required = true description = "作者ID" } - paged() - } - response { - statuses>(HttpStatus.OK, example = sliceOf(PostId(0))) } }) { getUserPosts() } get("/list/block/{block}", { description = "获取板块帖子列表" request { - authenticated(false) pathParameter("block") { required = true description = "板块ID" } - queryParameter("sort") - { - required = true - description = "排序方式" - example(Posts.PostListSort.NEW) - } - paged() - } - response { - statuses>(HttpStatus.OK, example = sliceOf(PostId(0))) } }) { getBlockPosts() } +} - get("/top/{block}", { - description = "获取板块置顶帖子列表" +private fun Route.version() = route("/version", { + response { + statuses(HttpStatus.NotFound) + } +}) +{ + get("/list/{postId}", { request { authenticated(false) - pathParameter("block") + paged() + pathParameter("postId") { required = true - description = "板块ID" + description = "帖子ID" } - paged() } response { - statuses>(HttpStatus.OK, example = sliceOf(PostId(0))) + statuses>(HttpStatus.OK, example = sliceOf(PostVersionBasicInfo.example)) } - }) { getBlockTopPosts() } + }) { listVersions() } - get("/{id}/setTop/{top}", { - description = "设置帖子是否置顶" + get("/{versionId}", { request { - authenticated(true) - pathParameter("id") - { - required = true - description = "帖子的id" - } - pathParameter("top") + authenticated(false) + pathParameter("versionId") { required = true - description = "是否置顶" - example(true) + description = "版本ID" } } response { - statuses(HttpStatus.OK) - statuses(HttpStatus.BadRequest) + statuses(HttpStatus.OK, example = PostVersionInfo.example) } - }) { setBlockTopPosts() } - - rateLimit(RateLimit.AddView.rateLimitName) - { - post("/view", { - description = "增加帖子浏览量, 应在用户打开帖子时调用. 若未登陆将不会增加浏览量" - request { - authenticated(true) - body - { - required = true - description = "帖子ID" - example("example", WarpPostId(PostId(0))) - } - } - response { - statuses( - HttpStatus.OK, - HttpStatus.Unauthorized, - HttpStatus.TooManyRequests - ) - } - }) { addView() } - } + }) { getVersion() } } -@Serializable -data class WarpPostId(val post: PostId) - private suspend fun Context.getPost() { val id = call.parameters["id"]?.toPostIdOrNull() ?: return call.respond(HttpStatus.BadRequest) - val postInfo = get().getPost(id) ?: return call.respond(HttpStatus.NotFound) + val postFull = get().getPostFull(id) ?: return call.respond(HttpStatus.NotFound) val loginUser = getLoginUser() - checkPermission { checkCanRead(postInfo) } - val postFull = postInfo.toPostFull() + checkPermission { checkCanRead(postFull.toPostInfo()) } if (!postFull.anonymous) call.respond(HttpStatus.OK, postFull) // 若不是匿名帖则直接返回 else if (loginUser == null || loginUser.permission < PermissionLevel.ADMIN) call.respond( HttpStatus.OK, @@ -239,28 +273,135 @@ private suspend fun Context.getPost() @Serializable private data class EditPost(val title: String, val content: String) +@Serializable data class Interval(val start: Int, val end: Int) + +@Serializable +data class Operators( + val insert: Map = mapOf(), + val del: List = listOf(), + val newTitle: String? = null +) + +// 因为编辑帖子计算开销较大, 所以使用锁保证同一时间只有一个编辑操作. +// 这里是保证同一用户只能同时编辑一个帖子 +private val editPostLock = Locks() private suspend fun Context.editPost() { - val post = receiveAndCheckBody() - val pid = call.parameters["id"]?.toPostIdOrNull() ?: return call.respond(HttpStatus.BadRequest) + val operators = receiveAndCheckBody() + val id = call.parameters["id"]?.toPostIdOrNull() ?: return call.respond(HttpStatus.BadRequest) val loginUser = getLoginUser() ?: return call.respond(HttpStatus.Unauthorized) - val postInfo = get().getPost(pid) ?: return call.respond(HttpStatus.NotFound) - if (postInfo.author != loginUser.id) checkPermission { checkHasAdminIn(postInfo.block) } - get().editPost(pid, post.title, post.content) + val postInfo = get().getPostInfo(id) ?: return call.respond(HttpStatus.NotFound) + if (postInfo.author != loginUser.id) call.respond(HttpStatus.Forbidden.copy(message = "文章仅允许作者编辑")) + + editPostLock.tryWithLock(loginUser.id, { call.respond(HttpStatus.TooManyRequests) }) + { + val postVersions = get() + val wordMarkings = get() + val oldVersion = postVersions.getLatestPostVersion(id) ?: return@tryWithLock call.respond(HttpStatus.NotFound) + val oldVersionInfo = postVersions.getPostVersion(oldVersion) ?: return@tryWithLock call.respond(HttpStatus.NotFound) + + var markings = wordMarkings.getWordMarkings(oldVersion) + + val del = IntArray(oldVersionInfo.content.length + 1) + for (i in operators.del) // 在每个删除区间的左端点处+1, 右端点+1处-1 + { + del[i.start]++ + del[i.end+1]-- + } + // 进行前缀和操作 + for (i in 1 until del.size) del[i] += del[i - 1] + // 到此处del[i]表示原字符串中第i个字符被删除的次数, 理论上只能为0或1 + + // 计算新的content + val newContent = StringBuilder() + for (i in oldVersionInfo.content.indices) + { + val insert = operators.insert[i] + // 如果有插入的话先插入 + if (insert != null) newContent.append(insert) + // 如果没有删除的话再添加 + if (del[i] == 0) newContent.append(oldVersionInfo.content[i]) + } + // 如果末尾有插入的话添加 + val insert = operators.insert[oldVersionInfo.content.length] + if (insert != null) newContent.append(insert) + + val newVersion = postVersions.createPostVersion( + post = id, + title = operators.newTitle ?: oldVersionInfo.title, + content = newContent.toString() + ) + + //////////////////////////// 以下为对划词评论的处理 //////////////////////////// + + val pre = IntArray(oldVersionInfo.content.length + 1) + + ///////// 处理划词评论中出现删除的情况 ///////// + + // 对del数组进行再前缀和操作 + for (i in 1 until pre.size) pre[i] = pre[i - 1] + del[i - 1] + // 到此处pre[i]表示原字符串中第i个字符及其之前的字符被删除的次数 + + markings = markings.map { + if (it.state != WordMarkingState.NORMAL) return@map it + // 如果删除区间的左端点和右端点的删除次数相同, 则说明标记区间内的字符没有被删除, 不做处理 + // 注意: pre[start-1]是在区间前的字符被删除的次数, pre[end]是在末尾及之前的字符被删除的次数, + // 所以要判断start-1和end是否相等. 如果判断start和end相等的话, 则会导致在start处的字符被删除的情况被忽略 + if (pre[it.start-1] == pre[it.end]) return@map it + // 如果删除区间的左端点和右端点的删除次数不同, 则说明标记区间内的字符有被删除的情况 + // 需要将标记区间的状态设置为DELETED + it.copy(state = WordMarkingState.DELETED, start = 0, end = 0) + } + // 到此处所有因为删除操作而被删除的标记都被处理完了 + for (i in pre.indices) pre[i] = 0 // 重置pre数组 + + ///////// 处理划词评论中出现插入的情况 ///////// + + // insert的位置进行前缀和操作 + for (i in 1 until pre.size) pre[i] = pre[i - 1] + (if (operators.insert[i - 1] != null) 1 else 0) + // 到此处pre[i]表示原字符串中第i个字符及其之前的字符被插入的次数 + + markings = markings.map { + if (it.state != WordMarkingState.NORMAL) return@map it + // 如果插入区间的左端点和右端点的插入次数相同, 则说明标记区间内的字符没有被插入, 不做处理 + // 注意: pre[start]是在区间前的字符被插入的次数, pre[end]是在末尾及之前的字符被插入的次数, 所以要判断start和end是否相等. + if (pre[it.start] == pre[it.end]) return@map it + // 如果插入区间的左端点和右端点的插入次数不同, 则说明标记区间内的字符有被插入的情况 + // 需要将标记区间的状态设置为DELETED + it.copy(state = WordMarkingState.DELETED, start = 0, end = 0) + } + for (i in pre.indices) pre[i] = 0 // 重置pre数组 + + ///////// 至此所有因为修改而失效的划词都处理完了, 接下来是修改导致的划词位置偏移 ///////// + + for (i in 1 until pre.size) pre[i] = pre[i - 1] - del[i - 1] + (if (operators.insert[i - 1] != null) 1 else 0) + // 到此处pre[i]表示原字符串中第i个字符及其之前的字符数目变化, 为正表示总的来说字符数目增加, 为负表示总的来说字符数目减少 + + markings = markings.map { + if (it.state != WordMarkingState.NORMAL) return@map it + it.copy(start = it.start + pre[it.start], end = it.end + pre[it.end]) + } + + ///////// 至此所有的划词评论的处理结束了 ///////// + + // 将新的标记写入数据库 + wordMarkings.batchAddWordMarking(markings) + } + call.respond(HttpStatus.OK) } private suspend fun Context.deletePost() { val id = call.parameters["id"]?.toPostIdOrNull() ?: return call.respond(HttpStatus.BadRequest) - val post = get().getPost(id) ?: return call.respond(HttpStatus.NotFound) + val post = get().getPostInfo(id) ?: return call.respond(HttpStatus.NotFound) val loginUser = getLoginUser() ?: return call.respond(HttpStatus.Unauthorized) checkPermission { checkCanDelete(post) } get().setPostState(id, State.DELETED) if (post.author != loginUser.id) get().createNotice( Notice.makeSystemNotice( user = post.author, - content = "您的帖子 ${post.title} 已被删除" + content = "您的帖子 ${post.id} 已被删除" ) ) call.respond(HttpStatus.OK) @@ -270,7 +411,6 @@ private suspend fun Context.deletePost() private enum class LikeType { LIKE, - DISLIKE, UNLIKE, STAR, UNSTAR @@ -282,14 +422,13 @@ private data class LikePost(val type: LikeType) private suspend fun Context.likePost() { val id = call.parameters["id"]?.toPostIdOrNull() ?: return call.respond(HttpStatus.BadRequest) - val post = get().getPost(id) ?: return call.respond(HttpStatus.NotFound) + val post = get().getPostInfo(id) ?: return call.respond(HttpStatus.NotFound) val type = receiveAndCheckBody().type val loginUser = getLoginUser() ?: return call.respond(HttpStatus.Unauthorized) checkPermission { checkCanRead(post) } when (type) { - LikeType.LIKE -> get().like(loginUser.id, id, true) - LikeType.DISLIKE -> get().like(loginUser.id, id, false) + LikeType.LIKE -> get().like(loginUser.id, id) LikeType.UNLIKE -> get().unlike(loginUser.id, id) LikeType.STAR -> get().addStar(loginUser.id, id) LikeType.UNSTAR -> get().removeStar(loginUser.id, id) @@ -325,22 +464,29 @@ private suspend fun Context.newPost() if (newPost.anonymous) checkPermission { checkCanAnonymous(block) } if (newPost.top) checkPermission { checkHasAdminIn(block.id) } val id = get().createPost( - title = newPost.title, - content = newPost.content, author = loginUser.id, anonymous = newPost.anonymous, block = newPost.block, - top = newPost.top + top = newPost.top, + parent = null + )!! // 父帖子为null, 不可能出现找不到父帖子的情况 + get().createPostVersion( + post = id, + title = newPost.title, + content = newPost.content, ) - call.respond(HttpStatus.OK, WarpPostId(id)) + call.respond(HttpStatus.OK, id) } private suspend fun Context.getUserPosts() { val loginUser = getLoginUser() val author = call.parameters["user"]?.toUserIdOrNull() ?: return call.respond(HttpStatus.BadRequest) + val type = call.parameters["sort"] + ?.runCatching { Posts.PostListSort.valueOf(this) } + ?.getOrNull() ?: return call.respond(HttpStatus.BadRequest) val (begin, count) = call.getPage() - val posts = get().getUserPosts(loginUser?.id, author, begin, count) + val posts = get().getUserPosts(loginUser?.toDatabaseUser(), author, type, begin, count) call.respond(HttpStatus.OK, posts) } @@ -371,16 +517,34 @@ private suspend fun Context.setBlockTopPosts() { val pid = call.parameters["id"]?.toPostIdOrNull() ?: return call.respond(HttpStatus.BadRequest) val top = call.parameters["top"]?.toBooleanStrictOrNull() ?: return call.respond(HttpStatus.BadRequest) - val postInfo = get().getPost(pid) ?: return call.respond(HttpStatus.NotFound) - checkPermission { checkHasAdminIn(postInfo.block) } - get().editPost(pid, top = top) + val postInfo = get().getPostInfo(pid) ?: return call.respond(HttpStatus.NotFound) + checkPermission { + checkCanRead(postInfo) + checkHasAdminIn(postInfo.block) + } + if (!get().setTop(pid, top = top)) return call.respond(HttpStatus.NotFound) call.respond(HttpStatus.OK) } private suspend fun Context.addView() { getLoginUser() ?: return call.respond(HttpStatus.Unauthorized) - val pid = receiveAndCheckBody() - get().addView(pid.post) + val pid = call.parameters["id"]?.toPostIdOrNull() ?: return call.respond(HttpStatus.BadRequest) + get().addView(pid) call.respond(HttpStatus.OK) +} + +private suspend fun Context.listVersions() +{ + val postId = call.parameters["postId"]?.toPostIdOrNull() ?: return call.respond(HttpStatus.BadRequest) + val (begin, count) = call.getPage() + val versions = get().getPostVersions(postId, begin, count) + call.respond(HttpStatus.OK, versions) +} + +private suspend fun Context.getVersion() +{ + val versionId = call.parameters["versionId"]?.toPostVersionIdOrNull() ?: return call.respond(HttpStatus.BadRequest) + val version = get().getPostVersion(versionId) ?: return call.respond(HttpStatus.NotFound) + call.respond(HttpStatus.OK, version) } \ No newline at end of file diff --git a/src/main/kotlin/subit/router/Router.kt b/src/main/kotlin/subit/router/Router.kt index 85a8be3..b29f953 100644 --- a/src/main/kotlin/subit/router/Router.kt +++ b/src/main/kotlin/subit/router/Router.kt @@ -23,14 +23,17 @@ import subit.router.posts.posts import subit.router.privateChat.privateChat import subit.router.report.report import subit.router.user.user +import subit.router.wordMarkings.wordMarkings import subit.utils.HttpStatus import subit.utils.respond fun Application.router() = routing() { + val rootPath = this.application.environment.rootPath + get("/", { hidden = true }) { - call.respondRedirect("/api-docs") + call.respondRedirect("$rootPath/api-docs") } authenticate("auth-api-docs") @@ -41,7 +44,7 @@ fun Application.router() = routing() { openApiSpec() } - swaggerUI("/api-docs/api.json") + swaggerUI("$rootPath/api-docs/api.json") } } @@ -81,5 +84,6 @@ fun Application.router() = routing() privateChat() report() user() + wordMarkings() } } \ No newline at end of file diff --git a/src/main/kotlin/subit/router/User.kt b/src/main/kotlin/subit/router/User.kt index 097c063..e9a9096 100644 --- a/src/main/kotlin/subit/router/User.kt +++ b/src/main/kotlin/subit/router/User.kt @@ -2,14 +2,11 @@ package subit.router.user -import io.github.smiley4.ktorswaggerui.dsl.routing.* -import io.ktor.http.* +import io.github.smiley4.ktorswaggerui.dsl.routing.get +import io.github.smiley4.ktorswaggerui.dsl.routing.post +import io.github.smiley4.ktorswaggerui.dsl.routing.route import io.ktor.server.application.* -import io.ktor.server.request.* -import io.ktor.server.response.* import io.ktor.server.routing.* -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.withContext import kotlinx.serialization.Serializable import subit.JWTAuth.getLoginUser import subit.dataClasses.* @@ -17,10 +14,10 @@ import subit.dataClasses.UserId.Companion.toUserIdOrNull import subit.database.* import subit.logger.ForumLogger import subit.router.* -import subit.utils.* -import java.io.ByteArrayOutputStream -import java.io.File -import javax.imageio.ImageIO +import subit.utils.HttpStatus +import subit.utils.SSO +import subit.utils.respond +import subit.utils.statuses private val logger = ForumLogger.getLogger() fun Route.user() = route("/user", { diff --git a/src/main/kotlin/subit/router/WordMarkings.kt b/src/main/kotlin/subit/router/WordMarkings.kt new file mode 100644 index 0000000..e225957 --- /dev/null +++ b/src/main/kotlin/subit/router/WordMarkings.kt @@ -0,0 +1,111 @@ +@file:Suppress("PackageDirectoryMismatch") + +package subit.router.wordMarkings + +import io.github.smiley4.ktorswaggerui.dsl.routing.get +import io.github.smiley4.ktorswaggerui.dsl.routing.route +import io.ktor.server.application.* +import io.ktor.server.routing.* +import subit.dataClasses.PostId +import subit.dataClasses.PostId.Companion.toPostIdOrNull +import subit.dataClasses.PostVersionId +import subit.dataClasses.PostVersionId.Companion.toPostVersionIdOrNull +import subit.dataClasses.WordMarkingId +import subit.dataClasses.WordMarkingInfo +import subit.database.PostVersions +import subit.database.Posts +import subit.database.WordMarkings +import subit.database.checkPermission +import subit.router.Context +import subit.router.get +import subit.utils.HttpStatus +import subit.utils.respond +import subit.utils.statuses + +fun Route.wordMarkings() = route("/wordMarkings",{ + tags = listOf("划词评论") + description = "划词评论接口" +}) +{ + get("/list/{postVersionId}", { + description = "获取一个帖子版本的划词评论列表" + request { + pathParameter("postVersionId") + { + required = true + description = "帖子版本id" + } + } + response { + statuses>(HttpStatus.OK, example = listOf(WordMarkingInfo.example)) + } + }) { getWordMarkings() } + + get("/comment/{commentId}", { + description = "获取一个评论对于某一个文章版本的划词评论" + request { + pathParameter("commentId") + { + required = true + description = "划词评论id" + } + queryParameter("postVersionId") + { + required = true + description = "帖子版本id" + } + } + response { + statuses(HttpStatus.OK, example = WordMarkingInfo.example) + statuses(HttpStatus.NotFound) + } + }) { getWordMarking() } + + get("/{id}",{ + description = "获取一个划词评论" + request { + pathParameter("id") + { + required = true + description = "划词评论id" + } + } + response { + statuses(HttpStatus.OK, example = WordMarkingInfo.example) + statuses(HttpStatus.NotFound) + } + }) { getWordMarkingById() } +} + +suspend fun Context.getWordMarkings() +{ + val postVersionId = call.parameters["postVersionId"]?.toPostVersionIdOrNull() ?: return call.respond(HttpStatus.BadRequest) + val postVersions = get() + val postVersion = postVersions.getPostVersion(postVersionId) ?: return call.respond(HttpStatus.NotFound) + val post = get().getPostInfo(postVersion.post) ?: return call.respond(HttpStatus.NotFound) + checkPermission { checkCanRead(post) } + val wordMarkings = get().getWordMarkings(postVersionId) + return call.respond(HttpStatus.OK, wordMarkings) +} + +suspend fun Context.getWordMarking() +{ + val commentId = call.parameters["commentId"]?.toPostIdOrNull() ?: return call.respond(HttpStatus.BadRequest) + val postVersionId = call.parameters["postVersionId"]?.toPostVersionIdOrNull() ?: return call.respond(HttpStatus.BadRequest) + val postVersions = get() + val postVersion = postVersions.getPostVersion(postVersionId) ?: return call.respond(HttpStatus.NotFound) + val post = get().getPostInfo(postVersion.post) ?: return call.respond(HttpStatus.NotFound) + checkPermission { checkCanRead(post) } + val wordMarking = get().getWordMarking(postVersionId, commentId) ?: return call.respond(HttpStatus.NotFound) + return call.respond(HttpStatus.OK, wordMarking) +} + +suspend fun Context.getWordMarkingById() +{ + val id = call.parameters["id"]?.toLongOrNull() ?: return call.respond(HttpStatus.BadRequest) + val wordMarking = get().getWordMarking(WordMarkingId(id)) ?: return call.respond(HttpStatus.NotFound) + val postVersion = get().getPostVersion(wordMarking.postVersion) ?: return call.respond(HttpStatus.NotFound) + val post = get().getPostInfo(postVersion.post) ?: return call.respond(HttpStatus.NotFound) + checkPermission { checkCanRead(post) } + return call.respond(HttpStatus.OK, wordMarking) +} \ No newline at end of file diff --git a/src/main/kotlin/subit/utils/Locks.kt b/src/main/kotlin/subit/utils/Locks.kt new file mode 100644 index 0000000..c15dc5a --- /dev/null +++ b/src/main/kotlin/subit/utils/Locks.kt @@ -0,0 +1,86 @@ +package subit.utils + +import kotlinx.coroutines.GlobalScope +import kotlinx.coroutines.launch +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import java.lang.ref.PhantomReference +import java.lang.ref.ReferenceQueue +import kotlin.contracts.ExperimentalContracts +import kotlin.contracts.InvocationKind +import kotlin.contracts.contract + +class Locks +{ + val data = hashMapOf>>() + private val mutex = Mutex() + + companion object + { + private val queue = ReferenceQueue>() + + init + { + @Suppress("OPT_IN_USAGE") + GlobalScope.launch() + { + while (true) + { + val ref = runCatching { queue.remove() }.getOrNull() ?: continue + ref as LockReference<*> + ref.locks.mutex.withLock() + { + ref.locks.data.remove(ref.id) + } + } + }.start() + } + } + + class Lock(val locks: Locks, val id: K): Mutex by Mutex() + class LockReference(lock: Lock): PhantomReference>(lock, queue) + { + val id = lock.id + val locks = lock.locks + } + + suspend fun getLock(key: K): Lock = mutex.withLock() + { + data[key]?.get()?.let { return it } + val newLock = Lock(this, key) + data[key] = LockReference(newLock) + return newLock + } + + @OptIn(ExperimentalContracts::class) + suspend inline fun withLock(key: K, block: ()->R): R + { + contract { + callsInPlace(block, InvocationKind.EXACTLY_ONCE) + } + return getLock(key).withLock { block() } + } + + + /** + * 尝试获取锁, 获取失败则执行 onFail 并返回其结果 + */ + @OptIn(ExperimentalContracts::class) + suspend inline fun tryWithLock(key: K, onFail: ()->Unit, block: ()->R): R? + { + contract { + callsInPlace(block, InvocationKind.AT_MOST_ONCE) + callsInPlace(onFail, InvocationKind.AT_MOST_ONCE) + } + val lock = getLock(key) + if (!lock.tryLock()) return onFail().let { null } + return try + { + block() + } + finally + { + lock.unlock() + } + } +} \ No newline at end of file diff --git a/src/main/kotlin/subit/utils/SSO.kt b/src/main/kotlin/subit/utils/SSO.kt index b2905cf..10496a4 100644 --- a/src/main/kotlin/subit/utils/SSO.kt +++ b/src/main/kotlin/subit/utils/SSO.kt @@ -14,19 +14,25 @@ import java.net.URL object SSO: KoinComponent { val users: Users by inject() - val json = Json { + private val json = Json { ignoreUnknownKeys = true isLenient = true allowStructuredMapKeys = true encodeDefaults = true } - private fun decodeSsoUser(response: String): SsoUser? = - runCatching { json.decodeFromString(SsoUserFull.serializer(), response) } - .getOrElse { - runCatching { json.decodeFromString(SsoUserInfo.serializer(), response) } - .getOrNull() - } + private fun decodeSsoUser(response: String): SsoUser? + { + runCatching { + return json.decodeFromString>(response).data + } + + runCatching { + return json.decodeFromString>(response).data + } + + return null + } suspend fun getUser(userId: UserId): SsoUser? = withContext(Dispatchers.IO) { diff --git a/src/main/kotlin/subit/utils/Utils.kt b/src/main/kotlin/subit/utils/Utils.kt index dac2e63..8817a96 100644 --- a/src/main/kotlin/subit/utils/Utils.kt +++ b/src/main/kotlin/subit/utils/Utils.kt @@ -1,21 +1,13 @@ package subit.utils +import kotlinx.datetime.Instant +import org.jetbrains.exposed.sql.kotlin.datetime.timestampParam import java.util.* -/** - * 检查密码是否合法 - * 要求密码长度在 6-20 之间,且仅包含数字、字母和特殊字符 !@#$%^&*()_+-= - */ -fun checkPassword(password: String): Boolean = - password.length in 8..20 && - password.all { it.isLetterOrDigit() || it in "!@#$%^&*()_+-=" } +fun String?.toUUIDOrNull(): UUID? = runCatching { UUID.fromString(this) }.getOrNull() -/** - * 检查用户名是否合法 - * 要求用户名长度在 2-15 之间,且仅包含中文、数字、字母和特殊字符 _-. - */ -fun checkUsername(username: String): Boolean = - username.length in 2..20 && - username.all { it in '\u4e00'..'\u9fa5' || it.isLetterOrDigit() || it in "_-." } +fun Long.toInstant(): Instant = + Instant.fromEpochMilliseconds(this) -fun String?.toUUIDOrNull(): UUID? = runCatching { UUID.fromString(this) }.getOrNull() \ No newline at end of file +fun Long.toTimestamp() = + timestampParam(this.toInstant()) \ No newline at end of file diff --git a/src/main/resources/default_config.yaml b/src/main/resources/default_config.yaml index 7a2f6ff..e99838c 100644 --- a/src/main/resources/default_config.yaml +++ b/src/main/resources/default_config.yaml @@ -1,3 +1,7 @@ +ktor: + deployment: + port: 8080 + rootPath: '/api' database: # 数据库实现, 可选值: sql, memory impl: sql