diff --git a/.gitignore b/.gitignore index 6a42f2e..11d7353 100644 --- a/.gitignore +++ b/.gitignore @@ -6,6 +6,7 @@ # Kotlin / Gradle .gradle/ +.kotlin/ **/build/ # Flutter / Dart diff --git a/backend/.kotlin/sessions/kotlin-compiler-2119854500868872929.salive b/backend/.kotlin/sessions/kotlin-compiler-2119854500868872929.salive deleted file mode 100644 index e69de29..0000000 diff --git a/backend/README.md b/backend/README.md index 281ea8f..4a24724 100644 --- a/backend/README.md +++ b/backend/README.md @@ -24,6 +24,8 @@ export GRADLE_USER_HOME=/tmp/aioa-gradle-home - 基础 Schema 和审计事件表迁移 - 租户、用户、部门、岗位、任职关系和业务角色模型 - `/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 new file mode 100644 index 0000000..75545be --- /dev/null +++ b/backend/boot/src/main/kotlin/com/all8ai/aioa/approval/api/LeaveRequestController.kt @@ -0,0 +1,104 @@ +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.LeaveStatus +import com.all8ai.aioa.approval.domain.LeaveType +import com.all8ai.aioa.identity.application.CurrentUserService +import jakarta.validation.Valid +import jakarta.validation.constraints.NotBlank +import jakarta.validation.constraints.PositiveOrZero +import jakarta.validation.constraints.Size +import org.springframework.http.HttpStatus +import org.springframework.security.core.annotation.AuthenticationPrincipal +import org.springframework.security.oauth2.jwt.Jwt +import org.springframework.web.bind.annotation.GetMapping +import org.springframework.web.bind.annotation.PathVariable +import org.springframework.web.bind.annotation.PostMapping +import org.springframework.web.bind.annotation.PutMapping +import org.springframework.web.bind.annotation.RequestBody +import org.springframework.web.bind.annotation.RequestHeader +import org.springframework.web.bind.annotation.RequestMapping +import org.springframework.web.bind.annotation.ResponseStatus +import org.springframework.web.bind.annotation.RestController +import java.time.Instant +import java.util.UUID + +@RestController +@RequestMapping("/api/v1/leave-requests") +class LeaveRequestController( + private val currentUserService: CurrentUserService, + private val leaveRequestService: LeaveRequestService, +) { + @PostMapping("/drafts") + @ResponseStatus(HttpStatus.CREATED) + fun createDraft( + @AuthenticationPrincipal jwt: Jwt, + @RequestHeader("Idempotency-Key") idempotencyKey: String, + @Valid @RequestBody request: SaveDraftRequest, + ): LeaveRequestResponse = leaveRequestService + .createDraft(currentUserService.get(jwt.subject, jwt.getClaimAsString("tenant_id")), idempotencyKey, request.toCommand()) + .toResponse() + + @PutMapping("/{id}") + fun updateDraft( + @AuthenticationPrincipal jwt: Jwt, + @PathVariable id: UUID, + @Valid @RequestBody request: SaveDraftRequest, + ): LeaveRequestResponse = leaveRequestService + .updateDraft(currentUserService.get(jwt.subject, jwt.getClaimAsString("tenant_id")), id, request.toCommand()) + .toResponse() + + @GetMapping("/{id}") + fun getOwn( + @AuthenticationPrincipal jwt: Jwt, + @PathVariable id: UUID, + ): LeaveRequestResponse = leaveRequestService + .getOwn(currentUserService.get(jwt.subject, jwt.getClaimAsString("tenant_id")), id) + .toResponse() + + @GetMapping + fun listOwn(@AuthenticationPrincipal jwt: Jwt): List = leaveRequestService + .listOwn(currentUserService.get(jwt.subject, jwt.getClaimAsString("tenant_id"))) + .map(LeaveRequest::toResponse) +} + +data class SaveDraftRequest( + val type: LeaveType, + val startsAt: Instant, + val endsAt: Instant, + @field:NotBlank + @field:Size(max = 2000) + val reason: String, + @field:PositiveOrZero + val version: Long = 0, +) { + fun toCommand() = SaveDraftCommand(type, startsAt, endsAt, reason, version) +} + +data class LeaveRequestResponse( + val id: UUID, + val applicantId: UUID, + val type: LeaveType, + val startsAt: Instant, + val endsAt: Instant, + val reason: String, + val status: LeaveStatus, + val createdAt: Instant, + val updatedAt: Instant, + val version: Long, +) + +private fun LeaveRequest.toResponse() = LeaveRequestResponse( + id = id, + applicantId = applicantId, + type = type, + startsAt = startsAt, + endsAt = endsAt, + reason = reason, + status = status, + createdAt = createdAt, + updatedAt = updatedAt, + version = version, +) 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 new file mode 100644 index 0000000..4da1c94 --- /dev/null +++ b/backend/boot/src/main/kotlin/com/all8ai/aioa/approval/application/LeaveRequestService.kt @@ -0,0 +1,93 @@ +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.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.shared.id.UuidV7 +import com.all8ai.aioa.shared.web.ApiException +import org.springframework.http.HttpStatus +import org.springframework.stereotype.Service +import java.nio.charset.StandardCharsets +import java.security.MessageDigest +import java.time.Instant +import java.util.UUID + +@Service +class LeaveRequestService( + private val repository: LeaveRequestRepository, +) { + fun createDraft(actor: CurrentUser, idempotencyKey: String, command: SaveDraftCommand): LeaveRequest { + validateIdempotencyKey(idempotencyKey) + val content = command.toValidatedContent() + val fingerprint = fingerprint(content) + val result = repository.createDraft( + id = UuidV7.generate(), + tenantId = actor.tenantId, + applicantId = actor.id, + idempotencyKey = idempotencyKey, + fingerprint = fingerprint, + content = content, + ) + if (result.fingerprint != fingerprint) { + throw ApiException( + HttpStatus.CONFLICT, + "IDEMPOTENCY_KEY_REUSED", + "该幂等键已用于不同的请求内容", + ) + } + return result.leaveRequest + } + + fun updateDraft(actor: CurrentUser, id: UUID, command: SaveDraftCommand): LeaveRequest { + val content = command.toValidatedContent() + repository.updateDraft(actor.tenantId, actor.id, id, command.version, content)?.let { return it } + + val existing = repository.findOwn(actor.tenantId, actor.id, id) + ?: throw ApiException(HttpStatus.NOT_FOUND, "LEAVE_REQUEST_NOT_FOUND", "请假申请不存在") + if (existing.status != LeaveStatus.DRAFT) { + throw ApiException(HttpStatus.CONFLICT, "LEAVE_REQUEST_NOT_EDITABLE", "只有草稿可以修改") + } + throw ApiException(HttpStatus.CONFLICT, "VERSION_CONFLICT", "申请已被其他操作更新,请刷新后重试") + } + + fun getOwn(actor: CurrentUser, id: UUID): LeaveRequest = + repository.findOwn(actor.tenantId, actor.id, id) + ?: throw ApiException(HttpStatus.NOT_FOUND, "LEAVE_REQUEST_NOT_FOUND", "请假申请不存在") + + fun listOwn(actor: CurrentUser): List = repository.listOwn(actor.tenantId, actor.id, 100) + + private fun SaveDraftCommand.toValidatedContent(): DraftContent { + if (!endsAt.isAfter(startsAt)) { + throw ApiException(HttpStatus.BAD_REQUEST, "LEAVE_TIME_INVALID", "结束时间必须晚于开始时间") + } + val normalizedReason = reason.trim() + if (normalizedReason.isEmpty() || normalizedReason.length > 2000) { + throw ApiException(HttpStatus.BAD_REQUEST, "LEAVE_REASON_INVALID", "请假原因长度必须为 1 到 2000 个字符") + } + return DraftContent(type, startsAt, endsAt, normalizedReason) + } + + private fun validateIdempotencyKey(value: String) { + if (value.length !in 16..128 || !value.matches(Regex("[A-Za-z0-9._:-]+"))) { + throw ApiException(HttpStatus.BAD_REQUEST, "IDEMPOTENCY_KEY_INVALID", "幂等键格式无效") + } + } + + private fun fingerprint(content: DraftContent): String { + val canonical = listOf(content.type.name, content.startsAt, content.endsAt, content.reason).joinToString("|") + return MessageDigest.getInstance("SHA-256") + .digest(canonical.toByteArray(StandardCharsets.UTF_8)) + .joinToString("") { "%02x".format(it.toInt() and 0xff) } + } +} + +data class SaveDraftCommand( + val type: LeaveType, + val startsAt: Instant, + val endsAt: Instant, + val reason: String, + val version: Long = 0, +) 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 new file mode 100644 index 0000000..fedfa17 --- /dev/null +++ b/backend/boot/src/main/kotlin/com/all8ai/aioa/approval/domain/LeaveRequest.kt @@ -0,0 +1,67 @@ +package com.all8ai.aioa.approval.domain + +import java.time.Instant +import java.util.UUID + +enum class LeaveType { + PERSONAL, + SICK, + ANNUAL, +} + +enum class LeaveStatus { + DRAFT, + PENDING, + APPROVED, + REJECTED, + WITHDRAWN, +} + +data class LeaveRequest( + val id: UUID, + val tenantId: UUID, + val applicantId: UUID, + val type: LeaveType, + val startsAt: Instant, + val endsAt: Instant, + val reason: String, + val status: LeaveStatus, + val createdAt: Instant, + val updatedAt: Instant, + val version: Long, +) + +data class DraftContent( + val type: LeaveType, + val startsAt: Instant, + val endsAt: Instant, + val reason: String, +) + +interface LeaveRequestRepository { + fun createDraft( + id: UUID, + tenantId: UUID, + applicantId: UUID, + idempotencyKey: String, + fingerprint: String, + content: DraftContent, + ): IdempotentDraft + + fun updateDraft( + tenantId: UUID, + applicantId: UUID, + id: UUID, + expectedVersion: Long, + content: DraftContent, + ): LeaveRequest? + + fun findOwn(tenantId: UUID, applicantId: UUID, id: UUID): LeaveRequest? + + fun listOwn(tenantId: UUID, applicantId: UUID, limit: Int): List +} + +data class IdempotentDraft( + val leaveRequest: LeaveRequest, + val fingerprint: String, +) 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 new file mode 100644 index 0000000..48ebc92 --- /dev/null +++ b/backend/boot/src/main/kotlin/com/all8ai/aioa/approval/infrastructure/JooqLeaveRequestRepository.kt @@ -0,0 +1,118 @@ +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.LeaveRequestRepository +import com.all8ai.aioa.approval.domain.LeaveStatus +import com.all8ai.aioa.approval.domain.LeaveType +import org.jooq.DSLContext +import org.jooq.Record +import org.springframework.stereotype.Repository +import java.time.OffsetDateTime +import java.util.UUID + +@Repository +class JooqLeaveRequestRepository( + private val dsl: DSLContext, +) : LeaveRequestRepository { + override fun createDraft( + id: UUID, + tenantId: UUID, + applicantId: UUID, + idempotencyKey: String, + fingerprint: String, + content: DraftContent, + ): IdempotentDraft = dsl.transactionResult { configuration -> + val tx = configuration.dsl() + tx.execute( + """ + INSERT INTO business.leave_request ( + id, tenant_id, applicant_id, leave_type, starts_at, ends_at, + reason, status, idempotency_key, request_fingerprint + ) VALUES (?, ?, ?, ?, CAST(? AS TIMESTAMPTZ), CAST(? AS TIMESTAMPTZ), ?, 'DRAFT', ?, ?) + ON CONFLICT (tenant_id, applicant_id, idempotency_key) DO NOTHING + """.trimIndent(), + id, + tenantId, + applicantId, + content.type.name, + content.startsAt.toString(), + content.endsAt.toString(), + content.reason, + idempotencyKey, + fingerprint, + ) + val record = tx.fetchOne( + """ + SELECT * FROM business.leave_request + WHERE tenant_id = ? AND applicant_id = ? AND idempotency_key = ? + """.trimIndent(), + tenantId, + applicantId, + idempotencyKey, + )!! + IdempotentDraft(map(record), record.get("request_fingerprint", String::class.java)!!.trim()) + } + + override fun updateDraft( + tenantId: UUID, + applicantId: UUID, + id: UUID, + expectedVersion: Long, + content: DraftContent, + ): LeaveRequest? = dsl.fetchOne( + """ + UPDATE business.leave_request + SET leave_type = ?, starts_at = CAST(? AS TIMESTAMPTZ), + ends_at = CAST(? AS TIMESTAMPTZ), reason = ?, + updated_at = CURRENT_TIMESTAMP, version = version + 1 + WHERE tenant_id = ? AND applicant_id = ? AND id = ? + AND status = 'DRAFT' AND version = ? + RETURNING * + """.trimIndent(), + content.type.name, + content.startsAt.toString(), + content.endsAt.toString(), + content.reason, + tenantId, + applicantId, + id, + expectedVersion, + )?.let(::map) + + override fun findOwn(tenantId: UUID, applicantId: UUID, id: UUID): LeaveRequest? = + dsl.fetchOne( + "SELECT * FROM business.leave_request WHERE tenant_id = ? AND applicant_id = ? AND id = ?", + tenantId, + applicantId, + id, + )?.let(::map) + + override fun listOwn(tenantId: UUID, applicantId: UUID, limit: Int): List = + dsl.fetch( + """ + SELECT * FROM business.leave_request + WHERE tenant_id = ? AND applicant_id = ? + ORDER BY created_at DESC, id DESC + LIMIT ? + """.trimIndent(), + tenantId, + applicantId, + limit, + ).map(::map) + + private fun map(record: Record): LeaveRequest = LeaveRequest( + id = record.get("id", UUID::class.java)!!, + tenantId = record.get("tenant_id", UUID::class.java)!!, + applicantId = record.get("applicant_id", UUID::class.java)!!, + type = LeaveType.valueOf(record.get("leave_type", String::class.java)!!), + startsAt = record.get("starts_at", OffsetDateTime::class.java)!!.toInstant(), + endsAt = record.get("ends_at", OffsetDateTime::class.java)!!.toInstant(), + reason = record.get("reason", String::class.java)!!, + status = LeaveStatus.valueOf(record.get("status", String::class.java)!!), + createdAt = record.get("created_at", OffsetDateTime::class.java)!!.toInstant(), + updatedAt = record.get("updated_at", OffsetDateTime::class.java)!!.toInstant(), + version = record.get("version", Long::class.java)!!, + ) +} diff --git a/backend/boot/src/main/kotlin/com/all8ai/aioa/shared/id/UuidV7.kt b/backend/boot/src/main/kotlin/com/all8ai/aioa/shared/id/UuidV7.kt new file mode 100644 index 0000000..598c13b --- /dev/null +++ b/backend/boot/src/main/kotlin/com/all8ai/aioa/shared/id/UuidV7.kt @@ -0,0 +1,17 @@ +package com.all8ai.aioa.shared.id + +import java.security.SecureRandom +import java.util.UUID + +object UuidV7 { + private val random = SecureRandom() + + fun generate(nowMillis: Long = System.currentTimeMillis()): UUID { + require(nowMillis in 0..0xFFFFFFFFFFFFL) { "Timestamp is outside UUIDv7 range" } + val randomA = random.nextInt(1 shl 12).toLong() + val randomB = random.nextLong() and 0x3FFFFFFFFFFFFFFFL + val mostSignificantBits = (nowMillis shl 16) or 0x7000L or randomA + val leastSignificantBits = Long.MIN_VALUE or randomB + return UUID(mostSignificantBits, leastSignificantBits) + } +} diff --git a/backend/boot/src/main/kotlin/com/all8ai/aioa/shared/web/GlobalExceptionHandler.kt b/backend/boot/src/main/kotlin/com/all8ai/aioa/shared/web/GlobalExceptionHandler.kt index fa16956..b8546fd 100644 --- a/backend/boot/src/main/kotlin/com/all8ai/aioa/shared/web/GlobalExceptionHandler.kt +++ b/backend/boot/src/main/kotlin/com/all8ai/aioa/shared/web/GlobalExceptionHandler.kt @@ -26,6 +26,7 @@ class GlobalExceptionHandler { @ExceptionHandler(Exception::class) fun handleUnexpected(exception: Exception, request: HttpServletRequest): ResponseEntity { + logger.error("Unhandled request root cause: {}", exception.rootCause().message) logger.error("Unhandled request error", exception) return response(HttpStatus.INTERNAL_SERVER_ERROR, "INTERNAL_ERROR", "服务器处理请求失败", request) } @@ -46,3 +47,11 @@ class GlobalExceptionHandler { return ResponseEntity.status(status).body(problem) } } + +private fun Throwable.rootCause(): Throwable { + var current = this + while (current.cause != null && current.cause !== current) { + current = current.cause!! + } + return current +} diff --git a/backend/boot/src/main/resources/db/migration/V4__create_leave_requests.sql b/backend/boot/src/main/resources/db/migration/V4__create_leave_requests.sql new file mode 100644 index 0000000..ee46ff6 --- /dev/null +++ b/backend/boot/src/main/resources/db/migration/V4__create_leave_requests.sql @@ -0,0 +1,34 @@ +CREATE TABLE business.leave_request ( + id UUID PRIMARY KEY, + tenant_id UUID NOT NULL REFERENCES identity.tenant(id), + applicant_id UUID NOT NULL, + leave_type VARCHAR(32) NOT NULL, + starts_at TIMESTAMPTZ NOT NULL, + ends_at TIMESTAMPTZ NOT NULL, + reason VARCHAR(2000) NOT NULL, + status VARCHAR(32) NOT NULL, + idempotency_key VARCHAR(128) NOT NULL, + request_fingerprint CHAR(64) NOT NULL, + submitted_at TIMESTAMPTZ, + completed_at TIMESTAMPTZ, + created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, + version BIGINT NOT NULL DEFAULT 0, + CONSTRAINT fk_leave_request_applicant FOREIGN KEY (tenant_id, applicant_id) + REFERENCES identity.user_account(tenant_id, id), + CONSTRAINT ck_leave_request_type + CHECK (leave_type IN ('PERSONAL', 'SICK', 'ANNUAL')), + CONSTRAINT ck_leave_request_status + CHECK (status IN ('DRAFT', 'PENDING', 'APPROVED', 'REJECTED', 'WITHDRAWN')), + CONSTRAINT ck_leave_request_time CHECK (ends_at > starts_at), + CONSTRAINT ck_leave_request_reason CHECK (char_length(reason) BETWEEN 1 AND 2000), + CONSTRAINT uq_leave_request_idempotency + UNIQUE (tenant_id, applicant_id, idempotency_key), + UNIQUE (tenant_id, id) +); + +CREATE INDEX idx_leave_request_applicant_created + ON business.leave_request (tenant_id, applicant_id, created_at DESC); + +CREATE INDEX idx_leave_request_status_created + ON business.leave_request (tenant_id, status, created_at DESC); 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 new file mode 100644 index 0000000..698bf3f --- /dev/null +++ b/backend/boot/src/test/kotlin/com/all8ai/aioa/approval/application/LeaveRequestServiceTest.kt @@ -0,0 +1,122 @@ +package com.all8ai.aioa.approval.application + +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.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.shared.web.ApiException +import org.assertj.core.api.Assertions.assertThat +import org.assertj.core.api.Assertions.assertThatThrownBy +import org.junit.jupiter.api.Test +import org.springframework.http.HttpStatus +import java.time.Instant +import java.util.UUID + +class LeaveRequestServiceTest { + private val actor = CurrentUser( + id = UUID.randomUUID(), + tenantId = UUID.randomUUID(), + username = "employee", + displayName = "员工", + email = null, + department = null, + position = null, + roles = setOf("employee"), + ) + private val command = SaveDraftCommand( + type = LeaveType.PERSONAL, + startsAt = Instant.parse("2026-07-20T01:00:00Z"), + endsAt = Instant.parse("2026-07-20T05:00:00Z"), + reason = "办理个人事务", + ) + + @Test + fun `creates own draft with normalized reason`() { + val repository = InMemoryRepository() + val service = LeaveRequestService(repository) + + val created = service.createDraft(actor, "draft-create-0001", command.copy(reason = " 办理个人事务 ")) + + assertThat(created.applicantId).isEqualTo(actor.id) + assertThat(created.tenantId).isEqualTo(actor.tenantId) + assertThat(created.reason).isEqualTo("办理个人事务") + assertThat(created.status).isEqualTo(LeaveStatus.DRAFT) + } + + @Test + fun `rejects invalid time range before repository call`() { + val service = LeaveRequestService(InMemoryRepository()) + + assertThatThrownBy { + service.createDraft(actor, "draft-create-0002", command.copy(endsAt = command.startsAt)) + }.isInstanceOfSatisfying(ApiException::class.java) { + assertThat(it.status).isEqualTo(HttpStatus.BAD_REQUEST) + assertThat(it.code).isEqualTo("LEAVE_TIME_INVALID") + } + } + + @Test + fun `returns same draft when idempotent request is replayed`() { + val service = LeaveRequestService(InMemoryRepository()) + + val first = service.createDraft(actor, "draft-create-0003", command) + val replay = service.createDraft(actor, "draft-create-0003", command) + + assertThat(replay.id).isEqualTo(first.id) + } + + @Test + fun `rejects same idempotency key with different content`() { + val service = LeaveRequestService(InMemoryRepository()) + service.createDraft(actor, "draft-create-0004", command) + + assertThatThrownBy { + service.createDraft(actor, "draft-create-0004", command.copy(reason = "不同内容")) + }.isInstanceOfSatisfying(ApiException::class.java) { + assertThat(it.status).isEqualTo(HttpStatus.CONFLICT) + assertThat(it.code).isEqualTo("IDEMPOTENCY_KEY_REUSED") + } + } + + private class InMemoryRepository : LeaveRequestRepository { + private val drafts = mutableMapOf, IdempotentDraft>() + + override fun createDraft( + id: UUID, + tenantId: UUID, + applicantId: UUID, + idempotencyKey: String, + fingerprint: String, + content: DraftContent, + ): IdempotentDraft = drafts.getOrPut(Triple(tenantId, applicantId, idempotencyKey)) { + val now = Instant.parse("2026-07-18T00:00:00Z") + IdempotentDraft( + LeaveRequest( + id, tenantId, applicantId, content.type, content.startsAt, content.endsAt, + content.reason, LeaveStatus.DRAFT, now, now, 0, + ), + fingerprint, + ) + } + + override fun updateDraft( + tenantId: UUID, + applicantId: UUID, + id: UUID, + expectedVersion: Long, + content: DraftContent, + ): LeaveRequest? = null + + override fun findOwn(tenantId: UUID, applicantId: UUID, id: UUID): LeaveRequest? = + drafts.values.map { it.leaveRequest } + .firstOrNull { it.tenantId == tenantId && it.applicantId == applicantId && it.id == id } + + override fun listOwn(tenantId: UUID, applicantId: UUID, limit: Int): List = + drafts.values.map { it.leaveRequest } + .filter { it.tenantId == tenantId && it.applicantId == applicantId } + .take(limit) + } +} diff --git a/backend/boot/src/test/kotlin/com/all8ai/aioa/shared/id/UuidV7Test.kt b/backend/boot/src/test/kotlin/com/all8ai/aioa/shared/id/UuidV7Test.kt new file mode 100644 index 0000000..7850489 --- /dev/null +++ b/backend/boot/src/test/kotlin/com/all8ai/aioa/shared/id/UuidV7Test.kt @@ -0,0 +1,15 @@ +package com.all8ai.aioa.shared.id + +import org.assertj.core.api.Assertions.assertThat +import org.junit.jupiter.api.Test + +class UuidV7Test { + @Test + fun `generates version 7 RFC 4122 UUID`() { + val uuid = UuidV7.generate(1_700_000_000_000) + + assertThat(uuid.version()).isEqualTo(7) + assertThat(uuid.variant()).isEqualTo(2) + assertThat(uuid.mostSignificantBits ushr 16).isEqualTo(1_700_000_000_000) + } +} diff --git a/contracts/openapi/aioa-v1.yaml b/contracts/openapi/aioa-v1.yaml index 9a986e1..052a13c 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.1.0 + version: 0.2.0 servers: - url: /api/v1 paths: @@ -19,9 +19,24 @@ paths: "401": $ref: "#/components/responses/Unauthorized" /leave-requests: + get: + operationId: listOwnLeaveRequests + summary: 查询当前用户的请假申请 + responses: + "200": + description: 申请列表 + content: + application/json: + schema: + type: array + items: + $ref: "#/components/schemas/LeaveRequest" + "401": + $ref: "#/components/responses/Unauthorized" + /leave-requests/drafts: post: - operationId: createLeaveRequest - summary: 创建并发起请假申请 + operationId: createLeaveRequestDraft + summary: 创建请假草稿 parameters: - $ref: "#/components/parameters/IdempotencyKey" requestBody: @@ -29,7 +44,7 @@ paths: content: application/json: schema: - $ref: "#/components/schemas/CreateLeaveRequest" + $ref: "#/components/schemas/SaveLeaveDraft" responses: "201": description: 已创建 @@ -43,6 +58,52 @@ paths: $ref: "#/components/responses/Unauthorized" "403": $ref: "#/components/responses/Forbidden" + "409": + $ref: "#/components/responses/Conflict" + /leave-requests/{id}: + parameters: + - name: id + in: path + required: true + schema: { type: string, format: uuid } + get: + operationId: getOwnLeaveRequest + summary: 查询当前用户的单条请假申请 + responses: + "200": + description: 请假申请 + content: + application/json: + schema: + $ref: "#/components/schemas/LeaveRequest" + "401": + $ref: "#/components/responses/Unauthorized" + "404": + $ref: "#/components/responses/NotFound" + put: + operationId: updateLeaveRequestDraft + summary: 修改当前用户的请假草稿 + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/SaveLeaveDraft" + responses: + "200": + description: 已修改 + content: + application/json: + schema: + $ref: "#/components/schemas/LeaveRequest" + "400": + $ref: "#/components/responses/BadRequest" + "401": + $ref: "#/components/responses/Unauthorized" + "404": + $ref: "#/components/responses/NotFound" + "409": + $ref: "#/components/responses/Conflict" components: parameters: IdempotencyKey: @@ -72,6 +133,18 @@ components: application/problem+json: schema: $ref: "#/components/schemas/Problem" + NotFound: + description: 资源不存在或当前用户不可见 + content: + application/problem+json: + schema: + $ref: "#/components/schemas/Problem" + Conflict: + description: 幂等键、状态或乐观锁冲突 + content: + application/problem+json: + schema: + $ref: "#/components/schemas/Problem" schemas: CurrentUser: type: object @@ -102,9 +175,9 @@ components: properties: id: { type: string, format: uuid } name: { type: string } - CreateLeaveRequest: + SaveLeaveDraft: type: object - required: [type, startsAt, endsAt, reason] + required: [type, startsAt, endsAt, reason, version] properties: type: type: string @@ -112,20 +185,25 @@ components: startsAt: { type: string, format: date-time } endsAt: { type: string, format: date-time } reason: { type: string, minLength: 1, maxLength: 2000 } - attachmentIds: - type: array - maxItems: 10 - items: { type: string, format: uuid } + version: { type: integer, format: int64, minimum: 0 } LeaveRequest: type: object - required: [id, status, version, createdAt] + required: [id, applicantId, type, startsAt, endsAt, reason, status, version, createdAt, updatedAt] properties: id: { type: string, format: uuid } + applicantId: { type: string, format: uuid } + type: + type: string + enum: [PERSONAL, SICK, ANNUAL] + startsAt: { type: string, format: date-time } + endsAt: { type: string, format: date-time } + reason: { type: string } status: type: string - enum: [PENDING, APPROVED, REJECTED, WITHDRAWN] + enum: [DRAFT, PENDING, APPROVED, REJECTED, WITHDRAWN] version: { type: integer, minimum: 0 } createdAt: { type: string, format: date-time } + updatedAt: { 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 212cdb8..25dc225 100644 --- a/docs/engineering/roadmap.md +++ b/docs/engineering/roadmap.md @@ -21,7 +21,9 @@ ## M2:请假审批闭环 -- 表单草稿和版本 +- [x] 请假草稿数据模型和版本 +- [x] 草稿创建、修改、本人查询与列表 API +- [x] 创建幂等、租户/用户隔离和乐观锁 - Flowable 流程发布与执行 - 发起、待办、批准、驳回、撤回和时间线 - 附件、通知、弱网恢复和幂等处理