feat: complete leave approval MVP

This commit is contained in:
selfrelease
2026-07-18 19:20:07 +08:00
parent 2105fe3bac
commit 090a7e33ce
133 changed files with 7845 additions and 100 deletions
@@ -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,
)
@@ -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)
@@ -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)
@@ -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
}
@@ -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,
@@ -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,
)
@@ -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?)
@@ -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(),
)
}
@@ -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)
}
@@ -0,0 +1,39 @@
package com.all8ai.aioa.device.infrastructure
import com.all8ai.aioa.device.domain.UserDeviceRepository
import com.all8ai.aioa.identity.application.CurrentUserService
import jakarta.servlet.http.HttpServletRequest
import jakarta.servlet.http.HttpServletResponse
import org.springframework.http.MediaType
import org.springframework.security.core.context.SecurityContextHolder
import org.springframework.security.oauth2.jwt.Jwt
import org.springframework.stereotype.Component
import org.springframework.web.servlet.HandlerInterceptor
import java.util.UUID
@Component
class DeviceSessionInterceptor(
private val currentUserService: CurrentUserService,
private val devices: UserDeviceRepository,
) : HandlerInterceptor {
override fun preHandle(request: HttpServletRequest, response: HttpServletResponse, handler: Any): Boolean {
if (request.method == "OPTIONS" || !request.requestURI.startsWith("/api/v1/") || request.requestURI == "/api/v1/devices/register") return true
val jwt = SecurityContextHolder.getContext().authentication?.principal as? Jwt ?: return true
val actor = currentUserService.get(jwt.subject, jwt.getClaimAsString("tenant_id"))
val deviceId = request.getHeader(DEVICE_ID_HEADER)?.let { value ->
runCatching { UUID.fromString(value) }.getOrNull()
}
if (deviceId != null && devices.touchActive(actor.tenantId, actor.id, deviceId)) return true
response.status = HttpServletResponse.SC_UNAUTHORIZED
response.contentType = MediaType.APPLICATION_PROBLEM_JSON_VALUE
response.characterEncoding = Charsets.UTF_8.name()
response.setHeader(AUTH_ERROR_HEADER, "DEVICE_REVOKED")
response.writer.write("""{"title":"Unauthorized","status":401,"detail":"设备未注册或已被撤销","code":"DEVICE_REVOKED"}""")
return false
}
companion object {
const val DEVICE_ID_HEADER = "X-AIOA-Device-Id"
const val AUTH_ERROR_HEADER = "X-AIOA-Auth-Error"
}
}
@@ -0,0 +1,12 @@
package com.all8ai.aioa.device.infrastructure
import org.springframework.context.annotation.Configuration
import org.springframework.web.servlet.config.annotation.InterceptorRegistry
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer
@Configuration
class DeviceWebConfiguration(private val interceptor: DeviceSessionInterceptor) : WebMvcConfigurer {
override fun addInterceptors(registry: InterceptorRegistry) {
registry.addInterceptor(interceptor)
}
}
@@ -0,0 +1,68 @@
package com.all8ai.aioa.device.infrastructure
import com.all8ai.aioa.device.domain.*
import org.jooq.DSLContext
import org.jooq.Record
import org.springframework.stereotype.Repository
import java.time.OffsetDateTime
import java.util.UUID
@Repository
class JooqUserDeviceRepository(private val dsl: DSLContext) : UserDeviceRepository {
override fun register(id: UUID, tenantId: UUID, userId: UUID, name: String, platform: DevicePlatform, appVersion: String?): UserDevice? =
dsl.fetchOne(
"""
INSERT INTO identity.user_device (id, tenant_id, user_id, name, platform, app_version)
VALUES (?, ?, ?, ?, ?, ?)
ON CONFLICT (tenant_id, user_id, id) DO UPDATE SET
name = EXCLUDED.name, platform = EXCLUDED.platform,
app_version = EXCLUDED.app_version, last_seen_at = CURRENT_TIMESTAMP
WHERE user_device.status = 'ACTIVE'
RETURNING *
""".trimIndent(), id, tenantId, userId, name, platform.name, appVersion,
)?.let(::map)
override fun list(tenantId: UUID, userId: UUID): List<UserDevice> = dsl.fetch(
"SELECT * FROM identity.user_device WHERE tenant_id = ? AND user_id = ? ORDER BY last_seen_at DESC",
tenantId, userId,
).map(::map)
override fun revoke(tenantId: UUID, userId: UUID, id: UUID): UserDevice? = dsl.fetchOne(
"""
UPDATE identity.user_device SET status = 'REVOKED', revoked_at = COALESCE(revoked_at, CURRENT_TIMESTAMP)
WHERE tenant_id = ? AND user_id = ? AND id = ? RETURNING *
""".trimIndent(), tenantId, userId, id,
)?.let(::map)
override fun touchActive(tenantId: UUID, userId: UUID, id: UUID): Boolean = dsl.execute(
"UPDATE identity.user_device SET last_seen_at = CURRENT_TIMESTAMP WHERE tenant_id = ? AND user_id = ? AND id = ? AND status = 'ACTIVE'",
tenantId, userId, id,
) == 1
override fun updatePushToken(tenantId: UUID, userId: UUID, id: UUID, token: String?): Boolean = dsl.execute(
"UPDATE identity.user_device SET push_token = ?, push_token_updated_at = CURRENT_TIMESTAMP WHERE tenant_id = ? AND user_id = ? AND id = ? AND status = 'ACTIVE'",
token, tenantId, userId, id,
) == 1
override fun listActivePushTokens(tenantId: UUID, userId: UUID): List<String> = dsl.fetch(
"SELECT push_token FROM identity.user_device WHERE tenant_id = ? AND user_id = ? AND status = 'ACTIVE' AND push_token IS NOT NULL",
tenantId, userId,
).map { it.get("push_token", String::class.java)!! }
override fun clearPushToken(token: String) {
dsl.execute("UPDATE identity.user_device SET push_token = NULL, push_token_updated_at = CURRENT_TIMESTAMP WHERE push_token = ?", token)
}
private fun map(record: Record) = UserDevice(
record.get("id", UUID::class.java)!!,
record.get("tenant_id", UUID::class.java)!!,
record.get("user_id", UUID::class.java)!!,
record.get("name", String::class.java)!!,
DevicePlatform.valueOf(record.get("platform", String::class.java)!!),
record.get("app_version", String::class.java),
DeviceStatus.valueOf(record.get("status", String::class.java)!!),
record.get("registered_at", OffsetDateTime::class.java)!!.toInstant(),
record.get("last_seen_at", OffsetDateTime::class.java)!!.toInstant(),
record.get("revoked_at", OffsetDateTime::class.java)?.toInstant(),
)
}
@@ -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(
@@ -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,
)
@@ -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
}
}
@@ -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
}
@@ -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
}
}
}
@@ -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(),
)
}
@@ -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,
)
@@ -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);
@@ -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)
}
}
}
@@ -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")
}
}
}
@@ -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) }
}
}
@@ -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)
}
@@ -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")
}
}
@@ -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 }
}
}
}
@@ -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)
}
}
@@ -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,
)
}