feat: integrate Flowable manager approval workflow

This commit is contained in:
selfrelease
2026-07-18 08:36:18 +08:00
parent aaf3bb5b58
commit c5d80aba43
19 changed files with 754 additions and 19 deletions
+3
View File
@@ -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 表结构。
## 开发身份
+1
View File
@@ -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")
@@ -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<ApprovalTaskResponse> = 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(),
)
@@ -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<LeaveRequestResponse> = 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(
@@ -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<ApprovalTask> =
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,
)
@@ -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<LeaveRequest> = 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<LeaveRequestEvent> {
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,
@@ -0,0 +1,7 @@
package com.all8ai.aioa.approval.domain
import java.util.UUID
fun interface ApprovalRoutingRepository {
fun findDepartmentManager(tenantId: UUID, applicantId: UUID): UUID?
}
@@ -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<LeaveRequestEvent>
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(
@@ -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)
}
@@ -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),
)
}
@@ -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<WorkflowTask>
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,
)
@@ -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<WorkflowTask> =
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"
}
}
@@ -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
@@ -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;
@@ -0,0 +1,26 @@
<?xml version="1.0" encoding="UTF-8"?>
<definitions xmlns="http://www.omg.org/spec/BPMN/20100524/MODEL"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xmlns:flowable="http://flowable.org/bpmn"
targetNamespace="https://aioa.all8ai.com/processes">
<process id="leaveApproval" name="请假审批" isExecutable="true">
<startEvent id="start" name="申请已提交"/>
<sequenceFlow id="flow-start-review" sourceRef="start" targetRef="managerReview"/>
<userTask id="managerReview"
name="部门主管审批"
flowable:assignee="${approverId}"/>
<sequenceFlow id="flow-review-decision" sourceRef="managerReview" targetRef="decision"/>
<exclusiveGateway id="decision" name="审批结果"/>
<sequenceFlow id="flow-approved" sourceRef="decision" targetRef="approvedEnd">
<conditionExpression xsi:type="tFormalExpression"><![CDATA[${approved == true}]]></conditionExpression>
</sequenceFlow>
<sequenceFlow id="flow-rejected" sourceRef="decision" targetRef="rejectedEnd">
<conditionExpression xsi:type="tFormalExpression"><![CDATA[${approved == false}]]></conditionExpression>
</sequenceFlow>
<endEvent id="approvedEnd" name="已批准"/>
<endEvent id="rejectedEnd" name="已驳回"/>
</process>
</definitions>
@@ -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<WorkflowTask> = 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<AuditEvent>()
@@ -214,5 +237,34 @@ class LeaveRequestServiceTest {
}
override fun listTimeline(tenantId: UUID, leaveRequestId: UUID): List<LeaveRequestEvent> = 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
}
}
+1 -1
View File
@@ -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
+88 -1
View File
@@ -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]
+2 -2
View File
@@ -25,8 +25,8 @@
- [x] 草稿创建、修改、本人查询与列表 API
- [x] 创建幂等、租户/用户隔离和乐观锁
- [x] 提交、撤回、状态时间线和同步审计
- Flowable 流程发布与执行
- 发起、待办、批准、驳回、撤回和时间线
- [x] Flowable 部门主管审批流程发布与执行
- [x] 发起、主管待办、批准、驳回、撤回和时间线
- 附件、通知、弱网恢复和幂等处理
## M3AI 最小闭环