feat: 新增 approval_staff 员工角色并收紧派单候选池为审批参与人(#45)

This commit is contained in:
2026-10-31 05:02:42 +08:00
parent 2866abe1f5
commit 5018463005
5 changed files with 108 additions and 38 deletions
+59
View File
@@ -0,0 +1,59 @@
-- 审批参与权限授予种子(可重复执行)
-- 目标库:gxtyzx_core(server)
-- 执行顺序:01-approval.sql -> 02-approval_menu.sql -> 03-approval_orgs_roles.sql -> 04-approval_role_grants.sql
--
-- 背景(#45):审批动作的 @PreAuthorize 按 #14 动作矩阵要求 approval:instance:approve,
-- 而报送人/被派单人是一般员工——参与审批的权限由角色授予承载,接口鉴权不放宽。
--
-- 授予矩阵:
-- approval_staff(员工,本文件新建)与既有岗位角色
-- dept_leader_*、dept_deputy_*、approval_archivist、approval_cashier、
-- approval_typist、approval_issuer、approval_general_manager
-- -> 页面 /approval、/approval/todo、/approval/instance
-- -> approval:instance:list、submit、approve
-- approval_archivist 额外 -> approval:instance:archive(收文员归档)
-- assign、export 仍仅 superAdmin(02-approval_menu.sql 已授)
--
-- 成员分配(把 approval_staff 发给员工)由客户在后台完成,同 03 的约定。
-- 按 tenant_id=10150 执行;换租户时改下面的 @tenant_id。
SET @tenant_id := 10150;
-- 1. 员工基础角色
INSERT INTO sys_role (role_name, role_code, comments, sort_number, tenant_id, deleted)
SELECT '员工', 'approval_staff', '审批种子:内部员工基础角色,提交/审批/确认自己的单据(#45)', 900, @tenant_id, 0
FROM DUAL
WHERE NOT EXISTS (
SELECT 1 FROM sys_role WHERE tenant_id = @tenant_id AND deleted = 0 AND role_code = 'approval_staff'
);
-- 2. 参与人角色:页面 + list/submit/approve
INSERT INTO sys_role_menu (role_id, menu_id, tenant_id)
SELECT r.role_id, m.menu_id, @tenant_id
FROM sys_role r
JOIN sys_menu m
ON m.tenant_id = @tenant_id AND m.deleted = 0
AND (m.path IN ('/approval', '/approval/todo', '/approval/instance')
OR m.authority IN ('approval:instance:list', 'approval:instance:submit', 'approval:instance:approve'))
WHERE r.tenant_id = @tenant_id AND r.deleted = 0
AND (r.role_code = 'approval_staff'
OR LEFT(r.role_code, 12) = 'dept_leader_'
OR LEFT(r.role_code, 12) = 'dept_deputy_'
OR r.role_code IN ('approval_archivist', 'approval_cashier', 'approval_typist',
'approval_issuer', 'approval_general_manager'))
AND NOT EXISTS (
SELECT 1 FROM sys_role_menu rm
WHERE rm.role_id = r.role_id AND rm.menu_id = m.menu_id AND rm.tenant_id = @tenant_id
);
-- 3. 收文员追加归档权限
INSERT INTO sys_role_menu (role_id, menu_id, tenant_id)
SELECT r.role_id, m.menu_id, @tenant_id
FROM sys_role r
JOIN sys_menu m
ON m.tenant_id = @tenant_id AND m.deleted = 0 AND m.authority = 'approval:instance:archive'
WHERE r.tenant_id = @tenant_id AND r.deleted = 0 AND r.role_code = 'approval_archivist'
AND NOT EXISTS (
SELECT 1 FROM sys_role_menu rm
WHERE rm.role_id = r.role_id AND rm.menu_id = m.menu_id AND rm.tenant_id = @tenant_id
);
@@ -68,9 +68,11 @@ public final class ApprovalConstants {
// 角色编码
public static final String ROLE_ARCHIVIST = "approval_archivist";
public static final String ROLE_SUPER_ADMIN = "superAdmin";
/** 普通消费用户角色:无部门且仅有这些角色时不算派单候选。 */
public static final String ROLE_USER = "user";
public static final String ROLE_GUEST = "guest";
/** 内部员工基础角色:持有它才能提交/审批/确认,也才进入派单候选池(#45)。 */
public static final String ROLE_STAFF = "approval_staff";
/** 部门岗位角色前缀(dept_leader_<orgCode> / dept_deputy_<orgCode>)。 */
public static final String ROLE_DEPT_LEADER_PREFIX = "dept_leader_";
public static final String ROLE_DEPT_DEPUTY_PREFIX = "dept_deputy_";
// 权限串(与 spec 约定一致;菜单/按钮共用)
public static final String PERMISSION_LIST = "approval:instance:list";
@@ -121,19 +121,25 @@ public class ApprovalDirectoryService {
return jdbcTemplate.query(sql.toString(), USER_MAPPER, args.toArray());
}
/** 部门启用成员。 */
/** 部门启用成员中的审批参与人;无审批角色的成员不进入候选池(#45:派给无权限的人会在存档确认时 403)。 */
public List<ApprovalUserVO> listOrganizationMembers(Integer organizationId) {
if (organizationId == null || organizationId <= 0) {
return Collections.emptyList();
}
Integer tenantId = ApprovalTenantContext.currentTenantId();
List<Object> args = new java.util.ArrayList<>();
args.add(tenantId);
args.add(organizationId);
args.add(tenantId);
args.addAll(participantArgs(tenantId));
return jdbcTemplate.query(
"SELECT u.user_id, u.username, u.nickname, u.real_name, u.organization_id, o.organization_name "
+ "FROM sys_user u "
+ "LEFT JOIN sys_organization o ON o.organization_id = u.organization_id AND o.tenant_id = ? "
+ "WHERE u.organization_id = ? AND u.tenant_id = ? AND u.status = 0 AND u.deleted = 0 "
+ "ORDER BY u.user_id",
USER_MAPPER, tenantId, organizationId, tenantId);
+ "AND " + PARTICIPANT_EXISTS
+ " ORDER BY u.user_id",
USER_MAPPER, args.toArray());
}
/** 按id批量取启用用户。 */
@@ -173,10 +179,10 @@ public class ApprovalDirectoryService {
}
/**
* 派单候选池:优先受理部门启用成员;部门无成员时回退为当前租户的非普通消费用户。
* 派单候选池:优先受理部门启用成员中的审批参与人;部门无参与人时回退为当前租户的审批参与人。
*
* <p>回退排除「未归属组织且角色仅为 user/guest 或无角色」的账号;
* 部门有成员时不混入回退候选。</p>
* <p>审批参与人 = 持有员工或任一审批岗位角色的用户(#45 授予矩阵,见 sql/04-approval_role_grants.sql);
* 两个分支同一判定,避免把单据派给无权限、存档确认必然 403 的人。</p>
*/
public List<ApprovalUserVO> listDispatchCandidates(Integer acceptOrganizationId) {
List<ApprovalUserVO> members = listOrganizationMembers(acceptOrganizationId);
@@ -184,19 +190,37 @@ public class ApprovalDirectoryService {
return members;
}
Integer tenantId = ApprovalTenantContext.currentTenantId();
List<Object> args = new java.util.ArrayList<>();
args.add(tenantId);
args.add(tenantId);
args.addAll(participantArgs(tenantId));
return jdbcTemplate.query(
"SELECT u.user_id, u.username, u.nickname, u.real_name, u.organization_id, o.organization_name "
+ "FROM sys_user u "
+ "LEFT JOIN sys_organization o ON o.organization_id = u.organization_id AND o.tenant_id = ? "
+ "WHERE u.tenant_id = ? AND u.status = 0 AND u.deleted = 0 "
+ "AND NOT ((u.organization_id IS NULL OR u.organization_id = 0) AND NOT EXISTS ("
+ "SELECT 1 FROM sys_user_role ur "
+ "JOIN sys_role r ON r.role_id = ur.role_id AND r.tenant_id = ? "
+ "WHERE ur.user_id = u.user_id AND ur.tenant_id = ? AND r.deleted = 0 "
+ "AND r.role_code NOT IN (?, ?))) "
+ "ORDER BY u.user_id",
USER_MAPPER, tenantId, tenantId, tenantId, tenantId,
ApprovalConstants.ROLE_USER, ApprovalConstants.ROLE_GUEST);
+ "AND " + PARTICIPANT_EXISTS
+ " ORDER BY u.user_id",
USER_MAPPER, args.toArray());
}
/** 审批参与人判定:持有员工、superAdmin 或任一审批岗位角色。 */
private static final String PARTICIPANT_EXISTS =
"EXISTS (SELECT 1 FROM sys_user_role pur "
+ "JOIN sys_role pr ON pr.role_id = pur.role_id AND pr.tenant_id = ? AND pr.deleted = 0 "
+ "WHERE pur.user_id = u.user_id AND pur.tenant_id = ? AND ("
+ "pr.role_code = ? OR LEFT(pr.role_code, 12) = ? OR LEFT(pr.role_code, 12) = ? "
+ "OR pr.role_code IN (?, ?, ?, ?, ?, ?)))";
private static List<Object> participantArgs(Integer tenantId) {
// 与 sql/04-approval_role_grants.sql 的授予矩阵保持一致(superAdmin 在 02 已获全部审批权限)
return List.of(tenantId, tenantId,
ApprovalConstants.ROLE_STAFF,
ApprovalConstants.ROLE_DEPT_LEADER_PREFIX,
ApprovalConstants.ROLE_DEPT_DEPUTY_PREFIX,
ApprovalConstants.ROLE_ARCHIVIST,
ApprovalConstants.ROLE_SUPER_ADMIN,
"approval_cashier", "approval_typist", "approval_issuer", "approval_general_manager");
}
/** 是否持有任一角色(用于 allVisible 判断)。 */
@@ -148,25 +148,6 @@ class ApprovalConfirmArchiveIT {
+ "AND action = 'CONFIRM' AND opinion IS NOT NULL", instanceId)).isZero();
}
/**
* 回归 #45:按 #11/#14,存档确认是报送人本人的动作,不应要求 approval:instance:approve。
* 当前实现 403,掩盖于测试给 reporter 免费发放 APPROVE;待决策(权限授予或改 @PreAuthorize)后
* 按结论启用或改写本用例。
*/
@Test
@org.junit.jupiter.api.Disabled("待 #45 决策:仅持 submit 的报送人当前无法确认自己的存档任务")
void reporterWithoutApproveAuthorityConfirmsOwnArchiveTask() throws Exception {
long instanceId = createAndSubmit("最小权限存档");
approveByLeader(instanceId);
long reporterTask = pendingTaskId(instanceId, reporter.userId);
mockMvc.perform(authed(post("/api/approval/task/" + reporterTask + "/confirm"),
reporter, organizationId, SUBMIT))
.andExpect(status().isOk())
.andExpect(jsonPath("$.code").value(0))
.andExpect(jsonPath("$.data.status").value("APPROVED"));
}
@Test
void archivesOnceAndCancelsOtherArchiveTasks() throws Exception {
long instanceId = createAndSubmit("归档或签");
@@ -89,14 +89,18 @@ class ApprovalDispatchIT {
data.bindTenant(acceptLeader.userId, acceptLeaderRole.roleId, TENANT);
acceptMember = data.createUser("派单受理成员" + data.uniqueSuffix(), acceptOrgId, TENANT);
// 候选池只含审批参与人(#45):报送人/受理成员持 approval_staff
ApprovalTestData.TestRole staffRole = data.createRole("approval_staff", "员工", TENANT);
data.bindTenant(reporter.userId, staffRole.roleId, TENANT);
data.bindTenant(acceptMember.userId, staffRole.roleId, TENANT);
ApprovalTestData.TestRole archivistRole = data.createRole("approval_archivist", "收文员", TENANT);
archivist = data.createUser("派单收文员" + data.uniqueSuffix(), reportOrgId, TENANT);
data.bindTenant(archivist.userId, archivistRole.roleId, TENANT);
outsider = data.createUser("派单路人" + data.uniqueSuffix(), reportOrgId, TENANT);
// 回退候选:无部门但持有业务角色应保留,仅有 user/guest 角色或无角色的普通消费用户被排除
ApprovalTestData.TestRole staffRole = data.createRole("backoffice_staff", "业务管理员", TENANT);
// 回退候选:无部门但持有审批参与角色的用户保留;user/guest 或无角色一律排除
staffNoOrg = data.createUser("派单无部门业务员" + data.uniqueSuffix(), null, TENANT);
data.bindTenant(staffNoOrg.userId, staffRole.roleId, TENANT);