feat: add leave submission withdrawal and audit trail

This commit is contained in:
selfrelease
2026-07-18 08:22:59 +08:00
parent 7107c7cc82
commit aaf3bb5b58
12 changed files with 646 additions and 5 deletions
+1
View File
@@ -26,6 +26,7 @@ export GRADLE_USER_HOME=/tmp/aioa-gradle-home
- `/api/v1/me` 根据 JWT 租户与 Subject 查询 OA 权威用户数据 - `/api/v1/me` 根据 JWT 租户与 Subject 查询 OA 权威用户数据
- 请假草稿创建、修改、本人查询和列表 - 请假草稿创建、修改、本人查询和列表
- UUIDv7、写入幂等、租户隔离和乐观锁冲突处理 - UUIDv7、写入幂等、租户隔离和乐观锁冲突处理
- 请假提交、撤回、状态时间线与事务内审计
## 开发身份 ## 开发身份
@@ -3,6 +3,7 @@ package com.all8ai.aioa.approval.api
import com.all8ai.aioa.approval.application.LeaveRequestService import com.all8ai.aioa.approval.application.LeaveRequestService
import com.all8ai.aioa.approval.application.SaveDraftCommand import com.all8ai.aioa.approval.application.SaveDraftCommand
import com.all8ai.aioa.approval.domain.LeaveRequest 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.LeaveStatus
import com.all8ai.aioa.approval.domain.LeaveType import com.all8ai.aioa.approval.domain.LeaveType
import com.all8ai.aioa.identity.application.CurrentUserService import com.all8ai.aioa.identity.application.CurrentUserService
@@ -62,6 +63,42 @@ class LeaveRequestController(
fun listOwn(@AuthenticationPrincipal jwt: Jwt): List<LeaveRequestResponse> = leaveRequestService fun listOwn(@AuthenticationPrincipal jwt: Jwt): List<LeaveRequestResponse> = leaveRequestService
.listOwn(currentUserService.get(jwt.subject, jwt.getClaimAsString("tenant_id"))) .listOwn(currentUserService.get(jwt.subject, jwt.getClaimAsString("tenant_id")))
.map(LeaveRequest::toResponse) .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<LeaveRequestEventResponse> = leaveRequestService
.timeline(currentUserService.get(jwt.subject, jwt.getClaimAsString("tenant_id")), id)
.map(LeaveRequestEvent::toResponse)
} }
data class SaveDraftRequest( data class SaveDraftRequest(
@@ -90,6 +127,21 @@ data class LeaveRequestResponse(
val version: Long, 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( private fun LeaveRequest.toResponse() = LeaveRequestResponse(
id = id, id = id,
applicantId = applicantId, applicantId = applicantId,
@@ -102,3 +154,13 @@ private fun LeaveRequest.toResponse() = LeaveRequestResponse(
updatedAt = updatedAt, updatedAt = updatedAt,
version = version, version = version,
) )
private fun LeaveRequestEvent.toResponse() = LeaveRequestEventResponse(
id = id,
eventType = eventType,
fromStatus = fromStatus,
toStatus = toStatus,
actorId = actorId,
traceId = traceId,
occurredAt = occurredAt,
)
@@ -2,14 +2,19 @@ package com.all8ai.aioa.approval.application
import com.all8ai.aioa.approval.domain.DraftContent import com.all8ai.aioa.approval.domain.DraftContent
import com.all8ai.aioa.approval.domain.LeaveRequest 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.LeaveRequestRepository
import com.all8ai.aioa.approval.domain.LeaveStatus import com.all8ai.aioa.approval.domain.LeaveStatus
import com.all8ai.aioa.approval.domain.LeaveType import com.all8ai.aioa.approval.domain.LeaveType
import com.all8ai.aioa.identity.domain.CurrentUser 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.id.UuidV7
import com.all8ai.aioa.shared.web.ApiException import com.all8ai.aioa.shared.web.ApiException
import org.springframework.http.HttpStatus import org.springframework.http.HttpStatus
import org.springframework.stereotype.Service 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.nio.charset.StandardCharsets
import java.security.MessageDigest import java.security.MessageDigest
import java.time.Instant import java.time.Instant
@@ -18,6 +23,7 @@ import java.util.UUID
@Service @Service
class LeaveRequestService( class LeaveRequestService(
private val repository: LeaveRequestRepository, private val repository: LeaveRequestRepository,
private val auditService: AuditService,
) { ) {
fun createDraft(actor: CurrentUser, idempotencyKey: String, command: SaveDraftCommand): LeaveRequest { fun createDraft(actor: CurrentUser, idempotencyKey: String, command: SaveDraftCommand): LeaveRequest {
validateIdempotencyKey(idempotencyKey) validateIdempotencyKey(idempotencyKey)
@@ -59,6 +65,101 @@ class LeaveRequestService(
fun listOwn(actor: CurrentUser): List<LeaveRequest> = repository.listOwn(actor.tenantId, actor.id, 100) 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(
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<LeaveRequestEvent> {
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 { private fun SaveDraftCommand.toValidatedContent(): DraftContent {
if (!endsAt.isAfter(startsAt)) { if (!endsAt.isAfter(startsAt)) {
throw ApiException(HttpStatus.BAD_REQUEST, "LEAVE_TIME_INVALID", "结束时间必须晚于开始时间") throw ApiException(HttpStatus.BAD_REQUEST, "LEAVE_TIME_INVALID", "结束时间必须晚于开始时间")
@@ -82,6 +183,13 @@ class LeaveRequestService(
.digest(canonical.toByteArray(StandardCharsets.UTF_8)) .digest(canonical.toByteArray(StandardCharsets.UTF_8))
.joinToString("") { "%02x".format(it.toInt() and 0xff) } .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( data class SaveDraftCommand(
@@ -59,9 +59,49 @@ interface LeaveRequestRepository {
fun findOwn(tenantId: UUID, applicantId: UUID, id: UUID): LeaveRequest? fun findOwn(tenantId: UUID, applicantId: UUID, id: UUID): LeaveRequest?
fun listOwn(tenantId: UUID, applicantId: UUID, limit: Int): List<LeaveRequest> fun listOwn(tenantId: UUID, applicantId: UUID, limit: Int): List<LeaveRequest>
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<LeaveRequestEvent>
} }
data class IdempotentDraft( data class IdempotentDraft(
val leaveRequest: LeaveRequest, val leaveRequest: LeaveRequest,
val fingerprint: String, 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,
)
@@ -3,9 +3,11 @@ package com.all8ai.aioa.approval.infrastructure
import com.all8ai.aioa.approval.domain.DraftContent import com.all8ai.aioa.approval.domain.DraftContent
import com.all8ai.aioa.approval.domain.IdempotentDraft import com.all8ai.aioa.approval.domain.IdempotentDraft
import com.all8ai.aioa.approval.domain.LeaveRequest 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.LeaveRequestRepository
import com.all8ai.aioa.approval.domain.LeaveStatus import com.all8ai.aioa.approval.domain.LeaveStatus
import com.all8ai.aioa.approval.domain.LeaveType import com.all8ai.aioa.approval.domain.LeaveType
import com.all8ai.aioa.approval.domain.OperationClaim
import org.jooq.DSLContext import org.jooq.DSLContext
import org.jooq.Record import org.jooq.Record
import org.springframework.stereotype.Repository import org.springframework.stereotype.Repository
@@ -102,6 +104,123 @@ class JooqLeaveRequestRepository(
limit, limit,
).map(::map) ).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<LeaveRequestEvent> =
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( private fun map(record: Record): LeaveRequest = LeaveRequest(
id = record.get("id", UUID::class.java)!!, id = record.get("id", UUID::class.java)!!,
tenantId = record.get("tenant_id", UUID::class.java)!!, tenantId = record.get("tenant_id", UUID::class.java)!!,
@@ -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<String, Any?>,
) {
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,
),
)
}
}
@@ -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<String, Any?>,
)
fun interface AuditEventRepository {
fun append(event: AuditEvent)
}
@@ -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),
)
}
}
@@ -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);
@@ -6,6 +6,11 @@ import com.all8ai.aioa.approval.domain.LeaveRequest
import com.all8ai.aioa.approval.domain.LeaveRequestRepository import com.all8ai.aioa.approval.domain.LeaveRequestRepository
import com.all8ai.aioa.approval.domain.LeaveStatus import com.all8ai.aioa.approval.domain.LeaveStatus
import com.all8ai.aioa.approval.domain.LeaveType 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.identity.domain.CurrentUser
import com.all8ai.aioa.shared.web.ApiException import com.all8ai.aioa.shared.web.ApiException
import org.assertj.core.api.Assertions.assertThat import org.assertj.core.api.Assertions.assertThat
@@ -36,7 +41,7 @@ class LeaveRequestServiceTest {
@Test @Test
fun `creates own draft with normalized reason`() { fun `creates own draft with normalized reason`() {
val repository = InMemoryRepository() val repository = InMemoryRepository()
val service = LeaveRequestService(repository) val service = service(repository)
val created = service.createDraft(actor, "draft-create-0001", command.copy(reason = " 办理个人事务 ")) val created = service.createDraft(actor, "draft-create-0001", command.copy(reason = " 办理个人事务 "))
@@ -48,7 +53,7 @@ class LeaveRequestServiceTest {
@Test @Test
fun `rejects invalid time range before repository call`() { fun `rejects invalid time range before repository call`() {
val service = LeaveRequestService(InMemoryRepository()) val service = service(InMemoryRepository())
assertThatThrownBy { assertThatThrownBy {
service.createDraft(actor, "draft-create-0002", command.copy(endsAt = command.startsAt)) service.createDraft(actor, "draft-create-0002", command.copy(endsAt = command.startsAt))
@@ -60,7 +65,7 @@ class LeaveRequestServiceTest {
@Test @Test
fun `returns same draft when idempotent request is replayed`() { 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 first = service.createDraft(actor, "draft-create-0003", command)
val replay = service.createDraft(actor, "draft-create-0003", command) val replay = service.createDraft(actor, "draft-create-0003", command)
@@ -70,7 +75,7 @@ class LeaveRequestServiceTest {
@Test @Test
fun `rejects same idempotency key with different content`() { fun `rejects same idempotency key with different content`() {
val service = LeaveRequestService(InMemoryRepository()) val service = service(InMemoryRepository())
service.createDraft(actor, "draft-create-0004", command) service.createDraft(actor, "draft-create-0004", command)
assertThatThrownBy { 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<AuditEvent>()
override fun append(event: AuditEvent) {
events += event
}
}
private class InMemoryRepository : LeaveRequestRepository { private class InMemoryRepository : LeaveRequestRepository {
private val drafts = mutableMapOf<Triple<UUID, UUID, String>, IdempotentDraft>() private val drafts = mutableMapOf<Triple<UUID, UUID, String>, IdempotentDraft>()
private val claims = mutableMapOf<List<Any>, OperationClaim>()
private val events = mutableListOf<LeaveRequestEvent>()
override fun createDraft( override fun createDraft(
id: UUID, id: UUID,
@@ -118,5 +171,48 @@ class LeaveRequestServiceTest {
drafts.values.map { it.leaveRequest } drafts.values.map { it.leaveRequest }
.filter { it.tenantId == tenantId && it.applicantId == applicantId } .filter { it.tenantId == tenantId && it.applicantId == applicantId }
.take(limit) .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<LeaveRequestEvent> = events.toList()
} }
} }
+91 -1
View File
@@ -1,7 +1,7 @@
openapi: 3.1.0 openapi: 3.1.0
info: info:
title: AIOA API title: AIOA API
version: 0.2.0 version: 0.3.0
servers: servers:
- url: /api/v1 - url: /api/v1
paths: paths:
@@ -104,8 +104,78 @@ paths:
$ref: "#/components/responses/NotFound" $ref: "#/components/responses/NotFound"
"409": "409":
$ref: "#/components/responses/Conflict" $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: components:
parameters: parameters:
LeaveRequestId:
name: id
in: path
required: true
schema: { type: string, format: uuid }
IdempotencyKey: IdempotencyKey:
name: Idempotency-Key name: Idempotency-Key
in: header in: header
@@ -204,6 +274,26 @@ components:
version: { type: integer, minimum: 0 } version: { type: integer, minimum: 0 }
createdAt: { type: string, format: date-time } createdAt: { type: string, format: date-time }
updatedAt: { 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: Problem:
type: object type: object
required: [type, title, status, code, traceId] required: [type, title, status, code, traceId]
+1
View File
@@ -24,6 +24,7 @@
- [x] 请假草稿数据模型和版本 - [x] 请假草稿数据模型和版本
- [x] 草稿创建、修改、本人查询与列表 API - [x] 草稿创建、修改、本人查询与列表 API
- [x] 创建幂等、租户/用户隔离和乐观锁 - [x] 创建幂等、租户/用户隔离和乐观锁
- [x] 提交、撤回、状态时间线和同步审计
- Flowable 流程发布与执行 - Flowable 流程发布与执行
- 发起、待办、批准、驳回、撤回和时间线 - 发起、待办、批准、驳回、撤回和时间线
- 附件、通知、弱网恢复和幂等处理 - 附件、通知、弱网恢复和幂等处理