diff --git a/backend/README.md b/backend/README.md index 60a47c5..af54e8f 100644 --- a/backend/README.md +++ b/backend/README.md @@ -27,6 +27,9 @@ export GRADLE_USER_HOME=/tmp/aioa-gradle-home - 请假草稿创建、修改、本人查询和列表 - UUIDv7、写入幂等、租户隔离和乐观锁冲突处理 - 请假提交、撤回、状态时间线与事务内审计 +- Flowable 7.2 部门主管审批 BPMN、待办、批准和驳回 + +Flowable 开发环境自动维护 `flowable` Schema。生产环境必须设置 `FLOWABLE_SCHEMA_UPDATE=false`,并通过受控数据库变更流程管理 Flowable 表结构。 ## 开发身份 diff --git a/backend/boot/build.gradle.kts b/backend/boot/build.gradle.kts index ab7ed02..0d5dc69 100644 --- a/backend/boot/build.gradle.kts +++ b/backend/boot/build.gradle.kts @@ -27,6 +27,7 @@ dependencies { implementation("org.flywaydb:flyway-core") implementation("org.flywaydb:flyway-database-postgresql") implementation("org.jetbrains.kotlin:kotlin-reflect") + implementation("org.flowable:flowable-spring-boot-starter-process:7.2.0") runtimeOnly("org.postgresql:postgresql") testImplementation("org.springframework.boot:spring-boot-starter-test") diff --git a/backend/boot/src/main/kotlin/com/all8ai/aioa/approval/api/ApprovalTaskController.kt b/backend/boot/src/main/kotlin/com/all8ai/aioa/approval/api/ApprovalTaskController.kt new file mode 100644 index 0000000..799ad45 --- /dev/null +++ b/backend/boot/src/main/kotlin/com/all8ai/aioa/approval/api/ApprovalTaskController.kt @@ -0,0 +1,79 @@ +package com.all8ai.aioa.approval.api + +import com.all8ai.aioa.approval.application.ApprovalTask +import com.all8ai.aioa.approval.application.ApprovalTaskService +import com.all8ai.aioa.identity.application.CurrentUserService +import jakarta.validation.Valid +import jakarta.validation.constraints.PositiveOrZero +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.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.RequestHeader +import org.springframework.web.bind.annotation.RequestMapping +import org.springframework.web.bind.annotation.RestController +import java.time.Instant + +@RestController +@RequestMapping("/api/v1/approval-tasks") +class ApprovalTaskController( + private val currentUserService: CurrentUserService, + private val approvalTaskService: ApprovalTaskService, +) { + @GetMapping + fun listAssigned(@AuthenticationPrincipal jwt: Jwt): List = approvalTaskService + .listAssigned(currentUserService.get(jwt.subject, jwt.getClaimAsString("tenant_id"))) + .map(ApprovalTask::toResponse) + + @PostMapping("/{taskId}/approve") + fun approve( + @AuthenticationPrincipal jwt: Jwt, + @PathVariable taskId: String, + @RequestHeader("Idempotency-Key") idempotencyKey: String, + @Valid @RequestBody request: ApprovalDecisionRequest, + ): LeaveRequestResponse = approvalTaskService.approve( + currentUserService.get(jwt.subject, jwt.getClaimAsString("tenant_id")), + taskId, + idempotencyKey, + request.version, + request.comment, + ).toResponseModel() + + @PostMapping("/{taskId}/reject") + fun reject( + @AuthenticationPrincipal jwt: Jwt, + @PathVariable taskId: String, + @RequestHeader("Idempotency-Key") idempotencyKey: String, + @Valid @RequestBody request: ApprovalDecisionRequest, + ): LeaveRequestResponse = approvalTaskService.reject( + currentUserService.get(jwt.subject, jwt.getClaimAsString("tenant_id")), + taskId, + idempotencyKey, + request.version, + request.comment, + ).toResponseModel() +} + +data class ApprovalDecisionRequest( + @field:PositiveOrZero + val version: Long, + @field:Size(max = 1000) + val comment: String? = null, +) + +data class ApprovalTaskResponse( + val id: String, + val name: String, + val createdAt: Instant, + val leaveRequest: LeaveRequestResponse, +) + +private fun ApprovalTask.toResponse() = ApprovalTaskResponse( + id = id, + name = name, + createdAt = createdAt, + leaveRequest = leaveRequest.toResponseModel(), +) diff --git a/backend/boot/src/main/kotlin/com/all8ai/aioa/approval/api/LeaveRequestController.kt b/backend/boot/src/main/kotlin/com/all8ai/aioa/approval/api/LeaveRequestController.kt index 6b9f2b9..ed12bef 100644 --- a/backend/boot/src/main/kotlin/com/all8ai/aioa/approval/api/LeaveRequestController.kt +++ b/backend/boot/src/main/kotlin/com/all8ai/aioa/approval/api/LeaveRequestController.kt @@ -40,7 +40,7 @@ class LeaveRequestController( @Valid @RequestBody request: SaveDraftRequest, ): LeaveRequestResponse = leaveRequestService .createDraft(currentUserService.get(jwt.subject, jwt.getClaimAsString("tenant_id")), idempotencyKey, request.toCommand()) - .toResponse() + .toResponseModel() @PutMapping("/{id}") fun updateDraft( @@ -49,7 +49,7 @@ class LeaveRequestController( @Valid @RequestBody request: SaveDraftRequest, ): LeaveRequestResponse = leaveRequestService .updateDraft(currentUserService.get(jwt.subject, jwt.getClaimAsString("tenant_id")), id, request.toCommand()) - .toResponse() + .toResponseModel() @GetMapping("/{id}") fun getOwn( @@ -57,12 +57,12 @@ class LeaveRequestController( @PathVariable id: UUID, ): LeaveRequestResponse = leaveRequestService .getOwn(currentUserService.get(jwt.subject, jwt.getClaimAsString("tenant_id")), id) - .toResponse() + .toResponseModel() @GetMapping fun listOwn(@AuthenticationPrincipal jwt: Jwt): List = leaveRequestService .listOwn(currentUserService.get(jwt.subject, jwt.getClaimAsString("tenant_id"))) - .map(LeaveRequest::toResponse) + .map(LeaveRequest::toResponseModel) @PostMapping("/{id}/submit") fun submit( @@ -76,7 +76,7 @@ class LeaveRequestController( id, idempotencyKey, request.version, - ).toResponse() + ).toResponseModel() @PostMapping("/{id}/withdraw") fun withdraw( @@ -90,7 +90,7 @@ class LeaveRequestController( id, idempotencyKey, request.version, - ).toResponse() + ).toResponseModel() @GetMapping("/{id}/timeline") fun timeline( @@ -125,6 +125,8 @@ data class LeaveRequestResponse( val createdAt: Instant, val updatedAt: Instant, val version: Long, + val processInstanceId: String?, + val processDefinitionId: String?, ) data class TransitionRequest( @@ -142,7 +144,7 @@ data class LeaveRequestEventResponse( val occurredAt: Instant, ) -private fun LeaveRequest.toResponse() = LeaveRequestResponse( +internal fun LeaveRequest.toResponseModel() = LeaveRequestResponse( id = id, applicantId = applicantId, type = type, @@ -153,6 +155,8 @@ private fun LeaveRequest.toResponse() = LeaveRequestResponse( createdAt = createdAt, updatedAt = updatedAt, version = version, + processInstanceId = processInstanceId, + processDefinitionId = processDefinitionId, ) private fun LeaveRequestEvent.toResponse() = LeaveRequestEventResponse( diff --git a/backend/boot/src/main/kotlin/com/all8ai/aioa/approval/application/ApprovalTaskService.kt b/backend/boot/src/main/kotlin/com/all8ai/aioa/approval/application/ApprovalTaskService.kt new file mode 100644 index 0000000..97f6503 --- /dev/null +++ b/backend/boot/src/main/kotlin/com/all8ai/aioa/approval/application/ApprovalTaskService.kt @@ -0,0 +1,152 @@ +package com.all8ai.aioa.approval.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.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 com.all8ai.aioa.shared.web.TraceIdFilter +import com.all8ai.aioa.workflow.domain.LeaveWorkflowGateway +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 + +@Service +class ApprovalTaskService( + private val workflowGateway: LeaveWorkflowGateway, + private val leaveRequestRepository: LeaveRequestRepository, + private val auditService: AuditService, +) { + fun listAssigned(actor: CurrentUser): List = + 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( + actor: CurrentUser, + taskId: String, + idempotencyKey: String, + expectedVersion: Long, + comment: String?, + ): LeaveRequest = decide(actor, taskId, idempotencyKey, expectedVersion, comment, approved = true) + + @Transactional + fun reject( + actor: CurrentUser, + taskId: String, + idempotencyKey: String, + expectedVersion: Long, + comment: String?, + ): LeaveRequest = decide(actor, taskId, idempotencyKey, expectedVersion, comment, approved = false) + + private fun decide( + actor: CurrentUser, + taskId: String, + idempotencyKey: String, + expectedVersion: Long, + comment: String?, + approved: Boolean, + ): LeaveRequest { + validate(idempotencyKey, expectedVersion, comment) + val task = workflowGateway.resolveTask(taskId) + ?: throw ApiException(HttpStatus.NOT_FOUND, "APPROVAL_TASK_NOT_FOUND", "审批任务不存在") + if (task.assigneeId != actor.id) { + throw ApiException(HttpStatus.NOT_FOUND, "APPROVAL_TASK_NOT_FOUND", "审批任务不存在") + } + val request = leaveRequestRepository.findById(actor.tenantId, task.leaveRequestId) + ?: throw ApiException(HttpStatus.NOT_FOUND, "LEAVE_REQUEST_NOT_FOUND", "请假申请不存在") + if (request.applicantId == actor.id) { + throw ApiException(HttpStatus.CONFLICT, "SELF_APPROVAL_NOT_ALLOWED", "申请人不能审批自己的申请") + } + + val operation = if (approved) "LEAVE_APPROVE" else "LEAVE_REJECT" + val action = if (approved) "LEAVE_REQUEST_APPROVED" else "LEAVE_REQUEST_REJECTED" + val targetStatus = if (approved) LeaveStatus.APPROVED else LeaveStatus.REJECTED + val fingerprint = fingerprint(operation, taskId, expectedVersion, comment) + val claim = leaveRequestRepository.claimOperation( + UuidV7.generate(), actor.tenantId, actor.id, operation, + idempotencyKey, fingerprint, request.id, + ) + if (claim.fingerprint != fingerprint || claim.resourceId != request.id) { + throw ApiException(HttpStatus.CONFLICT, "IDEMPOTENCY_KEY_REUSED", "该幂等键已用于不同的操作参数") + } + if (!claim.claimed) { + return leaveRequestRepository.findById(actor.tenantId, request.id)!! + } + if (task.completed) { + throw ApiException(HttpStatus.CONFLICT, "APPROVAL_TASK_COMPLETED", "审批任务已处理") + } + + val transitioned = leaveRequestRepository.transitionForApprover( + tenantId = actor.tenantId, + actorId = actor.id, + id = request.id, + expectedVersion = expectedVersion, + toStatus = targetStatus, + eventId = UuidV7.generate(), + eventType = action, + traceId = MDC.get(TraceIdFilter.MDC_TRACE_ID) ?: "unknown", + ) ?: transitionFailure(actor, request.id, expectedVersion) + + workflowGateway.completeTask(taskId, approved, comment) + auditService.recordSuccess( + actor = actor, + action = action, + resourceType = "LEAVE_REQUEST", + resourceId = request.id.toString(), + idempotencyKey = idempotencyKey, + details = mapOf( + "taskId" to taskId, + "toStatus" to targetStatus.name, + "version" to transitioned.version, + "comment" to comment?.trim(), + ), + ) + return transitioned + } + + private fun transitionFailure(actor: CurrentUser, id: java.util.UUID, expectedVersion: Long): Nothing { + val existing = leaveRequestRepository.findById(actor.tenantId, id) + ?: throw ApiException(HttpStatus.NOT_FOUND, "LEAVE_REQUEST_NOT_FOUND", "请假申请不存在") + if (existing.status != LeaveStatus.PENDING) { + throw ApiException(HttpStatus.CONFLICT, "LEAVE_STATUS_INVALID", "当前申请状态不允许审批") + } + if (existing.version != expectedVersion) { + throw ApiException(HttpStatus.CONFLICT, "VERSION_CONFLICT", "申请已被其他操作更新,请刷新后重试") + } + throw ApiException(HttpStatus.CONFLICT, "LEAVE_TRANSITION_CONFLICT", "审批状态转换失败") + } + + private fun validate(idempotencyKey: String, version: Long, comment: String?) { + if (idempotencyKey.length !in 16..128 || !idempotencyKey.matches(Regex("[A-Za-z0-9._:-]+"))) { + throw ApiException(HttpStatus.BAD_REQUEST, "IDEMPOTENCY_KEY_INVALID", "幂等键格式无效") + } + if (version < 0) throw ApiException(HttpStatus.BAD_REQUEST, "VERSION_INVALID", "版本号不能小于零") + if (comment != null && comment.trim().length > 1000) { + throw ApiException(HttpStatus.BAD_REQUEST, "APPROVAL_COMMENT_INVALID", "审批意见不能超过 1000 个字符") + } + } + + private fun fingerprint(operation: String, taskId: String, version: Long, comment: String?): String { + val canonical = "$operation|$taskId|$version|${comment?.trim().orEmpty()}" + return MessageDigest.getInstance("SHA-256") + .digest(canonical.toByteArray(StandardCharsets.UTF_8)) + .joinToString("") { "%02x".format(it.toInt() and 0xff) } + } +} + +data class ApprovalTask( + val id: String, + val name: String, + val createdAt: java.time.Instant, + val leaveRequest: LeaveRequest, +) diff --git a/backend/boot/src/main/kotlin/com/all8ai/aioa/approval/application/LeaveRequestService.kt b/backend/boot/src/main/kotlin/com/all8ai/aioa/approval/application/LeaveRequestService.kt index 6d7519b..20cb11f 100644 --- a/backend/boot/src/main/kotlin/com/all8ai/aioa/approval/application/LeaveRequestService.kt +++ b/backend/boot/src/main/kotlin/com/all8ai/aioa/approval/application/LeaveRequestService.kt @@ -4,6 +4,7 @@ import com.all8ai.aioa.approval.domain.DraftContent import com.all8ai.aioa.approval.domain.LeaveRequest import com.all8ai.aioa.approval.domain.LeaveRequestEvent import com.all8ai.aioa.approval.domain.LeaveRequestRepository +import com.all8ai.aioa.approval.domain.ApprovalRoutingRepository import com.all8ai.aioa.approval.domain.LeaveStatus import com.all8ai.aioa.approval.domain.LeaveType import com.all8ai.aioa.identity.domain.CurrentUser @@ -15,6 +16,7 @@ import org.springframework.stereotype.Service 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 java.nio.charset.StandardCharsets import java.security.MessageDigest import java.time.Instant @@ -24,6 +26,8 @@ import java.util.UUID class LeaveRequestService( private val repository: LeaveRequestRepository, private val auditService: AuditService, + private val routingRepository: ApprovalRoutingRepository, + private val workflowGateway: LeaveWorkflowGateway, ) { fun createDraft(actor: CurrentUser, idempotencyKey: String, command: SaveDraftCommand): LeaveRequest { validateIdempotencyKey(idempotencyKey) @@ -66,8 +70,13 @@ class LeaveRequestService( fun listOwn(actor: CurrentUser): List = repository.listOwn(actor.tenantId, actor.id, 100) @Transactional - fun submit(actor: CurrentUser, id: UUID, idempotencyKey: String, expectedVersion: Long): LeaveRequest = - transition( + fun submit(actor: CurrentUser, id: UUID, idempotencyKey: String, expectedVersion: Long): LeaveRequest { + val approverId = routingRepository.findDepartmentManager(actor.tenantId, actor.id) + ?: throw ApiException(HttpStatus.CONFLICT, "APPROVER_NOT_FOUND", "未找到当前部门的有效主管") + if (approverId == actor.id) { + throw ApiException(HttpStatus.CONFLICT, "SELF_APPROVAL_NOT_ALLOWED", "申请人不能审批自己的申请") + } + val outcome = transition( actor = actor, id = id, idempotencyKey = idempotencyKey, @@ -77,10 +86,29 @@ class LeaveRequestService( fromStatus = LeaveStatus.DRAFT, toStatus = LeaveStatus.PENDING, ) + if (outcome.replayed) return outcome.leaveRequest + + val process = workflowGateway.startLeaveApproval( + actor.tenantId, + outcome.leaveRequest.id, + actor.id, + approverId, + ) + repository.attachWorkflow( + actor.tenantId, + outcome.leaveRequest.id, + process.processInstanceId, + process.processDefinitionId, + ) + return outcome.leaveRequest.copy( + processInstanceId = process.processInstanceId, + processDefinitionId = process.processDefinitionId, + ) + } @Transactional - fun withdraw(actor: CurrentUser, id: UUID, idempotencyKey: String, expectedVersion: Long): LeaveRequest = - transition( + fun withdraw(actor: CurrentUser, id: UUID, idempotencyKey: String, expectedVersion: Long): LeaveRequest { + val outcome = transition( actor = actor, id = id, idempotencyKey = idempotencyKey, @@ -90,6 +118,13 @@ class LeaveRequestService( fromStatus = LeaveStatus.PENDING, toStatus = LeaveStatus.WITHDRAWN, ) + if (!outcome.replayed) { + outcome.leaveRequest.processInstanceId?.let { + workflowGateway.cancelProcess(it, "Applicant withdrew leave request") + } + } + return outcome.leaveRequest + } fun timeline(actor: CurrentUser, id: UUID): List { getOwn(actor, id) @@ -105,7 +140,7 @@ class LeaveRequestService( action: String, fromStatus: LeaveStatus, toStatus: LeaveStatus, - ): LeaveRequest { + ): TransitionOutcome { validateIdempotencyKey(idempotencyKey) if (expectedVersion < 0) { throw ApiException(HttpStatus.BAD_REQUEST, "VERSION_INVALID", "版本号不能小于零") @@ -119,7 +154,7 @@ class LeaveRequestService( throw ApiException(HttpStatus.CONFLICT, "IDEMPOTENCY_KEY_REUSED", "该幂等键已用于不同的操作参数") } if (!claim.claimed) { - return getOwn(actor, id) + return TransitionOutcome(getOwn(actor, id), replayed = true) } val traceId = MDC.get(TraceIdFilter.MDC_TRACE_ID) ?: "unknown" @@ -140,7 +175,7 @@ class LeaveRequestService( "version" to transitioned.version, ), ) - return transitioned + return TransitionOutcome(transitioned, replayed = false) } private fun transitionFailure( @@ -192,6 +227,11 @@ class LeaveRequestService( } } +private data class TransitionOutcome( + val leaveRequest: LeaveRequest, + val replayed: Boolean, +) + data class SaveDraftCommand( val type: LeaveType, val startsAt: Instant, diff --git a/backend/boot/src/main/kotlin/com/all8ai/aioa/approval/domain/ApprovalRoutingRepository.kt b/backend/boot/src/main/kotlin/com/all8ai/aioa/approval/domain/ApprovalRoutingRepository.kt new file mode 100644 index 0000000..1149f29 --- /dev/null +++ b/backend/boot/src/main/kotlin/com/all8ai/aioa/approval/domain/ApprovalRoutingRepository.kt @@ -0,0 +1,7 @@ +package com.all8ai.aioa.approval.domain + +import java.util.UUID + +fun interface ApprovalRoutingRepository { + fun findDepartmentManager(tenantId: UUID, applicantId: UUID): UUID? +} diff --git a/backend/boot/src/main/kotlin/com/all8ai/aioa/approval/domain/LeaveRequest.kt b/backend/boot/src/main/kotlin/com/all8ai/aioa/approval/domain/LeaveRequest.kt index 3b9b722..cd11e8d 100644 --- a/backend/boot/src/main/kotlin/com/all8ai/aioa/approval/domain/LeaveRequest.kt +++ b/backend/boot/src/main/kotlin/com/all8ai/aioa/approval/domain/LeaveRequest.kt @@ -29,6 +29,8 @@ data class LeaveRequest( val createdAt: Instant, val updatedAt: Instant, val version: Long, + val processInstanceId: String? = null, + val processDefinitionId: String? = null, ) data class DraftContent( @@ -83,6 +85,26 @@ interface LeaveRequestRepository { ): LeaveRequest? fun listTimeline(tenantId: UUID, leaveRequestId: UUID): List + + fun attachWorkflow( + tenantId: UUID, + leaveRequestId: UUID, + processInstanceId: String, + processDefinitionId: String, + ) + + fun findById(tenantId: UUID, id: UUID): LeaveRequest? + + fun transitionForApprover( + tenantId: UUID, + actorId: UUID, + id: UUID, + expectedVersion: Long, + toStatus: LeaveStatus, + eventId: UUID, + eventType: String, + traceId: String, + ): LeaveRequest? } data class IdempotentDraft( diff --git a/backend/boot/src/main/kotlin/com/all8ai/aioa/approval/infrastructure/JooqApprovalRoutingRepository.kt b/backend/boot/src/main/kotlin/com/all8ai/aioa/approval/infrastructure/JooqApprovalRoutingRepository.kt new file mode 100644 index 0000000..e4975aa --- /dev/null +++ b/backend/boot/src/main/kotlin/com/all8ai/aioa/approval/infrastructure/JooqApprovalRoutingRepository.kt @@ -0,0 +1,42 @@ +package com.all8ai.aioa.approval.infrastructure + +import com.all8ai.aioa.approval.domain.ApprovalRoutingRepository +import org.jooq.DSLContext +import org.springframework.stereotype.Repository +import java.util.UUID + +@Repository +class JooqApprovalRoutingRepository( + private val dsl: DSLContext, +) : ApprovalRoutingRepository { + override fun findDepartmentManager(tenantId: UUID, applicantId: UUID): UUID? = + dsl.fetchOne( + """ + SELECT manager.user_id + FROM organization.user_assignment applicant + JOIN organization.user_assignment manager + ON manager.tenant_id = applicant.tenant_id + AND manager.department_id = applicant.department_id + AND manager.effective_from <= CURRENT_TIMESTAMP + AND (manager.effective_until IS NULL OR manager.effective_until > CURRENT_TIMESTAMP) + JOIN organization.position position + ON position.tenant_id = manager.tenant_id + AND position.id = manager.position_id + AND position.code = 'manager' + AND position.status = 'ACTIVE' + JOIN identity.user_account account + ON account.tenant_id = manager.tenant_id + AND account.id = manager.user_id + AND account.status = 'ACTIVE' + WHERE applicant.tenant_id = ? + AND applicant.user_id = ? + AND applicant.is_primary = TRUE + AND applicant.effective_from <= CURRENT_TIMESTAMP + AND (applicant.effective_until IS NULL OR applicant.effective_until > CURRENT_TIMESTAMP) + ORDER BY manager.is_primary DESC, manager.created_at + LIMIT 1 + """.trimIndent(), + tenantId, + applicantId, + )?.get("user_id", UUID::class.java) +} diff --git a/backend/boot/src/main/kotlin/com/all8ai/aioa/approval/infrastructure/JooqLeaveRequestRepository.kt b/backend/boot/src/main/kotlin/com/all8ai/aioa/approval/infrastructure/JooqLeaveRequestRepository.kt index d630f7f..82f8cd3 100644 --- a/backend/boot/src/main/kotlin/com/all8ai/aioa/approval/infrastructure/JooqLeaveRequestRepository.kt +++ b/backend/boot/src/main/kotlin/com/all8ai/aioa/approval/infrastructure/JooqLeaveRequestRepository.kt @@ -221,6 +221,76 @@ class JooqLeaveRequestRepository( ) } + override fun attachWorkflow( + tenantId: UUID, + leaveRequestId: UUID, + processInstanceId: String, + processDefinitionId: String, + ) { + val updated = dsl.execute( + """ + UPDATE business.leave_request + SET process_instance_id = ?, process_definition_id = ? + WHERE tenant_id = ? AND id = ? AND status = 'PENDING' + AND process_instance_id IS NULL + """.trimIndent(), + processInstanceId, + processDefinitionId, + tenantId, + leaveRequestId, + ) + check(updated == 1) { "Unable to attach workflow to leave request $leaveRequestId" } + } + + override fun findById(tenantId: UUID, id: UUID): LeaveRequest? = + dsl.fetchOne( + "SELECT * FROM business.leave_request WHERE tenant_id = ? AND id = ?", + tenantId, + id, + )?.let(::map) + + override fun transitionForApprover( + tenantId: UUID, + actorId: UUID, + id: UUID, + expectedVersion: Long, + toStatus: LeaveStatus, + eventId: UUID, + eventType: String, + traceId: String, + ): LeaveRequest? { + val transitioned = dsl.fetchOne( + """ + UPDATE business.leave_request + SET status = ?, completed_at = CURRENT_TIMESTAMP, + updated_at = CURRENT_TIMESTAMP, version = version + 1 + WHERE tenant_id = ? AND id = ? AND status = 'PENDING' AND version = ? + RETURNING * + """.trimIndent(), + toStatus.name, + tenantId, + id, + expectedVersion, + )?.let(::map) ?: return null + + dsl.execute( + """ + INSERT INTO business.leave_request_event ( + id, tenant_id, leave_request_id, actor_id, event_type, + from_status, to_status, trace_id + ) VALUES (?, ?, ?, ?, ?, 'PENDING', ?, ?) + """.trimIndent(), + eventId, + tenantId, + id, + actorId, + eventType, + toStatus.name, + traceId, + ) + return transitioned + } + private fun map(record: Record): LeaveRequest = LeaveRequest( id = record.get("id", UUID::class.java)!!, tenantId = record.get("tenant_id", UUID::class.java)!!, @@ -233,5 +303,7 @@ class JooqLeaveRequestRepository( createdAt = record.get("created_at", OffsetDateTime::class.java)!!.toInstant(), updatedAt = record.get("updated_at", OffsetDateTime::class.java)!!.toInstant(), version = record.get("version", Long::class.java)!!, + processInstanceId = record.get("process_instance_id", String::class.java), + processDefinitionId = record.get("process_definition_id", String::class.java), ) } diff --git a/backend/boot/src/main/kotlin/com/all8ai/aioa/workflow/domain/LeaveWorkflowGateway.kt b/backend/boot/src/main/kotlin/com/all8ai/aioa/workflow/domain/LeaveWorkflowGateway.kt new file mode 100644 index 0000000..a1a30e2 --- /dev/null +++ b/backend/boot/src/main/kotlin/com/all8ai/aioa/workflow/domain/LeaveWorkflowGateway.kt @@ -0,0 +1,36 @@ +package com.all8ai.aioa.workflow.domain + +import java.time.Instant +import java.util.UUID + +interface LeaveWorkflowGateway { + fun startLeaveApproval( + tenantId: UUID, + leaveRequestId: UUID, + applicantId: UUID, + approverId: UUID, + ): StartedProcess + + fun listAssignedTasks(assigneeId: UUID): List + + fun resolveTask(taskId: String): WorkflowTask? + + fun completeTask(taskId: String, approved: Boolean, comment: String?) + + fun cancelProcess(processInstanceId: String, reason: String) +} + +data class StartedProcess( + val processInstanceId: String, + val processDefinitionId: String, +) + +data class WorkflowTask( + val id: String, + val name: String, + val processInstanceId: String, + val leaveRequestId: UUID, + val assigneeId: UUID, + val createdAt: Instant, + val completed: Boolean, +) diff --git a/backend/boot/src/main/kotlin/com/all8ai/aioa/workflow/infrastructure/FlowableLeaveWorkflowGateway.kt b/backend/boot/src/main/kotlin/com/all8ai/aioa/workflow/infrastructure/FlowableLeaveWorkflowGateway.kt new file mode 100644 index 0000000..28a19aa --- /dev/null +++ b/backend/boot/src/main/kotlin/com/all8ai/aioa/workflow/infrastructure/FlowableLeaveWorkflowGateway.kt @@ -0,0 +1,99 @@ +package com.all8ai.aioa.workflow.infrastructure + +import com.all8ai.aioa.workflow.domain.LeaveWorkflowGateway +import com.all8ai.aioa.workflow.domain.StartedProcess +import com.all8ai.aioa.workflow.domain.WorkflowTask +import org.flowable.engine.HistoryService +import org.flowable.engine.RuntimeService +import org.flowable.engine.TaskService +import org.flowable.task.api.Task +import org.springframework.stereotype.Component +import java.util.UUID + +@Component +class FlowableLeaveWorkflowGateway( + private val runtimeService: RuntimeService, + private val taskService: TaskService, + private val historyService: HistoryService, +) : LeaveWorkflowGateway { + override fun startLeaveApproval( + tenantId: UUID, + leaveRequestId: UUID, + applicantId: UUID, + approverId: UUID, + ): StartedProcess { + val process = runtimeService.createProcessInstanceBuilder() + .processDefinitionKey(PROCESS_DEFINITION_KEY) + .businessKey(leaveRequestId.toString()) + .variables( + mapOf( + "tenantId" to tenantId.toString(), + "businessId" to leaveRequestId.toString(), + "applicantId" to applicantId.toString(), + "approverId" to approverId.toString(), + ), + ) + .start() + return StartedProcess(process.id, process.processDefinitionId) + } + + override fun listAssignedTasks(assigneeId: UUID): List = + taskService.createTaskQuery() + .taskAssignee(assigneeId.toString()) + .active() + .includeProcessVariables() + .orderByTaskCreateTime() + .desc() + .list() + .map(::mapActiveTask) + + override fun resolveTask(taskId: String): WorkflowTask? { + taskService.createTaskQuery() + .taskId(taskId) + .includeProcessVariables() + .singleResult() + ?.let { return mapActiveTask(it) } + + val historic = historyService.createHistoricTaskInstanceQuery() + .taskId(taskId) + .includeProcessVariables() + .singleResult() + ?: return null + return WorkflowTask( + id = historic.id, + name = historic.name, + processInstanceId = historic.processInstanceId, + leaveRequestId = UUID.fromString(historic.processVariables["businessId"] as String), + assigneeId = UUID.fromString(historic.assignee), + createdAt = historic.createTime.toInstant(), + completed = historic.endTime != null, + ) + } + + override fun completeTask(taskId: String, approved: Boolean, comment: String?) { + if (!comment.isNullOrBlank()) { + val task = taskService.createTaskQuery().taskId(taskId).singleResult() + ?: error("Active workflow task not found: $taskId") + taskService.addComment(taskId, task.processInstanceId, comment.trim()) + } + taskService.complete(taskId, mapOf("approved" to approved)) + } + + override fun cancelProcess(processInstanceId: String, reason: String) { + runtimeService.deleteProcessInstance(processInstanceId, reason) + } + + private fun mapActiveTask(task: Task): WorkflowTask = WorkflowTask( + id = task.id, + name = task.name, + processInstanceId = task.processInstanceId, + leaveRequestId = UUID.fromString(task.processVariables["businessId"] as String), + assigneeId = UUID.fromString(task.assignee), + createdAt = task.createTime.toInstant(), + completed = false, + ) + + companion object { + const val PROCESS_DEFINITION_KEY = "leaveApproval" + } +} diff --git a/backend/boot/src/main/resources/application.yaml b/backend/boot/src/main/resources/application.yaml index 01e44e9..8c02559 100644 --- a/backend/boot/src/main/resources/application.yaml +++ b/backend/boot/src/main/resources/application.yaml @@ -38,3 +38,9 @@ management: logging: pattern: correlation: "[traceId=%X{traceId:-}] " + +flowable: + database-schema: flowable + database-schema-update: ${FLOWABLE_SCHEMA_UPDATE:true} + async-executor-activate: false + history-level: audit diff --git a/backend/boot/src/main/resources/db/migration/V6__link_leave_requests_to_flowable.sql b/backend/boot/src/main/resources/db/migration/V6__link_leave_requests_to_flowable.sql new file mode 100644 index 0000000..45d19bd --- /dev/null +++ b/backend/boot/src/main/resources/db/migration/V6__link_leave_requests_to_flowable.sql @@ -0,0 +1,7 @@ +ALTER TABLE business.leave_request + ADD COLUMN process_instance_id VARCHAR(64), + ADD COLUMN process_definition_id VARCHAR(128); + +CREATE UNIQUE INDEX uq_leave_request_process_instance + ON business.leave_request (process_instance_id) + WHERE process_instance_id IS NOT NULL; diff --git a/backend/boot/src/main/resources/processes/leave-approval.bpmn20.xml b/backend/boot/src/main/resources/processes/leave-approval.bpmn20.xml new file mode 100644 index 0000000..d39cd3c --- /dev/null +++ b/backend/boot/src/main/resources/processes/leave-approval.bpmn20.xml @@ -0,0 +1,26 @@ + + + + + + + + + + + + + + + + + + + + + diff --git a/backend/boot/src/test/kotlin/com/all8ai/aioa/approval/application/LeaveRequestServiceTest.kt b/backend/boot/src/test/kotlin/com/all8ai/aioa/approval/application/LeaveRequestServiceTest.kt index b342cec..fc69865 100644 --- a/backend/boot/src/test/kotlin/com/all8ai/aioa/approval/application/LeaveRequestServiceTest.kt +++ b/backend/boot/src/test/kotlin/com/all8ai/aioa/approval/application/LeaveRequestServiceTest.kt @@ -11,6 +11,10 @@ import com.all8ai.aioa.approval.domain.OperationClaim import com.all8ai.aioa.audit.application.AuditService import com.all8ai.aioa.audit.domain.AuditEventRepository import com.all8ai.aioa.audit.domain.AuditEvent +import com.all8ai.aioa.approval.domain.ApprovalRoutingRepository +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.identity.domain.CurrentUser import com.all8ai.aioa.shared.web.ApiException import org.assertj.core.api.Assertions.assertThat @@ -123,7 +127,26 @@ class LeaveRequestServiceTest { private fun service( repository: LeaveRequestRepository, auditRepository: AuditEventRepository = AuditEventRepository { }, - ) = LeaveRequestService(repository, AuditService(auditRepository)) + ) = LeaveRequestService( + repository, + AuditService(auditRepository), + ApprovalRoutingRepository { _, _ -> UUID.fromString("40000000-0000-7000-8000-000000000002") }, + FakeWorkflowGateway(), + ) + + private class FakeWorkflowGateway : LeaveWorkflowGateway { + override fun startLeaveApproval( + tenantId: UUID, + leaveRequestId: UUID, + applicantId: UUID, + approverId: UUID, + ) = StartedProcess("process-$leaveRequestId", "leaveApproval:1:test") + + override fun listAssignedTasks(assigneeId: UUID): List = emptyList() + override fun resolveTask(taskId: String): WorkflowTask? = null + override fun completeTask(taskId: String, approved: Boolean, comment: String?) = Unit + override fun cancelProcess(processInstanceId: String, reason: String) = Unit + } private class CapturingAuditRepository : AuditEventRepository { val events = mutableListOf() @@ -214,5 +237,34 @@ class LeaveRequestServiceTest { } override fun listTimeline(tenantId: UUID, leaveRequestId: UUID): List = events.toList() + + override fun attachWorkflow( + tenantId: UUID, + leaveRequestId: UUID, + processInstanceId: String, + processDefinitionId: String, + ) { + val entry = drafts.entries.first { it.value.leaveRequest.id == leaveRequestId } + drafts[entry.key] = entry.value.copy( + leaveRequest = entry.value.leaveRequest.copy( + processInstanceId = processInstanceId, + processDefinitionId = processDefinitionId, + ), + ) + } + + override fun findById(tenantId: UUID, id: UUID): LeaveRequest? = + drafts.values.map { it.leaveRequest }.firstOrNull { it.tenantId == tenantId && it.id == id } + + override fun transitionForApprover( + tenantId: UUID, + actorId: UUID, + id: UUID, + expectedVersion: Long, + toStatus: LeaveStatus, + eventId: UUID, + eventType: String, + traceId: String, + ): LeaveRequest? = null } } diff --git a/backend/build.gradle.kts b/backend/build.gradle.kts index 5af5e20..0e28c17 100644 --- a/backend/build.gradle.kts +++ b/backend/build.gradle.kts @@ -1,5 +1,5 @@ plugins { - id("org.springframework.boot") version "3.5.3" apply false + id("org.springframework.boot") version "3.5.4" apply false id("io.spring.dependency-management") version "1.1.7" apply false kotlin("jvm") version "2.1.21" apply false kotlin("plugin.spring") version "2.1.21" apply false diff --git a/contracts/openapi/aioa-v1.yaml b/contracts/openapi/aioa-v1.yaml index d716b35..72ebcec 100644 --- a/contracts/openapi/aioa-v1.yaml +++ b/contracts/openapi/aioa-v1.yaml @@ -1,7 +1,7 @@ openapi: 3.1.0 info: title: AIOA API - version: 0.3.0 + version: 0.4.0 servers: - url: /api/v1 paths: @@ -169,6 +169,67 @@ paths: $ref: "#/components/schemas/LeaveRequestEvent" "404": $ref: "#/components/responses/NotFound" + /approval-tasks: + get: + operationId: listAssignedApprovalTasks + summary: 查询分配给当前用户的审批待办 + responses: + "200": + description: 待办列表 + content: + application/json: + schema: + type: array + items: + $ref: "#/components/schemas/ApprovalTask" + /approval-tasks/{taskId}/approve: + post: + operationId: approveTask + summary: 批准审批任务 + parameters: + - $ref: "#/components/parameters/ApprovalTaskId" + - $ref: "#/components/parameters/IdempotencyKey" + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/ApprovalDecision" + responses: + "200": + description: 已批准或幂等重放 + content: + application/json: + schema: + $ref: "#/components/schemas/LeaveRequest" + "404": + $ref: "#/components/responses/NotFound" + "409": + $ref: "#/components/responses/Conflict" + /approval-tasks/{taskId}/reject: + post: + operationId: rejectTask + summary: 驳回审批任务 + parameters: + - $ref: "#/components/parameters/ApprovalTaskId" + - $ref: "#/components/parameters/IdempotencyKey" + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/ApprovalDecision" + responses: + "200": + description: 已驳回或幂等重放 + content: + application/json: + schema: + $ref: "#/components/schemas/LeaveRequest" + "404": + $ref: "#/components/responses/NotFound" + "409": + $ref: "#/components/responses/Conflict" components: parameters: LeaveRequestId: @@ -184,6 +245,11 @@ components: type: string minLength: 16 maxLength: 128 + ApprovalTaskId: + name: taskId + in: path + required: true + schema: { type: string } responses: BadRequest: description: 请求无效 @@ -274,6 +340,10 @@ components: version: { type: integer, minimum: 0 } createdAt: { type: string, format: date-time } updatedAt: { type: string, format: date-time } + processInstanceId: + type: [string, "null"] + processDefinitionId: + type: [string, "null"] TransitionRequest: type: object required: [version] @@ -294,6 +364,23 @@ components: actorId: { type: string, format: uuid } traceId: { type: string } occurredAt: { type: string, format: date-time } + ApprovalDecision: + type: object + required: [version] + properties: + version: { type: integer, format: int64, minimum: 0 } + comment: + type: [string, "null"] + maxLength: 1000 + ApprovalTask: + type: object + required: [id, name, createdAt, leaveRequest] + properties: + id: { type: string } + name: { type: string } + createdAt: { type: string, format: date-time } + leaveRequest: + $ref: "#/components/schemas/LeaveRequest" Problem: type: object required: [type, title, status, code, traceId] diff --git a/docs/engineering/roadmap.md b/docs/engineering/roadmap.md index d632584..f881f07 100644 --- a/docs/engineering/roadmap.md +++ b/docs/engineering/roadmap.md @@ -25,8 +25,8 @@ - [x] 草稿创建、修改、本人查询与列表 API - [x] 创建幂等、租户/用户隔离和乐观锁 - [x] 提交、撤回、状态时间线和同步审计 -- Flowable 流程发布与执行 -- 发起、待办、批准、驳回、撤回和时间线 +- [x] Flowable 部门主管审批流程发布与执行 +- [x] 发起、主管待办、批准、驳回、撤回和时间线 - 附件、通知、弱网恢复和幂等处理 ## M3:AI 最小闭环