feat: complete leave approval MVP
This commit is contained in:
@@ -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)
|
||||
}
|
||||
+39
@@ -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"
|
||||
}
|
||||
}
|
||||
+12
@@ -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)
|
||||
}
|
||||
}
|
||||
+68
@@ -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(),
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user