feat: add leave submission withdrawal and audit trail
This commit is contained in:
@@ -26,6 +26,7 @@ export GRADLE_USER_HOME=/tmp/aioa-gradle-home
|
|||||||
- `/api/v1/me` 根据 JWT 租户与 Subject 查询 OA 权威用户数据
|
- `/api/v1/me` 根据 JWT 租户与 Subject 查询 OA 权威用户数据
|
||||||
- 请假草稿创建、修改、本人查询和列表
|
- 请假草稿创建、修改、本人查询和列表
|
||||||
- UUIDv7、写入幂等、租户隔离和乐观锁冲突处理
|
- UUIDv7、写入幂等、租户隔离和乐观锁冲突处理
|
||||||
|
- 请假提交、撤回、状态时间线与事务内审计
|
||||||
|
|
||||||
## 开发身份
|
## 开发身份
|
||||||
|
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ package com.all8ai.aioa.approval.api
|
|||||||
import com.all8ai.aioa.approval.application.LeaveRequestService
|
import com.all8ai.aioa.approval.application.LeaveRequestService
|
||||||
import com.all8ai.aioa.approval.application.SaveDraftCommand
|
import com.all8ai.aioa.approval.application.SaveDraftCommand
|
||||||
import com.all8ai.aioa.approval.domain.LeaveRequest
|
import com.all8ai.aioa.approval.domain.LeaveRequest
|
||||||
|
import com.all8ai.aioa.approval.domain.LeaveRequestEvent
|
||||||
import com.all8ai.aioa.approval.domain.LeaveStatus
|
import com.all8ai.aioa.approval.domain.LeaveStatus
|
||||||
import com.all8ai.aioa.approval.domain.LeaveType
|
import com.all8ai.aioa.approval.domain.LeaveType
|
||||||
import com.all8ai.aioa.identity.application.CurrentUserService
|
import com.all8ai.aioa.identity.application.CurrentUserService
|
||||||
@@ -62,6 +63,42 @@ class LeaveRequestController(
|
|||||||
fun listOwn(@AuthenticationPrincipal jwt: Jwt): List<LeaveRequestResponse> = leaveRequestService
|
fun listOwn(@AuthenticationPrincipal jwt: Jwt): List<LeaveRequestResponse> = leaveRequestService
|
||||||
.listOwn(currentUserService.get(jwt.subject, jwt.getClaimAsString("tenant_id")))
|
.listOwn(currentUserService.get(jwt.subject, jwt.getClaimAsString("tenant_id")))
|
||||||
.map(LeaveRequest::toResponse)
|
.map(LeaveRequest::toResponse)
|
||||||
|
|
||||||
|
@PostMapping("/{id}/submit")
|
||||||
|
fun submit(
|
||||||
|
@AuthenticationPrincipal jwt: Jwt,
|
||||||
|
@PathVariable id: UUID,
|
||||||
|
@RequestHeader("Idempotency-Key") idempotencyKey: String,
|
||||||
|
@Valid @RequestBody request: TransitionRequest,
|
||||||
|
): LeaveRequestResponse = leaveRequestService
|
||||||
|
.submit(
|
||||||
|
currentUserService.get(jwt.subject, jwt.getClaimAsString("tenant_id")),
|
||||||
|
id,
|
||||||
|
idempotencyKey,
|
||||||
|
request.version,
|
||||||
|
).toResponse()
|
||||||
|
|
||||||
|
@PostMapping("/{id}/withdraw")
|
||||||
|
fun withdraw(
|
||||||
|
@AuthenticationPrincipal jwt: Jwt,
|
||||||
|
@PathVariable id: UUID,
|
||||||
|
@RequestHeader("Idempotency-Key") idempotencyKey: String,
|
||||||
|
@Valid @RequestBody request: TransitionRequest,
|
||||||
|
): LeaveRequestResponse = leaveRequestService
|
||||||
|
.withdraw(
|
||||||
|
currentUserService.get(jwt.subject, jwt.getClaimAsString("tenant_id")),
|
||||||
|
id,
|
||||||
|
idempotencyKey,
|
||||||
|
request.version,
|
||||||
|
).toResponse()
|
||||||
|
|
||||||
|
@GetMapping("/{id}/timeline")
|
||||||
|
fun timeline(
|
||||||
|
@AuthenticationPrincipal jwt: Jwt,
|
||||||
|
@PathVariable id: UUID,
|
||||||
|
): List<LeaveRequestEventResponse> = leaveRequestService
|
||||||
|
.timeline(currentUserService.get(jwt.subject, jwt.getClaimAsString("tenant_id")), id)
|
||||||
|
.map(LeaveRequestEvent::toResponse)
|
||||||
}
|
}
|
||||||
|
|
||||||
data class SaveDraftRequest(
|
data class SaveDraftRequest(
|
||||||
@@ -90,6 +127,21 @@ data class LeaveRequestResponse(
|
|||||||
val version: Long,
|
val version: Long,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
data class TransitionRequest(
|
||||||
|
@field:PositiveOrZero
|
||||||
|
val version: Long,
|
||||||
|
)
|
||||||
|
|
||||||
|
data class LeaveRequestEventResponse(
|
||||||
|
val id: UUID,
|
||||||
|
val eventType: String,
|
||||||
|
val fromStatus: LeaveStatus,
|
||||||
|
val toStatus: LeaveStatus,
|
||||||
|
val actorId: UUID,
|
||||||
|
val traceId: String,
|
||||||
|
val occurredAt: Instant,
|
||||||
|
)
|
||||||
|
|
||||||
private fun LeaveRequest.toResponse() = LeaveRequestResponse(
|
private fun LeaveRequest.toResponse() = LeaveRequestResponse(
|
||||||
id = id,
|
id = id,
|
||||||
applicantId = applicantId,
|
applicantId = applicantId,
|
||||||
@@ -102,3 +154,13 @@ private fun LeaveRequest.toResponse() = LeaveRequestResponse(
|
|||||||
updatedAt = updatedAt,
|
updatedAt = updatedAt,
|
||||||
version = version,
|
version = version,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
private fun LeaveRequestEvent.toResponse() = LeaveRequestEventResponse(
|
||||||
|
id = id,
|
||||||
|
eventType = eventType,
|
||||||
|
fromStatus = fromStatus,
|
||||||
|
toStatus = toStatus,
|
||||||
|
actorId = actorId,
|
||||||
|
traceId = traceId,
|
||||||
|
occurredAt = occurredAt,
|
||||||
|
)
|
||||||
|
|||||||
+108
@@ -2,14 +2,19 @@ package com.all8ai.aioa.approval.application
|
|||||||
|
|
||||||
import com.all8ai.aioa.approval.domain.DraftContent
|
import com.all8ai.aioa.approval.domain.DraftContent
|
||||||
import com.all8ai.aioa.approval.domain.LeaveRequest
|
import com.all8ai.aioa.approval.domain.LeaveRequest
|
||||||
|
import com.all8ai.aioa.approval.domain.LeaveRequestEvent
|
||||||
import com.all8ai.aioa.approval.domain.LeaveRequestRepository
|
import com.all8ai.aioa.approval.domain.LeaveRequestRepository
|
||||||
import com.all8ai.aioa.approval.domain.LeaveStatus
|
import com.all8ai.aioa.approval.domain.LeaveStatus
|
||||||
import com.all8ai.aioa.approval.domain.LeaveType
|
import com.all8ai.aioa.approval.domain.LeaveType
|
||||||
import com.all8ai.aioa.identity.domain.CurrentUser
|
import com.all8ai.aioa.identity.domain.CurrentUser
|
||||||
|
import com.all8ai.aioa.audit.application.AuditService
|
||||||
import com.all8ai.aioa.shared.id.UuidV7
|
import com.all8ai.aioa.shared.id.UuidV7
|
||||||
import com.all8ai.aioa.shared.web.ApiException
|
import com.all8ai.aioa.shared.web.ApiException
|
||||||
import org.springframework.http.HttpStatus
|
import org.springframework.http.HttpStatus
|
||||||
import org.springframework.stereotype.Service
|
import org.springframework.stereotype.Service
|
||||||
|
import org.springframework.transaction.annotation.Transactional
|
||||||
|
import org.slf4j.MDC
|
||||||
|
import com.all8ai.aioa.shared.web.TraceIdFilter
|
||||||
import java.nio.charset.StandardCharsets
|
import java.nio.charset.StandardCharsets
|
||||||
import java.security.MessageDigest
|
import java.security.MessageDigest
|
||||||
import java.time.Instant
|
import java.time.Instant
|
||||||
@@ -18,6 +23,7 @@ import java.util.UUID
|
|||||||
@Service
|
@Service
|
||||||
class LeaveRequestService(
|
class LeaveRequestService(
|
||||||
private val repository: LeaveRequestRepository,
|
private val repository: LeaveRequestRepository,
|
||||||
|
private val auditService: AuditService,
|
||||||
) {
|
) {
|
||||||
fun createDraft(actor: CurrentUser, idempotencyKey: String, command: SaveDraftCommand): LeaveRequest {
|
fun createDraft(actor: CurrentUser, idempotencyKey: String, command: SaveDraftCommand): LeaveRequest {
|
||||||
validateIdempotencyKey(idempotencyKey)
|
validateIdempotencyKey(idempotencyKey)
|
||||||
@@ -59,6 +65,101 @@ class LeaveRequestService(
|
|||||||
|
|
||||||
fun listOwn(actor: CurrentUser): List<LeaveRequest> = repository.listOwn(actor.tenantId, actor.id, 100)
|
fun listOwn(actor: CurrentUser): List<LeaveRequest> = repository.listOwn(actor.tenantId, actor.id, 100)
|
||||||
|
|
||||||
|
@Transactional
|
||||||
|
fun submit(actor: CurrentUser, id: UUID, idempotencyKey: String, expectedVersion: Long): LeaveRequest =
|
||||||
|
transition(
|
||||||
|
actor = actor,
|
||||||
|
id = id,
|
||||||
|
idempotencyKey = idempotencyKey,
|
||||||
|
expectedVersion = expectedVersion,
|
||||||
|
operation = "LEAVE_SUBMIT",
|
||||||
|
action = "LEAVE_REQUEST_SUBMITTED",
|
||||||
|
fromStatus = LeaveStatus.DRAFT,
|
||||||
|
toStatus = LeaveStatus.PENDING,
|
||||||
|
)
|
||||||
|
|
||||||
|
@Transactional
|
||||||
|
fun withdraw(actor: CurrentUser, id: UUID, idempotencyKey: String, expectedVersion: Long): LeaveRequest =
|
||||||
|
transition(
|
||||||
|
actor = actor,
|
||||||
|
id = id,
|
||||||
|
idempotencyKey = idempotencyKey,
|
||||||
|
expectedVersion = expectedVersion,
|
||||||
|
operation = "LEAVE_WITHDRAW",
|
||||||
|
action = "LEAVE_REQUEST_WITHDRAWN",
|
||||||
|
fromStatus = LeaveStatus.PENDING,
|
||||||
|
toStatus = LeaveStatus.WITHDRAWN,
|
||||||
|
)
|
||||||
|
|
||||||
|
fun timeline(actor: CurrentUser, id: UUID): List<LeaveRequestEvent> {
|
||||||
|
getOwn(actor, id)
|
||||||
|
return repository.listTimeline(actor.tenantId, id)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun transition(
|
||||||
|
actor: CurrentUser,
|
||||||
|
id: UUID,
|
||||||
|
idempotencyKey: String,
|
||||||
|
expectedVersion: Long,
|
||||||
|
operation: String,
|
||||||
|
action: String,
|
||||||
|
fromStatus: LeaveStatus,
|
||||||
|
toStatus: LeaveStatus,
|
||||||
|
): LeaveRequest {
|
||||||
|
validateIdempotencyKey(idempotencyKey)
|
||||||
|
if (expectedVersion < 0) {
|
||||||
|
throw ApiException(HttpStatus.BAD_REQUEST, "VERSION_INVALID", "版本号不能小于零")
|
||||||
|
}
|
||||||
|
val fingerprint = operationFingerprint(operation, id, expectedVersion)
|
||||||
|
val claim = repository.claimOperation(
|
||||||
|
UuidV7.generate(), actor.tenantId, actor.id, operation,
|
||||||
|
idempotencyKey, fingerprint, id,
|
||||||
|
)
|
||||||
|
if (claim.fingerprint != fingerprint || claim.resourceId != id) {
|
||||||
|
throw ApiException(HttpStatus.CONFLICT, "IDEMPOTENCY_KEY_REUSED", "该幂等键已用于不同的操作参数")
|
||||||
|
}
|
||||||
|
if (!claim.claimed) {
|
||||||
|
return getOwn(actor, id)
|
||||||
|
}
|
||||||
|
|
||||||
|
val traceId = MDC.get(TraceIdFilter.MDC_TRACE_ID) ?: "unknown"
|
||||||
|
val transitioned = repository.transitionOwn(
|
||||||
|
actor.tenantId, actor.id, id, expectedVersion, fromStatus, toStatus,
|
||||||
|
UuidV7.generate(), action, traceId,
|
||||||
|
) ?: transitionFailure(actor, id, expectedVersion, fromStatus)
|
||||||
|
|
||||||
|
auditService.recordSuccess(
|
||||||
|
actor = actor,
|
||||||
|
action = action,
|
||||||
|
resourceType = "LEAVE_REQUEST",
|
||||||
|
resourceId = id.toString(),
|
||||||
|
idempotencyKey = idempotencyKey,
|
||||||
|
details = mapOf(
|
||||||
|
"fromStatus" to fromStatus.name,
|
||||||
|
"toStatus" to toStatus.name,
|
||||||
|
"version" to transitioned.version,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
return transitioned
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun transitionFailure(
|
||||||
|
actor: CurrentUser,
|
||||||
|
id: UUID,
|
||||||
|
expectedVersion: Long,
|
||||||
|
requiredStatus: LeaveStatus,
|
||||||
|
): Nothing {
|
||||||
|
val existing = repository.findOwn(actor.tenantId, actor.id, id)
|
||||||
|
?: throw ApiException(HttpStatus.NOT_FOUND, "LEAVE_REQUEST_NOT_FOUND", "请假申请不存在")
|
||||||
|
if (existing.status != requiredStatus) {
|
||||||
|
throw ApiException(HttpStatus.CONFLICT, "LEAVE_STATUS_INVALID", "当前申请状态不允许执行该操作")
|
||||||
|
}
|
||||||
|
if (existing.version != expectedVersion) {
|
||||||
|
throw ApiException(HttpStatus.CONFLICT, "VERSION_CONFLICT", "申请已被其他操作更新,请刷新后重试")
|
||||||
|
}
|
||||||
|
throw ApiException(HttpStatus.CONFLICT, "LEAVE_TRANSITION_CONFLICT", "申请状态转换失败")
|
||||||
|
}
|
||||||
|
|
||||||
private fun SaveDraftCommand.toValidatedContent(): DraftContent {
|
private fun SaveDraftCommand.toValidatedContent(): DraftContent {
|
||||||
if (!endsAt.isAfter(startsAt)) {
|
if (!endsAt.isAfter(startsAt)) {
|
||||||
throw ApiException(HttpStatus.BAD_REQUEST, "LEAVE_TIME_INVALID", "结束时间必须晚于开始时间")
|
throw ApiException(HttpStatus.BAD_REQUEST, "LEAVE_TIME_INVALID", "结束时间必须晚于开始时间")
|
||||||
@@ -82,6 +183,13 @@ class LeaveRequestService(
|
|||||||
.digest(canonical.toByteArray(StandardCharsets.UTF_8))
|
.digest(canonical.toByteArray(StandardCharsets.UTF_8))
|
||||||
.joinToString("") { "%02x".format(it.toInt() and 0xff) }
|
.joinToString("") { "%02x".format(it.toInt() and 0xff) }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun operationFingerprint(operation: String, id: UUID, version: Long): String {
|
||||||
|
val canonical = "$operation|$id|$version"
|
||||||
|
return MessageDigest.getInstance("SHA-256")
|
||||||
|
.digest(canonical.toByteArray(StandardCharsets.UTF_8))
|
||||||
|
.joinToString("") { "%02x".format(it.toInt() and 0xff) }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
data class SaveDraftCommand(
|
data class SaveDraftCommand(
|
||||||
|
|||||||
@@ -59,9 +59,49 @@ interface LeaveRequestRepository {
|
|||||||
fun findOwn(tenantId: UUID, applicantId: UUID, id: UUID): LeaveRequest?
|
fun findOwn(tenantId: UUID, applicantId: UUID, id: UUID): LeaveRequest?
|
||||||
|
|
||||||
fun listOwn(tenantId: UUID, applicantId: UUID, limit: Int): List<LeaveRequest>
|
fun listOwn(tenantId: UUID, applicantId: UUID, limit: Int): List<LeaveRequest>
|
||||||
|
|
||||||
|
fun claimOperation(
|
||||||
|
id: UUID,
|
||||||
|
tenantId: UUID,
|
||||||
|
actorId: UUID,
|
||||||
|
operation: String,
|
||||||
|
idempotencyKey: String,
|
||||||
|
fingerprint: String,
|
||||||
|
resourceId: UUID,
|
||||||
|
): OperationClaim
|
||||||
|
|
||||||
|
fun transitionOwn(
|
||||||
|
tenantId: UUID,
|
||||||
|
actorId: UUID,
|
||||||
|
id: UUID,
|
||||||
|
expectedVersion: Long,
|
||||||
|
fromStatus: LeaveStatus,
|
||||||
|
toStatus: LeaveStatus,
|
||||||
|
eventId: UUID,
|
||||||
|
eventType: String,
|
||||||
|
traceId: String,
|
||||||
|
): LeaveRequest?
|
||||||
|
|
||||||
|
fun listTimeline(tenantId: UUID, leaveRequestId: UUID): List<LeaveRequestEvent>
|
||||||
}
|
}
|
||||||
|
|
||||||
data class IdempotentDraft(
|
data class IdempotentDraft(
|
||||||
val leaveRequest: LeaveRequest,
|
val leaveRequest: LeaveRequest,
|
||||||
val fingerprint: String,
|
val fingerprint: String,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
data class OperationClaim(
|
||||||
|
val claimed: Boolean,
|
||||||
|
val fingerprint: String,
|
||||||
|
val resourceId: UUID,
|
||||||
|
)
|
||||||
|
|
||||||
|
data class LeaveRequestEvent(
|
||||||
|
val id: UUID,
|
||||||
|
val eventType: String,
|
||||||
|
val fromStatus: LeaveStatus,
|
||||||
|
val toStatus: LeaveStatus,
|
||||||
|
val actorId: UUID,
|
||||||
|
val traceId: String,
|
||||||
|
val occurredAt: Instant,
|
||||||
|
)
|
||||||
|
|||||||
+119
@@ -3,9 +3,11 @@ package com.all8ai.aioa.approval.infrastructure
|
|||||||
import com.all8ai.aioa.approval.domain.DraftContent
|
import com.all8ai.aioa.approval.domain.DraftContent
|
||||||
import com.all8ai.aioa.approval.domain.IdempotentDraft
|
import com.all8ai.aioa.approval.domain.IdempotentDraft
|
||||||
import com.all8ai.aioa.approval.domain.LeaveRequest
|
import com.all8ai.aioa.approval.domain.LeaveRequest
|
||||||
|
import com.all8ai.aioa.approval.domain.LeaveRequestEvent
|
||||||
import com.all8ai.aioa.approval.domain.LeaveRequestRepository
|
import com.all8ai.aioa.approval.domain.LeaveRequestRepository
|
||||||
import com.all8ai.aioa.approval.domain.LeaveStatus
|
import com.all8ai.aioa.approval.domain.LeaveStatus
|
||||||
import com.all8ai.aioa.approval.domain.LeaveType
|
import com.all8ai.aioa.approval.domain.LeaveType
|
||||||
|
import com.all8ai.aioa.approval.domain.OperationClaim
|
||||||
import org.jooq.DSLContext
|
import org.jooq.DSLContext
|
||||||
import org.jooq.Record
|
import org.jooq.Record
|
||||||
import org.springframework.stereotype.Repository
|
import org.springframework.stereotype.Repository
|
||||||
@@ -102,6 +104,123 @@ class JooqLeaveRequestRepository(
|
|||||||
limit,
|
limit,
|
||||||
).map(::map)
|
).map(::map)
|
||||||
|
|
||||||
|
override fun claimOperation(
|
||||||
|
id: UUID,
|
||||||
|
tenantId: UUID,
|
||||||
|
actorId: UUID,
|
||||||
|
operation: String,
|
||||||
|
idempotencyKey: String,
|
||||||
|
fingerprint: String,
|
||||||
|
resourceId: UUID,
|
||||||
|
): OperationClaim {
|
||||||
|
val inserted = dsl.execute(
|
||||||
|
"""
|
||||||
|
INSERT INTO business.idempotency_record (
|
||||||
|
id, tenant_id, actor_id, operation, idempotency_key,
|
||||||
|
request_fingerprint, resource_id
|
||||||
|
) VALUES (?, ?, ?, ?, ?, ?, ?)
|
||||||
|
ON CONFLICT (tenant_id, actor_id, operation, idempotency_key) DO NOTHING
|
||||||
|
""".trimIndent(),
|
||||||
|
id,
|
||||||
|
tenantId,
|
||||||
|
actorId,
|
||||||
|
operation,
|
||||||
|
idempotencyKey,
|
||||||
|
fingerprint,
|
||||||
|
resourceId,
|
||||||
|
)
|
||||||
|
val record = dsl.fetchOne(
|
||||||
|
"""
|
||||||
|
SELECT request_fingerprint, resource_id
|
||||||
|
FROM business.idempotency_record
|
||||||
|
WHERE tenant_id = ? AND actor_id = ? AND operation = ? AND idempotency_key = ?
|
||||||
|
""".trimIndent(),
|
||||||
|
tenantId,
|
||||||
|
actorId,
|
||||||
|
operation,
|
||||||
|
idempotencyKey,
|
||||||
|
)!!
|
||||||
|
return OperationClaim(
|
||||||
|
claimed = inserted == 1,
|
||||||
|
fingerprint = record.get("request_fingerprint", String::class.java)!!.trim(),
|
||||||
|
resourceId = record.get("resource_id", UUID::class.java)!!,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun transitionOwn(
|
||||||
|
tenantId: UUID,
|
||||||
|
actorId: UUID,
|
||||||
|
id: UUID,
|
||||||
|
expectedVersion: Long,
|
||||||
|
fromStatus: LeaveStatus,
|
||||||
|
toStatus: LeaveStatus,
|
||||||
|
eventId: UUID,
|
||||||
|
eventType: String,
|
||||||
|
traceId: String,
|
||||||
|
): LeaveRequest? {
|
||||||
|
val transitioned = dsl.fetchOne(
|
||||||
|
"""
|
||||||
|
UPDATE business.leave_request
|
||||||
|
SET status = ?,
|
||||||
|
submitted_at = CASE WHEN ? = 'PENDING' THEN CURRENT_TIMESTAMP ELSE submitted_at END,
|
||||||
|
completed_at = CASE WHEN ? IN ('APPROVED', 'REJECTED', 'WITHDRAWN')
|
||||||
|
THEN CURRENT_TIMESTAMP ELSE completed_at END,
|
||||||
|
updated_at = CURRENT_TIMESTAMP,
|
||||||
|
version = version + 1
|
||||||
|
WHERE tenant_id = ? AND applicant_id = ? AND id = ?
|
||||||
|
AND status = ? AND version = ?
|
||||||
|
RETURNING *
|
||||||
|
""".trimIndent(),
|
||||||
|
toStatus.name,
|
||||||
|
toStatus.name,
|
||||||
|
toStatus.name,
|
||||||
|
tenantId,
|
||||||
|
actorId,
|
||||||
|
id,
|
||||||
|
fromStatus.name,
|
||||||
|
expectedVersion,
|
||||||
|
)?.let(::map) ?: return null
|
||||||
|
|
||||||
|
dsl.execute(
|
||||||
|
"""
|
||||||
|
INSERT INTO business.leave_request_event (
|
||||||
|
id, tenant_id, leave_request_id, actor_id, event_type,
|
||||||
|
from_status, to_status, trace_id
|
||||||
|
) VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
||||||
|
""".trimIndent(),
|
||||||
|
eventId,
|
||||||
|
tenantId,
|
||||||
|
id,
|
||||||
|
actorId,
|
||||||
|
eventType,
|
||||||
|
fromStatus.name,
|
||||||
|
toStatus.name,
|
||||||
|
traceId,
|
||||||
|
)
|
||||||
|
return transitioned
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun listTimeline(tenantId: UUID, leaveRequestId: UUID): List<LeaveRequestEvent> =
|
||||||
|
dsl.fetch(
|
||||||
|
"""
|
||||||
|
SELECT * FROM business.leave_request_event
|
||||||
|
WHERE tenant_id = ? AND leave_request_id = ?
|
||||||
|
ORDER BY occurred_at, id
|
||||||
|
""".trimIndent(),
|
||||||
|
tenantId,
|
||||||
|
leaveRequestId,
|
||||||
|
).map { record ->
|
||||||
|
LeaveRequestEvent(
|
||||||
|
id = record.get("id", UUID::class.java)!!,
|
||||||
|
eventType = record.get("event_type", String::class.java)!!,
|
||||||
|
fromStatus = LeaveStatus.valueOf(record.get("from_status", String::class.java)!!),
|
||||||
|
toStatus = LeaveStatus.valueOf(record.get("to_status", String::class.java)!!),
|
||||||
|
actorId = record.get("actor_id", UUID::class.java)!!,
|
||||||
|
traceId = record.get("trace_id", String::class.java)!!,
|
||||||
|
occurredAt = record.get("occurred_at", OffsetDateTime::class.java)!!.toInstant(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
private fun map(record: Record): LeaveRequest = LeaveRequest(
|
private fun map(record: Record): LeaveRequest = LeaveRequest(
|
||||||
id = record.get("id", UUID::class.java)!!,
|
id = record.get("id", UUID::class.java)!!,
|
||||||
tenantId = record.get("tenant_id", UUID::class.java)!!,
|
tenantId = record.get("tenant_id", UUID::class.java)!!,
|
||||||
|
|||||||
@@ -0,0 +1,38 @@
|
|||||||
|
package com.all8ai.aioa.audit.application
|
||||||
|
|
||||||
|
import com.all8ai.aioa.audit.domain.AuditEvent
|
||||||
|
import com.all8ai.aioa.audit.domain.AuditEventRepository
|
||||||
|
import com.all8ai.aioa.identity.domain.CurrentUser
|
||||||
|
import com.all8ai.aioa.shared.id.UuidV7
|
||||||
|
import com.all8ai.aioa.shared.web.TraceIdFilter
|
||||||
|
import org.slf4j.MDC
|
||||||
|
import org.springframework.stereotype.Service
|
||||||
|
|
||||||
|
@Service
|
||||||
|
class AuditService(
|
||||||
|
private val repository: AuditEventRepository,
|
||||||
|
) {
|
||||||
|
fun recordSuccess(
|
||||||
|
actor: CurrentUser,
|
||||||
|
action: String,
|
||||||
|
resourceType: String,
|
||||||
|
resourceId: String,
|
||||||
|
idempotencyKey: String?,
|
||||||
|
details: Map<String, Any?>,
|
||||||
|
) {
|
||||||
|
repository.append(
|
||||||
|
AuditEvent(
|
||||||
|
id = UuidV7.generate(),
|
||||||
|
tenantId = actor.tenantId,
|
||||||
|
actorId = actor.id,
|
||||||
|
action = action,
|
||||||
|
resourceType = resourceType,
|
||||||
|
resourceId = resourceId,
|
||||||
|
traceId = MDC.get(TraceIdFilter.MDC_TRACE_ID) ?: "unknown",
|
||||||
|
idempotencyKey = idempotencyKey,
|
||||||
|
result = "SUCCESS",
|
||||||
|
details = details,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
package com.all8ai.aioa.audit.domain
|
||||||
|
|
||||||
|
import java.util.UUID
|
||||||
|
|
||||||
|
data class AuditEvent(
|
||||||
|
val id: UUID,
|
||||||
|
val tenantId: UUID,
|
||||||
|
val actorId: UUID,
|
||||||
|
val action: String,
|
||||||
|
val resourceType: String,
|
||||||
|
val resourceId: String,
|
||||||
|
val traceId: String,
|
||||||
|
val idempotencyKey: String?,
|
||||||
|
val result: String,
|
||||||
|
val details: Map<String, Any?>,
|
||||||
|
)
|
||||||
|
|
||||||
|
fun interface AuditEventRepository {
|
||||||
|
fun append(event: AuditEvent)
|
||||||
|
}
|
||||||
+34
@@ -0,0 +1,34 @@
|
|||||||
|
package com.all8ai.aioa.audit.infrastructure
|
||||||
|
|
||||||
|
import com.all8ai.aioa.audit.domain.AuditEvent
|
||||||
|
import com.all8ai.aioa.audit.domain.AuditEventRepository
|
||||||
|
import com.fasterxml.jackson.databind.ObjectMapper
|
||||||
|
import org.jooq.DSLContext
|
||||||
|
import org.springframework.stereotype.Repository
|
||||||
|
|
||||||
|
@Repository
|
||||||
|
class JooqAuditEventRepository(
|
||||||
|
private val dsl: DSLContext,
|
||||||
|
private val objectMapper: ObjectMapper,
|
||||||
|
) : AuditEventRepository {
|
||||||
|
override fun append(event: AuditEvent) {
|
||||||
|
dsl.execute(
|
||||||
|
"""
|
||||||
|
INSERT INTO audit.event (
|
||||||
|
id, tenant_id, actor_id, action, resource_type, resource_id,
|
||||||
|
trace_id, idempotency_key, result, details
|
||||||
|
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, CAST(? AS JSONB))
|
||||||
|
""".trimIndent(),
|
||||||
|
event.id,
|
||||||
|
event.tenantId,
|
||||||
|
event.actorId,
|
||||||
|
event.action,
|
||||||
|
event.resourceType,
|
||||||
|
event.resourceId,
|
||||||
|
event.traceId,
|
||||||
|
event.idempotencyKey,
|
||||||
|
event.result,
|
||||||
|
objectMapper.writeValueAsString(event.details),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
+32
@@ -0,0 +1,32 @@
|
|||||||
|
CREATE TABLE business.idempotency_record (
|
||||||
|
id UUID PRIMARY KEY,
|
||||||
|
tenant_id UUID NOT NULL REFERENCES identity.tenant(id),
|
||||||
|
actor_id UUID NOT NULL,
|
||||||
|
operation VARCHAR(100) NOT NULL,
|
||||||
|
idempotency_key VARCHAR(128) NOT NULL,
|
||||||
|
request_fingerprint CHAR(64) NOT NULL,
|
||||||
|
resource_id UUID NOT NULL,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
CONSTRAINT fk_idempotency_actor FOREIGN KEY (tenant_id, actor_id)
|
||||||
|
REFERENCES identity.user_account(tenant_id, id),
|
||||||
|
UNIQUE (tenant_id, actor_id, operation, idempotency_key)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE business.leave_request_event (
|
||||||
|
id UUID PRIMARY KEY,
|
||||||
|
tenant_id UUID NOT NULL REFERENCES identity.tenant(id),
|
||||||
|
leave_request_id UUID NOT NULL,
|
||||||
|
actor_id UUID NOT NULL,
|
||||||
|
event_type VARCHAR(100) NOT NULL,
|
||||||
|
from_status VARCHAR(32) NOT NULL,
|
||||||
|
to_status VARCHAR(32) NOT NULL,
|
||||||
|
trace_id VARCHAR(128) NOT NULL,
|
||||||
|
occurred_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
CONSTRAINT fk_leave_event_request FOREIGN KEY (tenant_id, leave_request_id)
|
||||||
|
REFERENCES business.leave_request(tenant_id, id),
|
||||||
|
CONSTRAINT fk_leave_event_actor FOREIGN KEY (tenant_id, actor_id)
|
||||||
|
REFERENCES identity.user_account(tenant_id, id)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX idx_leave_event_timeline
|
||||||
|
ON business.leave_request_event (tenant_id, leave_request_id, occurred_at, id);
|
||||||
+100
-4
@@ -6,6 +6,11 @@ import com.all8ai.aioa.approval.domain.LeaveRequest
|
|||||||
import com.all8ai.aioa.approval.domain.LeaveRequestRepository
|
import com.all8ai.aioa.approval.domain.LeaveRequestRepository
|
||||||
import com.all8ai.aioa.approval.domain.LeaveStatus
|
import com.all8ai.aioa.approval.domain.LeaveStatus
|
||||||
import com.all8ai.aioa.approval.domain.LeaveType
|
import com.all8ai.aioa.approval.domain.LeaveType
|
||||||
|
import com.all8ai.aioa.approval.domain.LeaveRequestEvent
|
||||||
|
import com.all8ai.aioa.approval.domain.OperationClaim
|
||||||
|
import com.all8ai.aioa.audit.application.AuditService
|
||||||
|
import com.all8ai.aioa.audit.domain.AuditEventRepository
|
||||||
|
import com.all8ai.aioa.audit.domain.AuditEvent
|
||||||
import com.all8ai.aioa.identity.domain.CurrentUser
|
import com.all8ai.aioa.identity.domain.CurrentUser
|
||||||
import com.all8ai.aioa.shared.web.ApiException
|
import com.all8ai.aioa.shared.web.ApiException
|
||||||
import org.assertj.core.api.Assertions.assertThat
|
import org.assertj.core.api.Assertions.assertThat
|
||||||
@@ -36,7 +41,7 @@ class LeaveRequestServiceTest {
|
|||||||
@Test
|
@Test
|
||||||
fun `creates own draft with normalized reason`() {
|
fun `creates own draft with normalized reason`() {
|
||||||
val repository = InMemoryRepository()
|
val repository = InMemoryRepository()
|
||||||
val service = LeaveRequestService(repository)
|
val service = service(repository)
|
||||||
|
|
||||||
val created = service.createDraft(actor, "draft-create-0001", command.copy(reason = " 办理个人事务 "))
|
val created = service.createDraft(actor, "draft-create-0001", command.copy(reason = " 办理个人事务 "))
|
||||||
|
|
||||||
@@ -48,7 +53,7 @@ class LeaveRequestServiceTest {
|
|||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `rejects invalid time range before repository call`() {
|
fun `rejects invalid time range before repository call`() {
|
||||||
val service = LeaveRequestService(InMemoryRepository())
|
val service = service(InMemoryRepository())
|
||||||
|
|
||||||
assertThatThrownBy {
|
assertThatThrownBy {
|
||||||
service.createDraft(actor, "draft-create-0002", command.copy(endsAt = command.startsAt))
|
service.createDraft(actor, "draft-create-0002", command.copy(endsAt = command.startsAt))
|
||||||
@@ -60,7 +65,7 @@ class LeaveRequestServiceTest {
|
|||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `returns same draft when idempotent request is replayed`() {
|
fun `returns same draft when idempotent request is replayed`() {
|
||||||
val service = LeaveRequestService(InMemoryRepository())
|
val service = service(InMemoryRepository())
|
||||||
|
|
||||||
val first = service.createDraft(actor, "draft-create-0003", command)
|
val first = service.createDraft(actor, "draft-create-0003", command)
|
||||||
val replay = service.createDraft(actor, "draft-create-0003", command)
|
val replay = service.createDraft(actor, "draft-create-0003", command)
|
||||||
@@ -70,7 +75,7 @@ class LeaveRequestServiceTest {
|
|||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `rejects same idempotency key with different content`() {
|
fun `rejects same idempotency key with different content`() {
|
||||||
val service = LeaveRequestService(InMemoryRepository())
|
val service = service(InMemoryRepository())
|
||||||
service.createDraft(actor, "draft-create-0004", command)
|
service.createDraft(actor, "draft-create-0004", command)
|
||||||
|
|
||||||
assertThatThrownBy {
|
assertThatThrownBy {
|
||||||
@@ -81,8 +86,56 @@ class LeaveRequestServiceTest {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `submits draft once and writes one audit event on replay`() {
|
||||||
|
val repository = InMemoryRepository()
|
||||||
|
val audits = CapturingAuditRepository()
|
||||||
|
val service = service(repository, audits)
|
||||||
|
val draft = service.createDraft(actor, "draft-create-0005", command)
|
||||||
|
|
||||||
|
val submitted = service.submit(actor, draft.id, "draft-submit-0005", 0)
|
||||||
|
val replay = service.submit(actor, draft.id, "draft-submit-0005", 0)
|
||||||
|
|
||||||
|
assertThat(submitted.status).isEqualTo(LeaveStatus.PENDING)
|
||||||
|
assertThat(submitted.version).isEqualTo(1)
|
||||||
|
assertThat(replay).isEqualTo(submitted)
|
||||||
|
assertThat(audits.events).hasSize(1)
|
||||||
|
assertThat(audits.events.single().action).isEqualTo("LEAVE_REQUEST_SUBMITTED")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `withdraws only a pending request`() {
|
||||||
|
val repository = InMemoryRepository()
|
||||||
|
val service = service(repository)
|
||||||
|
val draft = service.createDraft(actor, "draft-create-0006", command)
|
||||||
|
|
||||||
|
assertThatThrownBy { service.withdraw(actor, draft.id, "draft-withdraw-0006", 0) }
|
||||||
|
.isInstanceOfSatisfying(ApiException::class.java) {
|
||||||
|
assertThat(it.code).isEqualTo("LEAVE_STATUS_INVALID")
|
||||||
|
}
|
||||||
|
|
||||||
|
val submitted = service.submit(actor, draft.id, "draft-submit-0006", 0)
|
||||||
|
val withdrawn = service.withdraw(actor, draft.id, "draft-withdraw-0007", submitted.version)
|
||||||
|
assertThat(withdrawn.status).isEqualTo(LeaveStatus.WITHDRAWN)
|
||||||
|
assertThat(withdrawn.version).isEqualTo(2)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun service(
|
||||||
|
repository: LeaveRequestRepository,
|
||||||
|
auditRepository: AuditEventRepository = AuditEventRepository { },
|
||||||
|
) = LeaveRequestService(repository, AuditService(auditRepository))
|
||||||
|
|
||||||
|
private class CapturingAuditRepository : AuditEventRepository {
|
||||||
|
val events = mutableListOf<AuditEvent>()
|
||||||
|
override fun append(event: AuditEvent) {
|
||||||
|
events += event
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private class InMemoryRepository : LeaveRequestRepository {
|
private class InMemoryRepository : LeaveRequestRepository {
|
||||||
private val drafts = mutableMapOf<Triple<UUID, UUID, String>, IdempotentDraft>()
|
private val drafts = mutableMapOf<Triple<UUID, UUID, String>, IdempotentDraft>()
|
||||||
|
private val claims = mutableMapOf<List<Any>, OperationClaim>()
|
||||||
|
private val events = mutableListOf<LeaveRequestEvent>()
|
||||||
|
|
||||||
override fun createDraft(
|
override fun createDraft(
|
||||||
id: UUID,
|
id: UUID,
|
||||||
@@ -118,5 +171,48 @@ class LeaveRequestServiceTest {
|
|||||||
drafts.values.map { it.leaveRequest }
|
drafts.values.map { it.leaveRequest }
|
||||||
.filter { it.tenantId == tenantId && it.applicantId == applicantId }
|
.filter { it.tenantId == tenantId && it.applicantId == applicantId }
|
||||||
.take(limit)
|
.take(limit)
|
||||||
|
|
||||||
|
override fun claimOperation(
|
||||||
|
id: UUID,
|
||||||
|
tenantId: UUID,
|
||||||
|
actorId: UUID,
|
||||||
|
operation: String,
|
||||||
|
idempotencyKey: String,
|
||||||
|
fingerprint: String,
|
||||||
|
resourceId: UUID,
|
||||||
|
): OperationClaim {
|
||||||
|
val key = listOf(tenantId, actorId, operation, idempotencyKey)
|
||||||
|
val existing = claims[key]
|
||||||
|
if (existing != null) return existing.copy(claimed = false)
|
||||||
|
return OperationClaim(true, fingerprint, resourceId).also { claims[key] = it }
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun transitionOwn(
|
||||||
|
tenantId: UUID,
|
||||||
|
actorId: UUID,
|
||||||
|
id: UUID,
|
||||||
|
expectedVersion: Long,
|
||||||
|
fromStatus: LeaveStatus,
|
||||||
|
toStatus: LeaveStatus,
|
||||||
|
eventId: UUID,
|
||||||
|
eventType: String,
|
||||||
|
traceId: String,
|
||||||
|
): LeaveRequest? {
|
||||||
|
val entry = drafts.entries.firstOrNull {
|
||||||
|
val request = it.value.leaveRequest
|
||||||
|
request.tenantId == tenantId && request.applicantId == actorId && request.id == id
|
||||||
|
} ?: return null
|
||||||
|
val current = entry.value.leaveRequest
|
||||||
|
if (current.status != fromStatus || current.version != expectedVersion) return null
|
||||||
|
val updated = current.copy(status = toStatus, version = current.version + 1)
|
||||||
|
drafts[entry.key] = entry.value.copy(leaveRequest = updated)
|
||||||
|
events += LeaveRequestEvent(
|
||||||
|
eventId, eventType, fromStatus, toStatus, actorId, traceId,
|
||||||
|
Instant.parse("2026-07-18T00:00:00Z"),
|
||||||
|
)
|
||||||
|
return updated
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun listTimeline(tenantId: UUID, leaveRequestId: UUID): List<LeaveRequestEvent> = events.toList()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
openapi: 3.1.0
|
openapi: 3.1.0
|
||||||
info:
|
info:
|
||||||
title: AIOA API
|
title: AIOA API
|
||||||
version: 0.2.0
|
version: 0.3.0
|
||||||
servers:
|
servers:
|
||||||
- url: /api/v1
|
- url: /api/v1
|
||||||
paths:
|
paths:
|
||||||
@@ -104,8 +104,78 @@ paths:
|
|||||||
$ref: "#/components/responses/NotFound"
|
$ref: "#/components/responses/NotFound"
|
||||||
"409":
|
"409":
|
||||||
$ref: "#/components/responses/Conflict"
|
$ref: "#/components/responses/Conflict"
|
||||||
|
/leave-requests/{id}/submit:
|
||||||
|
post:
|
||||||
|
operationId: submitLeaveRequest
|
||||||
|
summary: 提交请假草稿
|
||||||
|
parameters:
|
||||||
|
- $ref: "#/components/parameters/LeaveRequestId"
|
||||||
|
- $ref: "#/components/parameters/IdempotencyKey"
|
||||||
|
requestBody:
|
||||||
|
required: true
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: "#/components/schemas/TransitionRequest"
|
||||||
|
responses:
|
||||||
|
"200":
|
||||||
|
description: 已提交或幂等重放
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: "#/components/schemas/LeaveRequest"
|
||||||
|
"404":
|
||||||
|
$ref: "#/components/responses/NotFound"
|
||||||
|
"409":
|
||||||
|
$ref: "#/components/responses/Conflict"
|
||||||
|
/leave-requests/{id}/withdraw:
|
||||||
|
post:
|
||||||
|
operationId: withdrawLeaveRequest
|
||||||
|
summary: 撤回待审批申请
|
||||||
|
parameters:
|
||||||
|
- $ref: "#/components/parameters/LeaveRequestId"
|
||||||
|
- $ref: "#/components/parameters/IdempotencyKey"
|
||||||
|
requestBody:
|
||||||
|
required: true
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: "#/components/schemas/TransitionRequest"
|
||||||
|
responses:
|
||||||
|
"200":
|
||||||
|
description: 已撤回或幂等重放
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: "#/components/schemas/LeaveRequest"
|
||||||
|
"404":
|
||||||
|
$ref: "#/components/responses/NotFound"
|
||||||
|
"409":
|
||||||
|
$ref: "#/components/responses/Conflict"
|
||||||
|
/leave-requests/{id}/timeline:
|
||||||
|
get:
|
||||||
|
operationId: getLeaveRequestTimeline
|
||||||
|
summary: 查询本人申请状态时间线
|
||||||
|
parameters:
|
||||||
|
- $ref: "#/components/parameters/LeaveRequestId"
|
||||||
|
responses:
|
||||||
|
"200":
|
||||||
|
description: 状态事件列表
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
type: array
|
||||||
|
items:
|
||||||
|
$ref: "#/components/schemas/LeaveRequestEvent"
|
||||||
|
"404":
|
||||||
|
$ref: "#/components/responses/NotFound"
|
||||||
components:
|
components:
|
||||||
parameters:
|
parameters:
|
||||||
|
LeaveRequestId:
|
||||||
|
name: id
|
||||||
|
in: path
|
||||||
|
required: true
|
||||||
|
schema: { type: string, format: uuid }
|
||||||
IdempotencyKey:
|
IdempotencyKey:
|
||||||
name: Idempotency-Key
|
name: Idempotency-Key
|
||||||
in: header
|
in: header
|
||||||
@@ -204,6 +274,26 @@ components:
|
|||||||
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 }
|
updatedAt: { type: string, format: date-time }
|
||||||
|
TransitionRequest:
|
||||||
|
type: object
|
||||||
|
required: [version]
|
||||||
|
properties:
|
||||||
|
version: { type: integer, format: int64, minimum: 0 }
|
||||||
|
LeaveRequestEvent:
|
||||||
|
type: object
|
||||||
|
required: [id, eventType, fromStatus, toStatus, actorId, traceId, occurredAt]
|
||||||
|
properties:
|
||||||
|
id: { type: string, format: uuid }
|
||||||
|
eventType: { type: string }
|
||||||
|
fromStatus:
|
||||||
|
type: string
|
||||||
|
enum: [DRAFT, PENDING, APPROVED, REJECTED, WITHDRAWN]
|
||||||
|
toStatus:
|
||||||
|
type: string
|
||||||
|
enum: [DRAFT, PENDING, APPROVED, REJECTED, WITHDRAWN]
|
||||||
|
actorId: { type: string, format: uuid }
|
||||||
|
traceId: { type: string }
|
||||||
|
occurredAt: { type: string, format: date-time }
|
||||||
Problem:
|
Problem:
|
||||||
type: object
|
type: object
|
||||||
required: [type, title, status, code, traceId]
|
required: [type, title, status, code, traceId]
|
||||||
|
|||||||
@@ -24,6 +24,7 @@
|
|||||||
- [x] 请假草稿数据模型和版本
|
- [x] 请假草稿数据模型和版本
|
||||||
- [x] 草稿创建、修改、本人查询与列表 API
|
- [x] 草稿创建、修改、本人查询与列表 API
|
||||||
- [x] 创建幂等、租户/用户隔离和乐观锁
|
- [x] 创建幂等、租户/用户隔离和乐观锁
|
||||||
|
- [x] 提交、撤回、状态时间线和同步审计
|
||||||
- Flowable 流程发布与执行
|
- Flowable 流程发布与执行
|
||||||
- 发起、待办、批准、驳回、撤回和时间线
|
- 发起、待办、批准、驳回、撤回和时间线
|
||||||
- 附件、通知、弱网恢复和幂等处理
|
- 附件、通知、弱网恢复和幂等处理
|
||||||
|
|||||||
Reference in New Issue
Block a user