feat: add leave request draft workflow foundation
This commit is contained in:
@@ -6,6 +6,7 @@
|
||||
|
||||
# Kotlin / Gradle
|
||||
.gradle/
|
||||
.kotlin/
|
||||
**/build/
|
||||
|
||||
# Flutter / Dart
|
||||
|
||||
@@ -24,6 +24,8 @@ export GRADLE_USER_HOME=/tmp/aioa-gradle-home
|
||||
- 基础 Schema 和审计事件表迁移
|
||||
- 租户、用户、部门、岗位、任职关系和业务角色模型
|
||||
- `/api/v1/me` 根据 JWT 租户与 Subject 查询 OA 权威用户数据
|
||||
- 请假草稿创建、修改、本人查询和列表
|
||||
- UUIDv7、写入幂等、租户隔离和乐观锁冲突处理
|
||||
|
||||
## 开发身份
|
||||
|
||||
|
||||
@@ -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<LeaveRequestResponse> = 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,
|
||||
)
|
||||
+93
@@ -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<LeaveRequest> = 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,
|
||||
)
|
||||
@@ -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<LeaveRequest>
|
||||
}
|
||||
|
||||
data class IdempotentDraft(
|
||||
val leaveRequest: LeaveRequest,
|
||||
val fingerprint: String,
|
||||
)
|
||||
+118
@@ -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<LeaveRequest> =
|
||||
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)!!,
|
||||
)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -26,6 +26,7 @@ class GlobalExceptionHandler {
|
||||
|
||||
@ExceptionHandler(Exception::class)
|
||||
fun handleUnexpected(exception: Exception, request: HttpServletRequest): ResponseEntity<ProblemDetail> {
|
||||
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
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
+122
@@ -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<Triple<UUID, UUID, String>, 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<LeaveRequest> =
|
||||
drafts.values.map { it.leaveRequest }
|
||||
.filter { it.tenantId == tenantId && it.applicantId == applicantId }
|
||||
.take(limit)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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]
|
||||
|
||||
@@ -21,7 +21,9 @@
|
||||
|
||||
## M2:请假审批闭环
|
||||
|
||||
- 表单草稿和版本
|
||||
- [x] 请假草稿数据模型和版本
|
||||
- [x] 草稿创建、修改、本人查询与列表 API
|
||||
- [x] 创建幂等、租户/用户隔离和乐观锁
|
||||
- Flowable 流程发布与执行
|
||||
- 发起、待办、批准、驳回、撤回和时间线
|
||||
- 附件、通知、弱网恢复和幂等处理
|
||||
|
||||
Reference in New Issue
Block a user