feat(job): 重构报表任务执行架构并新增邮件抄送功能

- 新增 AbstractZentaoReportBuilder 抽象类提供通用查询方法
- 新增 ReportBuilder 接口和 @ReportJob 注解实现报表构建器插件化
- 新增 ZentaoDailyEffortReportBuilder 实现日报工时统计报表
- 将 ZentaoWeComReportService 重命名为 BuildReportService 并重构为通用报表服务
- 在 job_config 表新增 mailCc 字段支持邮件抄送功能
- 更新 MailMessageClient 支持抄送人参数和收件人格式标准化
- 新增工作台菜单项并更新数据库补丁脚本
- 移除原有的 ZentaoEffortReportQueryService 和 ZentaoDailyEffortJobExecutor
- 更新 XXL-JOB 调度分发器支持新的报表构建器模式
This commit is contained in:
will
2026-08-09 14:13:43 +08:00
parent 18dcf0d082
commit c4bcfab316
23 changed files with 608 additions and 323 deletions
+93
View File
@@ -0,0 +1,93 @@
# willSystem 项目流程图
## 计划任务配置与执行流程
```mermaid
flowchart TD
A[前端计划任务配置页面] --> B[AgentReportController]
B --> C[JobConfigService]
C --> D[(job_config)]
C --> E[ConfigService]
E --> F[(config_group / config_item)]
F --> G[JOB_TYPE 任务功能下拉]
C --> H[JobScheduleSyncService]
H --> I[XxlJobScheduleSyncService]
I --> J[XxlJobAdminClient]
J --> K[XXL-JOB Admin]
K --> L[executorHandler: agentReportDispatchJob]
K --> M[executorParam: configId]
L --> N[ZentaoEffortReportJobHandler]
M --> N
N --> C
N --> O[JobExecutionDispatcher]
O --> P[JobExecutor]
O --> Q[BuildReportService]
Q --> R["@ReportJob + ReportBuilder"]
R --> S[ZentaoDailyEffortReportBuilder]
R --> T[ZentaoWeeklyTaskDetailReportBuilder]
S --> U[ZentaoEffortReportQueryService]
T --> U
U --> V[(Zentao DB)]
Q --> W[RobotMessageClient / MailMessageClient]
W --> X[企业微信 / 飞书 / 钉钉 / 邮件]
```
## 统一配置流程
```mermaid
flowchart TD
A[前端统一配置页面] --> B[ConfigController]
B --> C[ConfigService]
C --> D[(config_group)]
C --> E[(config_item)]
D --> F[配置组]
E --> G[配置项]
G --> H[JOB_TYPE]
H --> I[计划任务配置的任务功能下拉]
```
## 当前关键设置
- 计划任务配置表是 `job_config`。
- 系统配置主子表是 `config_group` 和 `config_item`。
- 任务功能下拉不写死在前端,来自 `config_group.code = JOB_TYPE` 下的启用配置项。
- `job_config` 不再保存 `zone`、`daysAgo`、`groupName`。
- 时区、执行范围、统计偏移等业务逻辑固定在后端执行逻辑中。
- 计划任务保存时,`name`、`jobType`、`cron` 必填。
- `jobType` 必须存在于启用的 `JOB_TYPE` 配置项中。
- 任务执行采用插件式 `JobExecutor` 结构。
- 报表类任务采用 `@ReportJob(jobType = "...") + ReportBuilder` 自动装载。
- 新增报表类计划任务时,优先新增一个 `ReportBuilder` 实现类,不改 `BuildReportService` 和调度入口。
- 非报表类任务仍可新增一个 `JobExecutor` 实现类。
- XXL-JOB 统一使用 `agentReportDispatchJob` 作为执行器 Handler。
- XXL-JOB 参数只传 `configId`,执行时再读取 `job_config` 获取完整配置。
## 新增计划任务推荐流程
```mermaid
flowchart TD
A[新增任务需求] --> B[确认是否需要新增 jobType]
B --> C[在 JOB_TYPE 配置项中新增任务功能]
C --> D{是否报表发送类任务}
D -->|是| E[新增 ReportBuilder 实现并标注 @ReportJob]
D -->|否| F[新增 JobExecutor 实现]
E --> G[实现 build ReportRequest]
F --> H[实现 execute JobConfigDTO]
G --> I[复用或新增业务 Service]
H --> I
I --> J[如需新字段再评估 job_config 表结构]
J --> K[同步 SQL 脚本和前端表单]
K --> L[运行后端编译和前端类型检查]
```
## 后续需求调整原则
- 先判断需求属于配置管理、任务类型、任务执行器、业务报表查询、消息发送还是 XXL-JOB 同步。
- 不在 `JobConfigService` 中增加具体业务执行分支。
- 不在 `ZentaoEffortReportJobHandler` 中增加具体任务类型判断。
- 不在 `BuildReportService` 中增加具体报表类型判断。
- 不在前端写死任务功能下拉值。
- 新增表字段前先确认是否是业务逻辑固定值,固定值优先放后端执行代码。
- 旧表迁移和新建表脚本必须同步检查。
@@ -0,0 +1,54 @@
package com.will.businessJob.builder;
import com.zaxxer.hikari.HikariDataSource;
import jakarta.annotation.PostConstruct;
import lombok.extern.slf4j.Slf4j;
import org.springframework.jdbc.core.JdbcTemplate;
import java.util.List;
import java.util.Map;
@Slf4j
public abstract class AbstractZentaoReportBuilder {
private final JdbcTemplate zentaoJdbcTemplate;
protected AbstractZentaoReportBuilder(JdbcTemplate zentaoJdbcTemplate) {
this.zentaoJdbcTemplate = zentaoJdbcTemplate;
}
@PostConstruct
public void logZentaoJdbcTemplate() {
Object dataSource = zentaoJdbcTemplate.getDataSource();
if (dataSource instanceof HikariDataSource hikariDataSource) {
log.info("[Job][Zentao] report builder bound jdbcUrl={}, username={}, builder={}",
hikariDataSource.getJdbcUrl(), hikariDataSource.getUsername(), getClass().getSimpleName());
} else {
log.info("[Job][Zentao] report builder bound datasource={}, builder={}",
dataSource == null ? "null" : dataSource.getClass().getName(), getClass().getSimpleName());
}
}
protected List<Map<String, Object>> queryList(String sql, Object... args) {
try {
logSql(sql, args);
return zentaoJdbcTemplate.queryForList(sql, args);
} catch (Exception ex) {
log.warn("[Job][Zentao][SQL] query failed: {}", ex.getMessage(), ex);
return List.of(Map.of("count", ex.getMessage() == null ? "bad SQL grammar" : ex.getMessage()));
}
}
protected boolean isQueryFailed(List<Map<String, Object>> rows) {
return rows.size() == 1 && rows.get(0).containsKey("count") && rows.get(0).size() == 1
&& rows.get(0).get("count") instanceof String;
}
private void logSql(String sql, Object... args) {
log.debug("[Job][Zentao][SQL] {} | args={}", normalizeSql(sql), List.of(args));
}
private String normalizeSql(String sql) {
return sql == null ? "" : sql.replaceAll("\\s+", " ").trim();
}
}
@@ -1,12 +1,13 @@
package com.will.businessJob.zentao;
package com.will.businessJob.builder;
import com.will.businessJob.config.ReportJob;
import com.will.businessJob.config.ReportRequest;
import com.will.businessJob.mail.MailMessageClient;
import com.will.businessJob.robot.RobotMessageClient;
import com.will.coreService.dto.JobConfigDTO;
import com.will.coreService.enums.RobotType;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.ObjectProvider;
import org.springframework.core.annotation.AnnotationUtils;
import org.springframework.stereotype.Service;
import org.springframework.util.StringUtils;
@@ -14,37 +15,50 @@ import java.time.DayOfWeek;
import java.time.LocalDate;
import java.time.ZoneId;
import java.time.temporal.TemporalAdjusters;
import java.util.List;
import java.util.Map;
import java.util.function.Function;
import java.util.stream.Collectors;
@Slf4j
@Service
@RequiredArgsConstructor
public class ZentaoWeComReportService {
public class BuildReportService {
private static final String DEFAULT_GROUP_NAME = "";
private static final String DEFAULT_REPORT_TITLE = "禅道工时完成情况";
private static final String DEFAULT_REPORT_TITLE = "";
private static final String DEFAULT_ZONE = "Asia/Shanghai";
private static final int WE_COM_MARKDOWN_MAX_LENGTH = 3900;
private final ObjectProvider<ZentaoEffortReportQueryService> effortReportQueryService;
private final Map<String, ReportBuilder> reportBuilderMap;
private final RobotMessageClient robotMessageClient;
private final MailMessageClient mailMessageClient;
public String sendSummaryReport(JobConfigDTO jobConfig) {
return sendReport(jobConfig, ZentaoEffortReportQueryService::getGroupTaskEffortRobotSummary);
public BuildReportService(
List<ReportBuilder> reportBuilders,
RobotMessageClient robotMessageClient,
MailMessageClient mailMessageClient) {
this.reportBuilderMap = reportBuilders.stream()
.collect(Collectors.toMap(this::jobType, Function.identity()));
this.robotMessageClient = robotMessageClient;
this.mailMessageClient = mailMessageClient;
}
public String sendTaskDetailReport(JobConfigDTO jobConfig) {
return sendReport(jobConfig, ZentaoEffortReportQueryService::getGroupTaskEffortDetailReport);
public boolean supports(String jobType) {
return reportBuilderMap.containsKey(jobType);
}
private String sendReport(JobConfigDTO jobConfig, ReportExecutor executor) {
public String sendReport(JobConfigDTO jobConfig) {
String disabledReason = validateJobConfig(jobConfig);
if (disabledReason != null) {
return disabledReason;
}
ReportBuilder reportBuilder = reportBuilderMap.get(jobConfig.getJobType());
if (reportBuilder == null) {
throw new IllegalArgumentException("不支持的报表任务类型:" + jobConfig.getJobType());
}
ReportRequest request = buildRequest(jobConfig);
return sendReport(request, jobConfig.getId(), executor);
return sendReport(request, jobConfig.getId(), reportBuilder);
}
private String validateJobConfig(JobConfigDTO jobConfig) {
@@ -66,21 +80,18 @@ public class ZentaoWeComReportService {
RobotType.defaultName(jobConfig.getRobotType()),
jobConfig.getWebhookUrl(),
jobConfig.getMailTo(),
jobConfig.getMailCc(),
defaultString(jobConfig.getMailSubject(), defaultString(jobConfig.getName(), DEFAULT_REPORT_TITLE))
);
}
private String sendReport(ReportRequest request, String jobId, ReportExecutor executor) {
ZentaoEffortReportQueryService queryService = getEffortReportQueryService();
String report = executor.execute(
queryService,
request.startDate().toString(),
request.endDate().toString(),
request.groupName());
String content = truncateMarkdown(request.robotType(), buildMarkdown(request, report));
private String sendReport(ReportRequest request, String jobId, ReportBuilder reportBuilder) {
String report = reportBuilder.build(request);
String content = buildMarkdown(request, report);
if (RobotType.EMAIL.name().equals(RobotType.defaultName(request.robotType()))) {
mailMessageClient.sendMarkdown(request.mailTo(), request.mailSubject(), content);
mailMessageClient.sendMarkdown(request.mailTo(), request.mailCc(), request.mailSubject(), content);
} else {
content = truncateMarkdown(request.robotType(), content);
robotMessageClient.sendMarkdown(request.robotType(), content, request.webhookUrl());
}
@@ -94,14 +105,6 @@ public class ZentaoWeComReportService {
return content;
}
private ZentaoEffortReportQueryService getEffortReportQueryService() {
ZentaoEffortReportQueryService queryService = effortReportQueryService.getIfAvailable();
if (queryService == null) {
throw new IllegalStateException("禅道工时报表通知查询服务未启用");
}
return queryService;
}
private String buildMarkdown(ReportRequest request, String report) {
return """
## %s
@@ -145,21 +148,11 @@ public class ZentaoWeComReportService {
return content.substring(0, cutIndex).trim() + suffix;
}
private record ReportRequest(
String title,
LocalDate startDate,
LocalDate endDate,
String groupName,
String robotType,
String webhookUrl,
String mailTo,
String mailSubject
) {
}
@FunctionalInterface
private interface ReportExecutor {
String execute(ZentaoEffortReportQueryService queryService, String startDate, String endDate, String groupName);
private String jobType(ReportBuilder reportBuilder) {
ReportJob reportJob = AnnotationUtils.findAnnotation(reportBuilder.getClass(), ReportJob.class);
if (reportJob == null || !StringUtils.hasText(reportJob.jobType())) {
throw new IllegalArgumentException("报表构建器未配置 ReportJob:" + reportBuilder.getClass().getName());
}
return reportJob.jobType();
}
}
@@ -0,0 +1,8 @@
package com.will.businessJob.builder;
import com.will.businessJob.config.ReportRequest;
public interface ReportBuilder {
String build(ReportRequest request);
}
@@ -1,4 +1,4 @@
package com.will.businessJob;
package com.will.businessJob.config;
public final class JobTypes {
@@ -0,0 +1,13 @@
package com.will.businessJob.config;
import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;
@Target(ElementType.TYPE)
@Retention(RetentionPolicy.RUNTIME)
public @interface ReportJob {
String jobType();
}
@@ -0,0 +1,16 @@
package com.will.businessJob.config;
import java.time.LocalDate;
public record ReportRequest(
String title,
LocalDate startDate,
LocalDate endDate,
String groupName,
String robotType,
String webhookUrl,
String mailTo,
String mailCc,
String mailSubject
) {
}
@@ -19,7 +19,7 @@ public class MailMessageClient {
private final ObjectProvider<JavaMailSender> mailSenderProvider;
private final ObjectProvider<MailProperties> mailPropertiesProvider;
public void sendMarkdown(String mailTo, String subject, String content) {
public void sendMarkdown(String mailTo, String mailCc, String subject, String content) {
if (!StringUtils.hasText(mailTo)) {
throw new IllegalStateException("邮件收件人未配置");
}
@@ -34,18 +34,30 @@ public class MailMessageClient {
if (StringUtils.hasText(from)) {
message.setFrom(new InternetAddress(from.trim()));
}
message.setRecipients(MimeMessage.RecipientType.TO, mailTo.trim());
message.setRecipients(MimeMessage.RecipientType.TO, normalizeRecipients(mailTo));
if (StringUtils.hasText(mailCc)) {
message.setRecipients(MimeMessage.RecipientType.CC, normalizeRecipients(mailCc));
}
message.setSubject(defaultString(subject, "计划任务通知"), "UTF-8");
message.setText(content == null ? "" : content, "UTF-8");
mailSender.send(message);
log.info("[MailMessage] markdown sent, mailTo={}, length={}", mailTo, content == null ? 0 : content.length());
log.info("[MailMessage] markdown sent, mailTo={}, mailCc={}, length={}", mailTo, mailCc, content == null ? 0 : content.length());
} catch (Exception ex) {
log.warn("[MailMessage] send failed, mailTo={}", mailTo, ex);
log.warn("[MailMessage] send failed, mailTo={}, mailCc={}", mailTo, mailCc, ex);
throw new IllegalStateException("邮件发送失败:" + ex.getMessage(), ex);
}
}
private String normalizeRecipients(String recipients) {
return recipients.trim()
.replace(',', ',')
.replace(';', ',')
.replace(';', ',')
.replace('\n', ',')
.replace('\r', ',');
}
private String defaultString(String value, String defaultValue) {
return StringUtils.hasText(value) ? value.trim() : defaultValue;
}
}
}
@@ -1,24 +0,0 @@
package com.will.businessJob.zentao;
import com.will.coreService.dto.JobConfigDTO;
import com.will.coreService.service.JobExecutor;
import com.will.businessJob.JobTypes;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Component;
@Component
@RequiredArgsConstructor
public class ZentaoDailyEffortJobExecutor implements JobExecutor {
private final ZentaoWeComReportService reportService;
@Override
public String jobType() {
return JobTypes.ZENTAO_DAILY_EFFORT;
}
@Override
public String execute(JobConfigDTO jobConfig) {
return reportService.sendSummaryReport(jobConfig);
}
}
@@ -0,0 +1,150 @@
package com.will.businessJob.zentao;
import com.will.businessJob.builder.AbstractZentaoReportBuilder;
import com.will.businessJob.builder.ReportBuilder;
import com.will.businessJob.config.JobTypes;
import com.will.businessJob.config.ReportJob;
import com.will.businessJob.config.ReportRequest;
import org.springframework.boot.autoconfigure.condition.ConditionalOnBean;
import org.springframework.beans.factory.annotation.Qualifier;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.stereotype.Component;
import org.springframework.util.StringUtils;
import java.time.LocalDate;
import java.util.Comparator;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.stream.Collectors;
@Component
@ConditionalOnBean(name = "zentaoJdbcTemplate")
@ReportJob(jobType = JobTypes.ZENTAO_DAILY_EFFORT)
public class ZentaoDailyEffortReportBuilder extends AbstractZentaoReportBuilder implements ReportBuilder {
private static final double MAX_WEEKLY_WARNING_HOURS = 35D;
public ZentaoDailyEffortReportBuilder(@Qualifier("zentaoJdbcTemplate") JdbcTemplate zentaoJdbcTemplate) {
super(zentaoJdbcTemplate);
}
@Override
public String build(ReportRequest request) {
LocalDate start = request.startDate();
LocalDate end = request.endDate();
if (start == null || end == null) {
return "禅道用户组任务工时统计:日期格式错误,请使用 yyyy-MM-dd,例如 2026-08-01";
}
if (start.isAfter(end)) {
return "禅道用户组任务工时统计:开始日期不能晚于结束日期";
}
String actualGroupName = StringUtils.hasText(request.groupName()) ? request.groupName().trim() : "TSO";
List<Object> params = List.of(start.toString(), end.toString(), actualGroupName);
List<Map<String, Object>> rows = queryList("""
SELECT u.account account,
COALESCE(NULLIF(u.realname, ''), u.account) realname,
COALESCE(effort.consumed, 0) consumed
FROM zt_user u
INNER JOIN zt_usergroup ug ON ug.account = u.account
INNER JOIN zt_group g ON g.id = ug.`group`
LEFT JOIN (
SELECT e.account, ROUND(SUM(e.consumed), 2) consumed
FROM zt_effort e
WHERE e.objectType = 'task'
AND e.`date` >= ?
AND e.`date` <= ?
AND e.deleted = '0'
GROUP BY e.account
) effort ON effort.account = u.account
WHERE g.`name` = ?
AND u.account <> 'sygu'
ORDER BY u.realname ASC, u.account ASC
""", params.toArray());
if (isQueryFailed(rows)) {
return "禅道用户组任务工时统计:查询失败," + rows.get(0).get("count");
}
if (rows.isEmpty()) {
return "禅道用户组任务工时统计:无成员数据";
}
long days = end.toEpochDay() - start.toEpochDay() + 1;
double threshold = Math.min(Math.max(days - 1, 0) * 7D, MAX_WEEKLY_WARNING_HOURS);
List<UserEffort> userEfforts = rows.stream()
.collect(Collectors.groupingBy(
row -> row.getOrDefault("account", "-").toString(),
LinkedHashMap::new,
Collectors.toList()))
.entrySet()
.stream()
.map(entry -> toUserEffort(entry.getKey(), entry.getValue()))
.sorted(Comparator.comparing(UserEffort::realname).thenComparing(UserEffort::account))
.toList();
double totalConsumed = userEfforts.stream()
.mapToDouble(UserEffort::consumed)
.sum();
String warningUsers = formatUsers(userEfforts.stream()
.filter(user -> user.consumed() < threshold)
.toList(), "warning");
String normalUsers = formatUsers(userEfforts.stream()
.filter(user -> user.consumed() >= threshold)
.toList(), "info");
String users = """
工时不足:
%s
工时达标:
%s
""".formatted(warningUsers, normalUsers);
return """
禅道 %s 组任务工时统计:
- 时间范围:%s 至 %s
- 人员数:%s
- 工时不足人数:%s
- 工时达标人数:%s
- 工时预警阈值:%.2f
- 总消耗工时:%.2f
按成员:
%s
""".formatted(
actualGroupName,
start,
end,
userEfforts.size(),
userEfforts.stream().filter(user -> user.consumed() < threshold).count(),
userEfforts.stream().filter(user -> user.consumed() >= threshold).count(),
threshold,
totalConsumed,
users);
}
private UserEffort toUserEffort(String account, List<Map<String, Object>> rows) {
String realname = rows.stream()
.map(row -> row.getOrDefault("realname", account).toString())
.filter(StringUtils::hasText)
.findFirst()
.orElse(account);
double consumed = rows.stream()
.map(row -> row.get("consumed"))
.filter(Number.class::isInstance)
.map(Number.class::cast)
.mapToDouble(Number::doubleValue)
.sum();
return new UserEffort(account, realname, consumed);
}
private String formatUsers(List<UserEffort> userEfforts, String color) {
if (userEfforts.isEmpty()) {
return "- 无";
}
return userEfforts.stream()
.map(user -> "- <font color=\"%s\">姓名=%s | 账号=%s | 消耗工时=%.2f</font>"
.formatted(color, user.realname(), user.account(), user.consumed()))
.collect(Collectors.joining("\n"));
}
private record UserEffort(String account, String realname, double consumed) {
}
}
@@ -1,219 +0,0 @@
package com.will.businessJob.zentao;
import com.zaxxer.hikari.HikariDataSource;
import jakarta.annotation.PostConstruct;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Qualifier;
import org.springframework.boot.autoconfigure.condition.ConditionalOnBean;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.stereotype.Service;
import org.springframework.util.StringUtils;
import java.time.LocalDate;
import java.time.format.DateTimeParseException;
import java.util.List;
import java.util.Map;
import java.util.stream.Collectors;
@Slf4j
@Service
@ConditionalOnBean(name = "zentaoJdbcTemplate")
public class ZentaoEffortReportQueryService {
private final JdbcTemplate zentaoJdbcTemplate;
public ZentaoEffortReportQueryService(@Qualifier("zentaoJdbcTemplate") JdbcTemplate zentaoJdbcTemplate) {
this.zentaoJdbcTemplate = zentaoJdbcTemplate;
}
@PostConstruct
public void logZentaoJdbcTemplate() {
Object dataSource = zentaoJdbcTemplate.getDataSource();
if (dataSource instanceof HikariDataSource hikariDataSource) {
log.info("[Job][Zentao] effort report bound jdbcUrl={}, username={}",
hikariDataSource.getJdbcUrl(), hikariDataSource.getUsername());
} else {
log.info("[Job][Zentao] effort report bound datasource={}",
dataSource == null ? "null" : dataSource.getClass().getName());
}
}
public String getGroupTaskEffortRobotSummary(String startDate, String endDate, String groupName) {
LocalDate start = parseDate(startDate);
LocalDate end = parseDate(endDate);
if (start == null || end == null) {
return "禅道用户组任务工时统计:日期格式错误,请使用 yyyy-MM-dd,例如 2026-08-01";
}
if (start.isAfter(end)) {
return "禅道用户组任务工时统计:开始日期不能晚于结束日期";
}
String actualGroupName = StringUtils.hasText(groupName) ? groupName.trim() : "TSO";
List<Object> params = List.of(start.toString(), end.toString(), actualGroupName);
List<Map<String, Object>> rows = queryList("""
SELECT u.account account,
COALESCE(NULLIF(u.realname, ''), u.account) realname,
COALESCE(effort.consumed, 0) consumed
FROM zt_user u
INNER JOIN zt_usergroup ug ON ug.account = u.account
INNER JOIN zt_group g ON g.id = ug.`group`
LEFT JOIN (
SELECT e.account, ROUND(SUM(e.consumed), 2) consumed
FROM zt_effort e
WHERE e.objectType = 'task'
AND e.`date` >= ?
AND e.`date` <= ?
GROUP BY e.account
) effort ON effort.account = u.account
WHERE g.`name` = ?
AND u.deleted = '0'
AND u.account <> 'sygu'
ORDER BY consumed ASC, u.account ASC
""", params.toArray());
if (isQueryFailed(rows)) {
return "禅道用户组任务工时统计:查询失败," + rows.get(0).get("count");
}
if (rows.isEmpty()) {
return "禅道用户组任务工时统计:无成员数据";
}
long days = end.toEpochDay() - start.toEpochDay() + 1;
double threshold = Math.max(days - 1, 0) * 7D;
double totalConsumed = rows.stream()
.map(row -> row.get("consumed"))
.filter(Number.class::isInstance)
.map(Number.class::cast)
.mapToDouble(Number::doubleValue)
.sum();
String users = rows.stream()
.map(row -> {
Object realname = row.getOrDefault("realname", "-");
Object consumedValue = row.getOrDefault("consumed", 0);
double consumed = consumedValue instanceof Number number ? number.doubleValue() : 0D;
String line = "姓名=%s | 消耗工时=%s".formatted(realname, consumedValue);
String color = consumed < threshold ? "warning" : "info";
return "- <font color=\"" + color + "\">" + line + "</font>";
})
.collect(Collectors.joining("\n"));
return """
禅道 %s 组任务工时统计:
- 时间范围:%s 至 %s
- 人员数:%s
- 工时预警阈值:%.2f
- 总消耗工时:%.2f
按成员:
%s
""".formatted(actualGroupName, start, end, rows.size(), threshold, totalConsumed, users);
}
public String getGroupTaskEffortDetailReport(String startDate, String endDate, String groupName) {
LocalDate start = parseDate(startDate);
LocalDate end = parseDate(endDate);
if (start == null || end == null) {
return "禅道任务明细报表:日期格式错误,请使用 yyyy-MM-dd,例如 2026-08-01";
}
if (start.isAfter(end)) {
return "禅道任务明细报表:开始日期不能晚于结束日期";
}
String actualGroupName = StringUtils.hasText(groupName) ? groupName.trim() : "PRO";
List<Object> params = List.of(start.toString(), end.toString(), actualGroupName);
List<Map<String, Object>> rows = queryList("""
SELECT e.`date` effortDate,
e.account account,
COALESCE(NULLIF(u.realname, ''), e.account) realname,
e.objectID taskId,
COALESCE(NULLIF(t.`name`, ''), CONCAT('TASK-', e.objectID)) taskName,
COALESCE(NULLIF(e.`work`, ''), '-') workContent,
ROUND(e.consumed, 2) consumed
FROM zt_effort e
INNER JOIN zt_user u ON u.account = e.account
INNER JOIN zt_usergroup ug ON ug.account = u.account
INNER JOIN zt_group g ON g.id = ug.`group`
LEFT JOIN zt_task t ON t.id = e.objectID
WHERE e.objectType = 'task'
AND e.`date` >= ?
AND e.`date` <= ?
AND g.`name` = ?
AND u.deleted = '0'
AND u.account <> 'sygu'
ORDER BY e.`date` ASC, e.account ASC, e.objectID ASC, e.id ASC
""", params.toArray());
if (isQueryFailed(rows)) {
return "禅道任务明细报表:查询失败," + rows.get(0).get("count");
}
if (rows.isEmpty()) {
return "禅道任务明细报表:本周暂无任务工时明细";
}
double totalConsumed = rows.stream()
.map(row -> row.get("consumed"))
.filter(Number.class::isInstance)
.map(Number.class::cast)
.mapToDouble(Number::doubleValue)
.sum();
String details = rows.stream()
.limit(80)
.map(this::formatEffortDetail)
.collect(Collectors.joining("\n"));
String suffix = rows.size() > 80
? "\n> 明细过多,仅展示前 80 条,共 " + rows.size() + " 条"
: "";
return """
禅道 %s 组本周任务明细:
- 时间范围:%s 至 %s
- 明细条数:%s
- 合计工时:%.2f
%s%s
""".formatted(actualGroupName, start, end, rows.size(), totalConsumed, details, suffix);
}
private String formatEffortDetail(Map<String, Object> row) {
return "- %s | %s | #%s %s | %s | 工时=%s".formatted(
row.getOrDefault("effortDate", "-"),
row.getOrDefault("realname", "-"),
row.getOrDefault("taskId", "-"),
row.getOrDefault("taskName", "-"),
row.getOrDefault("workContent", "-"),
row.getOrDefault("consumed", "-"));
}
private LocalDate parseDate(String value) {
if (!StringUtils.hasText(value)) {
return null;
}
try {
return LocalDate.parse(value.trim());
} catch (DateTimeParseException ex) {
return null;
}
}
private List<Map<String, Object>> queryList(String sql, Object... args) {
try {
logSql(sql, args);
return zentaoJdbcTemplate.queryForList(sql, args);
} catch (Exception ex) {
log.warn("[Job][Zentao][SQL] query failed: {}", ex.getMessage(), ex);
return List.of(Map.of("count", ex.getMessage() == null ? "bad SQL grammar" : ex.getMessage()));
}
}
private boolean isQueryFailed(List<Map<String, Object>> rows) {
return rows.size() == 1 && rows.get(0).containsKey("count") && rows.get(0).size() == 1
&& rows.get(0).get("count") instanceof String;
}
private void logSql(String sql, Object... args) {
log.debug("[Job][Zentao][SQL] {} | args={}", normalizeSql(sql), List.of(args));
}
private String normalizeSql(String sql) {
return sql == null ? "" : sql.replaceAll("\\s+", " ").trim();
}
}
@@ -1,24 +0,0 @@
package com.will.businessJob.zentao;
import com.will.coreService.dto.JobConfigDTO;
import com.will.coreService.service.JobExecutor;
import com.will.businessJob.JobTypes;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Component;
@Component
@RequiredArgsConstructor
public class ZentaoWeeklyTaskDetailJobExecutor implements JobExecutor {
private final ZentaoWeComReportService reportService;
@Override
public String jobType() {
return JobTypes.ZENTAO_WEEKLY_TASK_DETAIL;
}
@Override
public String execute(JobConfigDTO jobConfig) {
return reportService.sendTaskDetailReport(jobConfig);
}
}
@@ -0,0 +1,157 @@
package com.will.businessJob.zentao;
import com.will.businessJob.builder.AbstractZentaoReportBuilder;
import com.will.businessJob.builder.ReportBuilder;
import com.will.businessJob.config.JobTypes;
import com.will.businessJob.config.ReportJob;
import com.will.businessJob.config.ReportRequest;
import org.springframework.beans.factory.annotation.Qualifier;
import org.springframework.boot.autoconfigure.condition.ConditionalOnBean;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.stereotype.Component;
import org.springframework.util.StringUtils;
import java.time.LocalDate;
import java.util.Comparator;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.stream.Collectors;
@Component
@ConditionalOnBean(name = "zentaoJdbcTemplate")
@ReportJob(jobType = JobTypes.ZENTAO_WEEKLY_TASK_DETAIL)
public class ZentaoWeeklyTaskDetailReportBuilder extends AbstractZentaoReportBuilder implements ReportBuilder {
public ZentaoWeeklyTaskDetailReportBuilder(@Qualifier("zentaoJdbcTemplate") JdbcTemplate zentaoJdbcTemplate) {
super(zentaoJdbcTemplate);
}
@Override
public String build(ReportRequest request) {
LocalDate start = request.startDate();
LocalDate end = request.endDate();
if (start == null || end == null) {
return "禅道任务明细报表:日期格式错误,请使用 yyyy-MM-dd,例如 2026-08-01";
}
if (start.isAfter(end)) {
return "禅道任务明细报表:开始日期不能晚于结束日期";
}
String actualGroupName = StringUtils.hasText(request.groupName()) ? request.groupName().trim() : "PRO";
List<Object> params = List.of(start.toString(), end.toString(), actualGroupName);
List<Map<String, Object>> rows = queryList("""
SELECT e.`date` effortDate,
e.account account,
COALESCE(NULLIF(u.realname, ''), e.account) realname,
e.objectID taskId,
COALESCE(NULLIF(t.`name`, ''), CONCAT('TASK-', e.objectID)) taskName,
COALESCE(NULLIF(e.`work`, ''), '-') workContent,
ROUND(e.consumed, 2) consumed
FROM zt_effort e
INNER JOIN zt_user u ON u.account = e.account
INNER JOIN zt_usergroup ug ON ug.account = u.account
INNER JOIN zt_group g ON g.id = ug.`group`
LEFT JOIN zt_task t ON t.id = e.objectID
WHERE e.objectType = 'task'
AND e.`date` >= ?
AND e.`date` <= ?
AND g.`name` = ?
AND u.deleted = '0'
AND u.account <> 'sygu'
ORDER BY e.account ASC, e.`date` ASC, e.objectID ASC, e.id ASC
""", params.toArray());
if (isQueryFailed(rows)) {
return "禅道任务明细报表:查询失败," + rows.get(0).get("count");
}
if (rows.isEmpty()) {
return "禅道任务明细报表:本周暂无任务工时明细";
}
double totalConsumed = rows.stream()
.map(row -> row.get("consumed"))
.filter(Number.class::isInstance)
.map(Number.class::cast)
.mapToDouble(Number::doubleValue)
.sum();
List<UserTaskDetail> userDetails = rows.stream()
.collect(Collectors.groupingBy(
row -> row.getOrDefault("account", "-").toString(),
LinkedHashMap::new,
Collectors.toList()))
.entrySet()
.stream()
.map(entry -> toUserTaskDetail(entry.getKey(), entry.getValue()))
.sorted(Comparator.comparing(UserTaskDetail::account))
.toList();
String details = userDetails.stream()
.map(this::formatUserTaskDetail)
.collect(Collectors.joining("\n\n"));
return """
禅道 %s 组本周任务明细:
- 时间范围:%s 至 %s
- 人员数:%s
- 明细条数:%s
- 合计工时:%.2f
%s
""".formatted(actualGroupName, start, end, userDetails.size(), rows.size(), totalConsumed, details);
}
private UserTaskDetail toUserTaskDetail(String account, List<Map<String, Object>> rows) {
String realname = rows.stream()
.map(row -> row.getOrDefault("realname", account).toString())
.filter(StringUtils::hasText)
.findFirst()
.orElse(account);
double consumed = rows.stream()
.map(row -> row.get("consumed"))
.filter(Number.class::isInstance)
.map(Number.class::cast)
.mapToDouble(Number::doubleValue)
.sum();
List<Map<String, Object>> details = rows.stream()
.sorted(Comparator.comparing((Map<String, Object> row) -> row.getOrDefault("effortDate", "").toString())
.thenComparing(row -> row.getOrDefault("taskId", "").toString()))
.toList();
return new UserTaskDetail(account, realname, consumed, details.size(), details);
}
private String formatUserTaskDetail(UserTaskDetail userDetail) {
String details = userDetail.details()
.stream()
.map(this::formatEffortDetail)
.collect(Collectors.joining("\n"));
return """
### %s(%s)
- 明细条数:%s
- 合计工时:%.2f
%s
""".formatted(
userDetail.realname(),
userDetail.account(),
userDetail.detailCount(),
userDetail.consumed(),
details);
}
private String formatEffortDetail(Map<String, Object> row) {
return "- %s | #%s %s | %s | 工时=%s".formatted(
row.getOrDefault("effortDate", "-"),
row.getOrDefault("taskId", "-"),
row.getOrDefault("taskName", "-"),
row.getOrDefault("workContent", "-"),
row.getOrDefault("consumed", "-"));
}
private record UserTaskDetail(
String account,
String realname,
double consumed,
int detailCount,
List<Map<String, Object>> details
) {
}
}
@@ -42,6 +42,9 @@ public class JobConfig extends BaseEntity {
@TableField("mailTo")
private String mailTo;
@TableField("mailCc")
private String mailCc;
@TableField("mailSubject")
private String mailSubject;
@TableField("xxlJobId")
@@ -1,5 +1,6 @@
package com.will.coreJob.framework.xxl;
import com.will.businessJob.builder.BuildReportService;
import com.will.coreService.dto.JobConfigDTO;
import com.will.coreService.service.JobExecutor;
import org.springframework.stereotype.Service;
@@ -13,13 +14,18 @@ import java.util.stream.Collectors;
public class JobExecutionDispatcher {
private final Map<String, JobExecutor> executorMap;
private final BuildReportService buildReportService;
public JobExecutionDispatcher(List<JobExecutor> executors) {
public JobExecutionDispatcher(List<JobExecutor> executors, BuildReportService buildReportService) {
this.executorMap = executors.stream()
.collect(Collectors.toMap(JobExecutor::jobType, Function.identity()));
this.buildReportService = buildReportService;
}
public String execute(JobConfigDTO jobConfig) {
if (buildReportService.supports(jobConfig.getJobType())) {
return buildReportService.sendReport(jobConfig);
}
JobExecutor executor = executorMap.get(jobConfig.getJobType());
if (executor == null) {
throw new IllegalArgumentException("不支持的任务类型:" + jobConfig.getJobType());
@@ -13,6 +13,7 @@ public class JobConfigDTO {
private String robotType;
private String webhookUrl;
private String mailTo;
private String mailCc;
private String mailSubject;
private Integer xxlJobId;
private Boolean localSchedulerEnabled;
@@ -77,6 +77,7 @@ public class JobConfigService {
config.setRobotType(trimRobotTypeToNull(dto.getRobotType()));
config.setWebhookUrl(trimToNull(dto.getWebhookUrl()));
config.setMailTo(trimToNull(dto.getMailTo()));
config.setMailCc(trimToNull(dto.getMailCc()));
config.setMailSubject(trimToNull(dto.getMailSubject()));
config.setLocalSchedulerEnabled(false);
config.setRemark(trimToNull(dto.getRemark()));
@@ -210,9 +210,15 @@ public class MenuManageService {
private boolean isRouteVisible(AuthMenu menu, Set<String> permissions, boolean superAdmin) {
if (isPublicRoute(menu)) {
return true;
}
if (MenuTypes.DIR.equals(menu.getMenuType())) {
return isPublicRoute(menu) || superAdmin || hasVisibleDescendant(menu, permissions, superAdmin);
return superAdmin || hasVisibleDescendant(menu, permissions, superAdmin);
}
@@ -230,7 +236,9 @@ public class MenuManageService {
private boolean isPublicRoute(AuthMenu menu) {
return StringUtils.hasText(menu.getCode()) && menu.getCode().startsWith(MenuTypes.ROUTE_CODE_PREFIX);
String code = menu.getCode();
return "route:dashboard".equals(code) || "route:dashboard:workspace".equals(code);
}
@@ -30,6 +30,7 @@ public class RbacMenuPatchRunner implements ApplicationRunner {
patchJobConfigColumns();
patchSqlResource("/db/patch_drop_agent_report_config.sql", "[Agent][DropReportConfig]");
patchSqlResource("/db/patch_drop_agent_task_function.sql", "[Agent][DropTaskFunction]");
patchSqlResource("/db/patch_dashboard_menu.sql", "[RBAC][DashboardMenu]");
patchAgentMenu();
}
@@ -85,10 +86,14 @@ public class RbacMenuPatchRunner implements ApplicationRunner {
"job_config",
"mailTo",
"ALTER TABLE job_config ADD COLUMN mailTo VARCHAR(500) NULL COMMENT '邮件收件人' AFTER webhookUrl");
addColumnIfMissing(
"job_config",
"mailCc",
"ALTER TABLE job_config ADD COLUMN mailCc VARCHAR(500) NULL COMMENT '邮件抄送人' AFTER mailTo");
addColumnIfMissing(
"job_config",
"mailSubject",
"ALTER TABLE job_config ADD COLUMN mailSubject VARCHAR(200) NULL COMMENT '邮件主题' AFTER mailTo");
"ALTER TABLE job_config ADD COLUMN mailSubject VARCHAR(200) NULL COMMENT '邮件主题' AFTER mailCc");
addColumnIfMissing(
"job_config",
"xxlJobId",
@@ -29,6 +29,16 @@ spring:
request-timeout: 60s
stdio:
servers-configuration: classpath:mcp-servers.json
mail:
host: ${MAIL_HOST:smtp.qiye.aliyun.com}
port: ${MAIL_PORT:465}
username: ${MAIL_USERNAME:wall-e-alert@ittx.com.cn}
password: ${MAIL_PASSWORD:Ttx123456}
properties:
mail.from: ${MAIL_FROM:${MAIL_USERNAME:}}
mail.smtp.auth: ${MAIL_SMTP_AUTH:true}
mail.smtp.ssl.enable: ${MAIL_SMTP_SSL_ENABLE:true}
mail.smtp.starttls.enable: ${MAIL_SMTP_STARTTLS_ENABLE:true}
rabbitmq:
host: ${RABBITMQ_HOST:118.178.233.171}
port: ${RABBITMQ_PORT:5672}
@@ -29,6 +29,16 @@ spring:
request-timeout: 60s
stdio:
servers-configuration: classpath:mcp-servers.json
mail:
host: ${MAIL_HOST}
port: ${MAIL_PORT:465}
username: ${MAIL_USERNAME}
password: ${MAIL_PASSWORD}
properties:
mail.from: ${MAIL_FROM:${MAIL_USERNAME}}
mail.smtp.auth: ${MAIL_SMTP_AUTH:true}
mail.smtp.ssl.enable: ${MAIL_SMTP_SSL_ENABLE:true}
mail.smtp.starttls.enable: ${MAIL_SMTP_STARTTLS_ENABLE:false}
rabbitmq:
host: ${RABBITMQ_HOST}
port: ${RABBITMQ_PORT:5672}
@@ -0,0 +1,11 @@
INSERT INTO auth_menu (
id, code, name, parentId, menuType, routePath, routeName, component, redirect, icon, sortOrder, title,
created, createdBy, lastUpdated, lastUpdatedBy, version
) VALUES
('route-dashboard', 'route:dashboard', '工作台', NULL, 'DIR', '/dashboard', 'Dashboard', NULL, '/dashboard/workspace', 'lucide:layout-dashboard', -1, 'page.dashboard.title', NOW(), 'SYSTEM', NOW(), 'SYSTEM', 0),
('route-dashboard-analytics', 'route:dashboard:analytics', '分析页', 'route-dashboard', 'MENU', 'analytics', 'Analytics', '/dashboard/analytics/index', NULL, NULL, 1, 'page.dashboard.analytics', NOW(), 'SYSTEM', NOW(), 'SYSTEM', 0),
('route-dashboard-workspace', 'route:dashboard:workspace', '工作区', 'route-dashboard', 'MENU', 'workspace', 'Workspace', '/dashboard/workspace/index', NULL, NULL, 2, 'page.dashboard.workspace', NOW(), 'SYSTEM', NOW(), 'SYSTEM', 0)
ON DUPLICATE KEY UPDATE
code = VALUES(code), name = VALUES(name), parentId = VALUES(parentId), menuType = VALUES(menuType),
routePath = VALUES(routePath), routeName = VALUES(routeName), component = VALUES(component),
redirect = VALUES(redirect), icon = VALUES(icon), sortOrder = VALUES(sortOrder), title = VALUES(title);
@@ -7,6 +7,7 @@ CREATE TABLE IF NOT EXISTS job_config (
robotType VARCHAR(32) NULL COMMENT 'robot type override',
webhookUrl VARCHAR(500) NULL COMMENT 'webhook override',
mailTo VARCHAR(500) NULL COMMENT 'mail recipients',
mailCc VARCHAR(500) NULL COMMENT 'mail cc recipients',
mailSubject VARCHAR(200) NULL COMMENT 'mail subject',
xxlJobId INT NULL COMMENT 'xxl-job id',
localSchedulerEnabled TINYINT(1) NOT NULL DEFAULT 0 COMMENT 'local scheduler enabled flag',