增加禅道看板任务提醒功能
This commit is contained in:
parent
76b9bd2fd4
commit
d71708bb65
133
ZENTAO_INTEGRATION.md
Normal file
133
ZENTAO_INTEGRATION.md
Normal 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` - 禅道用户实体类
|
||||||
8
pom.xml
8
pom.xml
@ -65,6 +65,14 @@
|
|||||||
<groupId>org.springframework.boot</groupId>
|
<groupId>org.springframework.boot</groupId>
|
||||||
<artifactId>spring-boot-starter-logging</artifactId>
|
<artifactId>spring-boot-starter-logging</artifactId>
|
||||||
</dependency>
|
</dependency>
|
||||||
|
|
||||||
|
<!-- Lombok -->
|
||||||
|
<dependency>
|
||||||
|
<groupId>org.projectlombok</groupId>
|
||||||
|
<artifactId>lombok</artifactId>
|
||||||
|
<version>1.18.30</version>
|
||||||
|
<optional>true</optional>
|
||||||
|
</dependency>
|
||||||
</dependencies>
|
</dependencies>
|
||||||
|
|
||||||
<build>
|
<build>
|
||||||
|
|||||||
@ -75,6 +75,8 @@ public class TaskReminderConfig {
|
|||||||
private String taskSystem;
|
private String taskSystem;
|
||||||
private Map<String, Schedule> schedules = new HashMap<>();
|
private Map<String, Schedule> schedules = new HashMap<>();
|
||||||
private boolean enabled = true;
|
private boolean enabled = true;
|
||||||
|
private Zentao zentao = new Zentao();
|
||||||
|
private Map<String, String> userMapping = new HashMap<>();
|
||||||
|
|
||||||
public String getId() {
|
public String getId() {
|
||||||
return id;
|
return id;
|
||||||
@ -123,6 +125,22 @@ public class TaskReminderConfig {
|
|||||||
public void setEnabled(boolean enabled) {
|
public void setEnabled(boolean enabled) {
|
||||||
this.enabled = 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 {
|
public static class Webhook {
|
||||||
@ -157,4 +175,61 @@ public class TaskReminderConfig {
|
|||||||
this.message = message;
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,8 +1,10 @@
|
|||||||
package com.zeodao.reminder.controller;
|
package com.zeodao.reminder.controller;
|
||||||
|
|
||||||
import com.zeodao.reminder.config.TaskReminderConfig;
|
import com.zeodao.reminder.config.TaskReminderConfig;
|
||||||
|
import com.zeodao.reminder.model.ZentaoTask;
|
||||||
import com.zeodao.reminder.scheduler.DynamicTaskScheduler;
|
import com.zeodao.reminder.scheduler.DynamicTaskScheduler;
|
||||||
import com.zeodao.reminder.service.TaskReminderService;
|
import com.zeodao.reminder.service.TaskReminderService;
|
||||||
|
import com.zeodao.reminder.service.ZentaoTaskReminderService;
|
||||||
import org.slf4j.Logger;
|
import org.slf4j.Logger;
|
||||||
import org.slf4j.LoggerFactory;
|
import org.slf4j.LoggerFactory;
|
||||||
import org.springframework.beans.factory.annotation.Autowired;
|
import org.springframework.beans.factory.annotation.Autowired;
|
||||||
@ -35,6 +37,9 @@ public class TaskReminderController {
|
|||||||
@Autowired
|
@Autowired
|
||||||
private DynamicTaskScheduler dynamicTaskScheduler;
|
private DynamicTaskScheduler dynamicTaskScheduler;
|
||||||
|
|
||||||
|
@Autowired
|
||||||
|
private ZentaoTaskReminderService zentaoTaskReminderService;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 系统健康检查
|
* 系统健康检查
|
||||||
*/
|
*/
|
||||||
@ -197,4 +202,72 @@ public class TaskReminderController {
|
|||||||
|
|
||||||
return ResponseEntity.ok(response);
|
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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
43
src/main/java/com/zeodao/reminder/model/ProjectInfo.java
Normal file
43
src/main/java/com/zeodao/reminder/model/ProjectInfo.java
Normal 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 "未知状态";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
153
src/main/java/com/zeodao/reminder/model/ZentaoTask.java
Normal file
153
src/main/java/com/zeodao/reminder/model/ZentaoTask.java
Normal 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 "普通";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
49
src/main/java/com/zeodao/reminder/model/ZentaoUser.java
Normal file
49
src/main/java/com/zeodao/reminder/model/ZentaoUser.java
Normal 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;
|
||||||
|
}
|
||||||
@ -31,6 +31,9 @@ public class TaskReminderService {
|
|||||||
@Autowired
|
@Autowired
|
||||||
private TaskReminderConfig taskReminderConfig;
|
private TaskReminderConfig taskReminderConfig;
|
||||||
|
|
||||||
|
@Autowired
|
||||||
|
private ZentaoTaskReminderService zentaoTaskReminderService;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 发送指定群组和时间段的任务提醒
|
* 发送指定群组和时间段的任务提醒
|
||||||
*/
|
*/
|
||||||
@ -59,13 +62,20 @@ public class TaskReminderService {
|
|||||||
|
|
||||||
logger.info("开始发送任务提醒 - 群组: {}, 类型: {}", group.getName(), scheduleType);
|
logger.info("开始发送任务提醒 - 群组: {}, 类型: {}", group.getName(), scheduleType);
|
||||||
|
|
||||||
String message = wechatWebhookService.createTaskReminderMessage(groupId, schedule.getMessage(), scheduleType + "提醒");
|
// 根据任务系统类型选择不同的提醒方式
|
||||||
boolean success = wechatWebhookService.sendMarkdownMessage(groupId, message);
|
if ("zentao".equals(group.getTaskSystem())) {
|
||||||
|
// 发送禅道任务提醒
|
||||||
if (success) {
|
zentaoTaskReminderService.sendTaskReminder(group);
|
||||||
logger.info("任务提醒发送成功 - 群组: {}, 类型: {}", group.getName(), scheduleType);
|
|
||||||
} else {
|
} 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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -126,6 +126,19 @@ public class WechatWebhookService {
|
|||||||
return message;
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 发送消息到企业微信
|
* 发送消息到企业微信
|
||||||
*/
|
*/
|
||||||
|
|||||||
426
src/main/java/com/zeodao/reminder/service/ZentaoApiService.java
Normal file
426
src/main/java/com/zeodao/reminder/service/ZentaoApiService.java
Normal 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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(); // 返回空列表
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -20,6 +20,18 @@ task:
|
|||||||
webhook:
|
webhook:
|
||||||
url: "https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=614b110b-8957-4be8-95b9-4eca84c15028"
|
url: "https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=614b110b-8957-4be8-95b9-4eca84c15028"
|
||||||
task-system: "zentao" # 任务管理系统类型:zentao, smartsheet, jira, trello 等
|
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:
|
schedules:
|
||||||
morning:
|
morning:
|
||||||
time: "0 0 9 * * MON-FRI" # 工作日早上9点
|
time: "0 0 9 * * MON-FRI" # 工作日早上9点
|
||||||
@ -33,7 +45,7 @@ task:
|
|||||||
- id: "smartsheet-team"
|
- id: "smartsheet-team"
|
||||||
name: "智能表格团队"
|
name: "智能表格团队"
|
||||||
webhook:
|
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"
|
task-system: "smartsheet"
|
||||||
schedules:
|
schedules:
|
||||||
morning:
|
morning:
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user