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,