feat: complete leave approval MVP
This commit is contained in:
@@ -28,6 +28,8 @@ dependencies {
|
||||
implementation("org.flywaydb:flyway-database-postgresql")
|
||||
implementation("org.jetbrains.kotlin:kotlin-reflect")
|
||||
implementation("org.flowable:flowable-spring-boot-starter-process:7.2.0")
|
||||
implementation("io.minio:minio:8.5.17")
|
||||
implementation("com.google.firebase:firebase-admin:9.4.3")
|
||||
runtimeOnly("org.postgresql:postgresql")
|
||||
|
||||
testImplementation("org.springframework.boot:spring-boot-starter-test")
|
||||
|
||||
@@ -2,8 +2,10 @@ package com.all8ai.aioa
|
||||
|
||||
import org.springframework.boot.autoconfigure.SpringBootApplication
|
||||
import org.springframework.boot.runApplication
|
||||
import org.springframework.scheduling.annotation.EnableScheduling
|
||||
|
||||
@SpringBootApplication
|
||||
@EnableScheduling
|
||||
class AioaApplication
|
||||
|
||||
fun main(args: Array<String>) {
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
package com.all8ai.aioa.ai.api
|
||||
|
||||
import com.all8ai.aioa.ai.application.AiLeaveDraftService
|
||||
import com.all8ai.aioa.ai.domain.SuggestedLeaveDraft
|
||||
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.PostMapping
|
||||
import org.springframework.web.bind.annotation.RequestBody
|
||||
import org.springframework.web.bind.annotation.RequestMapping
|
||||
import org.springframework.web.bind.annotation.RestController
|
||||
|
||||
@RestController
|
||||
@RequestMapping("/api/v1/ai/leave-draft-suggestions")
|
||||
class AiLeaveDraftController(
|
||||
private val currentUserService: CurrentUserService,
|
||||
private val service: AiLeaveDraftService,
|
||||
) {
|
||||
@PostMapping
|
||||
fun suggest(
|
||||
@AuthenticationPrincipal jwt: Jwt,
|
||||
@Valid @RequestBody request: AiLeaveDraftSuggestionRequest,
|
||||
): SuggestedLeaveDraft = service.suggest(
|
||||
currentUserService.get(jwt.subject, jwt.getClaimAsString("tenant_id")),
|
||||
request.text,
|
||||
request.timezone,
|
||||
)
|
||||
}
|
||||
|
||||
data class AiLeaveDraftSuggestionRequest(
|
||||
@field:NotBlank @field:Size(max = 2000) val text: String,
|
||||
@field:NotBlank @field:Size(max = 64) val timezone: String = "Asia/Shanghai",
|
||||
)
|
||||
@@ -0,0 +1,20 @@
|
||||
package com.all8ai.aioa.ai.api
|
||||
|
||||
import com.all8ai.aioa.ai.application.AiLeaveProgressService
|
||||
import com.all8ai.aioa.ai.domain.LeaveProgressAnswerResult
|
||||
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/ai/leave-progress-answers")
|
||||
class AiLeaveProgressController(private val currentUserService: CurrentUserService, private val service: AiLeaveProgressService) {
|
||||
@PostMapping fun answer(@AuthenticationPrincipal jwt: Jwt, @Valid @RequestBody request: AiLeaveProgressRequest): LeaveProgressAnswerResult =
|
||||
service.answer(currentUserService.get(jwt.subject, jwt.getClaimAsString("tenant_id")), request.text, request.timezone, request.selectedRequestId)
|
||||
}
|
||||
data class AiLeaveProgressRequest(@field:NotBlank @field:Size(max = 2000) val text: String, @field:NotBlank @field:Size(max = 64) val timezone: String = "Asia/Shanghai", val selectedRequestId: UUID? = null)
|
||||
@@ -0,0 +1,45 @@
|
||||
package com.all8ai.aioa.ai.application
|
||||
|
||||
import com.all8ai.aioa.ai.domain.AiLeaveDraftGateway
|
||||
import com.all8ai.aioa.ai.domain.SuggestedLeaveDraft
|
||||
import com.all8ai.aioa.audit.application.AuditService
|
||||
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 com.all8ai.aioa.shared.security.ToolPermission
|
||||
import com.all8ai.aioa.shared.security.requirePermission
|
||||
|
||||
@Service
|
||||
class AiLeaveDraftService(
|
||||
private val gateway: AiLeaveDraftGateway,
|
||||
private val auditService: AuditService,
|
||||
) {
|
||||
fun suggest(actor: CurrentUser, text: String, timezone: String): SuggestedLeaveDraft {
|
||||
actor.requirePermission(ToolPermission.AI_LEAVE_DRAFT_SUGGEST)
|
||||
val normalized = text.trim()
|
||||
if (normalized.isEmpty() || normalized.length > 2000) {
|
||||
throw ApiException(HttpStatus.BAD_REQUEST, "AI_PROMPT_INVALID", "描述长度必须为 1 到 2000 个字符")
|
||||
}
|
||||
if (timezone.isBlank() || timezone.length > 64) {
|
||||
throw ApiException(HttpStatus.BAD_REQUEST, "TIMEZONE_INVALID", "时区无效")
|
||||
}
|
||||
val result = gateway.suggest(normalized, timezone)
|
||||
if (!result.requiresUserConfirmation) {
|
||||
throw ApiException(HttpStatus.BAD_GATEWAY, "AI_CONFIRMATION_REQUIRED", "AI 建议必须要求用户确认")
|
||||
}
|
||||
auditService.recordSuccess(
|
||||
actor,
|
||||
"AI_LEAVE_DRAFT_SUGGESTED",
|
||||
"AI_SUGGESTION",
|
||||
"leave-draft",
|
||||
null,
|
||||
mapOf(
|
||||
"model" to result.model,
|
||||
"promptLength" to normalized.length,
|
||||
"clarificationCount" to result.suggestion.needsClarification.size,
|
||||
),
|
||||
)
|
||||
return result
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
package com.all8ai.aioa.ai.application
|
||||
|
||||
import com.all8ai.aioa.ai.domain.*
|
||||
import com.all8ai.aioa.approval.domain.LeaveRequest
|
||||
import com.all8ai.aioa.approval.domain.LeaveRequestRepository
|
||||
import com.all8ai.aioa.audit.application.AuditService
|
||||
import com.all8ai.aioa.identity.domain.CurrentUser
|
||||
import com.all8ai.aioa.shared.web.ApiException
|
||||
import com.all8ai.aioa.workflow.domain.LeaveWorkflowGateway
|
||||
import org.springframework.http.HttpStatus
|
||||
import org.springframework.stereotype.Service
|
||||
import java.time.LocalDate
|
||||
import java.time.ZoneId
|
||||
import java.util.UUID
|
||||
import com.all8ai.aioa.shared.security.ToolPermission
|
||||
import com.all8ai.aioa.shared.security.requirePermission
|
||||
|
||||
@Service
|
||||
class AiLeaveProgressService(
|
||||
private val repository: LeaveRequestRepository,
|
||||
private val workflow: LeaveWorkflowGateway,
|
||||
private val gateway: AiLeaveProgressGateway,
|
||||
private val auditService: AuditService,
|
||||
) {
|
||||
fun answer(actor: CurrentUser, text: String, timezone: String, selectedRequestId: UUID?): LeaveProgressAnswerResult {
|
||||
actor.requirePermission(ToolPermission.AI_LEAVE_PROGRESS_READ_OWN)
|
||||
val question = text.trim()
|
||||
if (question.isEmpty() || question.length > 2000) throw ApiException(HttpStatus.BAD_REQUEST, "AI_PROMPT_INVALID", "问题长度必须为 1 到 2000 个字符")
|
||||
val zone = try { ZoneId.of(timezone) } catch (_: Exception) { throw ApiException(HttpStatus.BAD_REQUEST, "TIMEZONE_INVALID", "时区无效") }
|
||||
val own = repository.listOwn(actor.tenantId, actor.id, 100)
|
||||
val matches = selectedRequestId?.let { id -> own.filter { it.id == id } } ?: filter(question, zone, own)
|
||||
if (selectedRequestId != null && matches.isEmpty()) throw ApiException(HttpStatus.NOT_FOUND, "LEAVE_REQUEST_NOT_FOUND", "未找到本人的请假申请")
|
||||
if (matches.size != 1) return LeaveProgressAnswerResult(true, matches.take(10).map(::candidate))
|
||||
val request = matches.single()
|
||||
val rawProgress = request.processInstanceId?.let(workflow::getProgress)
|
||||
val progress = LeaveProgressView(rawProgress?.activeTaskNames.orEmpty(), rawProgress?.completedTaskNames.orEmpty(), rawProgress?.processEnded ?: request.status.name !in setOf("DRAFT", "PENDING"))
|
||||
val context = LeaveProgressContext(request.id, request.type.name, request.status.name, request.startsAt, request.endsAt, progress.activeTaskNames, progress.completedTaskNames, progress.processEnded, repository.listTimeline(actor.tenantId, request.id).map { it.eventType })
|
||||
val generated = gateway.answer(question, timezone, context)
|
||||
auditService.recordSuccess(actor, "AI_LEAVE_PROGRESS_QUERIED", "LEAVE_REQUEST", request.id.toString(), null, mapOf("model" to generated.model, "promptLength" to question.length, "requestId" to request.id.toString()))
|
||||
return LeaveProgressAnswerResult(false, answer = generated.answer, request = candidate(request), progress = progress)
|
||||
}
|
||||
|
||||
private fun filter(text: String, zone: ZoneId, requests: List<LeaveRequest>): List<LeaveRequest> {
|
||||
var result = requests
|
||||
val type = when { "病假" in text -> "SICK"; "年假" in text -> "ANNUAL"; "事假" in text -> "PERSONAL"; else -> null }
|
||||
if (type != null) result = result.filter { it.type.name == type }
|
||||
val today = LocalDate.now(zone)
|
||||
if ("昨天" in text) result = result.filter { it.createdAt.atZone(zone).toLocalDate() == today.minusDays(1) }
|
||||
if ("今天" in text) result = result.filter { it.createdAt.atZone(zone).toLocalDate() == today }
|
||||
if (listOf("最近", "最新", "刚才", "刚提交").any { it in text } && result.isNotEmpty()) return listOf(result.maxBy { it.createdAt })
|
||||
return result
|
||||
}
|
||||
|
||||
private fun candidate(it: LeaveRequest) = LeaveProgressCandidate(it.id, it.type.name, it.status.name, it.startsAt, it.endsAt, it.createdAt)
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
package com.all8ai.aioa.ai.domain
|
||||
|
||||
import java.time.Instant
|
||||
|
||||
data class LeaveDraftSuggestion(
|
||||
val type: String?,
|
||||
val startsAt: Instant?,
|
||||
val endsAt: Instant?,
|
||||
val reason: String?,
|
||||
val assumptions: List<String>,
|
||||
val needsClarification: List<String>,
|
||||
)
|
||||
|
||||
data class SuggestedLeaveDraft(
|
||||
val suggestion: LeaveDraftSuggestion,
|
||||
val model: String,
|
||||
val requiresUserConfirmation: Boolean,
|
||||
)
|
||||
|
||||
fun interface AiLeaveDraftGateway {
|
||||
fun suggest(text: String, timezone: String): SuggestedLeaveDraft
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
package com.all8ai.aioa.ai.domain
|
||||
|
||||
import java.time.Instant
|
||||
import java.util.UUID
|
||||
|
||||
data class LeaveProgressContext(
|
||||
val requestId: UUID,
|
||||
val type: String,
|
||||
val status: String,
|
||||
val startsAt: Instant,
|
||||
val endsAt: Instant,
|
||||
val activeTaskNames: List<String>,
|
||||
val completedTaskNames: List<String>,
|
||||
val processEnded: Boolean,
|
||||
val timelineEventTypes: List<String>,
|
||||
)
|
||||
|
||||
data class GeneratedProgressAnswer(val answer: String, val model: String)
|
||||
|
||||
fun interface AiLeaveProgressGateway {
|
||||
fun answer(question: String, timezone: String, context: LeaveProgressContext): GeneratedProgressAnswer
|
||||
}
|
||||
|
||||
data class LeaveProgressCandidate(
|
||||
val id: UUID,
|
||||
val type: String,
|
||||
val status: String,
|
||||
val startsAt: Instant,
|
||||
val endsAt: Instant,
|
||||
val createdAt: Instant,
|
||||
)
|
||||
|
||||
data class LeaveProgressAnswerResult(
|
||||
val requiresSelection: Boolean,
|
||||
val candidates: List<LeaveProgressCandidate> = emptyList(),
|
||||
val answer: String? = null,
|
||||
val request: LeaveProgressCandidate? = null,
|
||||
val progress: LeaveProgressView? = null,
|
||||
)
|
||||
|
||||
data class LeaveProgressView(
|
||||
val activeTaskNames: List<String>,
|
||||
val completedTaskNames: List<String>,
|
||||
val processEnded: Boolean,
|
||||
)
|
||||
+32
@@ -0,0 +1,32 @@
|
||||
package com.all8ai.aioa.ai.infrastructure
|
||||
|
||||
import com.all8ai.aioa.ai.domain.AiLeaveDraftGateway
|
||||
import com.all8ai.aioa.ai.domain.SuggestedLeaveDraft
|
||||
import com.all8ai.aioa.shared.web.ApiException
|
||||
import org.springframework.beans.factory.annotation.Value
|
||||
import org.springframework.http.HttpStatus
|
||||
import org.springframework.stereotype.Component
|
||||
import org.springframework.web.client.RestClient
|
||||
import org.springframework.web.client.RestClientException
|
||||
|
||||
@Component
|
||||
class HttpAiLeaveDraftGateway(
|
||||
@Value("\${aioa.ai-service.url}") aiServiceUrl: String,
|
||||
) : AiLeaveDraftGateway {
|
||||
private val client = RestClient.builder().baseUrl(aiServiceUrl).build()
|
||||
|
||||
override fun suggest(text: String, timezone: String): SuggestedLeaveDraft = try {
|
||||
client.post()
|
||||
.uri("/v1/leave-drafts/suggest")
|
||||
.body(SuggestionRequest(text, timezone))
|
||||
.retrieve()
|
||||
.body(SuggestedLeaveDraft::class.java)
|
||||
?: throw ApiException(HttpStatus.BAD_GATEWAY, "AI_RESPONSE_EMPTY", "AI 服务未返回建议")
|
||||
} catch (exception: ApiException) {
|
||||
throw exception
|
||||
} catch (exception: RestClientException) {
|
||||
throw ApiException(HttpStatus.BAD_GATEWAY, "AI_SERVICE_UNAVAILABLE", "AI 服务暂时不可用")
|
||||
}
|
||||
}
|
||||
|
||||
private data class SuggestionRequest(val text: String, val timezone: String)
|
||||
+19
@@ -0,0 +1,19 @@
|
||||
package com.all8ai.aioa.ai.infrastructure
|
||||
|
||||
import com.all8ai.aioa.ai.domain.*
|
||||
import com.all8ai.aioa.shared.web.ApiException
|
||||
import org.springframework.beans.factory.annotation.Value
|
||||
import org.springframework.http.HttpStatus
|
||||
import org.springframework.stereotype.Component
|
||||
import org.springframework.web.client.RestClient
|
||||
import org.springframework.web.client.RestClientException
|
||||
|
||||
@Component
|
||||
class HttpAiLeaveProgressGateway(@Value("\${aioa.ai-service.url}") aiServiceUrl: String) : AiLeaveProgressGateway {
|
||||
private val client = RestClient.builder().baseUrl(aiServiceUrl).build()
|
||||
override fun answer(question: String, timezone: String, context: LeaveProgressContext): GeneratedProgressAnswer = try {
|
||||
client.post().uri("/v1/leave-progress/answer").body(ProgressRequest(question, timezone, context)).retrieve().body(GeneratedProgressAnswer::class.java)
|
||||
?: throw ApiException(HttpStatus.BAD_GATEWAY, "AI_RESPONSE_EMPTY", "AI 服务未返回回答")
|
||||
} catch (e: ApiException) { throw e } catch (_: RestClientException) { throw ApiException(HttpStatus.BAD_GATEWAY, "AI_SERVICE_UNAVAILABLE", "AI 服务暂时不可用") }
|
||||
}
|
||||
private data class ProgressRequest(val question: String, val timezone: String, val context: LeaveProgressContext)
|
||||
+20
-2
@@ -9,26 +9,32 @@ import com.all8ai.aioa.shared.id.UuidV7
|
||||
import com.all8ai.aioa.shared.web.ApiException
|
||||
import com.all8ai.aioa.shared.web.TraceIdFilter
|
||||
import com.all8ai.aioa.workflow.domain.LeaveWorkflowGateway
|
||||
import com.all8ai.aioa.notification.application.NotificationService
|
||||
import org.slf4j.MDC
|
||||
import org.springframework.http.HttpStatus
|
||||
import org.springframework.stereotype.Service
|
||||
import org.springframework.transaction.annotation.Transactional
|
||||
import java.nio.charset.StandardCharsets
|
||||
import java.security.MessageDigest
|
||||
import com.all8ai.aioa.shared.security.ToolPermission
|
||||
import com.all8ai.aioa.shared.security.requirePermission
|
||||
|
||||
@Service
|
||||
class ApprovalTaskService(
|
||||
private val workflowGateway: LeaveWorkflowGateway,
|
||||
private val leaveRequestRepository: LeaveRequestRepository,
|
||||
private val auditService: AuditService,
|
||||
private val notificationService: NotificationService? = null,
|
||||
) {
|
||||
fun listAssigned(actor: CurrentUser): List<ApprovalTask> =
|
||||
workflowGateway.listAssignedTasks(actor.id).mapNotNull { task ->
|
||||
fun listAssigned(actor: CurrentUser): List<ApprovalTask> {
|
||||
actor.requirePermission(ToolPermission.APPROVAL_TASK_READ_ASSIGNED)
|
||||
return workflowGateway.listAssignedTasks(actor.id).mapNotNull { task ->
|
||||
val request = leaveRequestRepository.findById(actor.tenantId, task.leaveRequestId)
|
||||
?: return@mapNotNull null
|
||||
if (request.status != LeaveStatus.PENDING) return@mapNotNull null
|
||||
ApprovalTask(task.id, task.name, task.createdAt, request)
|
||||
}
|
||||
}
|
||||
|
||||
@Transactional
|
||||
fun approve(
|
||||
@@ -56,6 +62,7 @@ class ApprovalTaskService(
|
||||
comment: String?,
|
||||
approved: Boolean,
|
||||
): LeaveRequest {
|
||||
actor.requirePermission(ToolPermission.APPROVAL_TASK_DECIDE_ASSIGNED)
|
||||
validate(idempotencyKey, expectedVersion, comment)
|
||||
val task = workflowGateway.resolveTask(taskId)
|
||||
?: throw ApiException(HttpStatus.NOT_FOUND, "APPROVAL_TASK_NOT_FOUND", "审批任务不存在")
|
||||
@@ -131,6 +138,17 @@ class ApprovalTaskService(
|
||||
"comment" to comment?.trim(),
|
||||
),
|
||||
)
|
||||
if (completion.processEnded) {
|
||||
notificationService?.notify(
|
||||
actor.tenantId,
|
||||
request.applicantId,
|
||||
if (approved) "LEAVE_APPROVED" else "LEAVE_REJECTED",
|
||||
if (approved) "请假申请已通过" else "请假申请已驳回",
|
||||
if (approved) "你的请假申请已完成审批。" else "你的请假申请未通过审批,请查看审批意见。",
|
||||
"LEAVE_REQUEST",
|
||||
request.id.toString(),
|
||||
)
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
|
||||
+25
-3
@@ -17,11 +17,14 @@ import org.springframework.transaction.annotation.Transactional
|
||||
import org.slf4j.MDC
|
||||
import com.all8ai.aioa.shared.web.TraceIdFilter
|
||||
import com.all8ai.aioa.workflow.domain.LeaveWorkflowGateway
|
||||
import com.all8ai.aioa.notification.application.NotificationService
|
||||
import java.nio.charset.StandardCharsets
|
||||
import java.security.MessageDigest
|
||||
import java.time.Instant
|
||||
import java.time.Duration
|
||||
import java.util.UUID
|
||||
import com.all8ai.aioa.shared.security.ToolPermission
|
||||
import com.all8ai.aioa.shared.security.requirePermission
|
||||
|
||||
@Service
|
||||
class LeaveRequestService(
|
||||
@@ -29,8 +32,10 @@ class LeaveRequestService(
|
||||
private val auditService: AuditService,
|
||||
private val routingRepository: ApprovalRoutingRepository,
|
||||
private val workflowGateway: LeaveWorkflowGateway,
|
||||
private val notificationService: NotificationService? = null,
|
||||
) {
|
||||
fun createDraft(actor: CurrentUser, idempotencyKey: String, command: SaveDraftCommand): LeaveRequest {
|
||||
actor.requirePermission(ToolPermission.LEAVE_REQUEST_WRITE_OWN)
|
||||
validateIdempotencyKey(idempotencyKey)
|
||||
val content = command.toValidatedContent()
|
||||
val fingerprint = fingerprint(content)
|
||||
@@ -53,6 +58,7 @@ class LeaveRequestService(
|
||||
}
|
||||
|
||||
fun updateDraft(actor: CurrentUser, id: UUID, command: SaveDraftCommand): LeaveRequest {
|
||||
actor.requirePermission(ToolPermission.LEAVE_REQUEST_WRITE_OWN)
|
||||
val content = command.toValidatedContent()
|
||||
repository.updateDraft(actor.tenantId, actor.id, id, command.version, content)?.let { return it }
|
||||
|
||||
@@ -64,14 +70,20 @@ class LeaveRequestService(
|
||||
throw ApiException(HttpStatus.CONFLICT, "VERSION_CONFLICT", "申请已被其他操作更新,请刷新后重试")
|
||||
}
|
||||
|
||||
fun getOwn(actor: CurrentUser, id: UUID): LeaveRequest =
|
||||
repository.findOwn(actor.tenantId, actor.id, id)
|
||||
fun getOwn(actor: CurrentUser, id: UUID): LeaveRequest {
|
||||
actor.requirePermission(ToolPermission.LEAVE_REQUEST_READ_OWN)
|
||||
return repository.findOwn(actor.tenantId, actor.id, id)
|
||||
?: throw ApiException(HttpStatus.NOT_FOUND, "LEAVE_REQUEST_NOT_FOUND", "请假申请不存在")
|
||||
}
|
||||
|
||||
fun listOwn(actor: CurrentUser): List<LeaveRequest> = repository.listOwn(actor.tenantId, actor.id, 100)
|
||||
fun listOwn(actor: CurrentUser): List<LeaveRequest> {
|
||||
actor.requirePermission(ToolPermission.LEAVE_REQUEST_READ_OWN)
|
||||
return repository.listOwn(actor.tenantId, actor.id, 100)
|
||||
}
|
||||
|
||||
@Transactional
|
||||
fun submit(actor: CurrentUser, id: UUID, idempotencyKey: String, expectedVersion: Long): LeaveRequest {
|
||||
actor.requirePermission(ToolPermission.LEAVE_REQUEST_WRITE_OWN)
|
||||
val existing = getOwn(actor, id)
|
||||
val durationMinutes = Duration.between(existing.startsAt, existing.endsAt).toMinutes()
|
||||
val approverId = routingRepository.findDepartmentManager(actor.tenantId, actor.id)
|
||||
@@ -125,6 +137,15 @@ class LeaveRequestService(
|
||||
process.processInstanceId,
|
||||
process.processDefinitionId,
|
||||
)
|
||||
notificationService?.notify(
|
||||
actor.tenantId,
|
||||
approverId,
|
||||
"APPROVAL_TASK_ASSIGNED",
|
||||
"新的请假审批待办",
|
||||
"${actor.displayName} 提交了请假申请,请及时处理。",
|
||||
"LEAVE_REQUEST",
|
||||
outcome.leaveRequest.id.toString(),
|
||||
)
|
||||
return outcome.leaveRequest.copy(
|
||||
processInstanceId = process.processInstanceId,
|
||||
processDefinitionId = process.processDefinitionId,
|
||||
@@ -133,6 +154,7 @@ class LeaveRequestService(
|
||||
|
||||
@Transactional
|
||||
fun withdraw(actor: CurrentUser, id: UUID, idempotencyKey: String, expectedVersion: Long): LeaveRequest {
|
||||
actor.requirePermission(ToolPermission.LEAVE_REQUEST_WRITE_OWN)
|
||||
val outcome = transition(
|
||||
actor = actor,
|
||||
id = id,
|
||||
|
||||
+93
@@ -0,0 +1,93 @@
|
||||
package com.all8ai.aioa.attachment.api
|
||||
|
||||
import com.all8ai.aioa.attachment.application.CreateUploadCommand
|
||||
import com.all8ai.aioa.attachment.application.LeaveAttachmentService
|
||||
import com.all8ai.aioa.attachment.domain.LeaveAttachment
|
||||
import com.all8ai.aioa.identity.application.CurrentUserService
|
||||
import jakarta.validation.Valid
|
||||
import jakarta.validation.constraints.NotBlank
|
||||
import jakarta.validation.constraints.Positive
|
||||
import jakarta.validation.constraints.Size
|
||||
import org.springframework.http.HttpStatus
|
||||
import org.springframework.security.core.annotation.AuthenticationPrincipal
|
||||
import org.springframework.security.oauth2.jwt.Jwt
|
||||
import org.springframework.web.bind.annotation.DeleteMapping
|
||||
import org.springframework.web.bind.annotation.GetMapping
|
||||
import org.springframework.web.bind.annotation.PathVariable
|
||||
import org.springframework.web.bind.annotation.PostMapping
|
||||
import org.springframework.web.bind.annotation.RequestBody
|
||||
import org.springframework.web.bind.annotation.RequestMapping
|
||||
import org.springframework.web.bind.annotation.ResponseStatus
|
||||
import org.springframework.web.bind.annotation.RestController
|
||||
import java.time.Instant
|
||||
import java.util.UUID
|
||||
|
||||
@RestController
|
||||
@RequestMapping("/api/v1/leave-requests/{leaveRequestId}/attachments")
|
||||
class LeaveAttachmentController(
|
||||
private val currentUserService: CurrentUserService,
|
||||
private val service: LeaveAttachmentService,
|
||||
) {
|
||||
@PostMapping("/upload-tasks")
|
||||
@ResponseStatus(HttpStatus.CREATED)
|
||||
fun createUpload(
|
||||
@AuthenticationPrincipal jwt: Jwt,
|
||||
@PathVariable leaveRequestId: UUID,
|
||||
@Valid @RequestBody request: CreateAttachmentUploadRequest,
|
||||
): AttachmentUploadResponse {
|
||||
val upload = service.createUpload(currentUser(jwt), leaveRequestId, request.toCommand())
|
||||
return AttachmentUploadResponse(upload.attachment.toResponse(), upload.uploadUrl)
|
||||
}
|
||||
|
||||
@PostMapping("/{attachmentId}/complete")
|
||||
fun complete(
|
||||
@AuthenticationPrincipal jwt: Jwt,
|
||||
@PathVariable leaveRequestId: UUID,
|
||||
@PathVariable attachmentId: UUID,
|
||||
) = service.complete(currentUser(jwt), leaveRequestId, attachmentId).toResponse()
|
||||
|
||||
@GetMapping
|
||||
fun list(@AuthenticationPrincipal jwt: Jwt, @PathVariable leaveRequestId: UUID) =
|
||||
service.list(currentUser(jwt), leaveRequestId).map(LeaveAttachment::toResponse)
|
||||
|
||||
@GetMapping("/{attachmentId}/download")
|
||||
fun download(
|
||||
@AuthenticationPrincipal jwt: Jwt,
|
||||
@PathVariable leaveRequestId: UUID,
|
||||
@PathVariable attachmentId: UUID,
|
||||
) = AttachmentDownloadResponse(service.download(currentUser(jwt), leaveRequestId, attachmentId))
|
||||
|
||||
@DeleteMapping("/{attachmentId}")
|
||||
@ResponseStatus(HttpStatus.NO_CONTENT)
|
||||
fun delete(
|
||||
@AuthenticationPrincipal jwt: Jwt,
|
||||
@PathVariable leaveRequestId: UUID,
|
||||
@PathVariable attachmentId: UUID,
|
||||
) = service.delete(currentUser(jwt), leaveRequestId, attachmentId)
|
||||
|
||||
private fun currentUser(jwt: Jwt) = currentUserService.get(jwt.subject, jwt.getClaimAsString("tenant_id"))
|
||||
}
|
||||
|
||||
data class CreateAttachmentUploadRequest(
|
||||
@field:NotBlank @field:Size(max = 255) val fileName: String,
|
||||
@field:NotBlank @field:Size(max = 128) val contentType: String,
|
||||
@field:Positive val sizeBytes: Long,
|
||||
) {
|
||||
fun toCommand() = CreateUploadCommand(fileName, contentType, sizeBytes)
|
||||
}
|
||||
|
||||
data class AttachmentUploadResponse(val attachment: LeaveAttachmentResponse, val uploadUrl: String)
|
||||
data class AttachmentDownloadResponse(val downloadUrl: String)
|
||||
data class LeaveAttachmentResponse(
|
||||
val id: UUID,
|
||||
val fileName: String,
|
||||
val contentType: String,
|
||||
val sizeBytes: Long,
|
||||
val status: String,
|
||||
val createdAt: Instant,
|
||||
val completedAt: Instant?,
|
||||
)
|
||||
|
||||
private fun LeaveAttachment.toResponse() = LeaveAttachmentResponse(
|
||||
id, fileName, contentType, sizeBytes, status.name, createdAt, completedAt,
|
||||
)
|
||||
+113
@@ -0,0 +1,113 @@
|
||||
package com.all8ai.aioa.attachment.application
|
||||
|
||||
import com.all8ai.aioa.approval.domain.LeaveRequestRepository
|
||||
import com.all8ai.aioa.approval.domain.LeaveStatus
|
||||
import com.all8ai.aioa.attachment.domain.AttachmentStatus
|
||||
import com.all8ai.aioa.attachment.domain.LeaveAttachment
|
||||
import com.all8ai.aioa.attachment.domain.LeaveAttachmentRepository
|
||||
import com.all8ai.aioa.attachment.domain.ObjectStorageGateway
|
||||
import com.all8ai.aioa.audit.application.AuditService
|
||||
import com.all8ai.aioa.identity.domain.CurrentUser
|
||||
import com.all8ai.aioa.shared.id.UuidV7
|
||||
import com.all8ai.aioa.shared.web.ApiException
|
||||
import org.springframework.http.HttpStatus
|
||||
import org.springframework.stereotype.Service
|
||||
import java.time.Instant
|
||||
import java.util.UUID
|
||||
import com.all8ai.aioa.shared.security.ToolPermission
|
||||
import com.all8ai.aioa.shared.security.requirePermission
|
||||
|
||||
@Service
|
||||
class LeaveAttachmentService(
|
||||
private val leaveRequests: LeaveRequestRepository,
|
||||
private val attachments: LeaveAttachmentRepository,
|
||||
private val storage: ObjectStorageGateway,
|
||||
private val auditService: AuditService,
|
||||
) {
|
||||
fun createUpload(actor: CurrentUser, leaveRequestId: UUID, command: CreateUploadCommand): AttachmentUpload {
|
||||
actor.requirePermission(ToolPermission.LEAVE_ATTACHMENT_MANAGE_OWN)
|
||||
requireOwnDraft(actor, leaveRequestId)
|
||||
val fileName = command.fileName.trim().takeIf { it.isNotEmpty() && it.length <= 255 }
|
||||
?: invalid("ATTACHMENT_NAME_INVALID", "附件名称无效")
|
||||
if (command.sizeBytes !in 1..MAX_SIZE_BYTES) invalid("ATTACHMENT_SIZE_INVALID", "附件不能超过 10 MB")
|
||||
if (command.contentType !in ALLOWED_CONTENT_TYPES) invalid("ATTACHMENT_TYPE_INVALID", "不支持该附件类型")
|
||||
val id = UuidV7.generate()
|
||||
val safeName = fileName.replace(Regex("[^A-Za-z0-9._-]"), "_")
|
||||
val objectKey = "${actor.tenantId}/leave/$leaveRequestId/$id-$safeName"
|
||||
val attachment = attachments.create(
|
||||
LeaveAttachment(id, actor.tenantId, leaveRequestId, actor.id, fileName, command.contentType,
|
||||
command.sizeBytes, objectKey, AttachmentStatus.PENDING, Instant.now(), null),
|
||||
)
|
||||
val uploadUrl = storage.createUploadUrl(objectKey)
|
||||
auditService.recordSuccess(actor, "LEAVE_ATTACHMENT_UPLOAD_CREATE", "LEAVE_ATTACHMENT", id.toString(), null,
|
||||
mapOf("leaveRequestId" to leaveRequestId, "sizeBytes" to command.sizeBytes, "contentType" to command.contentType))
|
||||
return AttachmentUpload(attachment, uploadUrl)
|
||||
}
|
||||
|
||||
fun complete(actor: CurrentUser, leaveRequestId: UUID, attachmentId: UUID): LeaveAttachment {
|
||||
actor.requirePermission(ToolPermission.LEAVE_ATTACHMENT_MANAGE_OWN)
|
||||
requireOwnDraft(actor, leaveRequestId)
|
||||
val attachment = find(actor, leaveRequestId, attachmentId)
|
||||
if (attachment.status == AttachmentStatus.READY) return attachment
|
||||
val stored = storage.stat(attachment.objectKey)
|
||||
if (stored.sizeBytes != attachment.sizeBytes) invalid("ATTACHMENT_SIZE_MISMATCH", "附件大小校验失败")
|
||||
if (stored.contentType != null && stored.contentType != attachment.contentType) {
|
||||
invalid("ATTACHMENT_TYPE_MISMATCH", "附件类型校验失败")
|
||||
}
|
||||
val completed = attachments.markReady(actor.tenantId, attachmentId)
|
||||
?: throw ApiException(HttpStatus.CONFLICT, "ATTACHMENT_STATE_CONFLICT", "附件状态已变化")
|
||||
auditService.recordSuccess(actor, "LEAVE_ATTACHMENT_COMPLETE", "LEAVE_ATTACHMENT", attachmentId.toString(), null,
|
||||
mapOf("leaveRequestId" to leaveRequestId, "sizeBytes" to completed.sizeBytes))
|
||||
return completed
|
||||
}
|
||||
|
||||
fun list(actor: CurrentUser, leaveRequestId: UUID): List<LeaveAttachment> {
|
||||
actor.requirePermission(ToolPermission.LEAVE_ATTACHMENT_MANAGE_OWN)
|
||||
requireOwn(actor, leaveRequestId)
|
||||
return attachments.list(actor.tenantId, leaveRequestId)
|
||||
}
|
||||
|
||||
fun download(actor: CurrentUser, leaveRequestId: UUID, attachmentId: UUID): String {
|
||||
actor.requirePermission(ToolPermission.LEAVE_ATTACHMENT_MANAGE_OWN)
|
||||
requireOwn(actor, leaveRequestId)
|
||||
val attachment = find(actor, leaveRequestId, attachmentId)
|
||||
if (attachment.status != AttachmentStatus.READY) invalid("ATTACHMENT_NOT_READY", "附件尚未上传完成")
|
||||
val downloadUrl = storage.createDownloadUrl(attachment.objectKey)
|
||||
auditService.recordSuccess(actor, "LEAVE_ATTACHMENT_DOWNLOAD", "LEAVE_ATTACHMENT", attachmentId.toString(), null,
|
||||
mapOf("leaveRequestId" to leaveRequestId))
|
||||
return downloadUrl
|
||||
}
|
||||
|
||||
fun delete(actor: CurrentUser, leaveRequestId: UUID, attachmentId: UUID) {
|
||||
actor.requirePermission(ToolPermission.LEAVE_ATTACHMENT_MANAGE_OWN)
|
||||
requireOwnDraft(actor, leaveRequestId)
|
||||
val attachment = find(actor, leaveRequestId, attachmentId)
|
||||
storage.delete(attachment.objectKey)
|
||||
attachments.delete(actor.tenantId, attachmentId)
|
||||
auditService.recordSuccess(actor, "LEAVE_ATTACHMENT_DELETE", "LEAVE_ATTACHMENT", attachmentId.toString(), null,
|
||||
mapOf("leaveRequestId" to leaveRequestId))
|
||||
}
|
||||
|
||||
private fun find(actor: CurrentUser, requestId: UUID, attachmentId: UUID) =
|
||||
attachments.find(actor.tenantId, requestId, attachmentId)
|
||||
?: throw ApiException(HttpStatus.NOT_FOUND, "ATTACHMENT_NOT_FOUND", "附件不存在")
|
||||
|
||||
private fun requireOwn(actor: CurrentUser, id: UUID) = leaveRequests.findOwn(actor.tenantId, actor.id, id)
|
||||
?: throw ApiException(HttpStatus.NOT_FOUND, "LEAVE_REQUEST_NOT_FOUND", "请假申请不存在")
|
||||
|
||||
private fun requireOwnDraft(actor: CurrentUser, id: UUID) {
|
||||
if (requireOwn(actor, id).status != LeaveStatus.DRAFT) {
|
||||
throw ApiException(HttpStatus.CONFLICT, "LEAVE_REQUEST_NOT_DRAFT", "只有草稿可以修改附件")
|
||||
}
|
||||
}
|
||||
|
||||
private fun invalid(code: String, message: String): Nothing = throw ApiException(HttpStatus.BAD_REQUEST, code, message)
|
||||
|
||||
companion object {
|
||||
const val MAX_SIZE_BYTES = 10L * 1024 * 1024
|
||||
val ALLOWED_CONTENT_TYPES = setOf("image/jpeg", "image/png", "application/pdf")
|
||||
}
|
||||
}
|
||||
|
||||
data class CreateUploadCommand(val fileName: String, val contentType: String, val sizeBytes: Long)
|
||||
data class AttachmentUpload(val attachment: LeaveAttachment, val uploadUrl: String)
|
||||
@@ -0,0 +1,37 @@
|
||||
package com.all8ai.aioa.attachment.domain
|
||||
|
||||
import java.time.Instant
|
||||
import java.util.UUID
|
||||
|
||||
enum class AttachmentStatus { PENDING, READY }
|
||||
|
||||
data class LeaveAttachment(
|
||||
val id: UUID,
|
||||
val tenantId: UUID,
|
||||
val leaveRequestId: UUID,
|
||||
val uploaderId: UUID,
|
||||
val fileName: String,
|
||||
val contentType: String,
|
||||
val sizeBytes: Long,
|
||||
val objectKey: String,
|
||||
val status: AttachmentStatus,
|
||||
val createdAt: Instant,
|
||||
val completedAt: Instant?,
|
||||
)
|
||||
|
||||
interface LeaveAttachmentRepository {
|
||||
fun create(attachment: LeaveAttachment): LeaveAttachment
|
||||
fun find(tenantId: UUID, leaveRequestId: UUID, attachmentId: UUID): LeaveAttachment?
|
||||
fun list(tenantId: UUID, leaveRequestId: UUID): List<LeaveAttachment>
|
||||
fun markReady(tenantId: UUID, attachmentId: UUID): LeaveAttachment?
|
||||
fun delete(tenantId: UUID, attachmentId: UUID): Boolean
|
||||
}
|
||||
|
||||
interface ObjectStorageGateway {
|
||||
fun createUploadUrl(objectKey: String): String
|
||||
fun stat(objectKey: String): StoredObject
|
||||
fun createDownloadUrl(objectKey: String): String
|
||||
fun delete(objectKey: String)
|
||||
}
|
||||
|
||||
data class StoredObject(val sizeBytes: Long, val contentType: String?)
|
||||
+66
@@ -0,0 +1,66 @@
|
||||
package com.all8ai.aioa.attachment.infrastructure
|
||||
|
||||
import com.all8ai.aioa.attachment.domain.AttachmentStatus
|
||||
import com.all8ai.aioa.attachment.domain.LeaveAttachment
|
||||
import com.all8ai.aioa.attachment.domain.LeaveAttachmentRepository
|
||||
import org.jooq.DSLContext
|
||||
import org.jooq.Record
|
||||
import org.springframework.stereotype.Repository
|
||||
import java.time.OffsetDateTime
|
||||
import java.util.UUID
|
||||
|
||||
@Repository
|
||||
class JooqLeaveAttachmentRepository(private val dsl: DSLContext) : LeaveAttachmentRepository {
|
||||
override fun create(attachment: LeaveAttachment): LeaveAttachment {
|
||||
dsl.execute(
|
||||
"""
|
||||
INSERT INTO business.leave_attachment (
|
||||
id, tenant_id, leave_request_id, uploader_id, file_name,
|
||||
content_type, size_bytes, object_key, status
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
""".trimIndent(),
|
||||
attachment.id, attachment.tenantId, attachment.leaveRequestId, attachment.uploaderId,
|
||||
attachment.fileName, attachment.contentType, attachment.sizeBytes, attachment.objectKey,
|
||||
attachment.status.name,
|
||||
)
|
||||
return find(attachment.tenantId, attachment.leaveRequestId, attachment.id)!!
|
||||
}
|
||||
|
||||
override fun find(tenantId: UUID, leaveRequestId: UUID, attachmentId: UUID): LeaveAttachment? =
|
||||
dsl.fetchOne(
|
||||
"SELECT * FROM business.leave_attachment WHERE tenant_id = ? AND leave_request_id = ? AND id = ?",
|
||||
tenantId, leaveRequestId, attachmentId,
|
||||
)?.let(::map)
|
||||
|
||||
override fun list(tenantId: UUID, leaveRequestId: UUID): List<LeaveAttachment> = dsl.fetch(
|
||||
"SELECT * FROM business.leave_attachment WHERE tenant_id = ? AND leave_request_id = ? ORDER BY created_at, id",
|
||||
tenantId, leaveRequestId,
|
||||
).map(::map)
|
||||
|
||||
override fun markReady(tenantId: UUID, attachmentId: UUID): LeaveAttachment? = dsl.fetchOne(
|
||||
"""
|
||||
UPDATE business.leave_attachment
|
||||
SET status = 'READY', completed_at = CURRENT_TIMESTAMP
|
||||
WHERE tenant_id = ? AND id = ? AND status = 'PENDING'
|
||||
RETURNING *
|
||||
""".trimIndent(),
|
||||
tenantId, attachmentId,
|
||||
)?.let(::map)
|
||||
|
||||
override fun delete(tenantId: UUID, attachmentId: UUID): Boolean =
|
||||
dsl.execute("DELETE FROM business.leave_attachment WHERE tenant_id = ? AND id = ?", tenantId, attachmentId) == 1
|
||||
|
||||
private fun map(record: Record) = LeaveAttachment(
|
||||
id = record.get("id", UUID::class.java)!!,
|
||||
tenantId = record.get("tenant_id", UUID::class.java)!!,
|
||||
leaveRequestId = record.get("leave_request_id", UUID::class.java)!!,
|
||||
uploaderId = record.get("uploader_id", UUID::class.java)!!,
|
||||
fileName = record.get("file_name", String::class.java)!!,
|
||||
contentType = record.get("content_type", String::class.java)!!,
|
||||
sizeBytes = record.get("size_bytes", Long::class.java)!!,
|
||||
objectKey = record.get("object_key", String::class.java)!!,
|
||||
status = AttachmentStatus.valueOf(record.get("status", String::class.java)!!),
|
||||
createdAt = record.get("created_at", OffsetDateTime::class.java)!!.toInstant(),
|
||||
completedAt = record.get("completed_at", OffsetDateTime::class.java)?.toInstant(),
|
||||
)
|
||||
}
|
||||
+51
@@ -0,0 +1,51 @@
|
||||
package com.all8ai.aioa.attachment.infrastructure
|
||||
|
||||
import com.all8ai.aioa.attachment.domain.ObjectStorageGateway
|
||||
import com.all8ai.aioa.attachment.domain.StoredObject
|
||||
import io.minio.BucketExistsArgs
|
||||
import io.minio.GetPresignedObjectUrlArgs
|
||||
import io.minio.MakeBucketArgs
|
||||
import io.minio.MinioClient
|
||||
import io.minio.RemoveObjectArgs
|
||||
import io.minio.StatObjectArgs
|
||||
import io.minio.http.Method
|
||||
import org.springframework.beans.factory.annotation.Value
|
||||
import org.springframework.stereotype.Component
|
||||
import java.util.concurrent.TimeUnit
|
||||
|
||||
@Component
|
||||
class MinioObjectStorageGateway(
|
||||
@Value("\${aioa.object-storage.endpoint}") endpoint: String,
|
||||
@Value("\${aioa.object-storage.access-key}") accessKey: String,
|
||||
@Value("\${aioa.object-storage.secret-key}") secretKey: String,
|
||||
@Value("\${aioa.object-storage.bucket}") private val bucket: String,
|
||||
) : ObjectStorageGateway {
|
||||
private val client = MinioClient.builder().endpoint(endpoint).credentials(accessKey, secretKey).build()
|
||||
|
||||
override fun createUploadUrl(objectKey: String): String {
|
||||
ensureBucket()
|
||||
return client.getPresignedObjectUrl(
|
||||
GetPresignedObjectUrlArgs.builder().method(Method.PUT).bucket(bucket).`object`(objectKey)
|
||||
.expiry(15, TimeUnit.MINUTES).build(),
|
||||
)
|
||||
}
|
||||
|
||||
override fun stat(objectKey: String): StoredObject = client.statObject(
|
||||
StatObjectArgs.builder().bucket(bucket).`object`(objectKey).build(),
|
||||
).let { StoredObject(it.size(), it.contentType()) }
|
||||
|
||||
override fun createDownloadUrl(objectKey: String): String = client.getPresignedObjectUrl(
|
||||
GetPresignedObjectUrlArgs.builder().method(Method.GET).bucket(bucket).`object`(objectKey)
|
||||
.expiry(5, TimeUnit.MINUTES).build(),
|
||||
)
|
||||
|
||||
override fun delete(objectKey: String) {
|
||||
client.removeObject(RemoveObjectArgs.builder().bucket(bucket).`object`(objectKey).build())
|
||||
}
|
||||
|
||||
private fun ensureBucket() {
|
||||
if (!client.bucketExists(BucketExistsArgs.builder().bucket(bucket).build())) {
|
||||
client.makeBucket(MakeBucketArgs.builder().bucket(bucket).build())
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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(),
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
package com.all8ai.aioa.forms.api
|
||||
|
||||
import com.all8ai.aioa.shared.web.ApiException
|
||||
import org.springframework.http.HttpStatus
|
||||
import org.springframework.web.bind.annotation.GetMapping
|
||||
import org.springframework.web.bind.annotation.PathVariable
|
||||
import org.springframework.web.bind.annotation.RequestMapping
|
||||
import org.springframework.web.bind.annotation.RestController
|
||||
|
||||
@RestController
|
||||
@RequestMapping("/api/v1/form-definitions")
|
||||
class FormDefinitionController {
|
||||
@GetMapping("/{formKey}")
|
||||
fun getDefinition(@PathVariable formKey: String): FormDefinitionResponse = when (formKey) {
|
||||
LEAVE_REQUEST_FORM_KEY -> leaveRequestDefinition
|
||||
else -> throw ApiException(HttpStatus.NOT_FOUND, "FORM_DEFINITION_NOT_FOUND", "Form definition not found")
|
||||
}
|
||||
|
||||
companion object {
|
||||
const val LEAVE_REQUEST_FORM_KEY = "leave-request"
|
||||
|
||||
val leaveRequestDefinition = FormDefinitionResponse(
|
||||
key = LEAVE_REQUEST_FORM_KEY,
|
||||
version = 1,
|
||||
dataSchema = mapOf(
|
||||
"\$id" to "leave-request-v1",
|
||||
"title" to "请假申请",
|
||||
"type" to "object",
|
||||
"required" to listOf("type", "startsAt", "endsAt", "reason"),
|
||||
"properties" to mapOf(
|
||||
"type" to mapOf("type" to "string", "enum" to listOf("PERSONAL", "SICK", "ANNUAL")),
|
||||
"startsAt" to mapOf("type" to "string", "format" to "date-time"),
|
||||
"endsAt" to mapOf("type" to "string", "format" to "date-time"),
|
||||
"reason" to mapOf("type" to "string", "minLength" to 1, "maxLength" to 2000),
|
||||
),
|
||||
),
|
||||
uiSchema = mapOf(
|
||||
"description" to "表单卡片由服务端 Schema 自动生成,字段、顺序、控件和校验均可版本化。",
|
||||
"sections" to listOf(
|
||||
mapOf(
|
||||
"title" to "请假信息",
|
||||
"controls" to listOf(
|
||||
mapOf(
|
||||
"field" to "type",
|
||||
"label" to "请假类型",
|
||||
"control" to "select",
|
||||
"optionLabels" to mapOf("PERSONAL" to "事假", "SICK" to "病假", "ANNUAL" to "年假"),
|
||||
),
|
||||
mapOf("field" to "startsAt", "label" to "开始时间", "control" to "dateTime"),
|
||||
mapOf("field" to "endsAt", "label" to "结束时间", "control" to "dateTime"),
|
||||
),
|
||||
),
|
||||
mapOf(
|
||||
"title" to "补充说明",
|
||||
"controls" to listOf(
|
||||
mapOf(
|
||||
"field" to "reason",
|
||||
"label" to "请假原因",
|
||||
"control" to "textArea",
|
||||
"placeholder" to "请简要说明请假原因",
|
||||
"helperText" to "AI 可以帮助整理表达,但提交前必须由你确认。",
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
data class FormDefinitionResponse(
|
||||
val key: String,
|
||||
val version: Int,
|
||||
val dataSchema: Map<String, Any>,
|
||||
val uiSchema: Map<String, Any>,
|
||||
)
|
||||
@@ -7,6 +7,9 @@ import org.springframework.web.bind.annotation.GetMapping
|
||||
import org.springframework.web.bind.annotation.RequestMapping
|
||||
import org.springframework.web.bind.annotation.RestController
|
||||
import java.util.UUID
|
||||
import com.all8ai.aioa.shared.security.AuthorizationPolicy
|
||||
import com.all8ai.aioa.shared.security.DataScope
|
||||
import com.all8ai.aioa.shared.security.ToolPermission
|
||||
|
||||
@RestController
|
||||
@RequestMapping("/api/v1/me")
|
||||
@@ -16,6 +19,7 @@ class CurrentUserController(
|
||||
@GetMapping
|
||||
fun currentUser(@AuthenticationPrincipal jwt: Jwt): CurrentUserResponse {
|
||||
val currentUser = currentUserService.get(jwt.subject, jwt.getClaimAsString("tenant_id"))
|
||||
val capabilities = AuthorizationPolicy.capabilities(currentUser)
|
||||
return CurrentUserResponse(
|
||||
id = currentUser.id,
|
||||
tenantId = currentUser.tenantId,
|
||||
@@ -25,6 +29,8 @@ class CurrentUserController(
|
||||
department = currentUser.department?.let { OrganizationRef(it.id, it.name) },
|
||||
position = currentUser.position?.let { OrganizationRef(it.id, it.name) },
|
||||
roles = currentUser.roles,
|
||||
permissions = capabilities.permissions,
|
||||
dataScopes = capabilities.dataScopes,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -38,6 +44,8 @@ data class CurrentUserResponse(
|
||||
val department: OrganizationRef?,
|
||||
val position: OrganizationRef?,
|
||||
val roles: Set<String>,
|
||||
val permissions: Set<ToolPermission>,
|
||||
val dataScopes: Set<DataScope>,
|
||||
)
|
||||
|
||||
data class OrganizationRef(
|
||||
|
||||
+49
@@ -0,0 +1,49 @@
|
||||
package com.all8ai.aioa.notification.api
|
||||
|
||||
import com.all8ai.aioa.identity.application.CurrentUserService
|
||||
import com.all8ai.aioa.notification.application.NotificationService
|
||||
import com.all8ai.aioa.notification.domain.Notification
|
||||
import org.springframework.security.core.annotation.AuthenticationPrincipal
|
||||
import org.springframework.security.oauth2.jwt.Jwt
|
||||
import org.springframework.web.bind.annotation.GetMapping
|
||||
import org.springframework.web.bind.annotation.PathVariable
|
||||
import org.springframework.web.bind.annotation.PostMapping
|
||||
import org.springframework.web.bind.annotation.RequestMapping
|
||||
import org.springframework.web.bind.annotation.RestController
|
||||
import java.time.Instant
|
||||
import java.util.UUID
|
||||
|
||||
@RestController
|
||||
@RequestMapping("/api/v1/notifications")
|
||||
class NotificationController(
|
||||
private val currentUserService: CurrentUserService,
|
||||
private val service: NotificationService,
|
||||
) {
|
||||
@GetMapping
|
||||
fun list(@AuthenticationPrincipal jwt: Jwt) = service.list(currentUser(jwt)).map(Notification::toResponse)
|
||||
|
||||
@GetMapping("/unread-count")
|
||||
fun unreadCount(@AuthenticationPrincipal jwt: Jwt) = UnreadCountResponse(service.unreadCount(currentUser(jwt)))
|
||||
|
||||
@PostMapping("/{id}/read")
|
||||
fun markRead(@AuthenticationPrincipal jwt: Jwt, @PathVariable id: UUID) =
|
||||
service.markRead(currentUser(jwt), id).toResponse()
|
||||
|
||||
private fun currentUser(jwt: Jwt) = currentUserService.get(jwt.subject, jwt.getClaimAsString("tenant_id"))
|
||||
}
|
||||
|
||||
data class UnreadCountResponse(val unreadCount: Int)
|
||||
data class NotificationResponse(
|
||||
val id: UUID,
|
||||
val type: String,
|
||||
val title: String,
|
||||
val body: String,
|
||||
val resourceType: String?,
|
||||
val resourceId: String?,
|
||||
val createdAt: Instant,
|
||||
val readAt: Instant?,
|
||||
)
|
||||
|
||||
private fun Notification.toResponse() = NotificationResponse(
|
||||
id, type, title, body, resourceType, resourceId, createdAt, readAt,
|
||||
)
|
||||
+59
@@ -0,0 +1,59 @@
|
||||
package com.all8ai.aioa.notification.application
|
||||
|
||||
import com.all8ai.aioa.audit.application.AuditService
|
||||
import com.all8ai.aioa.identity.domain.CurrentUser
|
||||
import com.all8ai.aioa.notification.domain.Notification
|
||||
import com.all8ai.aioa.notification.domain.NotificationRepository
|
||||
import com.all8ai.aioa.shared.id.UuidV7
|
||||
import com.all8ai.aioa.shared.web.ApiException
|
||||
import org.springframework.http.HttpStatus
|
||||
import org.springframework.stereotype.Service
|
||||
import java.time.Instant
|
||||
import java.util.UUID
|
||||
import com.all8ai.aioa.shared.security.ToolPermission
|
||||
import com.all8ai.aioa.shared.security.requirePermission
|
||||
import com.all8ai.aioa.notification.domain.PushOutboxRepository
|
||||
import org.springframework.transaction.annotation.Transactional
|
||||
|
||||
@Service
|
||||
class NotificationService(
|
||||
private val repository: NotificationRepository,
|
||||
private val auditService: AuditService,
|
||||
private val pushOutbox: PushOutboxRepository? = null,
|
||||
) {
|
||||
@Transactional
|
||||
fun notify(
|
||||
tenantId: UUID,
|
||||
recipientId: UUID,
|
||||
type: String,
|
||||
title: String,
|
||||
body: String,
|
||||
resourceType: String? = null,
|
||||
resourceId: String? = null,
|
||||
): Notification {
|
||||
val notification = repository.create(
|
||||
Notification(UuidV7.generate(), tenantId, recipientId, type, title, body,
|
||||
resourceType, resourceId, Instant.now(), null),
|
||||
)
|
||||
pushOutbox?.enqueue(notification.id)
|
||||
return notification
|
||||
}
|
||||
|
||||
fun list(actor: CurrentUser): List<Notification> {
|
||||
actor.requirePermission(ToolPermission.NOTIFICATION_READ_OWN)
|
||||
return repository.list(actor.tenantId, actor.id, 100)
|
||||
}
|
||||
|
||||
fun unreadCount(actor: CurrentUser): Int {
|
||||
actor.requirePermission(ToolPermission.NOTIFICATION_READ_OWN)
|
||||
return repository.countUnread(actor.tenantId, actor.id)
|
||||
}
|
||||
|
||||
fun markRead(actor: CurrentUser, id: UUID): Notification {
|
||||
actor.requirePermission(ToolPermission.NOTIFICATION_READ_OWN)
|
||||
val notification = repository.markRead(actor.tenantId, actor.id, id)
|
||||
?: throw ApiException(HttpStatus.NOT_FOUND, "NOTIFICATION_NOT_FOUND", "通知不存在")
|
||||
auditService.recordSuccess(actor, "NOTIFICATION_READ", "NOTIFICATION", id.toString(), null, emptyMap())
|
||||
return notification
|
||||
}
|
||||
}
|
||||
+43
@@ -0,0 +1,43 @@
|
||||
package com.all8ai.aioa.notification.application
|
||||
|
||||
import com.all8ai.aioa.device.domain.UserDeviceRepository
|
||||
import com.all8ai.aioa.notification.domain.*
|
||||
import org.springframework.scheduling.annotation.Scheduled
|
||||
import org.springframework.stereotype.Component
|
||||
|
||||
@Component
|
||||
class PushDispatcher(
|
||||
private val outbox: PushOutboxRepository,
|
||||
private val devices: UserDeviceRepository,
|
||||
private val gateway: PushGateway,
|
||||
) {
|
||||
@Scheduled(fixedDelayString = "\${aioa.push.dispatch-interval-ms:5000}")
|
||||
fun dispatch() {
|
||||
outbox.findPending(50).forEach(::dispatchOne)
|
||||
}
|
||||
|
||||
private fun dispatchOne(pending: PendingPush) {
|
||||
val notification = pending.notification
|
||||
val tokens = devices.listActivePushTokens(notification.tenantId, notification.recipientId)
|
||||
if (tokens.isEmpty()) return outbox.markDelivered(notification.id)
|
||||
var delivered = false
|
||||
var disabled = false
|
||||
var failed = false
|
||||
tokens.forEach { token ->
|
||||
when (gateway.send(token, notification)) {
|
||||
PushSendResult.DELIVERED -> delivered = true
|
||||
PushSendResult.INVALID_TOKEN -> devices.clearPushToken(token)
|
||||
PushSendResult.DISABLED -> disabled = true
|
||||
PushSendResult.FAILED -> failed = true
|
||||
}
|
||||
}
|
||||
when {
|
||||
delivered || (!disabled && !failed) -> outbox.markDelivered(notification.id)
|
||||
disabled -> outbox.reschedule(notification.id, pending.attempts, 3600, "Firebase push is not configured")
|
||||
else -> {
|
||||
val attempts = pending.attempts + 1
|
||||
outbox.reschedule(notification.id, attempts, minOf(3600, 1L shl minOf(attempts, 10)), "Firebase delivery failed")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
package com.all8ai.aioa.notification.domain
|
||||
|
||||
import java.time.Instant
|
||||
import java.util.UUID
|
||||
|
||||
data class Notification(
|
||||
val id: UUID,
|
||||
val tenantId: UUID,
|
||||
val recipientId: UUID,
|
||||
val type: String,
|
||||
val title: String,
|
||||
val body: String,
|
||||
val resourceType: String?,
|
||||
val resourceId: String?,
|
||||
val createdAt: Instant,
|
||||
val readAt: Instant?,
|
||||
)
|
||||
|
||||
interface NotificationRepository {
|
||||
fun create(notification: Notification): Notification
|
||||
fun list(tenantId: UUID, recipientId: UUID, limit: Int): List<Notification>
|
||||
fun countUnread(tenantId: UUID, recipientId: UUID): Int
|
||||
fun markRead(tenantId: UUID, recipientId: UUID, id: UUID): Notification?
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
package com.all8ai.aioa.notification.domain
|
||||
|
||||
import java.util.UUID
|
||||
|
||||
data class PendingPush(val notification: Notification, val attempts: Int)
|
||||
|
||||
interface PushOutboxRepository {
|
||||
fun enqueue(notificationId: UUID)
|
||||
fun findPending(limit: Int): List<PendingPush>
|
||||
fun markDelivered(notificationId: UUID)
|
||||
fun reschedule(notificationId: UUID, attempts: Int, delaySeconds: Long, error: String)
|
||||
}
|
||||
|
||||
enum class PushSendResult { DELIVERED, INVALID_TOKEN, DISABLED, FAILED }
|
||||
|
||||
fun interface PushGateway {
|
||||
fun send(token: String, notification: Notification): PushSendResult
|
||||
}
|
||||
+41
@@ -0,0 +1,41 @@
|
||||
package com.all8ai.aioa.notification.infrastructure
|
||||
|
||||
import com.google.auth.oauth2.GoogleCredentials
|
||||
import com.google.firebase.FirebaseApp
|
||||
import com.google.firebase.FirebaseOptions
|
||||
import com.google.firebase.messaging.FirebaseMessaging
|
||||
import com.google.firebase.messaging.FirebaseMessagingException
|
||||
import com.google.firebase.messaging.Message
|
||||
import com.all8ai.aioa.notification.domain.*
|
||||
import org.springframework.beans.factory.annotation.Value
|
||||
import org.springframework.stereotype.Component
|
||||
import java.io.FileInputStream
|
||||
|
||||
@Component
|
||||
class FirebasePushGateway(
|
||||
@Value("\${aioa.push.firebase-credentials-file:}") credentialsFile: String,
|
||||
) : PushGateway {
|
||||
private val messaging: FirebaseMessaging? = credentialsFile.trim().takeIf { it.isNotEmpty() }?.let { path ->
|
||||
val options = FileInputStream(path).use { FirebaseOptions.builder().setCredentials(GoogleCredentials.fromStream(it)).build() }
|
||||
FirebaseMessaging.getInstance(FirebaseApp.initializeApp(options, "aioa-push"))
|
||||
}
|
||||
|
||||
override fun send(token: String, notification: Notification): PushSendResult {
|
||||
val client = messaging ?: return PushSendResult.DISABLED
|
||||
val message = Message.builder().setToken(token)
|
||||
.setNotification(com.google.firebase.messaging.Notification.builder().setTitle(notification.title).setBody(notification.body).build())
|
||||
.putData("type", notification.type)
|
||||
.apply {
|
||||
notification.resourceType?.let { putData("resourceType", it) }
|
||||
notification.resourceId?.let { putData("resourceId", it) }
|
||||
}.build()
|
||||
return try {
|
||||
client.send(message)
|
||||
PushSendResult.DELIVERED
|
||||
} catch (exception: FirebaseMessagingException) {
|
||||
if (exception.messagingErrorCode?.name in setOf("UNREGISTERED", "INVALID_ARGUMENT")) PushSendResult.INVALID_TOKEN else PushSendResult.FAILED
|
||||
} catch (_: Exception) {
|
||||
PushSendResult.FAILED
|
||||
}
|
||||
}
|
||||
}
|
||||
+62
@@ -0,0 +1,62 @@
|
||||
package com.all8ai.aioa.notification.infrastructure
|
||||
|
||||
import com.all8ai.aioa.notification.domain.Notification
|
||||
import com.all8ai.aioa.notification.domain.NotificationRepository
|
||||
import org.jooq.DSLContext
|
||||
import org.jooq.Record
|
||||
import org.springframework.stereotype.Repository
|
||||
import java.time.OffsetDateTime
|
||||
import java.util.UUID
|
||||
|
||||
@Repository
|
||||
class JooqNotificationRepository(private val dsl: DSLContext) : NotificationRepository {
|
||||
override fun create(notification: Notification): Notification {
|
||||
dsl.execute(
|
||||
"""
|
||||
INSERT INTO communication.notification (
|
||||
id, tenant_id, recipient_id, type, title, body,
|
||||
resource_type, resource_id
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
||||
""".trimIndent(),
|
||||
notification.id, notification.tenantId, notification.recipientId,
|
||||
notification.type, notification.title, notification.body,
|
||||
notification.resourceType, notification.resourceId,
|
||||
)
|
||||
return notification
|
||||
}
|
||||
|
||||
override fun list(tenantId: UUID, recipientId: UUID, limit: Int): List<Notification> = dsl.fetch(
|
||||
"""
|
||||
SELECT * FROM communication.notification
|
||||
WHERE tenant_id = ? AND recipient_id = ?
|
||||
ORDER BY created_at DESC, id DESC LIMIT ?
|
||||
""".trimIndent(),
|
||||
tenantId, recipientId, limit,
|
||||
).map(::map)
|
||||
|
||||
override fun countUnread(tenantId: UUID, recipientId: UUID): Int = dsl.fetchOne(
|
||||
"SELECT COUNT(*) AS count FROM communication.notification WHERE tenant_id = ? AND recipient_id = ? AND read_at IS NULL",
|
||||
tenantId, recipientId,
|
||||
)!!.get("count", Int::class.java)!!
|
||||
|
||||
override fun markRead(tenantId: UUID, recipientId: UUID, id: UUID): Notification? = dsl.fetchOne(
|
||||
"""
|
||||
UPDATE communication.notification SET read_at = COALESCE(read_at, CURRENT_TIMESTAMP)
|
||||
WHERE tenant_id = ? AND recipient_id = ? AND id = ? RETURNING *
|
||||
""".trimIndent(),
|
||||
tenantId, recipientId, id,
|
||||
)?.let(::map)
|
||||
|
||||
private fun map(record: Record) = Notification(
|
||||
id = record.get("id", UUID::class.java)!!,
|
||||
tenantId = record.get("tenant_id", UUID::class.java)!!,
|
||||
recipientId = record.get("recipient_id", UUID::class.java)!!,
|
||||
type = record.get("type", String::class.java)!!,
|
||||
title = record.get("title", String::class.java)!!,
|
||||
body = record.get("body", String::class.java)!!,
|
||||
resourceType = record.get("resource_type", String::class.java),
|
||||
resourceId = record.get("resource_id", String::class.java),
|
||||
createdAt = record.get("created_at", OffsetDateTime::class.java)!!.toInstant(),
|
||||
readAt = record.get("read_at", OffsetDateTime::class.java)?.toInstant(),
|
||||
)
|
||||
}
|
||||
+47
@@ -0,0 +1,47 @@
|
||||
package com.all8ai.aioa.notification.infrastructure
|
||||
|
||||
import com.all8ai.aioa.notification.domain.*
|
||||
import org.jooq.DSLContext
|
||||
import org.jooq.Record
|
||||
import org.springframework.stereotype.Repository
|
||||
import java.time.OffsetDateTime
|
||||
import java.util.UUID
|
||||
|
||||
@Repository
|
||||
class JooqPushOutboxRepository(private val dsl: DSLContext) : PushOutboxRepository {
|
||||
override fun enqueue(notificationId: UUID) {
|
||||
dsl.execute("INSERT INTO communication.notification_push_outbox (notification_id) VALUES (?) ON CONFLICT DO NOTHING", notificationId)
|
||||
}
|
||||
|
||||
override fun findPending(limit: Int): List<PendingPush> = dsl.fetch(
|
||||
"""
|
||||
SELECT n.*, o.attempts FROM communication.notification_push_outbox o
|
||||
JOIN communication.notification n ON n.id = o.notification_id
|
||||
WHERE o.status = 'PENDING' AND o.next_attempt_at <= CURRENT_TIMESTAMP
|
||||
ORDER BY o.next_attempt_at, o.notification_id LIMIT ?
|
||||
""".trimIndent(), limit,
|
||||
).map(::map)
|
||||
|
||||
override fun markDelivered(notificationId: UUID) {
|
||||
dsl.execute("UPDATE communication.notification_push_outbox SET status = 'DELIVERED', delivered_at = CURRENT_TIMESTAMP WHERE notification_id = ?", notificationId)
|
||||
}
|
||||
|
||||
override fun reschedule(notificationId: UUID, attempts: Int, delaySeconds: Long, error: String) {
|
||||
dsl.execute(
|
||||
"UPDATE communication.notification_push_outbox SET attempts = ?, next_attempt_at = CURRENT_TIMESTAMP + (? * INTERVAL '1 second'), last_error = ? WHERE notification_id = ?",
|
||||
attempts, delaySeconds, error.take(500), notificationId,
|
||||
)
|
||||
}
|
||||
|
||||
private fun map(record: Record) = PendingPush(
|
||||
Notification(
|
||||
record.get("id", UUID::class.java)!!, record.get("tenant_id", UUID::class.java)!!,
|
||||
record.get("recipient_id", UUID::class.java)!!, record.get("type", String::class.java)!!,
|
||||
record.get("title", String::class.java)!!, record.get("body", String::class.java)!!,
|
||||
record.get("resource_type", String::class.java), record.get("resource_id", String::class.java),
|
||||
record.get("created_at", OffsetDateTime::class.java)!!.toInstant(),
|
||||
record.get("read_at", OffsetDateTime::class.java)?.toInstant(),
|
||||
),
|
||||
record.get("attempts", Int::class.java)!!,
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
package com.all8ai.aioa.shared.security
|
||||
|
||||
import com.all8ai.aioa.identity.domain.CurrentUser
|
||||
import com.all8ai.aioa.shared.web.ApiException
|
||||
import org.springframework.http.HttpStatus
|
||||
|
||||
enum class ToolPermission {
|
||||
LEAVE_REQUEST_READ_OWN,
|
||||
LEAVE_REQUEST_WRITE_OWN,
|
||||
LEAVE_ATTACHMENT_MANAGE_OWN,
|
||||
NOTIFICATION_READ_OWN,
|
||||
AI_LEAVE_DRAFT_SUGGEST,
|
||||
AI_LEAVE_PROGRESS_READ_OWN,
|
||||
APPROVAL_TASK_READ_ASSIGNED,
|
||||
APPROVAL_TASK_DECIDE_ASSIGNED,
|
||||
}
|
||||
|
||||
enum class DataScope { OWN, ASSIGNED }
|
||||
|
||||
data class UserCapabilities(
|
||||
val permissions: Set<ToolPermission>,
|
||||
val dataScopes: Set<DataScope>,
|
||||
)
|
||||
|
||||
object AuthorizationPolicy {
|
||||
private val employeePermissions = setOf(
|
||||
ToolPermission.LEAVE_REQUEST_READ_OWN,
|
||||
ToolPermission.LEAVE_REQUEST_WRITE_OWN,
|
||||
ToolPermission.LEAVE_ATTACHMENT_MANAGE_OWN,
|
||||
ToolPermission.NOTIFICATION_READ_OWN,
|
||||
ToolPermission.AI_LEAVE_DRAFT_SUGGEST,
|
||||
ToolPermission.AI_LEAVE_PROGRESS_READ_OWN,
|
||||
)
|
||||
private val approverRoles = setOf("department_manager", "oa_admin", "hr_reviewer")
|
||||
private val approvalPermissions = setOf(
|
||||
ToolPermission.APPROVAL_TASK_READ_ASSIGNED,
|
||||
ToolPermission.APPROVAL_TASK_DECIDE_ASSIGNED,
|
||||
)
|
||||
|
||||
fun capabilities(user: CurrentUser): UserCapabilities {
|
||||
val permissions = buildSet {
|
||||
if ("employee" in user.roles) addAll(employeePermissions)
|
||||
if (user.roles.any(approverRoles::contains)) addAll(approvalPermissions)
|
||||
}
|
||||
return UserCapabilities(
|
||||
permissions,
|
||||
buildSet {
|
||||
if (permissions.any { it.name.endsWith("_OWN") }) add(DataScope.OWN)
|
||||
if (permissions.any { it.name.endsWith("_ASSIGNED") }) add(DataScope.ASSIGNED)
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fun CurrentUser.requirePermission(permission: ToolPermission) {
|
||||
if (permission !in AuthorizationPolicy.capabilities(this).permissions) {
|
||||
throw ApiException(HttpStatus.FORBIDDEN, "PERMISSION_DENIED", "当前用户无权使用该功能")
|
||||
}
|
||||
}
|
||||
@@ -22,6 +22,9 @@ interface LeaveWorkflowGateway {
|
||||
fun completeTask(taskId: String, approved: Boolean, comment: String?): TaskCompletion
|
||||
|
||||
fun cancelProcess(processInstanceId: String, reason: String)
|
||||
|
||||
fun getProgress(processInstanceId: String): WorkflowProgress =
|
||||
error("Workflow progress is not supported")
|
||||
}
|
||||
|
||||
data class StartedProcess(
|
||||
@@ -43,3 +46,9 @@ data class TaskCompletion(
|
||||
val processInstanceId: String,
|
||||
val processEnded: Boolean,
|
||||
)
|
||||
|
||||
data class WorkflowProgress(
|
||||
val activeTaskNames: List<String>,
|
||||
val completedTaskNames: List<String>,
|
||||
val processEnded: Boolean,
|
||||
)
|
||||
|
||||
+16
@@ -4,6 +4,7 @@ import com.all8ai.aioa.workflow.domain.LeaveWorkflowGateway
|
||||
import com.all8ai.aioa.workflow.domain.StartedProcess
|
||||
import com.all8ai.aioa.workflow.domain.WorkflowTask
|
||||
import com.all8ai.aioa.workflow.domain.TaskCompletion
|
||||
import com.all8ai.aioa.workflow.domain.WorkflowProgress
|
||||
import org.flowable.engine.HistoryService
|
||||
import org.flowable.engine.RuntimeService
|
||||
import org.flowable.engine.TaskService
|
||||
@@ -96,6 +97,21 @@ class FlowableLeaveWorkflowGateway(
|
||||
runtimeService.deleteProcessInstance(processInstanceId, reason)
|
||||
}
|
||||
|
||||
override fun getProgress(processInstanceId: String): WorkflowProgress {
|
||||
val active = taskService.createTaskQuery()
|
||||
.processInstanceId(processInstanceId).active().list()
|
||||
.map { it.name }.distinct()
|
||||
val completed = historyService.createHistoricTaskInstanceQuery()
|
||||
.processInstanceId(processInstanceId).finished().orderByHistoricTaskInstanceEndTime().asc().list()
|
||||
.map { it.name }.distinct()
|
||||
return WorkflowProgress(
|
||||
activeTaskNames = active,
|
||||
completedTaskNames = completed,
|
||||
processEnded = runtimeService.createProcessInstanceQuery()
|
||||
.processInstanceId(processInstanceId).singleResult() == null,
|
||||
)
|
||||
}
|
||||
|
||||
private fun mapActiveTask(task: Task): WorkflowTask = WorkflowTask(
|
||||
id = task.id,
|
||||
name = task.name,
|
||||
|
||||
@@ -44,3 +44,15 @@ flowable:
|
||||
database-schema-update: ${FLOWABLE_SCHEMA_UPDATE:true}
|
||||
async-executor-activate: false
|
||||
history-level: audit
|
||||
|
||||
aioa:
|
||||
ai-service:
|
||||
url: ${AI_SERVICE_URL:http://127.0.0.1:8000}
|
||||
object-storage:
|
||||
endpoint: ${MINIO_ENDPOINT:http://127.0.0.1:9000}
|
||||
access-key: ${MINIO_ROOT_USER:minioadmin}
|
||||
secret-key: ${MINIO_ROOT_PASSWORD:change-me-now}
|
||||
bucket: ${MINIO_BUCKET:aioa-attachments}
|
||||
push:
|
||||
firebase-credentials-file: ${FIREBASE_CREDENTIALS_FILE:}
|
||||
dispatch-interval-ms: ${PUSH_DISPATCH_INTERVAL_MS:5000}
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
CREATE SCHEMA IF NOT EXISTS communication;
|
||||
|
||||
CREATE TABLE communication.notification (
|
||||
id UUID PRIMARY KEY,
|
||||
tenant_id UUID NOT NULL REFERENCES identity.tenant(id),
|
||||
recipient_id UUID NOT NULL,
|
||||
type VARCHAR(64) NOT NULL,
|
||||
title VARCHAR(200) NOT NULL,
|
||||
body VARCHAR(1000) NOT NULL,
|
||||
resource_type VARCHAR(64),
|
||||
resource_id VARCHAR(128),
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
read_at TIMESTAMPTZ,
|
||||
CONSTRAINT fk_notification_recipient FOREIGN KEY (tenant_id, recipient_id)
|
||||
REFERENCES identity.user_account(tenant_id, id)
|
||||
);
|
||||
|
||||
CREATE INDEX idx_notification_recipient_created
|
||||
ON communication.notification (tenant_id, recipient_id, created_at DESC, id DESC);
|
||||
|
||||
CREATE INDEX idx_notification_recipient_unread
|
||||
ON communication.notification (tenant_id, recipient_id)
|
||||
WHERE read_at IS NULL;
|
||||
@@ -0,0 +1,19 @@
|
||||
CREATE TABLE identity.user_device (
|
||||
id UUID NOT NULL,
|
||||
tenant_id UUID NOT NULL,
|
||||
user_id UUID NOT NULL,
|
||||
name VARCHAR(200) NOT NULL,
|
||||
platform VARCHAR(32) NOT NULL,
|
||||
app_version VARCHAR(64),
|
||||
status VARCHAR(32) NOT NULL DEFAULT 'ACTIVE',
|
||||
registered_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
last_seen_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
revoked_at TIMESTAMPTZ,
|
||||
PRIMARY KEY (tenant_id, user_id, id),
|
||||
CONSTRAINT fk_user_device_user FOREIGN KEY (tenant_id, user_id)
|
||||
REFERENCES identity.user_account(tenant_id, id),
|
||||
CONSTRAINT ck_user_device_status CHECK (status IN ('ACTIVE', 'REVOKED')),
|
||||
CONSTRAINT ck_user_device_platform CHECK (platform IN ('IOS', 'ANDROID', 'OTHER'))
|
||||
);
|
||||
|
||||
CREATE INDEX idx_user_device_owner ON identity.user_device (tenant_id, user_id, last_seen_at DESC);
|
||||
@@ -0,0 +1,21 @@
|
||||
ALTER TABLE identity.user_device
|
||||
ADD COLUMN push_token VARCHAR(4096),
|
||||
ADD COLUMN push_token_updated_at TIMESTAMPTZ;
|
||||
|
||||
CREATE UNIQUE INDEX uq_user_device_push_token
|
||||
ON identity.user_device (push_token)
|
||||
WHERE push_token IS NOT NULL AND status = 'ACTIVE';
|
||||
|
||||
CREATE TABLE communication.notification_push_outbox (
|
||||
notification_id UUID PRIMARY KEY REFERENCES communication.notification(id) ON DELETE CASCADE,
|
||||
status VARCHAR(32) NOT NULL DEFAULT 'PENDING',
|
||||
attempts INTEGER NOT NULL DEFAULT 0,
|
||||
next_attempt_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
last_error VARCHAR(500),
|
||||
delivered_at TIMESTAMPTZ,
|
||||
CONSTRAINT ck_push_outbox_status CHECK (status IN ('PENDING', 'DELIVERED'))
|
||||
);
|
||||
|
||||
CREATE INDEX idx_push_outbox_pending
|
||||
ON communication.notification_push_outbox (next_attempt_at, notification_id)
|
||||
WHERE status = 'PENDING';
|
||||
@@ -0,0 +1,23 @@
|
||||
CREATE TABLE business.leave_attachment (
|
||||
id UUID PRIMARY KEY,
|
||||
tenant_id UUID NOT NULL,
|
||||
leave_request_id UUID NOT NULL,
|
||||
uploader_id UUID NOT NULL,
|
||||
file_name VARCHAR(255) NOT NULL,
|
||||
content_type VARCHAR(128) NOT NULL,
|
||||
size_bytes BIGINT NOT NULL,
|
||||
object_key VARCHAR(1024) NOT NULL,
|
||||
status VARCHAR(32) NOT NULL,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
completed_at TIMESTAMPTZ,
|
||||
CONSTRAINT fk_leave_attachment_request FOREIGN KEY (tenant_id, leave_request_id)
|
||||
REFERENCES business.leave_request(tenant_id, id) ON DELETE CASCADE,
|
||||
CONSTRAINT fk_leave_attachment_uploader FOREIGN KEY (tenant_id, uploader_id)
|
||||
REFERENCES identity.user_account(tenant_id, id),
|
||||
CONSTRAINT ck_leave_attachment_status CHECK (status IN ('PENDING', 'READY')),
|
||||
CONSTRAINT ck_leave_attachment_size CHECK (size_bytes > 0 AND size_bytes <= 10485760),
|
||||
UNIQUE (tenant_id, object_key)
|
||||
);
|
||||
|
||||
CREATE INDEX idx_leave_attachment_request
|
||||
ON business.leave_attachment (tenant_id, leave_request_id, created_at, id);
|
||||
+49
@@ -0,0 +1,49 @@
|
||||
package com.all8ai.aioa.ai.application
|
||||
|
||||
import com.all8ai.aioa.ai.domain.AiLeaveDraftGateway
|
||||
import com.all8ai.aioa.ai.domain.LeaveDraftSuggestion
|
||||
import com.all8ai.aioa.ai.domain.SuggestedLeaveDraft
|
||||
import com.all8ai.aioa.audit.application.AuditService
|
||||
import com.all8ai.aioa.identity.domain.CurrentUser
|
||||
import com.all8ai.aioa.shared.web.ApiException
|
||||
import org.assertj.core.api.Assertions.assertThat
|
||||
import org.assertj.core.api.Assertions.assertThatThrownBy
|
||||
import org.junit.jupiter.api.Test
|
||||
import org.mockito.Mockito.mock
|
||||
import org.springframework.http.HttpStatus
|
||||
import java.time.Instant
|
||||
import java.util.UUID
|
||||
|
||||
class AiLeaveDraftServiceTest {
|
||||
private val actor = CurrentUser(UUID.randomUUID(), UUID.randomUUID(), "employee", "员工", null, null, null, setOf("employee"))
|
||||
|
||||
@Test
|
||||
fun `returns suggestion that still requires user confirmation`() {
|
||||
val gateway = AiLeaveDraftGateway {
|
||||
_, _ -> SuggestedLeaveDraft(
|
||||
LeaveDraftSuggestion("PERSONAL", Instant.parse("2026-07-19T05:30:00Z"),
|
||||
Instant.parse("2026-07-19T09:30:00Z"), "办理个人事务", emptyList(), emptyList()),
|
||||
"qwen-plus", true,
|
||||
)
|
||||
}
|
||||
val service = AiLeaveDraftService(gateway, mock(AuditService::class.java))
|
||||
|
||||
val result = service.suggest(actor, "明天下午请事假四小时", "Asia/Shanghai")
|
||||
|
||||
assertThat(result.suggestion.type).isEqualTo("PERSONAL")
|
||||
assertThat(result.requiresUserConfirmation).isTrue()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `rejects model response that could bypass confirmation`() {
|
||||
val gateway = AiLeaveDraftGateway { _, _ ->
|
||||
SuggestedLeaveDraft(LeaveDraftSuggestion(null, null, null, null, emptyList(), emptyList()), "qwen-plus", false)
|
||||
}
|
||||
val service = AiLeaveDraftService(gateway, mock(AuditService::class.java))
|
||||
|
||||
assertThatThrownBy { service.suggest(actor, "请假", "Asia/Shanghai") }
|
||||
.isInstanceOfSatisfying(ApiException::class.java) {
|
||||
assertThat(it.status).isEqualTo(HttpStatus.BAD_GATEWAY)
|
||||
}
|
||||
}
|
||||
}
|
||||
+81
@@ -0,0 +1,81 @@
|
||||
package com.all8ai.aioa.ai.application
|
||||
|
||||
import com.all8ai.aioa.ai.domain.*
|
||||
import com.all8ai.aioa.approval.domain.*
|
||||
import com.all8ai.aioa.audit.application.AuditService
|
||||
import com.all8ai.aioa.identity.domain.CurrentUser
|
||||
import com.all8ai.aioa.shared.web.ApiException
|
||||
import com.all8ai.aioa.workflow.domain.LeaveWorkflowGateway
|
||||
import com.all8ai.aioa.workflow.domain.WorkflowProgress
|
||||
import org.assertj.core.api.Assertions.assertThat
|
||||
import org.assertj.core.api.Assertions.assertThatThrownBy
|
||||
import org.junit.jupiter.api.Test
|
||||
import org.mockito.Mockito.mock
|
||||
import org.mockito.Mockito.verify
|
||||
import org.mockito.Mockito.`when`
|
||||
import java.time.Instant
|
||||
import java.util.UUID
|
||||
|
||||
class AiLeaveProgressServiceTest {
|
||||
private val actor = CurrentUser(UUID.randomUUID(), UUID.randomUUID(), "employee", "员工", null, null, null, setOf("employee"))
|
||||
|
||||
@Test
|
||||
fun `returns candidates without calling AI when request is ambiguous`() {
|
||||
val repository = mock(LeaveRequestRepository::class.java)
|
||||
`when`(repository.listOwn(actor.tenantId, actor.id, 100)).thenReturn(listOf(request(1), request(2)))
|
||||
val gateway = CapturingGateway()
|
||||
val service = service(repository, mock(LeaveWorkflowGateway::class.java), gateway)
|
||||
|
||||
val result = service.answer(actor, "我的请假到哪一步了", "Asia/Shanghai", null)
|
||||
|
||||
assertThat(result.requiresSelection).isTrue()
|
||||
assertThat(result.candidates).hasSize(2)
|
||||
assertThat(gateway.context).isNull()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `selected own request sends only minimal progress context to AI`() {
|
||||
val selected = request(1)
|
||||
val repository = mock(LeaveRequestRepository::class.java)
|
||||
val workflow = mock(LeaveWorkflowGateway::class.java)
|
||||
`when`(repository.listOwn(actor.tenantId, actor.id, 100)).thenReturn(listOf(selected))
|
||||
`when`(repository.listTimeline(actor.tenantId, selected.id)).thenReturn(emptyList())
|
||||
`when`(workflow.getProgress(selected.processInstanceId!!)).thenReturn(WorkflowProgress(listOf("主管审批"), listOf("提交申请"), false))
|
||||
val gateway = CapturingGateway()
|
||||
val service = service(repository, workflow, gateway)
|
||||
|
||||
val result = service.answer(actor, "进度如何", "Asia/Shanghai", selected.id)
|
||||
|
||||
assertThat(result.requiresSelection).isFalse()
|
||||
assertThat(result.answer).contains("审批中")
|
||||
assertThat(gateway.context!!.requestId).isEqualTo(selected.id)
|
||||
assertThat(gateway.context!!.activeTaskNames).containsExactly("主管审批")
|
||||
verify(repository).listTimeline(actor.tenantId, selected.id)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `rejects selected request that does not belong to current user`() {
|
||||
val repository = mock(LeaveRequestRepository::class.java)
|
||||
`when`(repository.listOwn(actor.tenantId, actor.id, 100)).thenReturn(listOf(request(1)))
|
||||
val service = service(repository, mock(LeaveWorkflowGateway::class.java), CapturingGateway())
|
||||
|
||||
assertThatThrownBy { service.answer(actor, "查询进度", "Asia/Shanghai", UUID.randomUUID()) }
|
||||
.isInstanceOfSatisfying(ApiException::class.java) { assertThat(it.code).isEqualTo("LEAVE_REQUEST_NOT_FOUND") }
|
||||
}
|
||||
|
||||
private fun service(repository: LeaveRequestRepository, workflow: LeaveWorkflowGateway, gateway: AiLeaveProgressGateway) =
|
||||
AiLeaveProgressService(repository, workflow, gateway, mock(AuditService::class.java))
|
||||
|
||||
private fun request(number: Int): LeaveRequest {
|
||||
val created = Instant.parse("2026-07-${17 + number}T01:00:00Z")
|
||||
return LeaveRequest(UUID.randomUUID(), actor.tenantId, actor.id, LeaveType.ANNUAL, created, created.plusSeconds(28800), "私人原因", LeaveStatus.PENDING, created, created, 1, "process-$number", "definition")
|
||||
}
|
||||
|
||||
private class CapturingGateway : AiLeaveProgressGateway {
|
||||
var context: LeaveProgressContext? = null
|
||||
override fun answer(question: String, timezone: String, context: LeaveProgressContext): GeneratedProgressAnswer {
|
||||
this.context = context
|
||||
return GeneratedProgressAnswer("当前状态为审批中,正在主管审批。", "qwen-plus")
|
||||
}
|
||||
}
|
||||
}
|
||||
+103
@@ -0,0 +1,103 @@
|
||||
package com.all8ai.aioa.attachment.application
|
||||
|
||||
import com.all8ai.aioa.approval.domain.LeaveRequest
|
||||
import com.all8ai.aioa.approval.domain.LeaveRequestRepository
|
||||
import com.all8ai.aioa.approval.domain.LeaveStatus
|
||||
import com.all8ai.aioa.approval.domain.LeaveType
|
||||
import com.all8ai.aioa.attachment.domain.AttachmentStatus
|
||||
import com.all8ai.aioa.attachment.domain.LeaveAttachment
|
||||
import com.all8ai.aioa.attachment.domain.LeaveAttachmentRepository
|
||||
import com.all8ai.aioa.attachment.domain.ObjectStorageGateway
|
||||
import com.all8ai.aioa.attachment.domain.StoredObject
|
||||
import com.all8ai.aioa.audit.application.AuditService
|
||||
import com.all8ai.aioa.identity.domain.CurrentUser
|
||||
import com.all8ai.aioa.shared.web.ApiException
|
||||
import org.assertj.core.api.Assertions.assertThat
|
||||
import org.assertj.core.api.Assertions.assertThatThrownBy
|
||||
import org.junit.jupiter.api.Test
|
||||
import org.mockito.Mockito.mock
|
||||
import org.mockito.Mockito.`when`
|
||||
import org.springframework.http.HttpStatus
|
||||
import java.time.Instant
|
||||
import java.util.UUID
|
||||
|
||||
class LeaveAttachmentServiceTest {
|
||||
private val actor = CurrentUser(UUID.randomUUID(), UUID.randomUUID(), "employee", "员工", null, null, null, setOf("employee"))
|
||||
private val requestId = UUID.randomUUID()
|
||||
|
||||
@Test
|
||||
fun `creates upload task only for an owned draft`() {
|
||||
val attachments = InMemoryAttachments()
|
||||
val storage = FakeStorage()
|
||||
val service = service(attachments, storage, LeaveStatus.DRAFT)
|
||||
|
||||
val upload = service.createUpload(actor, requestId, CreateUploadCommand("证明.pdf", "application/pdf", 1024))
|
||||
|
||||
assertThat(upload.attachment.status).isEqualTo(AttachmentStatus.PENDING)
|
||||
assertThat(upload.uploadUrl).startsWith("https://minio.test/upload/")
|
||||
assertThat(upload.attachment.objectKey).startsWith("${actor.tenantId}/leave/$requestId/")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `rejects executable attachment types`() {
|
||||
val service = service(InMemoryAttachments(), FakeStorage(), LeaveStatus.DRAFT)
|
||||
|
||||
assertThatThrownBy {
|
||||
service.createUpload(actor, requestId, CreateUploadCommand("tool.exe", "application/octet-stream", 100))
|
||||
}.isInstanceOfSatisfying(ApiException::class.java) {
|
||||
assertThat(it.status).isEqualTo(HttpStatus.BAD_REQUEST)
|
||||
assertThat(it.code).isEqualTo("ATTACHMENT_TYPE_INVALID")
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `verifies object size before marking attachment ready`() {
|
||||
val attachments = InMemoryAttachments()
|
||||
val storage = FakeStorage()
|
||||
val service = service(attachments, storage, LeaveStatus.DRAFT)
|
||||
val upload = service.createUpload(actor, requestId, CreateUploadCommand("photo.png", "image/png", 2048))
|
||||
storage.objects[upload.attachment.objectKey] = StoredObject(2048, "image/png")
|
||||
|
||||
val completed = service.complete(actor, requestId, upload.attachment.id)
|
||||
|
||||
assertThat(completed.status).isEqualTo(AttachmentStatus.READY)
|
||||
assertThat(completed.completedAt).isNotNull()
|
||||
}
|
||||
|
||||
private fun service(
|
||||
attachments: LeaveAttachmentRepository,
|
||||
storage: ObjectStorageGateway,
|
||||
status: LeaveStatus,
|
||||
): LeaveAttachmentService {
|
||||
val requests = mock(LeaveRequestRepository::class.java)
|
||||
val audit = mock(AuditService::class.java)
|
||||
`when`(requests.findOwn(actor.tenantId, actor.id, requestId)).thenReturn(
|
||||
LeaveRequest(requestId, actor.tenantId, actor.id, LeaveType.PERSONAL,
|
||||
Instant.parse("2026-07-20T01:00:00Z"), Instant.parse("2026-07-20T05:00:00Z"),
|
||||
"个人事务", status, Instant.now(), Instant.now(), 0),
|
||||
)
|
||||
return LeaveAttachmentService(requests, attachments, storage, audit)
|
||||
}
|
||||
|
||||
private class InMemoryAttachments : LeaveAttachmentRepository {
|
||||
private val values = linkedMapOf<UUID, LeaveAttachment>()
|
||||
override fun create(attachment: LeaveAttachment) = attachment.also { values[it.id] = it }
|
||||
override fun find(tenantId: UUID, leaveRequestId: UUID, attachmentId: UUID) =
|
||||
values[attachmentId]?.takeIf { it.tenantId == tenantId && it.leaveRequestId == leaveRequestId }
|
||||
override fun list(tenantId: UUID, leaveRequestId: UUID) =
|
||||
values.values.filter { it.tenantId == tenantId && it.leaveRequestId == leaveRequestId }
|
||||
override fun markReady(tenantId: UUID, attachmentId: UUID): LeaveAttachment? = values[attachmentId]
|
||||
?.takeIf { it.tenantId == tenantId && it.status == AttachmentStatus.PENDING }
|
||||
?.copy(status = AttachmentStatus.READY, completedAt = Instant.now())
|
||||
?.also { values[attachmentId] = it }
|
||||
override fun delete(tenantId: UUID, attachmentId: UUID) = values.remove(attachmentId) != null
|
||||
}
|
||||
|
||||
private class FakeStorage : ObjectStorageGateway {
|
||||
val objects = mutableMapOf<String, StoredObject>()
|
||||
override fun createUploadUrl(objectKey: String) = "https://minio.test/upload/$objectKey"
|
||||
override fun stat(objectKey: String) = objects[objectKey] ?: error("missing object")
|
||||
override fun createDownloadUrl(objectKey: String) = "https://minio.test/download/$objectKey"
|
||||
override fun delete(objectKey: String) { objects.remove(objectKey) }
|
||||
}
|
||||
}
|
||||
+42
@@ -0,0 +1,42 @@
|
||||
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.assertj.core.api.Assertions.assertThat
|
||||
import org.assertj.core.api.Assertions.assertThatThrownBy
|
||||
import org.junit.jupiter.api.Test
|
||||
import org.mockito.Mockito.mock
|
||||
import org.mockito.Mockito.`when`
|
||||
import java.time.Instant
|
||||
import java.util.UUID
|
||||
|
||||
class UserDeviceServiceTest {
|
||||
private val actor = CurrentUser(UUID.randomUUID(), UUID.randomUUID(), "employee", "员工", null, null, null, setOf("employee"))
|
||||
|
||||
@Test
|
||||
fun `registers active device for current user`() {
|
||||
val repository = mock(UserDeviceRepository::class.java)
|
||||
val id = UUID.randomUUID()
|
||||
val device = device(id, DeviceStatus.ACTIVE)
|
||||
`when`(repository.register(id, actor.tenantId, actor.id, "iPhone", DevicePlatform.IOS, "1.0.0")).thenReturn(device)
|
||||
|
||||
val result = service(repository).register(actor, id, "iPhone", DevicePlatform.IOS, "1.0.0")
|
||||
|
||||
assertThat(result).isEqualTo(device)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `revoked device cannot register again`() {
|
||||
val repository = mock(UserDeviceRepository::class.java)
|
||||
val id = UUID.randomUUID()
|
||||
`when`(repository.register(id, actor.tenantId, actor.id, "iPhone", DevicePlatform.IOS, null)).thenReturn(null)
|
||||
|
||||
assertThatThrownBy { service(repository).register(actor, id, "iPhone", DevicePlatform.IOS, null) }
|
||||
.isInstanceOfSatisfying(ApiException::class.java) { assertThat(it.code).isEqualTo("DEVICE_REVOKED") }
|
||||
}
|
||||
|
||||
private fun service(repository: UserDeviceRepository) = UserDeviceService(repository, mock(AuditService::class.java))
|
||||
private fun device(id: UUID, status: DeviceStatus) = UserDevice(id, actor.tenantId, actor.id, "iPhone", DevicePlatform.IOS, "1.0.0", status, Instant.now(), Instant.now(), null)
|
||||
}
|
||||
+29
@@ -0,0 +1,29 @@
|
||||
package com.all8ai.aioa.forms.api
|
||||
|
||||
import com.all8ai.aioa.shared.web.ApiException
|
||||
import org.assertj.core.api.Assertions.assertThat
|
||||
import org.junit.jupiter.api.Test
|
||||
import org.junit.jupiter.api.assertThrows
|
||||
import org.springframework.http.HttpStatus
|
||||
|
||||
class FormDefinitionControllerTest {
|
||||
private val controller = FormDefinitionController()
|
||||
|
||||
@Test
|
||||
fun `returns versioned leave request schemas`() {
|
||||
val response = controller.getDefinition("leave-request")
|
||||
|
||||
assertThat(response.key).isEqualTo("leave-request")
|
||||
assertThat(response.version).isEqualTo(1)
|
||||
assertThat(response.dataSchema["\$id"]).isEqualTo("leave-request-v1")
|
||||
assertThat(response.uiSchema["sections"]).isInstanceOf(List::class.java)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `rejects unknown form keys`() {
|
||||
val exception = assertThrows<ApiException> { controller.getDefinition("unknown") }
|
||||
|
||||
assertThat(exception.status).isEqualTo(HttpStatus.NOT_FOUND)
|
||||
assertThat(exception.code).isEqualTo("FORM_DEFINITION_NOT_FOUND")
|
||||
}
|
||||
}
|
||||
+72
@@ -0,0 +1,72 @@
|
||||
package com.all8ai.aioa.notification.application
|
||||
|
||||
import com.all8ai.aioa.audit.application.AuditService
|
||||
import com.all8ai.aioa.identity.domain.CurrentUser
|
||||
import com.all8ai.aioa.notification.domain.Notification
|
||||
import com.all8ai.aioa.notification.domain.NotificationRepository
|
||||
import com.all8ai.aioa.shared.web.ApiException
|
||||
import org.assertj.core.api.Assertions.assertThat
|
||||
import org.assertj.core.api.Assertions.assertThatThrownBy
|
||||
import org.junit.jupiter.api.Test
|
||||
import org.mockito.Mockito.mock
|
||||
import org.springframework.http.HttpStatus
|
||||
import java.time.Instant
|
||||
import java.util.UUID
|
||||
|
||||
class NotificationServiceTest {
|
||||
private val actor = CurrentUser(UUID.randomUUID(), UUID.randomUUID(), "employee", "员工", null, null, null, setOf("employee"))
|
||||
|
||||
@Test
|
||||
fun `lists only recipient notifications and tracks unread count`() {
|
||||
val repository = InMemoryNotifications()
|
||||
val service = NotificationService(repository, mock(AuditService::class.java))
|
||||
service.notify(actor.tenantId, actor.id, "LEAVE_APPROVED", "已通过", "申请已通过")
|
||||
service.notify(actor.tenantId, UUID.randomUUID(), "OTHER", "其他人", "不可见")
|
||||
|
||||
assertThat(service.list(actor)).hasSize(1)
|
||||
assertThat(service.unreadCount(actor)).isEqualTo(1)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `marks own notification read idempotently`() {
|
||||
val repository = InMemoryNotifications()
|
||||
val service = NotificationService(repository, mock(AuditService::class.java))
|
||||
val notification = service.notify(actor.tenantId, actor.id, "TASK", "待办", "请审批")
|
||||
|
||||
val read = service.markRead(actor, notification.id)
|
||||
val replay = service.markRead(actor, notification.id)
|
||||
|
||||
assertThat(read.readAt).isNotNull()
|
||||
assertThat(replay.readAt).isEqualTo(read.readAt)
|
||||
assertThat(service.unreadCount(actor)).isZero()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `does not reveal another users notification`() {
|
||||
val repository = InMemoryNotifications()
|
||||
val service = NotificationService(repository, mock(AuditService::class.java))
|
||||
val notification = service.notify(actor.tenantId, UUID.randomUUID(), "TASK", "待办", "不可见")
|
||||
|
||||
assertThatThrownBy { service.markRead(actor, notification.id) }
|
||||
.isInstanceOfSatisfying(ApiException::class.java) {
|
||||
assertThat(it.status).isEqualTo(HttpStatus.NOT_FOUND)
|
||||
}
|
||||
}
|
||||
|
||||
private class InMemoryNotifications : NotificationRepository {
|
||||
private val values = linkedMapOf<UUID, Notification>()
|
||||
override fun create(notification: Notification) = notification.also { values[it.id] = it }
|
||||
override fun list(tenantId: UUID, recipientId: UUID, limit: Int) = values.values
|
||||
.filter { it.tenantId == tenantId && it.recipientId == recipientId }
|
||||
.sortedByDescending { it.createdAt }
|
||||
.take(limit)
|
||||
override fun countUnread(tenantId: UUID, recipientId: UUID) = values.values.count {
|
||||
it.tenantId == tenantId && it.recipientId == recipientId && it.readAt == null
|
||||
}
|
||||
override fun markRead(tenantId: UUID, recipientId: UUID, id: UUID): Notification? = values[id]
|
||||
?.takeIf { it.tenantId == tenantId && it.recipientId == recipientId }
|
||||
?.let { existing ->
|
||||
existing.copy(readAt = existing.readAt ?: Instant.now()).also { values[id] = it }
|
||||
}
|
||||
}
|
||||
}
|
||||
+43
@@ -0,0 +1,43 @@
|
||||
package com.all8ai.aioa.notification.application
|
||||
|
||||
import com.all8ai.aioa.device.domain.UserDeviceRepository
|
||||
import com.all8ai.aioa.notification.domain.*
|
||||
import org.junit.jupiter.api.Test
|
||||
import org.mockito.Mockito.mock
|
||||
import org.mockito.Mockito.verify
|
||||
import org.mockito.Mockito.`when`
|
||||
import java.time.Instant
|
||||
import java.util.UUID
|
||||
|
||||
class PushDispatcherTest {
|
||||
@Test
|
||||
fun `delivers pending notification to active device token`() {
|
||||
val outbox = mock(PushOutboxRepository::class.java)
|
||||
val devices = mock(UserDeviceRepository::class.java)
|
||||
val gateway = mock(PushGateway::class.java)
|
||||
val notification = Notification(UUID.randomUUID(), UUID.randomUUID(), UUID.randomUUID(), "LEAVE_APPROVED", "已通过", "申请已通过", "LEAVE_REQUEST", "leave-1", Instant.now(), null)
|
||||
`when`(outbox.findPending(50)).thenReturn(listOf(PendingPush(notification, 0)))
|
||||
`when`(devices.listActivePushTokens(notification.tenantId, notification.recipientId)).thenReturn(listOf("token-1"))
|
||||
`when`(gateway.send("token-1", notification)).thenReturn(PushSendResult.DELIVERED)
|
||||
|
||||
PushDispatcher(outbox, devices, gateway).dispatch()
|
||||
|
||||
verify(outbox).markDelivered(notification.id)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `removes invalid device token and completes outbox item`() {
|
||||
val outbox = mock(PushOutboxRepository::class.java)
|
||||
val devices = mock(UserDeviceRepository::class.java)
|
||||
val gateway = mock(PushGateway::class.java)
|
||||
val notification = Notification(UUID.randomUUID(), UUID.randomUUID(), UUID.randomUUID(), "TASK", "待办", "新待办", null, null, Instant.now(), null)
|
||||
`when`(outbox.findPending(50)).thenReturn(listOf(PendingPush(notification, 0)))
|
||||
`when`(devices.listActivePushTokens(notification.tenantId, notification.recipientId)).thenReturn(listOf("expired"))
|
||||
`when`(gateway.send("expired", notification)).thenReturn(PushSendResult.INVALID_TOKEN)
|
||||
|
||||
PushDispatcher(outbox, devices, gateway).dispatch()
|
||||
|
||||
verify(devices).clearPushToken("expired")
|
||||
verify(outbox).markDelivered(notification.id)
|
||||
}
|
||||
}
|
||||
+39
@@ -0,0 +1,39 @@
|
||||
package com.all8ai.aioa.shared.security
|
||||
|
||||
import com.all8ai.aioa.identity.domain.CurrentUser
|
||||
import com.all8ai.aioa.shared.web.ApiException
|
||||
import org.assertj.core.api.Assertions.assertThat
|
||||
import org.assertj.core.api.Assertions.assertThatThrownBy
|
||||
import org.junit.jupiter.api.Test
|
||||
import java.util.UUID
|
||||
|
||||
class AuthorizationPolicyTest {
|
||||
@Test
|
||||
fun `employee receives only own-data tools`() {
|
||||
val capabilities = AuthorizationPolicy.capabilities(user(setOf("employee")))
|
||||
|
||||
assertThat(capabilities.dataScopes).containsExactly(DataScope.OWN)
|
||||
assertThat(capabilities.permissions).contains(ToolPermission.LEAVE_REQUEST_WRITE_OWN)
|
||||
assertThat(capabilities.permissions).doesNotContain(ToolPermission.APPROVAL_TASK_DECIDE_ASSIGNED)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `all approver roles receive assigned-task tools`() {
|
||||
for (role in setOf("department_manager", "oa_admin", "hr_reviewer")) {
|
||||
val capabilities = AuthorizationPolicy.capabilities(user(setOf("employee", role)))
|
||||
assertThat(capabilities.dataScopes).contains(DataScope.OWN, DataScope.ASSIGNED)
|
||||
assertThat(capabilities.permissions).contains(ToolPermission.APPROVAL_TASK_READ_ASSIGNED, ToolPermission.APPROVAL_TASK_DECIDE_ASSIGNED)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `unknown role cannot use employee tools`() {
|
||||
val actor = user(setOf("unknown"))
|
||||
assertThatThrownBy { actor.requirePermission(ToolPermission.LEAVE_REQUEST_READ_OWN) }
|
||||
.isInstanceOfSatisfying(ApiException::class.java) { assertThat(it.code).isEqualTo("PERMISSION_DENIED") }
|
||||
}
|
||||
|
||||
private fun user(roles: Set<String>) = CurrentUser(
|
||||
UUID.randomUUID(), UUID.randomUUID(), "user", "用户", null, null, null, roles,
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user