会议纪要功能修复与改进

后端:
- 待办同步改走 ISysOaTaskService.insertByBo/updateByBo,新任务带操作日志和IM通知
- 任务状态映射修正:done→2执行完成,其余→0执行中(原 progress→1 会显示成"等待验收")
- 无负责人/无内容的待办仅作纪要记录,不再生成无主任务
- 更新时可清空字段改用显式 set(原来解绑项目、清空内容不生效)
- 新增接口返回纪要ID,前端据此进入编辑态,避免重复保存生成多条
- 会议编号加3位随机数防同秒撞唯一键;异常改 ServiceException;同步失败记日志
- enrich 为待办条目注入 assigneeName,列表/详情/导出可显示负责人姓名
- SysOaTaskServiceImpl.insertByBo 回填 taskId 供调用方关联

前端:
- 主持人/待办负责人改用人员单选弹窗(原多选组件取首位的方式易误操作)
- 会议类型、待办状态接入 sys_dict 字典(oa_meeting_type / oa_meeting_task_status)
- 新建保存后切换为编辑态;默认日期用本地时区(原 UTC 凌晨会差一天)
- 导出/打印带主持人、参会人、待办负责人姓名(原来只有用户ID)
- 删除已同步待办时提示任务不会被删除

SQL(已直接应用到生产库):
- 字典数据补全并修复 dict_id=0 脏数据(sys_dict_* 主键为雪花ID须显式指定)
- 菜单 2063809716454174722 icon 修为 documentation,授权10个角色
- 脚本改为幂等,去掉 DROP TABLE,del_flag 注释修正为逻辑删除值2

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-06-11 10:12:48 +08:00
parent e5bfa0c78c
commit a9c9b8a5ea
5 changed files with 330 additions and 174 deletions

View File

@@ -39,11 +39,17 @@ public class OaMeetingMinutesController extends BaseController {
return R.ok(service.queryById(id));
}
/**
* 新增返回新纪要ID前端据此切换为编辑态避免重复保存生成多条
*/
@Log(title = "会议纪要", businessType = BusinessType.INSERT)
@RepeatSubmit
@PostMapping
public R<Void> add(@RequestBody OaMeetingMinutesBo bo) {
return toAjax(service.insertByBo(bo));
public R<Long> add(@RequestBody OaMeetingMinutesBo bo) {
if (!service.insertByBo(bo)) {
return R.fail("保存失败");
}
return R.ok(bo.getId());
}
@Log(title = "会议纪要", businessType = BusinessType.UPDATE)

View File

@@ -1,8 +1,10 @@
package com.ruoyi.oa.service.impl;
import cn.hutool.core.bean.BeanUtil;
import cn.hutool.core.util.RandomUtil;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper;
import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper;
import com.baomidou.mybatisplus.core.toolkit.Wrappers;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.fasterxml.jackson.databind.JsonNode;
@@ -12,19 +14,22 @@ import com.fasterxml.jackson.databind.node.ObjectNode;
import com.ruoyi.common.core.domain.PageQuery;
import com.ruoyi.common.core.domain.entity.SysUser;
import com.ruoyi.common.core.page.TableDataInfo;
import com.ruoyi.common.helper.LoginHelper;
import com.ruoyi.common.exception.ServiceException;
import com.ruoyi.common.utils.StringUtils;
import com.ruoyi.oa.domain.OaMeetingMinutes;
import com.ruoyi.oa.domain.SysOaProject;
import com.ruoyi.oa.domain.SysOaTask;
import com.ruoyi.oa.domain.bo.OaMeetingMinutesBo;
import com.ruoyi.oa.domain.bo.SysOaTaskBo;
import com.ruoyi.oa.domain.vo.OaMeetingMinutesVo;
import com.ruoyi.oa.mapper.OaMeetingMinutesMapper;
import com.ruoyi.oa.mapper.SysOaProjectMapper;
import com.ruoyi.oa.mapper.SysOaTaskMapper;
import com.ruoyi.oa.service.IOaMeetingMinutesService;
import com.ruoyi.oa.service.ISysOaTaskService;
import com.ruoyi.system.mapper.SysUserMapper;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@@ -32,6 +37,13 @@ import java.text.SimpleDateFormat;
import java.util.*;
import java.util.stream.Collectors;
/**
* 会议纪要
*
* 待办同步说明tasks_json 中"有负责人且有内容"的条目会通过 ISysOaTaskService
* 生成/更新 sys_oa_task复用任务模块的操作日志、IM 通知逻辑任务ID回写进 JSON。
*/
@Slf4j
@RequiredArgsConstructor
@Service
public class OaMeetingMinutesServiceImpl implements IOaMeetingMinutesService {
@@ -39,12 +51,20 @@ public class OaMeetingMinutesServiceImpl implements IOaMeetingMinutesService {
private final OaMeetingMinutesMapper baseMapper;
private final SysOaProjectMapper projectMapper;
private final SysOaTaskMapper taskMapper;
private final ISysOaTaskService taskService;
private final SysUserMapper userMapper;
private final ObjectMapper json = new ObjectMapper();
/** sys_oa_task.state0执行中 2执行完成1等待验收/15延期申请由任务模块流转会议页不使用 */
private static final Long TASK_STATE_DOING = 0L;
private static final Long TASK_STATE_DONE = 2L;
@Override
public OaMeetingMinutesVo queryById(Long id) {
OaMeetingMinutesVo vo = baseMapper.selectVoById(id);
if (vo == null) {
throw new ServiceException("会议纪要不存在或已删除");
}
enrich(Collections.singletonList(vo));
return vo;
}
@@ -72,7 +92,8 @@ public class OaMeetingMinutesServiceImpl implements IOaMeetingMinutesService {
if (StringUtils.isNotBlank(bo.getKeyword())) {
String kw = bo.getKeyword().trim();
lqw.and(w -> w.like(OaMeetingMinutes::getSubject, kw)
.or().like(OaMeetingMinutes::getLocation, kw));
.or().like(OaMeetingMinutes::getLocation, kw)
.or().like(OaMeetingMinutes::getMeetingCode, kw));
}
lqw.ge(bo.getDateFrom() != null, OaMeetingMinutes::getMeetingDate, bo.getDateFrom());
lqw.le(bo.getDateTo() != null, OaMeetingMinutes::getMeetingDate, bo.getDateTo());
@@ -82,16 +103,25 @@ public class OaMeetingMinutesServiceImpl implements IOaMeetingMinutesService {
return lqw;
}
/** 给列表填上 项目名/编号、主持人/参会人 昵称 */
/** 填充 项目名/编号、主持人/参会人/待办负责人 昵称 */
private void enrich(List<OaMeetingMinutesVo> list) {
if (list == null || list.isEmpty()) return;
Set<Long> projectIds = new HashSet<>();
Set<Long> userIds = new HashSet<>();
Map<Long, ArrayNode> taskNodes = new HashMap<>();
for (OaMeetingMinutesVo v : list) {
if (v == null) continue;
if (v.getProjectId() != null) projectIds.add(v.getProjectId());
if (v.getHostUserId() != null) userIds.add(v.getHostUserId());
for (Long uid : parseLongCsv(v.getAttendeeUserIds())) userIds.add(uid);
userIds.addAll(parseLongCsv(v.getAttendeeUserIds()));
ArrayNode arr = parseTaskArray(v.getTasksJson());
if (arr != null) {
taskNodes.put(v.getId(), arr);
for (JsonNode n : arr) {
Long uid = longOf(n, "assigneeUserId");
if (uid != null) userIds.add(uid);
}
}
}
Map<Long, SysOaProject> pMap = projectIds.isEmpty() ? Collections.emptyMap()
: projectMapper.selectList(new QueryWrapper<SysOaProject>().in("project_id", projectIds))
@@ -113,14 +143,26 @@ public class OaMeetingMinutesServiceImpl implements IOaMeetingMinutesService {
SysUser u = uMap.get(v.getHostUserId());
if (u != null) v.setHostUserName(u.getNickName());
}
List<Long> attIds = parseLongCsv(v.getAttendeeUserIds());
if (!attIds.isEmpty()) {
List<String> names = new ArrayList<>();
for (Long uid : attIds) {
SysUser u = uMap.get(uid);
if (u != null) names.add(u.getNickName());
List<String> names = new ArrayList<>();
for (Long uid : parseLongCsv(v.getAttendeeUserIds())) {
SysUser u = uMap.get(uid);
if (u != null) names.add(u.getNickName());
}
if (!names.isEmpty()) v.setAttendeeUserNames(String.join(",", names));
// 待办条目写入 assigneeName前端展示/导出用
ArrayNode arr = taskNodes.get(v.getId());
if (arr != null) {
for (JsonNode n : arr) {
if (!n.isObject()) continue;
Long uid = longOf(n, "assigneeUserId");
SysUser u = uid == null ? null : uMap.get(uid);
((ObjectNode) n).put("assigneeName", u == null ? null : u.getNickName());
}
try {
v.setTasksJson(json.writeValueAsString(arr));
} catch (Exception e) {
log.warn("会议纪要[{}] tasksJson 序列化失败", v.getId(), e);
}
v.setAttendeeUserNames(String.join(",", names));
}
}
}
@@ -129,9 +171,10 @@ public class OaMeetingMinutesServiceImpl implements IOaMeetingMinutesService {
@Transactional(rollbackFor = Exception.class)
public Boolean insertByBo(OaMeetingMinutesBo bo) {
OaMeetingMinutes add = BeanUtil.toBean(bo, OaMeetingMinutes.class);
if (StringUtils.isBlank(add.getMeetingCode())) {
add.setMeetingCode("MT-" + new SimpleDateFormat("yyyyMMddHHmmss").format(new Date()));
}
add.setId(null);
// 时间戳+3位随机数避免同一秒并发保存撞唯一键
add.setMeetingCode("MT-" + new SimpleDateFormat("yyyyMMddHHmmss").format(new Date())
+ RandomUtil.randomNumbers(3));
if (StringUtils.isBlank(add.getMeetingType())) add.setMeetingType("other");
if (add.getSyncTask() == null) add.setSyncTask(1);
validBeforeSave(add);
@@ -141,8 +184,7 @@ public class OaMeetingMinutesServiceImpl implements IOaMeetingMinutesService {
if (Integer.valueOf(1).equals(add.getSyncTask())) {
String updated = syncTasks(add);
if (updated != null && !updated.equals(add.getTasksJson())) {
add.setTasksJson(updated);
baseMapper.updateById(add);
patchTasksJson(add.getId(), updated);
}
}
}
@@ -152,28 +194,50 @@ public class OaMeetingMinutesServiceImpl implements IOaMeetingMinutesService {
@Override
@Transactional(rollbackFor = Exception.class)
public Boolean updateByBo(OaMeetingMinutesBo bo) {
if (bo.getId() == null) throw new ServiceException("缺少纪要ID");
OaMeetingMinutes old = baseMapper.selectById(bo.getId());
if (old == null) throw new ServiceException("会议纪要不存在或已删除");
OaMeetingMinutes upd = BeanUtil.toBean(bo, OaMeetingMinutes.class);
if (StringUtils.isBlank(upd.getMeetingType())) upd.setMeetingType("other");
validBeforeSave(upd);
boolean ok = baseMapper.updateById(upd) > 0;
if (ok && Integer.valueOf(1).equals(upd.getSyncTask())) {
String updated = syncTasks(upd);
if (updated != null && !updated.equals(upd.getTasksJson())) {
upd.setTasksJson(updated);
baseMapper.updateById(upd);
}
if (Integer.valueOf(1).equals(upd.getSyncTask())) {
upd.setTasksJson(syncTasks(upd));
}
return ok;
// 可清空字段(解绑项目、清空人员/内容等)显式 set避免 MyBatis-Plus 忽略 null 导致清空不生效
LambdaUpdateWrapper<OaMeetingMinutes> luw = Wrappers.<OaMeetingMinutes>lambdaUpdate()
.set(OaMeetingMinutes::getProjectId, upd.getProjectId())
.set(OaMeetingMinutes::getHostUserId, upd.getHostUserId())
.set(OaMeetingMinutes::getAttendeeUserIds, upd.getAttendeeUserIds())
.set(OaMeetingMinutes::getLocation, upd.getLocation())
.set(OaMeetingMinutes::getTopic, upd.getTopic())
.set(OaMeetingMinutes::getDiscussion, upd.getDiscussion())
.set(OaMeetingMinutes::getDecision, upd.getDecision())
.set(OaMeetingMinutes::getTasksJson, upd.getTasksJson())
.eq(OaMeetingMinutes::getId, upd.getId());
OaMeetingMinutes entity = new OaMeetingMinutes();
entity.setMeetingDate(upd.getMeetingDate());
entity.setMeetingType(upd.getMeetingType());
entity.setSubject(upd.getSubject());
entity.setSyncTask(upd.getSyncTask());
return baseMapper.update(entity, luw) > 0;
}
private void patchTasksJson(Long id, String tasksJson) {
baseMapper.update(null, Wrappers.<OaMeetingMinutes>lambdaUpdate()
.set(OaMeetingMinutes::getTasksJson, tasksJson)
.eq(OaMeetingMinutes::getId, id));
}
private void validBeforeSave(OaMeetingMinutes e) {
if (e.getMeetingDate() == null) throw new IllegalArgumentException("请选择会议日期");
if (StringUtils.isBlank(e.getSubject())) throw new IllegalArgumentException("请输入会议主题");
if (e.getMeetingDate() == null) throw new ServiceException("请选择会议日期");
if (StringUtils.isBlank(e.getSubject())) throw new ServiceException("请输入会议主题");
}
/**
* 将 tasks_json 中的每个待办同步生成 sys_oa_task
* - 若条目已有 taskId 且仍存在 → 更新内容/截止/完成状态
* - 否则新建并把 taskId 写回 JSON
* 将待办同步 sys_oa_task(通过任务服务,带操作日志和 IM 通知)
* - 无内容或无负责人的条目仅作纪要记录,不生成任务
* - 已有 taskId 且任务仍存在 → 更新;否则新建并把 taskId 写回 JSON
* - 同步失败不阻塞纪要保存,仅记录日志
*/
private String syncTasks(OaMeetingMinutes meeting) {
if (StringUtils.isBlank(meeting.getTasksJson())) return meeting.getTasksJson();
@@ -181,61 +245,64 @@ public class OaMeetingMinutesServiceImpl implements IOaMeetingMinutesService {
JsonNode root = json.readTree(meeting.getTasksJson());
if (!root.isArray()) return meeting.getTasksJson();
ArrayNode arr = (ArrayNode) root;
Long currentUser = LoginHelper.getUserId();
for (int i = 0; i < arr.size(); i++) {
JsonNode n = arr.get(i);
for (JsonNode n : arr) {
if (!n.isObject()) continue;
ObjectNode o = (ObjectNode) n;
String content = textOf(o, "content");
if (StringUtils.isBlank(content)) continue;
Long assignee = longOf(o, "assigneeUserId");
String deadline = textOf(o, "deadline");
if (StringUtils.isBlank(content) || assignee == null) continue;
String status = textOf(o, "status");
boolean done = "done".equals(status);
Long existTaskId = longOf(o, "taskId");
SysOaTask exist = existTaskId == null ? null : taskMapper.selectById(existTaskId);
SysOaTask t = null;
if (existTaskId != null) {
t = taskMapper.selectById(existTaskId);
}
boolean isNew = (t == null);
if (isNew) {
t = new SysOaTask();
t.setCreateUserId(currentUser);
t.setBeginTime(meeting.getMeetingDate());
}
SysOaTaskBo t = new SysOaTaskBo();
t.setProjectId(meeting.getProjectId());
t.setTaskTitle(content.length() > 200 ? content.substring(0, 200) : content);
t.setContent("来自会议纪要" + meeting.getSubject());
t.setWorkerId(assignee);
if (StringUtils.isNotBlank(deadline)) {
try { t.setFinishTime(new SimpleDateFormat("yyyy-MM-dd").parse(deadline)); } catch (Exception ignored) {}
}
// 任务状态done=2 已完成progress=1 进行中pending=0 待办
if ("done".equals(status)) t.setState(2L);
else if ("progress".equals(status)) t.setState(1L);
else t.setState(0L);
if (isNew) {
taskMapper.insert(t);
o.put("taskId", t.getTaskId());
t.setTaskTitle(StringUtils.substring(content, 0, 200));
t.setContent("来自会议纪要" + meeting.getSubject() + "");
t.setFinishTime(parseDay(textOf(o, "deadline")));
t.setState(done ? TASK_STATE_DONE : TASK_STATE_DOING);
if (done) t.setCompletedTime(new Date());
if (exist == null) {
t.setBeginTime(meeting.getMeetingDate());
t.setWorkerIds(String.valueOf(assignee));
t.setStatus(0L);
taskService.insertByBo(t);
if (t.getTaskId() != null) o.put("taskId", t.getTaskId());
} else {
taskMapper.updateById(t);
t.setTaskId(existTaskId);
t.setWorkerId(assignee);
taskService.updateByBo(t);
}
}
return json.writeValueAsString(arr);
} catch (Exception e) {
// 同步失败不影响主流程,仅日志
log.warn("会议纪要[{}]待办同步OA任务失败", meeting.getMeetingCode(), e);
return meeting.getTasksJson();
}
}
private String textOf(ObjectNode n, String k) {
private Date parseDay(String s) {
if (StringUtils.isBlank(s)) return null;
try { return new SimpleDateFormat("yyyy-MM-dd").parse(s); }
catch (Exception e) { return null; }
}
private ArrayNode parseTaskArray(String s) {
if (StringUtils.isBlank(s)) return null;
try {
JsonNode root = json.readTree(s);
return root.isArray() ? (ArrayNode) root : null;
} catch (Exception e) { return null; }
}
private String textOf(JsonNode n, String k) {
JsonNode v = n.get(k);
return v == null || v.isNull() ? null : v.asText();
}
private Long longOf(ObjectNode n, String k) {
private Long longOf(JsonNode n, String k) {
JsonNode v = n.get(k);
if (v == null || v.isNull()) return null;
try { return v.isNumber() ? v.asLong() : Long.parseLong(v.asText()); }

View File

@@ -217,6 +217,8 @@ public class SysOaTaskServiceImpl implements ISysOaTaskService {
add.setOriginFinishTime(add.getFinishTime());
add.setWorkerId(workerId);
flag = baseMapper.insert(add) > 0;
// 回填任务ID供调用方如会议纪要待办同步建立关联
bo.setTaskId(add.getTaskId());
if (flag) {
operationLogService.recordLog(add.getProjectId(), 3, add.getTaskId(),
add.getTaskTitle(), 1, "新增任务: " + add.getTaskTitle(), null, null);