diff --git a/README.md b/README.md index 9ac5be2..28045df 100644 --- a/README.md +++ b/README.md @@ -23,6 +23,7 @@ Ktor 3.x 后端项目,采用纯正、原生的 Ktor 方式(Idiomatic Ktor) - Auth0 Java-JWT (`4.4.0`) + Ktor Auth JWT (`ktor-server-auth-jwt`) - BCrypt 密码加密 (`at.favre.lib:bcrypt:0.10.2`,与 Spring Security BCrypt 100% 兼容) - **序列化与时间**:`kotlinx-serialization-json` + `kotlinx-datetime` +- **RPC 框架**:JetBrains `kotlinx-rpc` (`0.10.4`,基于 WebSocket 的 kRPC 协议与 Flow 双向流通信) - **实时通信**:Ktor WebSockets (`ktor-server-websockets`) - **跨域与异常拦截**:Ktor CORS (`ktor-server-cors`) + StatusPages (`ktor-server-status-pages`) @@ -44,7 +45,7 @@ Ktor 3.x 后端项目,采用纯正、原生的 Ktor 方式(Idiomatic Ktor) 导入项目自带的 SQL 架构与初始数据: ```bash -mysql -u backend -p kotlin_springboot < src/main/resources/springboot_vue3.sql +mysql -u backend -p kotlin_springboot < src/main/resources/ktor_rpc.sql ``` > **默认测试账户**: @@ -95,3 +96,7 @@ INFO Application - Responding at http://0.0.0.0:8080 | `GET` | `/api/test/wstoken` | 公开 | 签发 WebSocket 一次性握手 Token | | `WS` | `/ws/test` | 公开 | 测试 WebSocket 管道 | | `WS` | `/ws/v1/example` | `?token=...` | 校验一次性 Token 后建立连接 | +| `RPC` | `/rpc` | 契约代理 | kotlinx-rpc 远程过程调用端点 (kRPC) | + +> 详细的接口定义、字段校验规则、RPC 客户端接入代码请查阅 [API 接口文档 (api.md)](./api.md)。 + diff --git a/README_en.md b/README_en.md index 5483e4c..2505b68 100644 --- a/README_en.md +++ b/README_en.md @@ -44,7 +44,7 @@ A Ktor 3.x backend project rewritten in an idiomatic Ktor manner, fully aligned Import the included database schema and sample data: ```bash -mysql -u backend -p kotlin_springboot < src/main/resources/springboot_vue3.sql +mysql -u backend -p kotlin_springboot < src/main/resources/ktor_rpc.sql ``` > **Default Test Account**: diff --git a/api.md b/api.md new file mode 100644 index 0000000..04a398f --- /dev/null +++ b/api.md @@ -0,0 +1,326 @@ +# Ktor Server Demo 接口文档 (RESTful & kotlinx-rpc) + +本文档详细描述了 **Ktor Server Demo** 后端服务的 **kotlinx-rpc 远程过程调用接口** 与 **RESTful / WebSocket 接口** +规范,旨在帮助前端开发者、Kotlin 多平台(KMP)客户端开发者以及联调人员快速集成与测试。 + +--- + +## 1. kotlinx-rpc 远程过程调用规范 (推荐) + +服务端基于 JetBrains 官方 **kotlinx-rpc** (kRPC 协议) 提供强类型、跨平台、端到端的 RPC 通信支持,基于 WebSocket 传输并支持 +JSON 序列化。 + +### 1.1 RPC 服务端点 + +- **RPC 挂载端点**: `ws://localhost:8080/rpc` +- **传输协议**: `kRPC` (基于 Ktor WebSockets) +- **序列化格式**: JSON (`kotlinx.rpc.krpc.serialization.json`) + +### 1.2 客户端接入方式 (Kotlin / KMP) + +在客户端的 `build.gradle.kts` 中引入依赖: + +```kotlin +plugins { + id("org.jetbrains.kotlinx.rpc.plugin") version "0.10.4" + id("org.jetbrains.kotlin.plugin.serialization") version "2.4.0" +} + +dependencies { + implementation("org.jetbrains.kotlinx:kotlinx-rpc-krpc-ktor-client:0.10.4") + implementation("org.jetbrains.kotlinx:kotlinx-rpc-krpc-serialization-json:0.10.4") + implementation("io.ktor:ktor-client-cio:3.6.0") + implementation("io.ktor:ktor-client-websockets:3.6.0") +} +``` + +客户端建立 RPC 连接与调用示例: + +```kotlin +import io.ktor.client.* +import io.ktor.client.engine.cio.* +import io.ktor.http.* +import kotlinx.rpc.krpc.ktor.client.installKrpc +import kotlinx.rpc.krpc.ktor.client.rpc +import kotlinx.rpc.krpc.serialization.json.json +import kotlinx.rpc.withService + +val httpClient = HttpClient(CIO) { + installKrpc { + serialization { + json() + } + } +} + +// 建立连接到 RPC 端点 +val rpcClient = httpClient.rpc { + url { + protocol = URLProtocol.WS + host = "localhost" + port = 8080 + path("/rpc") + } +} + +// 1. 获取认证服务代理 +val authService = rpcClient.withService() +val loginResult = authService.login(LoginVO(username = "test", password = "123456")) + +// 2. 获取测试服务代理 +val testService = rpcClient.withService() +println(testService.hello()) + +// 3. 获取实时流式服务代理 (支持 Flow 双向流) +val streamService = rpcClient.withService() +val echoResult = streamService.echo("hello") +``` + +--- + +### 1.3 RPC 契约定义清单 + +#### 1.3.1 认证服务 (`AuthRpcService`) + +定义在包 `io.github.sor2171.backend.rpc.AuthRpcService`: + +```kotlin +@Rpc +interface AuthRpcService { + // 用户名/邮箱与密码登录 + suspend fun login(loginVO: LoginVO): ApiResponse + + // 发送邮箱验证码 (支持 REGISTER 或 RESET) + suspend fun askCode(email: String, type: EmailType, clientIp: String = "127.0.0.1"): ApiResponse + + // 邮箱与验证码注册新账号 + suspend fun register(registerVO: EmailRegisterVO): ApiResponse + + // 验证码重置密码 + suspend fun resetPassword(resetVO: PasswordResetVO): ApiResponse + + // 注销登录并拉黑 Token + suspend fun logout(token: String): ApiResponse + + // 续签刷新 Token + suspend fun relogin(token: String): ApiResponse +} +``` + +#### 1.3.2 辅助与测试服务 (`TestRpcService`) + +定义在包 `io.github.sor2171.backend.rpc.TestRpcService`: + +```kotlin +@Rpc +interface TestRpcService { + // 连通性测试,返回 "Hello Kotlin Ktor RPC!" + suspend fun hello(): String + + // 签发一次性 WebSocket 握手 Token + suspend fun getWsToken(handlerName: String = "ExampleHandler"): ApiResponse +} +``` + +#### 1.3.3 实时与双向流通信服务 (`StreamRpcService`) + +定义在包 `io.github.sor2171.backend.rpc.StreamRpcService`,利用 Kotlin 协程 `Flow` 进行全双工流式传输: + +```kotlin +@Rpc +interface StreamRpcService { + // 单次回显调用 + suspend fun echo(message: String): String + + // 响应式双向流传输 (Client Flow -> Server Flow) + fun echoStream(messages: Flow): Flow +} +``` + +--- + +## 2. 全局设计规范 (RESTful API) + +### 2.1 服务基础信息 + +- **服务基础地址 (Base URL)**: `http://localhost:8080` +- **WebSocket 基础地址 (WS URL)**: `ws://localhost:8080` +- **字符编码**: `UTF-8` +- **数据格式**: 请求体与响应体默认采用 `application/json`;部分接口(如登录)额外兼容 `application/x-www-form-urlencoded`。 + +### 2.2 跨域策略 (CORS) + +服务已配置全局跨域支持: + +- 支持任意 Host 源请求 (`anyHost()`) +- 允许携带凭证 (`Access-Control-Allow-Credentials: true`) +- 允许的请求方法:`GET`, `POST`, `PUT`, `DELETE`, `PATCH`, `OPTIONS` +- 允许的请求头:`Content-Type`, `Authorization` + +### 2.3 身份认证机制 (Authentication) + +- 基于 **JWT (JSON Web Token)** 进行无状态认证(HMAC256 签名)。 +- 对于受保护的接口,客户端须在请求头中携带: + ```http + Authorization: Bearer + ``` +- **Token 结构说明**:包含用户 ID (`id`)、用户名 (`username`)、角色权限 (`authorities`)、唯一标识 (`jti`) 及签发/过期时间戳。默认有效期为 + 168 小时(7 天)。 +- **Token 黑名单机制**:用户调用注销登录(Logout)后,Token 的唯一标识(`jti`)将被置入 Redis 黑名单,直至该 Token 自然过期。黑名单中的 + Token 即使签名有效也无法通过鉴权。 + +### 2.4 全局限流与防护 (Flow Limit) + +- 服务端基于客户端 IP 实现全局访问频率保护: + - 默认限频规则: **3 秒内请求次数超过 1000 次** 将触发防护。 + - 触发防护后封禁时长: **60 秒**。 + - 被限流时的响应:HTTP 状态码 `403 Forbidden`,返回信息为 `"Too many requests"`。 +- 发送邮件验证码接口具有独立的频控逻辑: **同一 IP 60 秒内仅允许请求一次**。 + +### 2.5 统一响应结构 (ApiResponse) + +所有 RESTful 接口均遵循统一的包装结构(除连通性测试等纯文本接口外): + +```json +{ + "code": 200, + "data": null, + "message": "Success" +} +``` + +#### 字段说明 + +| 字段名 | 类型 | 说明 | +|:----------|:---------|:--------------------------------------------------------------------------| +| `code` | `Int` | 业务状态码,通常与 HTTP 状态码保持一致(如 200、400、401、403、404、500) | +| `data` | `Any?` | 业务数据对象,成功时返回对应实体,无数据或失败时通常为 `null` | +| `message` | `String` | 提示消息(如 `"Success"` 或具体错误原因) | + +--- + +## 3. RESTful 接口列表概览 + +> **说明**:用户认证相关路由同时支持 `/api/v1/auth/...` 与兼容前缀 `/api/auth/...`。 + +| 分类 | 方法 | 路径 | 认证方式 | 说明 | +|:------------|:-------|:------------------------|:---------------|:---------------------------------------| +| **认证** | `POST` | `/api/v1/auth/login` | 公开 | 用户名/邮箱与密码登录,返回 JWT | +| **认证** | `GET` | `/api/v1/auth/ask-code` | 公开 | 发送邮箱验证码(注册/重置密码) | +| **认证** | `POST` | `/api/v1/auth/register` | 公开 | 邮箱验证码注册新账号 | +| **认证** | `POST` | `/api/v1/auth/reset` | 公开 | 邮箱验证码重置登录密码 | +| **认证** | `GET` | `/api/v1/auth/logout` | `Bearer Token` | 注销登录并拉黑当前 Token | +| **认证** | `GET` | `/api/v1/auth/relogin` | `Bearer Token` | 续签刷新 Token | +| **测试** | `GET` | `/api/test/hello` | 公开 | 服务连通性测试 | +| **测试** | `GET` | `/api/test/wstoken` | 公开 | 签发一次性 WebSocket 握手 Token | +| **WS 通信** | `WS` | `/ws/test` | 公开 | WebSocket 基础连通测试通道 | +| **WS 通信** | `WS` | `/ws/v1/example` | `?token=...` | 基于一次性 Token 鉴权的 WebSocket 管道 | + +--- + +## 4. RESTful 详细接口规范 + +### 4.1 用户登录 (`POST /api/v1/auth/login`) + +- **请求体 (JSON)**: + ```json + { + "username": "test", + "password": "123456", + "remember": false + } + ``` +- **成功响应 (HTTP 200)**: + ```json + { + "code": 200, + "data": { + "username": "test", + "role": "user", + "token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...", + "expire": "2026-10-18T10:00:00" + }, + "message": "Success" + } + ``` + +### 4.2 发送邮箱验证码 (`GET /api/v1/auth/ask-code`) + +- **Query 参数**: `email`(邮箱地址),`type`(可选,`register` 或 `reset`)。 +- **成功响应 (HTTP 200)**: + ```json + { + "code": 200, + "data": null, + "message": "Success" + } + ``` + +### 4.3 邮箱注册 (`POST /api/v1/auth/register`) + +- **请求体 (JSON)**: + ```json + { + "email": "user@example.com", + "code": "849201", + "username": "newuser", + "password": "Password123" + } + ``` +- **成功响应 (HTTP 200)**: + ```json + { + "code": 200, + "data": null, + "message": "Success" + } + ``` + +### 4.4 密码重置 (`POST /api/v1/auth/reset`) + +- **请求体 (JSON)**: + ```json + { + "email": "user@example.com", + "code": "849201", + "password": "NewPassword123" + } + ``` + +### 4.5 退出登录 (`GET /api/v1/auth/logout`) + +- **请求头**: `Authorization: Bearer ` +- **成功响应 (HTTP 200)**: + ```json + { + "code": 200, + "data": null, + "message": "Success" + } + ``` + +### 4.6 刷新续期 Token (`GET /api/v1/auth/relogin`) + +- **请求头**: `Authorization: Bearer ` +- **成功响应 (HTTP 200)**: + ```json + { + "code": 200, + "data": { + "token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...", + "expire": "2026-10-18T10:00:00" + }, + "message": "Success" + } + ``` + +--- + +## 5. 附录:测试账号 + +在导入初始数据后,默认预置以下测试账号: + +- **用户名**: `test` +- **登录密码**: `123456` +- **绑定邮箱**: `1234567890@gmail.com` +- **默认角色**: `user` diff --git a/build.gradle.kts b/build.gradle.kts index b61ff91..f42b72e 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -3,6 +3,7 @@ plugins { alias(libs.plugins.kotlin.jvm) alias(libs.plugins.kotlin.serialization) + alias(libs.plugins.kotlinx.rpc) alias(ktorLibs.plugins.ktor) } @@ -61,6 +62,11 @@ dependencies { // Logging implementation(libs.logback.classic) + // kotlinx-rpc + implementation("org.jetbrains.kotlinx:kotlinx-rpc-krpc-ktor-server:0.10.4") + implementation("org.jetbrains.kotlinx:kotlinx-rpc-krpc-serialization-json:0.10.4") + testImplementation("org.jetbrains.kotlinx:kotlinx-rpc-krpc-ktor-client:0.10.4") + testImplementation(kotlin("test")) testImplementation(ktorLibs.server.testHost) } diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 6031108..28fc62e 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -1,6 +1,7 @@ [versions] kotlin = "2.4.0" logback = "1.6.3" +kotlinx-rpc = "0.10.4" [libraries] logback-classic = { module = "ch.qos.logback:logback-classic", version.ref = "logback" } @@ -9,3 +10,5 @@ logback-classic = { module = "ch.qos.logback:logback-classic", version.ref = "lo kotlin-jvm = { id = "org.jetbrains.kotlin.jvm", version.ref = "kotlin" } kotlin-serialization = { id = "org.jetbrains.kotlin.plugin.serialization", version.ref = "kotlin" } kotlin-multiplatform = { id = "org.jetbrains.kotlin.multiplatform", version.ref = "kotlin" } +kotlinx-rpc = { id = "org.jetbrains.kotlinx.rpc.plugin", version.ref = "kotlinx-rpc" } + diff --git a/src/main/kotlin/io/github/sor2171/backend/Application.kt b/src/main/kotlin/io/github/sor2171/backend/Application.kt index aee5788..2652412 100644 --- a/src/main/kotlin/io/github/sor2171/backend/Application.kt +++ b/src/main/kotlin/io/github/sor2171/backend/Application.kt @@ -6,6 +6,7 @@ import io.github.sor2171.backend.plugins.configureSecurity import io.github.sor2171.backend.plugins.configureSerialization import io.github.sor2171.backend.plugins.configureStatusPages import io.github.sor2171.backend.plugins.configureWebsockets +import io.github.sor2171.backend.plugins.configureRpc import io.github.sor2171.backend.service.AuthorizationService import io.github.sor2171.backend.service.MailService import io.github.sor2171.backend.service.RabbitService @@ -16,6 +17,7 @@ import io.github.sor2171.backend.database.DatabaseFactory import io.github.sor2171.backend.routes.authRoutes import io.github.sor2171.backend.routes.testRoutes import io.github.sor2171.backend.routes.wsRoutes +import io.github.sor2171.backend.routes.rpcRoutes import io.github.sor2171.security.JwtUtils import io.ktor.server.application.* import io.ktor.server.config.* @@ -117,6 +119,7 @@ fun Application.module() { configureSecurity(jwtUtils) configureStatusPages() configureWebsockets() + configureRpc() // 9. Configure FlowLimit Plugin val flowCount = environment.config.tryGetString("flow-limit.count")?.toIntOrNull() ?: 1000 @@ -135,5 +138,6 @@ fun Application.module() { authRoutes(authorizationService) testRoutes(wsTokenService) wsRoutes(wsTokenService) + rpcRoutes(authorizationService, wsTokenService) } } diff --git a/src/main/kotlin/io/github/sor2171/backend/database/DatabaseFactory.kt b/src/main/kotlin/io/github/sor2171/backend/database/DatabaseFactory.kt index 5d14943..910b9db 100644 --- a/src/main/kotlin/io/github/sor2171/backend/database/DatabaseFactory.kt +++ b/src/main/kotlin/io/github/sor2171/backend/database/DatabaseFactory.kt @@ -12,10 +12,10 @@ object DatabaseFactory : AutoCloseable { fun init(config: ApplicationConfig) { val url = config.tryGetString("datasource.url") - ?: "jdbc:mysql://localhost:3306/kotlin_springboot?useSSL=false&allowPublicKeyRetrieval=true&serverTimezone=UTC" + ?: "jdbc:mysql://localhost:3306/ktor_rpc?useSSL=false&allowPublicKeyRetrieval=true&serverTimezone=UTC" val driver = config.tryGetString("datasource.driver") ?: "com.mysql.cj.jdbc.Driver" - val username = config.tryGetString("datasource.username") ?: "backend" + val username = config.tryGetString("datasource.username") ?: "root" val password = config.tryGetString("datasource.password") ?: "123456" val maxPoolSize = config.tryGetString("datasource.max-pool-size")?.toIntOrNull() ?: 10 diff --git a/src/main/kotlin/io/github/sor2171/backend/plugins/Rpc.kt b/src/main/kotlin/io/github/sor2171/backend/plugins/Rpc.kt new file mode 100644 index 0000000..303552c --- /dev/null +++ b/src/main/kotlin/io/github/sor2171/backend/plugins/Rpc.kt @@ -0,0 +1,8 @@ +package io.github.sor2171.backend.plugins + +import io.ktor.server.application.* +import kotlinx.rpc.krpc.ktor.server.Krpc + +fun Application.configureRpc() { + install(Krpc) +} diff --git a/src/main/kotlin/io/github/sor2171/backend/routes/RpcRoutes.kt b/src/main/kotlin/io/github/sor2171/backend/routes/RpcRoutes.kt new file mode 100644 index 0000000..11cbe44 --- /dev/null +++ b/src/main/kotlin/io/github/sor2171/backend/routes/RpcRoutes.kt @@ -0,0 +1,35 @@ +package io.github.sor2171.backend.routes + +import io.github.sor2171.backend.rpc.AuthRpcService +import io.github.sor2171.backend.rpc.AuthRpcServiceImpl +import io.github.sor2171.backend.rpc.StreamRpcService +import io.github.sor2171.backend.rpc.StreamRpcServiceImpl +import io.github.sor2171.backend.rpc.TestRpcService +import io.github.sor2171.backend.rpc.TestRpcServiceImpl +import io.github.sor2171.backend.service.AuthorizationService +import io.github.sor2171.backend.service.WsTokenService +import io.ktor.server.routing.* +import kotlinx.rpc.krpc.ktor.server.rpc +import kotlinx.rpc.krpc.serialization.json.json + +fun Route.rpcRoutes( + authorizationService: AuthorizationService, + wsTokenService: WsTokenService +) { + rpc("/rpc") { + rpcConfig { + serialization { + json() + } + } + registerService { + AuthRpcServiceImpl(authorizationService) + } + registerService { + TestRpcServiceImpl(wsTokenService) + } + registerService { + StreamRpcServiceImpl() + } + } +} diff --git a/src/main/kotlin/io/github/sor2171/backend/rpc/AuthRpcService.kt b/src/main/kotlin/io/github/sor2171/backend/rpc/AuthRpcService.kt new file mode 100644 index 0000000..088f1ee --- /dev/null +++ b/src/main/kotlin/io/github/sor2171/backend/rpc/AuthRpcService.kt @@ -0,0 +1,20 @@ +package io.github.sor2171.backend.rpc + +import io.github.sor2171.backend.entity.ApiResponse +import io.github.sor2171.backend.entity.enums.EmailType +import io.github.sor2171.backend.entity.vo.request.EmailRegisterVO +import io.github.sor2171.backend.entity.vo.request.LoginVO +import io.github.sor2171.backend.entity.vo.request.PasswordResetVO +import io.github.sor2171.backend.entity.vo.response.AuthorizeVO +import io.github.sor2171.backend.entity.vo.response.ReloginVO +import kotlinx.rpc.annotations.Rpc + +@Rpc +interface AuthRpcService { + suspend fun login(loginVO: LoginVO): ApiResponse + suspend fun askCode(email: String, type: EmailType, clientIp: String = "127.0.0.1"): ApiResponse + suspend fun register(registerVO: EmailRegisterVO): ApiResponse + suspend fun resetPassword(resetVO: PasswordResetVO): ApiResponse + suspend fun logout(token: String): ApiResponse + suspend fun relogin(token: String): ApiResponse +} diff --git a/src/main/kotlin/io/github/sor2171/backend/rpc/AuthRpcServiceImpl.kt b/src/main/kotlin/io/github/sor2171/backend/rpc/AuthRpcServiceImpl.kt new file mode 100644 index 0000000..9a98f06 --- /dev/null +++ b/src/main/kotlin/io/github/sor2171/backend/rpc/AuthRpcServiceImpl.kt @@ -0,0 +1,76 @@ +package io.github.sor2171.backend.rpc + +import io.github.sor2171.backend.entity.ApiResponse +import io.github.sor2171.backend.entity.enums.EmailType +import io.github.sor2171.backend.entity.vo.request.EmailRegisterVO +import io.github.sor2171.backend.entity.vo.request.LoginVO +import io.github.sor2171.backend.entity.vo.request.PasswordResetVO +import io.github.sor2171.backend.entity.vo.response.AuthorizeVO +import io.github.sor2171.backend.entity.vo.response.ReloginVO +import io.github.sor2171.backend.service.AuthorizationService + +class AuthRpcServiceImpl( + private val authorizationService: AuthorizationService +) : AuthRpcService { + + override suspend fun login(loginVO: LoginVO): ApiResponse { + if (loginVO.username.isBlank() || loginVO.password.isBlank()) { + return ApiResponse.failure(400, null, "Username and password cannot be empty") + } + val result = authorizationService.login(loginVO.username, loginVO.password) + return if (result != null) { + ApiResponse.success(data = result) + } else { + ApiResponse.failure(401, null, "Invalid username or password") + } + } + + override suspend fun askCode(email: String, type: EmailType, clientIp: String): ApiResponse { + if (email.isBlank()) { + return ApiResponse.failure(400, null, "Email cannot be blank") + } + val ip = clientIp.ifBlank { "127.0.0.1" } + val msg = authorizationService.askEmailVerifyCode(type, email, ip) + return if (msg.isBlank()) { + ApiResponse.success(data = null) + } else { + ApiResponse.failure(400, null, msg) + } + } + + override suspend fun register(registerVO: EmailRegisterVO): ApiResponse { + val msg = authorizationService.registerEmailAccount(registerVO) + return if (msg.isBlank()) { + ApiResponse.success(data = null) + } else { + ApiResponse.failure(400, null, msg) + } + } + + override suspend fun resetPassword(resetVO: PasswordResetVO): ApiResponse { + val msg = authorizationService.resetEmailAccountPassword(resetVO) + return if (msg == "Password reset successfully") { + ApiResponse.success(data = null, message = "Password reset successfully") + } else { + ApiResponse.failure(400, null, msg) + } + } + + override suspend fun logout(token: String): ApiResponse { + val success = authorizationService.invalidateJwt(token) + return if (success) { + ApiResponse.success(data = null) + } else { + ApiResponse.failure(400, null, "Logout Failed") + } + } + + override suspend fun relogin(token: String): ApiResponse { + val vo = authorizationService.jwtTokenRelogin(token) + return if (vo != null) { + ApiResponse.success(data = vo) + } else { + ApiResponse.failure(401, null, "Please login with password.") + } + } +} diff --git a/src/main/kotlin/io/github/sor2171/backend/rpc/StreamRpcService.kt b/src/main/kotlin/io/github/sor2171/backend/rpc/StreamRpcService.kt new file mode 100644 index 0000000..bc9c4bf --- /dev/null +++ b/src/main/kotlin/io/github/sor2171/backend/rpc/StreamRpcService.kt @@ -0,0 +1,10 @@ +package io.github.sor2171.backend.rpc + +import kotlinx.coroutines.flow.Flow +import kotlinx.rpc.annotations.Rpc + +@Rpc +interface StreamRpcService { + suspend fun echo(message: String): String + fun echoStream(messages: Flow): Flow +} diff --git a/src/main/kotlin/io/github/sor2171/backend/rpc/StreamRpcServiceImpl.kt b/src/main/kotlin/io/github/sor2171/backend/rpc/StreamRpcServiceImpl.kt new file mode 100644 index 0000000..7fde475 --- /dev/null +++ b/src/main/kotlin/io/github/sor2171/backend/rpc/StreamRpcServiceImpl.kt @@ -0,0 +1,17 @@ +package io.github.sor2171.backend.rpc + +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.map + +class StreamRpcServiceImpl : StreamRpcService { + + override suspend fun echo(message: String): String { + return "Kotlin Ktor RPC received: $message" + } + + override fun echoStream(messages: Flow): Flow { + return messages.map { message -> + "Kotlin Ktor RPC received: $message" + } + } +} diff --git a/src/main/kotlin/io/github/sor2171/backend/rpc/TestRpcService.kt b/src/main/kotlin/io/github/sor2171/backend/rpc/TestRpcService.kt new file mode 100644 index 0000000..2d10d91 --- /dev/null +++ b/src/main/kotlin/io/github/sor2171/backend/rpc/TestRpcService.kt @@ -0,0 +1,10 @@ +package io.github.sor2171.backend.rpc + +import io.github.sor2171.backend.entity.ApiResponse +import kotlinx.rpc.annotations.Rpc + +@Rpc +interface TestRpcService { + suspend fun hello(): String + suspend fun getWsToken(handlerName: String = "ExampleHandler"): ApiResponse +} diff --git a/src/main/kotlin/io/github/sor2171/backend/rpc/TestRpcServiceImpl.kt b/src/main/kotlin/io/github/sor2171/backend/rpc/TestRpcServiceImpl.kt new file mode 100644 index 0000000..7e57895 --- /dev/null +++ b/src/main/kotlin/io/github/sor2171/backend/rpc/TestRpcServiceImpl.kt @@ -0,0 +1,22 @@ +package io.github.sor2171.backend.rpc + +import io.github.sor2171.backend.entity.ApiResponse +import io.github.sor2171.backend.service.WsTokenService + +class TestRpcServiceImpl( + private val wsTokenService: WsTokenService +) : TestRpcService { + + override suspend fun hello(): String { + return "Hello Kotlin Ktor RPC!" + } + + override suspend fun getWsToken(handlerName: String): ApiResponse { + return try { + val token = wsTokenService.registerToken(handlerName) + ApiResponse.success(data = token) + } catch (e: Exception) { + ApiResponse.failure(500, "", e.message) + } + } +} diff --git a/src/main/resources/application-example.yaml b/src/main/resources/application-example.yaml index 8d88488..d7e130e 100644 --- a/src/main/resources/application-example.yaml +++ b/src/main/resources/application-example.yaml @@ -6,8 +6,8 @@ ktor: - io.github.sor2171.backend.ApplicationKt.module datasource: - url: "jdbc:mysql://localhost:3306/kotlin_springboot?useSSL=false&allowPublicKeyRetrieval=true&serverTimezone=UTC" - username: "backend" + url: "jdbc:mysql://localhost:3306/ktor_rpc?useSSL=false&allowPublicKeyRetrieval=true&serverTimezone=UTC" + username: "root" password: "123456" driver: "com.mysql.cj.jdbc.Driver" max-pool-size: 10 diff --git a/src/main/resources/springboot_vue3.sql b/src/main/resources/ktor_rpc.sql similarity index 100% rename from src/main/resources/springboot_vue3.sql rename to src/main/resources/ktor_rpc.sql diff --git a/src/test/kotlin/RpcTest.kt b/src/test/kotlin/RpcTest.kt new file mode 100644 index 0000000..500f8fb --- /dev/null +++ b/src/test/kotlin/RpcTest.kt @@ -0,0 +1,86 @@ +package io.github.sor2171.backend + +import io.github.sor2171.backend.entity.enums.EmailType +import io.github.sor2171.backend.entity.vo.request.LoginVO +import io.github.sor2171.backend.routes.rpcRoutes +import io.github.sor2171.backend.rpc.AuthRpcService +import io.github.sor2171.backend.rpc.StreamRpcService +import io.github.sor2171.backend.rpc.TestRpcService +import io.github.sor2171.backend.service.AuthorizationService +import io.github.sor2171.backend.service.WsTokenService +import io.ktor.http.* +import io.ktor.server.testing.* +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.flow.toList +import kotlinx.rpc.krpc.ktor.client.installKrpc +import kotlinx.rpc.krpc.ktor.client.rpc +import kotlinx.rpc.krpc.ktor.server.Krpc +import kotlinx.rpc.krpc.serialization.json.json +import kotlinx.rpc.withService +import kotlin.test.Test +import kotlin.test.assertEquals + +class RpcTest { + + @Test + fun testRealRpcServices() = testApplication { + install(Krpc) + val appScope = CoroutineScope(Dispatchers.Default) + + routing { + val redis = io.github.sor2171.backend.service.RedisService("redis://localhost:6379") + val wsTokenService = WsTokenService(redis) + val accountRepo = io.github.sor2171.backend.database.AccountRepository() + val jwt = io.github.sor2171.security.JwtUtils("dummy-key-for-test-348572394857293847529384", 24, redis) + val mail = io.github.sor2171.backend.service.MailService("", 465, "", "") + val rabbit = io.github.sor2171.backend.service.RabbitService("localhost", 5672, "/", "admin", "123456", "mail", mail, appScope) + val authService = AuthorizationService(accountRepo, jwt, redis, rabbit) + + rpcRoutes(authService, wsTokenService) + } + + val rpcClient = createClient { + installKrpc { + serialization { + json() + } + } + }.rpc { + url { + path("/rpc") + } + } + + // 1. Test TestRpcService + val testService = rpcClient.withService() + val helloResult = testService.hello() + assertEquals("Hello Kotlin Ktor RPC!", helloResult) + + // 2. Test StreamRpcService - single call & streaming Flow + val streamService = rpcClient.withService() + val echoResult = streamService.echo("hello stream") + assertEquals("Kotlin Ktor RPC received: hello stream", echoResult) + + val streamResponses = streamService.echoStream(flowOf("A", "B", "C")).toList() + assertEquals( + listOf( + "Kotlin Ktor RPC received: A", + "Kotlin Ktor RPC received: B", + "Kotlin Ktor RPC received: C" + ), + streamResponses + ) + + // 3. Test AuthRpcService validation + val authRpcService = rpcClient.withService() + val emptyLoginRes = authRpcService.login(LoginVO("", "")) + assertEquals(400, emptyLoginRes.code) + assertEquals("Username and password cannot be empty", emptyLoginRes.message) + + val emptyCodeRes = authRpcService.askCode("", EmailType.REGISTER) + assertEquals(400, emptyCodeRes.code) + assertEquals("Email cannot be blank", emptyCodeRes.message) + } +}