Files
flow-center-service/src/main/java/com/yunhai/flowcenter/service/FlowMapAdminService.java
T
chenxing 9cc0b06c29
build / build-push (push) Successful in 1m30s
feat(flow): 工作台与轨迹优化 + 管理端启用回路 + 本地验签配置修复
- 已办去重: 同一办件一行 + doneCount 累计次数(FlwTaskRepository 查询去重)
- 发起/撤回/重提留痕事件行(状态码 6/7, 无需 DDL); /oapi/flow/progress steps 含撤回/重提步骤
- 管理端新增启用回路: POST /admin/map/enable/{id}、POST /admin/def/enable/{id}; GET /api/flow/def/source 源码查询
- 修复 findEnabledByOrdId 未过滤 status 的隐性缺陷; 新增 DeptLabelSupport 科室标签支撑
- 测试: FlowEngineIT 撤回留痕/已办去重断言、OapiFlowContractIT 撤回步骤断言(94 tests 全过)
- 本地验签修复: 本地 UserBase 无 context-path, yh.open.services 显式覆盖服务目录枚举兜底的 /userbase basePath, 否则出站签名与服务端剥离不对齐 → GetUserInfo 等全部「验签失败」; 生产经 Caddy 挂 /userbase 时删除 services 覆盖
2026-09-18 16:56:01 +08:00

419 lines
18 KiB
Java
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package com.yunhai.flowcenter.service;
import com.yunhai.flowcenter.entity.FlwAssigneeMap;
import com.yunhai.flowcenter.repository.FlwAssigneeMapRepository;
import com.yunhai.platform.apistd.BizException;
import com.yunhai.platform.utils.exception.ErrorCode;
import com.yunhai.platform.utils.model.BoaUser;
import com.yunhai.platform.openapi.base.OpenApiGroupClient;
import com.yunhai.platform.openapi.base.OpenApiUserClient;
import com.yunhai.platform.utils.model.BoaGroup;
import lombok.Getter;
import lombok.RequiredArgsConstructor;
import lombok.Setter;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.util.ArrayList;
import java.util.Collections;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
/**
* 办理人映射管理(概览 §4.7/§六):DEPT_KEY → 办理人显式映射,成员检索经
* UserBase /oapi/user/byId 补全姓名快照(检索按 id 精确查;按名检索二期接 UserBase 模糊端点)。
* <p>
* 2026-09-16 新增「一键拉取教师组」:按 {@code dept-group-type}(默认 7=教师组,与 svc-hall
* {@code svchall.item.dept-group-type} 同口径)从 UserBase 学校组子树取科室容器,其子组即科室
* (容器无子组时容器本身即科室),deptKey=UserBase 组 id;成员=组内用户(不含子组)。
* 批量落 flw_assignee_map,替代管理端逐人录入。
*/
@Slf4j
@Service
@RequiredArgsConstructor
public class FlowMapAdminService {
private final FlwAssigneeMapRepository mapRepository;
private final OpenApiUserClient userClient;
private final OpenApiGroupClient groupClient;
/** 科室"容器组"类型(UserBase GroupTypeEnum,默认 7=教师组;口径承袭 svc-hall) */
@Value("${flow-center.map.dept-group-type:7}")
private int deptGroupType = 7;
/** 学生组(UserBase GroupTypeEnum.STUDENT_GROUP),选人组树中整棵剔除(口径同 Notice) */
private static final int GROUP_TYPE_STUDENT = 8;
public List<FlwAssigneeMap> listByDept(Long schId, String deptKey) {
if (deptKey == null || deptKey.trim().isEmpty()) {
// 全量(含停用行):管理端要能看到停用行才能点"启用"恢复
return mapRepository.findAllBySchId(schId);
}
return mapRepository.findBySchIdAndDeptKeyOrderByUserIdAsc(schId, deptKey.trim());
}
/** 新增/启用映射(uk: sch+dept+user;已存在则刷新姓名/状态) */
@Transactional
public FlwAssigneeMap save(Long schId, String deptKey, String deptName,
Integer userId, String userName) {
if (deptKey == null || deptKey.trim().isEmpty() || userId == null) {
throw new BizException(ErrorCode.PARAM_ERROR, "deptKey/userId 必填");
}
if (userName == null || userName.trim().isEmpty()) {
userName = fetchName(userId);
}
FlwAssigneeMap map = mapRepository.findBySchIdAndDeptKeyAndUserId(schId, deptKey.trim(), userId);
if (map == null) {
map = new FlwAssigneeMap();
map.setSchId(schId);
map.setDeptKey(deptKey.trim());
map.setUserId(userId);
}
map.setDeptName(deptName);
map.setUserName(userName);
map.setStatus(0);
return mapRepository.save(map);
}
/** 停用映射(保留历史,DEPT_KEY 解析只取 status=0) */
@Transactional
public void disable(Long schId, Long id) {
FlwAssigneeMap map = mapRepository.findById(id)
.orElseThrow(() -> new BizException(ErrorCode.PARAM_ERROR, "映射不存在:" + id));
if (!map.getSchId().equals(schId)) {
throw new BizException(ErrorCode.FORBIDDEN, "映射不属于该学校");
}
map.setStatus(1);
mapRepository.save(map);
}
/** 启用映射(2026-09-18 补齐:原实现只有停用,停用后只能重录一条 sch+dept+user 来恢复) */
@Transactional
public FlwAssigneeMap enable(Long schId, Long id) {
FlwAssigneeMap map = mapRepository.findById(id)
.orElseThrow(() -> new BizException(ErrorCode.PARAM_ERROR, "映射不存在:" + id));
if (!map.getSchId().equals(schId)) {
throw new BizException(ErrorCode.FORBIDDEN, "映射不属于该学校");
}
map.setStatus(0);
return mapRepository.save(map);
}
/** 成员检索:按用户 id 从 UserBase 取姓名快照(管理端录入校验) */
public BoaUser lookupUser(Integer userId) {
try {
return userClient.getUserById(String.valueOf(userId));
} catch (Exception e) {
throw new BizException(ErrorCode.PARAM_ERROR, "UserBase 用户检索失败(服务不可达)");
}
}
// ==================== 选人组件数据源 ====================
/** 选人组件树节点:组=isParent(true)+children;用户=叶子(isParent 缺省 false) */
@Getter
@Setter
public static class SelectUserNodeVo {
private Integer id;
private String name;
private Boolean isParent;
private List<SelectUserNodeVo> children = new ArrayList<>();
}
/**
* 学校组树(组→组内用户叶子),供管理端「选择用户」弹窗(多选指定用户/单选映射办理人)。
* 与 {@link #previewGroupDepts(Long)} 同源:UserBase getGroupById(schId, withSub) 子树 + 逐组挂成员。
*/
public List<SelectUserNodeVo> userTree(Long schId) {
BoaGroup subtree;
try {
subtree = groupClient.getGroupById(schId.intValue(), true);
} catch (Exception e) {
throw new BizException(ErrorCode.PARAM_ERROR, "UserBase 组树不可达:" + e.getMessage());
}
if (subtree == null || subtree.getId() == null) {
throw new BizException(ErrorCode.PARAM_ERROR, "学校组不存在(schId=" + schId + ")");
}
return Collections.singletonList(groupNode(subtree));
}
/** 组节点:先挂用户叶子再递归子组;isParent=UserBase 已宣告子组,防"空组"被当作用户叶子 */
private SelectUserNodeVo groupNode(BoaGroup group) {
SelectUserNodeVo vo = new SelectUserNodeVo();
vo.setId(group.getId());
vo.setName(group.getName());
boolean declaredSub = group.getChildren() != null && !group.getChildren().isEmpty();
try {
List<BoaUser> members = groupClient.getGroupUsers(group.getId(), false, null);
if (members != null) {
for (BoaUser u : members) {
if (u != null && u.getId() != null) {
SelectUserNodeVo leaf = new SelectUserNodeVo();
leaf.setId(u.getId());
leaf.setName(u.getName() == null ? "" : u.getName());
vo.getChildren().add(leaf);
}
}
}
} catch (Exception e) {
log.warn("[flow-center] 组成员拉取失败(groupId={}, name={}):{}", group.getId(), group.getName(), e.getMessage());
}
for (BoaGroup child : group.getChildren() == null ? Collections.<BoaGroup>emptyList() : group.getChildren()) {
if (child != null && child.getId() != null) {
vo.getChildren().add(groupNode(child));
}
}
vo.setIsParent(declaredSub || !vo.getChildren().isEmpty());
return vo;
}
/**
* 选人组件树·教师版(2026-09-17 仿 Notice GetSchoolAll 口径):组树只做骨架(剔除学生组),
* 成员来自 userclass(YschClassOpenApi.getSchTeaList)的教师列表,按教师所属 GroupIds 挂组。
* 组内 getGroupUsers 不再混入——旧实现会把管理员等非教师账号挂进树(选人数据不对的根因)。
* 空组(无教师)剪枝;教师归属组全被剔除时挂学校根节点。
*/
public List<SelectUserNodeVo> teacherTree(Long schId) {
BoaGroup subtree;
try {
subtree = groupClient.getGroupById(schId.intValue(), true);
} catch (Exception e) {
throw new BizException(ErrorCode.PARAM_ERROR, "UserBase 组树不可达:" + e.getMessage());
}
if (subtree == null || subtree.getId() == null) {
throw new BizException(ErrorCode.PARAM_ERROR, "学校组不存在(schId=" + schId + ")");
}
// 扁平化组(剔除学生组整棵),组节点先建壳 isParent=true
Map<Integer, SelectUserNodeVo> groups = new LinkedHashMap<>();
List<BoaGroup> flat = new ArrayList<>();
flattenGroups(subtree, flat);
for (BoaGroup g : flat) {
SelectUserNodeVo vo = new SelectUserNodeVo();
vo.setId(g.getId());
vo.setName(g.getName() == null ? "" : g.getName());
vo.setIsParent(true);
groups.put(g.getId(), vo);
}
// 教师挂组;同一 (教师, 组) 去重,归属组全不在树内时兜底挂根
try {
List<com.yunhai.platform.openapi.clazz.model.SchTea> teachers =
com.yunhai.platform.openapi.clazz.YschClassOpenApi.getSchTeaList(schId.intValue());
if (teachers != null) {
for (com.yunhai.platform.openapi.clazz.model.SchTea tea : teachers) {
if (tea == null || tea.getUserTeaSerId() == null) {
continue;
}
SelectUserNodeVo leaf = new SelectUserNodeVo();
leaf.setId(tea.getUserTeaSerId());
leaf.setName(tea.getTeaName() == null ? "" : tea.getTeaName());
List<Integer> gids = tea.getGroupIds();
if ((gids == null || gids.isEmpty()) && tea.getGroupId() != null) {
gids = Collections.singletonList(tea.getGroupId());
}
boolean attached = false;
if (gids != null) {
for (Integer gid : gids) {
SelectUserNodeVo g = groups.get(gid);
if (g != null) {
g.getChildren().add(leaf);
attached = true;
}
}
}
if (!attached) {
SelectUserNodeVo root = groups.get(schId.intValue());
if (root != null) {
root.getChildren().add(leaf);
}
}
}
}
} catch (Exception e) {
log.warn("[flow-center] 教师列表拉取失败(schId={}):{}", schId, e.getMessage());
}
// 建父子结构并剪空组(无教师后代的组不进树,根始终保留)
for (BoaGroup g : flat) {
for (BoaGroup child : g.getChildren() == null ? Collections.<BoaGroup>emptyList() : g.getChildren()) {
SelectUserNodeVo childVo = child == null ? null : groups.get(child.getId());
if (childVo != null && groups.containsKey(g.getId())) {
groups.get(g.getId()).getChildren().add(childVo);
}
}
}
SelectUserNodeVo root = groups.get(schId.intValue());
if (root == null) {
return new ArrayList<>();
}
pruneEmpty(root);
return Collections.singletonList(root);
}
/** 组子树扁平化;学生组(type=8)整棵剔除(含其后代) */
private void flattenGroups(BoaGroup group, List<BoaGroup> out) {
if (group == null || group.getId() == null) {
return;
}
if (group.getType() != null && group.getType() == GROUP_TYPE_STUDENT) {
return;
}
out.add(group);
for (BoaGroup child : group.getChildren() == null ? Collections.<BoaGroup>emptyList() : group.getChildren()) {
flattenGroups(child, out);
}
}
/** 剪掉无教师叶子后代(isParent 组但 children 全空)的组节点;返回该子树是否仍有内容 */
private boolean pruneEmpty(SelectUserNodeVo node) {
List<SelectUserNodeVo> kept = new ArrayList<>();
for (SelectUserNodeVo child : node.getChildren() == null ? new ArrayList<SelectUserNodeVo>() : node.getChildren()) {
if (child.getIsParent() != null && child.getIsParent()) {
if (pruneEmpty(child)) {
kept.add(child);
}
} else {
kept.add(child);
}
}
node.getChildren().clear();
node.getChildren().addAll(kept);
return Boolean.TRUE.equals(node.getIsParent()) && !node.getChildren().isEmpty();
}
// ==================== 一键拉取教师组 ====================
/** 科室预览/导入项(deptKey=UserBase 组 id) */
@Getter
@Setter
public static class GroupDeptVo {
private String deptKey;
private String deptName;
private List<MemberVo> users = new ArrayList<>();
}
@Getter
@Setter
public static class MemberVo {
private Integer userId;
private String userName;
public MemberVo() {
}
public MemberVo(Integer userId, String userName) {
this.userId = userId;
this.userName = userName;
}
}
/** 导入结果计数 */
@Getter
@Setter
public static class GroupImportResultVo {
private int depts;
private int users;
private int skippedExisting;
}
/**
* 预览:学校子树内教师组容器 → 科室列表(含组内成员)。
* UserBase 不可达/学校组不存在时抛业务异常(预览即校验,导入前先给前端看结果)。
*/
public List<GroupDeptVo> previewGroupDepts(Long schId) {
BoaGroup subtree;
try {
subtree = groupClient.getGroupById(schId.intValue(), true);
} catch (Exception e) {
throw new BizException(ErrorCode.PARAM_ERROR, "UserBase 组树不可达:" + e.getMessage());
}
if (subtree == null || subtree.getId() == null) {
throw new BizException(ErrorCode.PARAM_ERROR, "学校组不存在(schId=" + schId + ")");
}
List<BoaGroup> depts = new ArrayList<>();
collectDeptGroups(subtree, depts);
List<GroupDeptVo> out = new ArrayList<>();
for (BoaGroup dept : depts) {
GroupDeptVo vo = new GroupDeptVo();
vo.setDeptKey(String.valueOf(dept.getId()));
vo.setDeptName(dept.getName());
try {
List<BoaUser> members = groupClient.getGroupUsers(dept.getId(), false, null);
if (members != null) {
for (BoaUser u : members) {
if (u != null && u.getId() != null) {
vo.getUsers().add(new MemberVo(u.getId(),
u.getName() == null ? "" : u.getName()));
}
}
}
} catch (Exception e) {
log.warn("[flow-center] 组成员拉取失败(groupId={}, name={}):{}", dept.getId(), dept.getName(), e.getMessage());
}
out.add(vo);
}
return out;
}
/**
* 一键导入:按 {@link #previewGroupDepts} 的口径批量落映射;已存在(sch+dept+user 且启用中)
* 跳过不重复刷写。空成员的科室保留为无映射科室(流程编辑器可见提示,成员后续手工补)。
*/
@Transactional
public GroupImportResultVo importGroups(Long schId) {
List<GroupDeptVo> depts = previewGroupDepts(schId);
GroupImportResultVo result = new GroupImportResultVo();
for (GroupDeptVo dept : depts) {
for (MemberVo m : dept.getUsers()) {
FlwAssigneeMap existing = mapRepository.findBySchIdAndDeptKeyAndUserId(
schId, dept.getDeptKey(), m.getUserId());
if (existing != null && existing.getStatus() == 0) {
result.setSkippedExisting(result.getSkippedExisting() + 1);
continue;
}
save(schId, dept.getDeptKey(), dept.getDeptName(), m.getUserId(), m.getUserName());
result.setUsers(result.getUsers() + 1);
}
result.setDepts(result.getDepts() + 1);
}
return result;
}
/**
* 收集科室组:子树内 type=容器组类型的节点,其**子组**为科室(容器无子组时容器自身即科室)。
* 语义与 svc-hall ServiceItemService.buildDeptTree 逐字对齐(同一份学校组树两种消费)。
*/
private void collectDeptGroups(BoaGroup node, List<BoaGroup> out) {
if (node == null) {
return;
}
if (node.getType() != null && node.getType() == deptGroupType) {
if (node.getChildren() == null || node.getChildren().isEmpty()) {
out.add(node);
} else {
for (BoaGroup child : node.getChildren()) {
if (child != null && child.getId() != null) {
out.add(child);
}
}
}
return;
}
if (node.getChildren() != null) {
for (BoaGroup child : node.getChildren()) {
collectDeptGroups(child, out);
}
}
}
private String fetchName(Integer userId) {
try {
BoaUser user = userClient.getUserById(String.valueOf(userId));
return user == null ? "" : (user.getName() == null ? "" : user.getName());
} catch (Exception e) {
return "";
}
}
}