diff --git a/backend/README.md b/backend/README.md index 4a24724..60a47c5 100644 --- a/backend/README.md +++ b/backend/README.md @@ -26,6 +26,7 @@ export GRADLE_USER_HOME=/tmp/aioa-gradle-home - `/api/v1/me` 根据 JWT 租户与 Subject 查询 OA 权威用户数据 - 请假草稿创建、修改、本人查询和列表 - UUIDv7、写入幂等、租户隔离和乐观锁冲突处理 +- 请假提交、撤回、状态时间线与事务内审计 ## 开发身份 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 75545be..6b9f2b9 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 @@ -3,6 +3,7 @@ package com.all8ai.aioa.approval.api import com.all8ai.aioa.approval.application.LeaveRequestService import com.all8ai.aioa.approval.application.SaveDraftCommand import com.all8ai.aioa.approval.domain.LeaveRequest +import com.all8ai.aioa.approval.domain.LeaveRequestEvent import com.all8ai.aioa.approval.domain.LeaveStatus import com.all8ai.aioa.approval.domain.LeaveType import com.all8ai.aioa.identity.application.CurrentUserService @@ -62,6 +63,42 @@ class LeaveRequestController( fun listOwn(@AuthenticationPrincipal jwt: Jwt): List = leaveRequestService .listOwn(currentUserService.get(jwt.subject, jwt.getClaimAsString("tenant_id"))) .map(LeaveRequest::toResponse) + + @PostMapping("/{id}/submit") + fun submit( + @AuthenticationPrincipal jwt: Jwt, + @PathVariable id: UUID, + @RequestHeader("Idempotency-Key") idempotencyKey: String, + @Valid @RequestBody request: TransitionRequest, + ): LeaveRequestResponse = leaveRequestService + .submit( + currentUserService.get(jwt.subject, jwt.getClaimAsString("tenant_id")), + id, + idempotencyKey, + request.version, + ).toResponse() + + @PostMapping("/{id}/withdraw") + fun withdraw( + @AuthenticationPrincipal jwt: Jwt, + @PathVariable id: UUID, + @RequestHeader("Idempotency-Key") idempotencyKey: String, + @Valid @RequestBody request: TransitionRequest, + ): LeaveRequestResponse = leaveRequestService + .withdraw( + currentUserService.get(jwt.subject, jwt.getClaimAsString("tenant_id")), + id, + idempotencyKey, + request.version, + ).toResponse() + + @GetMapping("/{id}/timeline") + fun timeline( + @AuthenticationPrincipal jwt: Jwt, + @PathVariable id: UUID, + ): List = leaveRequestService + .timeline(currentUserService.get(jwt.subject, jwt.getClaimAsString("tenant_id")), id) + .map(LeaveRequestEvent::toResponse) } data class SaveDraftRequest( @@ -90,6 +127,21 @@ data class LeaveRequestResponse( val version: Long, ) +data class TransitionRequest( + @field:PositiveOrZero + val version: Long, +) + +data class LeaveRequestEventResponse( + val id: UUID, + val eventType: String, + val fromStatus: LeaveStatus, + val toStatus: LeaveStatus, + val actorId: UUID, + val traceId: String, + val occurredAt: Instant, +) + private fun LeaveRequest.toResponse() = LeaveRequestResponse( id = id, applicantId = applicantId, @@ -102,3 +154,13 @@ private fun LeaveRequest.toResponse() = LeaveRequestResponse( updatedAt = updatedAt, version = version, ) + +private fun LeaveRequestEvent.toResponse() = LeaveRequestEventResponse( + id = id, + eventType = eventType, + fromStatus = fromStatus, + toStatus = toStatus, + actorId = actorId, + traceId = traceId, + occurredAt = occurredAt, +) 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 4da1c94..6d7519b 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 @@ -2,14 +2,19 @@ package com.all8ai.aioa.approval.application 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.LeaveStatus import com.all8ai.aioa.approval.domain.LeaveType import com.all8ai.aioa.identity.domain.CurrentUser +import com.all8ai.aioa.audit.application.AuditService 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 org.springframework.transaction.annotation.Transactional +import org.slf4j.MDC +import com.all8ai.aioa.shared.web.TraceIdFilter import java.nio.charset.StandardCharsets import java.security.MessageDigest import java.time.Instant @@ -18,6 +23,7 @@ import java.util.UUID @Service class LeaveRequestService( private val repository: LeaveRequestRepository, + private val auditService: AuditService, ) { fun createDraft(actor: CurrentUser, idempotencyKey: String, command: SaveDraftCommand): LeaveRequest { validateIdempotencyKey(idempotencyKey) @@ -59,6 +65,101 @@ 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( + actor = actor, + id = id, + idempotencyKey = idempotencyKey, + expectedVersion = expectedVersion, + operation = "LEAVE_SUBMIT", + action = "LEAVE_REQUEST_SUBMITTED", + fromStatus = LeaveStatus.DRAFT, + toStatus = LeaveStatus.PENDING, + ) + + @Transactional + fun withdraw(actor: CurrentUser, id: UUID, idempotencyKey: String, expectedVersion: Long): LeaveRequest = + transition( + actor = actor, + id = id, + idempotencyKey = idempotencyKey, + expectedVersion = expectedVersion, + operation = "LEAVE_WITHDRAW", + action = "LEAVE_REQUEST_WITHDRAWN", + fromStatus = LeaveStatus.PENDING, + toStatus = LeaveStatus.WITHDRAWN, + ) + + fun timeline(actor: CurrentUser, id: UUID): List { + getOwn(actor, id) + return repository.listTimeline(actor.tenantId, id) + } + + private fun transition( + actor: CurrentUser, + id: UUID, + idempotencyKey: String, + expectedVersion: Long, + operation: String, + action: String, + fromStatus: LeaveStatus, + toStatus: LeaveStatus, + ): LeaveRequest { + validateIdempotencyKey(idempotencyKey) + if (expectedVersion < 0) { + throw ApiException(HttpStatus.BAD_REQUEST, "VERSION_INVALID", "版本号不能小于零") + } + val fingerprint = operationFingerprint(operation, id, expectedVersion) + val claim = repository.claimOperation( + UuidV7.generate(), actor.tenantId, actor.id, operation, + idempotencyKey, fingerprint, id, + ) + if (claim.fingerprint != fingerprint || claim.resourceId != id) { + throw ApiException(HttpStatus.CONFLICT, "IDEMPOTENCY_KEY_REUSED", "该幂等键已用于不同的操作参数") + } + if (!claim.claimed) { + return getOwn(actor, id) + } + + val traceId = MDC.get(TraceIdFilter.MDC_TRACE_ID) ?: "unknown" + val transitioned = repository.transitionOwn( + actor.tenantId, actor.id, id, expectedVersion, fromStatus, toStatus, + UuidV7.generate(), action, traceId, + ) ?: transitionFailure(actor, id, expectedVersion, fromStatus) + + auditService.recordSuccess( + actor = actor, + action = action, + resourceType = "LEAVE_REQUEST", + resourceId = id.toString(), + idempotencyKey = idempotencyKey, + details = mapOf( + "fromStatus" to fromStatus.name, + "toStatus" to toStatus.name, + "version" to transitioned.version, + ), + ) + return transitioned + } + + private fun transitionFailure( + actor: CurrentUser, + id: UUID, + expectedVersion: Long, + requiredStatus: LeaveStatus, + ): Nothing { + val existing = repository.findOwn(actor.tenantId, actor.id, id) + ?: throw ApiException(HttpStatus.NOT_FOUND, "LEAVE_REQUEST_NOT_FOUND", "请假申请不存在") + if (existing.status != requiredStatus) { + 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 SaveDraftCommand.toValidatedContent(): DraftContent { if (!endsAt.isAfter(startsAt)) { throw ApiException(HttpStatus.BAD_REQUEST, "LEAVE_TIME_INVALID", "结束时间必须晚于开始时间") @@ -82,6 +183,13 @@ class LeaveRequestService( .digest(canonical.toByteArray(StandardCharsets.UTF_8)) .joinToString("") { "%02x".format(it.toInt() and 0xff) } } + + private fun operationFingerprint(operation: String, id: UUID, version: Long): String { + val canonical = "$operation|$id|$version" + return MessageDigest.getInstance("SHA-256") + .digest(canonical.toByteArray(StandardCharsets.UTF_8)) + .joinToString("") { "%02x".format(it.toInt() and 0xff) } + } } data class SaveDraftCommand( 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 fedfa17..3b9b722 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 @@ -59,9 +59,49 @@ interface LeaveRequestRepository { fun findOwn(tenantId: UUID, applicantId: UUID, id: UUID): LeaveRequest? fun listOwn(tenantId: UUID, applicantId: UUID, limit: Int): List + + fun claimOperation( + id: UUID, + tenantId: UUID, + actorId: UUID, + operation: String, + idempotencyKey: String, + fingerprint: String, + resourceId: UUID, + ): OperationClaim + + fun transitionOwn( + tenantId: UUID, + actorId: UUID, + id: UUID, + expectedVersion: Long, + fromStatus: LeaveStatus, + toStatus: LeaveStatus, + eventId: UUID, + eventType: String, + traceId: String, + ): LeaveRequest? + + fun listTimeline(tenantId: UUID, leaveRequestId: UUID): List } data class IdempotentDraft( val leaveRequest: LeaveRequest, val fingerprint: String, ) + +data class OperationClaim( + val claimed: Boolean, + val fingerprint: String, + val resourceId: UUID, +) + +data class LeaveRequestEvent( + val id: UUID, + val eventType: String, + val fromStatus: LeaveStatus, + val toStatus: LeaveStatus, + val actorId: UUID, + val traceId: String, + val occurredAt: Instant, +) 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 48ebc92..d630f7f 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 @@ -3,9 +3,11 @@ package com.all8ai.aioa.approval.infrastructure import com.all8ai.aioa.approval.domain.DraftContent import com.all8ai.aioa.approval.domain.IdempotentDraft 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.LeaveStatus import com.all8ai.aioa.approval.domain.LeaveType +import com.all8ai.aioa.approval.domain.OperationClaim import org.jooq.DSLContext import org.jooq.Record import org.springframework.stereotype.Repository @@ -102,6 +104,123 @@ class JooqLeaveRequestRepository( limit, ).map(::map) + override fun claimOperation( + id: UUID, + tenantId: UUID, + actorId: UUID, + operation: String, + idempotencyKey: String, + fingerprint: String, + resourceId: UUID, + ): OperationClaim { + val inserted = dsl.execute( + """ + INSERT INTO business.idempotency_record ( + id, tenant_id, actor_id, operation, idempotency_key, + request_fingerprint, resource_id + ) VALUES (?, ?, ?, ?, ?, ?, ?) + ON CONFLICT (tenant_id, actor_id, operation, idempotency_key) DO NOTHING + """.trimIndent(), + id, + tenantId, + actorId, + operation, + idempotencyKey, + fingerprint, + resourceId, + ) + val record = dsl.fetchOne( + """ + SELECT request_fingerprint, resource_id + FROM business.idempotency_record + WHERE tenant_id = ? AND actor_id = ? AND operation = ? AND idempotency_key = ? + """.trimIndent(), + tenantId, + actorId, + operation, + idempotencyKey, + )!! + return OperationClaim( + claimed = inserted == 1, + fingerprint = record.get("request_fingerprint", String::class.java)!!.trim(), + resourceId = record.get("resource_id", UUID::class.java)!!, + ) + } + + override fun transitionOwn( + tenantId: UUID, + actorId: UUID, + id: UUID, + expectedVersion: Long, + fromStatus: LeaveStatus, + toStatus: LeaveStatus, + eventId: UUID, + eventType: String, + traceId: String, + ): LeaveRequest? { + val transitioned = dsl.fetchOne( + """ + UPDATE business.leave_request + SET status = ?, + submitted_at = CASE WHEN ? = 'PENDING' THEN CURRENT_TIMESTAMP ELSE submitted_at END, + completed_at = CASE WHEN ? IN ('APPROVED', 'REJECTED', 'WITHDRAWN') + THEN CURRENT_TIMESTAMP ELSE completed_at END, + updated_at = CURRENT_TIMESTAMP, + version = version + 1 + WHERE tenant_id = ? AND applicant_id = ? AND id = ? + AND status = ? AND version = ? + RETURNING * + """.trimIndent(), + toStatus.name, + toStatus.name, + toStatus.name, + tenantId, + actorId, + id, + fromStatus.name, + 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 (?, ?, ?, ?, ?, ?, ?, ?) + """.trimIndent(), + eventId, + tenantId, + id, + actorId, + eventType, + fromStatus.name, + toStatus.name, + traceId, + ) + return transitioned + } + + override fun listTimeline(tenantId: UUID, leaveRequestId: UUID): List = + dsl.fetch( + """ + SELECT * FROM business.leave_request_event + WHERE tenant_id = ? AND leave_request_id = ? + ORDER BY occurred_at, id + """.trimIndent(), + tenantId, + leaveRequestId, + ).map { record -> + LeaveRequestEvent( + id = record.get("id", UUID::class.java)!!, + eventType = record.get("event_type", String::class.java)!!, + fromStatus = LeaveStatus.valueOf(record.get("from_status", String::class.java)!!), + toStatus = LeaveStatus.valueOf(record.get("to_status", String::class.java)!!), + actorId = record.get("actor_id", UUID::class.java)!!, + traceId = record.get("trace_id", String::class.java)!!, + occurredAt = record.get("occurred_at", OffsetDateTime::class.java)!!.toInstant(), + ) + } + private fun map(record: Record): LeaveRequest = LeaveRequest( id = record.get("id", UUID::class.java)!!, tenantId = record.get("tenant_id", UUID::class.java)!!, diff --git a/backend/boot/src/main/kotlin/com/all8ai/aioa/audit/application/AuditService.kt b/backend/boot/src/main/kotlin/com/all8ai/aioa/audit/application/AuditService.kt new file mode 100644 index 0000000..6967878 --- /dev/null +++ b/backend/boot/src/main/kotlin/com/all8ai/aioa/audit/application/AuditService.kt @@ -0,0 +1,38 @@ +package com.all8ai.aioa.audit.application + +import com.all8ai.aioa.audit.domain.AuditEvent +import com.all8ai.aioa.audit.domain.AuditEventRepository +import com.all8ai.aioa.identity.domain.CurrentUser +import com.all8ai.aioa.shared.id.UuidV7 +import com.all8ai.aioa.shared.web.TraceIdFilter +import org.slf4j.MDC +import org.springframework.stereotype.Service + +@Service +class AuditService( + private val repository: AuditEventRepository, +) { + fun recordSuccess( + actor: CurrentUser, + action: String, + resourceType: String, + resourceId: String, + idempotencyKey: String?, + details: Map, + ) { + repository.append( + AuditEvent( + id = UuidV7.generate(), + tenantId = actor.tenantId, + actorId = actor.id, + action = action, + resourceType = resourceType, + resourceId = resourceId, + traceId = MDC.get(TraceIdFilter.MDC_TRACE_ID) ?: "unknown", + idempotencyKey = idempotencyKey, + result = "SUCCESS", + details = details, + ), + ) + } +} diff --git a/backend/boot/src/main/kotlin/com/all8ai/aioa/audit/domain/AuditEvent.kt b/backend/boot/src/main/kotlin/com/all8ai/aioa/audit/domain/AuditEvent.kt new file mode 100644 index 0000000..4496eab --- /dev/null +++ b/backend/boot/src/main/kotlin/com/all8ai/aioa/audit/domain/AuditEvent.kt @@ -0,0 +1,20 @@ +package com.all8ai.aioa.audit.domain + +import java.util.UUID + +data class AuditEvent( + val id: UUID, + val tenantId: UUID, + val actorId: UUID, + val action: String, + val resourceType: String, + val resourceId: String, + val traceId: String, + val idempotencyKey: String?, + val result: String, + val details: Map, +) + +fun interface AuditEventRepository { + fun append(event: AuditEvent) +} diff --git a/backend/boot/src/main/kotlin/com/all8ai/aioa/audit/infrastructure/JooqAuditEventRepository.kt b/backend/boot/src/main/kotlin/com/all8ai/aioa/audit/infrastructure/JooqAuditEventRepository.kt new file mode 100644 index 0000000..c3dd4ea --- /dev/null +++ b/backend/boot/src/main/kotlin/com/all8ai/aioa/audit/infrastructure/JooqAuditEventRepository.kt @@ -0,0 +1,34 @@ +package com.all8ai.aioa.audit.infrastructure + +import com.all8ai.aioa.audit.domain.AuditEvent +import com.all8ai.aioa.audit.domain.AuditEventRepository +import com.fasterxml.jackson.databind.ObjectMapper +import org.jooq.DSLContext +import org.springframework.stereotype.Repository + +@Repository +class JooqAuditEventRepository( + private val dsl: DSLContext, + private val objectMapper: ObjectMapper, +) : AuditEventRepository { + override fun append(event: AuditEvent) { + dsl.execute( + """ + INSERT INTO audit.event ( + id, tenant_id, actor_id, action, resource_type, resource_id, + trace_id, idempotency_key, result, details + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, CAST(? AS JSONB)) + """.trimIndent(), + event.id, + event.tenantId, + event.actorId, + event.action, + event.resourceType, + event.resourceId, + event.traceId, + event.idempotencyKey, + event.result, + objectMapper.writeValueAsString(event.details), + ) + } +} diff --git a/backend/boot/src/main/resources/db/migration/V5__create_leave_transitions_and_idempotency.sql b/backend/boot/src/main/resources/db/migration/V5__create_leave_transitions_and_idempotency.sql new file mode 100644 index 0000000..661e6a6 --- /dev/null +++ b/backend/boot/src/main/resources/db/migration/V5__create_leave_transitions_and_idempotency.sql @@ -0,0 +1,32 @@ +CREATE TABLE business.idempotency_record ( + id UUID PRIMARY KEY, + tenant_id UUID NOT NULL REFERENCES identity.tenant(id), + actor_id UUID NOT NULL, + operation VARCHAR(100) NOT NULL, + idempotency_key VARCHAR(128) NOT NULL, + request_fingerprint CHAR(64) NOT NULL, + resource_id UUID NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, + CONSTRAINT fk_idempotency_actor FOREIGN KEY (tenant_id, actor_id) + REFERENCES identity.user_account(tenant_id, id), + UNIQUE (tenant_id, actor_id, operation, idempotency_key) +); + +CREATE TABLE business.leave_request_event ( + id UUID PRIMARY KEY, + tenant_id UUID NOT NULL REFERENCES identity.tenant(id), + leave_request_id UUID NOT NULL, + actor_id UUID NOT NULL, + event_type VARCHAR(100) NOT NULL, + from_status VARCHAR(32) NOT NULL, + to_status VARCHAR(32) NOT NULL, + trace_id VARCHAR(128) NOT NULL, + occurred_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, + CONSTRAINT fk_leave_event_request FOREIGN KEY (tenant_id, leave_request_id) + REFERENCES business.leave_request(tenant_id, id), + CONSTRAINT fk_leave_event_actor FOREIGN KEY (tenant_id, actor_id) + REFERENCES identity.user_account(tenant_id, id) +); + +CREATE INDEX idx_leave_event_timeline + ON business.leave_request_event (tenant_id, leave_request_id, occurred_at, id); 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 698bf3f..b342cec 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 @@ -6,6 +6,11 @@ import com.all8ai.aioa.approval.domain.LeaveRequest import com.all8ai.aioa.approval.domain.LeaveRequestRepository import com.all8ai.aioa.approval.domain.LeaveStatus import com.all8ai.aioa.approval.domain.LeaveType +import com.all8ai.aioa.approval.domain.LeaveRequestEvent +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.identity.domain.CurrentUser import com.all8ai.aioa.shared.web.ApiException import org.assertj.core.api.Assertions.assertThat @@ -36,7 +41,7 @@ class LeaveRequestServiceTest { @Test fun `creates own draft with normalized reason`() { val repository = InMemoryRepository() - val service = LeaveRequestService(repository) + val service = service(repository) val created = service.createDraft(actor, "draft-create-0001", command.copy(reason = " 办理个人事务 ")) @@ -48,7 +53,7 @@ class LeaveRequestServiceTest { @Test fun `rejects invalid time range before repository call`() { - val service = LeaveRequestService(InMemoryRepository()) + val service = service(InMemoryRepository()) assertThatThrownBy { service.createDraft(actor, "draft-create-0002", command.copy(endsAt = command.startsAt)) @@ -60,7 +65,7 @@ class LeaveRequestServiceTest { @Test fun `returns same draft when idempotent request is replayed`() { - val service = LeaveRequestService(InMemoryRepository()) + val service = service(InMemoryRepository()) val first = service.createDraft(actor, "draft-create-0003", command) val replay = service.createDraft(actor, "draft-create-0003", command) @@ -70,7 +75,7 @@ class LeaveRequestServiceTest { @Test fun `rejects same idempotency key with different content`() { - val service = LeaveRequestService(InMemoryRepository()) + val service = service(InMemoryRepository()) service.createDraft(actor, "draft-create-0004", command) assertThatThrownBy { @@ -81,8 +86,56 @@ class LeaveRequestServiceTest { } } + @Test + fun `submits draft once and writes one audit event on replay`() { + val repository = InMemoryRepository() + val audits = CapturingAuditRepository() + val service = service(repository, audits) + val draft = service.createDraft(actor, "draft-create-0005", command) + + val submitted = service.submit(actor, draft.id, "draft-submit-0005", 0) + val replay = service.submit(actor, draft.id, "draft-submit-0005", 0) + + assertThat(submitted.status).isEqualTo(LeaveStatus.PENDING) + assertThat(submitted.version).isEqualTo(1) + assertThat(replay).isEqualTo(submitted) + assertThat(audits.events).hasSize(1) + assertThat(audits.events.single().action).isEqualTo("LEAVE_REQUEST_SUBMITTED") + } + + @Test + fun `withdraws only a pending request`() { + val repository = InMemoryRepository() + val service = service(repository) + val draft = service.createDraft(actor, "draft-create-0006", command) + + assertThatThrownBy { service.withdraw(actor, draft.id, "draft-withdraw-0006", 0) } + .isInstanceOfSatisfying(ApiException::class.java) { + assertThat(it.code).isEqualTo("LEAVE_STATUS_INVALID") + } + + val submitted = service.submit(actor, draft.id, "draft-submit-0006", 0) + val withdrawn = service.withdraw(actor, draft.id, "draft-withdraw-0007", submitted.version) + assertThat(withdrawn.status).isEqualTo(LeaveStatus.WITHDRAWN) + assertThat(withdrawn.version).isEqualTo(2) + } + + private fun service( + repository: LeaveRequestRepository, + auditRepository: AuditEventRepository = AuditEventRepository { }, + ) = LeaveRequestService(repository, AuditService(auditRepository)) + + private class CapturingAuditRepository : AuditEventRepository { + val events = mutableListOf() + override fun append(event: AuditEvent) { + events += event + } + } + private class InMemoryRepository : LeaveRequestRepository { private val drafts = mutableMapOf, IdempotentDraft>() + private val claims = mutableMapOf, OperationClaim>() + private val events = mutableListOf() override fun createDraft( id: UUID, @@ -118,5 +171,48 @@ class LeaveRequestServiceTest { drafts.values.map { it.leaveRequest } .filter { it.tenantId == tenantId && it.applicantId == applicantId } .take(limit) + + override fun claimOperation( + id: UUID, + tenantId: UUID, + actorId: UUID, + operation: String, + idempotencyKey: String, + fingerprint: String, + resourceId: UUID, + ): OperationClaim { + val key = listOf(tenantId, actorId, operation, idempotencyKey) + val existing = claims[key] + if (existing != null) return existing.copy(claimed = false) + return OperationClaim(true, fingerprint, resourceId).also { claims[key] = it } + } + + override fun transitionOwn( + tenantId: UUID, + actorId: UUID, + id: UUID, + expectedVersion: Long, + fromStatus: LeaveStatus, + toStatus: LeaveStatus, + eventId: UUID, + eventType: String, + traceId: String, + ): LeaveRequest? { + val entry = drafts.entries.firstOrNull { + val request = it.value.leaveRequest + request.tenantId == tenantId && request.applicantId == actorId && request.id == id + } ?: return null + val current = entry.value.leaveRequest + if (current.status != fromStatus || current.version != expectedVersion) return null + val updated = current.copy(status = toStatus, version = current.version + 1) + drafts[entry.key] = entry.value.copy(leaveRequest = updated) + events += LeaveRequestEvent( + eventId, eventType, fromStatus, toStatus, actorId, traceId, + Instant.parse("2026-07-18T00:00:00Z"), + ) + return updated + } + + override fun listTimeline(tenantId: UUID, leaveRequestId: UUID): List = events.toList() } } diff --git a/contracts/openapi/aioa-v1.yaml b/contracts/openapi/aioa-v1.yaml index 052a13c..d716b35 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.2.0 + version: 0.3.0 servers: - url: /api/v1 paths: @@ -104,8 +104,78 @@ paths: $ref: "#/components/responses/NotFound" "409": $ref: "#/components/responses/Conflict" + /leave-requests/{id}/submit: + post: + operationId: submitLeaveRequest + summary: 提交请假草稿 + parameters: + - $ref: "#/components/parameters/LeaveRequestId" + - $ref: "#/components/parameters/IdempotencyKey" + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/TransitionRequest" + responses: + "200": + description: 已提交或幂等重放 + content: + application/json: + schema: + $ref: "#/components/schemas/LeaveRequest" + "404": + $ref: "#/components/responses/NotFound" + "409": + $ref: "#/components/responses/Conflict" + /leave-requests/{id}/withdraw: + post: + operationId: withdrawLeaveRequest + summary: 撤回待审批申请 + parameters: + - $ref: "#/components/parameters/LeaveRequestId" + - $ref: "#/components/parameters/IdempotencyKey" + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/TransitionRequest" + responses: + "200": + description: 已撤回或幂等重放 + content: + application/json: + schema: + $ref: "#/components/schemas/LeaveRequest" + "404": + $ref: "#/components/responses/NotFound" + "409": + $ref: "#/components/responses/Conflict" + /leave-requests/{id}/timeline: + get: + operationId: getLeaveRequestTimeline + summary: 查询本人申请状态时间线 + parameters: + - $ref: "#/components/parameters/LeaveRequestId" + responses: + "200": + description: 状态事件列表 + content: + application/json: + schema: + type: array + items: + $ref: "#/components/schemas/LeaveRequestEvent" + "404": + $ref: "#/components/responses/NotFound" components: parameters: + LeaveRequestId: + name: id + in: path + required: true + schema: { type: string, format: uuid } IdempotencyKey: name: Idempotency-Key in: header @@ -204,6 +274,26 @@ components: version: { type: integer, minimum: 0 } createdAt: { type: string, format: date-time } updatedAt: { type: string, format: date-time } + TransitionRequest: + type: object + required: [version] + properties: + version: { type: integer, format: int64, minimum: 0 } + LeaveRequestEvent: + type: object + required: [id, eventType, fromStatus, toStatus, actorId, traceId, occurredAt] + properties: + id: { type: string, format: uuid } + eventType: { type: string } + fromStatus: + type: string + enum: [DRAFT, PENDING, APPROVED, REJECTED, WITHDRAWN] + toStatus: + type: string + enum: [DRAFT, PENDING, APPROVED, REJECTED, WITHDRAWN] + actorId: { type: string, format: uuid } + traceId: { type: string } + occurredAt: { type: string, format: date-time } Problem: type: object required: [type, title, status, code, traceId] diff --git a/docs/engineering/roadmap.md b/docs/engineering/roadmap.md index 25dc225..d632584 100644 --- a/docs/engineering/roadmap.md +++ b/docs/engineering/roadmap.md @@ -24,6 +24,7 @@ - [x] 请假草稿数据模型和版本 - [x] 草稿创建、修改、本人查询与列表 API - [x] 创建幂等、租户/用户隔离和乐观锁 +- [x] 提交、撤回、状态时间线和同步审计 - Flowable 流程发布与执行 - 发起、待办、批准、驳回、撤回和时间线 - 附件、通知、弱网恢复和幂等处理