feat: add leave request draft workflow foundation

This commit is contained in:
selfrelease
2026-07-18 08:14:05 +08:00
parent bbb09ed56f
commit 7107c7cc82
14 changed files with 675 additions and 13 deletions
+1
View File
@@ -6,6 +6,7 @@
# Kotlin / Gradle
.gradle/
.kotlin/
**/build/
# Flutter / Dart
+2
View File
@@ -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,
)
@@ -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,
)
@@ -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);
@@ -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)
}
}
+90 -12
View File
@@ -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]
+3 -1
View File
@@ -21,7 +21,9 @@
## M2:请假审批闭环
- 表单草稿和版本
- [x] 请假草稿数据模型和版本
- [x] 草稿创建、修改、本人查询与列表 API
- [x] 创建幂等、租户/用户隔离和乐观锁
- Flowable 流程发布与执行
- 发起、待办、批准、驳回、撤回和时间线
- 附件、通知、弱网恢复和幂等处理