From d44e91d6e67e22b2405c0704f61ce54e52609f3f Mon Sep 17 00:00:00 2001
From: weicw <594098497@qq.com>
Date: Fri, 30 Oct 2026 19:59:41 +0800
Subject: [PATCH 01/18] =?UTF-8?q?feat:=20=E8=A7=92=E8=89=B2=E6=88=90?=
=?UTF-8?q?=E5=91=98=E7=BB=B4=E6=8A=A4=E6=8E=A5=E5=8F=A3=E4=B8=8E=E7=94=A8?=
=?UTF-8?q?=E6=88=B7=E6=B8=85=E7=A9=BA=E8=A7=92=E8=89=B2=E4=BF=AE=E5=A4=8D?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
- 新增 /api/system/role-member 分页/添加/移除,@DS("server"),权限 sys:role:update,写操作记操作日志
- UserServiceImpl.updateUser 在 roles 非空判断下先删后插,空列表清空全部角色;updateUser/pageRel 切 server 库
- OperationRecordServiceImpl 加类级 @DS("server"),操作日志真正写入 gxtyzx_core
- OperationLogAspect 兼容无 User-Agent 的客户端,避免写日志 NPE 导致写操作失败
- RoleMemberIT 覆盖添加成员、移除最后一名成员、按角色分页、用户清空角色、403 与操作日志
---
pom.xml | 7 +
.../core/aspect/OperationLogAspect.java | 9 +-
.../controller/RoleMemberController.java | 60 +++++
.../system/mapper/RoleMemberMapper.java | 29 +++
.../system/mapper/xml/RoleMemberMapper.xml | 26 ++
.../common/system/param/RoleMemberParam.java | 31 +++
.../system/service/RoleMemberService.java | 38 +++
.../impl/OperationRecordServiceImpl.java | 2 +
.../service/impl/RoleMemberServiceImpl.java | 102 ++++++++
.../system/service/impl/UserServiceImpl.java | 7 +-
.../common/system/vo/RoleMemberVO.java | 40 ++++
.../com/gxwebsoft/approval/RoleMemberIT.java | 225 ++++++++++++++++++
.../com/gxwebsoft/support/CoreTestData.java | 167 +++++++++++++
13 files changed, 738 insertions(+), 5 deletions(-)
create mode 100644 src/main/java/com/gxwebsoft/common/system/controller/RoleMemberController.java
create mode 100644 src/main/java/com/gxwebsoft/common/system/mapper/RoleMemberMapper.java
create mode 100644 src/main/java/com/gxwebsoft/common/system/mapper/xml/RoleMemberMapper.xml
create mode 100644 src/main/java/com/gxwebsoft/common/system/param/RoleMemberParam.java
create mode 100644 src/main/java/com/gxwebsoft/common/system/service/RoleMemberService.java
create mode 100644 src/main/java/com/gxwebsoft/common/system/service/impl/RoleMemberServiceImpl.java
create mode 100644 src/main/java/com/gxwebsoft/common/system/vo/RoleMemberVO.java
create mode 100644 src/test/java/com/gxwebsoft/approval/RoleMemberIT.java
create mode 100644 src/test/java/com/gxwebsoft/support/CoreTestData.java
diff --git a/pom.xml b/pom.xml
index bc1296e..c5e8ec3 100644
--- a/pom.xml
+++ b/pom.xml
@@ -40,6 +40,13 @@
test
+
+
+ org.springframework.security
+ spring-security-test
+ test
+
+
org.springframework.boot
diff --git a/src/main/java/com/gxwebsoft/common/core/aspect/OperationLogAspect.java b/src/main/java/com/gxwebsoft/common/core/aspect/OperationLogAspect.java
index fefe1c7..86e6cfb 100644
--- a/src/main/java/com/gxwebsoft/common/core/aspect/OperationLogAspect.java
+++ b/src/main/java/com/gxwebsoft/common/core/aspect/OperationLogAspect.java
@@ -89,9 +89,12 @@ public class OperationLogAspect {
record.setUrl(request.getRequestURI());
record.setRequestMethod(request.getMethod());
UserAgent ua = UserAgentUtil.parse(ServletUtil.getHeaderIgnoreCase(request, "User-Agent"));
- record.setOs(ua.getPlatform().toString());
- record.setDevice(ua.getOs().toString());
- record.setBrowser(ua.getBrowser().toString());
+ // 非浏览器客户端可能不带 User-Agent,此时不解析终端信息
+ if (ua != null) {
+ record.setOs(ua.getPlatform() == null ? null : ua.getPlatform().toString());
+ record.setDevice(ua.getOs() == null ? null : ua.getOs().toString());
+ record.setBrowser(ua.getBrowser() == null ? null : ua.getBrowser().toString());
+ }
record.setIp(ServletUtil.getClientIP(request));
}
// 记录异常信息
diff --git a/src/main/java/com/gxwebsoft/common/system/controller/RoleMemberController.java b/src/main/java/com/gxwebsoft/common/system/controller/RoleMemberController.java
new file mode 100644
index 0000000..8985d1d
--- /dev/null
+++ b/src/main/java/com/gxwebsoft/common/system/controller/RoleMemberController.java
@@ -0,0 +1,60 @@
+package com.gxwebsoft.common.system.controller;
+
+import com.gxwebsoft.common.core.annotation.OperationLog;
+import com.gxwebsoft.common.core.web.ApiResult;
+import com.gxwebsoft.common.core.web.BaseController;
+import com.gxwebsoft.common.core.web.PageResult;
+import com.gxwebsoft.common.system.param.RoleMemberParam;
+import com.gxwebsoft.common.system.service.RoleMemberService;
+import com.gxwebsoft.common.system.vo.RoleMemberVO;
+import io.swagger.annotations.Api;
+import io.swagger.annotations.ApiOperation;
+import org.springframework.security.access.prepost.PreAuthorize;
+import org.springframework.web.bind.annotation.DeleteMapping;
+import org.springframework.web.bind.annotation.GetMapping;
+import org.springframework.web.bind.annotation.PostMapping;
+import org.springframework.web.bind.annotation.RequestBody;
+import org.springframework.web.bind.annotation.RequestMapping;
+import org.springframework.web.bind.annotation.RequestParam;
+import org.springframework.web.bind.annotation.RestController;
+
+import javax.annotation.Resource;
+
+/**
+ * 角色成员控制器
+ *
+ * @author WebSoft
+ * @since 2026-10-30
+ */
+@Api(tags = "角色成员")
+@RestController
+@RequestMapping("/api/system/role-member")
+public class RoleMemberController extends BaseController {
+ @Resource
+ private RoleMemberService roleMemberService;
+
+ @PreAuthorize("hasAuthority('sys:role:update')")
+ @ApiOperation("分页查询角色成员")
+ @GetMapping("/page")
+ public ApiResult> page(RoleMemberParam param) {
+ return success(roleMemberService.pageMembers(param));
+ }
+
+ @PreAuthorize("hasAuthority('sys:role:update')")
+ @OperationLog
+ @ApiOperation("添加角色成员")
+ @PostMapping()
+ public ApiResult> save(@RequestBody RoleMemberParam param) {
+ roleMemberService.addMember(param.getRoleId(), param.getUserId());
+ return success("添加成功");
+ }
+
+ @PreAuthorize("hasAuthority('sys:role:update')")
+ @OperationLog
+ @ApiOperation("移除角色成员")
+ @DeleteMapping()
+ public ApiResult> remove(@RequestParam Integer roleId, @RequestParam Integer userId) {
+ roleMemberService.removeMember(roleId, userId);
+ return success("移除成功");
+ }
+}
diff --git a/src/main/java/com/gxwebsoft/common/system/mapper/RoleMemberMapper.java b/src/main/java/com/gxwebsoft/common/system/mapper/RoleMemberMapper.java
new file mode 100644
index 0000000..2bbc828
--- /dev/null
+++ b/src/main/java/com/gxwebsoft/common/system/mapper/RoleMemberMapper.java
@@ -0,0 +1,29 @@
+package com.gxwebsoft.common.system.mapper;
+
+import com.baomidou.mybatisplus.core.metadata.IPage;
+import com.gxwebsoft.common.system.vo.RoleMemberVO;
+import org.apache.ibatis.annotations.Param;
+
+import java.util.List;
+
+/**
+ * 角色成员Mapper
+ *
+ * @author WebSoft
+ * @since 2026-10-30
+ */
+public interface RoleMemberMapper {
+
+ /**
+ * 分页查询角色下的启用用户
+ *
+ * @param page 分页对象
+ * @param roleId 角色id
+ * @param keywords 账号/姓名关键词
+ * @return List
+ */
+ List selectPageByRoleId(@Param("page") IPage page,
+ @Param("roleId") Integer roleId,
+ @Param("keywords") String keywords);
+
+}
diff --git a/src/main/java/com/gxwebsoft/common/system/mapper/xml/RoleMemberMapper.xml b/src/main/java/com/gxwebsoft/common/system/mapper/xml/RoleMemberMapper.xml
new file mode 100644
index 0000000..103cacf
--- /dev/null
+++ b/src/main/java/com/gxwebsoft/common/system/mapper/xml/RoleMemberMapper.xml
@@ -0,0 +1,26 @@
+
+
+
+
+
+
+
+
diff --git a/src/main/java/com/gxwebsoft/common/system/param/RoleMemberParam.java b/src/main/java/com/gxwebsoft/common/system/param/RoleMemberParam.java
new file mode 100644
index 0000000..4f0d757
--- /dev/null
+++ b/src/main/java/com/gxwebsoft/common/system/param/RoleMemberParam.java
@@ -0,0 +1,31 @@
+package com.gxwebsoft.common.system.param;
+
+import com.baomidou.mybatisplus.annotation.TableField;
+import com.fasterxml.jackson.annotation.JsonInclude;
+import com.gxwebsoft.common.core.web.BaseParam;
+import io.swagger.annotations.ApiModel;
+import io.swagger.annotations.ApiModelProperty;
+import lombok.Data;
+import lombok.EqualsAndHashCode;
+
+/**
+ * 角色成员查询/操作参数
+ *
+ * @author WebSoft
+ * @since 2026-10-30
+ */
+@Data
+@EqualsAndHashCode(callSuper = true)
+@JsonInclude(JsonInclude.Include.NON_NULL)
+@ApiModel(description = "角色成员参数")
+public class RoleMemberParam extends BaseParam {
+ private static final long serialVersionUID = 1L;
+
+ @ApiModelProperty("角色id")
+ @TableField(exist = false)
+ private Integer roleId;
+
+ @ApiModelProperty("用户id")
+ @TableField(exist = false)
+ private Integer userId;
+}
diff --git a/src/main/java/com/gxwebsoft/common/system/service/RoleMemberService.java b/src/main/java/com/gxwebsoft/common/system/service/RoleMemberService.java
new file mode 100644
index 0000000..166b0bc
--- /dev/null
+++ b/src/main/java/com/gxwebsoft/common/system/service/RoleMemberService.java
@@ -0,0 +1,38 @@
+package com.gxwebsoft.common.system.service;
+
+import com.gxwebsoft.common.core.web.PageResult;
+import com.gxwebsoft.common.system.param.RoleMemberParam;
+import com.gxwebsoft.common.system.vo.RoleMemberVO;
+
+/**
+ * 角色成员Service
+ *
+ * @author WebSoft
+ * @since 2026-10-30
+ */
+public interface RoleMemberService {
+
+ /**
+ * 分页查询角色下的启用用户
+ *
+ * @param param 查询参数
+ * @return PageResult
+ */
+ PageResult pageMembers(RoleMemberParam param);
+
+ /**
+ * 添加角色成员
+ *
+ * @param roleId 角色id
+ * @param userId 用户id
+ */
+ void addMember(Integer roleId, Integer userId);
+
+ /**
+ * 移除角色成员
+ *
+ * @param roleId 角色id
+ * @param userId 用户id
+ */
+ void removeMember(Integer roleId, Integer userId);
+}
diff --git a/src/main/java/com/gxwebsoft/common/system/service/impl/OperationRecordServiceImpl.java b/src/main/java/com/gxwebsoft/common/system/service/impl/OperationRecordServiceImpl.java
index 8095bf4..00884df 100644
--- a/src/main/java/com/gxwebsoft/common/system/service/impl/OperationRecordServiceImpl.java
+++ b/src/main/java/com/gxwebsoft/common/system/service/impl/OperationRecordServiceImpl.java
@@ -1,5 +1,6 @@
package com.gxwebsoft.common.system.service.impl;
+import com.baomidou.dynamic.datasource.annotation.DS;
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
import com.gxwebsoft.common.core.web.PageParam;
import com.gxwebsoft.common.core.web.PageResult;
@@ -19,6 +20,7 @@ import java.util.List;
* @since 2018-12-24 16:10:02
*/
@Service
+@DS("server")
public class OperationRecordServiceImpl extends ServiceImpl
implements OperationRecordService {
diff --git a/src/main/java/com/gxwebsoft/common/system/service/impl/RoleMemberServiceImpl.java b/src/main/java/com/gxwebsoft/common/system/service/impl/RoleMemberServiceImpl.java
new file mode 100644
index 0000000..190bcab
--- /dev/null
+++ b/src/main/java/com/gxwebsoft/common/system/service/impl/RoleMemberServiceImpl.java
@@ -0,0 +1,102 @@
+package com.gxwebsoft.common.system.service.impl;
+
+import com.baomidou.dynamic.datasource.annotation.DS;
+import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
+import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper;
+import com.gxwebsoft.common.core.exception.BusinessException;
+import com.gxwebsoft.common.core.web.PageParam;
+import com.gxwebsoft.common.core.web.PageResult;
+import com.gxwebsoft.common.system.entity.Role;
+import com.gxwebsoft.common.system.entity.User;
+import com.gxwebsoft.common.system.entity.UserRole;
+import com.gxwebsoft.common.system.mapper.RoleMapper;
+import com.gxwebsoft.common.system.mapper.RoleMemberMapper;
+import com.gxwebsoft.common.system.mapper.UserMapper;
+import com.gxwebsoft.common.system.mapper.UserRoleMapper;
+import com.gxwebsoft.common.system.param.RoleMemberParam;
+import com.gxwebsoft.common.system.service.RoleMemberService;
+import com.gxwebsoft.common.system.vo.RoleMemberVO;
+import org.springframework.stereotype.Service;
+import org.springframework.transaction.annotation.Transactional;
+
+import javax.annotation.Resource;
+import java.util.Collections;
+import java.util.List;
+
+/**
+ * 角色成员Service实现
+ *
+ * @author WebSoft
+ * @since 2026-10-30
+ */
+@Service
+@DS("server")
+public class RoleMemberServiceImpl implements RoleMemberService {
+ @Resource
+ private RoleMapper roleMapper;
+ @Resource
+ private UserMapper userMapper;
+ @Resource
+ private UserRoleMapper userRoleMapper;
+ @Resource
+ private RoleMemberMapper roleMemberMapper;
+
+ @Override
+ public PageResult pageMembers(RoleMemberParam param) {
+ PageParam page = new PageParam<>(param);
+ List list = roleMemberMapper.selectPageByRoleId(page,
+ param.getRoleId(), param.getKeywords());
+ return new PageResult<>(list, page.getTotal());
+ }
+
+ @Transactional(rollbackFor = Exception.class)
+ @Override
+ public void addMember(Integer roleId, Integer userId) {
+ requireIds(roleId, userId);
+ checkRoleExists(roleId);
+ User user = userMapper.selectById(userId);
+ if (user == null) {
+ throw new BusinessException("用户不存在");
+ }
+ if (user.getStatus() == null || user.getStatus() != 0) {
+ throw new BusinessException("该用户已停用,不能添加为角色成员");
+ }
+ if (userRoleMapper.selectCount(new QueryWrapper()
+ .eq("user_id", userId)
+ .eq("role_id", roleId)) > 0) {
+ throw new BusinessException("该用户已是角色成员");
+ }
+ if (userRoleMapper.insertBatch(userId, Collections.singletonList(roleId)) < 1) {
+ throw new BusinessException("添加成员失败");
+ }
+ }
+
+ @Transactional(rollbackFor = Exception.class)
+ @Override
+ public void removeMember(Integer roleId, Integer userId) {
+ requireIds(roleId, userId);
+ checkRoleExists(roleId);
+ int removed = userRoleMapper.delete(new LambdaQueryWrapper()
+ .eq(UserRole::getUserId, userId)
+ .eq(UserRole::getRoleId, roleId));
+ if (removed < 1) {
+ throw new BusinessException("该用户不是该角色成员");
+ }
+ }
+
+ private void requireIds(Integer roleId, Integer userId) {
+ if (roleId == null) {
+ throw new BusinessException("角色不能为空");
+ }
+ if (userId == null) {
+ throw new BusinessException("用户不能为空");
+ }
+ }
+
+ private void checkRoleExists(Integer roleId) {
+ Role role = roleMapper.selectById(roleId);
+ if (role == null) {
+ throw new BusinessException("角色不存在");
+ }
+ }
+}
diff --git a/src/main/java/com/gxwebsoft/common/system/service/impl/UserServiceImpl.java b/src/main/java/com/gxwebsoft/common/system/service/impl/UserServiceImpl.java
index 660f618..ccaa7d2 100644
--- a/src/main/java/com/gxwebsoft/common/system/service/impl/UserServiceImpl.java
+++ b/src/main/java/com/gxwebsoft/common/system/service/impl/UserServiceImpl.java
@@ -49,6 +49,7 @@ public class UserServiceImpl extends ServiceImpl implements Us
@Resource
private RedisUtil redisUtil;
+ @DS("server")
@Override
public PageResult pageRel(UserParam param) {
PageParam page = new PageParam<>(param);
@@ -142,6 +143,7 @@ public class UserServiceImpl extends ServiceImpl implements Us
return result;
}
+ @DS("server")
@Transactional(rollbackFor = {Exception.class})
@Override
public boolean updateUser(User user) {
@@ -161,10 +163,11 @@ public class UserServiceImpl extends ServiceImpl implements Us
throw new BusinessException("邮箱已存在");
}
boolean result = baseMapper.updateById(user) > 0;
- if (result && user.getRoles() != null && user.getRoles().size() > 0) {
+ // roles 非 null 即代表本次提交了角色数据:先清空旧关联,空列表表示清空全部角色
+ if (result && user.getRoles() != null) {
userRoleService.remove(new LambdaUpdateWrapper().eq(UserRole::getUserId, user.getUserId()));
List roleIds = user.getRoles().stream().map(Role::getRoleId).collect(Collectors.toList());
- if (userRoleService.saveBatch(user.getUserId(), roleIds) < roleIds.size()) {
+ if (!roleIds.isEmpty() && userRoleService.saveBatch(user.getUserId(), roleIds) < roleIds.size()) {
throw new BusinessException("用户角色添加失败");
}
}
diff --git a/src/main/java/com/gxwebsoft/common/system/vo/RoleMemberVO.java b/src/main/java/com/gxwebsoft/common/system/vo/RoleMemberVO.java
new file mode 100644
index 0000000..68edf9f
--- /dev/null
+++ b/src/main/java/com/gxwebsoft/common/system/vo/RoleMemberVO.java
@@ -0,0 +1,40 @@
+package com.gxwebsoft.common.system.vo;
+
+import io.swagger.annotations.ApiModel;
+import io.swagger.annotations.ApiModelProperty;
+import lombok.Data;
+
+import java.io.Serializable;
+
+/**
+ * 角色成员
+ *
+ * @author WebSoft
+ * @since 2026-10-30
+ */
+@Data
+@ApiModel(description = "角色成员")
+public class RoleMemberVO implements Serializable {
+ private static final long serialVersionUID = 1L;
+
+ @ApiModelProperty("用户id")
+ private Integer userId;
+
+ @ApiModelProperty("账号")
+ private String username;
+
+ @ApiModelProperty("真实姓名")
+ private String realName;
+
+ @ApiModelProperty("昵称")
+ private String nickname;
+
+ @ApiModelProperty("所属组织id")
+ private Integer organizationId;
+
+ @ApiModelProperty("所属组织名称")
+ private String organizationName;
+
+ @ApiModelProperty("状态, 0正常, 1停用")
+ private Integer status;
+}
diff --git a/src/test/java/com/gxwebsoft/approval/RoleMemberIT.java b/src/test/java/com/gxwebsoft/approval/RoleMemberIT.java
new file mode 100644
index 0000000..c62a63b
--- /dev/null
+++ b/src/test/java/com/gxwebsoft/approval/RoleMemberIT.java
@@ -0,0 +1,225 @@
+package com.gxwebsoft.approval;
+
+import com.gxwebsoft.support.CoreTestData;
+import com.gxwebsoft.support.TestAuthentication;
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc;
+import org.springframework.boot.test.context.SpringBootTest;
+import org.springframework.http.MediaType;
+import org.springframework.test.context.ActiveProfiles;
+import org.springframework.test.web.servlet.MockMvc;
+import org.springframework.test.web.servlet.request.RequestPostProcessor;
+
+import javax.sql.DataSource;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.authentication;
+import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.delete;
+import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
+import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
+import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.put;
+import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
+import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
+
+/**
+ * 角色成员维护与用户清空角色的端到端行为。
+ */
+@SpringBootTest
+@AutoConfigureMockMvc
+@ActiveProfiles({"gxtyzx-test", "test"})
+class RoleMemberIT {
+
+ @Autowired
+ private MockMvc mockMvc;
+
+ @Autowired
+ private DataSource dataSource;
+
+ private CoreTestData data;
+ private String suffix;
+
+ @BeforeEach
+ void setUp() {
+ data = new CoreTestData(dataSource);
+ suffix = String.valueOf(System.nanoTime());
+ }
+
+ @AfterEach
+ void tearDown() {
+ data.cleanup();
+ }
+
+ @Test
+ void addsMemberAndListsRoleMembers() throws Exception {
+ int roleId = data.createRole("dept_leader_" + suffix, "部门负责人" + suffix);
+ int organizationId = data.createOrganization("测试部门-" + suffix);
+ int userId = data.createUser("member_" + suffix, "张三", organizationId, 0);
+
+ addMember(roleId, userId).andExpect(jsonPath("$.code").value(0));
+ assertThat(awaitOperationRecord("/api/system/role-member", String.valueOf(roleId))).isTrue();
+
+ mockMvc.perform(get("/api/system/role-member/page")
+ .with(auth("sys:role:update"))
+ .header("tenantId", TestAuthentication.TENANT_ID)
+ .param("roleId", String.valueOf(roleId))
+ .param("page", "1")
+ .param("limit", "10"))
+ .andExpect(status().isOk())
+ .andExpect(jsonPath("$.code").value(0))
+ .andExpect(jsonPath("$.data.count").value(1))
+ .andExpect(jsonPath("$.data.list[0].userId").value(userId))
+ .andExpect(jsonPath("$.data.list[0].username").value("member_" + suffix))
+ .andExpect(jsonPath("$.data.list[0].realName").value("张三"))
+ .andExpect(jsonPath("$.data.list[0].organizationId").value(organizationId))
+ .andExpect(jsonPath("$.data.list[0].organizationName").value("测试部门-" + suffix))
+ .andExpect(jsonPath("$.data.list[0].status").value(0));
+ }
+
+ @Test
+ void removingLastMemberLeavesRoleEmpty() throws Exception {
+ int roleId = data.createRole("approval_archivist_" + suffix, "收文员" + suffix);
+ int userId = data.createUser("archivist_" + suffix, "李四", null, 0);
+ addMember(roleId, userId).andExpect(jsonPath("$.code").value(0));
+ assertThat(data.roleIdsOf(userId)).containsExactly(roleId);
+
+ mockMvc.perform(delete("/api/system/role-member")
+ .with(auth("sys:role:update"))
+ .header("tenantId", TestAuthentication.TENANT_ID)
+ .param("roleId", String.valueOf(roleId))
+ .param("userId", String.valueOf(userId)))
+ .andExpect(status().isOk())
+ .andExpect(jsonPath("$.code").value(0));
+
+ assertThat(data.roleIdsOf(userId)).isEmpty();
+ mockMvc.perform(get("/api/system/role-member/page")
+ .with(auth("sys:role:update"))
+ .header("tenantId", TestAuthentication.TENANT_ID)
+ .param("roleId", String.valueOf(roleId))
+ .param("page", "1")
+ .param("limit", "10"))
+ .andExpect(status().isOk())
+ .andExpect(jsonPath("$.code").value(0))
+ .andExpect(jsonPath("$.data.count").value(0))
+ .andExpect(jsonPath("$.data.list.length()").value(0));
+ }
+
+ @Test
+ void clearsAllRolesWhenUserUpdateHasEmptyRoleList() throws Exception {
+ int roleId = data.createRole("dept_deputy_" + suffix, "部门副职" + suffix);
+ int userId = data.createUser("deputy_" + suffix, "王五", null, 0);
+ data.bind(userId, roleId);
+ assertThat(data.roleIdsOf(userId)).containsExactly(roleId);
+
+ mockMvc.perform(put("/api/system/user")
+ .with(auth("sys:user:update"))
+ .header("tenantId", TestAuthentication.TENANT_ID)
+ .contentType(MediaType.APPLICATION_JSON)
+ .content("{\"userId\":" + userId + ",\"nickname\":\"改名" + suffix + "\",\"roles\":[]}"))
+ .andExpect(status().isOk())
+ .andExpect(jsonPath("$.code").value(0));
+
+ assertThat(data.roleIdsOf(userId)).isEmpty();
+ mockMvc.perform(get("/api/system/role-member/page")
+ .with(auth("sys:role:update"))
+ .header("tenantId", TestAuthentication.TENANT_ID)
+ .param("roleId", String.valueOf(roleId))
+ .param("page", "1")
+ .param("limit", "10"))
+ .andExpect(status().isOk())
+ .andExpect(jsonPath("$.data.count").value(0));
+ }
+
+ @Test
+ void pagesRoleMembersAndHidesDisabledUsers() throws Exception {
+ int roleId = data.createRole("dept_leader_page_" + suffix, "分页部门负责人" + suffix);
+ int otherRoleId = data.createRole("other_role_" + suffix, "其他角色" + suffix);
+ int first = data.createUser("page_a_" + suffix, "甲", null, 0);
+ int second = data.createUser("page_b_" + suffix, "乙", null, 0);
+ int third = data.createUser("page_c_" + suffix, "丙", null, 0);
+ int disabled = data.createUser("page_disabled_" + suffix, "停用", null, 1);
+ int outsider = data.createUser("page_other_" + suffix, "外角色", null, 0);
+ for (int userId : new int[]{first, second, third, disabled}) {
+ data.bind(userId, roleId);
+ }
+ data.bind(outsider, otherRoleId);
+
+ mockMvc.perform(get("/api/system/role-member/page")
+ .with(auth("sys:role:update"))
+ .header("tenantId", TestAuthentication.TENANT_ID)
+ .param("roleId", String.valueOf(roleId))
+ .param("page", "1")
+ .param("limit", "2"))
+ .andExpect(status().isOk())
+ .andExpect(jsonPath("$.data.count").value(3))
+ .andExpect(jsonPath("$.data.list.length()").value(2));
+
+ mockMvc.perform(get("/api/system/role-member/page")
+ .with(auth("sys:role:update"))
+ .header("tenantId", TestAuthentication.TENANT_ID)
+ .param("roleId", String.valueOf(roleId))
+ .param("page", "2")
+ .param("limit", "2"))
+ .andExpect(status().isOk())
+ .andExpect(jsonPath("$.data.count").value(3))
+ .andExpect(jsonPath("$.data.list.length()").value(1));
+
+ mockMvc.perform(get("/api/system/role-member/page")
+ .with(auth("sys:role:update"))
+ .header("tenantId", TestAuthentication.TENANT_ID)
+ .param("roleId", String.valueOf(roleId))
+ .param("keywords", "page_a_"))
+ .andExpect(status().isOk())
+ .andExpect(jsonPath("$.data.count").value(1))
+ .andExpect(jsonPath("$.data.list[0].userId").value(first));
+ }
+
+ @Test
+ void rejectsDuplicateMemberWithChineseMessage() throws Exception {
+ int roleId = data.createRole("dept_leader_dup_" + suffix, "重复部门负责人" + suffix);
+ int userId = data.createUser("dup_" + suffix, "赵六", null, 0);
+ addMember(roleId, userId).andExpect(jsonPath("$.code").value(0));
+
+ addMember(roleId, userId)
+ .andExpect(status().isOk())
+ .andExpect(jsonPath("$.code").value(1))
+ .andExpect(jsonPath("$.message").value(org.hamcrest.Matchers.containsString("已是角色成员")));
+ }
+
+ @Test
+ void rejectsWithoutRoleUpdateAuthority() throws Exception {
+ mockMvc.perform(get("/api/system/role-member/page")
+ .with(auth("sys:role:list"))
+ .header("tenantId", TestAuthentication.TENANT_ID)
+ .param("roleId", "586"))
+ .andExpect(status().isOk())
+ .andExpect(jsonPath("$.code").value(403));
+ }
+
+ private org.springframework.test.web.servlet.ResultActions addMember(int roleId, int userId) throws Exception {
+ return mockMvc.perform(post("/api/system/role-member")
+ .with(auth("sys:role:update"))
+ .header("tenantId", TestAuthentication.TENANT_ID)
+ .contentType(MediaType.APPLICATION_JSON)
+ .content("{\"roleId\":" + roleId + ",\"userId\":" + userId + "}"))
+ .andExpect(status().isOk());
+ }
+
+ private RequestPostProcessor auth(String... authorities) {
+ return authentication(TestAuthentication.admin(authorities));
+ }
+
+ /* 操作日志异步写入,最多等待 2 秒 */
+ private boolean awaitOperationRecord(String url, String paramsMark) throws InterruptedException {
+ long deadline = System.currentTimeMillis() + 2000;
+ while (System.currentTimeMillis() < deadline) {
+ if (data.countOperationRecords(url, paramsMark) > 0) {
+ return true;
+ }
+ Thread.sleep(100);
+ }
+ return data.countOperationRecords(url, paramsMark) > 0;
+ }
+}
diff --git a/src/test/java/com/gxwebsoft/support/CoreTestData.java b/src/test/java/com/gxwebsoft/support/CoreTestData.java
new file mode 100644
index 0000000..4f19c0f
--- /dev/null
+++ b/src/test/java/com/gxwebsoft/support/CoreTestData.java
@@ -0,0 +1,167 @@
+package com.gxwebsoft.support;
+
+import com.baomidou.dynamic.datasource.DynamicRoutingDataSource;
+import org.springframework.jdbc.core.JdbcTemplate;
+import org.springframework.jdbc.support.GeneratedKeyHolder;
+import org.springframework.jdbc.support.KeyHolder;
+
+import javax.sql.DataSource;
+import java.sql.PreparedStatement;
+import java.sql.Statement;
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.List;
+
+/**
+ * gxtyzx_core 测试夹具:直接写 sys_user / sys_role / sys_user_role / sys_organization。
+ *
+ * 共享测试库不会被测试事务回滚,调用方必须在用例结束后执行 {@link #cleanup()}。
+ */
+public final class CoreTestData {
+
+ private final JdbcTemplate core;
+ private final List userIds = new ArrayList<>();
+ private final List roleIds = new ArrayList<>();
+ private final List organizationIds = new ArrayList<>();
+ private final List operationParamMarks = new ArrayList<>();
+
+ public CoreTestData(DataSource dataSource) {
+ DataSource server = ((DynamicRoutingDataSource) dataSource).getDataSource("server");
+ this.core = new JdbcTemplate(server);
+ }
+
+ /**
+ * 创建启用/停用用户,返回用户id。
+ */
+ public int createUser(String username, String realName, Integer organizationId, int status) {
+ KeyHolder keyHolder = new GeneratedKeyHolder();
+ core.update(connection -> {
+ PreparedStatement ps = connection.prepareStatement(
+ "INSERT INTO sys_user (username, password, nickname, real_name, organization_id, status, deleted, tenant_id) "
+ + "VALUES (?, ?, ?, ?, ?, ?, 0, ?)", Statement.RETURN_GENERATED_KEYS);
+ ps.setString(1, username);
+ ps.setString(2, "test-password");
+ ps.setString(3, realName);
+ ps.setString(4, realName);
+ if (organizationId == null) {
+ ps.setNull(5, java.sql.Types.INTEGER);
+ } else {
+ ps.setInt(5, organizationId);
+ }
+ ps.setInt(6, status);
+ ps.setInt(7, TestAuthentication.TENANT_ID);
+ return ps;
+ }, keyHolder);
+ int userId = keyHolder.getKey().intValue();
+ userIds.add(userId);
+ return userId;
+ }
+
+ /**
+ * 创建角色,返回角色id。
+ */
+ public int createRole(String roleCode, String roleName) {
+ KeyHolder keyHolder = new GeneratedKeyHolder();
+ core.update(connection -> {
+ PreparedStatement ps = connection.prepareStatement(
+ "INSERT INTO sys_role (role_name, role_code, deleted, tenant_id) VALUES (?, ?, 0, ?)",
+ Statement.RETURN_GENERATED_KEYS);
+ ps.setString(1, roleName);
+ ps.setString(2, roleCode);
+ ps.setInt(3, TestAuthentication.TENANT_ID);
+ return ps;
+ }, keyHolder);
+ int roleId = keyHolder.getKey().intValue();
+ roleIds.add(roleId);
+ // 角色成员写操作的操作日志参数里含 roleId,结束时可据此清理
+ operationParamMarks.add(String.valueOf(roleId));
+ return roleId;
+ }
+
+ /**
+ * 创建组织,返回组织id。
+ */
+ public int createOrganization(String organizationName) {
+ KeyHolder keyHolder = new GeneratedKeyHolder();
+ core.update(connection -> {
+ PreparedStatement ps = connection.prepareStatement(
+ "INSERT INTO sys_organization (parent_id, organization_name, organization_type, deleted, tenant_id) "
+ + "VALUES (0, ?, '', 0, ?)", Statement.RETURN_GENERATED_KEYS);
+ ps.setString(1, organizationName);
+ ps.setInt(2, TestAuthentication.TENANT_ID);
+ return ps;
+ }, keyHolder);
+ int organizationId = keyHolder.getKey().intValue();
+ organizationIds.add(organizationId);
+ return organizationId;
+ }
+
+ /**
+ * 绑定用户与角色。
+ */
+ public void bind(int userId, int roleId) {
+ core.update("INSERT INTO sys_user_role (user_id, role_id, tenant_id) VALUES (?, ?, ?)",
+ userId, roleId, TestAuthentication.TENANT_ID);
+ }
+
+ /**
+ * 查询用户当前的角色id。
+ */
+ public List roleIdsOf(int userId) {
+ return core.queryForList("SELECT role_id FROM sys_user_role WHERE user_id = ?", Integer.class, userId);
+ }
+
+ /**
+ * 统计包含指定参数标记的操作日志条数。
+ */
+ public int countOperationRecords(String url, String paramsMark) {
+ Integer count = core.queryForObject(
+ "SELECT COUNT(*) FROM sys_operation_record WHERE url = ? AND params LIKE ?",
+ Integer.class, url, "%" + paramsMark + "%");
+ return count == null ? 0 : count;
+ }
+
+ /**
+ * 清理本夹具创建的全部行。
+ */
+ public void cleanup() {
+ if (!userIds.isEmpty() || !roleIds.isEmpty()) {
+ List
+
+ org.springframework.security
+ spring-security-test
+ test
+
diff --git a/sql/approval.sql b/sql/approval.sql
new file mode 100644
index 0000000..c87f202
--- /dev/null
+++ b/sql/approval.sql
@@ -0,0 +1,140 @@
+-- 一期线下审批公共表与业务表(可重复执行)
+-- 目标库:db_gxtyzx(master)
+
+CREATE TABLE IF NOT EXISTS approval_flow_definition (
+ id BIGINT NOT NULL AUTO_INCREMENT,
+ tenant_id INT NOT NULL DEFAULT 10150,
+ flow_code VARCHAR(40) NOT NULL,
+ version INT NOT NULL,
+ name VARCHAR(100) NOT NULL,
+ nodes_json JSON NOT NULL,
+ create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ PRIMARY KEY (id),
+ UNIQUE KEY uk_flow_ver (tenant_id, flow_code, version)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='审批流程定义版本';
+
+CREATE TABLE IF NOT EXISTS approval_instance (
+ id BIGINT NOT NULL AUTO_INCREMENT,
+ tenant_id INT NOT NULL DEFAULT 10150,
+ biz_no VARCHAR(32) DEFAULT NULL,
+ flow_code VARCHAR(40) NOT NULL,
+ definition_id BIGINT NOT NULL,
+ definition_version INT NOT NULL,
+ doc_type VARCHAR(40) NOT NULL,
+ biz_id BIGINT NOT NULL,
+ status VARCHAR(20) NOT NULL,
+ round INT NOT NULL DEFAULT 0,
+ reporter_id INT NOT NULL,
+ reporter_name VARCHAR(100) DEFAULT NULL,
+ report_org_id INT DEFAULT NULL,
+ report_org_name VARCHAR(200) DEFAULT NULL,
+ accept_org_id INT DEFAULT NULL,
+ accept_org_name VARCHAR(200) DEFAULT NULL,
+ current_node_key VARCHAR(40) DEFAULT NULL,
+ current_node_type VARCHAR(20) DEFAULT NULL,
+ dispatch_target_id INT DEFAULT NULL,
+ dispatch_target_name VARCHAR(100) DEFAULT NULL,
+ submit_time DATETIME DEFAULT NULL,
+ approve_time DATETIME DEFAULT NULL,
+ archive_time DATETIME DEFAULT NULL,
+ archivist_id INT DEFAULT NULL,
+ archivist_name VARCHAR(100) DEFAULT NULL,
+ create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ update_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
+ PRIMARY KEY (id),
+ UNIQUE KEY uk_biz_no (tenant_id, biz_no),
+ KEY idx_status_submit (tenant_id, status, submit_time),
+ KEY idx_reporter (tenant_id, reporter_id),
+ KEY idx_report_org (tenant_id, report_org_id),
+ KEY idx_accept_org (tenant_id, accept_org_id)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='审批实例';
+
+CREATE TABLE IF NOT EXISTS approval_task (
+ id BIGINT NOT NULL AUTO_INCREMENT,
+ tenant_id INT NOT NULL DEFAULT 10150,
+ instance_id BIGINT NOT NULL,
+ node_key VARCHAR(40) NOT NULL,
+ node_type VARCHAR(20) NOT NULL,
+ round INT NOT NULL,
+ task_type VARCHAR(20) NOT NULL,
+ assignee_id INT NOT NULL,
+ assignee_name VARCHAR(100) DEFAULT NULL,
+ status VARCHAR(20) NOT NULL,
+ handle_time DATETIME DEFAULT NULL,
+ opinion VARCHAR(500) DEFAULT NULL,
+ dispatch_target_id INT DEFAULT NULL,
+ dispatch_target_name VARCHAR(100) DEFAULT NULL,
+ create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ update_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
+ PRIMARY KEY (id),
+ KEY idx_instance (instance_id),
+ KEY idx_assignee_status (tenant_id, assignee_id, status)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='审批任务';
+
+CREATE TABLE IF NOT EXISTS approval_record (
+ id BIGINT NOT NULL AUTO_INCREMENT,
+ tenant_id INT NOT NULL DEFAULT 10150,
+ instance_id BIGINT NOT NULL,
+ task_id BIGINT DEFAULT NULL,
+ node_key VARCHAR(40) DEFAULT NULL,
+ operator_id INT NOT NULL,
+ operator_name VARCHAR(100) DEFAULT NULL,
+ action VARCHAR(20) NOT NULL,
+ opinion VARCHAR(500) DEFAULT NULL,
+ round INT NOT NULL,
+ create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ PRIMARY KEY (id),
+ KEY idx_instance_time (instance_id, create_time)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='审批处理记录';
+
+CREATE TABLE IF NOT EXISTS approval_attachment (
+ id BIGINT NOT NULL AUTO_INCREMENT,
+ tenant_id INT NOT NULL DEFAULT 10150,
+ instance_id BIGINT NOT NULL,
+ file_id INT NOT NULL,
+ category VARCHAR(20) NOT NULL,
+ uploader_id INT NOT NULL,
+ deleted TINYINT NOT NULL DEFAULT 0,
+ create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ PRIMARY KEY (id),
+ KEY idx_instance (instance_id)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='审批附件';
+
+CREATE TABLE IF NOT EXISTS approval_no_sequence (
+ id BIGINT NOT NULL AUTO_INCREMENT,
+ tenant_id INT NOT NULL DEFAULT 10150,
+ prefix VARCHAR(8) NOT NULL,
+ seq_date DATE NOT NULL,
+ current_seq INT NOT NULL DEFAULT 0,
+ PRIMARY KEY (id),
+ UNIQUE KEY uk_seq (tenant_id, prefix, seq_date)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='审批单号序列';
+
+CREATE TABLE IF NOT EXISTS approval_completion_form (
+ id BIGINT NOT NULL AUTO_INCREMENT,
+ tenant_id INT NOT NULL DEFAULT 10150,
+ instance_id BIGINT NOT NULL,
+ name VARCHAR(100) NOT NULL,
+ implement_org VARCHAR(100) NOT NULL,
+ fee DECIMAL(12,2) NOT NULL,
+ service_content VARCHAR(2000) NOT NULL,
+ create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ update_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
+ PRIMARY KEY (id),
+ UNIQUE KEY uk_instance (instance_id)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='业务完成确认单';
+
+CREATE TABLE IF NOT EXISTS approval_coordination_form (
+ id BIGINT NOT NULL AUTO_INCREMENT,
+ tenant_id INT NOT NULL DEFAULT 10150,
+ instance_id BIGINT NOT NULL,
+ accept_org_id INT NOT NULL,
+ accept_org_name VARCHAR(200) NOT NULL,
+ title VARCHAR(100) NOT NULL,
+ content VARCHAR(2000) NOT NULL,
+ remark VARCHAR(500) DEFAULT NULL,
+ create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ update_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
+ PRIMARY KEY (id),
+ UNIQUE KEY uk_instance (instance_id)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='协调单';
diff --git a/sql/approval_menu.sql b/sql/approval_menu.sql
new file mode 100644
index 0000000..b92066c
--- /dev/null
+++ b/sql/approval_menu.sql
@@ -0,0 +1,77 @@
+-- 一期线下审批菜单与权限种子(可重复执行)
+-- 目标库:gxtyzx_core(server)
+
+-- 顶级菜单:审批管理
+INSERT INTO sys_menu (parent_id, title, path, component, sort_number, authority, icon, menu_type, tenant_id, deleted)
+SELECT 0, '审批管理', '/approval', '', 18, 'approval:instance:list', 'AuditOutlined', 0, 10150, 0
+FROM DUAL
+WHERE NOT EXISTS (
+ SELECT 1 FROM sys_menu WHERE tenant_id = 10150 AND path = '/approval' AND menu_type = 0 AND deleted = 0
+);
+
+SET @parent = (SELECT menu_id FROM sys_menu WHERE tenant_id = 10150 AND path = '/approval' AND menu_type = 0 AND deleted = 0 ORDER BY menu_id LIMIT 1);
+
+-- 子菜单:待办审批
+INSERT INTO sys_menu (parent_id, title, path, component, sort_number, authority, icon, menu_type, tenant_id, deleted)
+SELECT @parent, '待办审批', '/approval/todo', '/approval/todo', 1, 'approval:instance:list', '', 0, 10150, 0
+FROM DUAL
+WHERE NOT EXISTS (
+ SELECT 1 FROM sys_menu WHERE tenant_id = 10150 AND path = '/approval/todo' AND menu_type = 0 AND deleted = 0
+);
+
+-- 子菜单:单据查询
+INSERT INTO sys_menu (parent_id, title, path, component, sort_number, authority, icon, menu_type, tenant_id, deleted)
+SELECT @parent, '单据查询', '/approval/instance', '/approval/instance', 2, 'approval:instance:list', '', 0, 10150, 0
+FROM DUAL
+WHERE NOT EXISTS (
+ SELECT 1 FROM sys_menu WHERE tenant_id = 10150 AND path = '/approval/instance' AND menu_type = 0 AND deleted = 0
+);
+
+SET @instanceMenu = (SELECT menu_id FROM sys_menu WHERE tenant_id = 10150 AND path = '/approval/instance' AND menu_type = 0 AND deleted = 0 ORDER BY menu_id LIMIT 1);
+
+-- 按钮权限(挂在单据查询下)
+INSERT INTO sys_menu (parent_id, title, path, component, sort_number, authority, icon, menu_type, tenant_id, deleted)
+SELECT @instanceMenu, '提交单据', '', '', 1, 'approval:instance:submit', '', 1, 10150, 0
+FROM DUAL
+WHERE NOT EXISTS (
+ SELECT 1 FROM sys_menu WHERE tenant_id = 10150 AND authority = 'approval:instance:submit' AND menu_type = 1 AND deleted = 0
+);
+
+INSERT INTO sys_menu (parent_id, title, path, component, sort_number, authority, icon, menu_type, tenant_id, deleted)
+SELECT @instanceMenu, '审批通过', '', '', 2, 'approval:instance:approve', '', 1, 10150, 0
+FROM DUAL
+WHERE NOT EXISTS (
+ SELECT 1 FROM sys_menu WHERE tenant_id = 10150 AND authority = 'approval:instance:approve' AND menu_type = 1 AND deleted = 0
+);
+
+INSERT INTO sys_menu (parent_id, title, path, component, sort_number, authority, icon, menu_type, tenant_id, deleted)
+SELECT @instanceMenu, '归档', '', '', 3, 'approval:instance:archive', '', 1, 10150, 0
+FROM DUAL
+WHERE NOT EXISTS (
+ SELECT 1 FROM sys_menu WHERE tenant_id = 10150 AND authority = 'approval:instance:archive' AND menu_type = 1 AND deleted = 0
+);
+
+INSERT INTO sys_menu (parent_id, title, path, component, sort_number, authority, icon, menu_type, tenant_id, deleted)
+SELECT @instanceMenu, '改派', '', '', 4, 'approval:instance:assign', '', 1, 10150, 0
+FROM DUAL
+WHERE NOT EXISTS (
+ SELECT 1 FROM sys_menu WHERE tenant_id = 10150 AND authority = 'approval:instance:assign' AND menu_type = 1 AND deleted = 0
+);
+
+INSERT INTO sys_menu (parent_id, title, path, component, sort_number, authority, icon, menu_type, tenant_id, deleted)
+SELECT @instanceMenu, '导出', '', '', 5, 'approval:instance:export', '', 1, 10150, 0
+FROM DUAL
+WHERE NOT EXISTS (
+ SELECT 1 FROM sys_menu WHERE tenant_id = 10150 AND authority = 'approval:instance:export' AND menu_type = 1 AND deleted = 0
+);
+
+-- 授权 superAdmin(role_id = 586, tenant 10150)
+INSERT INTO sys_role_menu (role_id, menu_id, tenant_id)
+SELECT 586, m.menu_id, 10150
+FROM sys_menu m
+WHERE m.tenant_id = 10150
+ AND m.deleted = 0
+ AND (m.path IN ('/approval', '/approval/todo', '/approval/instance') OR m.authority LIKE 'approval:instance:%')
+ AND NOT EXISTS (
+ SELECT 1 FROM sys_role_menu rm WHERE rm.role_id = 586 AND rm.menu_id = m.menu_id AND rm.tenant_id = 10150
+ );
diff --git a/src/main/java/com/gxwebsoft/approval/constant/ApprovalConstants.java b/src/main/java/com/gxwebsoft/approval/constant/ApprovalConstants.java
new file mode 100644
index 0000000..3ea09af
--- /dev/null
+++ b/src/main/java/com/gxwebsoft/approval/constant/ApprovalConstants.java
@@ -0,0 +1,169 @@
+package com.gxwebsoft.approval.constant;
+
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+
+/**
+ * 审批域常量:状态、节点、动作、单据类型、单号前缀与附件规则。
+ */
+public final class ApprovalConstants {
+
+ private ApprovalConstants() {
+ }
+
+ // 实例状态
+ public static final String STATUS_DRAFT = "DRAFT";
+ public static final String STATUS_PENDING = "PENDING";
+ public static final String STATUS_REJECTED = "REJECTED";
+ public static final String STATUS_WITHDRAWN = "WITHDRAWN";
+ public static final String STATUS_APPROVED = "APPROVED";
+ public static final String STATUS_ARCHIVED = "ARCHIVED";
+
+ /** 发起人可编辑、可提交的状态。 */
+ public static final Set EDITABLE_STATUSES =
+ Collections.unmodifiableSet(new java.util.HashSet<>(Arrays.asList(
+ STATUS_DRAFT, STATUS_REJECTED, STATUS_WITHDRAWN)));
+
+ // 任务状态
+ public static final String TASK_PENDING = "PENDING";
+ public static final String TASK_DONE = "DONE";
+ public static final String TASK_CANCELLED = "CANCELLED";
+
+ // 节点类型 / 任务类型
+ public static final String NODE_APPROVE = "APPROVE";
+ public static final String NODE_COUNTERSIGN = "COUNTERSIGN";
+ public static final String NODE_DISPATCH = "DISPATCH";
+ public static final String NODE_CONFIRM = "CONFIRM";
+ public static final String NODE_ARCHIVE = "ARCHIVE";
+
+ // 动作
+ public static final String ACTION_SUBMIT = "SUBMIT";
+ public static final String ACTION_RESUBMIT = "RESUBMIT";
+ public static final String ACTION_APPROVE = "APPROVE";
+ public static final String ACTION_REJECT = "REJECT";
+ public static final String ACTION_WITHDRAW = "WITHDRAW";
+ public static final String ACTION_DISPATCH = "DISPATCH";
+ public static final String ACTION_CONFIRM = "CONFIRM";
+ public static final String ACTION_ARCHIVE = "ARCHIVE";
+ public static final String ACTION_ASSIGN = "ASSIGN";
+
+ // 单据类型 / 流程编码
+ public static final String DOC_COMPLETION = "completion";
+ public static final String DOC_COORDINATION = "coordination";
+ public static final String FLOW_COMPLETION = "completion";
+ public static final String FLOW_COORDINATION_INTERNAL = "coordination_internal";
+
+ // 单号前缀
+ public static final String NO_PREFIX_COMPLETION = "WG";
+ public static final String NO_PREFIX_COORDINATION = "XT";
+
+ // 附件分类
+ public static final String CATEGORY_SIGNED = "SIGNED";
+ public static final String CATEGORY_OTHER = "OTHER";
+
+ // 角色编码
+ public static final String ROLE_ARCHIVIST = "approval_archivist";
+ public static final String ROLE_SUPER_ADMIN = "superAdmin";
+
+ // 附件规则
+ public static final long MAX_ATTACHMENT_SIZE = 20L * 1024 * 1024;
+ public static final int MAX_ATTACHMENT_COUNT = 9;
+ public static final List ATTACHMENT_EXTENSIONS =
+ Collections.unmodifiableList(Arrays.asList("jpg", "jpeg", "png", "pdf", "doc", "docx", "xls", "xlsx"));
+
+ /** 扩展名与允许的 content-type 对应关系(服务端双校验)。 */
+ public static final Map> ATTACHMENT_CONTENT_TYPES;
+
+ static {
+ Map> types = new LinkedHashMap<>();
+ types.put("jpg", Arrays.asList("image/jpeg", "image/jpg"));
+ types.put("jpeg", Arrays.asList("image/jpeg", "image/jpg"));
+ types.put("png", Collections.singletonList("image/png"));
+ types.put("pdf", Collections.singletonList("application/pdf"));
+ types.put("doc", Collections.singletonList("application/msword"));
+ types.put("docx", Collections.singletonList(
+ "application/vnd.openxmlformats-officedocument.wordprocessingml.document"));
+ types.put("xls", Collections.singletonList("application/vnd.ms-excel"));
+ types.put("xlsx", Collections.singletonList(
+ "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"));
+ ATTACHMENT_CONTENT_TYPES = Collections.unmodifiableMap(types);
+ }
+
+ private static final Map STATUS_NAMES;
+ private static final Map DOC_TYPE_NAMES;
+ private static final Map ACTION_NAMES;
+ private static final Map CATEGORY_NAMES;
+
+ static {
+ Map status = new LinkedHashMap<>();
+ status.put(STATUS_DRAFT, "未提交");
+ status.put(STATUS_PENDING, "审批中");
+ status.put(STATUS_REJECTED, "已驳回");
+ status.put(STATUS_WITHDRAWN, "已撤回");
+ status.put(STATUS_APPROVED, "审批完成");
+ status.put(STATUS_ARCHIVED, "已归档");
+ STATUS_NAMES = Collections.unmodifiableMap(status);
+
+ Map docType = new LinkedHashMap<>();
+ docType.put(DOC_COMPLETION, "业务完成确认单");
+ docType.put(DOC_COORDINATION, "内部协调单");
+ DOC_TYPE_NAMES = Collections.unmodifiableMap(docType);
+
+ Map action = new LinkedHashMap<>();
+ action.put(ACTION_SUBMIT, "提交");
+ action.put(ACTION_RESUBMIT, "重新提交");
+ action.put(ACTION_APPROVE, "通过");
+ action.put(ACTION_REJECT, "驳回");
+ action.put(ACTION_WITHDRAW, "撤回");
+ action.put(ACTION_DISPATCH, "派单");
+ action.put(ACTION_CONFIRM, "存档确认");
+ action.put(ACTION_ARCHIVE, "归档");
+ action.put(ACTION_ASSIGN, "改派");
+ ACTION_NAMES = Collections.unmodifiableMap(action);
+
+ Map category = new LinkedHashMap<>();
+ category.put(CATEGORY_SIGNED, "签字件");
+ category.put(CATEGORY_OTHER, "其他");
+ CATEGORY_NAMES = Collections.unmodifiableMap(category);
+ }
+
+ /** 业务完成确认单流程定义(version=1)。 */
+ public static final String COMPLETION_NODES_JSON = "["
+ + "{\"key\":\"deptLeader\",\"type\":\"APPROVE\",\"resolver\":\"reporterOrgLeader\",\"name\":\"报送部门负责人\"},"
+ + "{\"key\":\"confirm\",\"type\":\"CONFIRM\",\"resolver\":\"reporter\",\"name\":\"存档\"},"
+ + "{\"key\":\"archive\",\"type\":\"ARCHIVE\",\"resolver\":\"archivist\",\"name\":\"归档\"}"
+ + "]";
+
+ /** 内部协调单流程定义(version=1,表单与派单由后续迭代启用)。 */
+ public static final String COORDINATION_NODES_JSON = "["
+ + "{\"key\":\"reportLeader\",\"type\":\"APPROVE\",\"resolver\":\"reporterOrgLeader\",\"name\":\"报送部门负责人\"},"
+ + "{\"key\":\"acceptLeader\",\"type\":\"APPROVE\",\"resolver\":\"acceptOrgLeader\",\"name\":\"受理部门负责人\"},"
+ + "{\"key\":\"dispatch\",\"type\":\"DISPATCH\",\"resolver\":\"acceptOrgMembers\",\"name\":\"派单\"},"
+ + "{\"key\":\"confirm\",\"type\":\"CONFIRM\",\"resolver\":\"reporterAndDispatchTarget\",\"name\":\"存档\"},"
+ + "{\"key\":\"archive\",\"type\":\"ARCHIVE\",\"resolver\":\"archivist\",\"name\":\"归档\"}"
+ + "]";
+
+ public static String statusName(String status) {
+ return STATUS_NAMES.getOrDefault(status, status);
+ }
+
+ public static String docTypeName(String docType) {
+ return DOC_TYPE_NAMES.getOrDefault(docType, docType);
+ }
+
+ public static String actionName(String action) {
+ return ACTION_NAMES.getOrDefault(action, action);
+ }
+
+ public static String categoryName(String category) {
+ return CATEGORY_NAMES.getOrDefault(category, category);
+ }
+
+ public static String departmentLeaderRoleCode(Integer organizationId) {
+ return organizationId == null ? null : "dept_leader_" + organizationId;
+ }
+}
diff --git a/src/main/java/com/gxwebsoft/approval/controller/ApprovalAttachmentController.java b/src/main/java/com/gxwebsoft/approval/controller/ApprovalAttachmentController.java
new file mode 100644
index 0000000..8893eeb
--- /dev/null
+++ b/src/main/java/com/gxwebsoft/approval/controller/ApprovalAttachmentController.java
@@ -0,0 +1,47 @@
+package com.gxwebsoft.approval.controller;
+
+import com.gxwebsoft.approval.service.ApprovalAttachmentService;
+import com.gxwebsoft.approval.vo.ApprovalAttachmentVO;
+import com.gxwebsoft.common.core.web.ApiResult;
+import com.gxwebsoft.common.core.web.BaseController;
+import io.swagger.annotations.Api;
+import io.swagger.annotations.ApiOperation;
+import org.springframework.security.access.prepost.PreAuthorize;
+import org.springframework.web.bind.annotation.DeleteMapping;
+import org.springframework.web.bind.annotation.PathVariable;
+import org.springframework.web.bind.annotation.PostMapping;
+import org.springframework.web.bind.annotation.RequestMapping;
+import org.springframework.web.bind.annotation.RequestParam;
+import org.springframework.web.bind.annotation.RestController;
+import org.springframework.web.multipart.MultipartFile;
+
+import javax.annotation.Resource;
+
+/**
+ * 审批附件接口:走审批域端点,不要求文件管理权限。
+ */
+@Api(tags = "审批附件")
+@RestController
+@RequestMapping("/api/approval/attachment")
+public class ApprovalAttachmentController extends BaseController {
+
+ @Resource
+ private ApprovalAttachmentService attachmentService;
+
+ @PreAuthorize("hasAuthority('approval:instance:submit')")
+ @ApiOperation("上传附件")
+ @PostMapping("/upload")
+ public ApiResult upload(@RequestParam("instanceId") Long instanceId,
+ @RequestParam("file") MultipartFile file,
+ @RequestParam(value = "category", required = false) String category) {
+ return success(attachmentService.upload(instanceId, file, category, getLoginUser()));
+ }
+
+ @PreAuthorize("hasAuthority('approval:instance:submit')")
+ @ApiOperation("逻辑删除附件")
+ @DeleteMapping("/{id}")
+ public ApiResult> delete(@PathVariable("id") Long id) {
+ attachmentService.delete(id, getLoginUser());
+ return success("删除成功");
+ }
+}
diff --git a/src/main/java/com/gxwebsoft/approval/controller/ApprovalDirectoryController.java b/src/main/java/com/gxwebsoft/approval/controller/ApprovalDirectoryController.java
new file mode 100644
index 0000000..192eb34
--- /dev/null
+++ b/src/main/java/com/gxwebsoft/approval/controller/ApprovalDirectoryController.java
@@ -0,0 +1,45 @@
+package com.gxwebsoft.approval.controller;
+
+import com.gxwebsoft.approval.service.ApprovalDirectoryService;
+import com.gxwebsoft.approval.vo.ApprovalOrganizationVO;
+import com.gxwebsoft.approval.vo.ApprovalUserVO;
+import com.gxwebsoft.common.core.web.ApiResult;
+import com.gxwebsoft.common.core.web.BaseController;
+import io.swagger.annotations.Api;
+import io.swagger.annotations.ApiOperation;
+import org.springframework.security.access.prepost.PreAuthorize;
+import org.springframework.web.bind.annotation.GetMapping;
+import org.springframework.web.bind.annotation.RequestMapping;
+import org.springframework.web.bind.annotation.RequestParam;
+import org.springframework.web.bind.annotation.RestController;
+
+import javax.annotation.Resource;
+import java.util.List;
+
+/**
+ * 审批目录接口:组织与启用用户。
+ */
+@Api(tags = "审批目录")
+@RestController
+@RequestMapping("/api/approval/directory")
+public class ApprovalDirectoryController extends BaseController {
+
+ @Resource
+ private ApprovalDirectoryService directoryService;
+
+ @PreAuthorize("hasAuthority('approval:instance:list')")
+ @ApiOperation("启用组织")
+ @GetMapping("/organizations")
+ public ApiResult> organizations() {
+ return success(directoryService.listOrganizations());
+ }
+
+ @PreAuthorize("hasAuthority('approval:instance:assign')")
+ @ApiOperation("启用用户搜索")
+ @GetMapping("/users")
+ public ApiResult> users(
+ @RequestParam(value = "keywords", required = false) String keywords,
+ @RequestParam(value = "organizationId", required = false) Integer organizationId) {
+ return success(directoryService.searchUsers(keywords, organizationId));
+ }
+}
diff --git a/src/main/java/com/gxwebsoft/approval/controller/ApprovalInstanceController.java b/src/main/java/com/gxwebsoft/approval/controller/ApprovalInstanceController.java
new file mode 100644
index 0000000..586a594
--- /dev/null
+++ b/src/main/java/com/gxwebsoft/approval/controller/ApprovalInstanceController.java
@@ -0,0 +1,65 @@
+package com.gxwebsoft.approval.controller;
+
+import com.gxwebsoft.approval.param.ApprovalCompletionParam;
+import com.gxwebsoft.approval.param.ApprovalMineParam;
+import com.gxwebsoft.approval.service.ApprovalInstanceService;
+import com.gxwebsoft.approval.vo.ApprovalInstanceVO;
+import com.gxwebsoft.approval.vo.ApprovalItemVO;
+import com.gxwebsoft.common.core.web.ApiResult;
+import com.gxwebsoft.common.core.web.BaseController;
+import com.gxwebsoft.common.core.web.PageResult;
+import com.gxwebsoft.common.system.entity.User;
+import io.swagger.annotations.Api;
+import io.swagger.annotations.ApiOperation;
+import org.springframework.security.access.prepost.PreAuthorize;
+import org.springframework.web.bind.annotation.GetMapping;
+import org.springframework.web.bind.annotation.PathVariable;
+import org.springframework.web.bind.annotation.PostMapping;
+import org.springframework.web.bind.annotation.RequestBody;
+import org.springframework.web.bind.annotation.RequestMapping;
+import org.springframework.web.bind.annotation.RestController;
+
+import javax.annotation.Resource;
+
+/**
+ * 审批实例接口(员工端与 PC 共用)。
+ */
+@Api(tags = "审批实例")
+@RestController
+@RequestMapping("/api/approval")
+public class ApprovalInstanceController extends BaseController {
+
+ @Resource
+ private ApprovalInstanceService instanceService;
+
+ @PreAuthorize("hasAuthority('approval:instance:submit')")
+ @ApiOperation("新建或保存业务完成确认单草稿")
+ @PostMapping("/instance/completion")
+ public ApiResult saveCompletion(@RequestBody ApprovalCompletionParam param) {
+ User user = getLoginUser();
+ return success("保存成功", instanceService.saveCompletion(param, user));
+ }
+
+ @PreAuthorize("hasAuthority('approval:instance:submit')")
+ @ApiOperation("提交或重提")
+ @PostMapping("/instance/{id}/submit")
+ public ApiResult submit(@PathVariable("id") Long id) {
+ User user = getLoginUser();
+ String warning = instanceService.submit(id, user);
+ return success(warning == null ? "提交成功" : warning, instanceService.detail(id, user));
+ }
+
+ @PreAuthorize("hasAuthority('approval:instance:list')")
+ @ApiOperation("单据详情")
+ @GetMapping("/instance/{id}")
+ public ApiResult detail(@PathVariable("id") Long id) {
+ return success(instanceService.detail(id, getLoginUser()));
+ }
+
+ @PreAuthorize("hasAuthority('approval:instance:list')")
+ @ApiOperation("我发起的")
+ @GetMapping("/mine/page")
+ public ApiResult> mine(ApprovalMineParam param) {
+ return success(instanceService.mine(param, getLoginUser()));
+ }
+}
diff --git a/src/main/java/com/gxwebsoft/approval/entity/ApprovalAttachment.java b/src/main/java/com/gxwebsoft/approval/entity/ApprovalAttachment.java
new file mode 100644
index 0000000..3aa1985
--- /dev/null
+++ b/src/main/java/com/gxwebsoft/approval/entity/ApprovalAttachment.java
@@ -0,0 +1,41 @@
+package com.gxwebsoft.approval.entity;
+
+import com.baomidou.mybatisplus.annotation.IdType;
+import com.baomidou.mybatisplus.annotation.TableId;
+import com.baomidou.mybatisplus.annotation.TableLogic;
+import com.baomidou.mybatisplus.annotation.TableName;
+import io.swagger.annotations.ApiModel;
+import io.swagger.annotations.ApiModelProperty;
+import lombok.Data;
+
+import java.io.Serializable;
+import java.time.LocalDateTime;
+
+/**
+ * 审批附件绑定(逻辑删除)。
+ */
+@Data
+@ApiModel(description = "审批附件")
+@TableName("approval_attachment")
+public class ApprovalAttachment implements Serializable {
+ private static final long serialVersionUID = 1L;
+
+ @TableId(type = IdType.AUTO)
+ private Long id;
+
+ private Integer tenantId;
+
+ private Long instanceId;
+
+ private Integer fileId;
+
+ @ApiModelProperty("分类:SIGNED 签字件 / OTHER 其他")
+ private String category;
+
+ private Integer uploaderId;
+
+ @TableLogic
+ private Integer deleted;
+
+ private LocalDateTime createTime;
+}
diff --git a/src/main/java/com/gxwebsoft/approval/entity/ApprovalCompletionForm.java b/src/main/java/com/gxwebsoft/approval/entity/ApprovalCompletionForm.java
new file mode 100644
index 0000000..456873b
--- /dev/null
+++ b/src/main/java/com/gxwebsoft/approval/entity/ApprovalCompletionForm.java
@@ -0,0 +1,40 @@
+package com.gxwebsoft.approval.entity;
+
+import com.baomidou.mybatisplus.annotation.IdType;
+import com.baomidou.mybatisplus.annotation.TableId;
+import com.baomidou.mybatisplus.annotation.TableName;
+import io.swagger.annotations.ApiModel;
+import lombok.Data;
+
+import java.io.Serializable;
+import java.math.BigDecimal;
+import java.time.LocalDateTime;
+
+/**
+ * 业务完成确认单。
+ */
+@Data
+@ApiModel(description = "业务完成确认单")
+@TableName("approval_completion_form")
+public class ApprovalCompletionForm implements Serializable {
+ private static final long serialVersionUID = 1L;
+
+ @TableId(type = IdType.AUTO)
+ private Long id;
+
+ private Integer tenantId;
+
+ private Long instanceId;
+
+ private String name;
+
+ private String implementOrg;
+
+ private BigDecimal fee;
+
+ private String serviceContent;
+
+ private LocalDateTime createTime;
+
+ private LocalDateTime updateTime;
+}
diff --git a/src/main/java/com/gxwebsoft/approval/entity/ApprovalCoordinationForm.java b/src/main/java/com/gxwebsoft/approval/entity/ApprovalCoordinationForm.java
new file mode 100644
index 0000000..121d497
--- /dev/null
+++ b/src/main/java/com/gxwebsoft/approval/entity/ApprovalCoordinationForm.java
@@ -0,0 +1,41 @@
+package com.gxwebsoft.approval.entity;
+
+import com.baomidou.mybatisplus.annotation.IdType;
+import com.baomidou.mybatisplus.annotation.TableId;
+import com.baomidou.mybatisplus.annotation.TableName;
+import io.swagger.annotations.ApiModel;
+import lombok.Data;
+
+import java.io.Serializable;
+import java.time.LocalDateTime;
+
+/**
+ * 内部协调单(一期仅内部流程)。
+ */
+@Data
+@ApiModel(description = "内部协调单")
+@TableName("approval_coordination_form")
+public class ApprovalCoordinationForm implements Serializable {
+ private static final long serialVersionUID = 1L;
+
+ @TableId(type = IdType.AUTO)
+ private Long id;
+
+ private Integer tenantId;
+
+ private Long instanceId;
+
+ private Integer acceptOrgId;
+
+ private String acceptOrgName;
+
+ private String title;
+
+ private String content;
+
+ private String remark;
+
+ private LocalDateTime createTime;
+
+ private LocalDateTime updateTime;
+}
diff --git a/src/main/java/com/gxwebsoft/approval/entity/ApprovalFlowDefinition.java b/src/main/java/com/gxwebsoft/approval/entity/ApprovalFlowDefinition.java
new file mode 100644
index 0000000..fce235c
--- /dev/null
+++ b/src/main/java/com/gxwebsoft/approval/entity/ApprovalFlowDefinition.java
@@ -0,0 +1,41 @@
+package com.gxwebsoft.approval.entity;
+
+import com.baomidou.mybatisplus.annotation.IdType;
+import com.baomidou.mybatisplus.annotation.TableId;
+import com.baomidou.mybatisplus.annotation.TableName;
+import io.swagger.annotations.ApiModel;
+import io.swagger.annotations.ApiModelProperty;
+import lombok.Data;
+
+import java.io.Serializable;
+import java.time.LocalDateTime;
+
+/**
+ * 审批流程定义版本。
+ */
+@Data
+@ApiModel(description = "审批流程定义版本")
+@TableName("approval_flow_definition")
+public class ApprovalFlowDefinition implements Serializable {
+ private static final long serialVersionUID = 1L;
+
+ @TableId(type = IdType.AUTO)
+ private Long id;
+
+ @ApiModelProperty("租户id")
+ private Integer tenantId;
+
+ @ApiModelProperty("流程编码")
+ private String flowCode;
+
+ @ApiModelProperty("流程版本")
+ private Integer version;
+
+ @ApiModelProperty("流程名称")
+ private String name;
+
+ @ApiModelProperty("节点JSON")
+ private String nodesJson;
+
+ private LocalDateTime createTime;
+}
diff --git a/src/main/java/com/gxwebsoft/approval/entity/ApprovalInstance.java b/src/main/java/com/gxwebsoft/approval/entity/ApprovalInstance.java
new file mode 100644
index 0000000..ee9ff55
--- /dev/null
+++ b/src/main/java/com/gxwebsoft/approval/entity/ApprovalInstance.java
@@ -0,0 +1,80 @@
+package com.gxwebsoft.approval.entity;
+
+import com.baomidou.mybatisplus.annotation.IdType;
+import com.baomidou.mybatisplus.annotation.TableId;
+import com.baomidou.mybatisplus.annotation.TableName;
+import io.swagger.annotations.ApiModel;
+import io.swagger.annotations.ApiModelProperty;
+import lombok.Data;
+
+import java.io.Serializable;
+import java.time.LocalDateTime;
+
+/**
+ * 审批实例。
+ */
+@Data
+@ApiModel(description = "审批实例")
+@TableName("approval_instance")
+public class ApprovalInstance implements Serializable {
+ private static final long serialVersionUID = 1L;
+
+ @TableId(type = IdType.AUTO)
+ private Long id;
+
+ private Integer tenantId;
+
+ @ApiModelProperty("单号,草稿为空")
+ private String bizNo;
+
+ private String flowCode;
+
+ private Long definitionId;
+
+ private Integer definitionVersion;
+
+ @ApiModelProperty("单据类型:completion/coordination")
+ private String docType;
+
+ @ApiModelProperty("业务表主键")
+ private Long bizId;
+
+ private String status;
+
+ @ApiModelProperty("重提轮次")
+ private Integer round;
+
+ private Integer reporterId;
+
+ private String reporterName;
+
+ private Integer reportOrgId;
+
+ private String reportOrgName;
+
+ private Integer acceptOrgId;
+
+ private String acceptOrgName;
+
+ private String currentNodeKey;
+
+ private String currentNodeType;
+
+ private Integer dispatchTargetId;
+
+ private String dispatchTargetName;
+
+ private LocalDateTime submitTime;
+
+ private LocalDateTime approveTime;
+
+ private LocalDateTime archiveTime;
+
+ private Integer archivistId;
+
+ private String archivistName;
+
+ private LocalDateTime createTime;
+
+ private LocalDateTime updateTime;
+}
diff --git a/src/main/java/com/gxwebsoft/approval/entity/ApprovalNoSequence.java b/src/main/java/com/gxwebsoft/approval/entity/ApprovalNoSequence.java
new file mode 100644
index 0000000..149b313
--- /dev/null
+++ b/src/main/java/com/gxwebsoft/approval/entity/ApprovalNoSequence.java
@@ -0,0 +1,31 @@
+package com.gxwebsoft.approval.entity;
+
+import com.baomidou.mybatisplus.annotation.IdType;
+import com.baomidou.mybatisplus.annotation.TableId;
+import com.baomidou.mybatisplus.annotation.TableName;
+import io.swagger.annotations.ApiModel;
+import lombok.Data;
+
+import java.io.Serializable;
+import java.time.LocalDate;
+
+/**
+ * 审批单号序列(租户 + 前缀 + 日期)。
+ */
+@Data
+@ApiModel(description = "审批单号序列")
+@TableName("approval_no_sequence")
+public class ApprovalNoSequence implements Serializable {
+ private static final long serialVersionUID = 1L;
+
+ @TableId(type = IdType.AUTO)
+ private Long id;
+
+ private Integer tenantId;
+
+ private String prefix;
+
+ private LocalDate seqDate;
+
+ private Integer currentSeq;
+}
diff --git a/src/main/java/com/gxwebsoft/approval/entity/ApprovalRecord.java b/src/main/java/com/gxwebsoft/approval/entity/ApprovalRecord.java
new file mode 100644
index 0000000..22d99a2
--- /dev/null
+++ b/src/main/java/com/gxwebsoft/approval/entity/ApprovalRecord.java
@@ -0,0 +1,43 @@
+package com.gxwebsoft.approval.entity;
+
+import com.baomidou.mybatisplus.annotation.IdType;
+import com.baomidou.mybatisplus.annotation.TableId;
+import com.baomidou.mybatisplus.annotation.TableName;
+import io.swagger.annotations.ApiModel;
+import lombok.Data;
+
+import java.io.Serializable;
+import java.time.LocalDateTime;
+
+/**
+ * 审批处理记录(只增不改)。
+ */
+@Data
+@ApiModel(description = "审批处理记录")
+@TableName("approval_record")
+public class ApprovalRecord implements Serializable {
+ private static final long serialVersionUID = 1L;
+
+ @TableId(type = IdType.AUTO)
+ private Long id;
+
+ private Integer tenantId;
+
+ private Long instanceId;
+
+ private Long taskId;
+
+ private String nodeKey;
+
+ private Integer operatorId;
+
+ private String operatorName;
+
+ private String action;
+
+ private String opinion;
+
+ private Integer round;
+
+ private LocalDateTime createTime;
+}
diff --git a/src/main/java/com/gxwebsoft/approval/entity/ApprovalTask.java b/src/main/java/com/gxwebsoft/approval/entity/ApprovalTask.java
new file mode 100644
index 0000000..81a34b8
--- /dev/null
+++ b/src/main/java/com/gxwebsoft/approval/entity/ApprovalTask.java
@@ -0,0 +1,53 @@
+package com.gxwebsoft.approval.entity;
+
+import com.baomidou.mybatisplus.annotation.IdType;
+import com.baomidou.mybatisplus.annotation.TableId;
+import com.baomidou.mybatisplus.annotation.TableName;
+import io.swagger.annotations.ApiModel;
+import lombok.Data;
+
+import java.io.Serializable;
+import java.time.LocalDateTime;
+
+/**
+ * 审批任务。
+ */
+@Data
+@ApiModel(description = "审批任务")
+@TableName("approval_task")
+public class ApprovalTask implements Serializable {
+ private static final long serialVersionUID = 1L;
+
+ @TableId(type = IdType.AUTO)
+ private Long id;
+
+ private Integer tenantId;
+
+ private Long instanceId;
+
+ private String nodeKey;
+
+ private String nodeType;
+
+ private Integer round;
+
+ private String taskType;
+
+ private Integer assigneeId;
+
+ private String assigneeName;
+
+ private String status;
+
+ private LocalDateTime handleTime;
+
+ private String opinion;
+
+ private Integer dispatchTargetId;
+
+ private String dispatchTargetName;
+
+ private LocalDateTime createTime;
+
+ private LocalDateTime updateTime;
+}
diff --git a/src/main/java/com/gxwebsoft/approval/mapper/ApprovalAttachmentMapper.java b/src/main/java/com/gxwebsoft/approval/mapper/ApprovalAttachmentMapper.java
new file mode 100644
index 0000000..eeb5962
--- /dev/null
+++ b/src/main/java/com/gxwebsoft/approval/mapper/ApprovalAttachmentMapper.java
@@ -0,0 +1,10 @@
+package com.gxwebsoft.approval.mapper;
+
+import com.baomidou.mybatisplus.core.mapper.BaseMapper;
+import com.gxwebsoft.approval.entity.ApprovalAttachment;
+
+/**
+ * 审批附件Mapper。
+ */
+public interface ApprovalAttachmentMapper extends BaseMapper {
+}
diff --git a/src/main/java/com/gxwebsoft/approval/mapper/ApprovalCompletionFormMapper.java b/src/main/java/com/gxwebsoft/approval/mapper/ApprovalCompletionFormMapper.java
new file mode 100644
index 0000000..0019df0
--- /dev/null
+++ b/src/main/java/com/gxwebsoft/approval/mapper/ApprovalCompletionFormMapper.java
@@ -0,0 +1,10 @@
+package com.gxwebsoft.approval.mapper;
+
+import com.baomidou.mybatisplus.core.mapper.BaseMapper;
+import com.gxwebsoft.approval.entity.ApprovalCompletionForm;
+
+/**
+ * 业务完成确认单Mapper。
+ */
+public interface ApprovalCompletionFormMapper extends BaseMapper {
+}
diff --git a/src/main/java/com/gxwebsoft/approval/mapper/ApprovalCoordinationFormMapper.java b/src/main/java/com/gxwebsoft/approval/mapper/ApprovalCoordinationFormMapper.java
new file mode 100644
index 0000000..9c4c13f
--- /dev/null
+++ b/src/main/java/com/gxwebsoft/approval/mapper/ApprovalCoordinationFormMapper.java
@@ -0,0 +1,10 @@
+package com.gxwebsoft.approval.mapper;
+
+import com.baomidou.mybatisplus.core.mapper.BaseMapper;
+import com.gxwebsoft.approval.entity.ApprovalCoordinationForm;
+
+/**
+ * 内部协调单Mapper。
+ */
+public interface ApprovalCoordinationFormMapper extends BaseMapper {
+}
diff --git a/src/main/java/com/gxwebsoft/approval/mapper/ApprovalFlowDefinitionMapper.java b/src/main/java/com/gxwebsoft/approval/mapper/ApprovalFlowDefinitionMapper.java
new file mode 100644
index 0000000..2ee3873
--- /dev/null
+++ b/src/main/java/com/gxwebsoft/approval/mapper/ApprovalFlowDefinitionMapper.java
@@ -0,0 +1,10 @@
+package com.gxwebsoft.approval.mapper;
+
+import com.baomidou.mybatisplus.core.mapper.BaseMapper;
+import com.gxwebsoft.approval.entity.ApprovalFlowDefinition;
+
+/**
+ * 审批流程定义Mapper。
+ */
+public interface ApprovalFlowDefinitionMapper extends BaseMapper {
+}
diff --git a/src/main/java/com/gxwebsoft/approval/mapper/ApprovalInstanceMapper.java b/src/main/java/com/gxwebsoft/approval/mapper/ApprovalInstanceMapper.java
new file mode 100644
index 0000000..0212f77
--- /dev/null
+++ b/src/main/java/com/gxwebsoft/approval/mapper/ApprovalInstanceMapper.java
@@ -0,0 +1,35 @@
+package com.gxwebsoft.approval.mapper;
+
+import com.baomidou.mybatisplus.core.mapper.BaseMapper;
+import com.baomidou.mybatisplus.core.metadata.IPage;
+import com.gxwebsoft.approval.entity.ApprovalInstance;
+import com.gxwebsoft.approval.param.ApprovalMineParam;
+import com.gxwebsoft.approval.support.ApprovalItemRow;
+import org.apache.ibatis.annotations.Param;
+
+import java.util.List;
+
+/**
+ * 审批实例Mapper。
+ */
+public interface ApprovalInstanceMapper extends BaseMapper {
+
+ /**
+ * 对实例行加锁(必须在事务内调用)。
+ */
+ ApprovalInstance selectByIdForUpdate(@Param("id") Long id);
+
+ /**
+ * 判断当前用户是否可查看实例。
+ */
+ int countVisible(@Param("id") Long id,
+ @Param("userId") Integer userId,
+ @Param("allVisible") Integer allVisible);
+
+ /**
+ * 我发起的列表。
+ */
+ List selectMinePage(@Param("page") IPage page,
+ @Param("param") ApprovalMineParam param,
+ @Param("userId") Integer userId);
+}
diff --git a/src/main/java/com/gxwebsoft/approval/mapper/ApprovalNoSequenceMapper.java b/src/main/java/com/gxwebsoft/approval/mapper/ApprovalNoSequenceMapper.java
new file mode 100644
index 0000000..999e44d
--- /dev/null
+++ b/src/main/java/com/gxwebsoft/approval/mapper/ApprovalNoSequenceMapper.java
@@ -0,0 +1,33 @@
+package com.gxwebsoft.approval.mapper;
+
+import com.gxwebsoft.approval.entity.ApprovalNoSequence;
+import org.apache.ibatis.annotations.Insert;
+import org.apache.ibatis.annotations.Param;
+import org.apache.ibatis.annotations.Select;
+import org.apache.ibatis.annotations.Update;
+
+/**
+ * 审批单号序列Mapper:事务内行锁取号。
+ */
+public interface ApprovalNoSequenceMapper {
+
+ /**
+ * 确保当天序列行存在;唯一键冲突时静默忽略。
+ */
+ @Insert("INSERT IGNORE INTO approval_no_sequence (prefix, seq_date, current_seq) "
+ + "VALUES (#{prefix}, CURDATE(), 0)")
+ int insertTodayIfAbsent(@Param("prefix") String prefix);
+
+ /**
+ * 锁定当天序列行。
+ */
+ @Select("SELECT id, prefix, seq_date, current_seq FROM approval_no_sequence "
+ + "WHERE prefix = #{prefix} AND seq_date = CURDATE() FOR UPDATE")
+ ApprovalNoSequence selectTodayForUpdate(@Param("prefix") String prefix);
+
+ /**
+ * 序号自增。
+ */
+ @Update("UPDATE approval_no_sequence SET current_seq = current_seq + 1 WHERE id = #{id}")
+ int increment(@Param("id") Long id);
+}
diff --git a/src/main/java/com/gxwebsoft/approval/mapper/ApprovalRecordMapper.java b/src/main/java/com/gxwebsoft/approval/mapper/ApprovalRecordMapper.java
new file mode 100644
index 0000000..34f58ed
--- /dev/null
+++ b/src/main/java/com/gxwebsoft/approval/mapper/ApprovalRecordMapper.java
@@ -0,0 +1,10 @@
+package com.gxwebsoft.approval.mapper;
+
+import com.baomidou.mybatisplus.core.mapper.BaseMapper;
+import com.gxwebsoft.approval.entity.ApprovalRecord;
+
+/**
+ * 审批处理记录Mapper。
+ */
+public interface ApprovalRecordMapper extends BaseMapper {
+}
diff --git a/src/main/java/com/gxwebsoft/approval/mapper/ApprovalTaskMapper.java b/src/main/java/com/gxwebsoft/approval/mapper/ApprovalTaskMapper.java
new file mode 100644
index 0000000..9326221
--- /dev/null
+++ b/src/main/java/com/gxwebsoft/approval/mapper/ApprovalTaskMapper.java
@@ -0,0 +1,10 @@
+package com.gxwebsoft.approval.mapper;
+
+import com.baomidou.mybatisplus.core.mapper.BaseMapper;
+import com.gxwebsoft.approval.entity.ApprovalTask;
+
+/**
+ * 审批任务Mapper。
+ */
+public interface ApprovalTaskMapper extends BaseMapper {
+}
diff --git a/src/main/java/com/gxwebsoft/approval/mapper/xml/ApprovalInstanceMapper.xml b/src/main/java/com/gxwebsoft/approval/mapper/xml/ApprovalInstanceMapper.xml
new file mode 100644
index 0000000..61cdb83
--- /dev/null
+++ b/src/main/java/com/gxwebsoft/approval/mapper/xml/ApprovalInstanceMapper.xml
@@ -0,0 +1,59 @@
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/main/java/com/gxwebsoft/approval/param/ApprovalCompletionParam.java b/src/main/java/com/gxwebsoft/approval/param/ApprovalCompletionParam.java
new file mode 100644
index 0000000..e8151d4
--- /dev/null
+++ b/src/main/java/com/gxwebsoft/approval/param/ApprovalCompletionParam.java
@@ -0,0 +1,30 @@
+package com.gxwebsoft.approval.param;
+
+import io.swagger.annotations.ApiModelProperty;
+import lombok.Data;
+
+import java.io.Serializable;
+import java.math.BigDecimal;
+
+/**
+ * 业务完成确认单草稿参数;id 为审批实例id,更新草稿时传入。
+ */
+@Data
+public class ApprovalCompletionParam implements Serializable {
+ private static final long serialVersionUID = 1L;
+
+ @ApiModelProperty("审批实例id(更新草稿时传入)")
+ private Long id;
+
+ @ApiModelProperty("名称")
+ private String name;
+
+ @ApiModelProperty("实施单位")
+ private String implementOrg;
+
+ @ApiModelProperty("费用(元)")
+ private BigDecimal fee;
+
+ @ApiModelProperty("服务内容")
+ private String serviceContent;
+}
diff --git a/src/main/java/com/gxwebsoft/approval/param/ApprovalMineParam.java b/src/main/java/com/gxwebsoft/approval/param/ApprovalMineParam.java
new file mode 100644
index 0000000..05b67f5
--- /dev/null
+++ b/src/main/java/com/gxwebsoft/approval/param/ApprovalMineParam.java
@@ -0,0 +1,20 @@
+package com.gxwebsoft.approval.param;
+
+import com.gxwebsoft.common.core.web.BaseParam;
+import io.swagger.annotations.ApiModelProperty;
+import lombok.Data;
+import lombok.EqualsAndHashCode;
+
+/**
+ * 我发起的查询参数。
+ */
+@Data
+@EqualsAndHashCode(callSuper = true)
+public class ApprovalMineParam extends BaseParam {
+
+ @ApiModelProperty("实例状态")
+ private String status;
+
+ @ApiModelProperty("单据类型")
+ private String docType;
+}
diff --git a/src/main/java/com/gxwebsoft/approval/service/ApprovalAttachmentService.java b/src/main/java/com/gxwebsoft/approval/service/ApprovalAttachmentService.java
new file mode 100644
index 0000000..7a3dbfa
--- /dev/null
+++ b/src/main/java/com/gxwebsoft/approval/service/ApprovalAttachmentService.java
@@ -0,0 +1,243 @@
+package com.gxwebsoft.approval.service;
+
+import cn.hutool.core.util.StrUtil;
+import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
+import com.gxwebsoft.approval.constant.ApprovalConstants;
+import com.gxwebsoft.approval.entity.ApprovalAttachment;
+import com.gxwebsoft.approval.entity.ApprovalInstance;
+import com.gxwebsoft.approval.mapper.ApprovalAttachmentMapper;
+import com.gxwebsoft.approval.mapper.ApprovalInstanceMapper;
+import com.gxwebsoft.approval.vo.ApprovalAttachmentVO;
+import com.gxwebsoft.approval.vo.ApprovalUserVO;
+import com.gxwebsoft.common.core.config.ConfigProperties;
+import com.gxwebsoft.common.core.exception.BusinessException;
+import com.gxwebsoft.common.core.utils.FileServerUtil;
+import com.gxwebsoft.common.system.entity.FileRecord;
+import com.gxwebsoft.common.system.entity.User;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+import org.springframework.stereotype.Service;
+import org.springframework.web.multipart.MultipartFile;
+
+import javax.annotation.Resource;
+import java.io.File;
+import java.time.LocalDateTime;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Map;
+import java.util.function.Function;
+import java.util.stream.Collectors;
+
+/**
+ * 审批附件服务:上传、逻辑删除、详情拼装。
+ *
+ * 文件落盘与 sys_file_record 复用既有文件基础设施,附件行写 master 库。
+ * 上传不开启跨库事务:先落文件记录再绑定附件,失败时最多留下孤儿文件(允许)。
+ */
+@Service
+public class ApprovalAttachmentService {
+
+ private static final Logger log = LoggerFactory.getLogger(ApprovalAttachmentService.class);
+
+ @Resource
+ private ApprovalInstanceMapper instanceMapper;
+ @Resource
+ private ApprovalAttachmentMapper attachmentMapper;
+ @Resource
+ private ApprovalFileService fileService;
+ @Resource
+ private ApprovalDirectoryService directoryService;
+ @Resource
+ private ConfigProperties config;
+
+ /**
+ * 详情附件列表:过滤逻辑删除,并从文件记录补齐名称/地址/大小。
+ */
+ public List listVos(Long instanceId) {
+ List attachments = attachmentMapper.selectList(
+ new LambdaQueryWrapper()
+ .eq(ApprovalAttachment::getInstanceId, instanceId)
+ .orderByAsc(ApprovalAttachment::getId));
+ if (attachments.isEmpty()) {
+ return new ArrayList<>();
+ }
+ List fileIds = attachments.stream().map(ApprovalAttachment::getFileId).collect(Collectors.toList());
+ Map files = fileService.listByIds(fileIds).stream()
+ .collect(Collectors.toMap(FileRecord::getId, Function.identity(), (a, b) -> a));
+ List uploaderIds = attachments.stream().map(ApprovalAttachment::getUploaderId)
+ .distinct().collect(Collectors.toList());
+ Map uploaders = directoryService.listUsersByIds(uploaderIds).stream()
+ .collect(Collectors.toMap(ApprovalUserVO::getUserId, this::displayName, (a, b) -> a));
+ List result = new ArrayList<>();
+ for (ApprovalAttachment attachment : attachments) {
+ FileRecord file = files.get(attachment.getFileId());
+ if (file == null) {
+ // 文件记录缺失视为不可用附件,不向前端暴露悬空链接
+ continue;
+ }
+ result.add(toVO(attachment, file, uploaders.get(attachment.getUploaderId())));
+ }
+ return result;
+ }
+
+ public int count(Long instanceId) {
+ Integer count = attachmentMapper.selectCount(new LambdaQueryWrapper()
+ .eq(ApprovalAttachment::getInstanceId, instanceId));
+ return count == null ? 0 : count;
+ }
+
+ /**
+ * 上传附件:仅发起人且实例可编辑时可上传。
+ */
+ public ApprovalAttachmentVO upload(Long instanceId, MultipartFile file, String category, User user) {
+ ApprovalInstance instance = instanceMapper.selectById(instanceId);
+ if (instance == null) {
+ throw new BusinessException("单据不存在");
+ }
+ checkEditable(instance, user);
+
+ String normalizedCategory = StrUtil.isBlank(category) ? ApprovalConstants.CATEGORY_OTHER : category.trim();
+ if (!ApprovalConstants.CATEGORY_SIGNED.equals(normalizedCategory)
+ && !ApprovalConstants.CATEGORY_OTHER.equals(normalizedCategory)) {
+ throw new BusinessException("附件分类不正确");
+ }
+ if (file == null || file.isEmpty()) {
+ throw new BusinessException("附件内容为空");
+ }
+ if (count(instanceId) >= ApprovalConstants.MAX_ATTACHMENT_COUNT) {
+ throw new BusinessException("每单最多上传" + ApprovalConstants.MAX_ATTACHMENT_COUNT + "张附件");
+ }
+ if (file.getSize() > ApprovalConstants.MAX_ATTACHMENT_SIZE) {
+ throw new BusinessException("单个附件不能超过20MB");
+ }
+ String extension = extensionOf(file.getOriginalFilename());
+ checkExtensionAndContentType(extension, file.getContentType());
+
+ File stored;
+ String path;
+ try {
+ String base = config.getUploadPath();
+ String dir = (base.endsWith("/") ? base : base + "/") + "approval/";
+ stored = FileServerUtil.upload(file, dir, Boolean.TRUE.equals(config.getUploadUuidName()));
+ path = stored.getAbsolutePath().replace("\\", "/").substring(dir.length() - 1);
+ } catch (Exception e) {
+ log.error("审批附件落盘失败", e);
+ throw new BusinessException("附件上传失败");
+ }
+
+ FileRecord fileRecord = new FileRecord();
+ fileRecord.setCreateUserId(user.getUserId());
+ fileRecord.setName(StrUtil.isBlank(file.getOriginalFilename()) ? stored.getName() : file.getOriginalFilename());
+ fileRecord.setLength(stored.length());
+ fileRecord.setPath(path);
+ fileRecord.setContentType(normalizeContentType(file.getContentType()));
+ fileService.save(fileRecord);
+
+ ApprovalAttachment attachment = new ApprovalAttachment();
+ attachment.setInstanceId(instanceId);
+ attachment.setFileId(fileRecord.getId());
+ attachment.setCategory(normalizedCategory);
+ attachment.setUploaderId(user.getUserId());
+ attachment.setDeleted(0);
+ attachment.setCreateTime(LocalDateTime.now());
+ attachmentMapper.insert(attachment);
+
+ return toVO(attachment, fileRecord, displayName(user));
+ }
+
+ /**
+ * 逻辑删除附件,底层文件记录不删除。
+ */
+ public void delete(Long attachmentId, User user) {
+ ApprovalAttachment attachment = attachmentMapper.selectById(attachmentId);
+ if (attachment == null) {
+ throw new BusinessException("附件不存在");
+ }
+ ApprovalInstance instance = instanceMapper.selectById(attachment.getInstanceId());
+ if (instance == null) {
+ throw new BusinessException("单据不存在");
+ }
+ checkEditable(instance, user);
+ attachmentMapper.deleteById(attachmentId);
+ }
+
+ private void checkEditable(ApprovalInstance instance, User user) {
+ if (user == null || !instance.getReporterId().equals(user.getUserId())
+ || !ApprovalConstants.EDITABLE_STATUSES.contains(instance.getStatus())) {
+ throw new BusinessException("当前状态不可修改");
+ }
+ }
+
+ private void checkExtensionAndContentType(String extension, String contentType) {
+ if (extension == null || !ApprovalConstants.ATTACHMENT_EXTENSIONS.contains(extension)) {
+ throw new BusinessException("不支持的附件类型,仅支持 jpg/jpeg/png/pdf/doc/docx/xls/xlsx");
+ }
+ List allowed = ApprovalConstants.ATTACHMENT_CONTENT_TYPES.get(extension);
+ String normalized = normalizeContentType(contentType);
+ if (normalized == null || allowed == null || !allowed.contains(normalized)) {
+ throw new BusinessException("不支持的附件类型,仅支持 jpg/jpeg/png/pdf/doc/docx/xls/xlsx");
+ }
+ }
+
+ private String extensionOf(String filename) {
+ if (filename == null) {
+ return null;
+ }
+ int index = filename.lastIndexOf('.');
+ if (index < 0 || index == filename.length() - 1) {
+ return null;
+ }
+ return filename.substring(index + 1).toLowerCase();
+ }
+
+ private String normalizeContentType(String contentType) {
+ if (contentType == null) {
+ return null;
+ }
+ int index = contentType.indexOf(';');
+ String value = index >= 0 ? contentType.substring(0, index) : contentType;
+ return value.trim().toLowerCase();
+ }
+
+ private ApprovalAttachmentVO toVO(ApprovalAttachment attachment, FileRecord file, String uploaderName) {
+ ApprovalAttachmentVO vo = new ApprovalAttachmentVO();
+ vo.setId(attachment.getId());
+ vo.setFileId(attachment.getFileId());
+ vo.setName(file.getName());
+ vo.setUrl(urlOf(file));
+ vo.setLength(file.getLength());
+ vo.setCategory(attachment.getCategory());
+ vo.setCategoryName(ApprovalConstants.categoryName(attachment.getCategory()));
+ vo.setUploaderId(attachment.getUploaderId());
+ vo.setUploaderName(uploaderName);
+ vo.setCreateTime(attachment.getCreateTime());
+ return vo;
+ }
+
+ private String urlOf(FileRecord file) {
+ if (StrUtil.isNotBlank(file.getUrl())) {
+ return file.getUrl();
+ }
+ return config.getServerUrl() + "/file" + (file.getPath() == null ? "" : file.getPath());
+ }
+
+ private String displayName(User user) {
+ if (StrUtil.isNotBlank(user.getRealName())) {
+ return user.getRealName();
+ }
+ if (StrUtil.isNotBlank(user.getNickname())) {
+ return user.getNickname();
+ }
+ return user.getUsername();
+ }
+
+ private String displayName(ApprovalUserVO user) {
+ if (StrUtil.isNotBlank(user.getRealName())) {
+ return user.getRealName();
+ }
+ if (StrUtil.isNotBlank(user.getNickname())) {
+ return user.getNickname();
+ }
+ return user.getUsername();
+ }
+}
diff --git a/src/main/java/com/gxwebsoft/approval/service/ApprovalDirectoryService.java b/src/main/java/com/gxwebsoft/approval/service/ApprovalDirectoryService.java
new file mode 100644
index 0000000..0005653
--- /dev/null
+++ b/src/main/java/com/gxwebsoft/approval/service/ApprovalDirectoryService.java
@@ -0,0 +1,177 @@
+package com.gxwebsoft.approval.service;
+
+import com.baomidou.dynamic.datasource.DynamicRoutingDataSource;
+import com.gxwebsoft.approval.support.ApprovalTenantContext;
+import com.gxwebsoft.approval.vo.ApprovalOrganizationVO;
+import com.gxwebsoft.approval.vo.ApprovalUserVO;
+import org.springframework.jdbc.core.JdbcTemplate;
+import org.springframework.jdbc.core.RowMapper;
+import org.springframework.stereotype.Service;
+
+import javax.sql.DataSource;
+import java.util.Collections;
+import java.util.List;
+
+/**
+ * 审批目录服务:组织、部门负责人/收文员解析、启用用户查询。
+ *
+ * 直接使用 gxtyzx_core 的专用连接,而不是 MyBatis 动态数据源代理:
+ * 审批请求可能处在外层事务中,动态数据源在事务内无法切换库。
+ * 所有 SQL 显式带 tenant_id。
+ */
+@Service
+public class ApprovalDirectoryService {
+
+ private static final RowMapper ORGANIZATION_MAPPER = (resultSet, rowNum) -> {
+ ApprovalOrganizationVO vo = new ApprovalOrganizationVO();
+ vo.setOrganizationId(resultSet.getInt("organization_id"));
+ vo.setParentId(resultSet.getInt("parent_id"));
+ vo.setOrganizationName(resultSet.getString("organization_name"));
+ vo.setOrganizationFullName(resultSet.getString("organization_full_name"));
+ vo.setOrganizationCode(resultSet.getString("organization_code"));
+ int leaderId = resultSet.getInt("leader_id");
+ vo.setLeaderId(resultSet.wasNull() ? null : leaderId);
+ return vo;
+ };
+
+ private static final RowMapper USER_MAPPER = (resultSet, rowNum) -> {
+ ApprovalUserVO vo = new ApprovalUserVO();
+ vo.setUserId(resultSet.getInt("user_id"));
+ vo.setUsername(resultSet.getString("username"));
+ vo.setNickname(resultSet.getString("nickname"));
+ vo.setRealName(resultSet.getString("real_name"));
+ int organizationId = resultSet.getInt("organization_id");
+ vo.setOrganizationId(resultSet.wasNull() ? null : organizationId);
+ vo.setOrganizationName(resultSet.getString("organization_name"));
+ return vo;
+ };
+
+ private final JdbcTemplate jdbcTemplate;
+
+ public ApprovalDirectoryService(DataSource dataSource) {
+ DataSource server = ((DynamicRoutingDataSource) dataSource).getDataSource("server");
+ this.jdbcTemplate = new JdbcTemplate(server);
+ }
+
+ /** 启用组织。 */
+ public List listOrganizations() {
+ return jdbcTemplate.query(
+ "SELECT organization_id, parent_id, organization_name, organization_full_name, "
+ + "organization_code, leader_id FROM sys_organization "
+ + "WHERE tenant_id = ? AND deleted = 0 ORDER BY sort_number, organization_id",
+ ORGANIZATION_MAPPER, ApprovalTenantContext.currentTenantId());
+ }
+
+ /** 按id取启用组织;不存在返回 null。 */
+ public ApprovalOrganizationVO getOrganization(Integer organizationId) {
+ if (organizationId == null || organizationId <= 0) {
+ return null;
+ }
+ List list = jdbcTemplate.query(
+ "SELECT organization_id, parent_id, organization_name, organization_full_name, "
+ + "organization_code, leader_id FROM sys_organization "
+ + "WHERE organization_id = ? AND tenant_id = ? AND deleted = 0",
+ ORGANIZATION_MAPPER, organizationId, ApprovalTenantContext.currentTenantId());
+ return list.isEmpty() ? null : list.get(0);
+ }
+
+ /** 角色下全部启用成员;角色不存在或成员为空时返回空列表。 */
+ public List listRoleMembers(String roleCode) {
+ if (roleCode == null) {
+ return Collections.emptyList();
+ }
+ Integer tenantId = ApprovalTenantContext.currentTenantId();
+ return jdbcTemplate.query(
+ "SELECT u.user_id, u.username, u.nickname, u.real_name, u.organization_id, o.organization_name "
+ + "FROM sys_user u "
+ + "JOIN sys_user_role ur ON ur.user_id = u.user_id AND ur.tenant_id = ? "
+ + "JOIN sys_role r ON r.role_id = ur.role_id AND r.tenant_id = ? "
+ + "LEFT JOIN sys_organization o ON o.organization_id = u.organization_id AND o.tenant_id = ? "
+ + "WHERE r.role_code = ? AND r.deleted = 0 AND u.status = 0 AND u.deleted = 0 "
+ + "AND u.tenant_id = ? ORDER BY u.user_id",
+ USER_MAPPER, tenantId, tenantId, tenantId, roleCode, tenantId);
+ }
+
+ /** 部门负责人角色是否已配置至少一名启用成员。 */
+ public boolean hasDepartmentLeader(Integer organizationId) {
+ return organizationId != null && !listRoleMembers("dept_leader_" + organizationId).isEmpty();
+ }
+
+ /** 启用用户搜索,最多 50 条。 */
+ public List searchUsers(String keywords, Integer organizationId) {
+ Integer tenantId = ApprovalTenantContext.currentTenantId();
+ StringBuilder sql = new StringBuilder(
+ "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");
+ List
-
- org.springframework.security
- spring-security-test
- test
-
From 3b599efb3b5466f6e28bc975b41c35b805b58c3e Mon Sep 17 00:00:00 2001
From: weicw <594098497@qq.com>
Date: Fri, 30 Oct 2026 20:27:11 +0800
Subject: [PATCH 04/18] =?UTF-8?q?test(approval):=20=E8=A6=86=E7=9B=96?=
=?UTF-8?q?=E6=B5=81=E7=A8=8B=E5=AE=9A=E4=B9=89=E5=B9=82=E7=AD=89=EF=BC=9B?=
=?UTF-8?q?=E6=A0=B8=E5=BF=83=E5=BA=93=E6=9C=8D=E5=8A=A1=E6=98=BE=E5=BC=8F?=
=?UTF-8?q?=E6=A0=87=E6=B3=A8=20server=20=E6=95=B0=E6=8D=AE=E6=BA=90?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
.../approval/service/ApprovalDirectoryService.java | 2 ++
.../approval/service/ApprovalFileService.java | 2 ++
.../approval/ApprovalCompletionDraftIT.java | 13 +++++++++++++
3 files changed, 17 insertions(+)
diff --git a/src/main/java/com/gxwebsoft/approval/service/ApprovalDirectoryService.java b/src/main/java/com/gxwebsoft/approval/service/ApprovalDirectoryService.java
index 0005653..c032296 100644
--- a/src/main/java/com/gxwebsoft/approval/service/ApprovalDirectoryService.java
+++ b/src/main/java/com/gxwebsoft/approval/service/ApprovalDirectoryService.java
@@ -1,6 +1,7 @@
package com.gxwebsoft.approval.service;
import com.baomidou.dynamic.datasource.DynamicRoutingDataSource;
+import com.baomidou.dynamic.datasource.annotation.DS;
import com.gxwebsoft.approval.support.ApprovalTenantContext;
import com.gxwebsoft.approval.vo.ApprovalOrganizationVO;
import com.gxwebsoft.approval.vo.ApprovalUserVO;
@@ -20,6 +21,7 @@ import java.util.List;
* 所有 SQL 显式带 tenant_id。
*/
@Service
+@DS("server")
public class ApprovalDirectoryService {
private static final RowMapper ORGANIZATION_MAPPER = (resultSet, rowNum) -> {
diff --git a/src/main/java/com/gxwebsoft/approval/service/ApprovalFileService.java b/src/main/java/com/gxwebsoft/approval/service/ApprovalFileService.java
index c987404..102a855 100644
--- a/src/main/java/com/gxwebsoft/approval/service/ApprovalFileService.java
+++ b/src/main/java/com/gxwebsoft/approval/service/ApprovalFileService.java
@@ -1,6 +1,7 @@
package com.gxwebsoft.approval.service;
import com.baomidou.dynamic.datasource.DynamicRoutingDataSource;
+import com.baomidou.dynamic.datasource.annotation.DS;
import com.gxwebsoft.approval.support.ApprovalTenantContext;
import com.gxwebsoft.common.system.entity.FileRecord;
import org.springframework.jdbc.core.JdbcTemplate;
@@ -24,6 +25,7 @@ import java.util.List;
* 不依赖文件管理权限,审批上传/详情均通过本服务复用既有文件记录。
*/
@Service
+@DS("server")
public class ApprovalFileService {
private static final RowMapper FILE_MAPPER = (resultSet, rowNum) -> {
diff --git a/src/test/java/com/gxwebsoft/approval/ApprovalCompletionDraftIT.java b/src/test/java/com/gxwebsoft/approval/ApprovalCompletionDraftIT.java
index 953aa0e..509b4c0 100644
--- a/src/test/java/com/gxwebsoft/approval/ApprovalCompletionDraftIT.java
+++ b/src/test/java/com/gxwebsoft/approval/ApprovalCompletionDraftIT.java
@@ -96,6 +96,19 @@ class ApprovalCompletionDraftIT {
.andExpect(jsonPath("$.data.list[0].bizNo").doesNotExist());
}
+ @Test
+ void publishesSingleFlowDefinitionPerTenant() throws Exception {
+ createCompletion(reporter, organizationId, TENANT, "流程一");
+ createCompletion(reporter, organizationId, TENANT, "流程二");
+
+ Integer count = data.countMaster("SELECT COUNT(*) FROM approval_flow_definition "
+ + "WHERE tenant_id = ? AND flow_code = 'completion' AND version = 1", TENANT);
+ assertThat(count).isEqualTo(1);
+ assertThat(data.queryMaster("SELECT nodes_json FROM approval_flow_definition "
+ + "WHERE tenant_id = ? AND flow_code = 'completion' AND version = 1", String.class, TENANT))
+ .contains("deptLeader").contains("confirm").contains("archive");
+ }
+
@Test
void validatesCompletionFields() throws Exception {
expectSaveFailure(body("", "实施单位", "10.00", "服务内容"), "名称不能为空");
From a85895583eb609d2eb5add68d426ad725a98c441 Mon Sep 17 00:00:00 2001
From: weicw <594098497@qq.com>
Date: Fri, 30 Oct 2026 20:45:26 +0800
Subject: [PATCH 05/18] =?UTF-8?q?feat(approval):=20=E5=AE=A1=E6=89=B9?=
=?UTF-8?q?=E4=B8=AD=E5=BF=83=E5=BE=85=E5=8A=9E/=E5=B7=B2=E5=8A=9E?=
=?UTF-8?q?=E4=B8=8E=E9=80=9A=E8=BF=87=E9=A9=B3=E5=9B=9E=E6=92=A4=E5=9B=9E?=
=?UTF-8?q?=20(#20)?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
- 新增 ApprovalTaskService/Controller:通过、驳回、撤回、待办数量与节点推进
- 通过后同节点同轮其余待办取消,下一节点审批人事务外解析、事务内生成任务
- 任务条件更新(WHERE status='PENDING')保证并发抢办仅成功一次
- 新增 todo/done 列表(状态+关键词)与实例去重的待办计数
- ApprovalTaskIT 覆盖通过、驳回重提、或签、并发、自审、撤回、列表筛选
---
.../controller/ApprovalTaskController.java | 88 ++++
.../approval/mapper/ApprovalTaskMapper.java | 19 +
.../mapper/xml/ApprovalTaskMapper.xml | 50 +++
.../param/ApprovalTaskActionParam.java | 17 +
.../approval/param/ApprovalTaskListParam.java | 20 +
.../service/ApprovalDirectoryService.java | 18 +
.../service/ApprovalEngineService.java | 16 +
.../service/ApprovalInstanceService.java | 29 +-
.../approval/service/ApprovalTaskService.java | 344 +++++++++++++++
.../gxwebsoft/approval/ApprovalTaskIT.java | 406 ++++++++++++++++++
10 files changed, 1006 insertions(+), 1 deletion(-)
create mode 100644 src/main/java/com/gxwebsoft/approval/controller/ApprovalTaskController.java
create mode 100644 src/main/java/com/gxwebsoft/approval/mapper/xml/ApprovalTaskMapper.xml
create mode 100644 src/main/java/com/gxwebsoft/approval/param/ApprovalTaskActionParam.java
create mode 100644 src/main/java/com/gxwebsoft/approval/param/ApprovalTaskListParam.java
create mode 100644 src/main/java/com/gxwebsoft/approval/service/ApprovalTaskService.java
create mode 100644 src/test/java/com/gxwebsoft/approval/ApprovalTaskIT.java
diff --git a/src/main/java/com/gxwebsoft/approval/controller/ApprovalTaskController.java b/src/main/java/com/gxwebsoft/approval/controller/ApprovalTaskController.java
new file mode 100644
index 0000000..58af23e
--- /dev/null
+++ b/src/main/java/com/gxwebsoft/approval/controller/ApprovalTaskController.java
@@ -0,0 +1,88 @@
+package com.gxwebsoft.approval.controller;
+
+import com.gxwebsoft.approval.param.ApprovalTaskActionParam;
+import com.gxwebsoft.approval.param.ApprovalTaskListParam;
+import com.gxwebsoft.approval.service.ApprovalInstanceService;
+import com.gxwebsoft.approval.service.ApprovalTaskService;
+import com.gxwebsoft.approval.vo.ApprovalInstanceVO;
+import com.gxwebsoft.approval.vo.ApprovalItemVO;
+import com.gxwebsoft.common.core.web.ApiResult;
+import com.gxwebsoft.common.core.web.BaseController;
+import com.gxwebsoft.common.core.web.PageResult;
+import com.gxwebsoft.common.system.entity.User;
+import io.swagger.annotations.Api;
+import io.swagger.annotations.ApiOperation;
+import org.springframework.security.access.prepost.PreAuthorize;
+import org.springframework.web.bind.annotation.GetMapping;
+import org.springframework.web.bind.annotation.PathVariable;
+import org.springframework.web.bind.annotation.PostMapping;
+import org.springframework.web.bind.annotation.RequestBody;
+import org.springframework.web.bind.annotation.RequestMapping;
+import org.springframework.web.bind.annotation.RestController;
+
+import javax.annotation.Resource;
+
+/**
+ * 审批待办与任务动作接口(员工端与 PC 共用)。
+ */
+@Api(tags = "审批待办")
+@RestController
+@RequestMapping("/api/approval")
+public class ApprovalTaskController extends BaseController {
+
+ @Resource
+ private ApprovalTaskService taskService;
+ @Resource
+ private ApprovalInstanceService instanceService;
+
+ @PreAuthorize("hasAuthority('approval:instance:list')")
+ @ApiOperation("我的待办")
+ @GetMapping("/todo/page")
+ public ApiResult> todo(ApprovalTaskListParam param) {
+ return success(instanceService.todo(param, getLoginUser()));
+ }
+
+ @PreAuthorize("hasAuthority('approval:instance:list')")
+ @ApiOperation("我的已办")
+ @GetMapping("/done/page")
+ public ApiResult> done(ApprovalTaskListParam param) {
+ return success(instanceService.done(param, getLoginUser()));
+ }
+
+ @PreAuthorize("hasAuthority('approval:instance:list')")
+ @ApiOperation("待办数量")
+ @GetMapping("/todo/count")
+ public ApiResult todoCount() {
+ return success(taskService.todoCount(getLoginUser()));
+ }
+
+ @PreAuthorize("hasAuthority('approval:instance:approve')")
+ @ApiOperation("通过")
+ @PostMapping("/task/{id}/approve")
+ public ApiResult approve(@PathVariable("id") Long id,
+ @RequestBody(required = false) ApprovalTaskActionParam param) {
+ User user = getLoginUser();
+ ApprovalTaskService.Result result = taskService.approve(id, param == null ? null : param.getOpinion(), user);
+ return success(result.getWarning() == null ? "审批成功" : result.getWarning(),
+ instanceService.detail(result.getInstanceId(), user));
+ }
+
+ @PreAuthorize("hasAuthority('approval:instance:approve')")
+ @ApiOperation("驳回")
+ @PostMapping("/task/{id}/reject")
+ public ApiResult reject(@PathVariable("id") Long id,
+ @RequestBody(required = false) ApprovalTaskActionParam param) {
+ User user = getLoginUser();
+ ApprovalTaskService.Result result = taskService.reject(id, param == null ? null : param.getOpinion(), user);
+ return success("驳回成功", instanceService.detail(result.getInstanceId(), user));
+ }
+
+ @PreAuthorize("hasAuthority('approval:instance:submit')")
+ @ApiOperation("撤回")
+ @PostMapping("/instance/{id}/withdraw")
+ public ApiResult withdraw(@PathVariable("id") Long id) {
+ User user = getLoginUser();
+ ApprovalTaskService.Result result = taskService.withdraw(id, user);
+ return success("撤回成功", instanceService.detail(result.getInstanceId(), user));
+ }
+}
diff --git a/src/main/java/com/gxwebsoft/approval/mapper/ApprovalTaskMapper.java b/src/main/java/com/gxwebsoft/approval/mapper/ApprovalTaskMapper.java
index 9326221..ece5152 100644
--- a/src/main/java/com/gxwebsoft/approval/mapper/ApprovalTaskMapper.java
+++ b/src/main/java/com/gxwebsoft/approval/mapper/ApprovalTaskMapper.java
@@ -1,10 +1,29 @@
package com.gxwebsoft.approval.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
+import com.baomidou.mybatisplus.core.metadata.IPage;
import com.gxwebsoft.approval.entity.ApprovalTask;
+import com.gxwebsoft.approval.param.ApprovalTaskListParam;
+import com.gxwebsoft.approval.support.ApprovalItemRow;
+import org.apache.ibatis.annotations.Param;
+
+import java.util.List;
/**
* 审批任务Mapper。
*/
public interface ApprovalTaskMapper extends BaseMapper {
+
+ /**
+ * 待办/已办列表:按本人任务状态取实例,天然按实例去重。
+ */
+ List selectTaskInstancePage(@Param("page") IPage page,
+ @Param("param") ApprovalTaskListParam param,
+ @Param("userId") Integer userId,
+ @Param("taskStatus") String taskStatus);
+
+ /**
+ * 本人待办实例数量(同一实例多任务只算一条)。
+ */
+ Integer countPendingInstances(@Param("userId") Integer userId);
}
diff --git a/src/main/java/com/gxwebsoft/approval/mapper/xml/ApprovalTaskMapper.xml b/src/main/java/com/gxwebsoft/approval/mapper/xml/ApprovalTaskMapper.xml
new file mode 100644
index 0000000..cedbc0b
--- /dev/null
+++ b/src/main/java/com/gxwebsoft/approval/mapper/xml/ApprovalTaskMapper.xml
@@ -0,0 +1,50 @@
+
+
+
+
+
+
+
+
+
diff --git a/src/main/java/com/gxwebsoft/approval/param/ApprovalTaskActionParam.java b/src/main/java/com/gxwebsoft/approval/param/ApprovalTaskActionParam.java
new file mode 100644
index 0000000..5f64d3e
--- /dev/null
+++ b/src/main/java/com/gxwebsoft/approval/param/ApprovalTaskActionParam.java
@@ -0,0 +1,17 @@
+package com.gxwebsoft.approval.param;
+
+import io.swagger.annotations.ApiModelProperty;
+import lombok.Data;
+
+import java.io.Serializable;
+
+/**
+ * 任务动作参数(通过/驳回)。
+ */
+@Data
+public class ApprovalTaskActionParam implements Serializable {
+ private static final long serialVersionUID = 1L;
+
+ @ApiModelProperty("处理意见;驳回必填")
+ private String opinion;
+}
diff --git a/src/main/java/com/gxwebsoft/approval/param/ApprovalTaskListParam.java b/src/main/java/com/gxwebsoft/approval/param/ApprovalTaskListParam.java
new file mode 100644
index 0000000..8bb757b
--- /dev/null
+++ b/src/main/java/com/gxwebsoft/approval/param/ApprovalTaskListParam.java
@@ -0,0 +1,20 @@
+package com.gxwebsoft.approval.param;
+
+import com.gxwebsoft.common.core.web.BaseParam;
+import io.swagger.annotations.ApiModelProperty;
+import lombok.Data;
+import lombok.EqualsAndHashCode;
+
+/**
+ * 待办/已办列表查询参数。
+ */
+@Data
+@EqualsAndHashCode(callSuper = true)
+public class ApprovalTaskListParam extends BaseParam {
+
+ @ApiModelProperty("实例状态")
+ private String status;
+
+ @ApiModelProperty("单据类型")
+ private String docType;
+}
diff --git a/src/main/java/com/gxwebsoft/approval/service/ApprovalDirectoryService.java b/src/main/java/com/gxwebsoft/approval/service/ApprovalDirectoryService.java
index c032296..132d6f9 100644
--- a/src/main/java/com/gxwebsoft/approval/service/ApprovalDirectoryService.java
+++ b/src/main/java/com/gxwebsoft/approval/service/ApprovalDirectoryService.java
@@ -158,6 +158,24 @@ public class ApprovalDirectoryService {
return jdbcTemplate.query(sql, USER_MAPPER, args.toArray());
}
+ /** 按id批量取启用用户;停用或已删除的用户不返回(节点缺配时不生成任务)。 */
+ public List listActiveUsersByIds(List userIds) {
+ if (userIds == null || userIds.isEmpty()) {
+ return Collections.emptyList();
+ }
+ Integer tenantId = ApprovalTenantContext.currentTenantId();
+ String placeholders = String.join(",", Collections.nCopies(userIds.size(), "?"));
+ String sql = "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 u.user_id IN (" + placeholders + ")";
+ List args = new java.util.ArrayList<>();
+ args.add(tenantId);
+ args.add(tenantId);
+ args.addAll(userIds);
+ return jdbcTemplate.query(sql, USER_MAPPER, args.toArray());
+ }
+
/** 是否持有任一角色(用于 allVisible 判断)。 */
public boolean hasAnyRole(Integer userId, String... roleCodes) {
if (userId == null || roleCodes == null || roleCodes.length == 0) {
diff --git a/src/main/java/com/gxwebsoft/approval/service/ApprovalEngineService.java b/src/main/java/com/gxwebsoft/approval/service/ApprovalEngineService.java
index 4711d23..7d9b789 100644
--- a/src/main/java/com/gxwebsoft/approval/service/ApprovalEngineService.java
+++ b/src/main/java/com/gxwebsoft/approval/service/ApprovalEngineService.java
@@ -63,6 +63,22 @@ public class ApprovalEngineService {
return createTasks(instance, node, instance.getRound(), approvers);
}
+ /**
+ * 当前节点处理完成后推进到下一节点:更新当前节点并快照生成任务。
+ *
+ * 与 {@link #activateNode} 的区别:处理记录已由调用方按任务补充 taskId,
+ * 因此这里只更新节点与任务,不重复写处理记录。
+ *
+ * @return 生成的任务数;0 表示节点缺配
+ */
+ public int moveToNode(ApprovalInstance instance, ApprovalNode node, Integer round,
+ List approvers) {
+ instance.setCurrentNodeKey(node.getKey());
+ instance.setCurrentNodeType(node.getType());
+ instanceMapper.updateById(instance);
+ return createTasks(instance, node, round, approvers);
+ }
+
/**
* 对实例行加锁,串行化节点推进。
*/
diff --git a/src/main/java/com/gxwebsoft/approval/service/ApprovalInstanceService.java b/src/main/java/com/gxwebsoft/approval/service/ApprovalInstanceService.java
index 7142dc9..d2b6af0 100644
--- a/src/main/java/com/gxwebsoft/approval/service/ApprovalInstanceService.java
+++ b/src/main/java/com/gxwebsoft/approval/service/ApprovalInstanceService.java
@@ -17,6 +17,7 @@ import com.gxwebsoft.approval.mapper.ApprovalRecordMapper;
import com.gxwebsoft.approval.mapper.ApprovalTaskMapper;
import com.gxwebsoft.approval.param.ApprovalCompletionParam;
import com.gxwebsoft.approval.param.ApprovalMineParam;
+import com.gxwebsoft.approval.param.ApprovalTaskListParam;
import com.gxwebsoft.approval.support.ApprovalApprover;
import com.gxwebsoft.approval.support.ApprovalItemRow;
import com.gxwebsoft.approval.support.ApprovalNode;
@@ -216,6 +217,32 @@ public class ApprovalInstanceService {
long size = param.getLimit() == null ? 10 : Math.max(1, param.getLimit());
Page page = new Page<>(current, size);
List rows = instanceMapper.selectMinePage(page, param, user.getUserId());
+ return new PageResult<>(toItems(rows), page.getTotal());
+ }
+
+ /**
+ * 我的待办:本人 PENDING 任务对应的实例,按实例去重。
+ */
+ public PageResult todo(ApprovalTaskListParam param, User user) {
+ return taskPage(param, user, ApprovalConstants.TASK_PENDING);
+ }
+
+ /**
+ * 我的已办:本人 DONE 任务对应的实例,按实例去重。
+ */
+ public PageResult done(ApprovalTaskListParam param, User user) {
+ return taskPage(param, user, ApprovalConstants.TASK_DONE);
+ }
+
+ private PageResult taskPage(ApprovalTaskListParam param, User user, String taskStatus) {
+ long current = param.getPage() == null ? 1 : Math.max(1, param.getPage());
+ long size = param.getLimit() == null ? 10 : Math.max(1, param.getLimit());
+ Page page = new Page<>(current, size);
+ List rows = taskMapper.selectTaskInstancePage(page, param, user.getUserId(), taskStatus);
+ return new PageResult<>(toItems(rows), page.getTotal());
+ }
+
+ private List toItems(List rows) {
Map> nodeCache = new HashMap<>();
List list = new ArrayList<>();
for (ApprovalItemRow row : rows) {
@@ -236,7 +263,7 @@ public class ApprovalInstanceService {
item.setCurrentNodeName(nodeName(nodeCache, row.getDefinitionId(), row.getCurrentNodeKey()));
list.add(item);
}
- return new PageResult<>(list, page.getTotal());
+ return list;
}
private Long createDraft(ApprovalCompletionParam param, User user, ApprovalFlowDefinition definition,
diff --git a/src/main/java/com/gxwebsoft/approval/service/ApprovalTaskService.java b/src/main/java/com/gxwebsoft/approval/service/ApprovalTaskService.java
new file mode 100644
index 0000000..0cf213d
--- /dev/null
+++ b/src/main/java/com/gxwebsoft/approval/service/ApprovalTaskService.java
@@ -0,0 +1,344 @@
+package com.gxwebsoft.approval.service;
+
+import cn.hutool.core.util.StrUtil;
+import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
+import com.baomidou.mybatisplus.core.conditions.update.UpdateWrapper;
+import com.gxwebsoft.approval.constant.ApprovalConstants;
+import com.gxwebsoft.approval.entity.ApprovalInstance;
+import com.gxwebsoft.approval.entity.ApprovalTask;
+import com.gxwebsoft.approval.mapper.ApprovalInstanceMapper;
+import com.gxwebsoft.approval.mapper.ApprovalTaskMapper;
+import com.gxwebsoft.approval.support.ApprovalApprover;
+import com.gxwebsoft.approval.support.ApprovalNode;
+import com.gxwebsoft.approval.vo.ApprovalUserVO;
+import com.gxwebsoft.common.core.exception.BusinessException;
+import com.gxwebsoft.common.system.entity.User;
+import org.springframework.stereotype.Service;
+import org.springframework.transaction.PlatformTransactionManager;
+import org.springframework.transaction.support.TransactionTemplate;
+
+import javax.annotation.Resource;
+import java.time.LocalDateTime;
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.List;
+import java.util.function.Supplier;
+import java.util.stream.Collectors;
+
+/**
+ * 审批任务动作:通过、驳回、撤回与节点推进。
+ *
+ * 跨库约束:审批人解析必须走 {@link ApprovalDirectoryService}(core 专用连接),
+ * 在事务外完成;写 master 的部分放进 TransactionTemplate。任务处理使用
+ * WHERE status='PENDING' 的条件更新,抢先后到者收到「任务已处理」。
+ */
+@Service
+public class ApprovalTaskService {
+
+ private static final String RESOLVER_REPORTER_ORG_LEADER = "reporterOrgLeader";
+ private static final String RESOLVER_ACCEPT_ORG_LEADER = "acceptOrgLeader";
+ private static final String RESOLVER_ARCHIVIST = "archivist";
+ private static final String RESOLVER_REPORTER = "reporter";
+ private static final String RESOLVER_REPORTER_AND_DISPATCH_TARGET = "reporterAndDispatchTarget";
+ private static final String RESOLVER_ACCEPT_ORG_MEMBERS = "acceptOrgMembers";
+ private static final int MAX_OPINION_LENGTH = 500;
+
+ @Resource
+ private ApprovalInstanceMapper instanceMapper;
+ @Resource
+ private ApprovalTaskMapper taskMapper;
+ @Resource
+ private ApprovalEngineService engineService;
+ @Resource
+ private ApprovalDirectoryService directoryService;
+ @Resource
+ private PlatformTransactionManager transactionManager;
+
+ /**
+ * 通过:APPROVE 节点或签,处理后取消同节点同轮其余待办并推进下一节点。
+ */
+ public Result approve(Long taskId, String opinion, User user) {
+ ApprovalTask task = requireTask(taskId, user);
+ if (!ApprovalConstants.NODE_APPROVE.equals(task.getNodeType())) {
+ throw new BusinessException("当前任务不支持通过");
+ }
+ String normalized = normalizeOpinion(opinion);
+ ApprovalInstance instance = instanceMapper.selectById(task.getInstanceId());
+ if (instance == null) {
+ throw new BusinessException("单据不存在");
+ }
+ ApprovalNode next = engineService.nextNode(instance, task.getNodeKey());
+ List approvers =
+ next == null ? Collections.emptyList() : resolveApprovers(instance, next);
+
+ String warning = inTransaction(() -> doApprove(taskId, task, normalized, user, next, approvers));
+ return new Result(instance.getId(), warning);
+ }
+
+ /**
+ * 驳回:意见必填,同轮待办全部取消,实例回到报送人。
+ */
+ public Result reject(Long taskId, String opinion, User user) {
+ ApprovalTask task = requireTask(taskId, user);
+ boolean rejectable = ApprovalConstants.NODE_APPROVE.equals(task.getNodeType())
+ || ApprovalConstants.NODE_DISPATCH.equals(task.getNodeType());
+ if (!rejectable) {
+ throw new BusinessException("当前任务不支持驳回");
+ }
+ String required = requireOpinion(opinion);
+ inTransaction(() -> doReject(taskId, task, required, user));
+ return new Result(task.getInstanceId(), null);
+ }
+
+ /**
+ * 撤回:仅报送人,且本轮没有任何任务被处理。
+ */
+ public Result withdraw(Long instanceId, User user) {
+ ApprovalInstance instance = instanceMapper.selectById(instanceId);
+ if (instance == null) {
+ throw new BusinessException("单据不存在");
+ }
+ if (user == null || !instance.getReporterId().equals(user.getUserId())) {
+ throw new BusinessException("无权操作该单据");
+ }
+ if (!ApprovalConstants.STATUS_PENDING.equals(instance.getStatus())) {
+ throw new BusinessException("当前状态不可撤回");
+ }
+ ensureNoHandledTask(instance);
+ inTransaction(() -> doWithdraw(instanceId, user));
+ return new Result(instanceId, null);
+ }
+
+ public Integer todoCount(User user) {
+ return taskMapper.countPendingInstances(user.getUserId());
+ }
+
+ private String doApprove(Long taskId, ApprovalTask task, String opinion, User user,
+ ApprovalNode next, List approvers) {
+ // 先锁实例串行化同单据的处理,再做任务条件更新,避免并发或签互相等待行锁
+ ApprovalInstance locked = engineService.lockInstance(task.getInstanceId());
+ ensureTaskHandleable(task, locked);
+ if (updatePendingTask(taskId, ApprovalConstants.TASK_DONE, opinion) == 0) {
+ throw new BusinessException("任务已处理");
+ }
+ engineService.cancelPendingTasks(locked.getId(), locked.getRound(), taskId);
+ engineService.addRecord(locked, taskId, task.getNodeKey(), user,
+ ApprovalConstants.ACTION_APPROVE, opinion);
+ if (next == null) {
+ return null;
+ }
+ int created = engineService.moveToNode(locked, next, locked.getRound(), approvers);
+ return created == 0 ? "当前节点缺少审批人,请联系管理员补配置" : null;
+ }
+
+ private void doReject(Long taskId, ApprovalTask task, String opinion, User user) {
+ ApprovalInstance locked = engineService.lockInstance(task.getInstanceId());
+ ensureTaskHandleable(task, locked);
+ if (updatePendingTask(taskId, ApprovalConstants.TASK_DONE, opinion) == 0) {
+ throw new BusinessException("任务已处理");
+ }
+ Integer round = locked.getRound();
+ engineService.addRecord(locked, taskId, task.getNodeKey(), user,
+ ApprovalConstants.ACTION_REJECT, opinion);
+ engineService.cancelPendingTasks(locked.getId(), round, null);
+ int rows = instanceMapper.update(null, new UpdateWrapper()
+ .eq("id", locked.getId())
+ .eq("status", ApprovalConstants.STATUS_PENDING)
+ .set("status", ApprovalConstants.STATUS_REJECTED)
+ .set("round", round + 1));
+ if (rows == 0) {
+ throw new BusinessException("单据状态已变化,请刷新后重试");
+ }
+ }
+
+ private void doWithdraw(Long instanceId, User user) {
+ ApprovalInstance locked = engineService.lockInstance(instanceId);
+ if (!ApprovalConstants.STATUS_PENDING.equals(locked.getStatus())) {
+ throw new BusinessException("当前状态不可撤回");
+ }
+ ensureNoHandledTask(locked);
+ Integer round = locked.getRound();
+ engineService.addRecord(locked, null, locked.getCurrentNodeKey(), user,
+ ApprovalConstants.ACTION_WITHDRAW, null);
+ engineService.cancelPendingTasks(locked.getId(), round, null);
+ int rows = instanceMapper.update(null, new UpdateWrapper()
+ .eq("id", instanceId)
+ .eq("status", ApprovalConstants.STATUS_PENDING)
+ .set("status", ApprovalConstants.STATUS_WITHDRAWN)
+ .set("round", round + 1));
+ if (rows == 0) {
+ throw new BusinessException("单据状态已变化,请刷新后重试");
+ }
+ }
+
+ /**
+ * 任务必须属于实例当前轮次、当前节点,否则视为已被处理或已失效。
+ */
+ private void ensureTaskHandleable(ApprovalTask task, ApprovalInstance instance) {
+ if (!ApprovalConstants.STATUS_PENDING.equals(instance.getStatus())) {
+ throw new BusinessException("单据状态已变化,请刷新后重试");
+ }
+ if (!task.getRound().equals(instance.getRound())
+ || !task.getNodeKey().equals(instance.getCurrentNodeKey())) {
+ throw new BusinessException("任务已处理");
+ }
+ }
+
+ private void ensureNoHandledTask(ApprovalInstance instance) {
+ Integer handled = taskMapper.selectCount(new LambdaQueryWrapper()
+ .eq(ApprovalTask::getInstanceId, instance.getId())
+ .eq(ApprovalTask::getRound, instance.getRound())
+ .eq(ApprovalTask::getStatus, ApprovalConstants.TASK_DONE));
+ if (handled != null && handled > 0) {
+ throw new BusinessException("已有审批人处理,无法撤回");
+ }
+ }
+
+ private int updatePendingTask(Long taskId, String status, String opinion) {
+ UpdateWrapper wrapper = new UpdateWrapper()
+ .eq("id", taskId)
+ .eq("status", ApprovalConstants.TASK_PENDING)
+ .set("status", status)
+ .set("handle_time", LocalDateTime.now());
+ if (opinion != null) {
+ wrapper.set("opinion", opinion);
+ }
+ return taskMapper.update(null, wrapper);
+ }
+
+ private ApprovalTask requireTask(Long taskId, User user) {
+ if (taskId == null) {
+ throw new BusinessException("任务不存在");
+ }
+ ApprovalTask task = taskMapper.selectById(taskId);
+ if (task == null) {
+ throw new BusinessException("任务不存在");
+ }
+ if (user == null || !user.getUserId().equals(task.getAssigneeId())) {
+ throw new BusinessException("无权处理该任务");
+ }
+ return task;
+ }
+
+ /**
+ * 事务外按节点解析器解析审批人;缺配时返回空列表,由调用方提示并停留当前节点。
+ */
+ private List resolveApprovers(ApprovalInstance instance, ApprovalNode node) {
+ String resolver = node.getResolver();
+ if (StrUtil.isBlank(resolver)) {
+ return Collections.emptyList();
+ }
+ switch (resolver) {
+ case RESOLVER_REPORTER_ORG_LEADER:
+ return roleApprovers(ApprovalConstants.departmentLeaderRoleCode(instance.getReportOrgId()));
+ case RESOLVER_ACCEPT_ORG_LEADER:
+ return roleApprovers(ApprovalConstants.departmentLeaderRoleCode(instance.getAcceptOrgId()));
+ case RESOLVER_ARCHIVIST:
+ return roleApprovers(ApprovalConstants.ROLE_ARCHIVIST);
+ case RESOLVER_REPORTER:
+ return userApprovers(Collections.singletonList(instance.getReporterId()));
+ case RESOLVER_REPORTER_AND_DISPATCH_TARGET:
+ return userApprovers(confirmCandidateIds(instance));
+ case RESOLVER_ACCEPT_ORG_MEMBERS:
+ return organizationApprovers(instance.getAcceptOrgId());
+ default:
+ return Collections.emptyList();
+ }
+ }
+
+ private List confirmCandidateIds(ApprovalInstance instance) {
+ List ids = new ArrayList<>();
+ if (instance.getReporterId() != null) {
+ ids.add(instance.getReporterId());
+ }
+ if (instance.getDispatchTargetId() != null && !ids.contains(instance.getDispatchTargetId())) {
+ ids.add(instance.getDispatchTargetId());
+ }
+ return ids;
+ }
+
+ private List roleApprovers(String roleCode) {
+ if (StrUtil.isBlank(roleCode)) {
+ return Collections.emptyList();
+ }
+ return toApprovers(directoryService.listRoleMembers(roleCode));
+ }
+
+ private List userApprovers(List userIds) {
+ return toApprovers(directoryService.listActiveUsersByIds(userIds));
+ }
+
+ private List organizationApprovers(Integer organizationId) {
+ return toApprovers(directoryService.listOrganizationMembers(organizationId));
+ }
+
+ private List toApprovers(List users) {
+ return users.stream()
+ .map(user -> new ApprovalApprover(user.getUserId(), displayName(user)))
+ .collect(Collectors.toList());
+ }
+
+ private String displayName(ApprovalUserVO user) {
+ if (StrUtil.isNotBlank(user.getRealName())) {
+ return user.getRealName();
+ }
+ if (StrUtil.isNotBlank(user.getNickname())) {
+ return user.getNickname();
+ }
+ return user.getUsername();
+ }
+
+ private String normalizeOpinion(String opinion) {
+ if (opinion == null) {
+ return null;
+ }
+ String trimmed = opinion.trim();
+ if (trimmed.isEmpty()) {
+ return null;
+ }
+ if (trimmed.length() > MAX_OPINION_LENGTH) {
+ throw new BusinessException("意见不能超过500字");
+ }
+ return trimmed;
+ }
+
+ private String requireOpinion(String opinion) {
+ String normalized = normalizeOpinion(opinion);
+ if (normalized == null) {
+ throw new BusinessException("请填写驳回意见");
+ }
+ return normalized;
+ }
+
+ private T inTransaction(Supplier action) {
+ return new TransactionTemplate(transactionManager).execute(status -> action.get());
+ }
+
+ private void inTransaction(Runnable action) {
+ new TransactionTemplate(transactionManager).execute(status -> {
+ action.run();
+ return null;
+ });
+ }
+
+ /**
+ * 动作结果:实例id + 需要透出的节点缺配提示。
+ */
+ public static class Result {
+ private final Long instanceId;
+ private final String warning;
+
+ public Result(Long instanceId, String warning) {
+ this.instanceId = instanceId;
+ this.warning = warning;
+ }
+
+ public Long getInstanceId() {
+ return instanceId;
+ }
+
+ public String getWarning() {
+ return warning;
+ }
+ }
+}
diff --git a/src/test/java/com/gxwebsoft/approval/ApprovalTaskIT.java b/src/test/java/com/gxwebsoft/approval/ApprovalTaskIT.java
new file mode 100644
index 0000000..4dfc907
--- /dev/null
+++ b/src/test/java/com/gxwebsoft/approval/ApprovalTaskIT.java
@@ -0,0 +1,406 @@
+package com.gxwebsoft.approval;
+
+import com.fasterxml.jackson.databind.ObjectMapper;
+import com.gxwebsoft.support.ApprovalTestData;
+import com.gxwebsoft.support.TestAuthentication;
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc;
+import org.springframework.boot.test.context.SpringBootTest;
+import org.springframework.http.MediaType;
+import org.springframework.mock.web.MockMultipartFile;
+import org.springframework.test.context.ActiveProfiles;
+import org.springframework.test.web.servlet.MockMvc;
+import org.springframework.test.web.servlet.request.MockHttpServletRequestBuilder;
+
+import javax.sql.DataSource;
+import java.nio.charset.StandardCharsets;
+import java.util.concurrent.CountDownLatch;
+import java.util.concurrent.ExecutorService;
+import java.util.concurrent.Executors;
+import java.util.concurrent.TimeUnit;
+import java.util.concurrent.atomic.AtomicInteger;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.authentication;
+import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
+import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.multipart;
+import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
+import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
+import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
+
+/**
+ * 审批中心:待办/已办、通过/驳回、撤回、或签与并发处理的集成测试。
+ *
+ * 本类不开启测试事务:动作接口各自独立提交,便于覆盖并发条件更新;
+ * 数据由 {@link ApprovalTestData#cleanup()} 清理。
+ */
+@SpringBootTest
+@AutoConfigureMockMvc
+@ActiveProfiles({"gxtyzx-test", "test"})
+class ApprovalTaskIT {
+
+ private static final int TENANT = TestAuthentication.TENANT_ID;
+ private static final String LIST = "approval:instance:list";
+ private static final String SUBMIT = "approval:instance:submit";
+ private static final String APPROVE = "approval:instance:approve";
+
+ @Autowired
+ private MockMvc mockMvc;
+ @Autowired
+ private ObjectMapper objectMapper;
+ @Autowired
+ private DataSource dataSource;
+
+ private ApprovalTestData data;
+ private ApprovalTestData.TestUser reporter;
+ private ApprovalTestData.TestUser leader;
+ private Integer organizationId;
+
+ @BeforeEach
+ void setUp() {
+ data = new ApprovalTestData(dataSource);
+ organizationId = data.createOrganization("任务部门" + data.uniqueSuffix(), 136);
+ reporter = data.createUser("任务报送人" + data.uniqueSuffix(), organizationId);
+ ApprovalTestData.TestRole leaderRole =
+ data.createRole("dept_leader_" + organizationId, "部门负责人");
+ leader = data.createUser("任务负责人" + data.uniqueSuffix(), organizationId);
+ data.bind(leader.userId, leaderRole.roleId);
+ ApprovalTestData.TestRole archivistRole = data.createRole("approval_archivist", "收文员");
+ ApprovalTestData.TestUser archivist = data.createUser("任务收文员" + data.uniqueSuffix(), organizationId);
+ data.bind(archivist.userId, archivistRole.roleId);
+ }
+
+ @AfterEach
+ void tearDown() {
+ data.cleanup();
+ }
+
+ @Test
+ void approvesTaskAndActivatesNextConfirmNode() throws Exception {
+ long instanceId = createAndSubmit("通过测试");
+ long taskId = pendingTaskId(instanceId, leader.userId);
+
+ mockMvc.perform(authed(post("/api/approval/task/" + taskId + "/approve"), leader, organizationId, APPROVE)
+ .contentType(MediaType.APPLICATION_JSON)
+ .content("{\"opinion\":\"同意办理\"}"))
+ .andExpect(status().isOk())
+ .andExpect(jsonPath("$.code").value(0))
+ .andExpect(jsonPath("$.data.id").value((int) instanceId))
+ .andExpect(jsonPath("$.data.status").value("PENDING"))
+ .andExpect(jsonPath("$.data.currentNodeName").value("存档"))
+ .andExpect(jsonPath("$.data.confirms.length()").value(1))
+ .andExpect(jsonPath("$.data.confirms[0].userId").value(reporter.userId))
+ .andExpect(jsonPath("$.data.confirms[0].status").value("PENDING"));
+
+ assertThat(data.queryMaster("SELECT status FROM approval_task WHERE id = ?", String.class, taskId))
+ .isEqualTo("DONE");
+ assertThat(data.queryMaster("SELECT opinion FROM approval_task WHERE id = ?", String.class, taskId))
+ .isEqualTo("同意办理");
+ assertThat(data.queryMaster("SELECT handle_time FROM approval_task WHERE id = ?", Object.class, taskId))
+ .isNotNull();
+ assertThat(data.queryMaster("SELECT current_node_key FROM approval_instance WHERE id = ?", String.class, instanceId))
+ .isEqualTo("confirm");
+ assertThat(data.countMaster("SELECT COUNT(*) FROM approval_task WHERE instance_id = ? "
+ + "AND node_type = 'CONFIRM' AND assignee_id = ? AND status = 'PENDING'", instanceId, reporter.userId))
+ .isEqualTo(1);
+ assertThat(data.countMaster("SELECT COUNT(*) FROM approval_record WHERE instance_id = ? "
+ + "AND action = 'APPROVE' AND operator_id = ? AND opinion = '同意办理'", instanceId, leader.userId))
+ .isEqualTo(1);
+
+ // 存档人能看到待确认任务与对应权限
+ mockMvc.perform(authed(get("/api/approval/instance/" + instanceId), reporter, organizationId, LIST))
+ .andExpect(jsonPath("$.data.myTask.nodeType").value("CONFIRM"))
+ .andExpect(jsonPath("$.data.permissions.canConfirm").value(true))
+ .andExpect(jsonPath("$.data.permissions.canApprove").value(false));
+ }
+
+ @Test
+ void requiresRejectOpinionAndResubmitsWithSameBizNo() throws Exception {
+ long instanceId = createAndSubmit("驳回重提");
+ long taskId = pendingTaskId(instanceId, leader.userId);
+ String bizNo = data.queryMaster("SELECT biz_no FROM approval_instance WHERE id = ?", String.class, instanceId);
+
+ mockMvc.perform(authed(post("/api/approval/task/" + taskId + "/reject"), leader, organizationId, APPROVE)
+ .contentType(MediaType.APPLICATION_JSON)
+ .content("{\"opinion\":\" \"}"))
+ .andExpect(status().isOk())
+ .andExpect(jsonPath("$.code").value(1))
+ .andExpect(jsonPath("$.message").value(org.hamcrest.Matchers.containsString("驳回意见")));
+ assertThat(data.queryMaster("SELECT status FROM approval_task WHERE id = ?", String.class, taskId))
+ .isEqualTo("PENDING");
+
+ mockMvc.perform(authed(post("/api/approval/task/" + taskId + "/reject"), leader, organizationId, APPROVE)
+ .contentType(MediaType.APPLICATION_JSON)
+ .content("{\"opinion\":\"材料不完整\"}"))
+ .andExpect(status().isOk())
+ .andExpect(jsonPath("$.code").value(0))
+ .andExpect(jsonPath("$.data.status").value("REJECTED"))
+ .andExpect(jsonPath("$.data.statusName").value("已驳回"))
+ .andExpect(jsonPath("$.data.round").value(2));
+
+ // 单据回到报送人:可修改、可重新提交
+ mockMvc.perform(authed(get("/api/approval/instance/" + instanceId), reporter, organizationId, LIST))
+ .andExpect(jsonPath("$.data.permissions.canEdit").value(true))
+ .andExpect(jsonPath("$.data.permissions.canSubmit").value(true));
+
+ assertThat(data.countMaster("SELECT COUNT(*) FROM approval_task WHERE instance_id = ? AND status = 'PENDING'",
+ instanceId)).isZero();
+ assertThat(data.countMaster("SELECT COUNT(*) FROM approval_record WHERE instance_id = ? "
+ + "AND action = 'REJECT' AND opinion = '材料不完整'", instanceId)).isEqualTo(1);
+
+ mockMvc.perform(authed(post("/api/approval/instance/" + instanceId + "/submit"), reporter, organizationId, SUBMIT))
+ .andExpect(jsonPath("$.code").value(0))
+ .andExpect(jsonPath("$.data.status").value("PENDING"))
+ .andExpect(jsonPath("$.data.round").value(3))
+ .andExpect(jsonPath("$.data.bizNo").value(bizNo))
+ .andExpect(jsonPath("$.data.currentNodeName").value("报送部门负责人"));
+ assertThat(pendingTaskId(instanceId, leader.userId)).isPositive();
+ assertThat(data.countMaster("SELECT COUNT(*) FROM approval_record WHERE instance_id = ? "
+ + "AND action = 'RESUBMIT' AND round = 3", instanceId)).isEqualTo(1);
+ }
+
+ @Test
+ void approvingOneCountersignTaskCancelsTheRest() throws Exception {
+ ApprovalTestData.TestUser second =
+ data.createUser("第二负责人" + data.uniqueSuffix(), organizationId);
+ Integer leaderRoleId = data.server().queryForObject("SELECT role_id FROM sys_role WHERE role_code = ? AND tenant_id = ?",
+ Integer.class, "dept_leader_" + organizationId, TENANT);
+ data.bind(second.userId, leaderRoleId);
+
+ long instanceId = createAndSubmit("或签取消");
+ long firstTask = pendingTaskId(instanceId, leader.userId);
+ long secondTask = pendingTaskId(instanceId, second.userId);
+
+ mockMvc.perform(authed(post("/api/approval/task/" + firstTask + "/approve"), leader, organizationId, APPROVE)
+ .contentType(MediaType.APPLICATION_JSON).content("{}"))
+ .andExpect(jsonPath("$.code").value(0));
+
+ assertThat(data.queryMaster("SELECT status FROM approval_task WHERE id = ?", String.class, secondTask))
+ .isEqualTo("CANCELLED");
+
+ mockMvc.perform(authed(get("/api/approval/todo/page"), second, organizationId, LIST))
+ .andExpect(jsonPath("$.code").value(0))
+ .andExpect(jsonPath("$.data.count").value(0));
+
+ mockMvc.perform(authed(post("/api/approval/task/" + secondTask + "/approve"), second, organizationId, APPROVE)
+ .contentType(MediaType.APPLICATION_JSON).content("{}"))
+ .andExpect(jsonPath("$.code").value(1))
+ .andExpect(jsonPath("$.message").value(org.hamcrest.Matchers.containsString("任务已处理")));
+
+ assertThat(data.countMaster("SELECT COUNT(*) FROM approval_record WHERE instance_id = ? AND action = 'APPROVE'",
+ instanceId)).isEqualTo(1);
+ }
+
+ @Test
+ void concurrentApprovalOnlyHandlesTaskOnce() throws Exception {
+ long instanceId = createAndSubmit("并发处理");
+ long taskId = pendingTaskId(instanceId, leader.userId);
+
+ CountDownLatch start = new CountDownLatch(1);
+ CountDownLatch done = new CountDownLatch(2);
+ ExecutorService executor = Executors.newFixedThreadPool(2);
+ AtomicInteger success = new AtomicInteger();
+ AtomicInteger conflict = new AtomicInteger();
+ try {
+ for (int i = 0; i < 2; i++) {
+ executor.submit(() -> {
+ try {
+ start.await();
+ int code = mockMvc.perform(authed(post("/api/approval/task/" + taskId + "/approve"),
+ leader, organizationId, APPROVE)
+ .contentType(MediaType.APPLICATION_JSON).content("{\"opinion\":\"并发\"}"))
+ .andReturn().getResponse().getContentAsString(StandardCharsets.UTF_8)
+ .contains("\"code\":0") ? 0 : 1;
+ if (code == 0) {
+ success.incrementAndGet();
+ } else {
+ conflict.incrementAndGet();
+ }
+ } catch (Exception e) {
+ conflict.incrementAndGet();
+ } finally {
+ done.countDown();
+ }
+ });
+ }
+ start.countDown();
+ assertThat(done.await(30, TimeUnit.SECONDS)).isTrue();
+ } finally {
+ executor.shutdownNow();
+ }
+
+ assertThat(success.get()).isEqualTo(1);
+ assertThat(conflict.get()).isEqualTo(1);
+ assertThat(data.countMaster("SELECT COUNT(*) FROM approval_record WHERE instance_id = ? AND action = 'APPROVE'",
+ instanceId)).isEqualTo(1);
+ assertThat(data.countMaster("SELECT COUNT(*) FROM approval_task WHERE instance_id = ? AND status = 'DONE'",
+ instanceId)).isEqualTo(1);
+ }
+
+ @Test
+ void allowsReporterToApproveOwnTask() throws Exception {
+ Integer leaderRoleId = data.server().queryForObject("SELECT role_id FROM sys_role WHERE role_code = ? AND tenant_id = ?",
+ Integer.class, "dept_leader_" + organizationId, TENANT);
+ data.bind(reporter.userId, leaderRoleId);
+
+ long instanceId = createAndSubmit("自审单据");
+ long taskId = pendingTaskId(instanceId, reporter.userId);
+
+ mockMvc.perform(authed(post("/api/approval/task/" + taskId + "/approve"),
+ reporter, organizationId, APPROVE))
+ .andExpect(jsonPath("$.code").value(0))
+ .andExpect(jsonPath("$.data.status").value("PENDING"))
+ .andExpect(jsonPath("$.data.currentNodeName").value("存档"));
+
+ assertThat(data.countMaster("SELECT COUNT(*) FROM approval_record WHERE instance_id = ? "
+ + "AND action = 'APPROVE' AND operator_id = ?", instanceId, reporter.userId)).isEqualTo(1);
+ }
+
+ @Test
+ void withdrawsOnlyBeforeAnyTaskHandled() throws Exception {
+ long withdrawn = createAndSubmit("撤回单据");
+ String bizNo = data.queryMaster("SELECT biz_no FROM approval_instance WHERE id = ?", String.class, withdrawn);
+
+ mockMvc.perform(authed(post("/api/approval/instance/" + withdrawn + "/withdraw"),
+ reporter, organizationId, SUBMIT))
+ .andExpect(status().isOk())
+ .andExpect(jsonPath("$.code").value(0))
+ .andExpect(jsonPath("$.data.status").value("WITHDRAWN"))
+ .andExpect(jsonPath("$.data.round").value(2))
+ .andExpect(jsonPath("$.data.bizNo").value(bizNo))
+ .andExpect(jsonPath("$.data.permissions.canSubmit").value(true));
+
+ assertThat(data.countMaster("SELECT COUNT(*) FROM approval_task WHERE instance_id = ? AND status = 'PENDING'",
+ withdrawn)).isZero();
+ assertThat(data.countMaster("SELECT COUNT(*) FROM approval_record WHERE instance_id = ? AND action = 'WITHDRAW'",
+ withdrawn)).isEqualTo(1);
+
+ // 已有处理记录的实例不能撤回
+ long handled = createAndSubmit("已审批撤回");
+ long taskId = pendingTaskId(handled, leader.userId);
+ mockMvc.perform(authed(post("/api/approval/task/" + taskId + "/approve"), leader, organizationId, APPROVE))
+ .andExpect(jsonPath("$.code").value(0));
+ mockMvc.perform(authed(post("/api/approval/instance/" + handled + "/withdraw"),
+ reporter, organizationId, SUBMIT))
+ .andExpect(jsonPath("$.code").value(1))
+ .andExpect(jsonPath("$.message").value(org.hamcrest.Matchers.containsString("已有审批人处理,无法撤回")));
+ assertThat(data.queryMaster("SELECT status FROM approval_instance WHERE id = ?", String.class, handled))
+ .isEqualTo("PENDING");
+
+ // 非报送人不能撤回
+ long other = createAndSubmit("他人撤回");
+ mockMvc.perform(authed(post("/api/approval/instance/" + other + "/withdraw"),
+ leader, organizationId, SUBMIT))
+ .andExpect(jsonPath("$.code").value(1))
+ .andExpect(jsonPath("$.message").value(org.hamcrest.Matchers.containsString("无权操作该单据")));
+ }
+
+ @Test
+ void listsTodoDoneAndTodoCountWithFilters() throws Exception {
+ long first = createAndSubmit("场馆维修A");
+ long second = createAndSubmit("场馆维修B");
+
+ mockMvc.perform(authed(get("/api/approval/todo/count"), leader, organizationId, LIST))
+ .andExpect(jsonPath("$.code").value(0))
+ .andExpect(jsonPath("$.data").value(2));
+
+ mockMvc.perform(authed(get("/api/approval/todo/page"), leader, organizationId, LIST))
+ .andExpect(jsonPath("$.code").value(0))
+ .andExpect(jsonPath("$.data.count").value(2))
+ .andExpect(jsonPath("$.data.list[0].id").value((int) second))
+ .andExpect(jsonPath("$.data.list[0].bizNo").isNotEmpty())
+ .andExpect(jsonPath("$.data.list[0].status").value("PENDING"))
+ .andExpect(jsonPath("$.data.list[0].currentNodeName").value("报送部门负责人"))
+ .andExpect(jsonPath("$.data.list[0].submitTime").isNotEmpty());
+
+ mockMvc.perform(authed(get("/api/approval/todo/page").param("keywords", "B"), leader, organizationId, LIST))
+ .andExpect(jsonPath("$.data.count").value(1))
+ .andExpect(jsonPath("$.data.list[0].id").value((int) second));
+ mockMvc.perform(authed(get("/api/approval/todo/page").param("status", "REJECTED"), leader, organizationId, LIST))
+ .andExpect(jsonPath("$.data.count").value(0));
+
+ mockMvc.perform(authed(get("/api/approval/mine/page").param("keywords", "A"), reporter, organizationId, LIST))
+ .andExpect(jsonPath("$.data.count").value(1))
+ .andExpect(jsonPath("$.data.list[0].id").value((int) first));
+
+ long taskId = pendingTaskId(first, leader.userId);
+ mockMvc.perform(authed(post("/api/approval/task/" + taskId + "/approve"), leader, organizationId, APPROVE))
+ .andExpect(jsonPath("$.code").value(0));
+
+ mockMvc.perform(authed(get("/api/approval/todo/count"), leader, organizationId, LIST))
+ .andExpect(jsonPath("$.data").value(1));
+ mockMvc.perform(authed(get("/api/approval/done/page"), leader, organizationId, LIST))
+ .andExpect(jsonPath("$.code").value(0))
+ .andExpect(jsonPath("$.data.count").value(1))
+ .andExpect(jsonPath("$.data.list[0].id").value((int) first))
+ .andExpect(jsonPath("$.data.list[0].status").value("PENDING"))
+ .andExpect(jsonPath("$.data.list[0].currentNodeName").value("存档"));
+ mockMvc.perform(authed(get("/api/approval/done/page").param("keywords", "B"), leader, organizationId, LIST))
+ .andExpect(jsonPath("$.data.count").value(0));
+
+ ApprovalTestData.TestUser outsider = data.createUser("任务路人" + data.uniqueSuffix(), organizationId);
+ mockMvc.perform(authed(get("/api/approval/todo/page"), outsider, organizationId, LIST))
+ .andExpect(jsonPath("$.data.count").value(0));
+ mockMvc.perform(authed(get("/api/approval/done/page"), outsider, organizationId, LIST))
+ .andExpect(jsonPath("$.data.count").value(0));
+ }
+
+ // ---------- helpers ----------
+
+ private long createAndSubmit(String name) throws Exception {
+ String body = "{\"name\":" + quote(name)
+ + ",\"implementOrg\":\"广西体育中心实施部\",\"fee\":123.45,"
+ + "\"serviceContent\":\"完成场馆设施维修与验收\"}";
+ String response = mockMvc.perform(authed(post("/api/approval/instance/completion"),
+ reporter, organizationId, SUBMIT)
+ .contentType(MediaType.APPLICATION_JSON)
+ .content(body))
+ .andExpect(status().isOk())
+ .andExpect(jsonPath("$.code").value(0))
+ .andReturn().getResponse().getContentAsString(StandardCharsets.UTF_8);
+ long instanceId = objectMapper.readTree(response).path("data").asLong();
+ data.trackInstance(instanceId);
+
+ uploadAttachment(instanceId);
+ mockMvc.perform(authed(post("/api/approval/instance/" + instanceId + "/submit"),
+ reporter, organizationId, SUBMIT))
+ .andExpect(status().isOk())
+ .andExpect(jsonPath("$.code").value(0));
+ return instanceId;
+ }
+
+ private void uploadAttachment(long instanceId) throws Exception {
+ MockMultipartFile file = new MockMultipartFile("file", "sign.png", "image/png",
+ new byte[]{(byte) 0x89, 'P', 'N', 'G', 0x0D, 0x0A, 0x1A, 0x0A});
+ mockMvc.perform(multipart("/api/approval/attachment/upload")
+ .file(file)
+ .param("instanceId", String.valueOf(instanceId))
+ .param("category", "SIGNED")
+ .header("tenantId", TENANT)
+ .with(authentication(TestAuthentication.user(reporter.userId, reporter.username,
+ organizationId, TENANT, SUBMIT))))
+ .andExpect(status().isOk())
+ .andExpect(jsonPath("$.code").value(0));
+ }
+
+ private long pendingTaskId(long instanceId, int userId) {
+ Long taskId = data.queryMaster("SELECT id FROM approval_task WHERE instance_id = ? "
+ + "AND assignee_id = ? AND status = 'PENDING' LIMIT 1", Long.class, instanceId, userId);
+ assertThat(taskId).as("待办任务应存在").isNotNull();
+ return taskId;
+ }
+
+ private MockHttpServletRequestBuilder authed(MockHttpServletRequestBuilder builder,
+ ApprovalTestData.TestUser user, Integer org,
+ String... authorities) {
+ return builder.header("tenantId", TENANT)
+ .with(authentication(TestAuthentication.user(user.userId, user.username, org, TENANT, authorities)));
+ }
+
+ private String quote(String value) {
+ return "\"" + value.replace("\\", "\\\\").replace("\"", "\\\"") + "\"";
+ }
+}
From da5b5cfde7223cd95a6ba2725e148c3bab5ca3c2 Mon Sep 17 00:00:00 2001
From: weicw <594098497@qq.com>
Date: Fri, 30 Oct 2026 20:50:53 +0800
Subject: [PATCH 06/18] =?UTF-8?q?feat(approval):=20PC=20=E5=8D=95=E6=8D=AE?=
=?UTF-8?q?=E6=9F=A5=E8=AF=A2=E5=88=86=E9=A1=B5=E4=B8=8E=E6=95=B0=E6=8D=AE?=
=?UTF-8?q?=E6=9D=83=E9=99=90=20(#25)?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
.../ApprovalDirectoryController.java | 2 +-
.../controller/ApprovalQueryController.java | 35 ++
.../approval/mapper/ApprovalQueryMapper.java | 22 ++
.../mapper/xml/ApprovalQueryMapper.xml | 71 ++++
.../approval/param/ApprovalQueryParam.java | 50 +++
.../service/ApprovalQueryService.java | 87 +++++
.../gxwebsoft/approval/ApprovalQueryIT.java | 343 ++++++++++++++++++
7 files changed, 609 insertions(+), 1 deletion(-)
create mode 100644 src/main/java/com/gxwebsoft/approval/controller/ApprovalQueryController.java
create mode 100644 src/main/java/com/gxwebsoft/approval/mapper/ApprovalQueryMapper.java
create mode 100644 src/main/java/com/gxwebsoft/approval/mapper/xml/ApprovalQueryMapper.xml
create mode 100644 src/main/java/com/gxwebsoft/approval/param/ApprovalQueryParam.java
create mode 100644 src/main/java/com/gxwebsoft/approval/service/ApprovalQueryService.java
create mode 100644 src/test/java/com/gxwebsoft/approval/ApprovalQueryIT.java
diff --git a/src/main/java/com/gxwebsoft/approval/controller/ApprovalDirectoryController.java b/src/main/java/com/gxwebsoft/approval/controller/ApprovalDirectoryController.java
index 192eb34..8819b12 100644
--- a/src/main/java/com/gxwebsoft/approval/controller/ApprovalDirectoryController.java
+++ b/src/main/java/com/gxwebsoft/approval/controller/ApprovalDirectoryController.java
@@ -34,7 +34,7 @@ public class ApprovalDirectoryController extends BaseController {
return success(directoryService.listOrganizations());
}
- @PreAuthorize("hasAuthority('approval:instance:assign')")
+ @PreAuthorize("hasAnyAuthority('approval:instance:list', 'approval:instance:assign')")
@ApiOperation("启用用户搜索")
@GetMapping("/users")
public ApiResult> users(
diff --git a/src/main/java/com/gxwebsoft/approval/controller/ApprovalQueryController.java b/src/main/java/com/gxwebsoft/approval/controller/ApprovalQueryController.java
new file mode 100644
index 0000000..80b26b6
--- /dev/null
+++ b/src/main/java/com/gxwebsoft/approval/controller/ApprovalQueryController.java
@@ -0,0 +1,35 @@
+package com.gxwebsoft.approval.controller;
+
+import com.gxwebsoft.approval.param.ApprovalQueryParam;
+import com.gxwebsoft.approval.service.ApprovalQueryService;
+import com.gxwebsoft.approval.vo.ApprovalItemVO;
+import com.gxwebsoft.common.core.web.ApiResult;
+import com.gxwebsoft.common.core.web.BaseController;
+import com.gxwebsoft.common.core.web.PageResult;
+import io.swagger.annotations.Api;
+import io.swagger.annotations.ApiOperation;
+import org.springframework.security.access.prepost.PreAuthorize;
+import org.springframework.web.bind.annotation.GetMapping;
+import org.springframework.web.bind.annotation.RequestMapping;
+import org.springframework.web.bind.annotation.RestController;
+
+import javax.annotation.Resource;
+
+/**
+ * PC 单据查询接口。
+ */
+@Api(tags = "审批单据查询")
+@RestController
+@RequestMapping("/api/approval")
+public class ApprovalQueryController extends BaseController {
+
+ @Resource
+ private ApprovalQueryService queryService;
+
+ @PreAuthorize("hasAuthority('approval:instance:list')")
+ @ApiOperation("PC 单据查询分页")
+ @GetMapping("/instance/page")
+ public ApiResult> page(ApprovalQueryParam param) {
+ return success(queryService.page(param, getLoginUser()));
+ }
+}
diff --git a/src/main/java/com/gxwebsoft/approval/mapper/ApprovalQueryMapper.java b/src/main/java/com/gxwebsoft/approval/mapper/ApprovalQueryMapper.java
new file mode 100644
index 0000000..e3ae64c
--- /dev/null
+++ b/src/main/java/com/gxwebsoft/approval/mapper/ApprovalQueryMapper.java
@@ -0,0 +1,22 @@
+package com.gxwebsoft.approval.mapper;
+
+import com.baomidou.mybatisplus.core.metadata.IPage;
+import com.gxwebsoft.approval.param.ApprovalQueryParam;
+import com.gxwebsoft.approval.support.ApprovalItemRow;
+import org.apache.ibatis.annotations.Param;
+
+import java.util.List;
+
+/**
+ * PC 单据查询Mapper:两类单据联表单,数据权限条件与详情一致。
+ */
+public interface ApprovalQueryMapper {
+
+ /**
+ * 单据查询分页,默认提交时间倒序、同秒 id 倒序。
+ */
+ List selectInstancePage(@Param("page") IPage page,
+ @Param("param") ApprovalQueryParam param,
+ @Param("userId") Integer userId,
+ @Param("allVisible") Integer allVisible);
+}
diff --git a/src/main/java/com/gxwebsoft/approval/mapper/xml/ApprovalQueryMapper.xml b/src/main/java/com/gxwebsoft/approval/mapper/xml/ApprovalQueryMapper.xml
new file mode 100644
index 0000000..1b13427
--- /dev/null
+++ b/src/main/java/com/gxwebsoft/approval/mapper/xml/ApprovalQueryMapper.xml
@@ -0,0 +1,71 @@
+
+
+
+
+
+
+
+
diff --git a/src/main/java/com/gxwebsoft/approval/param/ApprovalQueryParam.java b/src/main/java/com/gxwebsoft/approval/param/ApprovalQueryParam.java
new file mode 100644
index 0000000..3e87857
--- /dev/null
+++ b/src/main/java/com/gxwebsoft/approval/param/ApprovalQueryParam.java
@@ -0,0 +1,50 @@
+package com.gxwebsoft.approval.param;
+
+import com.gxwebsoft.common.core.web.BaseParam;
+import io.swagger.annotations.ApiModel;
+import io.swagger.annotations.ApiModelProperty;
+import lombok.Data;
+import lombok.EqualsAndHashCode;
+
+/**
+ * PC 单据查询参数:两类单据统一检索,含归档单据。
+ */
+@Data
+@EqualsAndHashCode(callSuper = true)
+@ApiModel(description = "审批单据查询参数")
+public class ApprovalQueryParam extends BaseParam {
+ private static final long serialVersionUID = 1L;
+
+ @ApiModelProperty("单据类型 completion/coordination")
+ private String docType;
+
+ @ApiModelProperty("单号")
+ private String bizNo;
+
+ @ApiModelProperty("关键词:单号、名称或标题、实施单位")
+ private String keyword;
+
+ @ApiModelProperty("报送部门id")
+ private Integer reportOrgId;
+
+ @ApiModelProperty("受理部门id")
+ private Integer acceptOrgId;
+
+ @ApiModelProperty("发起人id")
+ private Integer reporterId;
+
+ @ApiModelProperty("单据状态")
+ private String status;
+
+ @ApiModelProperty("提交时间起,yyyy-MM-dd HH:mm:ss")
+ private String submitTimeStart;
+
+ @ApiModelProperty("提交时间止,yyyy-MM-dd HH:mm:ss")
+ private String submitTimeEnd;
+
+ @ApiModelProperty("归档时间起,yyyy-MM-dd HH:mm:ss")
+ private String archiveTimeStart;
+
+ @ApiModelProperty("归档时间止,yyyy-MM-dd HH:mm:ss")
+ private String archiveTimeEnd;
+}
diff --git a/src/main/java/com/gxwebsoft/approval/service/ApprovalQueryService.java b/src/main/java/com/gxwebsoft/approval/service/ApprovalQueryService.java
new file mode 100644
index 0000000..1ab4609
--- /dev/null
+++ b/src/main/java/com/gxwebsoft/approval/service/ApprovalQueryService.java
@@ -0,0 +1,87 @@
+package com.gxwebsoft.approval.service;
+
+import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
+import com.gxwebsoft.approval.constant.ApprovalConstants;
+import com.gxwebsoft.approval.mapper.ApprovalQueryMapper;
+import com.gxwebsoft.approval.param.ApprovalQueryParam;
+import com.gxwebsoft.approval.support.ApprovalItemRow;
+import com.gxwebsoft.approval.support.ApprovalNode;
+import com.gxwebsoft.approval.vo.ApprovalItemVO;
+import com.gxwebsoft.common.core.web.PageResult;
+import com.gxwebsoft.common.system.entity.User;
+import org.springframework.stereotype.Service;
+
+import javax.annotation.Resource;
+import java.util.ArrayList;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+
+/**
+ * PC 单据查询服务:两类单据统一分页检索,列表与详情共用同一套数据权限条件。
+ *
+ * 跨库约束:allVisible 角色判定走 core 专用连接,本方法不开启事务。
+ */
+@Service
+public class ApprovalQueryService {
+
+ private static final long DEFAULT_LIMIT = 10L;
+
+ @Resource
+ private ApprovalQueryMapper queryMapper;
+
+ @Resource
+ private ApprovalFlowService flowService;
+
+ @Resource
+ private ApprovalDirectoryService directoryService;
+
+ /**
+ * 单据查询分页,默认提交时间倒序、同秒按 id 倒序。
+ */
+ public PageResult page(ApprovalQueryParam param, User user) {
+ long current = param.getPage() == null ? 1 : Math.max(1, param.getPage());
+ long size = param.getLimit() == null ? DEFAULT_LIMIT : Math.max(1, param.getLimit());
+ int allVisible = directoryService.hasAnyRole(user.getUserId(),
+ ApprovalConstants.ROLE_SUPER_ADMIN, ApprovalConstants.ROLE_ARCHIVIST) ? 1 : 0;
+ Page page = new Page<>(current, size);
+ List rows = queryMapper.selectInstancePage(page, param, user.getUserId(), allVisible);
+ Map> nodeCache = new HashMap<>();
+ List list = new ArrayList<>(rows.size());
+ for (ApprovalItemRow row : rows) {
+ list.add(toItem(row, nodeCache));
+ }
+ return new PageResult<>(list, page.getTotal());
+ }
+
+ private ApprovalItemVO toItem(ApprovalItemRow row, Map> nodeCache) {
+ ApprovalItemVO item = new ApprovalItemVO();
+ item.setId(row.getId());
+ item.setBizNo(row.getBizNo());
+ item.setDocType(row.getDocType());
+ item.setDocTypeName(ApprovalConstants.docTypeName(row.getDocType()));
+ item.setStatus(row.getStatus());
+ item.setStatusName(ApprovalConstants.statusName(row.getStatus()));
+ item.setTitle(row.getTitle());
+ item.setSubmitTime(row.getSubmitTime());
+ item.setReporterId(row.getReporterId());
+ item.setReporterName(row.getReporterName());
+ item.setReportOrgName(row.getReportOrgName());
+ item.setAcceptOrgName(row.getAcceptOrgName());
+ item.setArchiveTime(row.getArchiveTime());
+ item.setCurrentNodeName(nodeName(nodeCache, row.getDefinitionId(), row.getCurrentNodeKey()));
+ return item;
+ }
+
+ private String nodeName(Map> cache, Long definitionId, String nodeKey) {
+ if (definitionId == null || nodeKey == null) {
+ return null;
+ }
+ List nodes = cache.computeIfAbsent(definitionId, flowService::parseNodes);
+ return nodes.stream()
+ .filter(node -> nodeKey.equals(node.getKey()))
+ .map(ApprovalNode::getName)
+ .findFirst()
+ .orElse(null);
+ }
+}
diff --git a/src/test/java/com/gxwebsoft/approval/ApprovalQueryIT.java b/src/test/java/com/gxwebsoft/approval/ApprovalQueryIT.java
new file mode 100644
index 0000000..37c38ef
--- /dev/null
+++ b/src/test/java/com/gxwebsoft/approval/ApprovalQueryIT.java
@@ -0,0 +1,343 @@
+package com.gxwebsoft.approval;
+
+import com.fasterxml.jackson.databind.JsonNode;
+import com.fasterxml.jackson.databind.ObjectMapper;
+import com.gxwebsoft.approval.constant.ApprovalConstants;
+import com.gxwebsoft.support.ApprovalTestData;
+import com.gxwebsoft.support.TestAuthentication;
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc;
+import org.springframework.boot.test.context.SpringBootTest;
+import org.springframework.http.MediaType;
+import org.springframework.mock.web.MockMultipartFile;
+import org.springframework.test.context.ActiveProfiles;
+import org.springframework.test.web.servlet.MockMvc;
+import org.springframework.test.web.servlet.request.MockHttpServletRequestBuilder;
+import org.springframework.transaction.annotation.Transactional;
+
+import javax.sql.DataSource;
+import java.nio.charset.StandardCharsets;
+import java.sql.Timestamp;
+import java.time.LocalDateTime;
+import java.time.format.DateTimeFormatter;
+import java.util.ArrayList;
+import java.util.List;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.authentication;
+import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
+import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.multipart;
+import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
+import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
+import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
+
+/**
+ * PC 单据查询:筛选、关键词、排序、归档检索与数据权限的集成测试。
+ */
+@SpringBootTest
+@AutoConfigureMockMvc
+@ActiveProfiles({"gxtyzx-test", "test"})
+@Transactional
+class ApprovalQueryIT {
+
+ private static final int TENANT = TestAuthentication.TENANT_ID;
+ private static final String LIST = "approval:instance:list";
+ private static final String SUBMIT = "approval:instance:submit";
+ private static final DateTimeFormatter TIME_FORMAT = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss");
+
+ @Autowired
+ private MockMvc mockMvc;
+ @Autowired
+ private ObjectMapper objectMapper;
+ @Autowired
+ private DataSource dataSource;
+
+ private ApprovalTestData data;
+ private Integer reportOrgId;
+ private Integer acceptOrgId;
+ private ApprovalTestData.TestUser reporter;
+
+ @BeforeEach
+ void setUp() {
+ data = new ApprovalTestData(dataSource);
+ String suffix = data.uniqueSuffix();
+ reportOrgId = data.createOrganization("报送部门" + suffix, 136);
+ acceptOrgId = data.createOrganization("受理部门" + suffix, 136);
+ reporter = data.createUser("报送人" + suffix, reportOrgId);
+ }
+
+ @AfterEach
+ void tearDown() {
+ data.cleanup();
+ }
+
+ @Test
+ void filtersByTypeKeywordOrganizationReporterAndStatus() throws Exception {
+ long submitted = submitCompletion("场馆维修实施");
+ long coordination = seedCoordination("场馆维修协调", "PENDING", LocalDateTime.now().minusHours(1), null);
+ long draft = createCompletionDraft("场馆维护草稿", "广西体育中心保障部");
+
+ // 单据类型 + 关键词命中实施单位
+ assertThat(queryIds("?docType=completion&keyword=实施部")).containsExactly(submitted);
+ // 关键词命中协调单标题
+ assertThat(queryIds("?docType=coordination&keyword=维修协调")).containsExactly(coordination);
+ // 关键词命中单号
+ String bizNo = data.queryMaster("SELECT biz_no FROM approval_instance WHERE id = ?", String.class, submitted);
+ assertThat(queryIds("?keyword=" + bizNo)).containsExactly(submitted);
+ // 报送部门:两类单据都按报送部门过滤,其它部门不命中
+ assertThat(queryIds("?reportOrgId=" + reportOrgId))
+ .containsExactlyInAnyOrder(submitted, coordination, draft);
+ assertThat(queryIds("?reportOrgId=" + acceptOrgId)).isEmpty();
+ // 受理部门
+ assertThat(queryIds("?acceptOrgId=" + acceptOrgId)).containsExactly(coordination);
+ // 发起人
+ assertThat(queryIds("?reporterId=" + reporter.userId)).containsExactlyInAnyOrder(submitted, coordination, draft);
+ // 状态
+ assertThat(queryIds("?status=PENDING")).containsExactlyInAnyOrder(submitted, coordination);
+ assertThat(queryIds("?status=DRAFT")).containsExactly(draft);
+ // 总数与分页总数一致
+ assertThat(queryCount("?keyword=实施部")).isEqualTo(1);
+ // 单号精确筛选
+ assertThat(queryIds("?bizNo=" + bizNo)).containsExactly(submitted);
+ // 提交时间范围
+ assertThat(queryIds("?submitTimeStart=" + format(LocalDateTime.now().minusHours(2))
+ + "&submitTimeEnd=" + format(LocalDateTime.now().plusMinutes(1))))
+ .containsExactlyInAnyOrder(submitted, coordination);
+ assertThat(queryIds("?submitTimeEnd=" + format(LocalDateTime.now().minusDays(1)))).isEmpty();
+ }
+
+ @Test
+ void ordersBySubmitTimeDescThenIdDescAndKeepsPaginationStable() throws Exception {
+ LocalDateTime sameSecond = LocalDateTime.now().withNano(0).minusMinutes(10);
+ long first = seedCoordination("同秒一", "PENDING", sameSecond, null);
+ long second = seedCoordination("同秒二", "PENDING", sameSecond, null);
+ long third = seedCoordination("同秒三", "PENDING", sameSecond, null);
+ long older = seedCoordination("更早", "PENDING", sameSecond.minusDays(1), null);
+ long draft = createCompletionDraft("无提交时间草稿");
+
+ List page1 = queryIds("?limit=2&page=1");
+ List page2 = queryIds("?limit=2&page=2");
+ List page3 = queryIds("?limit=2&page=3");
+
+ assertThat(page1).containsExactly(third, second);
+ assertThat(page2).containsExactly(first, older);
+ assertThat(page3).containsExactly(draft);
+ }
+
+ @Test
+ void includesArchivedInstancesAndFiltersByArchiveTime() throws Exception {
+ LocalDateTime archivedAt = LocalDateTime.now().withNano(0).minusDays(3);
+ long archived = seedCoordination("已归档协调单", "ARCHIVED", archivedAt.minusDays(1), archivedAt);
+ long pending = seedCoordination("在途协调单", "PENDING", archivedAt.minusDays(1), null);
+
+ assertThat(queryIds("?status=ARCHIVED")).containsExactly(archived);
+ assertThat(queryIds("?archiveTimeStart=" + format(archivedAt.minusHours(1))
+ + "&archiveTimeEnd=" + format(archivedAt.plusHours(1)))).containsExactly(archived);
+ assertThat(queryIds("?archiveTimeStart=" + format(archivedAt.plusDays(1)))).isEmpty();
+ assertThat(queryIds("")).containsExactlyInAnyOrder(archived, pending);
+ }
+
+ @Test
+ void limitsListByDataPermission() throws Exception {
+ long draft = createCompletionDraft("仅发起人可见草稿");
+ long pending = seedCoordination("待处理协调单", "PENDING", LocalDateTime.now().minusHours(1), null);
+
+ ApprovalTestData.TestUser outsider = data.createUser("无关人员" + data.uniqueSuffix(), acceptOrgId);
+ ApprovalTestData.TestUser assignee = data.createUser("任务处理人" + data.uniqueSuffix(), acceptOrgId);
+ ApprovalTestData.TestUser operator = data.createUser("记录操作人" + data.uniqueSuffix(), acceptOrgId);
+ ApprovalTestData.TestUser dispatched = data.createUser("被派单人" + data.uniqueSuffix(), acceptOrgId);
+ ApprovalTestData.TestUser archivist = data.createUser("收文员" + data.uniqueSuffix(), acceptOrgId);
+
+ data.master().update("INSERT INTO approval_task (tenant_id, instance_id, node_key, node_type, round, "
+ + "task_type, assignee_id, status) VALUES (?, ?, 'reportLeader', 'APPROVE', 1, 'APPROVE', ?, 'PENDING')",
+ TENANT, pending, assignee.userId);
+ data.master().update("INSERT INTO approval_record (tenant_id, instance_id, operator_id, action, round) "
+ + "VALUES (?, ?, ?, 'SUBMIT', 1)", TENANT, pending, operator.userId);
+ data.master().update("UPDATE approval_instance SET dispatch_target_id = ?, dispatch_target_name = ? WHERE id = ?",
+ dispatched.userId, dispatched.realName, pending);
+ ApprovalTestData.TestRole archivistRole = data.createRole(ApprovalConstants.ROLE_ARCHIVIST, "收文员");
+ data.bind(archivist.userId, archivistRole.roleId);
+
+ // 发起人可见自己的草稿与在途单据
+ assertThat(queryIdsAs(reporter, "")).containsExactlyInAnyOrder(draft, pending);
+ // 参与人:有任务、有处理记录、被派单
+ assertThat(queryIdsAs(assignee, "")).containsExactly(pending);
+ assertThat(queryIdsAs(operator, "")).containsExactly(pending);
+ assertThat(queryIdsAs(dispatched, "")).containsExactly(pending);
+ // 无关人员看不到他人单据
+ assertThat(queryIdsAs(outsider, "")).isEmpty();
+ assertThat(queryIdsAs(outsider, "?status=PENDING")).isEmpty();
+ // 收文员看全租户,但草稿仍只属于发起人
+ assertThat(queryIdsAs(archivist, "")).contains(pending).doesNotContain(draft);
+ assertThat(queryIdsAs(archivist, "?status=DRAFT")).isEmpty();
+ }
+
+ @Test
+ void rejectsDetailOfInstanceOutsideDataPermission() throws Exception {
+ long draft = createCompletionDraft("他人草稿");
+ ApprovalTestData.TestUser outsider = data.createUser("越权人员" + data.uniqueSuffix(), acceptOrgId);
+
+ mockMvc.perform(authed(get("/api/approval/instance/" + draft), outsider, LIST))
+ .andExpect(status().isOk())
+ .andExpect(jsonPath("$.code").value(1))
+ .andExpect(jsonPath("$.message").value(org.hamcrest.Matchers.containsString("无权查看该单据")));
+
+ mockMvc.perform(authed(get("/api/approval/instance/" + draft), reporter, LIST))
+ .andExpect(status().isOk())
+ .andExpect(jsonPath("$.code").value(0))
+ .andExpect(jsonPath("$.data.id").value((int) draft))
+ .andExpect(jsonPath("$.data.docTypeName").value("业务完成确认单"));
+ }
+
+ @Test
+ void allowsInstanceQueryUsersToSearchDirectoryUsers() throws Exception {
+ mockMvc.perform(authed(get("/api/approval/directory/users?keywords=" + reporter.username), reporter, LIST))
+ .andExpect(status().isOk())
+ .andExpect(jsonPath("$.code").value(0))
+ .andExpect(jsonPath("$.data[0].userId").value(reporter.userId));
+ }
+
+ // ---------- helpers ----------
+
+ private List queryIds(String query) throws Exception {
+ return queryIdsAs(reporter, query);
+ }
+
+ private List queryIdsAs(ApprovalTestData.TestUser user, String query) throws Exception {
+ String response = mockMvc.perform(authed(get("/api/approval/instance/page" + query), user, LIST))
+ .andExpect(status().isOk())
+ .andExpect(jsonPath("$.code").value(0))
+ .andReturn().getResponse().getContentAsString(StandardCharsets.UTF_8);
+ JsonNode node = objectMapper.readTree(response).path("data");
+ List ids = new ArrayList<>();
+ for (JsonNode item : node.path("list")) {
+ ids.add(item.path("id").asLong());
+ }
+ return ids;
+ }
+
+ private long queryCount(String query) throws Exception {
+ String response = mockMvc.perform(authed(get("/api/approval/instance/page" + query), reporter, LIST))
+ .andExpect(status().isOk())
+ .andExpect(jsonPath("$.code").value(0))
+ .andReturn().getResponse().getContentAsString(StandardCharsets.UTF_8);
+ return objectMapper.readTree(response).path("data").path("count").asLong();
+ }
+
+ private long createCompletionDraft(String name) throws Exception {
+ return createCompletionDraft(name, "广西体育中心实施部");
+ }
+
+ private long createCompletionDraft(String name, String implementOrg) throws Exception {
+ String body = "{\"name\":" + quote(name) + ",\"implementOrg\":" + quote(implementOrg) + ","
+ + "\"fee\":100.00,\"serviceContent\":\"完成场馆设施维修与验收\"}";
+ String response = mockMvc.perform(authed(post("/api/approval/instance/completion"), reporter, SUBMIT)
+ .contentType(MediaType.APPLICATION_JSON)
+ .content(body))
+ .andExpect(status().isOk())
+ .andExpect(jsonPath("$.code").value(0))
+ .andReturn().getResponse().getContentAsString(StandardCharsets.UTF_8);
+ long id = objectMapper.readTree(response).path("data").asLong();
+ data.trackInstance(id);
+ return id;
+ }
+
+ private long submitCompletion(String name) throws Exception {
+ grantDepartmentLeader();
+ grantArchivist();
+ long instanceId = createCompletionDraft(name);
+ MockMultipartFile file = new MockMultipartFile("file", "sign.png", "image/png",
+ new byte[]{(byte) 0x89, 'P', 'N', 'G', 0x0D, 0x0A, 0x1A, 0x0A});
+ mockMvc.perform(multipart("/api/approval/attachment/upload")
+ .file(file)
+ .param("instanceId", String.valueOf(instanceId))
+ .param("category", "SIGNED")
+ .header("tenantId", TENANT)
+ .with(authentication(TestAuthentication.user(reporter.userId, reporter.username, reportOrgId, TENANT, SUBMIT))))
+ .andExpect(status().isOk())
+ .andExpect(jsonPath("$.code").value(0));
+ mockMvc.perform(authed(post("/api/approval/instance/" + instanceId + "/submit"), reporter, SUBMIT))
+ .andExpect(status().isOk())
+ .andExpect(jsonPath("$.code").value(0));
+ assertThat(data.queryMaster("SELECT status FROM approval_instance WHERE id = ?", String.class, instanceId))
+ .isEqualTo("PENDING");
+ return instanceId;
+ }
+
+ /**
+ * 直接落库一条协调单实例:T23 的表单/提交流程尚未合入,查询侧只需完整数据行。
+ */
+ private long seedCoordination(String title, String status, LocalDateTime submitTime, LocalDateTime archiveTime) {
+ String bizNo = "XT" + System.nanoTime();
+ data.master().update("INSERT INTO approval_instance (tenant_id, biz_no, flow_code, definition_id, "
+ + "definition_version, doc_type, biz_id, status, round, reporter_id, reporter_name, report_org_id, "
+ + "report_org_name, accept_org_id, accept_org_name, current_node_key, current_node_type, submit_time, "
+ + "archive_time, archivist_id, archivist_name) "
+ + "VALUES (?, ?, 'coordination_internal', ?, 1, 'coordination', 0, ?, 1, ?, ?, ?, ?, ?, ?, "
+ + "'reportLeader', 'APPROVE', ?, ?, ?, ?)",
+ TENANT, bizNo, coordinationDefinitionId(), status, reporter.userId, reporter.realName,
+ reportOrgId, orgName(reportOrgId), acceptOrgId, orgName(acceptOrgId),
+ Timestamp.valueOf(submitTime),
+ archiveTime == null ? null : Timestamp.valueOf(archiveTime),
+ archiveTime == null ? null : reporter.userId,
+ archiveTime == null ? null : reporter.realName);
+ Long instanceId = data.queryMaster("SELECT id FROM approval_instance WHERE tenant_id = ? AND biz_no = ?",
+ Long.class, TENANT, bizNo);
+ data.trackInstance(instanceId);
+ data.master().update("INSERT INTO approval_coordination_form (tenant_id, instance_id, accept_org_id, "
+ + "accept_org_name, title, content) VALUES (?, ?, ?, ?, ?, '协调内容')",
+ TENANT, instanceId, acceptOrgId, orgName(acceptOrgId), title);
+ Long formId = data.queryMaster("SELECT id FROM approval_coordination_form WHERE instance_id = ?",
+ Long.class, instanceId);
+ data.master().update("UPDATE approval_instance SET biz_id = ? WHERE id = ?", formId, instanceId);
+ return instanceId;
+ }
+
+ private long coordinationDefinitionId() {
+ List ids = data.master().queryForList("SELECT id FROM approval_flow_definition WHERE tenant_id = ? "
+ + "AND flow_code = 'coordination_internal' AND version = 1 LIMIT 1", Long.class, TENANT);
+ if (ids.isEmpty()) {
+ data.master().update("INSERT INTO approval_flow_definition (tenant_id, flow_code, version, name, nodes_json) "
+ + "VALUES (?, 'coordination_internal', 1, '内部协调单', ?)",
+ TENANT, ApprovalConstants.COORDINATION_NODES_JSON);
+ ids = data.master().queryForList("SELECT id FROM approval_flow_definition WHERE tenant_id = ? "
+ + "AND flow_code = 'coordination_internal' AND version = 1 LIMIT 1", Long.class, TENANT);
+ }
+ return ids.isEmpty() ? 0L : ids.get(0);
+ }
+
+ private String format(LocalDateTime time) {
+ return TIME_FORMAT.format(time.withNano(0));
+ }
+
+ private String orgName(Integer organizationId) {
+ return data.server().queryForObject("SELECT organization_name FROM sys_organization WHERE organization_id = ?",
+ String.class, organizationId);
+ }
+
+ private void grantDepartmentLeader() {
+ ApprovalTestData.TestRole role = data.createRole("dept_leader_" + reportOrgId, "部门负责人");
+ ApprovalTestData.TestUser leader = data.createUser("负责人" + data.uniqueSuffix(), reportOrgId);
+ data.bind(leader.userId, role.roleId);
+ }
+
+ private void grantArchivist() {
+ ApprovalTestData.TestRole role = data.createRole(ApprovalConstants.ROLE_ARCHIVIST, "收文员");
+ ApprovalTestData.TestUser archivist = data.createUser("收文员" + data.uniqueSuffix(), reportOrgId);
+ data.bind(archivist.userId, role.roleId);
+ }
+
+ private MockHttpServletRequestBuilder authed(MockHttpServletRequestBuilder builder,
+ ApprovalTestData.TestUser user, String... authorities) {
+ return builder.header("tenantId", TENANT)
+ .with(authentication(TestAuthentication.user(user.userId, user.username,
+ reportOrgId, TENANT, authorities)));
+ }
+
+ private String quote(String value) {
+ return "\"" + value.replace("\\", "\\\\").replace("\"", "\\\"") + "\"";
+ }
+}
From 7db8097c8445179127296f212730cb7c0e947852 Mon Sep 17 00:00:00 2001
From: weicw <594098497@qq.com>
Date: Fri, 30 Oct 2026 21:03:07 +0800
Subject: [PATCH 07/18] =?UTF-8?q?feat(approval):=20PC=20=E5=BE=85=E5=8A=9E?=
=?UTF-8?q?=E6=94=B9=E6=B4=BE=E6=8E=A5=E5=8F=A3=E4=B8=8E=E9=9B=86=E6=88=90?=
=?UTF-8?q?=E6=B5=8B=E8=AF=95=20(#22)?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
- 新增 POST /api/approval/task/{id}/assign:权限 approval:instance:assign,
任务须 PENDING、目标同租户启用用户、原因必填;事务内锁实例后条件更新
处理人并写 ASSIGN 记录(opinion=原因),@OperationLog 记录原/新处理人与原因
- 新增 ApprovalTaskAssignParam / ApprovalAssignVO
- 新增 ApprovalPcTodoIT:改派成功与归属变化、无效目标、非待办任务、原因必填、
无权限 403、ASSIGN 记录、操作日志、改派候选用户
---
.../controller/ApprovalTaskController.java | 12 +
.../param/ApprovalTaskAssignParam.java | 20 +
.../approval/service/ApprovalTaskService.java | 84 +++++
.../approval/vo/ApprovalAssignVO.java | 29 ++
.../gxwebsoft/approval/ApprovalPcTodoIT.java | 349 ++++++++++++++++++
5 files changed, 494 insertions(+)
create mode 100644 src/main/java/com/gxwebsoft/approval/param/ApprovalTaskAssignParam.java
create mode 100644 src/main/java/com/gxwebsoft/approval/vo/ApprovalAssignVO.java
create mode 100644 src/test/java/com/gxwebsoft/approval/ApprovalPcTodoIT.java
diff --git a/src/main/java/com/gxwebsoft/approval/controller/ApprovalTaskController.java b/src/main/java/com/gxwebsoft/approval/controller/ApprovalTaskController.java
index 58af23e..6d75442 100644
--- a/src/main/java/com/gxwebsoft/approval/controller/ApprovalTaskController.java
+++ b/src/main/java/com/gxwebsoft/approval/controller/ApprovalTaskController.java
@@ -1,11 +1,14 @@
package com.gxwebsoft.approval.controller;
import com.gxwebsoft.approval.param.ApprovalTaskActionParam;
+import com.gxwebsoft.approval.param.ApprovalTaskAssignParam;
import com.gxwebsoft.approval.param.ApprovalTaskListParam;
import com.gxwebsoft.approval.service.ApprovalInstanceService;
import com.gxwebsoft.approval.service.ApprovalTaskService;
+import com.gxwebsoft.approval.vo.ApprovalAssignVO;
import com.gxwebsoft.approval.vo.ApprovalInstanceVO;
import com.gxwebsoft.approval.vo.ApprovalItemVO;
+import com.gxwebsoft.common.core.annotation.OperationLog;
import com.gxwebsoft.common.core.web.ApiResult;
import com.gxwebsoft.common.core.web.BaseController;
import com.gxwebsoft.common.core.web.PageResult;
@@ -85,4 +88,13 @@ public class ApprovalTaskController extends BaseController {
ApprovalTaskService.Result result = taskService.withdraw(id, user);
return success("撤回成功", instanceService.detail(result.getInstanceId(), user));
}
+
+ @PreAuthorize("hasAuthority('approval:instance:assign')")
+ @OperationLog(value = "改派审批待办", module = "审批管理")
+ @ApiOperation("改派")
+ @PostMapping("/task/{id}/assign")
+ public ApiResult assign(@PathVariable("id") Long id,
+ @RequestBody(required = false) ApprovalTaskAssignParam param) {
+ return success("改派成功", taskService.assign(id, param, getLoginUser()));
+ }
}
diff --git a/src/main/java/com/gxwebsoft/approval/param/ApprovalTaskAssignParam.java b/src/main/java/com/gxwebsoft/approval/param/ApprovalTaskAssignParam.java
new file mode 100644
index 0000000..a826e08
--- /dev/null
+++ b/src/main/java/com/gxwebsoft/approval/param/ApprovalTaskAssignParam.java
@@ -0,0 +1,20 @@
+package com.gxwebsoft.approval.param;
+
+import io.swagger.annotations.ApiModelProperty;
+import lombok.Data;
+
+import java.io.Serializable;
+
+/**
+ * 待办改派参数。
+ */
+@Data
+public class ApprovalTaskAssignParam implements Serializable {
+ private static final long serialVersionUID = 1L;
+
+ @ApiModelProperty("改派对象用户id")
+ private Integer targetUserId;
+
+ @ApiModelProperty("改派原因;必填")
+ private String reason;
+}
diff --git a/src/main/java/com/gxwebsoft/approval/service/ApprovalTaskService.java b/src/main/java/com/gxwebsoft/approval/service/ApprovalTaskService.java
index 0cf213d..a6a1a2f 100644
--- a/src/main/java/com/gxwebsoft/approval/service/ApprovalTaskService.java
+++ b/src/main/java/com/gxwebsoft/approval/service/ApprovalTaskService.java
@@ -8,8 +8,10 @@ import com.gxwebsoft.approval.entity.ApprovalInstance;
import com.gxwebsoft.approval.entity.ApprovalTask;
import com.gxwebsoft.approval.mapper.ApprovalInstanceMapper;
import com.gxwebsoft.approval.mapper.ApprovalTaskMapper;
+import com.gxwebsoft.approval.param.ApprovalTaskAssignParam;
import com.gxwebsoft.approval.support.ApprovalApprover;
import com.gxwebsoft.approval.support.ApprovalNode;
+import com.gxwebsoft.approval.vo.ApprovalAssignVO;
import com.gxwebsoft.approval.vo.ApprovalUserVO;
import com.gxwebsoft.common.core.exception.BusinessException;
import com.gxwebsoft.common.system.entity.User;
@@ -109,6 +111,28 @@ public class ApprovalTaskService {
return new Result(instanceId, null);
}
+ /**
+ * 改派:管理员把待处理任务换给同租户其他启用用户,原因必填并写 ASSIGN 记录。
+ *
+ * 改派不改变任务状态,只替换处理人;操作人不必是任务当前处理人。
+ */
+ public ApprovalAssignVO assign(Long taskId, ApprovalTaskAssignParam param, User user) {
+ if (param == null || param.getTargetUserId() == null) {
+ throw new BusinessException("请选择改派对象");
+ }
+ String reason = requireAssignReason(param.getReason());
+ ApprovalTask task = requireAssignableTask(taskId);
+ // 事务外解析目标用户:启用、未删除且属于当前租户(@DS("server") 查询显式带租户)
+ List targets =
+ directoryService.listActiveUsersByIds(Collections.singletonList(param.getTargetUserId()));
+ if (targets.isEmpty()) {
+ throw new BusinessException("改派对象不存在或已停用");
+ }
+ ApprovalUserVO target = targets.get(0);
+ String targetName = displayName(target);
+ return inTransaction(() -> doAssign(taskId, task, target.getUserId(), targetName, reason, user));
+ }
+
public Integer todoCount(User user) {
return taskMapper.countPendingInstances(user.getUserId());
}
@@ -171,6 +195,58 @@ public class ApprovalTaskService {
}
}
+ /**
+ * 事务内改派:锁实例后条件更新处理人,并追加 ASSIGN 记录。
+ */
+ private ApprovalAssignVO doAssign(Long taskId, ApprovalTask task, Integer targetUserId,
+ String targetName, String reason, User user) {
+ ApprovalInstance locked = engineService.lockInstance(task.getInstanceId());
+ ensureTaskHandleable(task, locked);
+ int rows = taskMapper.update(null, new UpdateWrapper()
+ .eq("id", taskId)
+ .eq("status", ApprovalConstants.TASK_PENDING)
+ .set("assignee_id", targetUserId)
+ .set("assignee_name", targetName));
+ if (rows == 0) {
+ throw new BusinessException("任务已处理");
+ }
+ engineService.addRecord(locked, taskId, task.getNodeKey(), user,
+ ApprovalConstants.ACTION_ASSIGN, reason);
+ ApprovalAssignVO vo = new ApprovalAssignVO();
+ vo.setInstanceId(locked.getId());
+ vo.setTaskId(taskId);
+ vo.setOriginalAssigneeId(task.getAssigneeId());
+ vo.setOriginalAssigneeName(task.getAssigneeName());
+ vo.setAssigneeId(targetUserId);
+ vo.setAssigneeName(targetName);
+ vo.setReason(reason);
+ return vo;
+ }
+
+ /**
+ * 改派前的基本校验:任务存在、待处理且单据仍在审批中。
+ */
+ private ApprovalTask requireAssignableTask(Long taskId) {
+ if (taskId == null) {
+ throw new BusinessException("任务不存在");
+ }
+ ApprovalTask task = taskMapper.selectById(taskId);
+ if (task == null) {
+ throw new BusinessException("任务不存在");
+ }
+ if (!ApprovalConstants.TASK_PENDING.equals(task.getStatus())) {
+ throw new BusinessException("任务已处理");
+ }
+ ApprovalInstance instance = instanceMapper.selectById(task.getInstanceId());
+ if (instance == null) {
+ throw new BusinessException("单据不存在");
+ }
+ if (!ApprovalConstants.STATUS_PENDING.equals(instance.getStatus())) {
+ throw new BusinessException("单据不在审批中,无法改派");
+ }
+ return task;
+ }
+
/**
* 任务必须属于实例当前轮次、当前节点,否则视为已被处理或已失效。
*/
@@ -310,6 +386,14 @@ public class ApprovalTaskService {
return normalized;
}
+ private String requireAssignReason(String reason) {
+ String normalized = normalizeOpinion(reason);
+ if (normalized == null) {
+ throw new BusinessException("请填写改派原因");
+ }
+ return normalized;
+ }
+
private T inTransaction(Supplier action) {
return new TransactionTemplate(transactionManager).execute(status -> action.get());
}
diff --git a/src/main/java/com/gxwebsoft/approval/vo/ApprovalAssignVO.java b/src/main/java/com/gxwebsoft/approval/vo/ApprovalAssignVO.java
new file mode 100644
index 0000000..718c9d3
--- /dev/null
+++ b/src/main/java/com/gxwebsoft/approval/vo/ApprovalAssignVO.java
@@ -0,0 +1,29 @@
+package com.gxwebsoft.approval.vo;
+
+import io.swagger.annotations.ApiModel;
+import lombok.Data;
+
+import java.io.Serializable;
+
+/**
+ * 改派结果:原处理人、新处理人与原因,同时用于操作日志留痕。
+ */
+@Data
+@ApiModel(description = "改派结果")
+public class ApprovalAssignVO implements Serializable {
+ private static final long serialVersionUID = 1L;
+
+ private Long instanceId;
+
+ private Long taskId;
+
+ private Integer originalAssigneeId;
+
+ private String originalAssigneeName;
+
+ private Integer assigneeId;
+
+ private String assigneeName;
+
+ private String reason;
+}
diff --git a/src/test/java/com/gxwebsoft/approval/ApprovalPcTodoIT.java b/src/test/java/com/gxwebsoft/approval/ApprovalPcTodoIT.java
new file mode 100644
index 0000000..26e43fd
--- /dev/null
+++ b/src/test/java/com/gxwebsoft/approval/ApprovalPcTodoIT.java
@@ -0,0 +1,349 @@
+package com.gxwebsoft.approval;
+
+import com.fasterxml.jackson.databind.JsonNode;
+import com.fasterxml.jackson.databind.ObjectMapper;
+import com.gxwebsoft.support.ApprovalTestData;
+import com.gxwebsoft.support.TestAuthentication;
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc;
+import org.springframework.boot.test.context.SpringBootTest;
+import org.springframework.http.MediaType;
+import org.springframework.mock.web.MockMultipartFile;
+import org.springframework.test.context.ActiveProfiles;
+import org.springframework.test.web.servlet.MockMvc;
+import org.springframework.test.web.servlet.request.MockHttpServletRequestBuilder;
+
+import javax.sql.DataSource;
+import java.nio.charset.StandardCharsets;
+import java.util.ArrayList;
+import java.util.List;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.authentication;
+import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
+import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.multipart;
+import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
+import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
+import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
+
+/**
+ * PC 待办审批与改派的端到端行为。
+ *
+ * 覆盖:改派成功与待办归属、目标用户校验、非待办任务、原因必填、改派权限 403、
+ * ASSIGN 处理记录、操作日志以及改派候选用户查询。
+ */
+@SpringBootTest
+@AutoConfigureMockMvc
+@ActiveProfiles({"gxtyzx-test", "test"})
+class ApprovalPcTodoIT {
+
+ private static final int TENANT = TestAuthentication.TENANT_ID;
+ private static final String LIST = "approval:instance:list";
+ private static final String SUBMIT = "approval:instance:submit";
+ private static final String APPROVE = "approval:instance:approve";
+ private static final String ASSIGN = "approval:instance:assign";
+
+ @Autowired
+ private MockMvc mockMvc;
+ @Autowired
+ private ObjectMapper objectMapper;
+ @Autowired
+ private DataSource dataSource;
+
+ private ApprovalTestData data;
+ private ApprovalTestData.TestUser reporter;
+ private ApprovalTestData.TestUser leader;
+ private ApprovalTestData.TestUser admin;
+ private Integer organizationId;
+ private final List assignUrls = new ArrayList<>();
+
+ @BeforeEach
+ void setUp() {
+ data = new ApprovalTestData(dataSource);
+ organizationId = data.createOrganization("PC待办部门" + data.uniqueSuffix(), 136);
+ reporter = data.createUser("PC报送人" + data.uniqueSuffix(), organizationId);
+ ApprovalTestData.TestRole leaderRole =
+ data.createRole("dept_leader_" + organizationId, "部门负责人");
+ leader = data.createUser("PC负责人" + data.uniqueSuffix(), organizationId);
+ data.bind(leader.userId, leaderRole.roleId);
+ ApprovalTestData.TestRole archivistRole = data.createRole("approval_archivist", "收文员");
+ ApprovalTestData.TestUser archivist = data.createUser("PC收文员" + data.uniqueSuffix(), organizationId);
+ data.bind(archivist.userId, archivistRole.roleId);
+ admin = data.createUser("PC管理员" + data.uniqueSuffix(), organizationId);
+ }
+
+ @AfterEach
+ void tearDown() {
+ for (String url : assignUrls) {
+ data.server().update("DELETE FROM sys_operation_record WHERE user_id = ? AND url = ?",
+ admin.userId, url);
+ }
+ assignUrls.clear();
+ data.cleanup();
+ }
+
+ @Test
+ void assignsPendingTaskAndMovesTodoToTargetUser() throws Exception {
+ long instanceId = createAndSubmit("改派单据");
+ long taskId = pendingTaskId(instanceId, leader.userId);
+ ApprovalTestData.TestUser target = data.createUser("改派目标" + data.uniqueSuffix(), organizationId);
+ String reason = "原处理人休假" + data.uniqueSuffix();
+
+ mockMvc.perform(assign(taskId, admin, ASSIGN, target.userId, reason))
+ .andExpect(status().isOk())
+ .andExpect(jsonPath("$.code").value(0))
+ .andExpect(jsonPath("$.data.instanceId").value((int) instanceId))
+ .andExpect(jsonPath("$.data.taskId").value((int) taskId))
+ .andExpect(jsonPath("$.data.originalAssigneeId").value(leader.userId))
+ .andExpect(jsonPath("$.data.assigneeId").value(target.userId))
+ .andExpect(jsonPath("$.data.assigneeName").value(target.realName))
+ .andExpect(jsonPath("$.data.reason").value(reason));
+
+ // 任务仍为待处理,归属已换人
+ assertThat(data.queryMaster("SELECT status FROM approval_task WHERE id = ?", String.class, taskId))
+ .isEqualTo("PENDING");
+ assertThat(data.queryMaster("SELECT assignee_id FROM approval_task WHERE id = ?", Integer.class, taskId))
+ .isEqualTo(target.userId);
+ assertThat(data.queryMaster("SELECT assignee_name FROM approval_task WHERE id = ?", String.class, taskId))
+ .isEqualTo(target.realName);
+
+ // 处理记录留痕:改派人与原因
+ assertThat(data.countMaster("SELECT COUNT(*) FROM approval_record WHERE instance_id = ? "
+ + "AND action = 'ASSIGN' AND operator_id = ? AND opinion = ?", instanceId, admin.userId, reason))
+ .isEqualTo(1);
+
+ // 原处理人待办消失、新处理人待办出现
+ mockMvc.perform(authed(get("/api/approval/todo/page"), leader, organizationId, LIST))
+ .andExpect(jsonPath("$.code").value(0))
+ .andExpect(jsonPath("$.data.count").value(0));
+ mockMvc.perform(authed(get("/api/approval/todo/page"), target, organizationId, LIST))
+ .andExpect(jsonPath("$.code").value(0))
+ .andExpect(jsonPath("$.data.count").value(1))
+ .andExpect(jsonPath("$.data.list[0].id").value((int) instanceId))
+ .andExpect(jsonPath("$.data.list[0].currentNodeName").value("报送部门负责人"));
+
+ // 新处理人可正常处理改派后的任务
+ mockMvc.perform(authed(post("/api/approval/task/" + taskId + "/approve"), target, organizationId, APPROVE)
+ .contentType(MediaType.APPLICATION_JSON).content("{\"opinion\":\"接办\"}"))
+ .andExpect(jsonPath("$.code").value(0))
+ .andExpect(jsonPath("$.data.currentNodeName").value("存档"));
+
+ // 原处理人已无权处理该任务
+ long second = createAndSubmit("改派后原处理人");
+ long secondTask = pendingTaskId(second, leader.userId);
+ mockMvc.perform(assign(secondTask, admin, ASSIGN, target.userId, "转办" + data.uniqueSuffix()))
+ .andExpect(jsonPath("$.code").value(0));
+ mockMvc.perform(authed(post("/api/approval/task/" + secondTask + "/approve"), leader, organizationId, APPROVE)
+ .contentType(MediaType.APPLICATION_JSON).content("{}"))
+ .andExpect(jsonPath("$.code").value(1))
+ .andExpect(jsonPath("$.message").value(org.hamcrest.Matchers.containsString("无权处理该任务")));
+ }
+
+ @Test
+ void rejectsAssignToUnknownOrInactiveUser() throws Exception {
+ long instanceId = createAndSubmit("改派无效目标");
+ long taskId = pendingTaskId(instanceId, leader.userId);
+
+ ApprovalTestData.TestUser inactive = data.createUser("停用目标" + data.uniqueSuffix(), organizationId);
+ data.server().update("UPDATE sys_user SET status = 1 WHERE user_id = ?", inactive.userId);
+
+ mockMvc.perform(assign(taskId, admin, ASSIGN, inactive.userId, "停用目标改派"))
+ .andExpect(status().isOk())
+ .andExpect(jsonPath("$.code").value(1))
+ .andExpect(jsonPath("$.message").value(org.hamcrest.Matchers.containsString("改派对象不存在或已停用")));
+
+ mockMvc.perform(assign(taskId, admin, ASSIGN, 99999999, "不存在用户改派"))
+ .andExpect(status().isOk())
+ .andExpect(jsonPath("$.code").value(1))
+ .andExpect(jsonPath("$.message").value(org.hamcrest.Matchers.containsString("改派对象不存在或已停用")));
+
+ assertThat(data.queryMaster("SELECT assignee_id FROM approval_task WHERE id = ?", Integer.class, taskId))
+ .isEqualTo(leader.userId);
+ assertThat(data.countMaster("SELECT COUNT(*) FROM approval_record WHERE instance_id = ? AND action = 'ASSIGN'",
+ instanceId)).isZero();
+ }
+
+ @Test
+ void requiresAssignReason() throws Exception {
+ long instanceId = createAndSubmit("改派无原因");
+ long taskId = pendingTaskId(instanceId, leader.userId);
+ ApprovalTestData.TestUser target = data.createUser("改派目标" + data.uniqueSuffix(), organizationId);
+
+ mockMvc.perform(assign(taskId, admin, ASSIGN, target.userId, " "))
+ .andExpect(status().isOk())
+ .andExpect(jsonPath("$.code").value(1))
+ .andExpect(jsonPath("$.message").value(org.hamcrest.Matchers.containsString("请填写改派原因")));
+
+ assertThat(data.queryMaster("SELECT assignee_id FROM approval_task WHERE id = ?", Integer.class, taskId))
+ .isEqualTo(leader.userId);
+ }
+
+ @Test
+ void rejectsAssignForHandledTask() throws Exception {
+ long instanceId = createAndSubmit("改派已处理任务");
+ long taskId = pendingTaskId(instanceId, leader.userId);
+ ApprovalTestData.TestUser target = data.createUser("改派目标" + data.uniqueSuffix(), organizationId);
+
+ mockMvc.perform(authed(post("/api/approval/task/" + taskId + "/approve"), leader, organizationId, APPROVE)
+ .contentType(MediaType.APPLICATION_JSON).content("{}"))
+ .andExpect(jsonPath("$.code").value(0));
+
+ mockMvc.perform(assign(taskId, admin, ASSIGN, target.userId, "已处理后改派"))
+ .andExpect(status().isOk())
+ .andExpect(jsonPath("$.code").value(1))
+ .andExpect(jsonPath("$.message").value(org.hamcrest.Matchers.containsString("任务已处理")));
+
+ assertThat(data.queryMaster("SELECT assignee_id FROM approval_task WHERE id = ?", Integer.class, taskId))
+ .isEqualTo(leader.userId);
+ }
+
+ @Test
+ void rejectsAssignWithoutAuthority() throws Exception {
+ long instanceId = createAndSubmit("无权限改派");
+ long taskId = pendingTaskId(instanceId, leader.userId);
+ ApprovalTestData.TestUser target = data.createUser("改派目标" + data.uniqueSuffix(), organizationId);
+
+ mockMvc.perform(assign(taskId, admin, APPROVE, target.userId, "越权改派"))
+ .andExpect(status().isOk())
+ .andExpect(jsonPath("$.code").value(403));
+
+ assertThat(data.queryMaster("SELECT assignee_id FROM approval_task WHERE id = ?", Integer.class, taskId))
+ .isEqualTo(leader.userId);
+
+ mockMvc.perform(authed(get("/api/approval/directory/users").param("keywords", "PC"),
+ admin, organizationId, APPROVE))
+ .andExpect(status().isOk())
+ .andExpect(jsonPath("$.code").value(403));
+ }
+
+ @Test
+ void writesOperationLogWithOriginalAndNewAssignee() throws Exception {
+ long instanceId = createAndSubmit("改派操作日志");
+ long taskId = pendingTaskId(instanceId, leader.userId);
+ ApprovalTestData.TestUser target = data.createUser("改派目标" + data.uniqueSuffix(), organizationId);
+ String reason = "操作日志原因" + data.uniqueSuffix();
+ String url = "/api/approval/task/" + taskId + "/assign";
+ assignUrls.add(url);
+
+ mockMvc.perform(assign(taskId, admin, ASSIGN, target.userId, reason))
+ .andExpect(jsonPath("$.code").value(0));
+
+ JsonNode record = awaitOperationRecord(url, reason);
+ assertThat(record).as("改派操作日志应写入 sys_operation_record").isNotNull();
+ assertThat(record.path("userId").asInt()).isEqualTo(admin.userId);
+ assertThat(record.path("description").asText()).contains("改派");
+ assertThat(record.path("params").asText()).contains(reason);
+ assertThat(record.path("result").asText())
+ .contains(leader.realName)
+ .contains(target.realName)
+ .contains(reason);
+ }
+
+ @Test
+ void listsOnlyActiveUsersForAssignCandidates() throws Exception {
+ ApprovalTestData.TestUser active = data.createUser("候选人" + data.uniqueSuffix(), organizationId);
+ ApprovalTestData.TestUser inactive = data.createUser("停用候选人" + data.uniqueSuffix(), organizationId);
+ data.server().update("UPDATE sys_user SET status = 1 WHERE user_id = ?", inactive.userId);
+
+ String body = mockMvc.perform(authed(get("/api/approval/directory/users")
+ .param("keywords", active.realName), admin, organizationId, ASSIGN))
+ .andExpect(status().isOk())
+ .andExpect(jsonPath("$.code").value(0))
+ .andReturn().getResponse().getContentAsString(StandardCharsets.UTF_8);
+ JsonNode users = objectMapper.readTree(body).path("data");
+ assertThat(users.isArray()).isTrue();
+ assertThat(users.findValuesAsText("userId")).contains(String.valueOf(active.userId));
+ assertThat(users.findValuesAsText("userId")).doesNotContain(String.valueOf(inactive.userId));
+ JsonNode matched = null;
+ for (JsonNode user : users) {
+ if (user.path("userId").asInt() == active.userId) {
+ matched = user;
+ }
+ }
+ assertThat(matched).isNotNull();
+ assertThat(matched.path("realName").asText()).isEqualTo(active.realName);
+ }
+
+ // ---------- helpers ----------
+
+ private MockHttpServletRequestBuilder assign(long taskId, ApprovalTestData.TestUser operator,
+ String authority, int targetUserId, String reason)
+ throws Exception {
+ return authed(post("/api/approval/task/" + taskId + "/assign"), operator, organizationId, authority)
+ .contentType(MediaType.APPLICATION_JSON)
+ .content("{\"targetUserId\":" + targetUserId + ",\"reason\":" + quote(reason) + "}");
+ }
+
+ private long createAndSubmit(String name) throws Exception {
+ String body = "{\"name\":" + quote(name)
+ + ",\"implementOrg\":\"广西体育中心实施部\",\"fee\":123.45,"
+ + "\"serviceContent\":\"完成场馆设施维修与验收\"}";
+ String response = mockMvc.perform(authed(post("/api/approval/instance/completion"),
+ reporter, organizationId, SUBMIT)
+ .contentType(MediaType.APPLICATION_JSON)
+ .content(body))
+ .andExpect(status().isOk())
+ .andExpect(jsonPath("$.code").value(0))
+ .andReturn().getResponse().getContentAsString(StandardCharsets.UTF_8);
+ long instanceId = objectMapper.readTree(response).path("data").asLong();
+ data.trackInstance(instanceId);
+
+ uploadAttachment(instanceId);
+ mockMvc.perform(authed(post("/api/approval/instance/" + instanceId + "/submit"),
+ reporter, organizationId, SUBMIT))
+ .andExpect(status().isOk())
+ .andExpect(jsonPath("$.code").value(0));
+ return instanceId;
+ }
+
+ private void uploadAttachment(long instanceId) throws Exception {
+ MockMultipartFile file = new MockMultipartFile("file", "sign.png", "image/png",
+ new byte[]{(byte) 0x89, 'P', 'N', 'G', 0x0D, 0x0A, 0x1A, 0x0A});
+ mockMvc.perform(multipart("/api/approval/attachment/upload")
+ .file(file)
+ .param("instanceId", String.valueOf(instanceId))
+ .param("category", "SIGNED")
+ .header("tenantId", TENANT)
+ .with(authentication(TestAuthentication.user(reporter.userId, reporter.username,
+ organizationId, TENANT, SUBMIT))))
+ .andExpect(status().isOk())
+ .andExpect(jsonPath("$.code").value(0));
+ }
+
+ private long pendingTaskId(long instanceId, int userId) {
+ Long taskId = data.queryMaster("SELECT id FROM approval_task WHERE instance_id = ? "
+ + "AND assignee_id = ? AND status = 'PENDING' LIMIT 1", Long.class, instanceId, userId);
+ assertThat(taskId).as("待办任务应存在").isNotNull();
+ return taskId;
+ }
+
+ /** 操作日志异步写入,最多等待 2 秒。 */
+ private JsonNode awaitOperationRecord(String url, String paramsMark) throws Exception {
+ long deadline = System.currentTimeMillis() + 2000;
+ while (System.currentTimeMillis() < deadline) {
+ List> rows = data.server().queryForList(
+ "SELECT user_id AS userId, description, params, result FROM sys_operation_record "
+ + "WHERE url = ? AND params LIKE ? ORDER BY id DESC LIMIT 1",
+ url, "%" + paramsMark + "%");
+ if (!rows.isEmpty()) {
+ return objectMapper.valueToTree(rows.get(0));
+ }
+ Thread.sleep(100);
+ }
+ return null;
+ }
+
+ private MockHttpServletRequestBuilder authed(MockHttpServletRequestBuilder builder,
+ ApprovalTestData.TestUser user, Integer org,
+ String... authorities) {
+ return builder.header("tenantId", TENANT)
+ .with(authentication(TestAuthentication.user(user.userId, user.username, org, TENANT, authorities)));
+ }
+
+ private String quote(String value) {
+ return "\"" + value.replace("\\", "\\\\").replace("\"", "\\\"") + "\"";
+ }
+}
From 7f26fafba3d399167ca0434ff2f87eb244720ef7 Mon Sep 17 00:00:00 2001
From: weicw <594098497@qq.com>
Date: Fri, 30 Oct 2026 21:07:35 +0800
Subject: [PATCH 08/18] =?UTF-8?q?feat(approval):=20=E5=86=85=E9=83=A8?=
=?UTF-8?q?=E5=8D=8F=E8=B0=83=E5=8D=95=E8=A1=A8=E5=8D=95=E3=80=81=E6=8F=90?=
=?UTF-8?q?=E4=BA=A4=E9=A2=84=E6=A3=80=E4=B8=8E=E5=8F=8C=E9=83=A8=E9=97=A8?=
=?UTF-8?q?=E8=B4=9F=E8=B4=A3=E4=BA=BA=E5=AE=A1=E6=89=B9=20(#23)?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
.../ApprovalInstanceController.java | 9 +
.../mapper/xml/ApprovalInstanceMapper.xml | 2 +-
.../param/ApprovalCoordinationParam.java | 31 +
.../service/ApprovalInstanceService.java | 170 +++++-
.../approval/ApprovalCoordinationIT.java | 568 ++++++++++++++++++
5 files changed, 775 insertions(+), 5 deletions(-)
create mode 100644 src/main/java/com/gxwebsoft/approval/param/ApprovalCoordinationParam.java
create mode 100644 src/test/java/com/gxwebsoft/approval/ApprovalCoordinationIT.java
diff --git a/src/main/java/com/gxwebsoft/approval/controller/ApprovalInstanceController.java b/src/main/java/com/gxwebsoft/approval/controller/ApprovalInstanceController.java
index 586a594..cf3b993 100644
--- a/src/main/java/com/gxwebsoft/approval/controller/ApprovalInstanceController.java
+++ b/src/main/java/com/gxwebsoft/approval/controller/ApprovalInstanceController.java
@@ -1,6 +1,7 @@
package com.gxwebsoft.approval.controller;
import com.gxwebsoft.approval.param.ApprovalCompletionParam;
+import com.gxwebsoft.approval.param.ApprovalCoordinationParam;
import com.gxwebsoft.approval.param.ApprovalMineParam;
import com.gxwebsoft.approval.service.ApprovalInstanceService;
import com.gxwebsoft.approval.vo.ApprovalInstanceVO;
@@ -40,6 +41,14 @@ public class ApprovalInstanceController extends BaseController {
return success("保存成功", instanceService.saveCompletion(param, user));
}
+ @PreAuthorize("hasAuthority('approval:instance:submit')")
+ @ApiOperation("新建或保存内部协调单草稿")
+ @PostMapping("/instance/coordination")
+ public ApiResult saveCoordination(@RequestBody ApprovalCoordinationParam param) {
+ User user = getLoginUser();
+ return success("保存成功", instanceService.saveCoordination(param, user));
+ }
+
@PreAuthorize("hasAuthority('approval:instance:submit')")
@ApiOperation("提交或重提")
@PostMapping("/instance/{id}/submit")
diff --git a/src/main/java/com/gxwebsoft/approval/mapper/xml/ApprovalInstanceMapper.xml b/src/main/java/com/gxwebsoft/approval/mapper/xml/ApprovalInstanceMapper.xml
index 61cdb83..e934317 100644
--- a/src/main/java/com/gxwebsoft/approval/mapper/xml/ApprovalInstanceMapper.xml
+++ b/src/main/java/com/gxwebsoft/approval/mapper/xml/ApprovalInstanceMapper.xml
@@ -32,7 +32,7 @@
i.reporter_id,
i.reporter_name,
i.report_org_name,
- i.accept_org_name,
+ COALESCE(i.accept_org_name, co.accept_org_name) AS accept_org_name,
i.archive_time,
i.current_node_key,
i.definition_id,
diff --git a/src/main/java/com/gxwebsoft/approval/param/ApprovalCoordinationParam.java b/src/main/java/com/gxwebsoft/approval/param/ApprovalCoordinationParam.java
new file mode 100644
index 0000000..bb9ab53
--- /dev/null
+++ b/src/main/java/com/gxwebsoft/approval/param/ApprovalCoordinationParam.java
@@ -0,0 +1,31 @@
+package com.gxwebsoft.approval.param;
+
+import io.swagger.annotations.ApiModelProperty;
+import lombok.Data;
+
+import java.io.Serializable;
+
+/**
+ * 内部协调单草稿参数;id 为审批实例id,更新草稿时传入。
+ *
+ * 报送部门与经办人由服务端按登录态带出,不在参数中接收。
+ */
+@Data
+public class ApprovalCoordinationParam implements Serializable {
+ private static final long serialVersionUID = 1L;
+
+ @ApiModelProperty("审批实例id(更新草稿时传入)")
+ private Long id;
+
+ @ApiModelProperty("受理部门id,必须是启用组织")
+ private Integer acceptOrgId;
+
+ @ApiModelProperty("标题")
+ private String title;
+
+ @ApiModelProperty("内容")
+ private String content;
+
+ @ApiModelProperty("备注")
+ private String remark;
+}
diff --git a/src/main/java/com/gxwebsoft/approval/service/ApprovalInstanceService.java b/src/main/java/com/gxwebsoft/approval/service/ApprovalInstanceService.java
index d2b6af0..fc709b0 100644
--- a/src/main/java/com/gxwebsoft/approval/service/ApprovalInstanceService.java
+++ b/src/main/java/com/gxwebsoft/approval/service/ApprovalInstanceService.java
@@ -16,6 +16,7 @@ import com.gxwebsoft.approval.mapper.ApprovalInstanceMapper;
import com.gxwebsoft.approval.mapper.ApprovalRecordMapper;
import com.gxwebsoft.approval.mapper.ApprovalTaskMapper;
import com.gxwebsoft.approval.param.ApprovalCompletionParam;
+import com.gxwebsoft.approval.param.ApprovalCoordinationParam;
import com.gxwebsoft.approval.param.ApprovalMineParam;
import com.gxwebsoft.approval.param.ApprovalTaskListParam;
import com.gxwebsoft.approval.support.ApprovalApprover;
@@ -99,6 +100,30 @@ public class ApprovalInstanceService {
return inTransaction(() -> createDraft(param, user, definition, organizationId, organizationName));
}
+ /**
+ * 新建或更新内部协调单草稿,返回实例id。
+ *
+ * 报送部门与经办人取登录态;受理部门必须是启用组织,保存时即校验。
+ */
+ public Long saveCoordination(ApprovalCoordinationParam param, User user) {
+ validateCoordination(param);
+ ApprovalOrganizationVO acceptOrganization = directoryService.getOrganization(param.getAcceptOrgId());
+ if (acceptOrganization == null) {
+ throw new BusinessException("受理部门不存在或已停用");
+ }
+ Integer organizationId = user.getOrganizationId();
+ ApprovalOrganizationVO organization = directoryService.getOrganization(organizationId);
+ String organizationName = organization == null ? null : organization.getOrganizationName();
+
+ if (param.getId() != null) {
+ return inTransaction(() ->
+ updateCoordinationDraft(param, user, organizationId, organizationName, acceptOrganization));
+ }
+ ApprovalFlowDefinition definition = flowService.getOrCreate(ApprovalConstants.FLOW_COORDINATION_INTERNAL);
+ return inTransaction(() ->
+ createCoordinationDraft(param, user, definition, organizationId, organizationName, acceptOrganization));
+ }
+
/**
* 提交或重提,返回节点缺配提示;正常提交返回 null。
*/
@@ -113,7 +138,14 @@ public class ApprovalInstanceService {
if (!ApprovalConstants.EDITABLE_STATUSES.contains(instance.getStatus())) {
throw new BusinessException(submitBlockedMessage(instance.getStatus()));
}
- if (completionForm(instanceId) == null) {
+ boolean coordination = ApprovalConstants.DOC_COORDINATION.equals(instance.getDocType());
+ ApprovalCoordinationForm coordinationForm = null;
+ if (coordination) {
+ coordinationForm = coordinationForm(instanceId);
+ if (coordinationForm == null) {
+ throw new BusinessException("单据内容不存在");
+ }
+ } else if (completionForm(instanceId) == null) {
throw new BusinessException("单据内容不存在");
}
@@ -130,6 +162,10 @@ public class ApprovalInstanceService {
if (leaders.isEmpty()) {
throw new BusinessException("报送部门负责人角色缺配");
}
+ ApprovalOrganizationVO acceptOrganization = null;
+ if (coordination) {
+ acceptOrganization = preflightAcceptOrganization(coordinationForm);
+ }
if (directoryService.listRoleMembers(ApprovalConstants.ROLE_ARCHIVIST).isEmpty()) {
throw new BusinessException("收文员角色缺配");
}
@@ -141,11 +177,30 @@ public class ApprovalInstanceService {
.map(this::toApprover)
.collect(Collectors.toList());
- return inTransaction(() -> doSubmit(instanceId, user, organization, approvers));
+ ApprovalOrganizationVO acceptOrg = acceptOrganization;
+ return inTransaction(() -> doSubmit(instanceId, user, organization, acceptOrg, approvers));
+ }
+
+ /**
+ * 协调单提交预检:受理部门负责人角色、受理部门停用。
+ */
+ private ApprovalOrganizationVO preflightAcceptOrganization(ApprovalCoordinationForm form) {
+ Integer acceptOrgId = form.getAcceptOrgId();
+ if (acceptOrgId == null || acceptOrgId <= 0) {
+ throw new BusinessException("请选择受理部门");
+ }
+ if (directoryService.listRoleMembers(ApprovalConstants.departmentLeaderRoleCode(acceptOrgId)).isEmpty()) {
+ throw new BusinessException("受理部门负责人角色缺配");
+ }
+ ApprovalOrganizationVO acceptOrganization = directoryService.getOrganization(acceptOrgId);
+ if (acceptOrganization == null) {
+ throw new BusinessException("受理部门已停用");
+ }
+ return acceptOrganization;
}
private String doSubmit(Long instanceId, User user, ApprovalOrganizationVO organization,
- List approvers) {
+ ApprovalOrganizationVO acceptOrganization, List approvers) {
ApprovalInstance locked = engineService.lockInstance(instanceId);
if (!locked.getReporterId().equals(user.getUserId())
|| !ApprovalConstants.EDITABLE_STATUSES.contains(locked.getStatus())) {
@@ -154,8 +209,15 @@ public class ApprovalInstanceService {
boolean firstSubmit = StrUtil.isBlank(locked.getBizNo());
locked.setReportOrgId(organization.getOrganizationId());
locked.setReportOrgName(organization.getOrganizationName());
+ if (acceptOrganization != null) {
+ locked.setAcceptOrgId(acceptOrganization.getOrganizationId());
+ locked.setAcceptOrgName(acceptOrganization.getOrganizationName());
+ }
if (firstSubmit) {
- locked.setBizNo(noService.nextNo(ApprovalConstants.NO_PREFIX_COMPLETION));
+ boolean coordination = ApprovalConstants.DOC_COORDINATION.equals(locked.getDocType());
+ locked.setBizNo(noService.nextNo(coordination
+ ? ApprovalConstants.NO_PREFIX_COORDINATION
+ : ApprovalConstants.NO_PREFIX_COMPLETION));
}
locked.setStatus(ApprovalConstants.STATUS_PENDING);
locked.setRound((locked.getRound() == null ? 0 : locked.getRound()) + 1);
@@ -323,6 +385,79 @@ public class ApprovalInstanceService {
return instance.getId();
}
+ private Long createCoordinationDraft(ApprovalCoordinationParam param, User user,
+ ApprovalFlowDefinition definition,
+ Integer organizationId, String organizationName,
+ ApprovalOrganizationVO acceptOrganization) {
+ ApprovalInstance instance = new ApprovalInstance();
+ instance.setFlowCode(ApprovalConstants.FLOW_COORDINATION_INTERNAL);
+ instance.setDefinitionId(definition.getId());
+ instance.setDefinitionVersion(definition.getVersion());
+ instance.setDocType(ApprovalConstants.DOC_COORDINATION);
+ instance.setBizId(0L);
+ instance.setStatus(ApprovalConstants.STATUS_DRAFT);
+ instance.setRound(0);
+ instance.setReporterId(user.getUserId());
+ instance.setReporterName(displayName(user));
+ instance.setReportOrgId(organizationId);
+ instance.setReportOrgName(organizationName);
+ instanceMapper.insert(instance);
+
+ ApprovalCoordinationForm form = new ApprovalCoordinationForm();
+ form.setInstanceId(instance.getId());
+ form.setAcceptOrgId(acceptOrganization.getOrganizationId());
+ form.setAcceptOrgName(acceptOrganization.getOrganizationName());
+ form.setTitle(param.getTitle().trim());
+ form.setContent(param.getContent().trim());
+ form.setRemark(normalizeRemark(param.getRemark()));
+ coordinationFormMapper.insert(form);
+
+ instance.setBizId(form.getId());
+ instanceMapper.updateById(instance);
+ return instance.getId();
+ }
+
+ private Long updateCoordinationDraft(ApprovalCoordinationParam param, User user,
+ Integer organizationId, String organizationName,
+ ApprovalOrganizationVO acceptOrganization) {
+ ApprovalInstance instance = instanceMapper.selectById(param.getId());
+ if (instance == null) {
+ throw new BusinessException("单据不存在");
+ }
+ if (!ApprovalConstants.DOC_COORDINATION.equals(instance.getDocType())) {
+ throw new BusinessException("单据类型不匹配");
+ }
+ if (!instance.getReporterId().equals(user.getUserId())) {
+ throw new BusinessException("无权操作该单据");
+ }
+ if (!ApprovalConstants.EDITABLE_STATUSES.contains(instance.getStatus())) {
+ throw new BusinessException("当前状态不可修改");
+ }
+ ApprovalCoordinationForm form = coordinationForm(instance.getId());
+ if (form == null) {
+ throw new BusinessException("单据内容不存在");
+ }
+ form.setAcceptOrgId(acceptOrganization.getOrganizationId());
+ form.setAcceptOrgName(acceptOrganization.getOrganizationName());
+ form.setTitle(param.getTitle().trim());
+ form.setContent(param.getContent().trim());
+ form.setRemark(normalizeRemark(param.getRemark()));
+ coordinationFormMapper.updateById(form);
+
+ instance.setReportOrgId(organizationId);
+ instance.setReportOrgName(organizationName);
+ instanceMapper.updateById(instance);
+ return instance.getId();
+ }
+
+ private String normalizeRemark(String remark) {
+ if (remark == null) {
+ return null;
+ }
+ String trimmed = remark.trim();
+ return trimmed.isEmpty() ? null : trimmed;
+ }
+
private ApprovalInstanceVO baseVO(ApprovalInstance instance) {
ApprovalInstanceVO vo = new ApprovalInstanceVO();
vo.setId(instance.getId());
@@ -502,6 +637,33 @@ public class ApprovalInstanceService {
}
}
+ private void validateCoordination(ApprovalCoordinationParam param) {
+ if (param == null) {
+ throw new BusinessException("参数不能为空");
+ }
+ if (param.getAcceptOrgId() == null || param.getAcceptOrgId() <= 0) {
+ throw new BusinessException("请选择受理部门");
+ }
+ String title = param.getTitle() == null ? "" : param.getTitle().trim();
+ if (title.isEmpty()) {
+ throw new BusinessException("标题不能为空");
+ }
+ if (title.length() > 100) {
+ throw new BusinessException("标题不能超过100字");
+ }
+ String content = param.getContent() == null ? "" : param.getContent().trim();
+ if (content.isEmpty()) {
+ throw new BusinessException("内容不能为空");
+ }
+ if (content.length() > 2000) {
+ throw new BusinessException("内容不能超过2000字");
+ }
+ String remark = normalizeRemark(param.getRemark());
+ if (remark != null && remark.length() > 500) {
+ throw new BusinessException("备注不能超过500字");
+ }
+ }
+
private String submitBlockedMessage(String status) {
if (ApprovalConstants.STATUS_PENDING.equals(status)) {
return "单据正在审批中,不能重复提交";
diff --git a/src/test/java/com/gxwebsoft/approval/ApprovalCoordinationIT.java b/src/test/java/com/gxwebsoft/approval/ApprovalCoordinationIT.java
new file mode 100644
index 0000000..4ffdf2a
--- /dev/null
+++ b/src/test/java/com/gxwebsoft/approval/ApprovalCoordinationIT.java
@@ -0,0 +1,568 @@
+package com.gxwebsoft.approval;
+
+import com.fasterxml.jackson.databind.JsonNode;
+import com.fasterxml.jackson.databind.ObjectMapper;
+import com.gxwebsoft.support.ApprovalTestData;
+import com.gxwebsoft.support.TestAuthentication;
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc;
+import org.springframework.boot.test.context.SpringBootTest;
+import org.springframework.http.MediaType;
+import org.springframework.mock.web.MockMultipartFile;
+import org.springframework.test.context.ActiveProfiles;
+import org.springframework.test.web.servlet.MockMvc;
+import org.springframework.test.web.servlet.request.MockHttpServletRequestBuilder;
+
+import javax.sql.DataSource;
+import java.nio.charset.StandardCharsets;
+import java.util.ArrayList;
+import java.util.List;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.authentication;
+import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
+import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.multipart;
+import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
+import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
+import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
+
+/**
+ * 内部协调单:草稿、字段校验、提交预检、部门快照与双节点审批的集成测试。
+ *
+ * 不开启测试事务:提交与审批动作各自提交,便于验证跨库解析与节点推进;
+ * 数据由 {@link ApprovalTestData#cleanup()} 清理。
+ */
+@SpringBootTest
+@AutoConfigureMockMvc
+@ActiveProfiles({"gxtyzx-test", "test"})
+class ApprovalCoordinationIT {
+
+ private static final int TENANT = TestAuthentication.TENANT_ID;
+ private static final String LIST = "approval:instance:list";
+ private static final String SUBMIT = "approval:instance:submit";
+ private static final String APPROVE = "approval:instance:approve";
+
+ @Autowired
+ private MockMvc mockMvc;
+ @Autowired
+ private ObjectMapper objectMapper;
+ @Autowired
+ private DataSource dataSource;
+
+ private ApprovalTestData data;
+ private ApprovalTestData.TestUser reporter;
+ private Integer reportOrgId;
+ private Integer acceptOrgId;
+
+ @BeforeEach
+ void setUp() {
+ data = new ApprovalTestData(dataSource);
+ reportOrgId = data.createOrganization("协调报送部门" + data.uniqueSuffix(), 136);
+ acceptOrgId = data.createOrganization("协调受理部门" + data.uniqueSuffix(), 136);
+ reporter = data.createUser("协调报送人" + data.uniqueSuffix(), reportOrgId);
+ }
+
+ @AfterEach
+ void tearDown() {
+ data.cleanup();
+ }
+
+ @Test
+ void createsDraftWithServerSideReportFields() throws Exception {
+ long instanceId = saveCoordination(reporter, reportOrgId, acceptOrgId, "设备借用协调",
+ "借用体育馆音响设备一天", "活动结束后归还");
+
+ assertThat(instanceStatus(instanceId)).isEqualTo("DRAFT");
+ assertThat(bizNo(instanceId)).isNull();
+ assertThat(data.queryMaster("SELECT doc_type FROM approval_instance WHERE id = ?", String.class, instanceId))
+ .isEqualTo("coordination");
+ assertThat(data.queryMaster("SELECT flow_code FROM approval_instance WHERE id = ?", String.class, instanceId))
+ .isEqualTo("coordination_internal");
+ assertThat(data.queryMaster("SELECT reporter_id FROM approval_instance WHERE id = ?", Integer.class, instanceId))
+ .isEqualTo(reporter.userId);
+ assertThat(data.queryMaster("SELECT reporter_name FROM approval_instance WHERE id = ?", String.class, instanceId))
+ .isEqualTo(reporter.username);
+ assertThat(data.queryMaster("SELECT report_org_id FROM approval_instance WHERE id = ?", Integer.class, instanceId))
+ .isEqualTo(reportOrgId);
+ assertThat(data.queryMaster("SELECT report_org_name FROM approval_instance WHERE id = ?", String.class, instanceId))
+ .isNotBlank();
+ // 受理部门在提交时才写入实例快照
+ assertThat(data.queryMaster("SELECT accept_org_id FROM approval_instance WHERE id = ?", Integer.class, instanceId))
+ .isNull();
+ assertThat(data.queryMaster("SELECT accept_org_id FROM approval_coordination_form WHERE instance_id = ?",
+ Integer.class, instanceId)).isEqualTo(acceptOrgId);
+ assertThat(data.queryMaster("SELECT title FROM approval_coordination_form WHERE instance_id = ?",
+ String.class, instanceId)).isEqualTo("设备借用协调");
+ assertThat(data.queryMaster("SELECT remark FROM approval_coordination_form WHERE instance_id = ?",
+ String.class, instanceId)).isEqualTo("活动结束后归还");
+
+ mockMvc.perform(authed(get("/api/approval/instance/" + instanceId), reporter, reportOrgId, LIST))
+ .andExpect(status().isOk())
+ .andExpect(jsonPath("$.code").value(0))
+ .andExpect(jsonPath("$.data.docType").value("coordination"))
+ .andExpect(jsonPath("$.data.docTypeName").value("内部协调单"))
+ .andExpect(jsonPath("$.data.statusName").value("未提交"))
+ .andExpect(jsonPath("$.data.title").value("设备借用协调"))
+ .andExpect(jsonPath("$.data.content").value("借用体育馆音响设备一天"))
+ .andExpect(jsonPath("$.data.remark").value("活动结束后归还"))
+ .andExpect(jsonPath("$.data.acceptOrgName").isNotEmpty())
+ .andExpect(jsonPath("$.data.permissions.canEdit").value(true))
+ .andExpect(jsonPath("$.data.bizNo").doesNotExist());
+
+ mockMvc.perform(authed(get("/api/approval/mine/page"), reporter, reportOrgId, LIST))
+ .andExpect(jsonPath("$.code").value(0))
+ .andExpect(jsonPath("$.data.list[0].id").value((int) instanceId))
+ .andExpect(jsonPath("$.data.list[0].docTypeName").value("内部协调单"))
+ .andExpect(jsonPath("$.data.list[0].title").value("设备借用协调"))
+ .andExpect(jsonPath("$.data.list[0].acceptOrgName").isNotEmpty())
+ .andExpect(jsonPath("$.data.list[0].statusName").value("未提交"));
+
+ // 流程定义按租户幂等,节点顺序固定;一期不产生外部协调流程
+ assertThat(data.countMaster("SELECT COUNT(*) FROM approval_flow_definition "
+ + "WHERE tenant_id = ? AND flow_code = 'coordination_internal' AND version = 1", TENANT)).isEqualTo(1);
+ String nodes = data.queryMaster("SELECT nodes_json FROM approval_flow_definition "
+ + "WHERE tenant_id = ? AND flow_code = 'coordination_internal' AND version = 1", String.class, TENANT);
+ assertThat(nodes).contains("reportLeader").contains("acceptLeader").contains("dispatch")
+ .contains("confirm").contains("archive");
+ assertThat(data.countMaster("SELECT COUNT(*) FROM approval_flow_definition "
+ + "WHERE tenant_id = ? AND flow_code = 'coordination_external'", TENANT)).isZero();
+ }
+
+ @Test
+ void updatesDraftOnlyForOwnerInEditableState() throws Exception {
+ long instanceId = saveCoordination(reporter, reportOrgId, acceptOrgId, "原标题", "原内容", null);
+ Integer otherAcceptOrg = data.createOrganization("备用受理部门" + data.uniqueSuffix(), 136);
+
+ mockMvc.perform(authed(post("/api/approval/instance/coordination"), reporter, reportOrgId, SUBMIT)
+ .contentType(MediaType.APPLICATION_JSON)
+ .content(body(otherAcceptOrg, "新标题", "新内容", "补充说明", instanceId)))
+ .andExpect(status().isOk())
+ .andExpect(jsonPath("$.code").value(0))
+ .andExpect(jsonPath("$.data").value((int) instanceId));
+
+ assertThat(data.queryMaster("SELECT title FROM approval_coordination_form WHERE instance_id = ?",
+ String.class, instanceId)).isEqualTo("新标题");
+ assertThat(data.queryMaster("SELECT accept_org_id FROM approval_coordination_form WHERE instance_id = ?",
+ Integer.class, instanceId)).isEqualTo(otherAcceptOrg);
+ assertThat(data.queryMaster("SELECT remark FROM approval_coordination_form WHERE instance_id = ?",
+ String.class, instanceId)).isEqualTo("补充说明");
+
+ ApprovalTestData.TestUser outsider = data.createUser("协调路人" + data.uniqueSuffix(), reportOrgId);
+ mockMvc.perform(authed(post("/api/approval/instance/coordination"), outsider, reportOrgId, SUBMIT)
+ .contentType(MediaType.APPLICATION_JSON)
+ .content(body(otherAcceptOrg, "越权标题", "内容", null, instanceId)))
+ .andExpect(jsonPath("$.code").value(1))
+ .andExpect(jsonPath("$.message").value(org.hamcrest.Matchers.containsString("无权操作该单据")));
+
+ // 提交后不可再修改
+ grantDepartmentLeader(reportOrgId, 1);
+ grantDepartmentLeader(otherAcceptOrg, 1);
+ grantArchivist();
+ uploadAttachment(instanceId, reporter, reportOrgId);
+ submitCoordination(instanceId, reporter, reportOrgId);
+
+ mockMvc.perform(authed(post("/api/approval/instance/coordination"), reporter, reportOrgId, SUBMIT)
+ .contentType(MediaType.APPLICATION_JSON)
+ .content(body(acceptOrgId, "提交后改标题", "内容", null, instanceId)))
+ .andExpect(jsonPath("$.code").value(1))
+ .andExpect(jsonPath("$.message").value(org.hamcrest.Matchers.containsString("不可修改")));
+ }
+
+ @Test
+ void validatesCoordinationFields() throws Exception {
+ expectSaveFailure(body(null, "标题", "内容", null, null), "请选择受理部门");
+ expectSaveFailure(body(acceptOrgId, "", "内容", null, null), "标题不能为空");
+ expectSaveFailure(body(acceptOrgId, repeat("标", 101), "内容", null, null), "标题不能超过100字");
+ expectSaveFailure(body(acceptOrgId, "标题", "", null, null), "内容不能为空");
+ expectSaveFailure(body(acceptOrgId, "标题", repeat("内", 2001), null, null), "内容不能超过2000字");
+ expectSaveFailure(body(acceptOrgId, "标题", "内容", repeat("备", 501), null), "备注不能超过500字");
+
+ Integer disabledOrg = data.createOrganization("停用受理部门" + data.uniqueSuffix(), 136);
+ data.disableOrganization(disabledOrg);
+ expectSaveFailure(body(disabledOrg, "标题", "内容", null, null), "受理部门不存在或已停用");
+
+ // 备注为可选字段,留空保存成功
+ long instanceId = saveCoordination(reporter, reportOrgId, acceptOrgId, "无备注", "内容", " ");
+ assertThat(data.queryMaster("SELECT remark FROM approval_coordination_form WHERE instance_id = ?",
+ String.class, instanceId)).isNull();
+ }
+
+ @Test
+ void rejectsSubmitWhenReportLeaderRoleMissing() throws Exception {
+ long instanceId = saveCoordination(reporter, reportOrgId, acceptOrgId, "缺报送负责人", "内容", null);
+ uploadAttachment(instanceId, reporter, reportOrgId);
+
+ mockMvc.perform(authed(post("/api/approval/instance/" + instanceId + "/submit"),
+ reporter, reportOrgId, SUBMIT))
+ .andExpect(status().isOk())
+ .andExpect(jsonPath("$.code").value(1))
+ .andExpect(jsonPath("$.message").value(org.hamcrest.Matchers.containsString("报送部门负责人角色缺配")));
+
+ assertThat(instanceStatus(instanceId)).isEqualTo("DRAFT");
+ assertThat(bizNo(instanceId)).isNull();
+ }
+
+ @Test
+ void rejectsSubmitWhenAcceptLeaderRoleMissing() throws Exception {
+ grantDepartmentLeader(reportOrgId, 1);
+ grantArchivist();
+ long instanceId = saveCoordination(reporter, reportOrgId, acceptOrgId, "缺受理负责人", "内容", null);
+ uploadAttachment(instanceId, reporter, reportOrgId);
+
+ mockMvc.perform(authed(post("/api/approval/instance/" + instanceId + "/submit"),
+ reporter, reportOrgId, SUBMIT))
+ .andExpect(jsonPath("$.code").value(1))
+ .andExpect(jsonPath("$.message").value(org.hamcrest.Matchers.containsString("受理部门负责人角色缺配")));
+
+ assertThat(instanceStatus(instanceId)).isEqualTo("DRAFT");
+ assertThat(bizNo(instanceId)).isNull();
+ }
+
+ @Test
+ void rejectsSubmitWhenAcceptOrganizationDisabled() throws Exception {
+ grantDepartmentLeader(reportOrgId, 1);
+ grantDepartmentLeader(acceptOrgId, 1);
+ grantArchivist();
+ long instanceId = saveCoordination(reporter, reportOrgId, acceptOrgId, "受理部门停用", "内容", null);
+ uploadAttachment(instanceId, reporter, reportOrgId);
+ data.disableOrganization(acceptOrgId);
+
+ mockMvc.perform(authed(post("/api/approval/instance/" + instanceId + "/submit"),
+ reporter, reportOrgId, SUBMIT))
+ .andExpect(jsonPath("$.code").value(1))
+ .andExpect(jsonPath("$.message").value(org.hamcrest.Matchers.containsString("受理部门已停用")));
+
+ assertThat(instanceStatus(instanceId)).isEqualTo("DRAFT");
+ assertThat(bizNo(instanceId)).isNull();
+ assertThat(data.queryMaster("SELECT accept_org_id FROM approval_instance WHERE id = ?",
+ Integer.class, instanceId)).isNull();
+ }
+
+ @Test
+ void submitsWithXtBizNoAndSnapshotsBothOrganizations() throws Exception {
+ ApprovalTestData.TestUser reportLeader = grantDepartmentLeader(reportOrgId, 1).get(0);
+ grantDepartmentLeader(acceptOrgId, 1);
+ grantArchivist();
+ long instanceId = saveCoordination(reporter, reportOrgId, acceptOrgId, "提交协调单", "协调内容", "备注内容");
+ uploadAttachment(instanceId, reporter, reportOrgId);
+
+ mockMvc.perform(authed(post("/api/approval/instance/" + instanceId + "/submit"),
+ reporter, reportOrgId, SUBMIT))
+ .andExpect(status().isOk())
+ .andExpect(jsonPath("$.code").value(0))
+ .andExpect(jsonPath("$.data.status").value("PENDING"))
+ .andExpect(jsonPath("$.data.statusName").value("审批中"))
+ .andExpect(jsonPath("$.data.docTypeName").value("内部协调单"))
+ .andExpect(jsonPath("$.data.currentNodeName").value("报送部门负责人"))
+ .andExpect(jsonPath("$.data.acceptOrgName").isNotEmpty())
+ .andExpect(jsonPath("$.data.attachments.length()").value(1))
+ .andExpect(jsonPath("$.data.records[0].action").value("SUBMIT"))
+ .andExpect(jsonPath("$.data.permissions.canEdit").value(false))
+ .andExpect(jsonPath("$.data.permissions.canSubmit").value(false));
+
+ String bizNo = bizNo(instanceId);
+ assertThat(bizNo).matches("XT\\d{8}-\\d{4}");
+ assertThat(bizNo.substring(2, 10)).isEqualTo(todayFromDatabase());
+
+ assertThat(data.queryMaster("SELECT round FROM approval_instance WHERE id = ?", Integer.class, instanceId))
+ .isEqualTo(1);
+ assertThat(data.queryMaster("SELECT submit_time FROM approval_instance WHERE id = ?", Object.class, instanceId))
+ .isNotNull();
+ assertThat(data.queryMaster("SELECT current_node_key FROM approval_instance WHERE id = ?",
+ String.class, instanceId)).isEqualTo("reportLeader");
+ assertThat(data.queryMaster("SELECT current_node_type FROM approval_instance WHERE id = ?",
+ String.class, instanceId)).isEqualTo("APPROVE");
+ assertThat(data.queryMaster("SELECT report_org_id FROM approval_instance WHERE id = ?",
+ Integer.class, instanceId)).isEqualTo(reportOrgId);
+ assertThat(data.queryMaster("SELECT accept_org_id FROM approval_instance WHERE id = ?",
+ Integer.class, instanceId)).isEqualTo(acceptOrgId);
+ assertThat(data.queryMaster("SELECT accept_org_name FROM approval_instance WHERE id = ?",
+ String.class, instanceId))
+ .isEqualTo(data.server().queryForObject(
+ "SELECT organization_name FROM sys_organization WHERE organization_id = ?",
+ String.class, acceptOrgId));
+
+ assertThat(pendingTaskIds(instanceId)).containsExactly(reportLeader.userId);
+ }
+
+ @Test
+ void approvesReportThenAcceptLeaderAndReachesDispatch() throws Exception {
+ ApprovalTestData.TestUser reportLeader = grantDepartmentLeader(reportOrgId, 1).get(0);
+ ApprovalTestData.TestUser acceptLeader = grantDepartmentLeader(acceptOrgId, 1).get(0);
+ grantArchivist();
+ ApprovalTestData.TestUser acceptMember = data.createUser("受理部门成员" + data.uniqueSuffix(), acceptOrgId);
+ long instanceId = saveCoordination(reporter, reportOrgId, acceptOrgId, "双节点审批", "内容", null);
+ uploadAttachment(instanceId, reporter, reportOrgId);
+ submitCoordination(instanceId, reporter, reportOrgId);
+
+ mockMvc.perform(authed(get("/api/approval/todo/page"), reportLeader, reportOrgId, LIST))
+ .andExpect(jsonPath("$.data.count").value(1))
+ .andExpect(jsonPath("$.data.list[0].currentNodeName").value("报送部门负责人"));
+
+ long reportTask = pendingTaskId(instanceId, reportLeader.userId);
+ mockMvc.perform(authed(post("/api/approval/task/" + reportTask + "/approve"),
+ reportLeader, reportOrgId, APPROVE)
+ .contentType(MediaType.APPLICATION_JSON)
+ .content("{\"opinion\":\"同意协调\"}"))
+ .andExpect(status().isOk())
+ .andExpect(jsonPath("$.code").value(0))
+ .andExpect(jsonPath("$.data.currentNodeName").value("受理部门负责人"))
+ .andExpect(jsonPath("$.data.myTask").doesNotExist());
+
+ assertThat(data.queryMaster("SELECT current_node_key FROM approval_instance WHERE id = ?",
+ String.class, instanceId)).isEqualTo("acceptLeader");
+ assertThat(pendingTaskIds(instanceId)).containsExactly(acceptLeader.userId);
+
+ mockMvc.perform(authed(get("/api/approval/todo/page"), acceptLeader, acceptOrgId, LIST))
+ .andExpect(jsonPath("$.data.count").value(1))
+ .andExpect(jsonPath("$.data.list[0].id").value((int) instanceId));
+
+ long acceptTask = pendingTaskId(instanceId, acceptLeader.userId);
+ mockMvc.perform(authed(post("/api/approval/task/" + acceptTask + "/approve"),
+ acceptLeader, acceptOrgId, APPROVE)
+ .contentType(MediaType.APPLICATION_JSON)
+ .content("{\"opinion\":\"可以协调\"}"))
+ .andExpect(jsonPath("$.code").value(0))
+ .andExpect(jsonPath("$.data.status").value("PENDING"))
+ .andExpect(jsonPath("$.data.currentNodeName").value("派单"));
+
+ assertThat(data.queryMaster("SELECT current_node_key FROM approval_instance WHERE id = ?",
+ String.class, instanceId)).isEqualTo("dispatch");
+ assertThat(data.queryMaster("SELECT current_node_type FROM approval_instance WHERE id = ?",
+ String.class, instanceId)).isEqualTo("DISPATCH");
+ assertThat(pendingTaskIds(instanceId)).contains(acceptMember.userId);
+ assertThat(data.countMaster("SELECT COUNT(*) FROM approval_record WHERE instance_id = ? "
+ + "AND action = 'APPROVE'", instanceId)).isEqualTo(2);
+ assertThat(data.queryMaster("SELECT opinion FROM approval_task WHERE id = ?", String.class, reportTask))
+ .isEqualTo("同意协调");
+ }
+
+ @Test
+ void allowsSamePersonToHandleBothApprovalNodes() throws Exception {
+ ApprovalTestData.TestUser both = data.createUser("双部门负责人" + data.uniqueSuffix(), reportOrgId);
+ ApprovalTestData.TestRole reportRole = data.createRole("dept_leader_" + reportOrgId, "部门负责人");
+ ApprovalTestData.TestRole acceptRole = data.createRole("dept_leader_" + acceptOrgId, "部门负责人");
+ data.bind(both.userId, reportRole.roleId);
+ data.bind(both.userId, acceptRole.roleId);
+ grantArchivist();
+ long instanceId = saveCoordination(reporter, reportOrgId, acceptOrgId, "同人连做", "内容", null);
+ uploadAttachment(instanceId, reporter, reportOrgId);
+ submitCoordination(instanceId, reporter, reportOrgId);
+
+ long firstTask = pendingTaskId(instanceId, both.userId);
+ mockMvc.perform(authed(post("/api/approval/task/" + firstTask + "/approve"), both, reportOrgId, APPROVE)
+ .contentType(MediaType.APPLICATION_JSON).content("{}"))
+ .andExpect(jsonPath("$.code").value(0))
+ .andExpect(jsonPath("$.data.currentNodeName").value("受理部门负责人"));
+
+ long secondTask = pendingTaskId(instanceId, both.userId);
+ assertThat(secondTask).isNotEqualTo(firstTask);
+ mockMvc.perform(authed(post("/api/approval/task/" + secondTask + "/approve"), both, reportOrgId, APPROVE)
+ .contentType(MediaType.APPLICATION_JSON).content("{}"))
+ .andExpect(jsonPath("$.code").value(0))
+ .andExpect(jsonPath("$.data.currentNodeName").value("派单"));
+
+ assertThat(data.queryMaster("SELECT current_node_key FROM approval_instance WHERE id = ?",
+ String.class, instanceId)).isEqualTo("dispatch");
+ assertThat(data.countMaster("SELECT COUNT(*) FROM approval_record WHERE instance_id = ? "
+ + "AND action = 'APPROVE' AND operator_id = ?", instanceId, both.userId)).isEqualTo(2);
+ }
+
+ @Test
+ void rejectsThenResubmitsKeepingBizNo() throws Exception {
+ ApprovalTestData.TestUser reportLeader = grantDepartmentLeader(reportOrgId, 1).get(0);
+ grantDepartmentLeader(acceptOrgId, 1);
+ grantArchivist();
+ long instanceId = saveCoordination(reporter, reportOrgId, acceptOrgId, "驳回重提协调", "内容", null);
+ uploadAttachment(instanceId, reporter, reportOrgId);
+ submitCoordination(instanceId, reporter, reportOrgId);
+ String bizNo = bizNo(instanceId);
+
+ long taskId = pendingTaskId(instanceId, reportLeader.userId);
+ mockMvc.perform(authed(post("/api/approval/task/" + taskId + "/reject"),
+ reportLeader, reportOrgId, APPROVE)
+ .contentType(MediaType.APPLICATION_JSON).content("{\"opinion\":\" \"}"))
+ .andExpect(jsonPath("$.code").value(1))
+ .andExpect(jsonPath("$.message").value(org.hamcrest.Matchers.containsString("驳回意见")));
+
+ mockMvc.perform(authed(post("/api/approval/task/" + taskId + "/reject"),
+ reportLeader, reportOrgId, APPROVE)
+ .contentType(MediaType.APPLICATION_JSON).content("{\"opinion\":\"协调范围不清\"}"))
+ .andExpect(jsonPath("$.code").value(0))
+ .andExpect(jsonPath("$.data.status").value("REJECTED"))
+ .andExpect(jsonPath("$.data.statusName").value("已驳回"))
+ .andExpect(jsonPath("$.data.round").value(2));
+
+ mockMvc.perform(authed(get("/api/approval/instance/" + instanceId), reporter, reportOrgId, LIST))
+ .andExpect(jsonPath("$.data.permissions.canEdit").value(true))
+ .andExpect(jsonPath("$.data.permissions.canSubmit").value(true))
+ .andExpect(jsonPath("$.data.bizNo").value(bizNo));
+
+ mockMvc.perform(authed(post("/api/approval/instance/" + instanceId + "/submit"),
+ reporter, reportOrgId, SUBMIT))
+ .andExpect(jsonPath("$.code").value(0))
+ .andExpect(jsonPath("$.data.status").value("PENDING"))
+ .andExpect(jsonPath("$.data.round").value(3))
+ .andExpect(jsonPath("$.data.bizNo").value(bizNo))
+ .andExpect(jsonPath("$.data.currentNodeName").value("报送部门负责人"));
+
+ assertThat(pendingTaskId(instanceId, reportLeader.userId)).isPositive();
+ assertThat(data.countMaster("SELECT COUNT(*) FROM approval_record WHERE instance_id = ? "
+ + "AND action = 'RESUBMIT' AND round = 3", instanceId)).isEqualTo(1);
+ assertThat(data.countMaster("SELECT COUNT(*) FROM approval_record WHERE instance_id = ? "
+ + "AND action = 'REJECT' AND opinion = '协调范围不清'", instanceId)).isEqualTo(1);
+ }
+
+ @Test
+ void withdrawsAndResubmitsBeforeAnyApproval() throws Exception {
+ grantDepartmentLeader(reportOrgId, 1);
+ grantDepartmentLeader(acceptOrgId, 1);
+ grantArchivist();
+ long instanceId = saveCoordination(reporter, reportOrgId, acceptOrgId, "撤回协调", "内容", null);
+ uploadAttachment(instanceId, reporter, reportOrgId);
+ submitCoordination(instanceId, reporter, reportOrgId);
+ String bizNo = bizNo(instanceId);
+
+ mockMvc.perform(authed(post("/api/approval/instance/" + instanceId + "/withdraw"),
+ reporter, reportOrgId, SUBMIT))
+ .andExpect(jsonPath("$.code").value(0))
+ .andExpect(jsonPath("$.data.status").value("WITHDRAWN"))
+ .andExpect(jsonPath("$.data.round").value(2))
+ .andExpect(jsonPath("$.data.bizNo").value(bizNo));
+
+ mockMvc.perform(authed(post("/api/approval/instance/" + instanceId + "/submit"),
+ reporter, reportOrgId, SUBMIT))
+ .andExpect(jsonPath("$.code").value(0))
+ .andExpect(jsonPath("$.data.status").value("PENDING"))
+ .andExpect(jsonPath("$.data.round").value(3))
+ .andExpect(jsonPath("$.data.bizNo").value(bizNo))
+ .andExpect(jsonPath("$.data.currentNodeName").value("报送部门负责人"));
+ }
+
+ @Test
+ void hidesDraftFromOutsider() throws Exception {
+ long instanceId = saveCoordination(reporter, reportOrgId, acceptOrgId, "草稿保密", "内容", null);
+ ApprovalTestData.TestUser outsider = data.createUser("协调外人" + data.uniqueSuffix(), reportOrgId);
+
+ mockMvc.perform(authed(get("/api/approval/instance/" + instanceId), outsider, reportOrgId, LIST))
+ .andExpect(jsonPath("$.code").value(1))
+ .andExpect(jsonPath("$.message").value(org.hamcrest.Matchers.containsString("无权查看该单据")));
+ }
+
+ // ---------- helpers ----------
+
+ private long saveCoordination(ApprovalTestData.TestUser user, Integer reportOrg, Integer acceptOrg,
+ String title, String content, String remark) throws Exception {
+ String response = mockMvc.perform(authed(post("/api/approval/instance/coordination"),
+ user, reportOrg, SUBMIT)
+ .contentType(MediaType.APPLICATION_JSON)
+ .content(body(acceptOrg, title, content, remark, null)))
+ .andExpect(status().isOk())
+ .andExpect(jsonPath("$.code").value(0))
+ .andReturn().getResponse().getContentAsString(StandardCharsets.UTF_8);
+ JsonNode node = objectMapper.readTree(response);
+ long id = node.path("data").asLong();
+ data.trackInstance(id);
+ return id;
+ }
+
+ private void expectSaveFailure(String body, String expectedMessage) throws Exception {
+ mockMvc.perform(authed(post("/api/approval/instance/coordination"), reporter, reportOrgId, SUBMIT)
+ .contentType(MediaType.APPLICATION_JSON)
+ .content(body))
+ .andExpect(status().isOk())
+ .andExpect(jsonPath("$.code").value(1))
+ .andExpect(jsonPath("$.message").value(org.hamcrest.Matchers.containsString(expectedMessage)));
+ }
+
+ private void submitCoordination(long instanceId, ApprovalTestData.TestUser user, Integer org) throws Exception {
+ mockMvc.perform(authed(post("/api/approval/instance/" + instanceId + "/submit"), user, org, SUBMIT))
+ .andExpect(status().isOk())
+ .andExpect(jsonPath("$.code").value(0));
+ }
+
+ private void uploadAttachment(long instanceId, ApprovalTestData.TestUser user, Integer org) throws Exception {
+ MockMultipartFile file = new MockMultipartFile("file", "sign.png", "image/png",
+ new byte[]{(byte) 0x89, 'P', 'N', 'G', 0x0D, 0x0A, 0x1A, 0x0A});
+ mockMvc.perform(multipart("/api/approval/attachment/upload")
+ .file(file)
+ .param("instanceId", String.valueOf(instanceId))
+ .param("category", "SIGNED")
+ .header("tenantId", TENANT)
+ .with(authentication(TestAuthentication.user(user.userId, user.username, org, TENANT, SUBMIT))))
+ .andExpect(status().isOk())
+ .andExpect(jsonPath("$.code").value(0));
+ }
+
+ private List grantDepartmentLeader(int orgId, int memberCount) {
+ ApprovalTestData.TestRole role = data.createRole("dept_leader_" + orgId, "部门负责人");
+ List members = new ArrayList<>();
+ for (int i = 0; i < memberCount; i++) {
+ ApprovalTestData.TestUser member = data.createUser("协调负责人" + data.uniqueSuffix(), orgId);
+ data.bind(member.userId, role.roleId);
+ members.add(member);
+ }
+ return members;
+ }
+
+ private void grantArchivist() {
+ ApprovalTestData.TestRole role = data.createRole("approval_archivist", "收文员");
+ ApprovalTestData.TestUser member = data.createUser("协调收文员" + data.uniqueSuffix(), reportOrgId);
+ data.bind(member.userId, role.roleId);
+ }
+
+ private long pendingTaskId(long instanceId, int userId) {
+ Long taskId = data.queryMaster("SELECT id FROM approval_task WHERE instance_id = ? "
+ + "AND assignee_id = ? AND status = 'PENDING' LIMIT 1", Long.class, instanceId, userId);
+ assertThat(taskId).as("待办任务应存在").isNotNull();
+ return taskId;
+ }
+
+ private List pendingTaskIds(long instanceId) {
+ return data.master().queryForList("SELECT assignee_id FROM approval_task "
+ + "WHERE instance_id = ? AND status = 'PENDING' ORDER BY id", Integer.class, instanceId);
+ }
+
+ private MockHttpServletRequestBuilder authed(MockHttpServletRequestBuilder builder,
+ ApprovalTestData.TestUser user, Integer org,
+ String... authorities) {
+ return builder.header("tenantId", TENANT)
+ .with(authentication(TestAuthentication.user(user.userId, user.username, org, TENANT, authorities)));
+ }
+
+ private String body(Integer acceptOrgId, String title, String content, String remark, Long id) {
+ StringBuilder sb = new StringBuilder("{");
+ if (id != null) {
+ sb.append("\"id\":").append(id).append(',');
+ }
+ sb.append("\"acceptOrgId\":").append(acceptOrgId == null ? "null" : acceptOrgId).append(',')
+ .append("\"title\":").append(quote(title)).append(',')
+ .append("\"content\":").append(quote(content)).append(',')
+ .append("\"remark\":").append(remark == null ? "null" : quote(remark))
+ .append('}');
+ return sb.toString();
+ }
+
+ private String quote(String value) {
+ return "\"" + value.replace("\\", "\\\\").replace("\"", "\\\"") + "\"";
+ }
+
+ private String repeat(String value, int count) {
+ return value.repeat(count);
+ }
+
+ private String instanceStatus(long instanceId) {
+ return data.queryMaster("SELECT status FROM approval_instance WHERE id = ?", String.class, instanceId);
+ }
+
+ private String bizNo(long instanceId) {
+ return data.queryMaster("SELECT biz_no FROM approval_instance WHERE id = ?", String.class, instanceId);
+ }
+
+ private String todayFromDatabase() {
+ return data.queryMaster("SELECT DATE_FORMAT(CURDATE(), '%Y%m%d')", String.class);
+ }
+}
From a336e207476d9b076cccd6a65a8be7cd960ebacf Mon Sep 17 00:00:00 2001
From: weicw <594098497@qq.com>
Date: Fri, 30 Oct 2026 21:09:05 +0800
Subject: [PATCH 09/18] =?UTF-8?q?feat(approval):=20=E5=AD=98=E6=A1=A3?=
=?UTF-8?q?=E7=A1=AE=E8=AE=A4=E4=B8=8E=E5=BD=92=E6=A1=A3=E9=97=AD=E7=8E=AF?=
=?UTF-8?q?=20(#21)?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
- 新增 POST /task/{id}/confirm(存档确认)与 /task/{id}/archive(归档)端点
- CONFIRM 全员完成后实例进入 APPROVED 并激活归档节点;缺配时停留审批中并提示
- ARCHIVE 或签,实例进入 ARCHIVED,记录归档人与时间,其余待办取消
- 归档后提交/撤回/编辑/附件/存档动作统一中文拒绝
- ApprovalConfirmArchiveIT 覆盖全员/部分存档、或签、只读、缺配阻断与全租户可见
---
.../approval/constant/ApprovalConstants.java | 7 +
.../controller/ApprovalTaskController.java | 19 +
.../service/ApprovalAttachmentService.java | 8 +-
.../service/ApprovalInstanceService.java | 2 +-
.../approval/service/ApprovalTaskService.java | 123 ++++++
.../approval/ApprovalConfirmArchiveIT.java | 367 ++++++++++++++++++
6 files changed, 522 insertions(+), 4 deletions(-)
create mode 100644 src/test/java/com/gxwebsoft/approval/ApprovalConfirmArchiveIT.java
diff --git a/src/main/java/com/gxwebsoft/approval/constant/ApprovalConstants.java b/src/main/java/com/gxwebsoft/approval/constant/ApprovalConstants.java
index 3ea09af..e27ff0e 100644
--- a/src/main/java/com/gxwebsoft/approval/constant/ApprovalConstants.java
+++ b/src/main/java/com/gxwebsoft/approval/constant/ApprovalConstants.java
@@ -163,6 +163,13 @@ public final class ApprovalConstants {
return CATEGORY_NAMES.getOrDefault(category, category);
}
+ /**
+ * 归档后单据为只读终态:任何写动作统一返回该提示。
+ */
+ public static String writeBlockedMessage(String status) {
+ return STATUS_ARCHIVED.equals(status) ? "单据已归档,不能修改" : "当前状态不可修改";
+ }
+
public static String departmentLeaderRoleCode(Integer organizationId) {
return organizationId == null ? null : "dept_leader_" + organizationId;
}
diff --git a/src/main/java/com/gxwebsoft/approval/controller/ApprovalTaskController.java b/src/main/java/com/gxwebsoft/approval/controller/ApprovalTaskController.java
index 58af23e..c062d8c 100644
--- a/src/main/java/com/gxwebsoft/approval/controller/ApprovalTaskController.java
+++ b/src/main/java/com/gxwebsoft/approval/controller/ApprovalTaskController.java
@@ -77,6 +77,25 @@ public class ApprovalTaskController extends BaseController {
return success("驳回成功", instanceService.detail(result.getInstanceId(), user));
}
+ @PreAuthorize("hasAuthority('approval:instance:approve')")
+ @ApiOperation("存档确认")
+ @PostMapping("/task/{id}/confirm")
+ public ApiResult confirm(@PathVariable("id") Long id) {
+ User user = getLoginUser();
+ ApprovalTaskService.Result result = taskService.confirm(id, user);
+ return success(result.getWarning() == null ? "存档成功" : result.getWarning(),
+ instanceService.detail(result.getInstanceId(), user));
+ }
+
+ @PreAuthorize("hasAuthority('approval:instance:archive')")
+ @ApiOperation("归档")
+ @PostMapping("/task/{id}/archive")
+ public ApiResult archive(@PathVariable("id") Long id) {
+ User user = getLoginUser();
+ ApprovalTaskService.Result result = taskService.archive(id, user);
+ return success("归档成功", instanceService.detail(result.getInstanceId(), user));
+ }
+
@PreAuthorize("hasAuthority('approval:instance:submit')")
@ApiOperation("撤回")
@PostMapping("/instance/{id}/withdraw")
diff --git a/src/main/java/com/gxwebsoft/approval/service/ApprovalAttachmentService.java b/src/main/java/com/gxwebsoft/approval/service/ApprovalAttachmentService.java
index 7a3dbfa..a48089f 100644
--- a/src/main/java/com/gxwebsoft/approval/service/ApprovalAttachmentService.java
+++ b/src/main/java/com/gxwebsoft/approval/service/ApprovalAttachmentService.java
@@ -162,9 +162,11 @@ public class ApprovalAttachmentService {
}
private void checkEditable(ApprovalInstance instance, User user) {
- if (user == null || !instance.getReporterId().equals(user.getUserId())
- || !ApprovalConstants.EDITABLE_STATUSES.contains(instance.getStatus())) {
- throw new BusinessException("当前状态不可修改");
+ if (user == null || !instance.getReporterId().equals(user.getUserId())) {
+ throw new BusinessException("无权操作该单据");
+ }
+ if (!ApprovalConstants.EDITABLE_STATUSES.contains(instance.getStatus())) {
+ throw new BusinessException(ApprovalConstants.writeBlockedMessage(instance.getStatus()));
}
}
diff --git a/src/main/java/com/gxwebsoft/approval/service/ApprovalInstanceService.java b/src/main/java/com/gxwebsoft/approval/service/ApprovalInstanceService.java
index d2b6af0..325a5be 100644
--- a/src/main/java/com/gxwebsoft/approval/service/ApprovalInstanceService.java
+++ b/src/main/java/com/gxwebsoft/approval/service/ApprovalInstanceService.java
@@ -305,7 +305,7 @@ public class ApprovalInstanceService {
throw new BusinessException("无权操作该单据");
}
if (!ApprovalConstants.EDITABLE_STATUSES.contains(instance.getStatus())) {
- throw new BusinessException("当前状态不可修改");
+ throw new BusinessException(ApprovalConstants.writeBlockedMessage(instance.getStatus()));
}
ApprovalCompletionForm form = completionForm(instance.getId());
if (form == null) {
diff --git a/src/main/java/com/gxwebsoft/approval/service/ApprovalTaskService.java b/src/main/java/com/gxwebsoft/approval/service/ApprovalTaskService.java
index 0cf213d..6bc98d5 100644
--- a/src/main/java/com/gxwebsoft/approval/service/ApprovalTaskService.java
+++ b/src/main/java/com/gxwebsoft/approval/service/ApprovalTaskService.java
@@ -75,6 +75,45 @@ public class ApprovalTaskService {
return new Result(instance.getId(), warning);
}
+ /**
+ * 存档确认:CONFIRM 任务只能确认(无意见),同节点同轮全部完成后实例进入审批完成,
+ * 并激活归档节点;归档节点缺配时实例停在审批中并提示管理员补配置。
+ */
+ public Result confirm(Long taskId, User user) {
+ ApprovalTask task = requireTask(taskId, user);
+ if (!ApprovalConstants.NODE_CONFIRM.equals(task.getNodeType())) {
+ throw new BusinessException("当前任务不支持存档确认");
+ }
+ ApprovalInstance instance = instanceMapper.selectById(task.getInstanceId());
+ if (instance == null) {
+ throw new BusinessException("单据不存在");
+ }
+ ensureNotArchived(instance);
+ ApprovalNode next = engineService.nextNode(instance, task.getNodeKey());
+ List approvers =
+ next == null ? Collections.emptyList() : resolveApprovers(instance, next);
+
+ String warning = inTransaction(() -> doConfirm(taskId, task, user, next, approvers));
+ return new Result(instance.getId(), warning);
+ }
+
+ /**
+ * 归档:ARCHIVE 任务或签,处理后取消同节点其余待办,实例进入只读终态。
+ */
+ public Result archive(Long taskId, User user) {
+ ApprovalTask task = requireTask(taskId, user);
+ if (!ApprovalConstants.NODE_ARCHIVE.equals(task.getNodeType())) {
+ throw new BusinessException("当前任务不支持归档");
+ }
+ ApprovalInstance instance = instanceMapper.selectById(task.getInstanceId());
+ if (instance == null) {
+ throw new BusinessException("单据不存在");
+ }
+ ensureNotArchived(instance);
+ inTransaction(() -> doArchive(taskId, task, user));
+ return new Result(instance.getId(), null);
+ }
+
/**
* 驳回:意见必填,同轮待办全部取消,实例回到报送人。
*/
@@ -101,6 +140,9 @@ public class ApprovalTaskService {
if (user == null || !instance.getReporterId().equals(user.getUserId())) {
throw new BusinessException("无权操作该单据");
}
+ if (ApprovalConstants.STATUS_ARCHIVED.equals(instance.getStatus())) {
+ throw new BusinessException("单据已归档,不能修改");
+ }
if (!ApprovalConstants.STATUS_PENDING.equals(instance.getStatus())) {
throw new BusinessException("当前状态不可撤回");
}
@@ -131,6 +173,67 @@ public class ApprovalTaskService {
return created == 0 ? "当前节点缺少审批人,请联系管理员补配置" : null;
}
+ private String doConfirm(Long taskId, ApprovalTask task, User user,
+ ApprovalNode next, List approvers) {
+ ApprovalInstance locked = engineService.lockInstance(task.getInstanceId());
+ ensureNotArchived(locked);
+ ensureTaskHandleable(task, locked);
+ if (updatePendingTask(taskId, ApprovalConstants.TASK_DONE, null) == 0) {
+ throw new BusinessException("任务已处理");
+ }
+ engineService.addRecord(locked, taskId, task.getNodeKey(), user,
+ ApprovalConstants.ACTION_CONFIRM, null);
+ Integer remaining = taskMapper.selectCount(new LambdaQueryWrapper()
+ .eq(ApprovalTask::getInstanceId, locked.getId())
+ .eq(ApprovalTask::getRound, locked.getRound())
+ .eq(ApprovalTask::getNodeKey, task.getNodeKey())
+ .eq(ApprovalTask::getStatus, ApprovalConstants.TASK_PENDING));
+ if (remaining != null && remaining > 0) {
+ return null;
+ }
+ if (next == null) {
+ return "当前节点缺少审批人,请联系管理员补配置";
+ }
+ int created = engineService.moveToNode(locked, next, locked.getRound(), approvers);
+ if (created == 0) {
+ return "当前节点缺少审批人,请联系管理员补配置";
+ }
+ int rows = instanceMapper.update(null, new UpdateWrapper()
+ .eq("id", locked.getId())
+ .eq("status", ApprovalConstants.STATUS_PENDING)
+ .set("status", ApprovalConstants.STATUS_APPROVED)
+ .set("approve_time", LocalDateTime.now()));
+ if (rows == 0) {
+ throw new BusinessException("单据状态已变化,请刷新后重试");
+ }
+ return null;
+ }
+
+ private void doArchive(Long taskId, ApprovalTask task, User user) {
+ ApprovalInstance locked = engineService.lockInstance(task.getInstanceId());
+ ensureNotArchived(locked);
+ if (!ApprovalConstants.STATUS_APPROVED.equals(locked.getStatus())) {
+ throw new BusinessException("单据状态已变化,请刷新后重试");
+ }
+ ensureTaskCurrent(task, locked);
+ if (updatePendingTask(taskId, ApprovalConstants.TASK_DONE, null) == 0) {
+ throw new BusinessException("任务已处理");
+ }
+ engineService.cancelPendingTasks(locked.getId(), locked.getRound(), taskId);
+ engineService.addRecord(locked, taskId, task.getNodeKey(), user,
+ ApprovalConstants.ACTION_ARCHIVE, null);
+ int rows = instanceMapper.update(null, new UpdateWrapper()
+ .eq("id", locked.getId())
+ .eq("status", ApprovalConstants.STATUS_APPROVED)
+ .set("status", ApprovalConstants.STATUS_ARCHIVED)
+ .set("archive_time", LocalDateTime.now())
+ .set("archivist_id", user.getUserId())
+ .set("archivist_name", displayName(user)));
+ if (rows == 0) {
+ throw new BusinessException("单据状态已变化,请刷新后重试");
+ }
+ }
+
private void doReject(Long taskId, ApprovalTask task, String opinion, User user) {
ApprovalInstance locked = engineService.lockInstance(task.getInstanceId());
ensureTaskHandleable(task, locked);
@@ -178,12 +281,22 @@ public class ApprovalTaskService {
if (!ApprovalConstants.STATUS_PENDING.equals(instance.getStatus())) {
throw new BusinessException("单据状态已变化,请刷新后重试");
}
+ ensureTaskCurrent(task, instance);
+ }
+
+ private void ensureTaskCurrent(ApprovalTask task, ApprovalInstance instance) {
if (!task.getRound().equals(instance.getRound())
|| !task.getNodeKey().equals(instance.getCurrentNodeKey())) {
throw new BusinessException("任务已处理");
}
}
+ private void ensureNotArchived(ApprovalInstance instance) {
+ if (ApprovalConstants.STATUS_ARCHIVED.equals(instance.getStatus())) {
+ throw new BusinessException("单据已归档,不能修改");
+ }
+ }
+
private void ensureNoHandledTask(ApprovalInstance instance) {
Integer handled = taskMapper.selectCount(new LambdaQueryWrapper()
.eq(ApprovalTask::getInstanceId, instance.getId())
@@ -288,6 +401,16 @@ public class ApprovalTaskService {
return user.getUsername();
}
+ private String displayName(User user) {
+ if (StrUtil.isNotBlank(user.getRealName())) {
+ return user.getRealName();
+ }
+ if (StrUtil.isNotBlank(user.getNickname())) {
+ return user.getNickname();
+ }
+ return user.getUsername();
+ }
+
private String normalizeOpinion(String opinion) {
if (opinion == null) {
return null;
diff --git a/src/test/java/com/gxwebsoft/approval/ApprovalConfirmArchiveIT.java b/src/test/java/com/gxwebsoft/approval/ApprovalConfirmArchiveIT.java
new file mode 100644
index 0000000..c89dc95
--- /dev/null
+++ b/src/test/java/com/gxwebsoft/approval/ApprovalConfirmArchiveIT.java
@@ -0,0 +1,367 @@
+package com.gxwebsoft.approval;
+
+import com.fasterxml.jackson.databind.ObjectMapper;
+import com.gxwebsoft.support.ApprovalTestData;
+import com.gxwebsoft.support.TestAuthentication;
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc;
+import org.springframework.boot.test.context.SpringBootTest;
+import org.springframework.http.MediaType;
+import org.springframework.mock.web.MockMultipartFile;
+import org.springframework.test.context.ActiveProfiles;
+import org.springframework.test.web.servlet.MockMvc;
+import org.springframework.test.web.servlet.request.MockHttpServletRequestBuilder;
+
+import javax.sql.DataSource;
+import java.nio.charset.StandardCharsets;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.authentication;
+import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.delete;
+import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
+import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.multipart;
+import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
+import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
+import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
+
+/**
+ * 存档确认与归档闭环集成测试:全员存档、部分存档、归档或签、归档后只读、
+ * 归档缺配阻断、收文员与 superAdmin 全租户可见。
+ */
+@SpringBootTest
+@AutoConfigureMockMvc
+@ActiveProfiles({"gxtyzx-test", "test"})
+class ApprovalConfirmArchiveIT {
+
+ /**
+ * 独立租户:收文员解析按 (tenant_id, role_code) 全局匹配,使用专属租户可避免
+ * 与并行执行的其它审批测试类互相污染角色成员。
+ */
+ private static final int TENANT = 10024;
+ private static final String LIST = "approval:instance:list";
+ private static final String SUBMIT = "approval:instance:submit";
+ private static final String APPROVE = "approval:instance:approve";
+ private static final String ARCHIVE = "approval:instance:archive";
+
+ @Autowired
+ private MockMvc mockMvc;
+ @Autowired
+ private ObjectMapper objectMapper;
+ @Autowired
+ private DataSource dataSource;
+
+ private ApprovalTestData data;
+ private ApprovalTestData.TestUser reporter;
+ private ApprovalTestData.TestUser leader;
+ private ApprovalTestData.TestUser archivist;
+ private ApprovalTestData.TestUser secondArchivist;
+ private ApprovalTestData.TestUser superAdmin;
+ private ApprovalTestData.TestUser outsider;
+ private Integer organizationId;
+ private Integer archivistRoleId;
+
+ @BeforeEach
+ void setUp() {
+ data = new ApprovalTestData(dataSource);
+ organizationId = data.createOrganization("归档部门" + data.uniqueSuffix(), 136, TENANT);
+ reporter = data.createUser("归档报送人" + data.uniqueSuffix(), organizationId, TENANT);
+ ApprovalTestData.TestRole leaderRole =
+ data.createRole("dept_leader_" + organizationId, "部门负责人", TENANT);
+ leader = data.createUser("归档负责人" + data.uniqueSuffix(), organizationId, TENANT);
+ data.bindTenant(leader.userId, leaderRole.roleId, TENANT);
+
+ ApprovalTestData.TestRole archivistRole = data.createRole("approval_archivist", "收文员", TENANT);
+ archivistRoleId = archivistRole.roleId;
+ archivist = data.createUser("归档收文员" + data.uniqueSuffix(), organizationId, TENANT);
+ secondArchivist = data.createUser("归档收文员B" + data.uniqueSuffix(), organizationId, TENANT);
+ data.bindTenant(archivist.userId, archivistRoleId, TENANT);
+ data.bindTenant(secondArchivist.userId, archivistRoleId, TENANT);
+
+ ApprovalTestData.TestRole superAdminRole = data.createRole("superAdmin", "超级管理员", TENANT);
+ superAdmin = data.createUser("归档超管" + data.uniqueSuffix(), organizationId, TENANT);
+ data.bindTenant(superAdmin.userId, superAdminRole.roleId, TENANT);
+
+ outsider = data.createUser("归档路人" + data.uniqueSuffix(), organizationId, TENANT);
+ }
+
+ @AfterEach
+ void tearDown() {
+ data.cleanup();
+ }
+
+ @Test
+ void confirmsOnlyAfterAllConfirmTasksDone() throws Exception {
+ long instanceId = createAndSubmit("全员存档");
+ approveByLeader(instanceId);
+ long reporterTask = pendingTaskId(instanceId, reporter.userId);
+
+ // 存档节点缺配前先验证:CONFIRM 任务不能驳回,也不需要意见
+ mockMvc.perform(authed(post("/api/approval/task/" + reporterTask + "/reject"),
+ reporter, organizationId, APPROVE)
+ .contentType(MediaType.APPLICATION_JSON)
+ .content("{\"opinion\":\"不同意\"}"))
+ .andExpect(status().isOk())
+ .andExpect(jsonPath("$.code").value(1))
+ .andExpect(jsonPath("$.message").value(org.hamcrest.Matchers.containsString("不支持驳回")));
+
+ // 追加一名并存档人(协调单场景:报送人 + 派单对象)
+ ApprovalTestData.TestUser secondConfirmer = data.createUser("归档存档人B" + data.uniqueSuffix(), organizationId);
+ long secondTask = insertConfirmTask(instanceId, secondConfirmer);
+
+ mockMvc.perform(authed(get("/api/approval/instance/" + instanceId), secondConfirmer, organizationId, LIST))
+ .andExpect(jsonPath("$.data.confirms.length()").value(2))
+ .andExpect(jsonPath("$.data.confirms[1].status").value("PENDING"))
+ .andExpect(jsonPath("$.data.confirms[1].statusName").value("待确认"))
+ .andExpect(jsonPath("$.data.permissions.canConfirm").value(true));
+
+ // 部分存档:仍等待其他人
+ mockMvc.perform(authed(post("/api/approval/task/" + reporterTask + "/confirm"), reporter, organizationId, APPROVE))
+ .andExpect(status().isOk())
+ .andExpect(jsonPath("$.code").value(0))
+ .andExpect(jsonPath("$.data.status").value("PENDING"))
+ .andExpect(jsonPath("$.data.currentNodeName").value("存档"))
+ .andExpect(jsonPath("$.data.confirms[0].status").value("DONE"))
+ .andExpect(jsonPath("$.data.confirms[0].statusName").value("已确认"))
+ .andExpect(jsonPath("$.data.confirms[0].handleTime").isNotEmpty())
+ .andExpect(jsonPath("$.data.confirms[1].status").value("PENDING"));
+ assertThat(data.queryMaster("SELECT approve_time FROM approval_instance WHERE id = ?",
+ Object.class, instanceId)).isNull();
+
+ // 全员存档完成 → APPROVED,并为全部收文员生成或签归档任务
+ mockMvc.perform(authed(post("/api/approval/task/" + secondTask + "/confirm"),
+ secondConfirmer, organizationId, APPROVE))
+ .andExpect(jsonPath("$.code").value(0))
+ .andExpect(jsonPath("$.data.status").value("APPROVED"))
+ .andExpect(jsonPath("$.data.statusName").value("审批完成"))
+ .andExpect(jsonPath("$.data.approveTime").isNotEmpty())
+ .andExpect(jsonPath("$.data.currentNodeName").value("归档"))
+ .andExpect(jsonPath("$.data.confirms[1].status").value("DONE"));
+ assertThat(data.countMaster("SELECT COUNT(*) FROM approval_task WHERE instance_id = ? "
+ + "AND node_type = 'ARCHIVE' AND status = 'PENDING'", instanceId)).isEqualTo(2);
+ assertThat(data.countMaster("SELECT COUNT(*) FROM approval_record WHERE instance_id = ? AND action = 'CONFIRM'",
+ instanceId)).isEqualTo(2);
+ // 存档记录不带意见
+ assertThat(data.countMaster("SELECT COUNT(*) FROM approval_record WHERE instance_id = ? "
+ + "AND action = 'CONFIRM' AND opinion IS NOT NULL", instanceId)).isZero();
+ }
+
+ @Test
+ void archivesOnceAndCancelsOtherArchiveTasks() throws Exception {
+ long instanceId = createAndSubmit("归档或签");
+ approveByLeader(instanceId);
+ confirmReporterTask(instanceId);
+
+ long firstArchive = pendingTaskId(instanceId, archivist.userId);
+ long secondArchive = pendingTaskId(instanceId, secondArchivist.userId);
+
+ mockMvc.perform(authed(post("/api/approval/task/" + firstArchive + "/archive"), archivist, organizationId, ARCHIVE))
+ .andExpect(status().isOk())
+ .andExpect(jsonPath("$.code").value(0))
+ .andExpect(jsonPath("$.data.status").value("ARCHIVED"))
+ .andExpect(jsonPath("$.data.statusName").value("已归档"))
+ .andExpect(jsonPath("$.data.archiveTime").isNotEmpty())
+ .andExpect(jsonPath("$.data.archivistId").value(archivist.userId))
+ .andExpect(jsonPath("$.data.archivistName").value(archivist.username));
+
+ assertThat(data.queryMaster("SELECT status FROM approval_task WHERE id = ?", String.class, secondArchive))
+ .isEqualTo("CANCELLED");
+ assertThat(data.queryMaster("SELECT handle_time FROM approval_task WHERE id = ?", Object.class, firstArchive))
+ .isNotNull();
+ assertThat(data.countMaster("SELECT COUNT(*) FROM approval_record WHERE instance_id = ? AND action = 'ARCHIVE'",
+ instanceId)).isEqualTo(1);
+
+ mockMvc.perform(authed(post("/api/approval/task/" + secondArchive + "/archive"),
+ secondArchivist, organizationId, ARCHIVE))
+ .andExpect(jsonPath("$.code").value(1))
+ .andExpect(jsonPath("$.message").value(org.hamcrest.Matchers.containsString("已归档")));
+ }
+
+ @Test
+ void rejectsEveryWriteAfterArchive() throws Exception {
+ long instanceId = createAndSubmit("归档只读");
+ long attachmentId = data.queryMaster("SELECT id FROM approval_attachment WHERE instance_id = ? LIMIT 1",
+ Long.class, instanceId);
+ approveByLeader(instanceId);
+ long confirmTask = pendingTaskId(instanceId, reporter.userId);
+ confirmReporterTask(instanceId);
+ long archiveTask = pendingTaskId(instanceId, archivist.userId);
+
+ mockMvc.perform(authed(post("/api/approval/task/" + archiveTask + "/archive"), archivist, organizationId, ARCHIVE))
+ .andExpect(jsonPath("$.code").value(0))
+ .andExpect(jsonPath("$.data.status").value("ARCHIVED"))
+ .andExpect(jsonPath("$.data.permissions.canEdit").value(false))
+ .andExpect(jsonPath("$.data.permissions.canSubmit").value(false))
+ .andExpect(jsonPath("$.data.permissions.canWithdraw").value(false))
+ .andExpect(jsonPath("$.data.permissions.canConfirm").value(false))
+ .andExpect(jsonPath("$.data.permissions.canArchive").value(false))
+ .andExpect(jsonPath("$.data.myTask").doesNotExist());
+
+ String archived = "单据已归档,不能修改";
+ mockMvc.perform(authed(post("/api/approval/instance/" + instanceId + "/submit"), reporter, organizationId, SUBMIT))
+ .andExpect(jsonPath("$.code").value(1))
+ .andExpect(jsonPath("$.message").value(archived));
+ mockMvc.perform(authed(post("/api/approval/instance/" + instanceId + "/withdraw"), reporter, organizationId, SUBMIT))
+ .andExpect(jsonPath("$.code").value(1))
+ .andExpect(jsonPath("$.message").value(archived));
+ mockMvc.perform(authed(post("/api/approval/instance/completion"), reporter, organizationId, SUBMIT)
+ .contentType(MediaType.APPLICATION_JSON)
+ .content("{\"id\":" + instanceId + ",\"name\":\"改归档单据\","
+ + "\"implementOrg\":\"广西体育中心实施部\",\"fee\":1.00,"
+ + "\"serviceContent\":\"尝试修改已归档单据\"}"))
+ .andExpect(jsonPath("$.code").value(1))
+ .andExpect(jsonPath("$.message").value(archived));
+ mockMvc.perform(authed(delete("/api/approval/attachment/" + attachmentId), reporter, organizationId, SUBMIT))
+ .andExpect(jsonPath("$.code").value(1))
+ .andExpect(jsonPath("$.message").value(archived));
+ mockMvc.perform(authed(post("/api/approval/task/" + confirmTask + "/confirm"), reporter, organizationId, APPROVE))
+ .andExpect(jsonPath("$.code").value(1))
+ .andExpect(jsonPath("$.message").value(archived));
+
+ assertThat(data.queryMaster("SELECT status FROM approval_instance WHERE id = ?", String.class, instanceId))
+ .isEqualTo("ARCHIVED");
+ }
+
+ @Test
+ void blocksWhenArchiveNodeHasNoApprover() throws Exception {
+ long instanceId = createAndSubmit("归档缺配");
+ approveByLeader(instanceId);
+
+ // 提交后收文员角色被清空,存档完成时归档节点缺配
+ data.server().update("DELETE FROM sys_user_role WHERE role_id = ?", archivistRoleId);
+
+ mockMvc.perform(authed(post("/api/approval/task/" + pendingTaskId(instanceId, reporter.userId) + "/confirm"),
+ reporter, organizationId, APPROVE))
+ .andExpect(status().isOk())
+ .andExpect(jsonPath("$.code").value(0))
+ .andExpect(jsonPath("$.message").value(org.hamcrest.Matchers.containsString("缺少审批人")))
+ .andExpect(jsonPath("$.data.status").value("PENDING"))
+ .andExpect(jsonPath("$.data.currentNodeName").value("归档"))
+ .andExpect(jsonPath("$.data.approveTime").doesNotExist());
+
+ assertThat(data.queryMaster("SELECT status FROM approval_instance WHERE id = ?", String.class, instanceId))
+ .isEqualTo("PENDING");
+ assertThat(data.queryMaster("SELECT current_node_key FROM approval_instance WHERE id = ?",
+ String.class, instanceId)).isEqualTo("archive");
+ assertThat(data.countMaster("SELECT COUNT(*) FROM approval_task WHERE instance_id = ? "
+ + "AND node_type = 'ARCHIVE'", instanceId)).isZero();
+ assertThat(data.countMaster("SELECT COUNT(*) FROM approval_record WHERE instance_id = ? AND action = 'CONFIRM'",
+ instanceId)).isEqualTo(1);
+ }
+
+ @Test
+ void letsArchivistAndSuperAdminViewAllTenantInstances() throws Exception {
+ long instanceId = createAndSubmit("全租户可见");
+ approveByLeader(instanceId);
+ confirmReporterTask(instanceId);
+ long archiveTask = pendingTaskId(instanceId, archivist.userId);
+ mockMvc.perform(authed(post("/api/approval/task/" + archiveTask + "/archive"), archivist, organizationId, ARCHIVE))
+ .andExpect(jsonPath("$.code").value(0));
+
+ // 收文员与 superAdmin 非参与人也可见完整详情(含归档人/时间与附件)
+ ApprovalTestData.TestUser otherArchivist =
+ data.createUser("归档外聘收文员" + data.uniqueSuffix(), organizationId, TENANT);
+ data.bindTenant(otherArchivist.userId, archivistRoleId, TENANT);
+ mockMvc.perform(authed(get("/api/approval/instance/" + instanceId), otherArchivist, organizationId, LIST))
+ .andExpect(jsonPath("$.code").value(0))
+ .andExpect(jsonPath("$.data.status").value("ARCHIVED"))
+ .andExpect(jsonPath("$.data.archivistName").value(archivist.username))
+ .andExpect(jsonPath("$.data.attachments.length()").value(1))
+ .andExpect(jsonPath("$.data.records.length()").isNotEmpty());
+ mockMvc.perform(authed(get("/api/approval/instance/" + instanceId), superAdmin, organizationId, LIST))
+ .andExpect(jsonPath("$.code").value(0))
+ .andExpect(jsonPath("$.data.id").value((int) instanceId));
+
+ // 普通参与人之外的用户仍被拒绝
+ mockMvc.perform(authed(get("/api/approval/instance/" + instanceId), outsider, organizationId, LIST))
+ .andExpect(jsonPath("$.code").value(1))
+ .andExpect(jsonPath("$.message").value(org.hamcrest.Matchers.containsString("无权查看该单据")));
+ // 参与人(报送人)可见
+ mockMvc.perform(authed(get("/api/approval/instance/" + instanceId), reporter, organizationId, LIST))
+ .andExpect(jsonPath("$.code").value(0));
+ }
+
+ // ---------- helpers ----------
+
+ private long createAndSubmit(String name) throws Exception {
+ String body = "{\"name\":" + quote(name)
+ + ",\"implementOrg\":\"广西体育中心实施部\",\"fee\":123.45,"
+ + "\"serviceContent\":\"完成场馆设施维修与验收\"}";
+ String response = mockMvc.perform(authed(post("/api/approval/instance/completion"),
+ reporter, organizationId, SUBMIT)
+ .contentType(MediaType.APPLICATION_JSON)
+ .content(body))
+ .andExpect(status().isOk())
+ .andExpect(jsonPath("$.code").value(0))
+ .andReturn().getResponse().getContentAsString(StandardCharsets.UTF_8);
+ long instanceId = objectMapper.readTree(response).path("data").asLong();
+ data.trackInstance(instanceId);
+
+ uploadAttachment(instanceId);
+ mockMvc.perform(authed(post("/api/approval/instance/" + instanceId + "/submit"),
+ reporter, organizationId, SUBMIT))
+ .andExpect(status().isOk())
+ .andExpect(jsonPath("$.code").value(0));
+ return instanceId;
+ }
+
+ private void approveByLeader(long instanceId) throws Exception {
+ mockMvc.perform(authed(post("/api/approval/task/" + pendingTaskId(instanceId, leader.userId) + "/approve"),
+ leader, organizationId, APPROVE)
+ .contentType(MediaType.APPLICATION_JSON)
+ .content("{\"opinion\":\"同意办理\"}"))
+ .andExpect(jsonPath("$.code").value(0));
+ }
+
+ private void confirmReporterTask(long instanceId) throws Exception {
+ mockMvc.perform(authed(post("/api/approval/task/" + pendingTaskId(instanceId, reporter.userId) + "/confirm"),
+ reporter, organizationId, APPROVE))
+ .andExpect(jsonPath("$.code").value(0))
+ .andExpect(jsonPath("$.data.status").value("APPROVED"));
+ }
+
+ private long insertConfirmTask(long instanceId, ApprovalTestData.TestUser assignee) {
+ Integer round = data.queryMaster("SELECT round FROM approval_instance WHERE id = ?", Integer.class, instanceId);
+ data.master().update("INSERT INTO approval_task (tenant_id, instance_id, node_key, node_type, round, "
+ + "task_type, assignee_id, assignee_name, status) VALUES (?, ?, 'confirm', 'CONFIRM', ?, 'CONFIRM', ?, ?, 'PENDING')",
+ TENANT, instanceId, round, assignee.userId, assignee.realName);
+ Long taskId = data.queryMaster("SELECT id FROM approval_task WHERE instance_id = ? AND assignee_id = ? "
+ + "AND status = 'PENDING'", Long.class, instanceId, assignee.userId);
+ assertThat(taskId).as("并存档任务应存在").isNotNull();
+ return taskId;
+ }
+
+ private void uploadAttachment(long instanceId) throws Exception {
+ MockMultipartFile file = new MockMultipartFile("file", "sign.png", "image/png",
+ new byte[]{(byte) 0x89, 'P', 'N', 'G', 0x0D, 0x0A, 0x1A, 0x0A});
+ mockMvc.perform(multipart("/api/approval/attachment/upload")
+ .file(file)
+ .param("instanceId", String.valueOf(instanceId))
+ .param("category", "SIGNED")
+ .header("tenantId", TENANT)
+ .with(authentication(TestAuthentication.user(reporter.userId, reporter.username,
+ organizationId, TENANT, SUBMIT))))
+ .andExpect(status().isOk())
+ .andExpect(jsonPath("$.code").value(0));
+ }
+
+ private long pendingTaskId(long instanceId, int userId) {
+ Long taskId = data.queryMaster("SELECT id FROM approval_task WHERE instance_id = ? "
+ + "AND assignee_id = ? AND status = 'PENDING' LIMIT 1", Long.class, instanceId, userId);
+ assertThat(taskId).as("待办任务应存在").isNotNull();
+ return taskId;
+ }
+
+ private MockHttpServletRequestBuilder authed(MockHttpServletRequestBuilder builder,
+ ApprovalTestData.TestUser user, Integer org,
+ String... authorities) {
+ return builder.header("tenantId", TENANT)
+ .with(authentication(TestAuthentication.user(user.userId, user.username, org, TENANT, authorities)));
+ }
+
+ private String quote(String value) {
+ return "\"" + value.replace("\\", "\\\\").replace("\"", "\\\"") + "\"";
+ }
+}
From 8224e6380a5e0b4b12c1880d052db96deaeb6a8f Mon Sep 17 00:00:00 2001
From: weicw <594098497@qq.com>
Date: Fri, 30 Oct 2026 21:11:51 +0800
Subject: [PATCH 10/18] =?UTF-8?q?test(approval):=20=E8=A6=86=E7=9B=96?=
=?UTF-8?q?=E5=8D=8F=E8=B0=83=E5=8D=95=E4=BC=AA=E9=80=A0=E6=8A=A5=E9=80=81?=
=?UTF-8?q?=E9=83=A8=E9=97=A8=E5=AD=97=E6=AE=B5=E8=A2=AB=E5=BF=BD=E7=95=A5?=
=?UTF-8?q?=20(#23)?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
.../approval/ApprovalCoordinationIT.java | 17 +++++++++++++++++
1 file changed, 17 insertions(+)
diff --git a/src/test/java/com/gxwebsoft/approval/ApprovalCoordinationIT.java b/src/test/java/com/gxwebsoft/approval/ApprovalCoordinationIT.java
index 4ffdf2a..867c788 100644
--- a/src/test/java/com/gxwebsoft/approval/ApprovalCoordinationIT.java
+++ b/src/test/java/com/gxwebsoft/approval/ApprovalCoordinationIT.java
@@ -129,6 +129,23 @@ class ApprovalCoordinationIT {
.contains("confirm").contains("archive");
assertThat(data.countMaster("SELECT COUNT(*) FROM approval_flow_definition "
+ "WHERE tenant_id = ? AND flow_code = 'coordination_external'", TENANT)).isZero();
+
+ // 报送部门与经办人只取登录态,客户端伪造字段被忽略
+ ApprovalTestData.TestUser other = data.createUser("他人" + data.uniqueSuffix(), acceptOrgId);
+ String forged = "{\"acceptOrgId\":" + acceptOrgId + ",\"title\":\"伪造部门\",\"content\":\"内容\","
+ + "\"reporterId\":" + other.userId + ",\"reportOrgId\":" + acceptOrgId + "}";
+ String forgedResponse = mockMvc.perform(authed(post("/api/approval/instance/coordination"),
+ reporter, reportOrgId, SUBMIT)
+ .contentType(MediaType.APPLICATION_JSON)
+ .content(forged))
+ .andExpect(jsonPath("$.code").value(0))
+ .andReturn().getResponse().getContentAsString(StandardCharsets.UTF_8);
+ long forgedId = objectMapper.readTree(forgedResponse).path("data").asLong();
+ data.trackInstance(forgedId);
+ assertThat(data.queryMaster("SELECT reporter_id FROM approval_instance WHERE id = ?",
+ Integer.class, forgedId)).isEqualTo(reporter.userId);
+ assertThat(data.queryMaster("SELECT report_org_id FROM approval_instance WHERE id = ?",
+ Integer.class, forgedId)).isEqualTo(reportOrgId);
}
@Test
From 2c1dc9b43c038b6e6a0be17d105d28dd92bf0d3a Mon Sep 17 00:00:00 2001
From: weicw <594098497@qq.com>
Date: Fri, 30 Oct 2026 21:21:10 +0800
Subject: [PATCH 11/18] =?UTF-8?q?feat(approval):=20=E5=8D=95=E6=8D=AE?=
=?UTF-8?q?=E6=9F=A5=E8=AF=A2=E5=AF=BC=E5=87=BA=20xlsx=20(#26)?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
.../controller/ApprovalExportController.java | 40 ++
.../approval/mapper/ApprovalExportMapper.java | 22 +
.../mapper/xml/ApprovalExportMapper.xml | 79 +++
.../service/ApprovalExportService.java | 186 +++++++
.../support/ApprovalExportResult.java | 20 +
.../approval/support/ApprovalExportRow.java | 45 ++
.../gxwebsoft/approval/ApprovalExportIT.java | 479 ++++++++++++++++++
7 files changed, 871 insertions(+)
create mode 100644 src/main/java/com/gxwebsoft/approval/controller/ApprovalExportController.java
create mode 100644 src/main/java/com/gxwebsoft/approval/mapper/ApprovalExportMapper.java
create mode 100644 src/main/java/com/gxwebsoft/approval/mapper/xml/ApprovalExportMapper.xml
create mode 100644 src/main/java/com/gxwebsoft/approval/service/ApprovalExportService.java
create mode 100644 src/main/java/com/gxwebsoft/approval/support/ApprovalExportResult.java
create mode 100644 src/main/java/com/gxwebsoft/approval/support/ApprovalExportRow.java
create mode 100644 src/test/java/com/gxwebsoft/approval/ApprovalExportIT.java
diff --git a/src/main/java/com/gxwebsoft/approval/controller/ApprovalExportController.java b/src/main/java/com/gxwebsoft/approval/controller/ApprovalExportController.java
new file mode 100644
index 0000000..89c94a4
--- /dev/null
+++ b/src/main/java/com/gxwebsoft/approval/controller/ApprovalExportController.java
@@ -0,0 +1,40 @@
+package com.gxwebsoft.approval.controller;
+
+import com.gxwebsoft.approval.param.ApprovalQueryParam;
+import com.gxwebsoft.approval.service.ApprovalExportService;
+import com.gxwebsoft.approval.support.ApprovalExportResult;
+import com.gxwebsoft.common.core.annotation.OperationLog;
+import com.gxwebsoft.common.core.web.ApiResult;
+import com.gxwebsoft.common.core.web.BaseController;
+import com.gxwebsoft.common.system.entity.FileRecord;
+import io.swagger.annotations.Api;
+import io.swagger.annotations.ApiOperation;
+import org.springframework.security.access.prepost.PreAuthorize;
+import org.springframework.web.bind.annotation.PostMapping;
+import org.springframework.web.bind.annotation.RequestBody;
+import org.springframework.web.bind.annotation.RequestMapping;
+import org.springframework.web.bind.annotation.RestController;
+
+import javax.annotation.Resource;
+import java.io.IOException;
+
+/**
+ * PC 单据导出接口:与单据查询共用筛选参数与数据权限。
+ */
+@Api(tags = "审批单据导出")
+@RestController
+@RequestMapping("/api/approval")
+public class ApprovalExportController extends BaseController {
+
+ @Resource
+ private ApprovalExportService exportService;
+
+ @PreAuthorize("hasAuthority('approval:instance:export')")
+ @OperationLog(module = "审批管理", comments = "导出单据查询结果")
+ @ApiOperation("导出单据")
+ @PostMapping("/export")
+ public ApiResult export(@RequestBody ApprovalQueryParam param) throws IOException {
+ ApprovalExportResult result = exportService.export(param, getLoginUser());
+ return success("导出成功,共" + result.getRowCount() + "条", result.getFile());
+ }
+}
diff --git a/src/main/java/com/gxwebsoft/approval/mapper/ApprovalExportMapper.java b/src/main/java/com/gxwebsoft/approval/mapper/ApprovalExportMapper.java
new file mode 100644
index 0000000..2663ca4
--- /dev/null
+++ b/src/main/java/com/gxwebsoft/approval/mapper/ApprovalExportMapper.java
@@ -0,0 +1,22 @@
+package com.gxwebsoft.approval.mapper;
+
+import com.gxwebsoft.approval.param.ApprovalQueryParam;
+import com.gxwebsoft.approval.support.ApprovalExportRow;
+import org.apache.ibatis.annotations.Param;
+
+import java.util.List;
+
+/**
+ * 导出查询Mapper:独立的非分页 SQL,绕过分页插件上限,最多取 5001 行。
+ *
+ * 筛选条件与数据权限必须与 {@code ApprovalQueryMapper.selectInstancePage} 保持一致。
+ */
+public interface ApprovalExportMapper {
+
+ /**
+ * 按筛选条件取导出数据,最多 5001 行(超出即代表需要拒绝导出)。
+ */
+ List selectExportRows(@Param("param") ApprovalQueryParam param,
+ @Param("userId") Integer userId,
+ @Param("allVisible") Integer allVisible);
+}
diff --git a/src/main/java/com/gxwebsoft/approval/mapper/xml/ApprovalExportMapper.xml b/src/main/java/com/gxwebsoft/approval/mapper/xml/ApprovalExportMapper.xml
new file mode 100644
index 0000000..837c511
--- /dev/null
+++ b/src/main/java/com/gxwebsoft/approval/mapper/xml/ApprovalExportMapper.xml
@@ -0,0 +1,79 @@
+
+
+
+
+
+
+
+
diff --git a/src/main/java/com/gxwebsoft/approval/service/ApprovalExportService.java b/src/main/java/com/gxwebsoft/approval/service/ApprovalExportService.java
new file mode 100644
index 0000000..4cbc29b
--- /dev/null
+++ b/src/main/java/com/gxwebsoft/approval/service/ApprovalExportService.java
@@ -0,0 +1,186 @@
+package com.gxwebsoft.approval.service;
+
+import com.gxwebsoft.approval.constant.ApprovalConstants;
+import com.gxwebsoft.approval.mapper.ApprovalExportMapper;
+import com.gxwebsoft.approval.param.ApprovalQueryParam;
+import com.gxwebsoft.approval.support.ApprovalExportResult;
+import com.gxwebsoft.approval.support.ApprovalExportRow;
+import com.gxwebsoft.approval.support.ApprovalNode;
+import com.gxwebsoft.common.core.config.ConfigProperties;
+import com.gxwebsoft.common.core.exception.BusinessException;
+import com.gxwebsoft.common.system.entity.FileRecord;
+import com.gxwebsoft.common.system.entity.User;
+import org.apache.poi.xssf.streaming.SXSSFRow;
+import org.apache.poi.xssf.streaming.SXSSFSheet;
+import org.apache.poi.xssf.streaming.SXSSFWorkbook;
+import org.springframework.stereotype.Service;
+
+import javax.annotation.Resource;
+import java.io.File;
+import java.io.IOException;
+import java.io.OutputStream;
+import java.math.BigDecimal;
+import java.math.RoundingMode;
+import java.nio.file.Files;
+import java.time.LocalDateTime;
+import java.time.format.DateTimeFormatter;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+
+/**
+ * 单据导出:按列表同一套筛选与数据权限同步生成 xlsx,并写文件记录。
+ *
+ * 独立查询一次取 5001 行判断是否超限,不经过分页插件的 2000 条上限;
+ * 超过 5000 行直接拒绝,不生成文件。导出内容不含附件。
+ */
+@Service
+public class ApprovalExportService {
+
+ /** 单次导出上限,超过即要求缩小筛选范围。 */
+ public static final int MAX_EXPORT_ROWS = 5000;
+
+ private static final DateTimeFormatter TIME_FORMAT = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss");
+
+ private static final String[] HEADERS = {
+ "单号", "单据类型", "标题或名称", "报送部门", "报送人", "受理部门", "实施单位", "费用(元)",
+ "服务内容或协调内容", "备注", "状态", "当前节点", "提交时间", "审批完成时间", "归档时间", "归档人"
+ };
+
+ /** 列宽(字符数),长文本列加宽但不截断内容。 */
+ private static final int[] COLUMN_WIDTHS = {
+ 20, 16, 28, 20, 12, 20, 22, 12, 50, 28, 12, 16, 20, 20, 20, 12
+ };
+
+ @Resource
+ private ApprovalExportMapper exportMapper;
+
+ @Resource
+ private ApprovalFlowService flowService;
+
+ @Resource
+ private ApprovalDirectoryService directoryService;
+
+ @Resource
+ private ApprovalFileService fileService;
+
+ @Resource
+ private ConfigProperties config;
+
+ /**
+ * 导出当前筛选结果。
+ *
+ * @throws BusinessException 结果超过 {@link #MAX_EXPORT_ROWS} 行时拒绝,不生成文件
+ */
+ public ApprovalExportResult export(ApprovalQueryParam param, User user) throws IOException {
+ int allVisible = directoryService.hasAnyRole(user.getUserId(),
+ ApprovalConstants.ROLE_SUPER_ADMIN, ApprovalConstants.ROLE_ARCHIVIST) ? 1 : 0;
+ List rows = exportMapper.selectExportRows(param, user.getUserId(), allVisible);
+ if (rows.size() > MAX_EXPORT_ROWS) {
+ throw new BusinessException("请缩小筛选范围");
+ }
+ String filename = "excel/审批单据-" + System.currentTimeMillis() + ".xlsx";
+ String path = config.getUploadPath() + filename;
+ File target = new File(path);
+ try {
+ File parent = target.getParentFile();
+ if (parent != null) {
+ Files.createDirectories(parent.toPath());
+ }
+ writeWorkbook(rows, target);
+ } catch (IOException | RuntimeException e) {
+ // 生成失败时不留下半成品文件
+ Files.deleteIfExists(target.toPath());
+ throw e;
+ }
+
+ FileRecord record = new FileRecord();
+ record.setName(filename);
+ record.setPath(path);
+ record.setLength(target.length());
+ record.setContentType("application/vnd.openxmlformats-officedocument.spreadsheetml.sheet");
+ record.setCreateUserId(user.getUserId());
+ record.setUrl(config.getServerUrl() + "/file/" + filename);
+ fileService.save(record);
+ return new ApprovalExportResult(record, rows.size());
+ }
+
+ private void writeWorkbook(List rows, File target) throws IOException {
+ SXSSFWorkbook workbook = new SXSSFWorkbook(100);
+ try {
+ SXSSFSheet sheet = workbook.createSheet("审批单据");
+ for (int i = 0; i < COLUMN_WIDTHS.length; i++) {
+ sheet.setColumnWidth(i, COLUMN_WIDTHS[i] * 256);
+ }
+ SXSSFRow header = sheet.createRow(0);
+ for (int i = 0; i < HEADERS.length; i++) {
+ header.createCell(i).setCellValue(HEADERS[i]);
+ }
+ Map> nodeCache = new HashMap<>();
+ int rowNum = 1;
+ for (ApprovalExportRow row : rows) {
+ SXSSFRow dataRow = sheet.createRow(rowNum++);
+ String[] values = toValues(row, nodeCache);
+ for (int i = 0; i < values.length; i++) {
+ dataRow.createCell(i).setCellValue(values[i]);
+ }
+ }
+ try (OutputStream output = Files.newOutputStream(target.toPath())) {
+ workbook.write(output);
+ }
+ } finally {
+ workbook.dispose();
+ workbook.close();
+ }
+ }
+
+ /**
+ * 固定列顺序:单号、单据类型、标题或名称、报送部门、报送人、受理部门、实施单位、费用(元)、
+ * 服务内容或协调内容、备注、状态、当前节点、提交时间、审批完成时间、归档时间、归档人。
+ */
+ private String[] toValues(ApprovalExportRow row, Map> nodeCache) {
+ boolean completion = ApprovalConstants.DOC_COMPLETION.equals(row.getDocType());
+ return new String[]{
+ text(row.getBizNo()),
+ text(ApprovalConstants.docTypeName(row.getDocType())),
+ completion ? text(row.getName()) : text(row.getTitle()),
+ text(row.getReportOrgName()),
+ text(row.getReporterName()),
+ text(row.getAcceptOrgName()),
+ completion ? text(row.getImplementOrg()) : "",
+ completion ? fee(row.getFee()) : "",
+ completion ? text(row.getServiceContent()) : text(row.getContent()),
+ text(row.getRemark()),
+ text(ApprovalConstants.statusName(row.getStatus())),
+ text(nodeName(nodeCache, row.getDefinitionId(), row.getCurrentNodeKey())),
+ time(row.getSubmitTime()),
+ time(row.getApproveTime()),
+ time(row.getArchiveTime()),
+ text(row.getArchivistName())
+ };
+ }
+
+ private String nodeName(Map> cache, Long definitionId, String nodeKey) {
+ if (definitionId == null || nodeKey == null) {
+ return null;
+ }
+ List nodes = cache.computeIfAbsent(definitionId, flowService::parseNodes);
+ return nodes.stream()
+ .filter(node -> nodeKey.equals(node.getKey()))
+ .map(ApprovalNode::getName)
+ .findFirst()
+ .orElse(null);
+ }
+
+ private String fee(BigDecimal fee) {
+ return fee == null ? "" : fee.setScale(2, RoundingMode.HALF_UP).toPlainString();
+ }
+
+ private String time(LocalDateTime time) {
+ return time == null ? "" : TIME_FORMAT.format(time);
+ }
+
+ private String text(String value) {
+ return value == null ? "" : value;
+ }
+}
diff --git a/src/main/java/com/gxwebsoft/approval/support/ApprovalExportResult.java b/src/main/java/com/gxwebsoft/approval/support/ApprovalExportResult.java
new file mode 100644
index 0000000..a63bccf
--- /dev/null
+++ b/src/main/java/com/gxwebsoft/approval/support/ApprovalExportResult.java
@@ -0,0 +1,20 @@
+package com.gxwebsoft.approval.support;
+
+import com.gxwebsoft.common.system.entity.FileRecord;
+import lombok.Data;
+
+/**
+ * 导出结果:返回给前端的文件记录 + 实际导出行数(用于提示与操作日志)。
+ */
+@Data
+public class ApprovalExportResult {
+
+ private final FileRecord file;
+
+ private final int rowCount;
+
+ public ApprovalExportResult(FileRecord file, int rowCount) {
+ this.file = file;
+ this.rowCount = rowCount;
+ }
+}
diff --git a/src/main/java/com/gxwebsoft/approval/support/ApprovalExportRow.java b/src/main/java/com/gxwebsoft/approval/support/ApprovalExportRow.java
new file mode 100644
index 0000000..0ad1409
--- /dev/null
+++ b/src/main/java/com/gxwebsoft/approval/support/ApprovalExportRow.java
@@ -0,0 +1,45 @@
+package com.gxwebsoft.approval.support;
+
+import lombok.Data;
+
+import java.io.Serializable;
+import java.math.BigDecimal;
+import java.time.LocalDateTime;
+
+/**
+ * 导出查询数据库行:两类单据合并列,未命中的表单字段为空。
+ */
+@Data
+public class ApprovalExportRow implements Serializable {
+ private static final long serialVersionUID = 1L;
+
+ private Long id;
+ private String bizNo;
+ private String docType;
+ private String status;
+ private String reporterName;
+ private String reportOrgName;
+ private String acceptOrgName;
+ private String currentNodeKey;
+ private Long definitionId;
+ private LocalDateTime submitTime;
+ private LocalDateTime approveTime;
+ private LocalDateTime archiveTime;
+ private String archivistName;
+
+ /** 业务完成确认单:名称。 */
+ private String name;
+ /** 业务完成确认单:实施单位。 */
+ private String implementOrg;
+ /** 业务完成确认单:费用(元)。 */
+ private BigDecimal fee;
+ /** 业务完成确认单:服务内容。 */
+ private String serviceContent;
+
+ /** 内部协调单:标题。 */
+ private String title;
+ /** 内部协调单:协调内容。 */
+ private String content;
+ /** 内部协调单:备注。 */
+ private String remark;
+}
diff --git a/src/test/java/com/gxwebsoft/approval/ApprovalExportIT.java b/src/test/java/com/gxwebsoft/approval/ApprovalExportIT.java
new file mode 100644
index 0000000..d29ddcc
--- /dev/null
+++ b/src/test/java/com/gxwebsoft/approval/ApprovalExportIT.java
@@ -0,0 +1,479 @@
+package com.gxwebsoft.approval;
+
+import com.fasterxml.jackson.databind.JsonNode;
+import com.fasterxml.jackson.databind.ObjectMapper;
+import com.gxwebsoft.approval.constant.ApprovalConstants;
+import com.gxwebsoft.common.core.config.ConfigProperties;
+import com.gxwebsoft.support.ApprovalTestData;
+import com.gxwebsoft.support.TestAuthentication;
+import org.apache.poi.ss.usermodel.Cell;
+import org.apache.poi.ss.usermodel.CellType;
+import org.apache.poi.ss.usermodel.Row;
+import org.apache.poi.ss.usermodel.Sheet;
+import org.apache.poi.xssf.usermodel.XSSFWorkbook;
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc;
+import org.springframework.boot.test.context.SpringBootTest;
+import org.springframework.http.MediaType;
+import org.springframework.test.context.ActiveProfiles;
+import org.springframework.test.web.servlet.MockMvc;
+import org.springframework.test.web.servlet.request.MockHttpServletRequestBuilder;
+
+import javax.sql.DataSource;
+import java.io.File;
+import java.io.FileInputStream;
+import java.nio.charset.StandardCharsets;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.nio.file.Paths;
+import java.sql.Timestamp;
+import java.time.LocalDateTime;
+import java.time.format.DateTimeFormatter;
+import java.util.ArrayList;
+import java.util.Comparator;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.authentication;
+import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
+import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
+import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
+
+/**
+ * 单据导出:权限、5000 行上限、列头与内容、数据权限与操作日志。
+ */
+@SpringBootTest
+@AutoConfigureMockMvc
+@ActiveProfiles({"gxtyzx-test", "test"})
+class ApprovalExportIT {
+
+ private static final int TENANT = TestAuthentication.TENANT_ID;
+ private static final String LIST = "approval:instance:list";
+ private static final String EXPORT = "approval:instance:export";
+ private static final String URL = "/api/approval/export";
+ private static final int EXPORT_LIMIT = 5000;
+ /** 多值 INSERT 每批行数,避免超出占位符上限。 */
+ private static final int SEED_CHUNK = 200;
+ private static final DateTimeFormatter TIME_FORMAT = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss");
+
+ private static final String[] HEADERS = {
+ "单号", "单据类型", "标题或名称", "报送部门", "报送人", "受理部门", "实施单位", "费用(元)",
+ "服务内容或协调内容", "备注", "状态", "当前节点", "提交时间", "审批完成时间", "归档时间", "归档人"
+ };
+
+ @Autowired
+ private MockMvc mockMvc;
+ @Autowired
+ private ObjectMapper objectMapper;
+ @Autowired
+ private DataSource dataSource;
+ @Autowired
+ private ConfigProperties config;
+
+ private ApprovalTestData data;
+ private Integer reportOrgId;
+ private Integer acceptOrgId;
+ private ApprovalTestData.TestUser reporter;
+ /** 本次用例创建的导出文件,结束时删除。 */
+ private final List exportedFiles = new ArrayList<>();
+ /** 本次用例批量种子的关键字,结束时按单号前缀清理。 */
+ private final List batchMarks = new ArrayList<>();
+ /** 本次用例创建的用户id,结束时清理其操作日志。 */
+ private final List testUserIds = new ArrayList<>();
+
+ @BeforeEach
+ void setUp() {
+ data = new ApprovalTestData(dataSource);
+ String suffix = data.uniqueSuffix();
+ reportOrgId = data.createOrganization("报送部门" + suffix, 136);
+ acceptOrgId = data.createOrganization("受理部门" + suffix, 136);
+ reporter = createUser("报送人" + suffix, reportOrgId);
+ }
+
+ @AfterEach
+ void tearDown() {
+ for (String mark : batchMarks) {
+ cleanBatch(mark);
+ }
+ batchMarks.clear();
+ for (Path path : exportedFiles) {
+ try {
+ Files.deleteIfExists(path);
+ } catch (Exception ignored) {
+ // 文件清理失败不影响断言结果
+ }
+ }
+ exportedFiles.clear();
+ try {
+ // 操作日志异步写入,稍等后再按测试用户清理
+ Thread.sleep(300);
+ for (Integer userId : testUserIds) {
+ data.server().update("DELETE FROM sys_operation_record WHERE url = ? AND user_id = ?", URL, userId);
+ }
+ } catch (Exception ignored) {
+ // 共享库清理尽力而为
+ }
+ testUserIds.clear();
+ data.cleanup();
+ }
+
+ @Test
+ void rejectsWithoutExportAuthority() throws Exception {
+ mockMvc.perform(authed(post(URL), reporter, LIST)
+ .contentType(MediaType.APPLICATION_JSON)
+ .content("{}"))
+ .andExpect(status().isOk())
+ .andExpect(jsonPath("$.code").value(403));
+ }
+
+ @Test
+ void rejectsWhenResultExceedsLimitAndWritesNoFile() throws Exception {
+ String mark = "超限" + data.uniqueSuffix();
+ seedCompletionBatch(mark, EXPORT_LIMIT + 1);
+ List before = listExportFiles();
+
+ mockMvc.perform(authed(post(URL), reporter, LIST, EXPORT)
+ .contentType(MediaType.APPLICATION_JSON)
+ .content("{\"keyword\":" + quote(mark) + "}"))
+ .andExpect(status().isOk())
+ .andExpect(jsonPath("$.code").value(1))
+ .andExpect(jsonPath("$.message").value(org.hamcrest.Matchers.containsString("请缩小筛选范围")));
+
+ assertThat(listExportFiles()).isEqualTo(before);
+ }
+
+ @Test
+ void exportsExactlyLimitRowsWithFixedColumnsAndFullText() throws Exception {
+ String mark = "上限" + data.uniqueSuffix();
+ String specialName = mark + "特殊名称";
+ String longContent = repeat("场馆设施维修与验收", 200);
+ Map special = new LinkedHashMap<>();
+ special.put("name", specialName);
+ special.put("implementOrg", mark + "实施单位");
+ special.put("fee", "1234.56");
+ special.put("serviceContent", longContent);
+ LocalDateTime submitTime = LocalDateTime.now().withNano(0).minusDays(1);
+ LocalDateTime approveTime = submitTime.plusHours(2);
+ LocalDateTime archiveTime = submitTime.plusDays(1);
+ seedCompletionBatch(mark, EXPORT_LIMIT, special, submitTime, approveTime, archiveTime);
+
+ String response = mockMvc.perform(authed(post(URL), reporter, LIST, EXPORT)
+ .contentType(MediaType.APPLICATION_JSON)
+ .content("{\"keyword\":" + quote(mark) + "}"))
+ .andExpect(status().isOk())
+ .andExpect(jsonPath("$.code").value(0))
+ .andReturn().getResponse().getContentAsString(StandardCharsets.UTF_8);
+ JsonNode node = objectMapper.readTree(response).path("data");
+ assertThat(node.path("id").asInt()).isPositive();
+ String name = node.path("name").asText();
+ assertThat(name).startsWith("excel/审批单据-").endsWith(".xlsx");
+ assertThat(node.path("url").asText()).contains("/file/excel/审批单据-");
+ File file = new File(node.path("path").asText());
+ assertThat(file).exists();
+ exportedFiles.add(file.toPath());
+ assertThat(data.countServer("SELECT COUNT(*) FROM sys_file_record WHERE id = ? AND name = ?",
+ node.path("id").asInt(), name)).isEqualTo(1);
+
+ try (XSSFWorkbook workbook = new XSSFWorkbook(new FileInputStream(file))) {
+ Sheet sheet = workbook.getSheetAt(0);
+ assertThat(sheet.getLastRowNum() + 1).isEqualTo(EXPORT_LIMIT + 1);
+ Row header = sheet.getRow(0);
+ List headerValues = new ArrayList<>();
+ for (int i = 0; i < HEADERS.length; i++) {
+ headerValues.add(cellText(header.getCell(i)));
+ }
+ assertThat(headerValues).containsExactly(HEADERS);
+
+ Row matched = null;
+ for (int i = 1; i <= sheet.getLastRowNum(); i++) {
+ Row row = sheet.getRow(i);
+ if (specialName.equals(cellText(row.getCell(2)))) {
+ matched = row;
+ break;
+ }
+ }
+ assertThat(matched).as("应导出特殊名称行").isNotNull();
+ assertThat(cellText(matched.getCell(0))).isEqualTo(mark + "-0");
+ assertThat(cellText(matched.getCell(1))).isEqualTo("业务完成确认单");
+ assertThat(cellText(matched.getCell(3))).isEqualTo(orgName(reportOrgId));
+ assertThat(cellText(matched.getCell(4))).isEqualTo(reporter.realName);
+ assertThat(cellText(matched.getCell(5))).isEqualTo(orgName(acceptOrgId));
+ assertThat(cellText(matched.getCell(6))).isEqualTo(mark + "实施单位");
+ assertThat(cellText(matched.getCell(7))).isEqualTo("1234.56");
+ assertThat(cellText(matched.getCell(8))).isEqualTo(longContent);
+ assertThat(cellText(matched.getCell(9))).isEmpty();
+ assertThat(cellText(matched.getCell(10))).isEqualTo("已归档");
+ assertThat(cellText(matched.getCell(11))).isEqualTo("报送部门负责人");
+ assertThat(cellText(matched.getCell(12))).isEqualTo(TIME_FORMAT.format(submitTime));
+ assertThat(cellText(matched.getCell(13))).isEqualTo(TIME_FORMAT.format(approveTime));
+ assertThat(cellText(matched.getCell(14))).isEqualTo(TIME_FORMAT.format(archiveTime));
+ assertThat(cellText(matched.getCell(15))).isEqualTo("收文人" + mark);
+ }
+ }
+
+ @Test
+ void appliesSameDataPermissionAsList() throws Exception {
+ String mark = "权限" + data.uniqueSuffix();
+ long draft = seedCompletion(mark + "草稿", "DRAFT", null);
+ long pending = seedCompletion(mark + "在途", "PENDING", LocalDateTime.now().minusHours(1));
+ ApprovalTestData.TestUser outsider = createUser("无关人员" + data.uniqueSuffix(), acceptOrgId);
+ ApprovalTestData.TestUser assignee = createUser("任务处理人" + data.uniqueSuffix(), acceptOrgId);
+ ApprovalTestData.TestUser archivist = createUser("收文员" + data.uniqueSuffix(), acceptOrgId);
+ data.master().update("INSERT INTO approval_task (tenant_id, instance_id, node_key, node_type, round, "
+ + "task_type, assignee_id, status) VALUES (?, ?, 'deptLeader', 'APPROVE', 1, 'APPROVE', ?, 'PENDING')",
+ TENANT, pending, assignee.userId);
+ ApprovalTestData.TestRole archivistRole = data.createRole(ApprovalConstants.ROLE_ARCHIVIST, "收文员");
+ data.bind(archivist.userId, archivistRole.roleId);
+
+ assertThat(exportBizNos(reporter, mark)).containsExactlyInAnyOrder(bizNoOf(draft), bizNoOf(pending));
+ assertThat(exportBizNos(assignee, mark)).containsExactly(bizNoOf(pending));
+ assertThat(exportBizNos(outsider, mark)).isEmpty();
+ assertThat(exportBizNos(archivist, mark)).containsExactly(bizNoOf(pending));
+ }
+
+ @Test
+ void writesOperationLogWithFilterAndRowCount() throws Exception {
+ String mark = "日志" + data.uniqueSuffix();
+ seedCompletion(mark + "一", "PENDING", LocalDateTime.now().minusHours(2));
+ seedCompletion(mark + "二", "PENDING", LocalDateTime.now().minusHours(1));
+
+ mockMvc.perform(authed(post(URL), reporter, LIST, EXPORT)
+ .contentType(MediaType.APPLICATION_JSON)
+ .content("{\"keyword\":" + quote(mark) + "}"))
+ .andExpect(status().isOk())
+ .andExpect(jsonPath("$.code").value(0))
+ .andExpect(jsonPath("$.message").value(org.hamcrest.Matchers.containsString("共2条")));
+
+ Map record = awaitOperationRecord(mark);
+ assertThat(record).as("应记录导出操作日志").isNotNull();
+ assertThat(record.get("user_id")).isEqualTo(reporter.userId);
+ assertThat((String) record.get("params")).contains(mark);
+ assertThat((String) record.get("result")).contains("共2条");
+ assertThat(record.get("create_time")).isNotNull();
+ assertThat(((Number) record.get("spend_time")).longValue()).isNotNegative();
+ }
+
+ // ---------- helpers ----------
+
+ private ApprovalTestData.TestUser createUser(String realName, Integer organizationId) {
+ ApprovalTestData.TestUser user = data.createUser(realName, organizationId);
+ testUserIds.add(user.userId);
+ return user;
+ }
+
+ private Map awaitOperationRecord(String paramsMark) throws InterruptedException {
+ long deadline = System.currentTimeMillis() + 3000;
+ while (System.currentTimeMillis() < deadline) {
+ List