feat: add leave request draft workflow foundation
This commit is contained in:
@@ -6,6 +6,7 @@
|
|||||||
|
|
||||||
# Kotlin / Gradle
|
# Kotlin / Gradle
|
||||||
.gradle/
|
.gradle/
|
||||||
|
.kotlin/
|
||||||
**/build/
|
**/build/
|
||||||
|
|
||||||
# Flutter / Dart
|
# Flutter / Dart
|
||||||
|
|||||||
@@ -24,6 +24,8 @@ export GRADLE_USER_HOME=/tmp/aioa-gradle-home
|
|||||||
- 基础 Schema 和审计事件表迁移
|
- 基础 Schema 和审计事件表迁移
|
||||||
- 租户、用户、部门、岗位、任职关系和业务角色模型
|
- 租户、用户、部门、岗位、任职关系和业务角色模型
|
||||||
- `/api/v1/me` 根据 JWT 租户与 Subject 查询 OA 权威用户数据
|
- `/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)
|
@ExceptionHandler(Exception::class)
|
||||||
fun handleUnexpected(exception: Exception, request: HttpServletRequest): ResponseEntity<ProblemDetail> {
|
fun handleUnexpected(exception: Exception, request: HttpServletRequest): ResponseEntity<ProblemDetail> {
|
||||||
|
logger.error("Unhandled request root cause: {}", exception.rootCause().message)
|
||||||
logger.error("Unhandled request error", exception)
|
logger.error("Unhandled request error", exception)
|
||||||
return response(HttpStatus.INTERNAL_SERVER_ERROR, "INTERNAL_ERROR", "服务器处理请求失败", request)
|
return response(HttpStatus.INTERNAL_SERVER_ERROR, "INTERNAL_ERROR", "服务器处理请求失败", request)
|
||||||
}
|
}
|
||||||
@@ -46,3 +47,11 @@ class GlobalExceptionHandler {
|
|||||||
return ResponseEntity.status(status).body(problem)
|
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
|
openapi: 3.1.0
|
||||||
info:
|
info:
|
||||||
title: AIOA API
|
title: AIOA API
|
||||||
version: 0.1.0
|
version: 0.2.0
|
||||||
servers:
|
servers:
|
||||||
- url: /api/v1
|
- url: /api/v1
|
||||||
paths:
|
paths:
|
||||||
@@ -19,9 +19,24 @@ paths:
|
|||||||
"401":
|
"401":
|
||||||
$ref: "#/components/responses/Unauthorized"
|
$ref: "#/components/responses/Unauthorized"
|
||||||
/leave-requests:
|
/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:
|
post:
|
||||||
operationId: createLeaveRequest
|
operationId: createLeaveRequestDraft
|
||||||
summary: 创建并发起请假申请
|
summary: 创建请假草稿
|
||||||
parameters:
|
parameters:
|
||||||
- $ref: "#/components/parameters/IdempotencyKey"
|
- $ref: "#/components/parameters/IdempotencyKey"
|
||||||
requestBody:
|
requestBody:
|
||||||
@@ -29,7 +44,7 @@ paths:
|
|||||||
content:
|
content:
|
||||||
application/json:
|
application/json:
|
||||||
schema:
|
schema:
|
||||||
$ref: "#/components/schemas/CreateLeaveRequest"
|
$ref: "#/components/schemas/SaveLeaveDraft"
|
||||||
responses:
|
responses:
|
||||||
"201":
|
"201":
|
||||||
description: 已创建
|
description: 已创建
|
||||||
@@ -43,6 +58,52 @@ paths:
|
|||||||
$ref: "#/components/responses/Unauthorized"
|
$ref: "#/components/responses/Unauthorized"
|
||||||
"403":
|
"403":
|
||||||
$ref: "#/components/responses/Forbidden"
|
$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:
|
components:
|
||||||
parameters:
|
parameters:
|
||||||
IdempotencyKey:
|
IdempotencyKey:
|
||||||
@@ -72,6 +133,18 @@ components:
|
|||||||
application/problem+json:
|
application/problem+json:
|
||||||
schema:
|
schema:
|
||||||
$ref: "#/components/schemas/Problem"
|
$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:
|
schemas:
|
||||||
CurrentUser:
|
CurrentUser:
|
||||||
type: object
|
type: object
|
||||||
@@ -102,9 +175,9 @@ components:
|
|||||||
properties:
|
properties:
|
||||||
id: { type: string, format: uuid }
|
id: { type: string, format: uuid }
|
||||||
name: { type: string }
|
name: { type: string }
|
||||||
CreateLeaveRequest:
|
SaveLeaveDraft:
|
||||||
type: object
|
type: object
|
||||||
required: [type, startsAt, endsAt, reason]
|
required: [type, startsAt, endsAt, reason, version]
|
||||||
properties:
|
properties:
|
||||||
type:
|
type:
|
||||||
type: string
|
type: string
|
||||||
@@ -112,20 +185,25 @@ components:
|
|||||||
startsAt: { type: string, format: date-time }
|
startsAt: { type: string, format: date-time }
|
||||||
endsAt: { type: string, format: date-time }
|
endsAt: { type: string, format: date-time }
|
||||||
reason: { type: string, minLength: 1, maxLength: 2000 }
|
reason: { type: string, minLength: 1, maxLength: 2000 }
|
||||||
attachmentIds:
|
version: { type: integer, format: int64, minimum: 0 }
|
||||||
type: array
|
|
||||||
maxItems: 10
|
|
||||||
items: { type: string, format: uuid }
|
|
||||||
LeaveRequest:
|
LeaveRequest:
|
||||||
type: object
|
type: object
|
||||||
required: [id, status, version, createdAt]
|
required: [id, applicantId, type, startsAt, endsAt, reason, status, version, createdAt, updatedAt]
|
||||||
properties:
|
properties:
|
||||||
id: { type: string, format: uuid }
|
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:
|
status:
|
||||||
type: string
|
type: string
|
||||||
enum: [PENDING, APPROVED, REJECTED, WITHDRAWN]
|
enum: [DRAFT, PENDING, APPROVED, REJECTED, WITHDRAWN]
|
||||||
version: { type: integer, minimum: 0 }
|
version: { type: integer, minimum: 0 }
|
||||||
createdAt: { type: string, format: date-time }
|
createdAt: { type: string, format: date-time }
|
||||||
|
updatedAt: { type: string, format: date-time }
|
||||||
Problem:
|
Problem:
|
||||||
type: object
|
type: object
|
||||||
required: [type, title, status, code, traceId]
|
required: [type, title, status, code, traceId]
|
||||||
|
|||||||
@@ -21,7 +21,9 @@
|
|||||||
|
|
||||||
## M2:请假审批闭环
|
## M2:请假审批闭环
|
||||||
|
|
||||||
- 表单草稿和版本
|
- [x] 请假草稿数据模型和版本
|
||||||
|
- [x] 草稿创建、修改、本人查询与列表 API
|
||||||
|
- [x] 创建幂等、租户/用户隔离和乐观锁
|
||||||
- Flowable 流程发布与执行
|
- Flowable 流程发布与执行
|
||||||
- 发起、待办、批准、驳回、撤回和时间线
|
- 发起、待办、批准、驳回、撤回和时间线
|
||||||
- 附件、通知、弱网恢复和幂等处理
|
- 附件、通知、弱网恢复和幂等处理
|
||||||
|
|||||||
Reference in New Issue
Block a user