feat: add leave submission withdrawal and audit trail
This commit is contained in:
@@ -26,6 +26,7 @@ export GRADLE_USER_HOME=/tmp/aioa-gradle-home
|
||||
- `/api/v1/me` 根据 JWT 租户与 Subject 查询 OA 权威用户数据
|
||||
- 请假草稿创建、修改、本人查询和列表
|
||||
- UUIDv7、写入幂等、租户隔离和乐观锁冲突处理
|
||||
- 请假提交、撤回、状态时间线与事务内审计
|
||||
|
||||
## 开发身份
|
||||
|
||||
|
||||
@@ -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<LeaveRequestResponse> = 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<LeaveRequestEventResponse> = 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,
|
||||
)
|
||||
|
||||
+108
@@ -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<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 {
|
||||
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(
|
||||
|
||||
@@ -59,9 +59,49 @@ interface LeaveRequestRepository {
|
||||
fun findOwn(tenantId: UUID, applicantId: UUID, id: UUID): 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(
|
||||
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,
|
||||
)
|
||||
|
||||
+119
@@ -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<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(
|
||||
id = record.get("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)
|
||||
}
|
||||
+34
@@ -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),
|
||||
)
|
||||
}
|
||||
}
|
||||
+32
@@ -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);
|
||||
+100
-4
@@ -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<AuditEvent>()
|
||||
override fun append(event: AuditEvent) {
|
||||
events += event
|
||||
}
|
||||
}
|
||||
|
||||
private class InMemoryRepository : LeaveRequestRepository {
|
||||
private val drafts = mutableMapOf<Triple<UUID, UUID, String>, IdempotentDraft>()
|
||||
private val claims = mutableMapOf<List<Any>, OperationClaim>()
|
||||
private val events = mutableListOf<LeaveRequestEvent>()
|
||||
|
||||
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<LeaveRequestEvent> = events.toList()
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user