feat: complete MVP administration tools
This commit is contained in:
+35
@@ -0,0 +1,35 @@
|
|||||||
|
package com.all8ai.aioa.admin.metrics
|
||||||
|
|
||||||
|
import com.all8ai.aioa.identity.application.CurrentUserService
|
||||||
|
import com.all8ai.aioa.shared.security.ToolPermission
|
||||||
|
import com.all8ai.aioa.shared.security.requirePermission
|
||||||
|
import org.jooq.DSLContext
|
||||||
|
import org.flowable.engine.RuntimeService
|
||||||
|
import org.flowable.engine.TaskService
|
||||||
|
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.RequestMapping
|
||||||
|
import org.springframework.web.bind.annotation.RestController
|
||||||
|
|
||||||
|
data class OperationsMetrics(
|
||||||
|
val leaveByStatus:Map<String,Int>,val activeWorkflowInstances:Long,val activeWorkflowTasks:Long,
|
||||||
|
val unreadNotifications:Int,val pendingPush:Int,val failedPushAttempts:Int,val activeDevices:Int,
|
||||||
|
)
|
||||||
|
|
||||||
|
@RestController @RequestMapping("/api/v1/admin/metrics")
|
||||||
|
class OperationsMetricsController(private val users:CurrentUserService,private val dsl:DSLContext,private val runtime:RuntimeService,private val tasks:TaskService) {
|
||||||
|
@GetMapping fun metrics(@AuthenticationPrincipal jwt:Jwt):OperationsMetrics {
|
||||||
|
val actor=users.get(jwt.subject,jwt.getClaimAsString("tenant_id")); actor.requirePermission(ToolPermission.OPERATIONS_METRICS_READ_TENANT)
|
||||||
|
fun count(sql:String,vararg bindings:Any):Int=dsl.fetchOne(sql,*bindings)?.get("count",Int::class.java)?:0
|
||||||
|
val statuses=dsl.fetch("SELECT status,COUNT(*)::int count FROM business.leave_request WHERE tenant_id=? GROUP BY status",actor.tenantId)
|
||||||
|
.associate { it.get("status",String::class.java)!! to it.get("count",Int::class.java)!! }
|
||||||
|
return OperationsMetrics(statuses,
|
||||||
|
runtime.createProcessInstanceQuery().variableValueEquals("tenantId",actor.tenantId.toString()).count(),
|
||||||
|
tasks.createTaskQuery().processVariableValueEquals("tenantId",actor.tenantId.toString()).active().count(),
|
||||||
|
count("SELECT COUNT(*)::int count FROM communication.notification WHERE tenant_id=? AND read_at IS NULL",actor.tenantId),
|
||||||
|
count("SELECT COUNT(*)::int count FROM communication.notification_push_outbox o JOIN communication.notification n ON n.id=o.notification_id WHERE n.tenant_id=? AND o.status='PENDING'",actor.tenantId),
|
||||||
|
count("SELECT COALESCE(SUM(o.attempts),0)::int count FROM communication.notification_push_outbox o JOIN communication.notification n ON n.id=o.notification_id WHERE n.tenant_id=?",actor.tenantId),
|
||||||
|
count("SELECT COUNT(*)::int count FROM identity.user_device WHERE tenant_id=? AND status='ACTIVE'",actor.tenantId))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,77 @@
|
|||||||
|
package com.all8ai.aioa.admin.organization
|
||||||
|
|
||||||
|
import com.all8ai.aioa.audit.application.AuditService
|
||||||
|
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 org.jooq.DSLContext
|
||||||
|
import org.springframework.http.HttpStatus
|
||||||
|
import org.springframework.stereotype.Service
|
||||||
|
import org.springframework.transaction.annotation.Transactional
|
||||||
|
import java.util.UUID
|
||||||
|
|
||||||
|
data class AdminDepartment(val id: UUID, val code: String, val name: String, val status: String)
|
||||||
|
data class AdminRole(val id: UUID, val code: String, val name: String, val status: String)
|
||||||
|
data class AdminUser(val id: UUID, val username: String, val displayName: String, val email: String?, val status: String, val departmentId: UUID?, val positionId: UUID?, val roles: Set<String>)
|
||||||
|
|
||||||
|
@Service
|
||||||
|
class AdminOrganizationService(private val dsl: DSLContext, private val audit: AuditService) {
|
||||||
|
fun departments(actor: CurrentUser): List<AdminDepartment> {
|
||||||
|
require(actor)
|
||||||
|
return dsl.fetch("SELECT id, code, name, status FROM organization.department WHERE tenant_id = ? ORDER BY code", actor.tenantId)
|
||||||
|
.map { AdminDepartment(it.get("id", UUID::class.java)!!, it.get("code", String::class.java)!!, it.get("name", String::class.java)!!, it.get("status", String::class.java)!!) }
|
||||||
|
}
|
||||||
|
fun roles(actor: CurrentUser): List<AdminRole> {
|
||||||
|
require(actor)
|
||||||
|
return dsl.fetch("SELECT id, code, name, status FROM authz.role WHERE tenant_id = ? ORDER BY code", actor.tenantId)
|
||||||
|
.map { AdminRole(it.get("id", UUID::class.java)!!, it.get("code", String::class.java)!!, it.get("name", String::class.java)!!, it.get("status", String::class.java)!!) }
|
||||||
|
}
|
||||||
|
fun users(actor: CurrentUser): List<AdminUser> {
|
||||||
|
require(actor)
|
||||||
|
return dsl.fetch("""
|
||||||
|
SELECT u.id, u.username, u.display_name, u.email, u.status, a.department_id, a.position_id,
|
||||||
|
COALESCE(array_agg(r.code ORDER BY r.code) FILTER (WHERE r.code IS NOT NULL), '{}') AS roles
|
||||||
|
FROM identity.user_account u
|
||||||
|
LEFT JOIN organization.user_assignment a ON a.tenant_id=u.tenant_id AND a.user_id=u.id AND a.is_primary=TRUE AND a.effective_until IS NULL
|
||||||
|
LEFT JOIN authz.user_role ur ON ur.tenant_id=u.tenant_id AND ur.user_id=u.id AND ur.effective_until IS NULL
|
||||||
|
LEFT JOIN authz.role r ON r.tenant_id=ur.tenant_id AND r.id=ur.role_id
|
||||||
|
WHERE u.tenant_id=? GROUP BY u.id,a.department_id,a.position_id ORDER BY u.username
|
||||||
|
""".trimIndent(), actor.tenantId).map {
|
||||||
|
AdminUser(it.get("id", UUID::class.java)!!, it.get("username", String::class.java)!!, it.get("display_name", String::class.java)!!,
|
||||||
|
it.get("email", String::class.java), it.get("status", String::class.java)!!, it.get("department_id", UUID::class.java),
|
||||||
|
it.get("position_id", UUID::class.java), (it.get("roles") as? Array<*>)?.filterIsInstance<String>()?.toSet().orEmpty())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
@Transactional
|
||||||
|
fun createDepartment(actor: CurrentUser, code: String, name: String): AdminDepartment {
|
||||||
|
require(actor); val c = valid(code, 64); val n = valid(name, 200); val id = UuidV7.generate()
|
||||||
|
try { dsl.execute("INSERT INTO organization.department(id,tenant_id,code,name,status) VALUES(?,?,?,?,'ACTIVE')", id, actor.tenantId, c, n) }
|
||||||
|
catch (_: Exception) { throw ApiException(HttpStatus.CONFLICT, "DEPARTMENT_CODE_EXISTS", "部门编码已存在") }
|
||||||
|
audit.recordSuccess(actor, "DEPARTMENT_CREATED", "DEPARTMENT", id.toString(), null, mapOf("code" to c))
|
||||||
|
return AdminDepartment(id, c, n, "ACTIVE")
|
||||||
|
}
|
||||||
|
@Transactional
|
||||||
|
fun replaceRoles(actor: CurrentUser, userId: UUID, roleCodes: Set<String>) {
|
||||||
|
require(actor); if (roleCodes.isEmpty()) throw ApiException(HttpStatus.BAD_REQUEST, "ROLES_REQUIRED", "至少保留一个角色")
|
||||||
|
val bindings = mutableListOf<Any>(actor.tenantId).apply { addAll(roleCodes) }.toTypedArray()
|
||||||
|
val roleIds = dsl.fetch("SELECT id,code FROM authz.role WHERE tenant_id=? AND code IN (${roleCodes.joinToString { "?" }}) AND status='ACTIVE'", *bindings)
|
||||||
|
if (roleIds.size != roleCodes.size) throw ApiException(HttpStatus.BAD_REQUEST, "ROLE_INVALID", "包含不存在的角色")
|
||||||
|
if (dsl.fetchOne("SELECT EXISTS(SELECT 1 FROM identity.user_account WHERE tenant_id=? AND id=?) AS ok", actor.tenantId, userId)?.get("ok", Boolean::class.java) != true) throw ApiException(HttpStatus.NOT_FOUND, "USER_NOT_FOUND", "用户不存在")
|
||||||
|
dsl.execute("DELETE FROM authz.user_role WHERE tenant_id=? AND user_id=?", actor.tenantId, userId)
|
||||||
|
roleIds.forEach { dsl.execute("INSERT INTO authz.user_role(tenant_id,user_id,role_id) VALUES(?,?,?)", actor.tenantId, userId, it.get("id", UUID::class.java)) }
|
||||||
|
audit.recordSuccess(actor, "USER_ROLES_REPLACED", "USER", userId.toString(), null, mapOf("roles" to roleCodes.sorted()))
|
||||||
|
}
|
||||||
|
@Transactional
|
||||||
|
fun assign(actor: CurrentUser, userId: UUID, departmentId: UUID, positionId: UUID) {
|
||||||
|
require(actor)
|
||||||
|
val validRefs = dsl.fetchOne("SELECT EXISTS(SELECT 1 FROM organization.department WHERE tenant_id=? AND id=? AND status='ACTIVE') AND EXISTS(SELECT 1 FROM organization.position WHERE tenant_id=? AND id=? AND status='ACTIVE') AS ok", actor.tenantId, departmentId, actor.tenantId, positionId)?.get("ok", Boolean::class.java) == true
|
||||||
|
if (!validRefs) throw ApiException(HttpStatus.BAD_REQUEST, "ASSIGNMENT_REFERENCE_INVALID", "部门或岗位无效")
|
||||||
|
dsl.execute("UPDATE organization.user_assignment SET effective_until=CURRENT_TIMESTAMP,is_primary=FALSE WHERE tenant_id=? AND user_id=? AND is_primary=TRUE AND effective_until IS NULL", actor.tenantId, userId)
|
||||||
|
dsl.execute("INSERT INTO organization.user_assignment(id,tenant_id,user_id,department_id,position_id,is_primary) VALUES(?,?,?,?,?,TRUE)", UuidV7.generate(), actor.tenantId, userId, departmentId, positionId)
|
||||||
|
audit.recordSuccess(actor, "USER_ASSIGNMENT_REPLACED", "USER", userId.toString(), null, mapOf("departmentId" to departmentId, "positionId" to positionId))
|
||||||
|
}
|
||||||
|
private fun require(actor: CurrentUser) = actor.requirePermission(ToolPermission.ORGANIZATION_MANAGE_TENANT)
|
||||||
|
private fun valid(value: String, max: Int) = value.trim().takeIf { it.isNotEmpty() && it.length <= max && it.matches(Regex("[A-Za-z0-9._-]+|[\\p{L}0-9 ._-]+")) } ?: throw ApiException(HttpStatus.BAD_REQUEST, "VALUE_INVALID", "输入值无效")
|
||||||
|
}
|
||||||
+24
@@ -0,0 +1,24 @@
|
|||||||
|
package com.all8ai.aioa.admin.organization
|
||||||
|
|
||||||
|
import com.all8ai.aioa.identity.application.CurrentUserService
|
||||||
|
import jakarta.validation.Valid
|
||||||
|
import jakarta.validation.constraints.NotBlank
|
||||||
|
import jakarta.validation.constraints.Size
|
||||||
|
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/admin/organization")
|
||||||
|
class AdminOrganizationController(private val users: CurrentUserService, private val service: AdminOrganizationService) {
|
||||||
|
private fun actor(jwt: Jwt) = users.get(jwt.subject, jwt.getClaimAsString("tenant_id"))
|
||||||
|
@GetMapping("/departments") fun departments(@AuthenticationPrincipal jwt: Jwt) = service.departments(actor(jwt))
|
||||||
|
@PostMapping("/departments") fun createDepartment(@AuthenticationPrincipal jwt: Jwt, @Valid @RequestBody body: DepartmentCommand) = service.createDepartment(actor(jwt), body.code, body.name)
|
||||||
|
@GetMapping("/roles") fun roles(@AuthenticationPrincipal jwt: Jwt) = service.roles(actor(jwt))
|
||||||
|
@GetMapping("/users") fun listUsers(@AuthenticationPrincipal jwt: Jwt) = service.users(actor(jwt))
|
||||||
|
@PutMapping("/users/{id}/roles") fun roles(@AuthenticationPrincipal jwt: Jwt, @PathVariable id: UUID, @RequestBody body: RolesCommand) = service.replaceRoles(actor(jwt), id, body.roles)
|
||||||
|
@PutMapping("/users/{id}/assignment") fun assign(@AuthenticationPrincipal jwt: Jwt, @PathVariable id: UUID, @RequestBody body: AssignmentCommand) = service.assign(actor(jwt), id, body.departmentId, body.positionId)
|
||||||
|
}
|
||||||
|
data class DepartmentCommand(@field:NotBlank @field:Size(max=64) val code:String,@field:NotBlank @field:Size(max=200) val name:String)
|
||||||
|
data class RolesCommand(val roles:Set<String>)
|
||||||
|
data class AssignmentCommand(val departmentId:UUID,val positionId:UUID)
|
||||||
+36
@@ -0,0 +1,36 @@
|
|||||||
|
package com.all8ai.aioa.admin.workflow
|
||||||
|
|
||||||
|
import com.all8ai.aioa.identity.application.CurrentUserService
|
||||||
|
import com.all8ai.aioa.identity.domain.CurrentUser
|
||||||
|
import com.all8ai.aioa.shared.security.ToolPermission
|
||||||
|
import com.all8ai.aioa.shared.security.requirePermission
|
||||||
|
import org.flowable.engine.HistoryService
|
||||||
|
import org.flowable.engine.RepositoryService
|
||||||
|
import org.flowable.engine.RuntimeService
|
||||||
|
import org.springframework.security.core.annotation.AuthenticationPrincipal
|
||||||
|
import org.springframework.security.oauth2.jwt.Jwt
|
||||||
|
import org.springframework.web.bind.annotation.*
|
||||||
|
import java.time.Instant
|
||||||
|
|
||||||
|
data class ProcessDefinitionView(val id:String,val key:String,val name:String?,val version:Int,val deploymentId:String,val suspended:Boolean)
|
||||||
|
data class ProcessInstanceView(val id:String,val definitionId:String,val businessKey:String?,val startedAt:Instant?,val endedAt:Instant?,val active:Boolean)
|
||||||
|
|
||||||
|
@RestController @RequestMapping("/api/v1/admin/workflows")
|
||||||
|
class AdminWorkflowController(
|
||||||
|
private val currentUsers:CurrentUserService, private val repository:RepositoryService,
|
||||||
|
private val runtime:RuntimeService, private val history:HistoryService,
|
||||||
|
) {
|
||||||
|
private fun actor(jwt:Jwt):CurrentUser=currentUsers.get(jwt.subject,jwt.getClaimAsString("tenant_id")).also { it.requirePermission(ToolPermission.WORKFLOW_READ_TENANT) }
|
||||||
|
@GetMapping("/definitions") fun definitions(@AuthenticationPrincipal jwt:Jwt):List<ProcessDefinitionView> {
|
||||||
|
actor(jwt); return repository.createProcessDefinitionQuery().orderByProcessDefinitionKey().asc().orderByProcessDefinitionVersion().desc().list()
|
||||||
|
.map { ProcessDefinitionView(it.id,it.key,it.name,it.version,it.deploymentId,it.isSuspended) }
|
||||||
|
}
|
||||||
|
@GetMapping("/instances") fun instances(@AuthenticationPrincipal jwt:Jwt,@RequestParam(defaultValue="100") limit:Int):List<ProcessInstanceView> {
|
||||||
|
val actor=actor(jwt); val active=runtime.createProcessInstanceQuery().variableValueEquals("tenantId",actor.tenantId.toString()).listPage(0,limit.coerceIn(1,200))
|
||||||
|
.map { ProcessInstanceView(it.id,it.processDefinitionId,it.businessKey,null,null,true) }
|
||||||
|
if(active.size>=limit) return active
|
||||||
|
val ended=history.createHistoricProcessInstanceQuery().variableValueEquals("tenantId",actor.tenantId.toString()).finished().orderByProcessInstanceEndTime().desc().listPage(0,(limit-active.size).coerceIn(0,200))
|
||||||
|
.map { ProcessInstanceView(it.id,it.processDefinitionId,it.businessKey,it.startTime?.toInstant(),it.endTime?.toInstant(),false) }
|
||||||
|
return active+ended
|
||||||
|
}
|
||||||
|
}
|
||||||
+10
-2
@@ -14,6 +14,9 @@ enum class ToolPermission {
|
|||||||
APPROVAL_TASK_READ_ASSIGNED,
|
APPROVAL_TASK_READ_ASSIGNED,
|
||||||
APPROVAL_TASK_DECIDE_ASSIGNED,
|
APPROVAL_TASK_DECIDE_ASSIGNED,
|
||||||
AUDIT_READ_TENANT_REDACTED,
|
AUDIT_READ_TENANT_REDACTED,
|
||||||
|
ORGANIZATION_MANAGE_TENANT,
|
||||||
|
WORKFLOW_READ_TENANT,
|
||||||
|
OPERATIONS_METRICS_READ_TENANT,
|
||||||
}
|
}
|
||||||
|
|
||||||
enum class DataScope { OWN, ASSIGNED, TENANT }
|
enum class DataScope { OWN, ASSIGNED, TENANT }
|
||||||
@@ -42,14 +45,19 @@ object AuthorizationPolicy {
|
|||||||
val permissions = buildSet {
|
val permissions = buildSet {
|
||||||
if ("employee" in user.roles) addAll(employeePermissions)
|
if ("employee" in user.roles) addAll(employeePermissions)
|
||||||
if (user.roles.any(approverRoles::contains)) addAll(approvalPermissions)
|
if (user.roles.any(approverRoles::contains)) addAll(approvalPermissions)
|
||||||
if ("oa_admin" in user.roles) add(ToolPermission.AUDIT_READ_TENANT_REDACTED)
|
if ("oa_admin" in user.roles) addAll(setOf(
|
||||||
|
ToolPermission.AUDIT_READ_TENANT_REDACTED,
|
||||||
|
ToolPermission.ORGANIZATION_MANAGE_TENANT,
|
||||||
|
ToolPermission.WORKFLOW_READ_TENANT,
|
||||||
|
ToolPermission.OPERATIONS_METRICS_READ_TENANT,
|
||||||
|
))
|
||||||
}
|
}
|
||||||
return UserCapabilities(
|
return UserCapabilities(
|
||||||
permissions,
|
permissions,
|
||||||
buildSet {
|
buildSet {
|
||||||
if (permissions.any { it.name.endsWith("_OWN") }) add(DataScope.OWN)
|
if (permissions.any { it.name.endsWith("_OWN") }) add(DataScope.OWN)
|
||||||
if (permissions.any { it.name.endsWith("_ASSIGNED") }) add(DataScope.ASSIGNED)
|
if (permissions.any { it.name.endsWith("_ASSIGNED") }) add(DataScope.ASSIGNED)
|
||||||
if (ToolPermission.AUDIT_READ_TENANT_REDACTED in permissions) add(DataScope.TENANT)
|
if (permissions.any { it.name.endsWith("_TENANT") }) add(DataScope.TENANT)
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
+13
@@ -33,6 +33,19 @@ class AuthorizationPolicyTest {
|
|||||||
.isInstanceOfSatisfying(ApiException::class.java) { assertThat(it.code).isEqualTo("PERMISSION_DENIED") }
|
.isInstanceOfSatisfying(ApiException::class.java) { assertThat(it.code).isEqualTo("PERMISSION_DENIED") }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `only oa administrator receives tenant management tools`() {
|
||||||
|
val admin = AuthorizationPolicy.capabilities(user(setOf("employee", "oa_admin")))
|
||||||
|
val manager = AuthorizationPolicy.capabilities(user(setOf("employee", "department_manager")))
|
||||||
|
assertThat(admin.permissions).contains(
|
||||||
|
ToolPermission.ORGANIZATION_MANAGE_TENANT,
|
||||||
|
ToolPermission.WORKFLOW_READ_TENANT,
|
||||||
|
ToolPermission.OPERATIONS_METRICS_READ_TENANT,
|
||||||
|
)
|
||||||
|
assertThat(admin.dataScopes).contains(DataScope.TENANT)
|
||||||
|
assertThat(manager.permissions).doesNotContain(ToolPermission.ORGANIZATION_MANAGE_TENANT)
|
||||||
|
}
|
||||||
|
|
||||||
private fun user(roles: Set<String>) = CurrentUser(
|
private fun user(roles: Set<String>) = CurrentUser(
|
||||||
UUID.randomUUID(), UUID.randomUUID(), "user", "用户", null, null, null, roles,
|
UUID.randomUUID(), UUID.randomUUID(), "user", "用户", null, null, null, roles,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,12 +1,29 @@
|
|||||||
openapi: 3.1.0
|
openapi: 3.1.0
|
||||||
info:
|
info:
|
||||||
title: AIOA API
|
title: AIOA API
|
||||||
version: 0.13.0
|
version: 0.14.0
|
||||||
servers:
|
servers:
|
||||||
- url: /api/v1
|
- url: /api/v1
|
||||||
security:
|
security:
|
||||||
- bearerAuth: []
|
- bearerAuth: []
|
||||||
paths:
|
paths:
|
||||||
|
/admin/organization/departments:
|
||||||
|
get: { operationId: listAdminDepartments, summary: OA 管理员查询租户部门, responses: { "200": { description: 部门列表 } } }
|
||||||
|
post: { operationId: createAdminDepartment, summary: OA 管理员创建部门, responses: { "200": { description: 已创建部门 } } }
|
||||||
|
/admin/organization/roles:
|
||||||
|
get: { operationId: listAdminRoles, summary: OA 管理员查询租户角色, responses: { "200": { description: 角色列表 } } }
|
||||||
|
/admin/organization/users:
|
||||||
|
get: { operationId: listAdminUsers, summary: OA 管理员查询租户用户与任职角色, responses: { "200": { description: 用户列表 } } }
|
||||||
|
/admin/organization/users/{id}/roles:
|
||||||
|
put: { operationId: replaceAdminUserRoles, summary: OA 管理员替换用户角色, parameters: [{ name: id, in: path, required: true, schema: { type: string, format: uuid } }], responses: { "200": { description: 角色已更新 } } }
|
||||||
|
/admin/organization/users/{id}/assignment:
|
||||||
|
put: { operationId: replaceAdminUserAssignment, summary: OA 管理员替换用户主任职, parameters: [{ name: id, in: path, required: true, schema: { type: string, format: uuid } }], responses: { "200": { description: 任职已更新 } } }
|
||||||
|
/admin/workflows/definitions:
|
||||||
|
get: { operationId: listWorkflowDefinitions, summary: 查询流程定义及版本, responses: { "200": { description: 流程定义列表 } } }
|
||||||
|
/admin/workflows/instances:
|
||||||
|
get: { operationId: listTenantWorkflowInstances, summary: 查询当前租户流程实例, responses: { "200": { description: 流程实例列表 } } }
|
||||||
|
/admin/metrics:
|
||||||
|
get: { operationId: getTenantOperationsMetrics, summary: 查询业务流程推送运行指标, responses: { "200": { description: 聚合运行指标 } } }
|
||||||
/admin/audit-events:
|
/admin/audit-events:
|
||||||
get:
|
get:
|
||||||
operationId: listRedactedTenantAuditEvents
|
operationId: listRedactedTenantAuditEvents
|
||||||
@@ -643,7 +660,7 @@ components:
|
|||||||
uniqueItems: true
|
uniqueItems: true
|
||||||
items:
|
items:
|
||||||
type: string
|
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]
|
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]
|
||||||
dataScopes:
|
dataScopes:
|
||||||
type: array
|
type: array
|
||||||
uniqueItems: true
|
uniqueItems: true
|
||||||
|
|||||||
@@ -51,10 +51,10 @@
|
|||||||
## M4:管理与运营闭环
|
## M4:管理与运营闭环
|
||||||
|
|
||||||
- [x] OA 管理员按租户脱敏查询审计记录
|
- [x] OA 管理员按租户脱敏查询审计记录
|
||||||
- [ ] OA 管理员组织与角色维护 API
|
- [x] OA 管理员组织与角色维护 API
|
||||||
- [ ] 流程定义、版本和运行实例只读管理
|
- [x] 流程定义、版本和运行实例只读管理
|
||||||
- [ ] Flutter 管理工具入口与权限驱动展示
|
- [x] Flutter 管理工具入口与权限驱动展示
|
||||||
- [ ] 业务与推送运行指标仪表板
|
- [x] 业务与推送运行指标仪表板
|
||||||
|
|
||||||
## Definition of Done
|
## Definition of Done
|
||||||
|
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import 'package:aioa_mobile/features/requests/presentation/leave_request_detail_
|
|||||||
import 'package:aioa_mobile/features/requests/presentation/leave_request_list_page.dart';
|
import 'package:aioa_mobile/features/requests/presentation/leave_request_list_page.dart';
|
||||||
import 'package:aioa_mobile/features/tasks/presentation/tasks_page.dart';
|
import 'package:aioa_mobile/features/tasks/presentation/tasks_page.dart';
|
||||||
import 'package:aioa_mobile/features/workspace/presentation/workspace_page.dart';
|
import 'package:aioa_mobile/features/workspace/presentation/workspace_page.dart';
|
||||||
|
import 'package:aioa_mobile/features/admin/presentation/admin_page.dart';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:go_router/go_router.dart';
|
import 'package:go_router/go_router.dart';
|
||||||
|
|
||||||
@@ -30,6 +31,7 @@ final appRouter = GoRouter(
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
GoRoute(path: '/leave', builder: (_, _) => const LeaveRequestListPage()),
|
GoRoute(path: '/leave', builder: (_, _) => const LeaveRequestListPage()),
|
||||||
|
GoRoute(path: '/admin', builder: (_, _) => const AdminPage()),
|
||||||
GoRoute(
|
GoRoute(
|
||||||
path: '/leave/:id',
|
path: '/leave/:id',
|
||||||
builder: (_, state) =>
|
builder: (_, state) =>
|
||||||
|
|||||||
@@ -0,0 +1,43 @@
|
|||||||
|
import 'package:aioa_mobile/core/auth/authenticated_http_client.dart';
|
||||||
|
import 'package:aioa_mobile/core/config/runtime_config.dart';
|
||||||
|
import 'package:aioa_mobile/features/admin/data/admin_repository.dart';
|
||||||
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
|
|
||||||
|
final adminRepositoryProvider = Provider(
|
||||||
|
(ref) => AdminRepository(
|
||||||
|
client: ref.watch(authenticatedHttpClientProvider),
|
||||||
|
baseUrl: RuntimeConfig.apiBaseUrl,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
final currentPermissionsProvider = FutureProvider(
|
||||||
|
(ref) => ref.watch(adminRepositoryProvider).permissions(),
|
||||||
|
);
|
||||||
|
final adminDashboardProvider =
|
||||||
|
AsyncNotifierProvider<AdminController, Map<String, Object?>>(
|
||||||
|
AdminController.new,
|
||||||
|
);
|
||||||
|
|
||||||
|
class AdminController extends AsyncNotifier<Map<String, Object?>> {
|
||||||
|
@override
|
||||||
|
Future<Map<String, Object?>> build() =>
|
||||||
|
ref.read(adminRepositoryProvider).dashboard();
|
||||||
|
Future<String?> createDepartment(String code, String name) async {
|
||||||
|
try {
|
||||||
|
await ref.read(adminRepositoryProvider).createDepartment(code, name);
|
||||||
|
ref.invalidateSelf();
|
||||||
|
return null;
|
||||||
|
} catch (e) {
|
||||||
|
return '$e';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<String?> replaceRoles(String id, Set<String> roles) async {
|
||||||
|
try {
|
||||||
|
await ref.read(adminRepositoryProvider).replaceRoles(id, roles);
|
||||||
|
ref.invalidateSelf();
|
||||||
|
return null;
|
||||||
|
} catch (e) {
|
||||||
|
return '$e';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,72 @@
|
|||||||
|
import 'dart:convert';
|
||||||
|
import 'package:http/http.dart' as http;
|
||||||
|
|
||||||
|
class AdminRepository {
|
||||||
|
AdminRepository({required this.client, required this.baseUrl});
|
||||||
|
final http.Client client;
|
||||||
|
final String baseUrl;
|
||||||
|
Future<Set<String>> permissions() async =>
|
||||||
|
((await _get('/me'))['permissions'] as List).cast<String>().toSet();
|
||||||
|
Future<Map<String, Object?>> dashboard() async {
|
||||||
|
final values = await Future.wait([
|
||||||
|
_get('/admin/metrics'),
|
||||||
|
_list('/admin/organization/departments'),
|
||||||
|
_list('/admin/organization/roles'),
|
||||||
|
_list('/admin/organization/users'),
|
||||||
|
_list('/admin/workflows/definitions'),
|
||||||
|
_list('/admin/workflows/instances'),
|
||||||
|
_list('/admin/audit-events?limit=100'),
|
||||||
|
]);
|
||||||
|
return {
|
||||||
|
'metrics': values[0],
|
||||||
|
'departments': values[1],
|
||||||
|
'roles': values[2],
|
||||||
|
'users': values[3],
|
||||||
|
'definitions': values[4],
|
||||||
|
'instances': values[5],
|
||||||
|
'audits': values[6],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> createDepartment(String code, String name) => _write(
|
||||||
|
'POST',
|
||||||
|
'/admin/organization/departments',
|
||||||
|
{'code': code, 'name': name},
|
||||||
|
);
|
||||||
|
Future<void> replaceRoles(String userId, Set<String> roles) => _write(
|
||||||
|
'PUT',
|
||||||
|
'/admin/organization/users/$userId/roles',
|
||||||
|
{'roles': roles.toList()},
|
||||||
|
);
|
||||||
|
Future<Map<String, Object?>> _get(String path) async {
|
||||||
|
final r = await client.get(Uri.parse('$baseUrl$path'));
|
||||||
|
_ok(r);
|
||||||
|
return Map<String, Object?>.from(jsonDecode(r.body) as Map);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<List<Map<String, Object?>>> _list(String path) async {
|
||||||
|
final r = await client.get(Uri.parse('$baseUrl$path'));
|
||||||
|
_ok(r);
|
||||||
|
return (jsonDecode(r.body) as List)
|
||||||
|
.map((e) => Map<String, Object?>.from(e as Map))
|
||||||
|
.toList();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _write(
|
||||||
|
String method,
|
||||||
|
String path,
|
||||||
|
Map<String, Object?> body,
|
||||||
|
) async {
|
||||||
|
final request = http.Request(method, Uri.parse('$baseUrl$path'))
|
||||||
|
..headers['Content-Type'] = 'application/json'
|
||||||
|
..body = jsonEncode(body);
|
||||||
|
final r = await http.Response.fromStream(await client.send(request));
|
||||||
|
_ok(r);
|
||||||
|
}
|
||||||
|
|
||||||
|
void _ok(http.Response r) {
|
||||||
|
if (r.statusCode < 200 || r.statusCode >= 300) {
|
||||||
|
throw Exception('管理请求失败(${r.statusCode})');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,242 @@
|
|||||||
|
import 'package:aioa_mobile/features/admin/application/admin_controller.dart';
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
|
|
||||||
|
class AdminPage extends ConsumerWidget {
|
||||||
|
const AdminPage({super.key});
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
|
final data = ref.watch(adminDashboardProvider);
|
||||||
|
return Scaffold(
|
||||||
|
appBar: AppBar(title: const Text('OA 管理中心')),
|
||||||
|
body: data.when(
|
||||||
|
loading: () => const Center(child: CircularProgressIndicator()),
|
||||||
|
error: (error, _) => Center(
|
||||||
|
child: FilledButton(
|
||||||
|
onPressed: () => ref.invalidate(adminDashboardProvider),
|
||||||
|
child: Text('加载失败,点击重试\n$error'),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
data: (value) => DefaultTabController(
|
||||||
|
length: 4,
|
||||||
|
child: Column(
|
||||||
|
children: [
|
||||||
|
const TabBar(
|
||||||
|
isScrollable: true,
|
||||||
|
tabs: [
|
||||||
|
Tab(text: '指标'),
|
||||||
|
Tab(text: '组织'),
|
||||||
|
Tab(text: '流程'),
|
||||||
|
Tab(text: '审计'),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
Expanded(
|
||||||
|
child: TabBarView(
|
||||||
|
children: [
|
||||||
|
_Metrics(value['metrics']! as Map<String, Object?>),
|
||||||
|
_Organization(value),
|
||||||
|
_Workflow(value),
|
||||||
|
_Audit(value['audits']! as List<Map<String, Object?>>),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _Metrics extends StatelessWidget {
|
||||||
|
const _Metrics(this.data);
|
||||||
|
final Map<String, Object?> data;
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) => GridView.count(
|
||||||
|
padding: const EdgeInsets.all(12),
|
||||||
|
crossAxisCount: 2,
|
||||||
|
childAspectRatio: 1.5,
|
||||||
|
children: data.entries
|
||||||
|
.where((entry) => entry.key != 'leaveByStatus')
|
||||||
|
.map(
|
||||||
|
(entry) => Card(
|
||||||
|
child: Center(
|
||||||
|
child: Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
'${entry.value}',
|
||||||
|
style: Theme.of(context).textTheme.headlineSmall,
|
||||||
|
),
|
||||||
|
Text(entry.key, textAlign: TextAlign.center),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.toList(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
class _Organization extends ConsumerWidget {
|
||||||
|
const _Organization(this.data);
|
||||||
|
final Map<String, Object?> data;
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
|
final users = data['users']! as List<Map<String, Object?>>;
|
||||||
|
final roles = data['roles']! as List<Map<String, Object?>>;
|
||||||
|
return ListView(
|
||||||
|
padding: const EdgeInsets.all(12),
|
||||||
|
children: [
|
||||||
|
FilledButton.icon(
|
||||||
|
onPressed: () => _add(context, ref),
|
||||||
|
icon: const Icon(Icons.add),
|
||||||
|
label: const Text('新增部门'),
|
||||||
|
),
|
||||||
|
...users.map(
|
||||||
|
(user) => Card(
|
||||||
|
child: ListTile(
|
||||||
|
title: Text('${user['displayName']}'),
|
||||||
|
subtitle: Text(
|
||||||
|
'${user['username']} · ${(user['roles'] as List).join('、')}',
|
||||||
|
),
|
||||||
|
trailing: IconButton(
|
||||||
|
icon: const Icon(Icons.manage_accounts),
|
||||||
|
onPressed: () => _roles(context, ref, user, roles),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _add(BuildContext context, WidgetRef ref) async {
|
||||||
|
final code = TextEditingController();
|
||||||
|
final name = TextEditingController();
|
||||||
|
final confirmed = await showDialog<bool>(
|
||||||
|
context: context,
|
||||||
|
builder: (dialogContext) => AlertDialog(
|
||||||
|
title: const Text('新增部门'),
|
||||||
|
content: Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
TextField(
|
||||||
|
controller: code,
|
||||||
|
decoration: const InputDecoration(labelText: '编码'),
|
||||||
|
),
|
||||||
|
TextField(
|
||||||
|
controller: name,
|
||||||
|
decoration: const InputDecoration(labelText: '名称'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
actions: [
|
||||||
|
TextButton(
|
||||||
|
onPressed: () => Navigator.pop(dialogContext, false),
|
||||||
|
child: const Text('取消'),
|
||||||
|
),
|
||||||
|
FilledButton(
|
||||||
|
onPressed: () => Navigator.pop(dialogContext, true),
|
||||||
|
child: const Text('创建'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
if (confirmed == true) {
|
||||||
|
await ref
|
||||||
|
.read(adminDashboardProvider.notifier)
|
||||||
|
.createDepartment(code.text, name.text);
|
||||||
|
}
|
||||||
|
code.dispose();
|
||||||
|
name.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _roles(
|
||||||
|
BuildContext context,
|
||||||
|
WidgetRef ref,
|
||||||
|
Map<String, Object?> user,
|
||||||
|
List<Map<String, Object?>> available,
|
||||||
|
) async {
|
||||||
|
final selected = (user['roles'] as List).cast<String>().toSet();
|
||||||
|
final result = await showDialog<Set<String>>(
|
||||||
|
context: context,
|
||||||
|
builder: (dialogContext) => StatefulBuilder(
|
||||||
|
builder: (context, setState) => AlertDialog(
|
||||||
|
title: Text('${user['displayName']} 的角色'),
|
||||||
|
content: Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: available.map((role) {
|
||||||
|
final code = '${role['code']}';
|
||||||
|
return CheckboxListTile(
|
||||||
|
value: selected.contains(code),
|
||||||
|
title: Text('${role['name']}'),
|
||||||
|
onChanged: (checked) => setState(
|
||||||
|
() => checked == true
|
||||||
|
? selected.add(code)
|
||||||
|
: selected.remove(code),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}).toList(),
|
||||||
|
),
|
||||||
|
actions: [
|
||||||
|
FilledButton(
|
||||||
|
onPressed: () => Navigator.pop(dialogContext, selected),
|
||||||
|
child: const Text('保存'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
if (result != null) {
|
||||||
|
await ref
|
||||||
|
.read(adminDashboardProvider.notifier)
|
||||||
|
.replaceRoles('${user['id']}', result);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _Workflow extends StatelessWidget {
|
||||||
|
const _Workflow(this.data);
|
||||||
|
final Map<String, Object?> data;
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) => ListView(
|
||||||
|
padding: const EdgeInsets.all(12),
|
||||||
|
children: [
|
||||||
|
const Text('流程定义'),
|
||||||
|
...(data['definitions']! as List<Map<String, Object?>>).map(
|
||||||
|
(item) => ListTile(
|
||||||
|
title: Text('${item['name'] ?? item['key']} v${item['version']}'),
|
||||||
|
subtitle: Text('${item['id']}'),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const Divider(),
|
||||||
|
const Text('最近实例'),
|
||||||
|
...(data['instances']! as List<Map<String, Object?>>).map(
|
||||||
|
(item) => ListTile(
|
||||||
|
title: Text('${item['businessKey'] ?? item['id']}'),
|
||||||
|
subtitle: Text(item['active'] == true ? '运行中' : '已结束'),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
class _Audit extends StatelessWidget {
|
||||||
|
const _Audit(this.items);
|
||||||
|
final List<Map<String, Object?>> items;
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) => ListView.builder(
|
||||||
|
padding: const EdgeInsets.all(12),
|
||||||
|
itemCount: items.length,
|
||||||
|
itemBuilder: (context, index) {
|
||||||
|
final item = items[index];
|
||||||
|
return Card(
|
||||||
|
child: ListTile(
|
||||||
|
title: Text('${item['action']}'),
|
||||||
|
subtitle: Text('${item['resourceType']} · ${item['traceId']}'),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -3,6 +3,8 @@ import 'package:flutter/material.dart';
|
|||||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
import 'package:aioa_mobile/features/profile/application/device_controller.dart';
|
import 'package:aioa_mobile/features/profile/application/device_controller.dart';
|
||||||
import 'package:intl/intl.dart';
|
import 'package:intl/intl.dart';
|
||||||
|
import 'package:aioa_mobile/features/admin/application/admin_controller.dart';
|
||||||
|
import 'package:go_router/go_router.dart';
|
||||||
|
|
||||||
class ProfilePage extends ConsumerWidget {
|
class ProfilePage extends ConsumerWidget {
|
||||||
const ProfilePage({super.key});
|
const ProfilePage({super.key});
|
||||||
@@ -10,6 +12,8 @@ class ProfilePage extends ConsumerWidget {
|
|||||||
@override
|
@override
|
||||||
Widget build(BuildContext context, WidgetRef ref) {
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
final devices = ref.watch(deviceListProvider);
|
final devices = ref.watch(deviceListProvider);
|
||||||
|
final permissions =
|
||||||
|
ref.watch(currentPermissionsProvider).value ?? const <String>{};
|
||||||
return ListView(
|
return ListView(
|
||||||
padding: const EdgeInsets.all(18),
|
padding: const EdgeInsets.all(18),
|
||||||
children: [
|
children: [
|
||||||
@@ -21,6 +25,14 @@ class ProfilePage extends ConsumerWidget {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 12),
|
const SizedBox(height: 12),
|
||||||
|
if (permissions.contains('ORGANIZATION_MANAGE_TENANT')) ...[
|
||||||
|
FilledButton.icon(
|
||||||
|
onPressed: () => context.push('/admin'),
|
||||||
|
icon: const Icon(Icons.admin_panel_settings),
|
||||||
|
label: const Text('OA 管理中心'),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
],
|
||||||
Text('登录设备', style: Theme.of(context).textTheme.titleMedium),
|
Text('登录设备', style: Theme.of(context).textTheme.titleMedium),
|
||||||
const SizedBox(height: 8),
|
const SizedBox(height: 8),
|
||||||
...devices.when(
|
...devices.when(
|
||||||
|
|||||||
@@ -0,0 +1,26 @@
|
|||||||
|
import 'dart:convert';
|
||||||
|
import 'package:aioa_mobile/features/admin/data/admin_repository.dart';
|
||||||
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
import 'package:http/http.dart' as http;
|
||||||
|
import 'package:http/testing.dart';
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
test('loads server computed permissions', () async {
|
||||||
|
final repository = AdminRepository(
|
||||||
|
baseUrl: 'https://api.test/api/v1',
|
||||||
|
client: MockClient((request) async {
|
||||||
|
expect(request.url.path, '/api/v1/me');
|
||||||
|
return http.Response(
|
||||||
|
jsonEncode({
|
||||||
|
'permissions': ['ORGANIZATION_MANAGE_TENANT'],
|
||||||
|
}),
|
||||||
|
200,
|
||||||
|
);
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
await repository.permissions(),
|
||||||
|
contains('ORGANIZATION_MANAGE_TENANT'),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user