feat: complete leave approval MVP

This commit is contained in:
selfrelease
2026-07-18 19:20:07 +08:00
parent 2105fe3bac
commit 090a7e33ce
133 changed files with 7845 additions and 100 deletions
@@ -0,0 +1,49 @@
package com.all8ai.aioa.device.api
import com.all8ai.aioa.device.application.UserDeviceService
import com.all8ai.aioa.device.domain.DevicePlatform
import com.all8ai.aioa.device.domain.UserDevice
import com.all8ai.aioa.identity.application.CurrentUserService
import jakarta.validation.Valid
import jakarta.validation.constraints.NotBlank
import jakarta.validation.constraints.Size
import org.springframework.security.core.annotation.AuthenticationPrincipal
import org.springframework.security.oauth2.jwt.Jwt
import org.springframework.web.bind.annotation.*
import java.util.UUID
@RestController
@RequestMapping("/api/v1/devices")
class UserDeviceController(private val currentUserService: CurrentUserService, private val service: UserDeviceService) {
@PostMapping("/register")
fun register(@AuthenticationPrincipal jwt: Jwt, @Valid @RequestBody request: RegisterDeviceRequest): UserDevice =
service.register(currentUserService.get(jwt.subject, jwt.getClaimAsString("tenant_id")), request.id, request.name, request.platform, request.appVersion)
@GetMapping fun list(@AuthenticationPrincipal jwt: Jwt): List<UserDevice> =
service.list(currentUserService.get(jwt.subject, jwt.getClaimAsString("tenant_id")))
@DeleteMapping("/{id}") fun revoke(@AuthenticationPrincipal jwt: Jwt, @PathVariable id: UUID): UserDevice =
service.revoke(currentUserService.get(jwt.subject, jwt.getClaimAsString("tenant_id")), id)
@PutMapping("/{id}/push-token")
fun updatePushToken(
@AuthenticationPrincipal jwt: Jwt,
@PathVariable id: UUID,
@RequestHeader("X-AIOA-Device-Id") currentDeviceId: UUID,
@Valid @RequestBody request: PushTokenRequest,
) {
if (id != currentDeviceId) throw com.all8ai.aioa.shared.web.ApiException(
org.springframework.http.HttpStatus.FORBIDDEN, "DEVICE_MISMATCH", "只能更新当前设备的推送令牌",
)
service.updatePushToken(currentUserService.get(jwt.subject, jwt.getClaimAsString("tenant_id")), id, request.token)
}
}
data class RegisterDeviceRequest(
val id: UUID,
@field:NotBlank @field:Size(max = 200) val name: String,
val platform: DevicePlatform,
@field:Size(max = 64) val appVersion: String? = null,
)
data class PushTokenRequest(@field:Size(max = 4096) val token: String?)
@@ -0,0 +1,40 @@
package com.all8ai.aioa.device.application
import com.all8ai.aioa.audit.application.AuditService
import com.all8ai.aioa.device.domain.*
import com.all8ai.aioa.identity.domain.CurrentUser
import com.all8ai.aioa.shared.web.ApiException
import org.springframework.http.HttpStatus
import org.springframework.stereotype.Service
import java.util.UUID
import org.springframework.transaction.annotation.Transactional
@Service
class UserDeviceService(private val repository: UserDeviceRepository, private val auditService: AuditService) {
fun register(actor: CurrentUser, id: UUID, name: String, platform: DevicePlatform, appVersion: String?): UserDevice {
val normalizedName = name.trim().takeIf { it.isNotEmpty() && it.length <= 200 }
?: throw ApiException(HttpStatus.BAD_REQUEST, "DEVICE_NAME_INVALID", "设备名称无效")
val normalizedVersion = appVersion?.trim()?.takeIf { it.isNotEmpty() && it.length <= 64 }
return repository.register(id, actor.tenantId, actor.id, normalizedName, platform, normalizedVersion)
?: throw ApiException(HttpStatus.UNAUTHORIZED, "DEVICE_REVOKED", "该设备已被撤销,请联系管理员或使用其他设备")
}
fun list(actor: CurrentUser): List<UserDevice> = repository.list(actor.tenantId, actor.id)
fun revoke(actor: CurrentUser, id: UUID): UserDevice {
val device = repository.revoke(actor.tenantId, actor.id, id)
?: throw ApiException(HttpStatus.NOT_FOUND, "DEVICE_NOT_FOUND", "设备不存在")
auditService.recordSuccess(actor, "USER_DEVICE_REVOKED", "USER_DEVICE", id.toString(), null, mapOf("platform" to device.platform.name))
return device
}
@Transactional
fun updatePushToken(actor: CurrentUser, id: UUID, token: String?) {
val normalized = token?.trim()?.takeIf { it.isNotEmpty() && it.length <= 4096 }
if (token != null && normalized == null) throw ApiException(HttpStatus.BAD_REQUEST, "PUSH_TOKEN_INVALID", "推送令牌无效")
normalized?.let(repository::clearPushToken)
if (!repository.updatePushToken(actor.tenantId, actor.id, id, normalized)) {
throw ApiException(HttpStatus.NOT_FOUND, "DEVICE_NOT_FOUND", "设备不存在或已撤销")
}
}
}
@@ -0,0 +1,30 @@
package com.all8ai.aioa.device.domain
import java.time.Instant
import java.util.UUID
enum class DeviceStatus { ACTIVE, REVOKED }
enum class DevicePlatform { IOS, ANDROID, OTHER }
data class UserDevice(
val id: UUID,
val tenantId: UUID,
val userId: UUID,
val name: String,
val platform: DevicePlatform,
val appVersion: String?,
val status: DeviceStatus,
val registeredAt: Instant,
val lastSeenAt: Instant,
val revokedAt: Instant?,
)
interface UserDeviceRepository {
fun register(id: UUID, tenantId: UUID, userId: UUID, name: String, platform: DevicePlatform, appVersion: String?): UserDevice?
fun list(tenantId: UUID, userId: UUID): List<UserDevice>
fun revoke(tenantId: UUID, userId: UUID, id: UUID): UserDevice?
fun touchActive(tenantId: UUID, userId: UUID, id: UUID): Boolean
fun updatePushToken(tenantId: UUID, userId: UUID, id: UUID, token: String?): Boolean
fun listActivePushTokens(tenantId: UUID, userId: UUID): List<String>
fun clearPushToken(token: String)
}
@@ -0,0 +1,39 @@
package com.all8ai.aioa.device.infrastructure
import com.all8ai.aioa.device.domain.UserDeviceRepository
import com.all8ai.aioa.identity.application.CurrentUserService
import jakarta.servlet.http.HttpServletRequest
import jakarta.servlet.http.HttpServletResponse
import org.springframework.http.MediaType
import org.springframework.security.core.context.SecurityContextHolder
import org.springframework.security.oauth2.jwt.Jwt
import org.springframework.stereotype.Component
import org.springframework.web.servlet.HandlerInterceptor
import java.util.UUID
@Component
class DeviceSessionInterceptor(
private val currentUserService: CurrentUserService,
private val devices: UserDeviceRepository,
) : HandlerInterceptor {
override fun preHandle(request: HttpServletRequest, response: HttpServletResponse, handler: Any): Boolean {
if (request.method == "OPTIONS" || !request.requestURI.startsWith("/api/v1/") || request.requestURI == "/api/v1/devices/register") return true
val jwt = SecurityContextHolder.getContext().authentication?.principal as? Jwt ?: return true
val actor = currentUserService.get(jwt.subject, jwt.getClaimAsString("tenant_id"))
val deviceId = request.getHeader(DEVICE_ID_HEADER)?.let { value ->
runCatching { UUID.fromString(value) }.getOrNull()
}
if (deviceId != null && devices.touchActive(actor.tenantId, actor.id, deviceId)) return true
response.status = HttpServletResponse.SC_UNAUTHORIZED
response.contentType = MediaType.APPLICATION_PROBLEM_JSON_VALUE
response.characterEncoding = Charsets.UTF_8.name()
response.setHeader(AUTH_ERROR_HEADER, "DEVICE_REVOKED")
response.writer.write("""{"title":"Unauthorized","status":401,"detail":"设备未注册或已被撤销","code":"DEVICE_REVOKED"}""")
return false
}
companion object {
const val DEVICE_ID_HEADER = "X-AIOA-Device-Id"
const val AUTH_ERROR_HEADER = "X-AIOA-Auth-Error"
}
}
@@ -0,0 +1,12 @@
package com.all8ai.aioa.device.infrastructure
import org.springframework.context.annotation.Configuration
import org.springframework.web.servlet.config.annotation.InterceptorRegistry
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer
@Configuration
class DeviceWebConfiguration(private val interceptor: DeviceSessionInterceptor) : WebMvcConfigurer {
override fun addInterceptors(registry: InterceptorRegistry) {
registry.addInterceptor(interceptor)
}
}
@@ -0,0 +1,68 @@
package com.all8ai.aioa.device.infrastructure
import com.all8ai.aioa.device.domain.*
import org.jooq.DSLContext
import org.jooq.Record
import org.springframework.stereotype.Repository
import java.time.OffsetDateTime
import java.util.UUID
@Repository
class JooqUserDeviceRepository(private val dsl: DSLContext) : UserDeviceRepository {
override fun register(id: UUID, tenantId: UUID, userId: UUID, name: String, platform: DevicePlatform, appVersion: String?): UserDevice? =
dsl.fetchOne(
"""
INSERT INTO identity.user_device (id, tenant_id, user_id, name, platform, app_version)
VALUES (?, ?, ?, ?, ?, ?)
ON CONFLICT (tenant_id, user_id, id) DO UPDATE SET
name = EXCLUDED.name, platform = EXCLUDED.platform,
app_version = EXCLUDED.app_version, last_seen_at = CURRENT_TIMESTAMP
WHERE user_device.status = 'ACTIVE'
RETURNING *
""".trimIndent(), id, tenantId, userId, name, platform.name, appVersion,
)?.let(::map)
override fun list(tenantId: UUID, userId: UUID): List<UserDevice> = dsl.fetch(
"SELECT * FROM identity.user_device WHERE tenant_id = ? AND user_id = ? ORDER BY last_seen_at DESC",
tenantId, userId,
).map(::map)
override fun revoke(tenantId: UUID, userId: UUID, id: UUID): UserDevice? = dsl.fetchOne(
"""
UPDATE identity.user_device SET status = 'REVOKED', revoked_at = COALESCE(revoked_at, CURRENT_TIMESTAMP)
WHERE tenant_id = ? AND user_id = ? AND id = ? RETURNING *
""".trimIndent(), tenantId, userId, id,
)?.let(::map)
override fun touchActive(tenantId: UUID, userId: UUID, id: UUID): Boolean = dsl.execute(
"UPDATE identity.user_device SET last_seen_at = CURRENT_TIMESTAMP WHERE tenant_id = ? AND user_id = ? AND id = ? AND status = 'ACTIVE'",
tenantId, userId, id,
) == 1
override fun updatePushToken(tenantId: UUID, userId: UUID, id: UUID, token: String?): Boolean = dsl.execute(
"UPDATE identity.user_device SET push_token = ?, push_token_updated_at = CURRENT_TIMESTAMP WHERE tenant_id = ? AND user_id = ? AND id = ? AND status = 'ACTIVE'",
token, tenantId, userId, id,
) == 1
override fun listActivePushTokens(tenantId: UUID, userId: UUID): List<String> = dsl.fetch(
"SELECT push_token FROM identity.user_device WHERE tenant_id = ? AND user_id = ? AND status = 'ACTIVE' AND push_token IS NOT NULL",
tenantId, userId,
).map { it.get("push_token", String::class.java)!! }
override fun clearPushToken(token: String) {
dsl.execute("UPDATE identity.user_device SET push_token = NULL, push_token_updated_at = CURRENT_TIMESTAMP WHERE push_token = ?", token)
}
private fun map(record: Record) = UserDevice(
record.get("id", UUID::class.java)!!,
record.get("tenant_id", UUID::class.java)!!,
record.get("user_id", UUID::class.java)!!,
record.get("name", String::class.java)!!,
DevicePlatform.valueOf(record.get("platform", String::class.java)!!),
record.get("app_version", String::class.java),
DeviceStatus.valueOf(record.get("status", String::class.java)!!),
record.get("registered_at", OffsetDateTime::class.java)!!.toInstant(),
record.get("last_seen_at", OffsetDateTime::class.java)!!.toInstant(),
record.get("revoked_at", OffsetDateTime::class.java)?.toInstant(),
)
}