feat: add versioned form and process routing

This commit is contained in:
selfrelease
2026-07-18 21:26:44 +08:00
parent 24101398d8
commit 15abd8a32f
14 changed files with 319 additions and 63 deletions
@@ -0,0 +1,48 @@
package com.all8ai.aioa.admin.configuration
import com.all8ai.aioa.audit.application.AuditService
import com.all8ai.aioa.identity.application.CurrentUserService
import com.all8ai.aioa.identity.domain.CurrentUser
import com.all8ai.aioa.shared.id.UuidV7
import com.all8ai.aioa.shared.security.ToolPermission
import com.all8ai.aioa.shared.security.requirePermission
import com.all8ai.aioa.shared.web.ApiException
import com.fasterxml.jackson.databind.ObjectMapper
import org.flowable.engine.RepositoryService
import org.jooq.DSLContext
import org.springframework.http.HttpStatus
import org.springframework.security.core.annotation.AuthenticationPrincipal
import org.springframework.security.oauth2.jwt.Jwt
import org.springframework.transaction.annotation.Transactional
import org.springframework.web.bind.annotation.*
import java.util.UUID
@RestController @RequestMapping("/api/v1/admin/process-configuration")
class ProcessConfigurationController(
private val users:CurrentUserService,private val dsl:DSLContext,private val mapper:ObjectMapper,
private val flowable:RepositoryService,private val audit:AuditService,
) {
private fun actor(jwt:Jwt)=users.get(jwt.subject,jwt.getClaimAsString("tenant_id")).also { it.requirePermission(ToolPermission.PROCESS_CONFIGURATION_MANAGE_TENANT) }
@GetMapping("/forms") fun forms(@AuthenticationPrincipal jwt:Jwt):List<Map<String,Any?>> { val a=actor(jwt);return dsl.fetch("SELECT form_key,version,status,created_at,published_at FROM form.definition WHERE tenant_id=? ORDER BY form_key,version DESC",a.tenantId).map{it.intoMap()} }
@GetMapping("/bindings") fun bindings(@AuthenticationPrincipal jwt:Jwt):List<Map<String,Any?>> { val a=actor(jwt);return dsl.fetch("SELECT id,business_type,form_key,form_version,process_definition_key,leave_type,min_duration_minutes,max_duration_minutes,priority,status FROM workflow.process_binding WHERE tenant_id=? ORDER BY priority DESC",a.tenantId).map{it.intoMap()} }
@PostMapping("/forms") @Transactional fun createForm(@AuthenticationPrincipal jwt:Jwt,@RequestBody body:FormVersionCommand):Map<String,Any?> {
val a=actor(jwt);validateSchema(body.dataSchema,body.uiSchema);val version=(dsl.fetchOne("SELECT COALESCE(MAX(version),0)+1 v FROM form.definition WHERE tenant_id=? AND form_key=?",a.tenantId,body.formKey)?.get("v",Int::class.java)?:1)
dsl.execute("INSERT INTO form.definition(tenant_id,form_key,version,status,data_schema,ui_schema) VALUES(?,?,?,'DRAFT',CAST(? AS JSONB),CAST(? AS JSONB))",a.tenantId,body.formKey,version,mapper.writeValueAsString(body.dataSchema),mapper.writeValueAsString(body.uiSchema))
audit.recordSuccess(a,"FORM_VERSION_CREATED","FORM_DEFINITION","${body.formKey}:$version",null,mapOf("version" to version));return mapOf("formKey" to body.formKey,"version" to version,"status" to "DRAFT")
}
@PostMapping("/forms/{key}/{version}/publish") @Transactional fun publish(@AuthenticationPrincipal jwt:Jwt,@PathVariable key:String,@PathVariable version:Int) {
val a=actor(jwt);dsl.execute("UPDATE form.definition SET status='RETIRED' WHERE tenant_id=? AND form_key=? AND status='PUBLISHED'",a.tenantId,key)
if(dsl.execute("UPDATE form.definition SET status='PUBLISHED',published_at=CURRENT_TIMESTAMP WHERE tenant_id=? AND form_key=? AND version=? AND status='DRAFT'",a.tenantId,key,version)!=1) throw ApiException(HttpStatus.CONFLICT,"FORM_VERSION_NOT_DRAFT","表单版本不存在或不可发布")
audit.recordSuccess(a,"FORM_VERSION_PUBLISHED","FORM_DEFINITION","$key:$version",null,mapOf("version" to version))
}
@PostMapping("/bindings") @Transactional fun createBinding(@AuthenticationPrincipal jwt:Jwt,@RequestBody body:BindingCommand):Map<String,Any?> {
val a=actor(jwt);if(flowable.createProcessDefinitionQuery().processDefinitionKey(body.processDefinitionKey).latestVersion().singleResult()==null) throw ApiException(HttpStatus.BAD_REQUEST,"PROCESS_DEFINITION_NOT_FOUND","流程定义不存在")
val id=UuidV7.generate();dsl.execute("INSERT INTO workflow.process_binding(id,tenant_id,business_type,form_key,form_version,process_definition_key,leave_type,min_duration_minutes,max_duration_minutes,priority,status) VALUES(?,?,?,?,?,?,?,?,?,?,'ACTIVE')",id,a.tenantId,body.businessType,body.formKey,body.formVersion,body.processDefinitionKey,body.leaveType,body.minDurationMinutes,body.maxDurationMinutes,body.priority)
audit.recordSuccess(a,"PROCESS_BINDING_CREATED","PROCESS_BINDING",id.toString(),null,mapOf("processDefinitionKey" to body.processDefinitionKey,"formVersion" to body.formVersion));return mapOf("id" to id,"status" to "ACTIVE")
}
@PutMapping("/bindings/{id}/status") fun status(@AuthenticationPrincipal jwt:Jwt,@PathVariable id:UUID,@RequestBody body:BindingStatusCommand) { val a=actor(jwt);if(body.status !in setOf("ACTIVE","INACTIVE")) throw ApiException(HttpStatus.BAD_REQUEST,"BINDING_STATUS_INVALID","状态无效");if(dsl.execute("UPDATE workflow.process_binding SET status=?,updated_at=CURRENT_TIMESTAMP WHERE tenant_id=? AND id=?",body.status,a.tenantId,id)!=1) throw ApiException(HttpStatus.NOT_FOUND,"PROCESS_BINDING_NOT_FOUND","绑定不存在");audit.recordSuccess(a,"PROCESS_BINDING_STATUS_CHANGED","PROCESS_BINDING",id.toString(),null,mapOf("status" to body.status)) }
private fun validateSchema(data:Map<String,Any>,ui:Map<String,Any>){if(data["type"]!="object"||data["properties"] !is Map<*,*>||ui["sections"] !is List<*>) throw ApiException(HttpStatus.BAD_REQUEST,"FORM_SCHEMA_INVALID","表单 Schema 结构无效")}
}
data class FormVersionCommand(val formKey:String,val dataSchema:Map<String,Any>,val uiSchema:Map<String,Any>)
data class BindingCommand(val businessType:String,val formKey:String,val formVersion:Int,val processDefinitionKey:String,val leaveType:String?=null,val minDurationMinutes:Long?=null,val maxDurationMinutes:Long?=null,val priority:Int=100)
data class BindingStatusCommand(val status:String)
@@ -25,6 +25,8 @@ import java.time.Duration
import java.util.UUID
import com.all8ai.aioa.shared.security.ToolPermission
import com.all8ai.aioa.shared.security.requirePermission
import com.all8ai.aioa.workflow.domain.ProcessBindingRouter
import com.all8ai.aioa.workflow.infrastructure.FlowableLeaveWorkflowGateway
@Service
class LeaveRequestService(
@@ -33,6 +35,7 @@ class LeaveRequestService(
private val routingRepository: ApprovalRoutingRepository,
private val workflowGateway: LeaveWorkflowGateway,
private val notificationService: NotificationService? = null,
private val processBindingRouter: ProcessBindingRouter? = null,
) {
fun createDraft(actor: CurrentUser, idempotencyKey: String, command: SaveDraftCommand): LeaveRequest {
actor.requirePermission(ToolPermission.LEAVE_REQUEST_WRITE_OWN)
@@ -86,6 +89,13 @@ class LeaveRequestService(
actor.requirePermission(ToolPermission.LEAVE_REQUEST_WRITE_OWN)
val existing = getOwn(actor, id)
val durationMinutes = Duration.between(existing.startsAt, existing.endsAt).toMinutes()
val processDefinitionKey = processBindingRouter?.select(
actor.tenantId, "LEAVE_REQUEST", existing.type.name, durationMinutes,
)?.processDefinitionKey ?: if (processBindingRouter == null) {
FlowableLeaveWorkflowGateway.PROCESS_DEFINITION_KEY
} else {
throw ApiException(HttpStatus.CONFLICT, "PROCESS_BINDING_NOT_FOUND", "未找到适用的已启用审批流程")
}
val approverId = routingRepository.findDepartmentManager(actor.tenantId, actor.id)
?: throw ApiException(HttpStatus.CONFLICT, "APPROVER_NOT_FOUND", "未找到当前部门的有效主管")
if (approverId == actor.id) {
@@ -121,7 +131,8 @@ class LeaveRequestService(
)
if (outcome.replayed) return outcome.leaveRequest
val process = workflowGateway.startLeaveApproval(
val process = workflowGateway.startLeaveApprovalWithDefinition(
processDefinitionKey,
actor.tenantId,
outcome.leaveRequest.id,
actor.id,
@@ -1,76 +1,64 @@
package com.all8ai.aioa.forms.api
import com.all8ai.aioa.identity.application.CurrentUserService
import com.all8ai.aioa.shared.web.ApiException
import com.fasterxml.jackson.databind.ObjectMapper
import org.jooq.DSLContext
import org.springframework.http.HttpStatus
import org.springframework.web.bind.annotation.GetMapping
import org.springframework.web.bind.annotation.PathVariable
import org.springframework.web.bind.annotation.RequestMapping
import org.springframework.web.bind.annotation.RestController
import org.springframework.security.core.annotation.AuthenticationPrincipal
import org.springframework.security.oauth2.jwt.Jwt
import org.springframework.web.bind.annotation.*
import java.util.UUID
@RestController
@RequestMapping("/api/v1/form-definitions")
class FormDefinitionController {
class FormDefinitionController(
private val currentUsers: CurrentUserService? = null,
private val dsl: DSLContext? = null,
private val objectMapper: ObjectMapper? = null,
) {
@GetMapping("/{formKey}")
fun getDefinition(@PathVariable formKey: String): FormDefinitionResponse = when (formKey) {
LEAVE_REQUEST_FORM_KEY -> leaveRequestDefinition
else -> throw ApiException(HttpStatus.NOT_FOUND, "FORM_DEFINITION_NOT_FOUND", "Form definition not found")
fun getPublished(@AuthenticationPrincipal jwt: Jwt, @PathVariable formKey: String): FormDefinitionResponse {
val actor = currentUsers!!.get(jwt.subject, jwt.getClaimAsString("tenant_id"))
return find(actor.tenantId, formKey) ?: notFound()
}
// 保留纯单元测试与离线内置定义入口;生产 HTTP 始终读取已发布数据库版本。
fun getDefinition(formKey: String): FormDefinitionResponse = when (formKey) {
LEAVE_REQUEST_FORM_KEY -> leaveRequestDefinition
else -> notFound()
}
private fun find(tenantId: UUID, key: String): FormDefinitionResponse? = dsl!!.fetchOne(
"SELECT * FROM form.definition WHERE tenant_id=? AND form_key=? AND status='PUBLISHED'",
tenantId, key,
)?.let {
@Suppress("UNCHECKED_CAST")
FormDefinitionResponse(
it.get("form_key", String::class.java)!!,
it.get("version", Int::class.java)!!,
objectMapper!!.readValue(it.get("data_schema")!!.toString(), Map::class.java) as Map<String, Any>,
objectMapper.readValue(it.get("ui_schema")!!.toString(), Map::class.java) as Map<String, Any>,
)
}
private fun notFound(): Nothing = throw ApiException(HttpStatus.NOT_FOUND, "FORM_DEFINITION_NOT_FOUND", "Form definition not found")
companion object {
const val LEAVE_REQUEST_FORM_KEY = "leave-request"
val leaveRequestDefinition = FormDefinitionResponse(
key = LEAVE_REQUEST_FORM_KEY,
version = 1,
dataSchema = mapOf(
"\$id" to "leave-request-v1",
"title" to "请假申请",
"type" to "object",
"required" to listOf("type", "startsAt", "endsAt", "reason"),
"properties" to mapOf(
"type" to mapOf("type" to "string", "enum" to listOf("PERSONAL", "SICK", "ANNUAL")),
"startsAt" to mapOf("type" to "string", "format" to "date-time"),
"endsAt" to mapOf("type" to "string", "format" to "date-time"),
"reason" to mapOf("type" to "string", "minLength" to 1, "maxLength" to 2000),
),
),
uiSchema = mapOf(
"description" to "表单卡片由服务端 Schema 自动生成,字段、顺序、控件和校验均可版本化。",
"sections" to listOf(
mapOf(
"title" to "请假信息",
"controls" to listOf(
mapOf(
"field" to "type",
"label" to "请假类型",
"control" to "select",
"optionLabels" to mapOf("PERSONAL" to "事假", "SICK" to "病假", "ANNUAL" to "年假"),
),
mapOf("field" to "startsAt", "label" to "开始时间", "control" to "dateTime"),
mapOf("field" to "endsAt", "label" to "结束时间", "control" to "dateTime"),
),
),
mapOf(
"title" to "补充说明",
"controls" to listOf(
mapOf(
"field" to "reason",
"label" to "请假原因",
"control" to "textArea",
"placeholder" to "请简要说明请假原因",
"helperText" to "AI 可以帮助整理表达,但提交前必须由你确认。",
),
),
),
),
),
LEAVE_REQUEST_FORM_KEY, 1,
mapOf("\$id" to "leave-request-v1", "title" to "请假申请", "type" to "object", "required" to listOf("type", "startsAt", "endsAt", "reason"), "properties" to mapOf(
"type" to mapOf("type" to "string", "enum" to listOf("PERSONAL", "SICK", "ANNUAL")),
"startsAt" to mapOf("type" to "string", "format" to "date-time"), "endsAt" to mapOf("type" to "string", "format" to "date-time"),
"reason" to mapOf("type" to "string", "minLength" to 1, "maxLength" to 2000))),
mapOf("description" to "表单卡片由服务端 Schema 自动生成,字段、顺序、控件和校验均可版本化。", "sections" to listOf(
mapOf("title" to "请假信息", "controls" to listOf(
mapOf("field" to "type", "label" to "请假类型", "control" to "select", "optionLabels" to mapOf("PERSONAL" to "事假", "SICK" to "病假", "ANNUAL" to "年假")),
mapOf("field" to "startsAt", "label" to "开始时间", "control" to "dateTime"), mapOf("field" to "endsAt", "label" to "结束时间", "control" to "dateTime"))),
mapOf("title" to "补充说明", "controls" to listOf(mapOf("field" to "reason", "label" to "请假原因", "control" to "textArea", "placeholder" to "请简要说明请假原因", "helperText" to "AI 可以帮助整理表达,但提交前必须由你确认。"))))),
)
}
}
data class FormDefinitionResponse(
val key: String,
val version: Int,
val dataSchema: Map<String, Any>,
val uiSchema: Map<String, Any>,
)
data class FormDefinitionResponse(val key: String, val version: Int, val dataSchema: Map<String, Any>, val uiSchema: Map<String, Any>)
@@ -17,6 +17,7 @@ enum class ToolPermission {
ORGANIZATION_MANAGE_TENANT,
WORKFLOW_READ_TENANT,
OPERATIONS_METRICS_READ_TENANT,
PROCESS_CONFIGURATION_MANAGE_TENANT,
}
enum class DataScope { OWN, ASSIGNED, TENANT }
@@ -50,6 +51,7 @@ object AuthorizationPolicy {
ToolPermission.ORGANIZATION_MANAGE_TENANT,
ToolPermission.WORKFLOW_READ_TENANT,
ToolPermission.OPERATIONS_METRICS_READ_TENANT,
ToolPermission.PROCESS_CONFIGURATION_MANAGE_TENANT,
))
}
return UserCapabilities(
@@ -15,6 +15,18 @@ interface LeaveWorkflowGateway {
leaveType: String,
): StartedProcess
fun startLeaveApprovalWithDefinition(
processDefinitionKey: String,
tenantId: UUID,
leaveRequestId: UUID,
applicantId: UUID,
approverId: UUID,
oaAdministratorId: UUID,
hrReviewerId: UUID,
durationMinutes: Long,
leaveType: String,
): StartedProcess = startLeaveApproval(tenantId, leaveRequestId, applicantId, approverId, oaAdministratorId, hrReviewerId, durationMinutes, leaveType)
fun listAssignedTasks(assigneeId: UUID): List<WorkflowTask>
fun resolveTask(taskId: String): WorkflowTask?
@@ -0,0 +1,20 @@
package com.all8ai.aioa.workflow.domain
import java.util.UUID
data class ProcessBinding(
val id: UUID,
val businessType: String,
val formKey: String,
val formVersion: Int,
val processDefinitionKey: String,
val leaveType: String?,
val minDurationMinutes: Long?,
val maxDurationMinutes: Long?,
val priority: Int,
val status: String,
)
fun interface ProcessBindingRouter {
fun select(tenantId: UUID, businessType: String, leaveType: String, durationMinutes: Long): ProcessBinding?
}
@@ -27,9 +27,21 @@ class FlowableLeaveWorkflowGateway(
hrReviewerId: UUID,
durationMinutes: Long,
leaveType: String,
): StartedProcess = startLeaveApprovalWithDefinition(PROCESS_DEFINITION_KEY, tenantId, leaveRequestId, applicantId, approverId, oaAdministratorId, hrReviewerId, durationMinutes, leaveType)
override fun startLeaveApprovalWithDefinition(
processDefinitionKey: String,
tenantId: UUID,
leaveRequestId: UUID,
applicantId: UUID,
approverId: UUID,
oaAdministratorId: UUID,
hrReviewerId: UUID,
durationMinutes: Long,
leaveType: String,
): StartedProcess {
val process = runtimeService.createProcessInstanceBuilder()
.processDefinitionKey(PROCESS_DEFINITION_KEY)
.processDefinitionKey(processDefinitionKey)
.businessKey(leaveRequestId.toString())
.variables(
mapOf(
@@ -0,0 +1,31 @@
package com.all8ai.aioa.workflow.infrastructure
import com.all8ai.aioa.workflow.domain.ProcessBinding
import com.all8ai.aioa.workflow.domain.ProcessBindingRouter
import org.jooq.DSLContext
import org.springframework.stereotype.Repository
import java.util.UUID
@Repository
class JooqProcessBindingRouter(private val dsl: DSLContext) : ProcessBindingRouter {
override fun select(tenantId: UUID, businessType: String, leaveType: String, durationMinutes: Long): ProcessBinding? =
dsl.fetchOne(
"""
SELECT * FROM workflow.process_binding
WHERE tenant_id=? AND business_type=? AND status='ACTIVE'
AND (leave_type IS NULL OR leave_type=?)
AND (min_duration_minutes IS NULL OR min_duration_minutes<=?)
AND (max_duration_minutes IS NULL OR max_duration_minutes>=?)
ORDER BY priority DESC,
(CASE WHEN leave_type IS NULL THEN 0 ELSE 1 END) DESC,
(CASE WHEN min_duration_minutes IS NULL AND max_duration_minutes IS NULL THEN 0 ELSE 1 END) DESC
LIMIT 1
""".trimIndent(), tenantId, businessType, leaveType, durationMinutes, durationMinutes,
)?.let { ProcessBinding(
it.get("id", UUID::class.java)!!, it.get("business_type", String::class.java)!!,
it.get("form_key", String::class.java)!!, it.get("form_version", Int::class.java)!!,
it.get("process_definition_key", String::class.java)!!, it.get("leave_type", String::class.java),
it.get("min_duration_minutes", Long::class.java), it.get("max_duration_minutes", Long::class.java),
it.get("priority", Int::class.java)!!, it.get("status", String::class.java)!!,
) }
}
@@ -0,0 +1,58 @@
CREATE TABLE form.definition (
tenant_id UUID NOT NULL REFERENCES identity.tenant(id),
form_key VARCHAR(100) NOT NULL,
version INTEGER NOT NULL,
status VARCHAR(32) NOT NULL,
data_schema JSONB NOT NULL,
ui_schema JSONB NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
published_at TIMESTAMPTZ,
PRIMARY KEY (tenant_id, form_key, version),
CONSTRAINT ck_form_definition_status CHECK (status IN ('DRAFT', 'PUBLISHED', 'RETIRED'))
);
CREATE UNIQUE INDEX uq_form_definition_published
ON form.definition (tenant_id, form_key)
WHERE status = 'PUBLISHED';
CREATE TABLE workflow.process_binding (
id UUID PRIMARY KEY,
tenant_id UUID NOT NULL REFERENCES identity.tenant(id),
business_type VARCHAR(100) NOT NULL,
form_key VARCHAR(100) NOT NULL,
form_version INTEGER NOT NULL,
process_definition_key VARCHAR(100) NOT NULL,
leave_type VARCHAR(32),
min_duration_minutes BIGINT,
max_duration_minutes BIGINT,
priority INTEGER NOT NULL DEFAULT 100,
status VARCHAR(32) NOT NULL DEFAULT 'ACTIVE',
created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT fk_process_binding_form FOREIGN KEY (tenant_id, form_key, form_version)
REFERENCES form.definition(tenant_id, form_key, version),
CONSTRAINT ck_process_binding_status CHECK (status IN ('ACTIVE', 'INACTIVE')),
CONSTRAINT ck_process_binding_duration CHECK (
(min_duration_minutes IS NULL OR min_duration_minutes >= 0) AND
(max_duration_minutes IS NULL OR max_duration_minutes >= min_duration_minutes)
)
);
CREATE INDEX idx_process_binding_route
ON workflow.process_binding (tenant_id, business_type, status, priority DESC);
INSERT INTO form.definition (tenant_id, form_key, version, status, data_schema, ui_schema, published_at)
VALUES (
'00000000-0000-7000-8000-000000000001', 'leave-request', 1, 'PUBLISHED',
'{"$id":"leave-request-v1","title":"请假申请","type":"object","required":["type","startsAt","endsAt","reason"],"properties":{"type":{"type":"string","enum":["PERSONAL","SICK","ANNUAL"]},"startsAt":{"type":"string","format":"date-time"},"endsAt":{"type":"string","format":"date-time"},"reason":{"type":"string","minLength":1,"maxLength":2000}}}'::jsonb,
'{"description":"表单卡片由服务端 Schema 自动生成,字段、顺序、控件和校验均可版本化。","sections":[{"title":"请假信息","controls":[{"field":"type","label":"请假类型","control":"select","optionLabels":{"PERSONAL":"事假","SICK":"病假","ANNUAL":"年假"}},{"field":"startsAt","label":"开始时间","control":"dateTime"},{"field":"endsAt","label":"结束时间","control":"dateTime"}]},{"title":"补充说明","controls":[{"field":"reason","label":"请假原因","control":"textArea","placeholder":"请简要说明请假原因","helperText":"AI 可以帮助整理表达,但提交前必须由你确认。"}]}]}'::jsonb,
CURRENT_TIMESTAMP
);
INSERT INTO workflow.process_binding (
id, tenant_id, business_type, form_key, form_version, process_definition_key, priority
) VALUES (
'70000000-0000-7000-8000-000000000001',
'00000000-0000-7000-8000-000000000001',
'LEAVE_REQUEST', 'leave-request', 1, 'leaveApproval', 100
);
+12 -2
View File
@@ -1,12 +1,22 @@
openapi: 3.1.0
info:
title: AIOA API
version: 0.14.0
version: 0.15.0
servers:
- url: /api/v1
security:
- bearerAuth: []
paths:
/admin/process-configuration/forms:
get: { operationId: listFormVersions, summary: 查询表单版本与发布状态, responses: { "200": { description: 表单版本列表 } } }
post: { operationId: createFormVersion, summary: 创建安全校验后的表单草稿版本, responses: { "200": { description: 已创建表单草稿版本 } } }
/admin/process-configuration/forms/{key}/{version}/publish:
post: { operationId: publishFormVersion, summary: 发布表单版本并退役旧发布版本, parameters: [{ name: key, in: path, required: true, schema: { type: string } }, { name: version, in: path, required: true, schema: { type: integer } }], responses: { "200": { description: 已发布 } } }
/admin/process-configuration/bindings:
get: { operationId: listProcessBindings, summary: 查询业务表单与流程绑定, responses: { "200": { description: 流程绑定列表 } } }
post: { operationId: createProcessBinding, summary: 创建业务流程路由绑定, responses: { "200": { description: 已创建并启用绑定 } } }
/admin/process-configuration/bindings/{id}/status:
put: { operationId: updateProcessBindingStatus, summary: 启用或停用流程绑定, parameters: [{ name: id, in: path, required: true, schema: { type: string, format: uuid } }], responses: { "200": { description: 状态已更新 } } }
/admin/organization/departments:
get: { operationId: listAdminDepartments, summary: OA 管理员查询租户部门, responses: { "200": { description: 部门列表 } } }
post: { operationId: createAdminDepartment, summary: OA 管理员创建部门, responses: { "200": { description: 已创建部门 } } }
@@ -660,7 +670,7 @@ components:
uniqueItems: true
items:
type: string
enum: [LEAVE_REQUEST_READ_OWN, LEAVE_REQUEST_WRITE_OWN, LEAVE_ATTACHMENT_MANAGE_OWN, NOTIFICATION_READ_OWN, AI_LEAVE_DRAFT_SUGGEST, AI_LEAVE_PROGRESS_READ_OWN, APPROVAL_TASK_READ_ASSIGNED, APPROVAL_TASK_DECIDE_ASSIGNED, AUDIT_READ_TENANT_REDACTED, ORGANIZATION_MANAGE_TENANT, WORKFLOW_READ_TENANT, OPERATIONS_METRICS_READ_TENANT]
enum: [LEAVE_REQUEST_READ_OWN, LEAVE_REQUEST_WRITE_OWN, LEAVE_ATTACHMENT_MANAGE_OWN, NOTIFICATION_READ_OWN, AI_LEAVE_DRAFT_SUGGEST, AI_LEAVE_PROGRESS_READ_OWN, APPROVAL_TASK_READ_ASSIGNED, APPROVAL_TASK_DECIDE_ASSIGNED, AUDIT_READ_TENANT_REDACTED, ORGANIZATION_MANAGE_TENANT, WORKFLOW_READ_TENANT, OPERATIONS_METRICS_READ_TENANT, PROCESS_CONFIGURATION_MANAGE_TENANT]
dataScopes:
type: array
uniqueItems: true
+34
View File
@@ -0,0 +1,34 @@
# 表单版本与流程选择
## 发布模型
表单定义以 `tenant + formKey + version` 唯一标识,状态为 `DRAFT``PUBLISHED``RETIRED`。同一租户同一表单只能有一个已发布版本。
流程绑定将以下条件映射到 Flowable Process Definition Key
- 业务类型
- 表单 Key 和版本
- 可选业务枚举,例如请假类型
- 可选最小时长和最大时长
- 优先级
- ACTIVE / INACTIVE 状态
## 运行时选择
Flutter 只能提交业务表单数据,不能提交流程定义 ID。Kotlin 后端按当前租户和权威业务数据查询绑定:
1. 只选择 ACTIVE 绑定。
2. 业务类型必须精确匹配。
3. 枚举和时长条件必须满足。
4. 优先级高的规则优先。
5. 精确枚举或范围规则优先于通配规则。
6. 无匹配绑定时拒绝提交,不启动默认或任意流程。
流程启动后保存实际 `processDefinitionId`,运行中实例不会因之后修改绑定而切换版本。
## 安全边界
- 只有 OA 管理员拥有 `PROCESS_CONFIGURATION_MANAGE_TENANT`
- 新表单版本先进入草稿,发布前执行 Schema 结构校验。
- 绑定只能引用数据库中存在的表单版本和 Flowable 已部署流程 Key。
- 创建、发布、启停均写入审计。
+1
View File
@@ -55,6 +55,7 @@
- [x] 流程定义、版本和运行实例只读管理
- [x] Flutter 管理工具入口与权限驱动展示
- [x] 业务与推送运行指标仪表板
- [x] 表单版本发布、业务流程绑定与服务端路由选择
## Definition of Done
@@ -16,6 +16,8 @@ class AdminRepository {
_list('/admin/workflows/definitions'),
_list('/admin/workflows/instances'),
_list('/admin/audit-events?limit=100'),
_list('/admin/process-configuration/forms'),
_list('/admin/process-configuration/bindings'),
]);
return {
'metrics': values[0],
@@ -25,6 +27,8 @@ class AdminRepository {
'definitions': values[4],
'instances': values[5],
'audits': values[6],
'formVersions': values[7],
'bindings': values[8],
};
}
@@ -203,6 +203,31 @@ class _Workflow extends StatelessWidget {
Widget build(BuildContext context) => ListView(
padding: const EdgeInsets.all(12),
children: [
const Text('业务流程绑定'),
...(data['bindings']! as List<Map<String, Object?>>).map(
(item) => Card(
child: ListTile(
leading: Icon(
item['status'] == 'ACTIVE' ? Icons.link : Icons.link_off,
),
title: Text(
'${item['business_type']}${item['process_definition_key']}',
),
subtitle: Text(
'表单 ${item['form_key']} v${item['form_version']} · 优先级 ${item['priority']}',
),
),
),
),
const Divider(),
const Text('表单版本'),
...(data['formVersions']! as List<Map<String, Object?>>).map(
(item) => ListTile(
title: Text('${item['form_key']} v${item['version']}'),
subtitle: Text('${item['status']}'),
),
),
const Divider(),
const Text('流程定义'),
...(data['definitions']! as List<Map<String, Object?>>).map(
(item) => ListTile(