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_DECIDE_ASSIGNED,
|
||||
AUDIT_READ_TENANT_REDACTED,
|
||||
ORGANIZATION_MANAGE_TENANT,
|
||||
WORKFLOW_READ_TENANT,
|
||||
OPERATIONS_METRICS_READ_TENANT,
|
||||
}
|
||||
|
||||
enum class DataScope { OWN, ASSIGNED, TENANT }
|
||||
@@ -42,14 +45,19 @@ object AuthorizationPolicy {
|
||||
val permissions = buildSet {
|
||||
if ("employee" in user.roles) addAll(employeePermissions)
|
||||
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(
|
||||
permissions,
|
||||
buildSet {
|
||||
if (permissions.any { it.name.endsWith("_OWN") }) add(DataScope.OWN)
|
||||
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") }
|
||||
}
|
||||
|
||||
@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(
|
||||
UUID.randomUUID(), UUID.randomUUID(), "user", "用户", null, null, null, roles,
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user