From d71708bb65a14acd76e81a4cda5aaa165ed49c49 Mon Sep 17 00:00:00 2001 From: dengqichen Date: Wed, 28 May 2025 16:26:42 +0800 Subject: [PATCH] =?UTF-8?q?=E5=A2=9E=E5=8A=A0=E7=A6=85=E9=81=93=E7=9C=8B?= =?UTF-8?q?=E6=9D=BF=E4=BB=BB=E5=8A=A1=E6=8F=90=E9=86=92=E5=8A=9F=E8=83=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ZENTAO_INTEGRATION.md | 133 ++++++ pom.xml | 8 + .../reminder/config/TaskReminderConfig.java | 77 +++- .../controller/TaskReminderController.java | 73 +++ .../exception/ProjectNotFoundException.java | 18 + .../zeodao/reminder/model/ProjectInfo.java | 43 ++ .../com/zeodao/reminder/model/ZentaoTask.java | 153 +++++++ .../com/zeodao/reminder/model/ZentaoUser.java | 49 ++ .../reminder/service/TaskReminderService.java | 22 +- .../reminder/service/UserMappingService.java | 157 +++++++ .../service/WechatWebhookService.java | 13 + .../reminder/service/ZentaoApiService.java | 426 ++++++++++++++++++ .../service/ZentaoTaskReminderService.java | 214 +++++++++ src/main/resources/application.yml | 14 +- 14 files changed, 1392 insertions(+), 8 deletions(-) create mode 100644 ZENTAO_INTEGRATION.md create mode 100644 src/main/java/com/zeodao/reminder/exception/ProjectNotFoundException.java create mode 100644 src/main/java/com/zeodao/reminder/model/ProjectInfo.java create mode 100644 src/main/java/com/zeodao/reminder/model/ZentaoTask.java create mode 100644 src/main/java/com/zeodao/reminder/model/ZentaoUser.java create mode 100644 src/main/java/com/zeodao/reminder/service/UserMappingService.java create mode 100644 src/main/java/com/zeodao/reminder/service/ZentaoApiService.java create mode 100644 src/main/java/com/zeodao/reminder/service/ZentaoTaskReminderService.java diff --git a/ZENTAO_INTEGRATION.md b/ZENTAO_INTEGRATION.md new file mode 100644 index 0000000..0cbd0d7 --- /dev/null +++ b/ZENTAO_INTEGRATION.md @@ -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` - 禅道用户实体类 diff --git a/pom.xml b/pom.xml index 150c335..70f52c6 100644 --- a/pom.xml +++ b/pom.xml @@ -65,6 +65,14 @@ org.springframework.boot spring-boot-starter-logging + + + + org.projectlombok + lombok + 1.18.30 + true + diff --git a/src/main/java/com/zeodao/reminder/config/TaskReminderConfig.java b/src/main/java/com/zeodao/reminder/config/TaskReminderConfig.java index e991e3b..e7b5a37 100644 --- a/src/main/java/com/zeodao/reminder/config/TaskReminderConfig.java +++ b/src/main/java/com/zeodao/reminder/config/TaskReminderConfig.java @@ -10,7 +10,7 @@ import java.util.Map; /** * 多群任务提醒配置类 - * + * * @author Zeodao * @version 2.0.0 */ @@ -75,6 +75,8 @@ public class TaskReminderConfig { private String taskSystem; private Map schedules = new HashMap<>(); private boolean enabled = true; + private Zentao zentao = new Zentao(); + private Map 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 getUserMapping() { + return userMapping; + } + + public void setUserMapping(Map 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; + } + } } diff --git a/src/main/java/com/zeodao/reminder/controller/TaskReminderController.java b/src/main/java/com/zeodao/reminder/controller/TaskReminderController.java index 12720f4..329e84f 100644 --- a/src/main/java/com/zeodao/reminder/controller/TaskReminderController.java +++ b/src/main/java/com/zeodao/reminder/controller/TaskReminderController.java @@ -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> testZentaoConnection(@PathVariable String groupId) { + Map 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 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> triggerZentaoReminder(@PathVariable String groupId) { + Map 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); + } } diff --git a/src/main/java/com/zeodao/reminder/exception/ProjectNotFoundException.java b/src/main/java/com/zeodao/reminder/exception/ProjectNotFoundException.java new file mode 100644 index 0000000..0653454 --- /dev/null +++ b/src/main/java/com/zeodao/reminder/exception/ProjectNotFoundException.java @@ -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); + } +} diff --git a/src/main/java/com/zeodao/reminder/model/ProjectInfo.java b/src/main/java/com/zeodao/reminder/model/ProjectInfo.java new file mode 100644 index 0000000..f6fc3d1 --- /dev/null +++ b/src/main/java/com/zeodao/reminder/model/ProjectInfo.java @@ -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 "未知状态"; + } + } + +} diff --git a/src/main/java/com/zeodao/reminder/model/ZentaoTask.java b/src/main/java/com/zeodao/reminder/model/ZentaoTask.java new file mode 100644 index 0000000..be885c2 --- /dev/null +++ b/src/main/java/com/zeodao/reminder/model/ZentaoTask.java @@ -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 "普通"; + } + } +} diff --git a/src/main/java/com/zeodao/reminder/model/ZentaoUser.java b/src/main/java/com/zeodao/reminder/model/ZentaoUser.java new file mode 100644 index 0000000..5d5104d --- /dev/null +++ b/src/main/java/com/zeodao/reminder/model/ZentaoUser.java @@ -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; +} diff --git a/src/main/java/com/zeodao/reminder/service/TaskReminderService.java b/src/main/java/com/zeodao/reminder/service/TaskReminderService.java index 1d423b3..7ec48d7 100644 --- a/src/main/java/com/zeodao/reminder/service/TaskReminderService.java +++ b/src/main/java/com/zeodao/reminder/service/TaskReminderService.java @@ -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); + } } } diff --git a/src/main/java/com/zeodao/reminder/service/UserMappingService.java b/src/main/java/com/zeodao/reminder/service/UserMappingService.java new file mode 100644 index 0000000..898d079 --- /dev/null +++ b/src/main/java/com/zeodao/reminder/service/UserMappingService.java @@ -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 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 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 getWechatMentions(TaskReminderConfig.Group group, List 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 getAllUserMappings(TaskReminderConfig.Group group) { + return group.getUserMapping(); + } + + /** + * 验证用户映射配置 + */ + public boolean validateUserMapping(TaskReminderConfig.Group group) { + Map 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 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; + } +} diff --git a/src/main/java/com/zeodao/reminder/service/WechatWebhookService.java b/src/main/java/com/zeodao/reminder/service/WechatWebhookService.java index ec368d2..7fe3666 100644 --- a/src/main/java/com/zeodao/reminder/service/WechatWebhookService.java +++ b/src/main/java/com/zeodao/reminder/service/WechatWebhookService.java @@ -126,6 +126,19 @@ public class WechatWebhookService { return message; } + /** + * 发送Markdown消息到指定URL + */ + public boolean sendMessage(String webhookUrl, String markdownContent) { + try { + Map messageBody = createMarkdownMessage(markdownContent); + return sendMessage(webhookUrl, messageBody); + } catch (Exception e) { + logger.error("发送企业微信消息失败, URL: {}", webhookUrl, e); + return false; + } + } + /** * 发送消息到企业微信 */ diff --git a/src/main/java/com/zeodao/reminder/service/ZentaoApiService.java b/src/main/java/com/zeodao/reminder/service/ZentaoApiService.java new file mode 100644 index 0000000..dce671a --- /dev/null +++ b/src/main/java/com/zeodao/reminder/service/ZentaoApiService.java @@ -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 sessionCache = new HashMap<>(); + + /** + * 获取项目下的未完成任务 + */ + public List 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 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 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 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>() {}); + } + } + } + } 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 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 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 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 parseTasksFromJsonNode(JsonNode rootNode) { + List 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 parseKanbanTasks(JsonNode rootNode) { + List 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(); + } +} diff --git a/src/main/java/com/zeodao/reminder/service/ZentaoTaskReminderService.java b/src/main/java/com/zeodao/reminder/service/ZentaoTaskReminderService.java new file mode 100644 index 0000000..98699c9 --- /dev/null +++ b/src/main/java/com/zeodao/reminder/service/ZentaoTaskReminderService.java @@ -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 incompleteTasks = zentaoApiService.getIncompleteTasks(group); + if (incompleteTasks.isEmpty()) { + logger.info("No incomplete tasks found for group: {}", group.getId()); + sendNoTasksMessage(group, projectInfo); + return; + } + + // 按负责人分组 + Map> 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> tasksByAssignee, + List 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> entry : tasksByAssignee.entrySet()) { + String assignee = entry.getKey(); + List 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 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(); // 返回空列表 + } + } +} diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index 097839b..eedae02 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -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: