基本功能

This commit is contained in:
2024-04-14 22:25:50 +08:00
parent 52c84fb33b
commit 7b61753d43
21 changed files with 584 additions and 2 deletions

View File

@@ -1,7 +1,7 @@
import org.jetbrains.kotlin.gradle.dsl.JvmTarget
plugins {
id("org.springframework.boot") version "3.2.2"
id("org.springframework.boot") version "3.2.4"
id("io.spring.dependency-management") version "1.1.4"
kotlin("jvm") version "1.9.22"
kotlin("plugin.spring") version "1.9.22"
@@ -25,15 +25,41 @@ repositories {
mavenCentral()
}
//版本号定义
object Versions {
const val FAST_JSON_2 = "2.0.45"
}
dependencies {
// Kotlin
// https://mvnrepository.com/artifact/org.jetbrains.kotlin/kotlin-stdlib
implementation("org.jetbrains.kotlin:kotlin-stdlib")
// https://mvnrepository.com/artifact/org.jetbrains.kotlin/kotlin-reflect
runtimeOnly("org.jetbrains.kotlin:kotlin-reflect")
// Apache Commons CSV https://mvnrepository.com/artifact/org.apache.commons/commons-csv
implementation("org.apache.commons:commons-csv:1.10.0")
// Spring Boot
// https://mvnrepository.com/artifact/org.springframework.boot/spring-boot-starter-web
implementation("org.springframework.boot:spring-boot-starter-web")
// Spring Boot Test
// https://mvnrepository.com/artifact/org.springframework.boot/spring-boot-starter-test
testImplementation("org.springframework.boot:spring-boot-starter-test")
// Spring Security
// https://mvnrepository.com/artifact/org.springframework.boot/spring-boot-starter-security
implementation("org.springframework.boot:spring-boot-starter-security")
// Spring Validation
// https://mvnrepository.com/artifact/org.springframework.boot/spring-boot-starter-validation
implementation("org.springframework.boot:spring-boot-starter-validation:3.2.4")
// Redis
// https://mvnrepository.com/artifact/org.springframework.boot/spring-boot-starter-data-redis
implementation("org.springframework.boot:spring-boot-starter-data-redis")
// Fast Json 2
// https://mvnrepository.com/artifact/com.alibaba.fastjson2/fastjson2
implementation("com.alibaba.fastjson2:fastjson2:${Versions.FAST_JSON_2}")
// https://mvnrepository.com/artifact/com.alibaba.fastjson2/fastjson2-kotlin
implementation("com.alibaba.fastjson2:fastjson2-kotlin:${Versions.FAST_JSON_2}")
// https://mvnrepository.com/artifact/com.alibaba.fastjson2/fastjson2-extension-spring6
implementation("com.alibaba.fastjson2:fastjson2-extension-spring6:${Versions.FAST_JSON_2}")
}
tasks.withType<Test> {

View File

@@ -1 +1 @@
rootProject.name = "fortern-api"
rootProject.name = "message-api"

View File

@@ -0,0 +1,39 @@
package xyz.fortern.forternapi.config
import com.alibaba.fastjson2.support.spring6.data.redis.GenericFastJsonRedisSerializer
import org.springframework.context.annotation.Bean
import org.springframework.context.annotation.Configuration
import org.springframework.data.redis.connection.RedisConnectionFactory
import org.springframework.data.redis.core.RedisTemplate
import org.springframework.data.redis.serializer.StringRedisSerializer
/**
* Redis配置类
*/
@Configuration
class RedisConfig {
@Bean
fun redisTemplate(redisConnectionFactory: RedisConnectionFactory): RedisTemplate<String, Any> {
// Redis模板对象
val template = RedisTemplate<String, Any>()
// 设置连接工厂
template.connectionFactory = redisConnectionFactory
// 设置自定义序列化方式
// key字符串类型使用String的序列化方式
val stringRedisSerializer = StringRedisSerializer()
// 使用fastjson2的序列化方式,直接序列化对象
val fastJsonRedisSerializer = GenericFastJsonRedisSerializer()
// 指定序列化和反序列化方式
template.keySerializer = stringRedisSerializer
template.valueSerializer = fastJsonRedisSerializer
template.hashKeySerializer = stringRedisSerializer
template.hashValueSerializer = fastJsonRedisSerializer
// 初始化模板
template.afterPropertiesSet()
return template
}
}

View File

@@ -0,0 +1,32 @@
package xyz.fortern.forternapi.config
import org.springframework.context.annotation.Bean
import org.springframework.context.annotation.Configuration
import org.springframework.http.HttpMethod
import org.springframework.security.config.annotation.web.builders.HttpSecurity
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity
import org.springframework.security.web.SecurityFilterChain
@Configuration
@EnableWebSecurity
class SecurityConfig {
@Bean
fun filterChain(http: HttpSecurity): SecurityFilterChain {
http
.csrf { csrf -> csrf.disable() }
.requestCache { requestCache -> requestCache.disable() }
.authorizeHttpRequests { authorizeHttpRequests ->
authorizeHttpRequests
// 登录接口
.requestMatchers("/auth").permitAll()
// 读取消息的接口
.requestMatchers(HttpMethod.GET, "/main/**").permitAll()
// 内部转发到错误信息接口
.requestMatchers("/error").permitAll()
// 测试类接口
.requestMatchers("/test/**").permitAll()
.anyRequest().authenticated()
}
return http.build()
}
}

View File

@@ -0,0 +1,39 @@
package xyz.fortern.forternapi.controller
import jakarta.servlet.http.HttpServletRequest
import jakarta.servlet.http.HttpServletResponse
import org.springframework.http.HttpStatus
import org.springframework.http.ResponseEntity
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken
import org.springframework.security.core.context.SecurityContextHolder
import org.springframework.security.web.context.HttpSessionSecurityContextRepository
import org.springframework.security.web.context.SecurityContextRepository
import org.springframework.web.bind.annotation.PostMapping
import org.springframework.web.bind.annotation.RequestMapping
import org.springframework.web.bind.annotation.RestController
import xyz.fortern.forternapi.exception.BusinessException
import xyz.fortern.forternapi.service.AuthService
import xyz.fortern.forternapi.web.ERROR_PASSWORD
//TODO 统一鉴权服务
/**
* 账号与会话管理Controller
*/
@RestController
@RequestMapping("/auth")
class AuthController(
val authService: AuthService
) {
private val contextRepository: SecurityContextRepository = HttpSessionSecurityContextRepository()
@PostMapping
fun login(password: String, request: HttpServletRequest, response: HttpServletResponse): ResponseEntity<Any> {
if (!authService.login(password)) {
throw BusinessException(HttpStatus.UNAUTHORIZED, ERROR_PASSWORD, "login.error_password")
}
val authentication = UsernamePasswordAuthenticationToken("master", null, emptyList())
val context = SecurityContextHolder.getContext().also { it.authentication = authentication }
contextRepository.saveContext(context, request, response)
return ResponseEntity.ok(null)
}
}

View File

@@ -0,0 +1,84 @@
package xyz.fortern.forternapi.controller
import jakarta.validation.constraints.Max
import jakarta.validation.constraints.NotNull
import jakarta.validation.constraints.Size
import org.springframework.http.HttpStatus
import org.springframework.http.ResponseEntity
import org.springframework.web.bind.annotation.DeleteMapping
import org.springframework.web.bind.annotation.GetMapping
import org.springframework.web.bind.annotation.PostMapping
import org.springframework.web.bind.annotation.RequestMapping
import org.springframework.web.bind.annotation.RestController
import xyz.fortern.forternapi.model.Message
import xyz.fortern.forternapi.service.MessageService
@RestController
@RequestMapping("/main")
class MessageController(
private val messageService: MessageService,
) {
/**
* 读取一条消息
*
* @param name 读者名字
* @param id 消息id
*/
@GetMapping
fun test(
@NotNull @Size(min = 1, max = 30) name: String,
@NotNull @Size(min = 1, max = 50) id: String
): ResponseEntity<Message> {
val message = messageService.readMessage(name, id)
return if (message != null) {
ResponseEntity.ok(message)
} else {
ResponseEntity.notFound().build()
}
}
/**
* 发送消息
*
* @param name 接受者名字
* @param content 消息内容
* @param expire 过期时间
*
* @return 消息的ID
*/
@PostMapping
fun send(
@NotNull @Size(min = 1, max = 30) name: String,
@NotNull @Size(max = 1024) content: String,
@NotNull @Max(14400) expire: Long,
): ResponseEntity<String> {
val id = messageService.sendMessage(name, content, expire)
return ResponseEntity.status(HttpStatus.CREATED).body(id)
}
/**
* 删除一条消息
*
* @param name 消息接收者
* @param id
*/
@DeleteMapping
fun delete(
@NotNull @Size(min = 1, max = 30) name: String,
@NotNull @Size(min = 1, max = 50) id: String
): ResponseEntity<Any> {
messageService.delMessage(name, id)
return ResponseEntity.ok(null)
}
/**
* 读取消息列表
*
* @param name 消息接受者
*/
@GetMapping("/list")
fun list(@Size(min = 1, max = 30) name: String?): Map<String, List<Message>> {
return messageService.messageList(name ?: "")
}
}

View File

@@ -0,0 +1,16 @@
package xyz.fortern.forternapi.controller
import org.springframework.web.bind.annotation.PostMapping
import org.springframework.web.bind.annotation.RequestMapping
import org.springframework.web.bind.annotation.RestController
@RestController
@RequestMapping("/test")
class TestController {
@PostMapping("/hello")
fun hello(): String {
return "Hello world"
}
}

View File

@@ -0,0 +1,35 @@
package xyz.fortern.forternapi.exception
import org.springframework.http.HttpStatus
/**
* 在 Web 开发中,有时会使用异常进行流程控制。比如在一些 Web 框架中,可以使用一些
* 断言工具,对用户参数进行检查,如果有问题则快速抛出异常,再通过统一异常处理,转为
* 用户可理解的信息。
*
*
* 一般情况下产生一个异常会有不小的性能开销,因为异常对象需要捕获当前的栈信息。但是,
* 如果禁止异常对象存入栈信息,则不会有这些性能开销。此时创建异常对象就和创建一个普通
* 对象几乎没有区别。
*
*
* 当前类表示一个通用的业务异常,可用于在 Controller 层抛出,然后走到对应的异常处理,
* 将错误转为正常的响应。
*
* @author Fortern
* @since 1.0
*/
class BusinessException(
/**
* Http响应状态码
*/
val httpCode: HttpStatus,
/**
* 业务状态码
*/
val statusCode: String,
/**
* 消息的i18n的翻译key
*/
message: String
) : RuntimeException(message, null, true, false)

View File

@@ -0,0 +1,16 @@
package xyz.fortern.forternapi.model
/**
* 消息实体
*
* @author Fortern
* @since 1.0.0
*/
class Message (
val from: String,
val to: String,
val expire: Long,
val content: String,
) {
}

View File

@@ -0,0 +1,16 @@
package xyz.fortern.forternapi.service
import org.springframework.data.redis.core.RedisTemplate
import org.springframework.stereotype.Service
import xyz.fortern.forternapi.util.RedisKeys
@Service
class AuthService (
redisTemplate: RedisTemplate<String, Any>
) {
val opsForValue = redisTemplate.opsForValue()
fun login(password: String): Boolean {
return opsForValue[RedisKeys.PASSWORD_KEY] == password
}
}

View File

@@ -0,0 +1,83 @@
package xyz.fortern.forternapi.service
import org.springframework.beans.factory.annotation.Value
import org.springframework.data.redis.core.RedisTemplate
import org.springframework.stereotype.Service
import xyz.fortern.forternapi.model.Message
import xyz.fortern.forternapi.util.Generator
import xyz.fortern.forternapi.util.RedisKeys
import java.util.ArrayList
import java.util.concurrent.TimeUnit
@Service
class MessageService(
private val redisTemplate: RedisTemplate<String, Any>,
) {
private val forValue = redisTemplate.opsForValue()
@Value("\${fortern.msg.defaultSender}")
private lateinit var defaultSender: String
/**
* 给别人发送一条消息
*
* @param name 接受者名字
* @param content 消息内容
* @param expire 过期时间
*/
fun sendMessage(
name: String,
content: String,
expire: Long
): String {
val id = Generator.randomString(16)
forValue.set(
"${RedisKeys.MESSAGE_PREFIX}$name:$id",
Message(defaultSender, name, System.currentTimeMillis() + expire * 1000L, content),
expire,
TimeUnit.SECONDS
)
return id
}
/**
* 获取消息列表
*/
fun messageList(name: String): Map<String, List<Message>> {
// key命令虽然时间复杂度为 O(N),但扫描速度极快,在当前项目中可以使用
val keys = redisTemplate.keys("${RedisKeys.MESSAGE_PREFIX}${if (name.isNotEmpty()) "$name:" else ""}*")
if (name.isEmpty()) {
val map = HashMap<String, MutableList<Message>>()
keys.forEach { key ->
val message = forValue[key] as Message
val list = map.getOrPut(message.to) { ArrayList() }
list.add(message)
}
return map
} else {
val list = keys.map { key -> forValue[key] as Message }
return mapOf(Pair(name, list))
}
}
/**
* 读取一条消息
*
* @param name 消息接受者
* @param id 消息的ID
*/
fun readMessage(name: String, id: String): Message? {
return forValue["${RedisKeys.MESSAGE_PREFIX}$name:$id"] as Message?
}
/**
* 删除一条消息
*
* @param name 消息接收者
* @param id
*/
fun delMessage(name: String, id: String): Boolean {
return redisTemplate.delete("${RedisKeys.MESSAGE_PREFIX}$name:$id")
}
}

View File

@@ -0,0 +1,17 @@
package xyz.fortern.forternapi.util
import kotlin.random.Random
object Generator {
private const val CHARACTERS = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789"
private val random = Random(System.currentTimeMillis())
fun randomString(length: Int): String {
val randomString = StringBuilder(length)
for (i in 0 until length)
randomString.append(CHARACTERS[random.nextInt(CHARACTERS.length)])
return randomString.toString()
}
}

View File

@@ -0,0 +1,14 @@
package xyz.fortern.forternapi.util
object RedisKeys {
/**
* Fortern的密码的key
*/
const val PASSWORD_KEY = "fortern:auth:password"
/**
* 消息key的前缀
*/
const val MESSAGE_PREFIX = "fortern:msg:"
}

View File

@@ -0,0 +1,90 @@
package xyz.fortern.forternapi.web
import jakarta.servlet.http.HttpServletRequest
import jakarta.validation.Valid
import org.slf4j.Logger
import org.slf4j.LoggerFactory
import org.springframework.context.MessageSource
import org.springframework.http.HttpStatus
import org.springframework.http.ResponseEntity
import org.springframework.validation.FieldError
import org.springframework.web.bind.MethodArgumentNotValidException
import org.springframework.web.bind.annotation.ExceptionHandler
import org.springframework.web.bind.annotation.ResponseStatus
import org.springframework.web.bind.annotation.RestControllerAdvice
import org.springframework.web.method.annotation.HandlerMethodValidationException
import xyz.fortern.forternapi.exception.BusinessException
/**
* 用于处理参数校验结果的Advice
*/
@RestControllerAdvice
class ControllerAdvice(
val messageSource: MessageSource
) {
val logger: Logger = LoggerFactory.getLogger(ControllerAdvice::class.java)
/**
* 对于Controller参数中有[Valid]注解的参数,
* 如果校验失败会抛出[MethodArgumentNotValidException]
* 使用此函数进行处理
*/
@ResponseStatus(HttpStatus.BAD_REQUEST)
@ExceptionHandler(MethodArgumentNotValidException::class)
fun handleMethodArgumentNotValidExceptions(ex: MethodArgumentNotValidException): ExResponse {
val errors: MutableMap<String, StringBuilder> = HashMap()
ex.bindingResult.allErrors.forEach { error ->
val fieldName = (error as FieldError).field
val errorMessage = error.getDefaultMessage() ?: return@forEach
val stringBuilder = errors.getOrPut(fieldName) { StringBuilder() }
stringBuilder.append(errorMessage).append(';').append(' ')
}
val list = errors.map { (k, v) ->
if (v.isNotEmpty())
v.deleteCharAt(v.length - 1).deleteCharAt(v.length - 1)
StringBuilder("${k}: ").append(v).toString()
}
return ExResponse(BAD_REQUEST, list)
}
/**
* 对于Controller参数中有[jakarta.validation.constraints]包下的注解的参数,
* 如果校验失败会抛出[HandlerMethodValidationException]
* 使用此函数进行处理
*/
@ResponseStatus(HttpStatus.BAD_REQUEST)
@ExceptionHandler(HandlerMethodValidationException::class)
fun handleHandlerMethodValidationException(ex: HandlerMethodValidationException): ExResponse {
val errors: MutableMap<String, StringBuilder> = HashMap()
ex.allValidationResults.forEach o@ { result ->
val fieldName = result.methodParameter.parameterName!!
val stringBuilder = StringBuilder()
result.resolvableErrors.forEach { error ->
val message = error.defaultMessage ?: return@forEach
stringBuilder.append(message).append(';').append(' ')
}
errors[fieldName] = stringBuilder
}
val list = errors.map { (k, v) ->
if (v.isNotEmpty())
v.deleteCharAt(v.length - 1).deleteCharAt(v.length - 1)
StringBuilder("${k}: ").append(v).toString()
}
return ExResponse(BAD_REQUEST, list)
}
/**
* 常规的业务异常处理
*/
@ExceptionHandler(BusinessException::class)
fun handlerBusinessException(ex: BusinessException, request: HttpServletRequest): ResponseEntity<ExResponse> {
val locale = request.locale
val message = try {
messageSource.getMessage(ex.message!!, null, locale)
} catch (e: Exception) {
logger.error("Message translation failed.", e)
"{{untranslated message}}"
}
val response = ExResponse(ex.statusCode, listOf(message))
return ResponseEntity.status(ex.httpCode).body(response)
}
}

View File

@@ -0,0 +1,9 @@
package xyz.fortern.forternapi.web
/**
* 通用的错误响应,一般在异常处理中使用
*/
class ExResponse(
val status: String,
val errors: List<String>
)

View File

@@ -0,0 +1,16 @@
package xyz.fortern.forternapi.web
/**
* 参数错误
*/
const val BAD_REQUEST = "BAD_REQUEST"
/**
* 密码错误
*/
const val ERROR_PASSWORD = "ERROR_PASSWORD"
/**
* 用户不存在
*/
const val NO_USER = "NO_USER"

View File

@@ -0,0 +1,35 @@
spring:
profiles:
active: dev
web:
locale: zh_CN
messages:
basename: i18n/messages
fortern:
msg:
defaultSender: Fortern
server:
servlet:
context-path: /message
---
spring:
config:
activate:
on-profile: dev
data:
redis:
host: 127.0.0.1
password: Redis20868354
---
spring:
config:
activate:
on-profile: prod
data:
redis:
host: redis-server
password: Redis20868354

View File

@@ -0,0 +1 @@
# 必须有默认的资源文件,否则 MessageSourceAutoConfiguration 不会执行

View File

@@ -0,0 +1,7 @@
login.no_user=No user found with the given username.
login.error_password=The password is incorrect.
ex_msg.read_msg.no_msg=No message found with the given id.
ex_msg.msg.username_error=Name cannot be empty and the length is less than 17.
ex_msg.msg.id_error=The message ID must be non-null.
ex_msg.send_msg.expire_error=The message expiration time must be less than 14400 seconds.
ex_msg.send_msg.length_error=The message length must be less than 10000 characters.

View File

@@ -0,0 +1,7 @@
login.no_user=用户不存在
login.error_password=密码错误
ex_msg.read_msg.no_msg=没有对应此 ID 的消息
ex_msg.msg.username_error=name 不能为空且长度要小于 17
ex_msg.msg.id_error=消息ID不能为空
ex_msg.send_msg.expire_error=消息到期时间必须小于 14400 秒
ex_msg.send_msg.length_error=消息长度必须小于 10000 字符