增加禅道看板任务提醒功能

This commit is contained in:
dengqichen 2025-05-28 16:26:42 +08:00
parent 76b9bd2fd4
commit d71708bb65
14 changed files with 1392 additions and 8 deletions

133
ZENTAO_INTEGRATION.md Normal file
View File

@ -0,0 +1,133 @@
# 禅道集成功能说明
## 功能概述
本系统已集成禅道开源版20.8的API可以自动查询项目中的未完成任务并通过企业微信发送详细的任务提醒支持@相关负责人。
## 配置说明
### 1. 禅道配置
`application.yml` 中配置禅道相关信息:
```yaml
task:
reminder:
groups:
- id: "zentao-team"
name: "禅道开发团队"
webhook:
url: "https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=your-webhook-key"
task-system: "zentao" # 必须设置为 zentao
# 禅道配置
zentao:
api-url: "https://zentao.iscmtech.com" # 禅道地址
username: "your-username" # 禅道用户名
password: "your-password" # 禅道密码
project-id: 26 # 项目ID
# 用户映射:禅道邮箱 -> 企业微信手机号
user-mapping:
"liguiling@iscmtech.com": "13800138000"
"zhongweiyue@iscmtech.com": "13900139000"
"meya@iscmtech.com": "13700137000"
schedules:
morning:
time: "0 0 9 * * MON-FRI"
message: "早上好!请及时更新禅道任务状态"
evening:
time: "0 30 17 * * MON-FRI"
message: "下班前提醒:请更新今日任务完成情况"
enabled: true
```
### 2. 用户映射配置
用户映射支持两种方式:
#### 方式一:邮箱映射(推荐)
```yaml
user-mapping:
"user@iscmtech.com": "13800138000" # 禅道用户邮箱 -> 手机号
```
#### 方式二:用户名映射
```yaml
user-mapping:
"zhangsan": "13900139000" # 禅道用户名 -> 手机号
```
### 3. 企业微信@人格式
- 手机号:`13800138000`(系统会自动添加@前缀)
- @所有人`all`(系统会转换为@all
## API接口
### 测试禅道连接
```
GET /api/reminder/zentao/test/{groupId}
```
### 手动触发禅道任务提醒
```
POST /api/reminder/zentao/reminder/{groupId}
```
### 手动触发指定群组提醒
```
POST /api/reminder/groups/{groupId}/morning
POST /api/reminder/groups/{groupId}/evening
```
## 消息格式
系统会发送包含以下信息的详细任务提醒:
- 📊 任务统计(总数、过期数、涉及人员)
- 👥 按人员分组的任务列表
- 🔴 过期任务标识
- 🟡 进行中任务标识
- ⚪ 其他状态任务标识
- 📋 任务优先级和截止日期
- 🔗 禅道系统访问链接
## 使用步骤
1. **配置禅道信息**在配置文件中填入正确的禅道地址、用户名、密码和项目ID
2. **配置用户映射**:将团队成员的禅道邮箱映射到企业微信手机号
3. **测试连接**调用测试API确认禅道连接正常
4. **启用定时任务**:系统会按配置的时间自动发送提醒
## 注意事项
1. **禅道版本**目前支持禅道开源版20.8
2. **API认证**:使用用户名密码方式认证
3. **项目权限**:配置的禅道用户需要有项目访问权限
4. **网络连接**:确保服务器能访问禅道系统
5. **手机号格式**必须是11位中国大陆手机号
## 故障排查
### 1. 禅道连接失败
- 检查禅道地址是否正确
- 检查用户名密码是否正确
- 检查网络连接是否正常
### 2. 无法获取任务
- 检查项目ID是否正确
- 检查用户是否有项目访问权限
- 查看日志中的详细错误信息
### 3. @人不生效
- 检查用户映射配置是否正确
- 确认手机号格式是否正确
- 确认企业微信群中是否有对应用户
## 开发说明
相关代码文件:
- `ZentaoApiService.java` - 禅道API调用服务
- `ZentaoTaskReminderService.java` - 禅道任务提醒服务
- `UserMappingService.java` - 用户映射服务
- `ZentaoTask.java` - 禅道任务实体类
- `ZentaoUser.java` - 禅道用户实体类

View File

@ -65,6 +65,14 @@
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-logging</artifactId>
</dependency>
<!-- Lombok -->
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<version>1.18.30</version>
<optional>true</optional>
</dependency>
</dependencies>
<build>

View File

@ -75,6 +75,8 @@ public class TaskReminderConfig {
private String taskSystem;
private Map<String, Schedule> schedules = new HashMap<>();
private boolean enabled = true;
private Zentao zentao = new Zentao();
private Map<String, String> userMapping = new HashMap<>();
public String getId() {
return id;
@ -123,6 +125,22 @@ public class TaskReminderConfig {
public void setEnabled(boolean enabled) {
this.enabled = enabled;
}
public Zentao getZentao() {
return zentao;
}
public void setZentao(Zentao zentao) {
this.zentao = zentao;
}
public Map<String, String> getUserMapping() {
return userMapping;
}
public void setUserMapping(Map<String, String> userMapping) {
this.userMapping = userMapping;
}
}
public static class Webhook {
@ -157,4 +175,61 @@ public class TaskReminderConfig {
this.message = message;
}
}
public static class Zentao {
private String apiUrl;
private String token;
private String username;
private String password;
private Integer projectId;
private Integer kanbanId;
public String getApiUrl() {
return apiUrl;
}
public void setApiUrl(String apiUrl) {
this.apiUrl = apiUrl;
}
public String getToken() {
return token;
}
public void setToken(String token) {
this.token = token;
}
public String getUsername() {
return username;
}
public void setUsername(String username) {
this.username = username;
}
public String getPassword() {
return password;
}
public void setPassword(String password) {
this.password = password;
}
public Integer getProjectId() {
return projectId;
}
public void setProjectId(Integer projectId) {
this.projectId = projectId;
}
public Integer getKanbanId() {
return kanbanId;
}
public void setKanbanId(Integer kanbanId) {
this.kanbanId = kanbanId;
}
}
}

View File

@ -1,8 +1,10 @@
package com.zeodao.reminder.controller;
import com.zeodao.reminder.config.TaskReminderConfig;
import com.zeodao.reminder.model.ZentaoTask;
import com.zeodao.reminder.scheduler.DynamicTaskScheduler;
import com.zeodao.reminder.service.TaskReminderService;
import com.zeodao.reminder.service.ZentaoTaskReminderService;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Autowired;
@ -35,6 +37,9 @@ public class TaskReminderController {
@Autowired
private DynamicTaskScheduler dynamicTaskScheduler;
@Autowired
private ZentaoTaskReminderService zentaoTaskReminderService;
/**
* 系统健康检查
*/
@ -197,4 +202,72 @@ public class TaskReminderController {
return ResponseEntity.ok(response);
}
/**
* 测试禅道API连接
*/
@GetMapping("/zentao/test/{groupId}")
public ResponseEntity<Map<String, Object>> testZentaoConnection(@PathVariable String groupId) {
Map<String, Object> response = new HashMap<>();
try {
TaskReminderConfig.Group group = taskReminderConfig.getGroupById(groupId);
if (group == null) {
response.put("success", false);
response.put("message", "群组不存在:" + groupId);
return ResponseEntity.ok(response);
}
if (!"zentao".equals(group.getTaskSystem())) {
response.put("success", false);
response.put("message", "该群组不是禅道类型");
return ResponseEntity.ok(response);
}
List<ZentaoTask> tasks = zentaoTaskReminderService.getTasksForTesting(group);
response.put("success", true);
response.put("message", "禅道API连接成功");
response.put("taskCount", tasks.size());
response.put("tasks", tasks);
} catch (Exception e) {
logger.error("测试禅道连接失败: {}", groupId, e);
response.put("success", false);
response.put("message", "测试禅道连接失败:" + e.getMessage());
}
return ResponseEntity.ok(response);
}
/**
* 手动触发禅道任务提醒
*/
@PostMapping("/zentao/reminder/{groupId}")
public ResponseEntity<Map<String, Object>> triggerZentaoReminder(@PathVariable String groupId) {
Map<String, Object> response = new HashMap<>();
try {
TaskReminderConfig.Group group = taskReminderConfig.getGroupById(groupId);
if (group == null) {
response.put("success", false);
response.put("message", "群组不存在:" + groupId);
return ResponseEntity.ok(response);
}
if (!"zentao".equals(group.getTaskSystem())) {
response.put("success", false);
response.put("message", "该群组不是禅道类型");
return ResponseEntity.ok(response);
}
zentaoTaskReminderService.sendTaskReminder(group);
response.put("success", true);
response.put("message", "禅道任务提醒已发送");
} catch (Exception e) {
logger.error("发送禅道任务提醒失败: {}", groupId, e);
response.put("success", false);
response.put("message", "发送禅道任务提醒失败:" + e.getMessage());
}
return ResponseEntity.ok(response);
}
}

View File

@ -0,0 +1,18 @@
package com.zeodao.reminder.exception;
/**
* 项目不存在异常
*
* @author Zeodao
* @version 2.0.0
*/
public class ProjectNotFoundException extends Exception {
public ProjectNotFoundException(String message) {
super(message);
}
public ProjectNotFoundException(String message, Throwable cause) {
super(message, cause);
}
}

View File

@ -0,0 +1,43 @@
package com.zeodao.reminder.model;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
/**
* 项目信息实体类
*
* @author Zeodao
* @version 2.0.0
*/
@Data
@NoArgsConstructor
@AllArgsConstructor
public class ProjectInfo {
private Integer id;
private String name;
private String status;
private boolean exists;
/**
* 检查项目是否处于活跃状态
*/
public boolean isActive() {
return exists && !"closed".equals(status) && !"suspended".equals(status);
}
/**
* 获取项目状态描述
*/
public String getStatusDescription() {
switch (status) {
case "wait": return "未开始";
case "doing": return "进行中";
case "suspended": return "已挂起";
case "closed": return "已关闭";
default: return "未知状态";
}
}
}

View File

@ -0,0 +1,153 @@
package com.zeodao.reminder.model;
import com.fasterxml.jackson.databind.JsonNode;
import lombok.Data;
/**
* 禅道任务实体类 - 使用JsonNode实现动态字段解析
*
* @author Zeodao
* @version 2.0.0
*/
@Data
public class ZentaoTask {
private String id;
private String name;
private String type;
private String status;
private String pri;
private String assignedTo;
private String assignedToRealName;
private String assignedDate;
private String deadline;
private String estStarted;
private String realStarted;
private String finishedBy;
private String finishedDate;
private String canceledBy;
private String canceledDate;
private String closedBy;
private String closedDate;
private String closedReason;
private String lastEditedBy;
private String lastEditedDate;
private String openedBy;
private String openedDate;
private String desc;
private String project;
private String story;
private String storyTitle;
private String module;
private String estimate;
private String consumed;
private String left;
private String progress;
private String color;
private String deleted;
/**
* 从JsonNode创建ZentaoTask对象安全地提取字段
*/
public static ZentaoTask fromJsonNode(JsonNode node) {
ZentaoTask task = new ZentaoTask();
// 安全地提取字段如果字段不存在或类型不匹配使用默认值
task.setId(getStringValue(node, "id"));
task.setName(getStringValue(node, "name"));
task.setType(getStringValue(node, "type"));
task.setStatus(getStringValue(node, "status"));
task.setPri(getStringValue(node, "pri"));
task.setAssignedTo(getStringValue(node, "assignedTo"));
task.setAssignedToRealName(getStringValue(node, "assignedToRealName"));
task.setAssignedDate(getStringValue(node, "assignedDate"));
task.setDeadline(getStringValue(node, "deadline"));
task.setEstStarted(getStringValue(node, "estStarted"));
task.setRealStarted(getStringValue(node, "realStarted"));
task.setFinishedBy(getStringValue(node, "finishedBy"));
task.setFinishedDate(getStringValue(node, "finishedDate"));
task.setCanceledBy(getStringValue(node, "canceledBy"));
task.setCanceledDate(getStringValue(node, "canceledDate"));
task.setClosedBy(getStringValue(node, "closedBy"));
task.setClosedDate(getStringValue(node, "closedDate"));
task.setClosedReason(getStringValue(node, "closedReason"));
task.setLastEditedBy(getStringValue(node, "lastEditedBy"));
task.setLastEditedDate(getStringValue(node, "lastEditedDate"));
task.setOpenedBy(getStringValue(node, "openedBy"));
task.setOpenedDate(getStringValue(node, "openedDate"));
task.setDesc(getStringValue(node, "desc"));
task.setProject(getStringValue(node, "project"));
task.setStory(getStringValue(node, "story"));
task.setStoryTitle(getStringValue(node, "storyTitle"));
task.setModule(getStringValue(node, "module"));
task.setEstimate(getStringValue(node, "estimate"));
task.setConsumed(getStringValue(node, "consumed"));
task.setLeft(getStringValue(node, "left"));
task.setProgress(getStringValue(node, "progress"));
task.setColor(getStringValue(node, "color"));
task.setDeleted(getStringValue(node, "deleted"));
return task;
}
/**
* 安全地从JsonNode中提取字符串值
*/
private static String getStringValue(JsonNode node, String fieldName) {
if (node == null || !node.has(fieldName)) {
return null;
}
JsonNode fieldNode = node.get(fieldName);
if (fieldNode == null || fieldNode.isNull()) {
return null;
}
// 如果是对象类型如openedBy尝试提取其中的字符串字段
if (fieldNode.isObject()) {
// 对于用户对象尝试提取account或realname
if (fieldNode.has("account")) {
return fieldNode.get("account").asText();
} else if (fieldNode.has("realname")) {
return fieldNode.get("realname").asText();
} else if (fieldNode.has("id")) {
return fieldNode.get("id").asText();
}
return null;
}
// 对于基本类型直接转换为字符串
return fieldNode.asText();
}
/**
* 判断任务是否未完成
*/
public boolean isIncomplete() {
return !"done".equals(status) && !"closed".equals(status) && !"cancel".equals(status);
}
/**
* 判断任务是否已过期
*/
public boolean isOverdue() {
if (deadline == null || deadline.isEmpty() || "0000-00-00".equals(deadline)) {
return false;
}
// 简单的日期比较实际项目中应该使用更严格的日期处理
return deadline.compareTo(java.time.LocalDate.now().toString()) < 0;
}
/**
* 获取优先级描述
*/
public String getPriorityDesc() {
switch (pri) {
case "1": return "";
case "2": return "";
case "3": return "";
case "4": return "最低";
default: return "普通";
}
}
}

View File

@ -0,0 +1,49 @@
package com.zeodao.reminder.model;
import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
import lombok.Data;
/**
* 禅道用户实体类
*
* @author Zeodao
* @version 2.0.0
*/
@Data
@JsonIgnoreProperties(ignoreUnknown = true)
public class ZentaoUser {
private String id;
private String account;
private String realname;
private String nickname;
private String avatar;
private String birthday;
private String gender;
private String email;
private String skype;
private String qq;
private String yahoo;
private String gtalk;
private String wangwang;
private String mobile;
private String phone;
private String address;
private String zipcode;
private String join;
private String visits;
private String ip;
private String last;
private String fails;
private String locked;
private String feedback;
private String mail;
private String clientStatus;
private String clientLang;
private String dept;
private String role;
private String type;
private String groups;
private String view;
private String deleted;
}

View File

@ -31,6 +31,9 @@ public class TaskReminderService {
@Autowired
private TaskReminderConfig taskReminderConfig;
@Autowired
private ZentaoTaskReminderService zentaoTaskReminderService;
/**
* 发送指定群组和时间段的任务提醒
*/
@ -59,13 +62,20 @@ public class TaskReminderService {
logger.info("开始发送任务提醒 - 群组: {}, 类型: {}", group.getName(), scheduleType);
String message = wechatWebhookService.createTaskReminderMessage(groupId, schedule.getMessage(), scheduleType + "提醒");
boolean success = wechatWebhookService.sendMarkdownMessage(groupId, message);
if (success) {
logger.info("任务提醒发送成功 - 群组: {}, 类型: {}", group.getName(), scheduleType);
// 根据任务系统类型选择不同的提醒方式
if ("zentao".equals(group.getTaskSystem())) {
// 发送禅道任务提醒
zentaoTaskReminderService.sendTaskReminder(group);
} else {
logger.error("任务提醒发送失败 - 群组: {}, 类型: {}", group.getName(), scheduleType);
// 发送传统的文本提醒
String message = wechatWebhookService.createTaskReminderMessage(groupId, schedule.getMessage(), scheduleType + "提醒");
boolean success = wechatWebhookService.sendMarkdownMessage(groupId, message);
if (success) {
logger.info("任务提醒发送成功 - 群组: {}, 类型: {}", group.getName(), scheduleType);
} else {
logger.error("任务提醒发送失败 - 群组: {}, 类型: {}", group.getName(), scheduleType);
}
}
}

View File

@ -0,0 +1,157 @@
package com.zeodao.reminder.service;
import com.zeodao.reminder.config.TaskReminderConfig;
import com.zeodao.reminder.model.ZentaoUser;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import java.util.List;
import java.util.Map;
/**
* 用户映射服务
* 负责将禅道用户映射到企业微信用户
*
* @author Zeodao
* @version 2.0.0
*/
@Service
public class UserMappingService {
private static final Logger logger = LoggerFactory.getLogger(UserMappingService.class);
@Autowired
private ZentaoApiService zentaoApiService;
/**
* 根据禅道用户名获取企业微信@标识
*
* @param group 群组配置
* @param zentaoUsername 禅道用户名
* @return 企业微信@标识 @13800138000 null
*/
public String getWechatMention(TaskReminderConfig.Group group, String zentaoUsername) {
if (zentaoUsername == null || zentaoUsername.isEmpty()) {
return null;
}
// 1. 首先尝试从配置的用户映射中查找
Map<String, String> userMapping = group.getUserMapping();
if (userMapping != null && !userMapping.isEmpty()) {
// 直接用户名映射
if (userMapping.containsKey(zentaoUsername)) {
String wechatId = userMapping.get(zentaoUsername);
return formatWechatMention(wechatId);
}
// 尝试通过邮箱映射
String email = getUserEmailByUsername(group, zentaoUsername);
if (email != null && userMapping.containsKey(email)) {
String wechatId = userMapping.get(email);
return formatWechatMention(wechatId);
}
}
logger.debug("No WeChat mapping found for Zentao user: {}", zentaoUsername);
return null;
}
/**
* 根据禅道用户名获取用户邮箱
*/
private String getUserEmailByUsername(TaskReminderConfig.Group group, String username) {
try {
List<ZentaoUser> users = zentaoApiService.getUsers(group);
return users.stream()
.filter(user -> username.equals(user.getAccount()))
.map(ZentaoUser::getEmail)
.filter(email -> email != null && !email.isEmpty())
.findFirst()
.orElse(null);
} catch (Exception e) {
logger.error("Error getting user email for username: {}", username, e);
return null;
}
}
/**
* 格式化企业微信@标识
*/
private String formatWechatMention(String wechatId) {
if (wechatId == null || wechatId.isEmpty()) {
return null;
}
// 如果已经是@格式直接返回
if (wechatId.startsWith("@")) {
return wechatId;
}
// 如果是手机号格式添加@前缀
if (wechatId.matches("^1[3-9]\\d{9}$")) {
return "@" + wechatId;
}
// 如果是特殊标识如all添加@前缀
if ("all".equals(wechatId)) {
return "@all";
}
// 其他情况当作用户ID处理
return "@" + wechatId;
}
/**
* 批量获取企业微信@标识
*
* @param group 群组配置
* @param zentaoUsernames 禅道用户名列表
* @return 企业微信@标识列表
*/
public List<String> getWechatMentions(TaskReminderConfig.Group group, List<String> zentaoUsernames) {
if (zentaoUsernames == null || zentaoUsernames.isEmpty()) {
return List.of();
}
return zentaoUsernames.stream()
.map(username -> getWechatMention(group, username))
.filter(mention -> mention != null)
.distinct()
.toList();
}
/**
* 获取所有配置的用户映射信息用于调试
*/
public Map<String, String> getAllUserMappings(TaskReminderConfig.Group group) {
return group.getUserMapping();
}
/**
* 验证用户映射配置
*/
public boolean validateUserMapping(TaskReminderConfig.Group group) {
Map<String, String> userMapping = group.getUserMapping();
if (userMapping == null || userMapping.isEmpty()) {
logger.warn("No user mapping configured for group: {}", group.getId());
return false;
}
int validMappings = 0;
for (Map.Entry<String, String> entry : userMapping.entrySet()) {
String key = entry.getKey();
String value = entry.getValue();
if (key != null && !key.isEmpty() && value != null && !value.isEmpty()) {
validMappings++;
} else {
logger.warn("Invalid user mapping: {} -> {}", key, value);
}
}
logger.info("Group {} has {} valid user mappings", group.getId(), validMappings);
return validMappings > 0;
}
}

View File

@ -126,6 +126,19 @@ public class WechatWebhookService {
return message;
}
/**
* 发送Markdown消息到指定URL
*/
public boolean sendMessage(String webhookUrl, String markdownContent) {
try {
Map<String, Object> messageBody = createMarkdownMessage(markdownContent);
return sendMessage(webhookUrl, messageBody);
} catch (Exception e) {
logger.error("发送企业微信消息失败, URL: {}", webhookUrl, e);
return false;
}
}
/**
* 发送消息到企业微信
*/

View File

@ -0,0 +1,426 @@
package com.zeodao.reminder.service;
import com.fasterxml.jackson.core.type.TypeReference;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.zeodao.reminder.config.TaskReminderConfig;
import com.zeodao.reminder.exception.ProjectNotFoundException;
import com.zeodao.reminder.model.ProjectInfo;
import com.zeodao.reminder.model.ZentaoTask;
import com.zeodao.reminder.model.ZentaoUser;
import org.apache.http.HttpEntity;
import org.apache.http.client.methods.CloseableHttpResponse;
import org.apache.http.client.methods.HttpGet;
import org.apache.http.client.methods.HttpPost;
import org.apache.http.entity.StringEntity;
import org.apache.http.impl.client.CloseableHttpClient;
import org.apache.http.impl.client.HttpClients;
import org.apache.http.util.EntityUtils;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.stereotype.Service;
import java.io.IOException;
import java.net.URLEncoder;
import java.nio.charset.StandardCharsets;
import java.util.*;
/**
* 禅道API服务类
* 支持禅道开源版20.8
*
* @author Zeodao
* @version 2.0.0
*/
@Service
public class ZentaoApiService {
private static final Logger logger = LoggerFactory.getLogger(ZentaoApiService.class);
private final ObjectMapper objectMapper = new ObjectMapper();
private final Map<String, String> sessionCache = new HashMap<>();
/**
* 获取项目下的未完成任务
*/
public List<ZentaoTask> getIncompleteTasks(TaskReminderConfig.Group group) {
try {
String sessionId = getSessionId(group.getZentao());
if (sessionId == null) {
logger.error("Failed to get session for group: {}", group.getId());
return Collections.emptyList();
}
// 尝试多个API路径来获取任务
List<String> urlsToTry = new ArrayList<>();
if (group.getZentao().getKanbanId() != null) {
// 看板模式尝试多个可能的API路径
urlsToTry.add(group.getZentao().getApiUrl() + "/api.php/v1/kanbans/" + group.getZentao().getKanbanId());
urlsToTry.add(group.getZentao().getApiUrl() + "/api.php/v1/executions/" + group.getZentao().getProjectId() + "/tasks");
urlsToTry.add(group.getZentao().getApiUrl() + "/api.php/v1/projects/" + group.getZentao().getProjectId() + "/executions");
urlsToTry.add(group.getZentao().getApiUrl() + "/api.php/v1/tasks?execution=" + group.getZentao().getProjectId());
} else {
// 传统项目模式
urlsToTry.add(group.getZentao().getApiUrl() + "/api.php/v1/projects/" + group.getZentao().getProjectId() + "/tasks");
}
// 尝试每个URL直到找到有效的
for (String url : urlsToTry) {
logger.debug("Trying URL: {}", url);
List<ZentaoTask> tasks = tryGetTasksFromUrl(url, sessionId, group);
if (!tasks.isEmpty()) {
return tasks;
}
}
} catch (Exception e) {
logger.error("Error fetching tasks from Zentao for group: {}", group.getId(), e);
}
return Collections.emptyList();
}
/**
* 检查项目是否存在并获取项目信息
*/
public ProjectInfo getProjectInfo(TaskReminderConfig.Group group) throws ProjectNotFoundException {
try {
String sessionId = getSessionId(group.getZentao());
if (sessionId == null) {
throw new ProjectNotFoundException("无法获取禅道会话,请检查用户名密码是否正确");
}
// 禅道开源版API路径
String url = group.getZentao().getApiUrl() + "/api.php/v1/projects/" + group.getZentao().getProjectId();
try (CloseableHttpClient client = HttpClients.createDefault()) {
HttpGet request = new HttpGet(url);
request.setHeader("Cookie", "zentaosid=" + sessionId);
request.setHeader("Content-Type", "application/json");
try (CloseableHttpResponse response = client.execute(request)) {
String responseBody = EntityUtils.toString(response.getEntity());
logger.debug("Zentao project API response: {}", responseBody);
JsonNode rootNode = objectMapper.readTree(responseBody);
// 检查是否有错误信息
if (rootNode.has("error")) {
String errorMsg = rootNode.get("error").asText();
throw new ProjectNotFoundException("项目不存在或已被删除:" + errorMsg);
}
// 禅道开源版直接返回项目对象不是包装在data字段中
if (rootNode.has("id") && rootNode.has("name")) {
// 检查项目是否被删除
if (rootNode.has("deleted") && rootNode.get("deleted").asBoolean()) {
throw new ProjectNotFoundException("项目ID " + group.getZentao().getProjectId() + " 已被删除");
}
// 检查项目状态
String status = rootNode.has("status") ? rootNode.get("status").asText() : "unknown";
if ("closed".equals(status)) {
logger.warn("项目ID {} 已关闭", group.getZentao().getProjectId());
}
String projectName = rootNode.get("name").asText();
return new ProjectInfo(group.getZentao().getProjectId(), projectName, status, true);
} else {
throw new ProjectNotFoundException("项目ID " + group.getZentao().getProjectId() + " 不存在或无权限访问");
}
}
}
} catch (ProjectNotFoundException e) {
throw e; // 重新抛出项目不存在异常
} catch (Exception e) {
logger.error("Error fetching project info from Zentao for group: {}", group.getId(), e);
throw new ProjectNotFoundException("获取项目信息失败:" + e.getMessage());
}
}
/**
* 获取项目名称兼容旧方法
*/
@Deprecated
public String getProjectName(TaskReminderConfig.Group group) {
try {
ProjectInfo projectInfo = getProjectInfo(group);
return projectInfo.getName();
} catch (ProjectNotFoundException e) {
logger.error("项目不存在: {}", e.getMessage());
return "项目ID: " + group.getZentao().getProjectId() + " (不存在)";
}
}
/**
* 获取用户信息
*/
public List<ZentaoUser> getUsers(TaskReminderConfig.Group group) {
try {
String sessionId = getSessionId(group.getZentao());
if (sessionId == null) {
logger.error("Failed to get session for group: {}", group.getId());
return Collections.emptyList();
}
String url = buildApiUrl(group.getZentao().getApiUrl(), "user", "list", null);
try (CloseableHttpClient client = HttpClients.createDefault()) {
HttpGet request = new HttpGet(url);
request.setHeader("Cookie", "zentaosid=" + sessionId);
request.setHeader("Content-Type", "application/json");
try (CloseableHttpResponse response = client.execute(request)) {
String responseBody = EntityUtils.toString(response.getEntity());
logger.debug("Zentao users API response: {}", responseBody);
JsonNode rootNode = objectMapper.readTree(responseBody);
if (rootNode.has("data")) {
JsonNode dataNode = rootNode.get("data");
return objectMapper.convertValue(
dataNode, new TypeReference<List<ZentaoUser>>() {});
}
}
}
} catch (Exception e) {
logger.error("Error fetching users from Zentao for group: {}", group.getId(), e);
}
return Collections.emptyList();
}
/**
* 获取Session ID
*/
private String getSessionId(TaskReminderConfig.Zentao zentaoConfig) {
String cacheKey = zentaoConfig.getApiUrl() + ":" + zentaoConfig.getUsername();
// 检查缓存
if (sessionCache.containsKey(cacheKey)) {
return sessionCache.get(cacheKey);
}
try {
String loginUrl = zentaoConfig.getApiUrl() + "/api.php/v1/tokens";
try (CloseableHttpClient client = HttpClients.createDefault()) {
HttpPost request = new HttpPost(loginUrl);
request.setHeader("Content-Type", "application/json");
// 构建登录请求体
Map<String, String> loginData = new HashMap<>();
loginData.put("account", zentaoConfig.getUsername());
loginData.put("password", zentaoConfig.getPassword());
String jsonBody = objectMapper.writeValueAsString(loginData);
request.setEntity(new StringEntity(jsonBody, StandardCharsets.UTF_8));
try (CloseableHttpResponse response = client.execute(request)) {
String responseBody = EntityUtils.toString(response.getEntity());
logger.debug("Zentao login response: {}", responseBody);
JsonNode rootNode = objectMapper.readTree(responseBody);
if (rootNode.has("token")) {
String sessionId = rootNode.get("token").asText();
sessionCache.put(cacheKey, sessionId);
return sessionId;
}
}
}
} catch (Exception e) {
logger.error("Error getting session from Zentao", e);
}
return null;
}
/**
* 构建API URL
*/
private String buildApiUrl(String baseUrl, String module, String method, String params) {
StringBuilder url = new StringBuilder(baseUrl);
if (!baseUrl.endsWith("/")) {
url.append("/");
}
url.append("api.php/v1/").append(module).append("/").append(method);
if (params != null && !params.isEmpty()) {
url.append("/").append(params);
}
return url.toString();
}
/**
* 尝试从指定URL获取任务
*/
private List<ZentaoTask> tryGetTasksFromUrl(String url, String sessionId, TaskReminderConfig.Group group) {
try (CloseableHttpClient client = HttpClients.createDefault()) {
HttpGet request = new HttpGet(url);
request.setHeader("Cookie", "zentaosid=" + sessionId);
request.setHeader("Content-Type", "application/json");
try (CloseableHttpResponse response = client.execute(request)) {
String responseBody = EntityUtils.toString(response.getEntity());
logger.debug("Zentao API response for {}: {}", url, responseBody);
JsonNode rootNode = objectMapper.readTree(responseBody);
// 检查是否有错误
if (rootNode.has("error")) {
logger.debug("API returned error for {}: {}", url, rootNode.get("error").asText());
return Collections.emptyList();
}
// 使用安全的JsonNode解析方法
List<ZentaoTask> allTasks = parseTasksFromJsonNode(rootNode);
logger.debug("Found {} tasks, filtering incomplete ones", allTasks.size());
for (ZentaoTask task : allTasks) {
logger.debug("Task: {} - Status: {} - Incomplete: {}",
task.getName(), task.getStatus(), task.isIncomplete());
}
return allTasks.stream()
.filter(ZentaoTask::isIncomplete)
.toList();
}
} catch (Exception e) {
logger.debug("Error trying URL {}: {}", url, e.getMessage());
}
return Collections.emptyList();
}
/**
* 安全地从JsonNode解析任务列表
*/
private List<ZentaoTask> parseTasksFromJsonNode(JsonNode rootNode) {
List<ZentaoTask> tasks = new ArrayList<>();
try {
// 尝试从tasks字段获取任务数组
if (rootNode.has("tasks") && rootNode.get("tasks").isArray()) {
JsonNode tasksArray = rootNode.get("tasks");
for (JsonNode taskNode : tasksArray) {
ZentaoTask task = ZentaoTask.fromJsonNode(taskNode);
if (task != null) {
tasks.add(task);
}
}
return tasks;
}
// 尝试从data字段获取任务
if (rootNode.has("data")) {
JsonNode dataNode = rootNode.get("data");
// 如果data是数组直接解析
if (dataNode.isArray()) {
for (JsonNode taskNode : dataNode) {
ZentaoTask task = ZentaoTask.fromJsonNode(taskNode);
if (task != null) {
tasks.add(task);
}
}
return tasks;
}
// 如果data是对象且包含tasks数组
if (dataNode.has("tasks") && dataNode.get("tasks").isArray()) {
JsonNode tasksArray = dataNode.get("tasks");
for (JsonNode taskNode : tasksArray) {
ZentaoTask task = ZentaoTask.fromJsonNode(taskNode);
if (task != null) {
tasks.add(task);
}
}
return tasks;
}
}
// 如果根节点直接是数组
if (rootNode.isArray()) {
for (JsonNode taskNode : rootNode) {
ZentaoTask task = ZentaoTask.fromJsonNode(taskNode);
if (task != null) {
tasks.add(task);
}
}
return tasks;
}
} catch (Exception e) {
logger.debug("Error parsing tasks from JsonNode: {}", e.getMessage());
}
return tasks;
}
/**
* 解析看板任务数据
*/
private List<ZentaoTask> parseKanbanTasks(JsonNode rootNode) {
List<ZentaoTask> tasks = new ArrayList<>();
try {
// 看板API返回的是泳道数据需要遍历每个泳道中的卡片
if (rootNode.isArray()) {
for (JsonNode laneNode : rootNode) {
if (laneNode.has("cards") && laneNode.get("cards").isArray()) {
for (JsonNode cardNode : laneNode.get("cards")) {
ZentaoTask task = parseKanbanCard(cardNode);
if (task != null && task.isIncomplete()) {
tasks.add(task);
}
}
}
}
}
} catch (Exception e) {
logger.error("Error parsing kanban tasks", e);
}
return tasks;
}
/**
* 解析看板卡片为任务对象
*/
private ZentaoTask parseKanbanCard(JsonNode cardNode) {
try {
ZentaoTask task = new ZentaoTask();
if (cardNode.has("id")) {
task.setId(String.valueOf(cardNode.get("id").asInt()));
}
if (cardNode.has("title")) {
task.setName(cardNode.get("title").asText());
}
if (cardNode.has("assignedTo")) {
task.setAssignedTo(cardNode.get("assignedTo").asText());
}
if (cardNode.has("status")) {
task.setStatus(cardNode.get("status").asText());
}
if (cardNode.has("deadline")) {
task.setDeadline(cardNode.get("deadline").asText());
}
return task;
} catch (Exception e) {
logger.error("Error parsing kanban card", e);
return null;
}
}
/**
* 清除Session缓存
*/
public void clearSessionCache() {
sessionCache.clear();
}
}

View File

@ -0,0 +1,214 @@
package com.zeodao.reminder.service;
import com.zeodao.reminder.config.TaskReminderConfig;
import com.zeodao.reminder.exception.ProjectNotFoundException;
import com.zeodao.reminder.model.ProjectInfo;
import com.zeodao.reminder.model.ZentaoTask;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import java.time.LocalDate;
import java.time.format.DateTimeFormatter;
import java.util.List;
import java.util.Map;
import java.util.stream.Collectors;
/**
* 禅道任务提醒服务
*
* @author Zeodao
* @version 2.0.0
*/
@Service
public class ZentaoTaskReminderService {
private static final Logger logger = LoggerFactory.getLogger(ZentaoTaskReminderService.class);
@Autowired
private ZentaoApiService zentaoApiService;
@Autowired
private UserMappingService userMappingService;
@Autowired
private WechatWebhookService wechatWebhookService;
/**
* 发送禅道任务提醒
*/
public void sendTaskReminder(TaskReminderConfig.Group group) {
try {
logger.info("Starting Zentao task reminder for group: {}", group.getId());
// 首先检查项目是否存在
ProjectInfo projectInfo;
try {
projectInfo = zentaoApiService.getProjectInfo(group);
logger.info("Project found: {} (ID: {}, Status: {})",
projectInfo.getName(), projectInfo.getId(), projectInfo.getStatus());
} catch (ProjectNotFoundException e) {
logger.error("Project validation failed for group {}: {}", group.getId(), e.getMessage());
// 项目不存在时只记录日志不发送企业微信消息
throw new RuntimeException("禅道项目配置错误: " + e.getMessage(), e);
}
// 验证用户映射配置
if (!userMappingService.validateUserMapping(group)) {
logger.warn("User mapping validation failed for group: {}", group.getId());
return;
}
// 获取未完成任务
List<ZentaoTask> incompleteTasks = zentaoApiService.getIncompleteTasks(group);
if (incompleteTasks.isEmpty()) {
logger.info("No incomplete tasks found for group: {}", group.getId());
sendNoTasksMessage(group, projectInfo);
return;
}
// 按负责人分组
Map<String, List<ZentaoTask>> tasksByAssignee = incompleteTasks.stream()
.filter(task -> task.getAssignedTo() != null && !task.getAssignedTo().isEmpty())
.collect(Collectors.groupingBy(ZentaoTask::getAssignedTo));
// 生成提醒消息
String message = buildTaskReminderMessage(group, tasksByAssignee, incompleteTasks, projectInfo);
// 发送消息
wechatWebhookService.sendMessage(group.getWebhook().getUrl(), message);
logger.info("Zentao task reminder sent successfully for group: {}", group.getId());
} catch (Exception e) {
logger.error("Error sending Zentao task reminder for group: {}", group.getId(), e);
}
}
/**
* 构建任务提醒消息
*/
private String buildTaskReminderMessage(TaskReminderConfig.Group group,
Map<String, List<ZentaoTask>> tasksByAssignee,
List<ZentaoTask> allTasks,
ProjectInfo projectInfo) {
StringBuilder message = new StringBuilder();
// 消息头部
message.append("📋 **禅道任务提醒**\n");
message.append("项目: ").append(projectInfo.getName());
// 如果项目状态不是进行中显示状态
if (!"doing".equals(projectInfo.getStatus())) {
message.append(" [").append(projectInfo.getStatusDescription()).append("]");
}
message.append("\n");
message.append("统计时间: ").append(LocalDate.now().format(DateTimeFormatter.ofPattern("yyyy-MM-dd"))).append("\n\n");
// 统计信息
long overdueTasks = allTasks.stream().filter(ZentaoTask::isOverdue).count();
message.append("📊 **任务统计**\n");
message.append("- 未完成任务总数: ").append(allTasks.size()).append("\n");
message.append("- 已过期任务: ").append(overdueTasks).append("\n");
message.append("- 涉及人员: ").append(tasksByAssignee.size()).append("\n\n");
// 按人员列出任务
message.append("👥 **任务分配详情**\n");
for (Map.Entry<String, List<ZentaoTask>> entry : tasksByAssignee.entrySet()) {
String assignee = entry.getKey();
List<ZentaoTask> tasks = entry.getValue();
// 获取企业微信@标识
String wechatMention = userMappingService.getWechatMention(group, assignee);
String displayName = wechatMention != null ? wechatMention : assignee;
message.append("\n**").append(displayName).append("** (").append(tasks.size()).append("个任务)\n");
for (ZentaoTask task : tasks) {
message.append("- ");
// 任务状态图标
if (task.isOverdue()) {
message.append("🔴 ");
} else if ("doing".equals(task.getStatus())) {
message.append("🟡 ");
} else {
message.append("");
}
// 任务信息
message.append("[").append(task.getId()).append("] ");
message.append(task.getName());
// 优先级
if (!"3".equals(task.getPri())) { // 不是普通优先级
message.append(" [").append(task.getPriorityDesc()).append("]");
}
// 截止日期
if (task.getDeadline() != null && !task.getDeadline().isEmpty() && !"0000-00-00".equals(task.getDeadline())) {
message.append(" (截止: ").append(task.getDeadline()).append(")");
}
message.append("\n");
}
}
// 消息尾部
message.append("\n💡 **温馨提示**\n");
message.append("- 🔴 表示已过期任务,请优先处理\n");
message.append("- 🟡 表示进行中任务\n");
message.append("- ⚪ 表示其他状态任务\n");
message.append("- 请及时登录禅道系统更新任务状态\n");
// 禅道链接
String zentaoUrl = group.getZentao().getApiUrl().replace("/api.php", "");
message.append("- [点击访问禅道系统](").append(zentaoUrl).append(")\n");
return message.toString();
}
/**
* 发送无任务消息
*/
private void sendNoTasksMessage(TaskReminderConfig.Group group, ProjectInfo projectInfo) {
StringBuilder message = new StringBuilder();
message.append("✅ **禅道任务提醒**\n");
message.append("项目: ").append(projectInfo.getName());
// 如果项目状态不是进行中显示状态
if (!"doing".equals(projectInfo.getStatus())) {
message.append(" [").append(projectInfo.getStatusDescription()).append("]");
}
message.append("\n");
message.append("统计时间: ").append(LocalDate.now().format(DateTimeFormatter.ofPattern("yyyy-MM-dd"))).append("\n\n");
message.append("🎉 恭喜!当前项目没有未完成的任务。\n");
message.append("继续保持,团队表现优秀!💪");
try {
wechatWebhookService.sendMessage(group.getWebhook().getUrl(), message.toString());
} catch (Exception e) {
logger.error("Error sending no tasks message for group: {}", group.getId(), e);
}
}
/**
* 获取任务详情用于测试
*/
public List<ZentaoTask> getTasksForTesting(TaskReminderConfig.Group group) {
try {
// 先检查项目是否存在
zentaoApiService.getProjectInfo(group);
return zentaoApiService.getIncompleteTasks(group);
} catch (ProjectNotFoundException e) {
logger.error("Project not found during testing: {}", e.getMessage());
return List.of(); // 返回空列表
}
}
}

View File

@ -20,6 +20,18 @@ task:
webhook:
url: "https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=614b110b-8957-4be8-95b9-4eca84c15028"
task-system: "zentao" # 任务管理系统类型zentao, smartsheet, jira, trello 等
# 禅道配置
zentao:
api-url: "https://zentao.iscmtech.com"
username: "admin" # 请替换为实际的禅道用户名
password: "Lianyu!@#~123456" # 请替换为实际的禅道密码
project-id: 38 # 项目ID
kanban-id: 39 # 看板ID看板模式项目需要
# 用户映射:禅道用户名/邮箱 -> 企业微信手机号
user-mapping:
"dengqichen@iscmtech.com": "18525522818"
# 也可以直接用用户名映射
# "zhangsan": "13600136000"
schedules:
morning:
time: "0 0 9 * * MON-FRI" # 工作日早上9点
@ -33,7 +45,7 @@ task:
- id: "smartsheet-team"
name: "智能表格团队"
webhook:
url: "https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=ed54908b-0f58-46b7-beb6-8facca4438d6"
url: "https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=614b110b-8957-4be8-95b9-4eca84c15028"
task-system: "smartsheet"
schedules:
morning: