fix(工作台-工时、周报、日志管理):

- 修改工时周聚合逻辑,从仅统计周一到周五改为周一到周日
- 修复周末工时分配逻辑,周末工时不参与工作日均摊而是保留在周末
- 修复头像上传时旧头像删除逻辑,通过URL查找文件路径进行删除
- 添加加班工时统计功能,在团队工时周报表中显示批准的加班时间
- 优化未提交报告提醒功能,避免重复发送未读消息的提醒
- 修改周报告提醒的cron表达式,从每周五改为每周日执行
This commit is contained in:
dk
2026-07-14 11:39:45 +08:00
parent 5a2ca23217
commit 57061d4f03
10 changed files with 266 additions and 69 deletions

View File

@@ -31,6 +31,18 @@ public final class WorkReportConstants {
public static final String ACTION_APPROVE = "approve";
public static final String ACTION_REJECT = "reject";
/**
* 未提交提醒附加进消息模板参数的报告类型键。
* 仅供“未读不重发”判定使用,不参与模板正文渲染。
*/
public static final String PARAM_REMIND_REPORT_TYPE = "reportType";
/**
* 未提交提醒附加进消息模板参数的报告标识键。
* 统一按字符串存,避免 JSON 反序列化后的数字类型差异。
*/
public static final String PARAM_REMIND_REPORT_ID = "reportId";
public static final String PERMISSION_QUERY = "project:work-report:query";
public static final String PERMISSION_CREATE = "project:work-report:create";
public static final String PERMISSION_UPDATE = "project:work-report:update";

View File

@@ -39,7 +39,7 @@ public class MyWorkbenchController {
}
@GetMapping("/worklog-week")
@Operation(summary = "我的工时周聚合(逐日为均摊推算值;weekStart 任意日期自动归一到周一)")
@Operation(summary = "我的工时周聚合(返回周一~周日;按周填报只均摊到工作日,周末单天工时保留在周末)")
public CommonResult<MyWorklogWeekRespVO> getMyWorklogWeek(
@RequestParam("weekStart")
@DateTimeFormat(iso = DateTimeFormat.ISO.DATE) @NotNull(message = "weekStart 不能为空") LocalDate weekStart) {

View File

@@ -15,7 +15,7 @@ public class MyWorklogWeekRespVO {
@Schema(description = "所选周周一", requiredMode = Schema.RequiredMode.REQUIRED, example = "2026-06-08")
private LocalDate weekStart;
@Schema(description = "周一~周逐日工时(固定 5 元素,均摊推算,保留 2 位小数)", requiredMode = Schema.RequiredMode.REQUIRED)
@Schema(description = "周一~周逐日工时(固定 7 元素,按填报日期段推算,保留 2 位小数)", requiredMode = Schema.RequiredMode.REQUIRED)
private List<BigDecimal> dailyHours;
@Schema(description = "本周工时按归属分布(hours 降序,personal/other 排在项目后)", requiredMode = Schema.RequiredMode.REQUIRED)
private List<DistributionItemVO> distribution;

View File

@@ -5,28 +5,37 @@ import com.fasterxml.jackson.databind.ser.std.ToStringSerializer;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
import java.math.BigDecimal;
import java.time.LocalDate;
import java.util.List;
@Schema(description = "管理后台 - 工作台「团队工时周聚合」Response VO")
@Schema(description = "Admin - workbench team worklog week response")
@Data
public class TeamWorklogWeekRespVO {
@Schema(description = "所选周周一", requiredMode = Schema.RequiredMode.REQUIRED, example = "2026-06-08")
@Schema(description = "Week monday", requiredMode = Schema.RequiredMode.REQUIRED, example = "2026-06-08")
private LocalDate weekStart;
@Schema(description = "团队成员工时列表;members[0] 恒为当前用户;该周无填报的成员 items 为空数组", requiredMode = Schema.RequiredMode.REQUIRED)
@Schema(description = "Team members", requiredMode = Schema.RequiredMode.REQUIRED)
private List<MemberVO> members;
@Schema(description = "单个成员的周工时")
@Schema(description = "Single member week worklog")
@Data
public static class MemberVO {
@Schema(description = "用户编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "1001")
@Schema(description = "User id", requiredMode = Schema.RequiredMode.REQUIRED, example = "1001")
@JsonSerialize(using = ToStringSerializer.class)
private Long userId;
@Schema(description = "用户昵称", example = "张三")
@Schema(description = "User nickname", example = "zhangsan")
private String userNickname;
@Schema(description = "本周工时按归属分布", requiredMode = Schema.RequiredMode.REQUIRED)
@Schema(description = "Weekly worklog distribution", requiredMode = Schema.RequiredMode.REQUIRED)
private List<MyWorklogWeekRespVO.DistributionItemVO> items;
@Schema(description = "Approved overtime hours in selected week",
requiredMode = Schema.RequiredMode.REQUIRED, example = "9.00")
private BigDecimal overtimeHours;
}
}

View File

@@ -11,6 +11,7 @@ import org.springframework.util.StringUtils;
import java.util.Collection;
import java.util.List;
import java.time.LocalDate;
@Mapper
public interface OvertimeApplicationMapper extends BaseMapperX<OvertimeApplicationDO> {
@@ -53,6 +54,20 @@ public interface OvertimeApplicationMapper extends BaseMapperX<OvertimeApplicati
.eq(OvertimeApplicationDO::getApplicantId, applicantId));
}
default List<OvertimeApplicationDO> selectApprovedListByApplicantIdsAndDateRange(Collection<Long> applicantIds,
LocalDate startDate,
LocalDate endDate,
String approvedStatusCode) {
if (applicantIds == null || applicantIds.isEmpty() || startDate == null || endDate == null
|| !StringUtils.hasText(approvedStatusCode)) {
return List.of();
}
return selectList(new LambdaQueryWrapperX<OvertimeApplicationDO>()
.in(OvertimeApplicationDO::getApplicantId, applicantIds)
.eq(OvertimeApplicationDO::getStatusCode, approvedStatusCode)
.between(OvertimeApplicationDO::getOvertimeDate, startDate, endDate));
}
default int updateByIdAndStatus(OvertimeApplicationDO update, Long id, String fromStatus) {
return update(update, new LambdaQueryWrapperX<OvertimeApplicationDO>()
.eq(OvertimeApplicationDO::getId, id)

View File

@@ -1,18 +1,22 @@
package com.njcn.rdms.module.project.service.project;
import com.njcn.rdms.framework.security.core.util.SecurityFrameworkUtils;
import com.njcn.rdms.module.project.constant.OvertimeApplicationConstants;
import com.njcn.rdms.module.project.controller.admin.project.project.vo.workbench.MyWorklogWeekRespVO;
import com.njcn.rdms.module.project.controller.admin.project.project.vo.workbench.TeamWorklogWeekRespVO;
import com.njcn.rdms.module.project.dal.dataobject.overtime.OvertimeApplicationDO;
import com.njcn.rdms.module.project.dal.dataobject.personal.PersonalItemDO;
import com.njcn.rdms.module.project.dal.dataobject.project.ProjectDO;
import com.njcn.rdms.module.project.dal.dataobject.project.task.ProjectTaskDO;
import com.njcn.rdms.module.project.dal.dataobject.project.task.TaskWorklogDO;
import com.njcn.rdms.module.project.dal.mysql.overtime.OvertimeApplicationMapper;
import com.njcn.rdms.module.project.dal.mysql.personal.PersonalItemMapper;
import com.njcn.rdms.module.project.dal.mysql.project.ProjectMapper;
import com.njcn.rdms.module.project.dal.mysql.project.task.ProjectTaskMapper;
import com.njcn.rdms.module.project.dal.mysql.project.task.TaskWorklogMapper;
import jakarta.annotation.Resource;
import org.springframework.stereotype.Service;
import org.springframework.util.StringUtils;
import java.math.BigDecimal;
import java.math.RoundingMode;
@@ -31,6 +35,8 @@ import java.util.stream.Collectors;
@Service
public class MyWorklogServiceImpl implements MyWorklogService {
private static final BigDecimal OVERTIME_DAY_HOURS = new BigDecimal("6");
@Resource
private TaskWorklogMapper taskWorklogMapper;
@Resource
@@ -41,29 +47,29 @@ public class MyWorklogServiceImpl implements MyWorklogService {
private ProjectMapper projectMapper;
@Resource
private MyTeamService myTeamService;
@Resource
private OvertimeApplicationMapper overtimeApplicationMapper;
@Override
public MyWorklogWeekRespVO getMyWorklogWeek(LocalDate weekStart) {
Long me = SecurityFrameworkUtils.getLoginUserId();
LocalDate monday = MyWorklogWeekSupport.normalizeToMonday(weekStart);
// 查询区间含周末:周六/周日的段要兜底归周五,必须查回来
List<TaskWorklogDO> worklogs = taskWorklogMapper
.selectListByUserIdAndPeriod(me, monday, monday.plusDays(6));
// 逐日累计(scale=4)与每任务份额累计
Map<LocalDate, BigDecimal> daily = new LinkedHashMap<>();
Map<Long, BigDecimal> hoursByTask = new HashMap<>();
for (TaskWorklogDO w : worklogs) {
for (TaskWorklogDO worklog : worklogs) {
Map<LocalDate, BigDecimal> shares = MyWorklogWeekSupport
.apportionToWeek(w.getStartDate(), w.getEndDate(), w.getDurationHours(), monday);
for (Map.Entry<LocalDate, BigDecimal> e : shares.entrySet()) {
daily.merge(e.getKey(), e.getValue(), BigDecimal::add);
hoursByTask.merge(w.getTaskId(), e.getValue(), BigDecimal::add);
.apportionToWeek(worklog.getStartDate(), worklog.getEndDate(), worklog.getDurationHours(), monday);
for (Map.Entry<LocalDate, BigDecimal> entry : shares.entrySet()) {
daily.merge(entry.getKey(), entry.getValue(), BigDecimal::add);
hoursByTask.merge(worklog.getTaskId(), entry.getValue(), BigDecimal::add);
}
}
MyWorklogWeekRespVO resp = new MyWorklogWeekRespVO();
resp.setWeekStart(monday);
List<BigDecimal> dailyHours = new ArrayList<>(5);
for (int i = 0; i < 5; i++) {
List<BigDecimal> dailyHours = new ArrayList<>(7);
for (int i = 0; i < 7; i++) {
dailyHours.add(daily.getOrDefault(monday.plusDays(i), BigDecimal.ZERO)
.setScale(2, RoundingMode.HALF_UP));
}
@@ -75,40 +81,82 @@ public class MyWorklogServiceImpl implements MyWorklogService {
@Override
public TeamWorklogWeekRespVO getTeamWorklogWeek(LocalDate weekStart) {
LocalDate monday = MyWorklogWeekSupport.normalizeToMonday(weekStart);
LocalDate sunday = monday.plusDays(6);
List<MyTeamService.TeamMember> members = myTeamService.resolveTeamMembers();
List<Long> userIds = members.stream().map(MyTeamService.TeamMember::userId).collect(Collectors.toList());
List<TaskWorklogDO> worklogs = taskWorklogMapper
.selectListByUserIdsAndPeriod(userIds, monday, monday.plusDays(6));
// 每成员每任务的本周份额合计
List<TaskWorklogDO> worklogs = taskWorklogMapper.selectListByUserIdsAndPeriod(userIds, monday, sunday);
Map<Long, Map<Long, BigDecimal>> hoursByUserAndTask = new LinkedHashMap<>();
for (TaskWorklogDO w : worklogs) {
for (TaskWorklogDO worklog : worklogs) {
BigDecimal weekTotal = MyWorklogWeekSupport
.apportionToWeek(w.getStartDate(), w.getEndDate(), w.getDurationHours(), monday)
.apportionToWeek(worklog.getStartDate(), worklog.getEndDate(), worklog.getDurationHours(), monday)
.values().stream().reduce(BigDecimal.ZERO, BigDecimal::add);
if (weekTotal.signum() > 0) {
hoursByUserAndTask.computeIfAbsent(w.getUserId(), k -> new HashMap<>())
.merge(w.getTaskId(), weekTotal, BigDecimal::add);
hoursByUserAndTask.computeIfAbsent(worklog.getUserId(), key -> new HashMap<>())
.merge(worklog.getTaskId(), weekTotal, BigDecimal::add);
}
}
Map<Long, BigDecimal> overtimeHoursByUser = buildApprovedOvertimeHoursByUser(userIds, monday, sunday);
TeamWorklogWeekRespVO resp = new TeamWorklogWeekRespVO();
resp.setWeekStart(monday);
resp.setMembers(members.stream().map(m -> {
resp.setMembers(members.stream().map(member -> {
TeamWorklogWeekRespVO.MemberVO vo = new TeamWorklogWeekRespVO.MemberVO();
vo.setUserId(m.userId());
vo.setUserNickname(m.nickname());
vo.setUserId(member.userId());
vo.setUserNickname(member.nickname());
vo.setItems(buildDistribution(
hoursByUserAndTask.getOrDefault(m.userId(), Collections.emptyMap())));
hoursByUserAndTask.getOrDefault(member.userId(), Collections.emptyMap())));
vo.setOvertimeHours(overtimeHoursByUser.getOrDefault(member.userId(), BigDecimal.ZERO)
.setScale(2, RoundingMode.HALF_UP));
return vo;
}).collect(Collectors.toList()));
return resp;
}
/**
* 把 taskId→本周工时 聚成归属分布:工时表的 task_id 既可能指向任务也可能指向个人事项
* (个人事项工时复用 rdms_task_worklog),先按任务表归 project,再按个人事项表归 personal,
* 都对不上归 other。排序:project 行按 hours 降序,personal/other 殿后。
* 被软删的任务/事项的残留工时归 other(两表主键同为雪花 id 全局不撞,不存在误归类)。
*/
private Map<Long, BigDecimal> buildApprovedOvertimeHoursByUser(List<Long> userIds,
LocalDate startDate,
LocalDate endDate) {
if (userIds == null || userIds.isEmpty()) {
return Collections.emptyMap();
}
List<OvertimeApplicationDO> applications = overtimeApplicationMapper.selectApprovedListByApplicantIdsAndDateRange(
userIds, startDate, endDate, OvertimeApplicationConstants.STATUS_APPROVED);
if (applications.isEmpty()) {
return Collections.emptyMap();
}
Map<Long, BigDecimal> overtimeHoursByUser = new HashMap<>();
for (OvertimeApplicationDO application : applications) {
if (application == null || application.getApplicantId() == null) {
continue;
}
BigDecimal overtimeHours = parseOvertimeHours(application.getOvertimeDuration());
if (overtimeHours.signum() <= 0) {
continue;
}
overtimeHoursByUser.merge(application.getApplicantId(), overtimeHours, BigDecimal::add);
}
return overtimeHoursByUser;
}
private BigDecimal parseOvertimeHours(String overtimeDuration) {
if (!StringUtils.hasText(overtimeDuration)) {
return BigDecimal.ZERO;
}
String normalized = overtimeDuration.trim()
.replace("\u5929", "")
.replace("\u5c0f\u65f6", "")
.replace("h", "")
.trim();
if (!StringUtils.hasText(normalized)) {
return BigDecimal.ZERO;
}
try {
return new BigDecimal(normalized).multiply(OVERTIME_DAY_HOURS);
} catch (NumberFormatException ex) {
return BigDecimal.ZERO;
}
}
private List<MyWorklogWeekRespVO.DistributionItemVO> buildDistribution(Map<Long, BigDecimal> hoursByTask) {
if (hoursByTask.isEmpty()) {
return Collections.emptyList();
@@ -117,37 +165,41 @@ public class MyWorklogServiceImpl implements MyWorklogService {
Map<Long, ProjectTaskDO> taskMap = projectTaskMapper.selectBatchIds(taskIds).stream()
.collect(Collectors.toMap(ProjectTaskDO::getId, Function.identity(), (a, b) -> a));
Set<Long> personalIds = taskIds.stream()
.filter(id -> !taskMap.containsKey(id)).collect(Collectors.toSet());
.filter(id -> !taskMap.containsKey(id))
.collect(Collectors.toSet());
Set<Long> personalHit = personalIds.isEmpty() ? Collections.emptySet()
: personalItemMapper.selectBatchIds(personalIds).stream()
.map(PersonalItemDO::getId).collect(Collectors.toSet());
// 聚合:projectId→hours / personal / other
.map(PersonalItemDO::getId)
.collect(Collectors.toSet());
Map<Long, BigDecimal> hoursByProject = new LinkedHashMap<>();
BigDecimal personalHours = BigDecimal.ZERO;
BigDecimal otherHours = BigDecimal.ZERO;
for (Map.Entry<Long, BigDecimal> e : hoursByTask.entrySet()) {
ProjectTaskDO task = taskMap.get(e.getKey());
for (Map.Entry<Long, BigDecimal> entry : hoursByTask.entrySet()) {
ProjectTaskDO task = taskMap.get(entry.getKey());
if (task != null) {
hoursByProject.merge(task.getProjectId(), e.getValue(), BigDecimal::add);
} else if (personalHit.contains(e.getKey())) {
personalHours = personalHours.add(e.getValue());
hoursByProject.merge(task.getProjectId(), entry.getValue(), BigDecimal::add);
} else if (personalHit.contains(entry.getKey())) {
personalHours = personalHours.add(entry.getValue());
} else {
otherHours = otherHours.add(e.getValue());
otherHours = otherHours.add(entry.getValue());
}
}
Map<Long, ProjectDO> projectMap = hoursByProject.isEmpty() ? Collections.emptyMap()
: projectMapper.selectBatchIds(hoursByProject.keySet()).stream()
.collect(Collectors.toMap(ProjectDO::getId, Function.identity(), (a, b) -> a));
.collect(Collectors.toMap(ProjectDO::getId, Function.identity(), (a, b) -> a));
List<MyWorklogWeekRespVO.DistributionItemVO> items = new ArrayList<>();
hoursByProject.entrySet().stream()
.sorted(Map.Entry.comparingByValue(Comparator.reverseOrder()))
.forEach(e -> {
.forEach(entry -> {
MyWorklogWeekRespVO.DistributionItemVO item = new MyWorklogWeekRespVO.DistributionItemVO();
item.setProjectId(e.getKey());
ProjectDO project = projectMap.get(e.getKey());
item.setProjectId(entry.getKey());
ProjectDO project = projectMap.get(entry.getKey());
item.setProjectName(project == null ? null : project.getProjectName());
item.setKind("project");
item.setHours(e.getValue().setScale(2, RoundingMode.HALF_UP));
item.setHours(entry.getValue().setScale(2, RoundingMode.HALF_UP));
items.add(item);
});
if (personalHours.signum() > 0) {

View File

@@ -10,8 +10,8 @@ import java.util.Map;
/**
* 工时周聚合的纯函数支撑:周一归一 + 按段均摊。
* 口径(2026-06-12 与用户确认):工时按段填报无逐日明细,均摊分母 = 段内工作日(周一~周五)数;
* 纯周末段全额兜底归段结束日前最近的工作日;份额 scale=4,最终展示由调用方聚合后 setScale(2)。
* 口径(2026-07-14 更新):工时按段填报无逐日明细,均摊分母 = 段内工作日(周一~周五)数;
* 周六/周日不参与工作日段均摊;周末单天工时保留在当天展示;份额 scale=4,最终展示由调用方聚合后 setScale(2)。
*/
final class MyWorklogWeekSupport {
@@ -25,7 +25,7 @@ final class MyWorklogWeekSupport {
/**
* 把一笔按段填报的工时摊到所选周的工作日。
* 返回 [weekMonday, weekMonday+4] 内各工作日的份额(scale=4);无份额的日不出现在 map。
* 返回 [weekMonday, weekMonday+6] 内各自然日的份额(scale=4);无份额的日不出现在 map。
*
* <p>前置条件:weekMonday 必须已归一到 ISO 周的周一(调用方先调 {@link #normalizeToMonday})。
* 方法内不做防御归一,以便在调用方传入非周一时尽早暴露 bug。
@@ -36,26 +36,30 @@ final class MyWorklogWeekSupport {
if (segStart == null || segEnd == null || hours == null || segStart.isAfter(segEnd)) {
return result;
}
LocalDate weekFriday = weekMonday.plusDays(4);
LocalDate weekSunday = weekMonday.plusDays(6);
List<LocalDate> workdays = segStart.datesUntil(segEnd.plusDays(1))
.filter(d -> d.getDayOfWeek().getValue() <= 5)
.toList();
if (workdays.isEmpty()) {
// 纯周末段:全额归段结束日前最近的工作日(周六/周日 → 周五)
LocalDate fallback = segEnd;
while (fallback.getDayOfWeek().getValue() > 5) {
fallback = fallback.minusDays(1);
}
if (!fallback.isBefore(weekMonday) && !fallback.isAfter(weekFriday)) {
result.put(fallback, hours.setScale(4, RoundingMode.HALF_UP));
if (!workdays.isEmpty()) {
BigDecimal share = hours.divide(BigDecimal.valueOf(workdays.size()), 4, RoundingMode.HALF_UP);
for (LocalDate d : workdays) {
if (!d.isBefore(weekMonday) && !d.isAfter(weekSunday)) {
result.put(d, share);
}
}
return result;
}
BigDecimal share = hours.divide(BigDecimal.valueOf(workdays.size()), 4, RoundingMode.HALF_UP);
for (LocalDate d : workdays) {
if (!d.isBefore(weekMonday) && !d.isAfter(weekFriday)) {
result.put(d, share);
}
List<LocalDate> weekendDays = segStart.datesUntil(segEnd.plusDays(1))
.filter(d -> d.getDayOfWeek().getValue() > 5)
.filter(d -> !d.isBefore(weekMonday) && !d.isAfter(weekSunday))
.toList();
if (weekendDays.isEmpty()) {
return result;
}
BigDecimal share = hours.divide(BigDecimal.valueOf(weekendDays.size()), 4, RoundingMode.HALF_UP);
for (LocalDate d : weekendDays) {
result.put(d, share);
}
return result;
}

View File

@@ -9,8 +9,13 @@ import com.njcn.rdms.module.project.dal.mysql.workreport.project.ProjectReportMa
import com.njcn.rdms.module.project.dal.mysql.workreport.weekly.WeeklyReportMapper;
import com.njcn.rdms.module.project.framework.notify.NotifySendEvent;
import com.njcn.rdms.module.project.framework.notify.NotifyTemplateCodeConstants;
import com.njcn.rdms.module.system.api.notify.NotifyMessageSendApi;
import com.njcn.rdms.module.system.api.notify.dto.NotifyUnreadMessageListReqDTO;
import com.njcn.rdms.module.system.api.notify.dto.NotifyUnreadMessageRespDTO;
import com.njcn.rdms.framework.common.pojo.CommonResult;
import com.njcn.rdms.module.system.enums.notify.NotifyMessageLevelConstants;
import jakarta.annotation.Resource;
import lombok.extern.slf4j.Slf4j;
import org.springframework.context.ApplicationEventPublisher;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@@ -19,11 +24,16 @@ import org.springframework.util.StringUtils;
import java.time.LocalDate;
import java.time.LocalDateTime;
import java.time.temporal.IsoFields;
import java.util.ArrayList;
import java.util.Collection;
import java.util.HashSet;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Set;
@Service
@Slf4j
public class WorkReportPendingSubmitRemindService {
private static final List<String> REMIND_STATUS_CODES = List.of(
@@ -38,6 +48,8 @@ public class WorkReportPendingSubmitRemindService {
private ProjectReportMapper projectReportMapper;
@Resource
private ApplicationEventPublisher applicationEventPublisher;
@Resource
private NotifyMessageSendApi notifyMessageSendApi;
@Transactional(rollbackFor = Exception.class)
public void remindPendingSubmitReports() {
@@ -49,14 +61,21 @@ public class WorkReportPendingSubmitRemindService {
private void remindWeekly(LocalDateTime cutoffTime) {
List<WeeklyReportDO> reports = weeklyReportMapper.selectListByPendingSubmitReminder(cutoffTime, REMIND_STATUS_CODES);
Set<String> unreadKeys = loadUnreadReminderKeys(
reports.stream().map(WeeklyReportDO::getReporterId).toList(),
List.of(NotifyTemplateCodeConstants.WORK_REPORT_TEAM_REMIND));
for (WeeklyReportDO report : reports) {
if (report == null || report.getReporterId() == null) {
continue;
}
if (hasUnreadReminder(report.getReporterId(), WorkReportConstants.REPORT_TYPE_WEEKLY, report.getId(), unreadKeys)) {
continue;
}
Map<String, Object> params = new HashMap<>();
params.put("reportTypeName", "周报");
params.put("periodKey", formatWeeklyPeriod(report.getPeriodKey(), report.getPeriodStartDate(), report.getPeriodEndDate()));
params.put("managerName", defaultText(report.getSupervisorName(), "系统通知"));
appendRemindIdentity(params, WorkReportConstants.REPORT_TYPE_WEEKLY, report.getId());
applicationEventPublisher.publishEvent(NotifySendEvent.of(
List.of(report.getReporterId()),
NotifyTemplateCodeConstants.WORK_REPORT_TEAM_REMIND,
@@ -67,14 +86,21 @@ public class WorkReportPendingSubmitRemindService {
private void remindMonthly(LocalDateTime cutoffTime) {
List<MonthlyReportDO> reports = monthlyReportMapper.selectListByPendingSubmitReminder(cutoffTime, REMIND_STATUS_CODES);
Set<String> unreadKeys = loadUnreadReminderKeys(
reports.stream().map(MonthlyReportDO::getReporterId).toList(),
List.of(NotifyTemplateCodeConstants.WORK_REPORT_TEAM_REMIND));
for (MonthlyReportDO report : reports) {
if (report == null || report.getReporterId() == null) {
continue;
}
if (hasUnreadReminder(report.getReporterId(), WorkReportConstants.REPORT_TYPE_MONTHLY, report.getId(), unreadKeys)) {
continue;
}
Map<String, Object> params = new HashMap<>();
params.put("reportTypeName", "月报");
params.put("periodKey", formatMonthlyPeriod(report.getPeriodKey(), report.getPeriodStartDate()));
params.put("managerName", defaultText(report.getSupervisorName(), "系统通知"));
appendRemindIdentity(params, WorkReportConstants.REPORT_TYPE_MONTHLY, report.getId());
applicationEventPublisher.publishEvent(NotifySendEvent.of(
List.of(report.getReporterId()),
NotifyTemplateCodeConstants.WORK_REPORT_TEAM_REMIND,
@@ -85,14 +111,21 @@ public class WorkReportPendingSubmitRemindService {
private void remindProject(LocalDateTime cutoffTime) {
List<ProjectReportDO> reports = projectReportMapper.selectListByPendingSubmitReminder(cutoffTime, REMIND_STATUS_CODES);
Set<String> unreadKeys = loadUnreadReminderKeys(
reports.stream().map(ProjectReportDO::getProjectOwnerId).toList(),
List.of(NotifyTemplateCodeConstants.WORK_REPORT_PROJECT_HALF_MONTH_TEAM_REMIND));
for (ProjectReportDO report : reports) {
if (report == null || report.getProjectOwnerId() == null) {
continue;
}
if (hasUnreadReminder(report.getProjectOwnerId(), WorkReportConstants.REPORT_TYPE_PROJECT, report.getId(), unreadKeys)) {
continue;
}
Map<String, Object> params = new HashMap<>();
params.put("projectName", defaultText(report.getProjectName(), ""));
params.put("periodLabel", defaultText(report.getPeriodLabel(), report.getPeriodKey()));
params.put("managerName", defaultText(report.getSupervisorName(), "系统通知"));
appendRemindIdentity(params, WorkReportConstants.REPORT_TYPE_PROJECT, report.getId());
applicationEventPublisher.publishEvent(NotifySendEvent.of(
List.of(report.getProjectOwnerId()),
NotifyTemplateCodeConstants.WORK_REPORT_PROJECT_HALF_MONTH_TEAM_REMIND,
@@ -119,6 +152,58 @@ public class WorkReportPendingSubmitRemindService {
return startDate.getYear() + "" + startDate.getMonthValue() + "";
}
private Set<String> loadUnreadReminderKeys(Collection<Long> userIds, List<String> templateCodes) {
if (userIds == null || userIds.isEmpty() || templateCodes == null || templateCodes.isEmpty()) {
return Set.of();
}
List<Long> filteredUserIds = userIds.stream().filter(java.util.Objects::nonNull).distinct().toList();
if (filteredUserIds.isEmpty()) {
return Set.of();
}
try {
CommonResult<List<NotifyUnreadMessageRespDTO>> result = notifyMessageSendApi.getUnreadNotifyMessageListByTemplateCodes(
new NotifyUnreadMessageListReqDTO()
.setUserIds(new ArrayList<>(filteredUserIds))
.setTemplateCodes(templateCodes));
List<NotifyUnreadMessageRespDTO> messages = result == null ? null : result.getCheckedData();
if (messages == null || messages.isEmpty()) {
return Set.of();
}
Set<String> keys = new HashSet<>();
for (NotifyUnreadMessageRespDTO message : messages) {
if (message == null || message.getUserId() == null || message.getTemplateParams() == null) {
continue;
}
Object reportType = message.getTemplateParams().get(WorkReportConstants.PARAM_REMIND_REPORT_TYPE);
Object reportId = message.getTemplateParams().get(WorkReportConstants.PARAM_REMIND_REPORT_ID);
if (reportType == null || reportId == null) {
continue;
}
keys.add(buildUnreadReminderKey(message.getUserId(), String.valueOf(reportType), String.valueOf(reportId)));
}
return keys;
} catch (Exception ex) {
log.warn("[loadUnreadReminderKeys][未读消息查询失败,本轮按无未读处理]", ex);
return Set.of();
}
}
private boolean hasUnreadReminder(Long userId, String reportType, Long reportId, Set<String> unreadKeys) {
if (userId == null || reportId == null || unreadKeys == null || unreadKeys.isEmpty()) {
return false;
}
return unreadKeys.contains(buildUnreadReminderKey(userId, reportType, String.valueOf(reportId)));
}
private String buildUnreadReminderKey(Long userId, String reportType, String reportId) {
return userId + "#" + reportType + "#" + reportId;
}
private void appendRemindIdentity(Map<String, Object> params, String reportType, Long reportId) {
params.put(WorkReportConstants.PARAM_REMIND_REPORT_TYPE, reportType);
params.put(WorkReportConstants.PARAM_REMIND_REPORT_ID, reportId == null ? null : String.valueOf(reportId));
}
private String defaultText(String text, String fallback) {
return StringUtils.hasText(text) ? text.trim() : fallback;
}

View File

@@ -123,7 +123,7 @@ rdms:
enabled: true
weekly:
enabled: true
cron: "0 0 12 ? * FRI"
cron: "0 0 12 ? * SUN"
monthly:
enabled: true
cron: "0 0 12 1-31 * ?"

View File

@@ -563,8 +563,8 @@ public class AdminUserServiceImpl implements AdminUserService {
url = client.upload(content, path, type);
//删除旧的
client.delete(user.getAvatar());
// 旧头像删除失败不应影响新头像上传;而且 avatar 字段存的是 URL不能直接当 path 删。
deleteOldAvatarQuietly(client, user);
// 3. 保存到数据库
fileDO = new FileDO().setConfigId(client.getId())
@@ -574,11 +574,31 @@ public class AdminUserServiceImpl implements AdminUserService {
user.setAvatar(fileDO.getUrl());
this.userMapper.updateById(user);
} catch (Exception e) {
log.warn("头像上传失败userId={}, avatar={}, fileName={}, contentType={}",
user == null ? null : user.getId(),
user == null ? null : user.getAvatar(),
name, type, e);
throw exception(USER_AVATAR_UPLOAD_FAILED);
}
return fileDO;
}
private void deleteOldAvatarQuietly(FileClient client, AdminUserDO user) {
if (client == null || user == null || StrUtil.isBlank(user.getAvatar())) {
return;
}
try {
FileDO oldAvatar = fileMapper.selectByUrl(user.getAvatar());
if (oldAvatar == null || StrUtil.isBlank(oldAvatar.getPath())) {
log.warn("删除旧头像时未找到文件记录userId={}, avatar={}", user.getId(), user.getAvatar());
return;
}
client.delete(oldAvatar.getPath());
} catch (Exception ex) {
log.warn("删除旧头像失败userId={}, avatar={}", user.getId(), user.getAvatar(), ex);
}
}
private void validateImageFile(MultipartFile file) {
// 检查文件类型
String contentType = file.getContentType();