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 权威用户数据
- 请假草稿创建、修改、本人查询和列表
- 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,
)
@@ -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,
)
@@ -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)
}
@@ -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.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()
}
}
+91 -1
View File
@@ -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]
+1
View File
@@ -24,6 +24,7 @@
- [x] 请假草稿数据模型和版本
- [x] 草稿创建、修改、本人查询与列表 API
- [x] 创建幂等、租户/用户隔离和乐观锁
- [x] 提交、撤回、状态时间线和同步审计
- Flowable 流程发布与执行
- 发起、待办、批准、驳回、撤回和时间线
- 附件、通知、弱网恢复和幂等处理