feat(ticket): 实现工单系统并集成企业微信飞书通知
- 在控制器中统一返回格式,修复fail方法调用 - 实现工单提交、查询、回复等核心功能 - 添加工单状态管理(待处理、已分配、处理中、已解决、已关闭) - 集成企业微信群机器人和飞书群机器人实时通知 - 实现异步推送机制支持四种通知场景:新工单、重新分配、新回复、状态变更 - 添加工单统计功能和用户权限控制 - 创建工单主表和回复表的数据库结构定义
This commit is contained in:
48
docs/sql/2026-03-30_app_ticket_tables.sql
Normal file
48
docs/sql/2026-03-30_app_ticket_tables.sql
Normal file
@@ -0,0 +1,48 @@
|
||||
-- 工单主表
|
||||
CREATE TABLE IF NOT EXISTS `app_ticket` (
|
||||
`ticket_id` BIGINT NOT NULL AUTO_INCREMENT COMMENT '工单ID',
|
||||
`ticket_no` VARCHAR(32) NOT NULL COMMENT '工单编号(TK-yyyyMMddHHmmss+4位随机)',
|
||||
`title` VARCHAR(200) NOT NULL COMMENT '工单标题',
|
||||
`content` TEXT NOT NULL COMMENT '工单内容描述',
|
||||
`website_id` BIGINT DEFAULT NULL COMMENT '关联应用ID',
|
||||
`website_name` VARCHAR(100) DEFAULT NULL COMMENT '应用名称(冗余)',
|
||||
`category` VARCHAR(30) NOT NULL DEFAULT 'other' COMMENT '分类: bug/feature/consultation/complaint/other',
|
||||
`priority` VARCHAR(20) NOT NULL DEFAULT 'normal' COMMENT '优先级: low/normal/high/urgent',
|
||||
`status` VARCHAR(20) NOT NULL DEFAULT 'pending' COMMENT '状态: pending/assigned/processing/resolved/closed/rejected',
|
||||
`attachments` TEXT DEFAULT NULL COMMENT '附件JSON数组',
|
||||
`submit_user_id` INT NOT NULL COMMENT '提交人用户ID',
|
||||
`submit_user_name` VARCHAR(50) DEFAULT NULL COMMENT '提交人昵称(冗余)',
|
||||
`submit_user_avatar` VARCHAR(500) DEFAULT NULL COMMENT '提交人头像(冗余)',
|
||||
`assignee_id` INT DEFAULT NULL COMMENT '处理人用户ID',
|
||||
`assignee_name` VARCHAR(50) DEFAULT NULL COMMENT '处理人昵称(冗余)',
|
||||
`assignee_avatar` VARCHAR(500) DEFAULT NULL COMMENT '处理人头像(冗余)',
|
||||
`reply_count` INT NOT NULL DEFAULT 0 COMMENT '回复数量',
|
||||
`resolved_time` DATETIME DEFAULT NULL COMMENT '解决时间',
|
||||
`closed_time` DATETIME DEFAULT NULL COMMENT '关闭时间',
|
||||
`deleted` TINYINT NOT NULL DEFAULT 0 COMMENT '是否删除: 0否 1是',
|
||||
`create_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
|
||||
`update_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
|
||||
PRIMARY KEY (`ticket_id`),
|
||||
UNIQUE KEY `uk_ticket_no` (`ticket_no`),
|
||||
KEY `idx_submit_user` (`submit_user_id`),
|
||||
KEY `idx_assignee` (`assignee_id`),
|
||||
KEY `idx_website_status` (`website_id`, `status`),
|
||||
KEY `idx_status_create` (`status`, `create_time`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='应用工单';
|
||||
|
||||
-- 工单回复表
|
||||
CREATE TABLE IF NOT EXISTS `app_ticket_reply` (
|
||||
`reply_id` BIGINT NOT NULL AUTO_INCREMENT COMMENT '回复ID',
|
||||
`ticket_id` BIGINT NOT NULL COMMENT '关联工单ID',
|
||||
`content` TEXT NOT NULL COMMENT '回复内容',
|
||||
`attachments` TEXT DEFAULT NULL COMMENT '附件JSON数组',
|
||||
`user_id` INT NOT NULL COMMENT '回复人用户ID',
|
||||
`user_name` VARCHAR(50) DEFAULT NULL COMMENT '回复人昵称(冗余)',
|
||||
`user_avatar` VARCHAR(500) DEFAULT NULL COMMENT '回复人头像(冗余)',
|
||||
`is_staff` TINYINT NOT NULL DEFAULT 0 COMMENT '是否技术人员/客服: 0否 1是',
|
||||
`deleted` TINYINT NOT NULL DEFAULT 0 COMMENT '是否删除: 0否 1是',
|
||||
`create_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
|
||||
PRIMARY KEY (`reply_id`),
|
||||
KEY `idx_ticket_id` (`ticket_id`),
|
||||
KEY `idx_user_id` (`user_id`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='工单回复';
|
||||
@@ -38,7 +38,7 @@ public class AppTicketController extends BaseController {
|
||||
@GetMapping("/my")
|
||||
public ApiResult<PageResult<AppTicket>> myTickets(AppTicketParam param) {
|
||||
User loginUser = getLoginUser();
|
||||
if (loginUser == null) return fail("请先登录");
|
||||
if (loginUser == null) return fail("请先登录",null);
|
||||
return success(appTicketService.myPage(param, loginUser.getUserId()));
|
||||
}
|
||||
|
||||
@@ -46,12 +46,12 @@ public class AppTicketController extends BaseController {
|
||||
@PostMapping("/submit")
|
||||
public ApiResult<AppTicket> submit(@RequestBody AppTicket ticket) {
|
||||
User loginUser = getLoginUser();
|
||||
if (loginUser == null) return fail("请先登录");
|
||||
if (loginUser == null) return fail("请先登录",null);
|
||||
try {
|
||||
AppTicket result = appTicketService.submit(ticket, loginUser.getUserId());
|
||||
return success("工单提交成功", result);
|
||||
} catch (Exception e) {
|
||||
return fail(e.getMessage());
|
||||
return fail(e.getMessage(),null);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -74,7 +74,7 @@ public class AppTicketController extends BaseController {
|
||||
@GetMapping("/list")
|
||||
public ApiResult<PageResult<AppTicket>> allTickets(AppTicketParam param) {
|
||||
User loginUser = getLoginUser();
|
||||
if (loginUser == null) return fail("请先登录");
|
||||
if (loginUser == null) return fail("请先登录",null);
|
||||
return success(appTicketService.allPage(param));
|
||||
}
|
||||
|
||||
@@ -118,15 +118,15 @@ public class AppTicketController extends BaseController {
|
||||
@PostMapping("/reply")
|
||||
public ApiResult<AppTicketReply> reply(@RequestBody AppTicketReply reply) {
|
||||
User loginUser = getLoginUser();
|
||||
if (loginUser == null) return fail("请先登录");
|
||||
if (loginUser == null) return fail("请先登录",null);
|
||||
if (reply.getContent() == null || reply.getContent().trim().isEmpty()) {
|
||||
return fail("回复内容不能为空");
|
||||
return fail("回复内容不能为空",null);
|
||||
}
|
||||
try {
|
||||
AppTicketReply result = appTicketService.addReply(reply, loginUser.getUserId());
|
||||
return success("回复成功", result);
|
||||
} catch (Exception e) {
|
||||
return fail(e.getMessage());
|
||||
return fail(e.getMessage(),null);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -137,7 +137,7 @@ public class AppTicketController extends BaseController {
|
||||
public ApiResult<Map<String, Long>> stats(
|
||||
@RequestParam(required = false) Long websiteId) {
|
||||
User loginUser = getLoginUser();
|
||||
if (loginUser == null) return fail("请先登录");
|
||||
if (loginUser == null) return fail("请先登录",null);
|
||||
// 技术端不限制用户维度;客户端通过路由区分
|
||||
return success(appTicketService.stats(websiteId, null));
|
||||
}
|
||||
|
||||
@@ -1,10 +1,14 @@
|
||||
package com.gxwebsoft.app.service.impl;
|
||||
|
||||
import cn.hutool.core.util.IdUtil;
|
||||
import cn.hutool.core.util.ObjectUtil;
|
||||
import cn.hutool.core.util.StrUtil;
|
||||
import cn.hutool.http.HttpUtil;
|
||||
import com.alibaba.fastjson.JSON;
|
||||
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
|
||||
import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper;
|
||||
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
|
||||
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
|
||||
import com.gxwebsoft.common.core.web.PageParam;
|
||||
import com.gxwebsoft.app.entity.AppTicket;
|
||||
import com.gxwebsoft.app.entity.AppTicketReply;
|
||||
import com.gxwebsoft.app.entity.AppUser;
|
||||
@@ -13,11 +17,12 @@ import com.gxwebsoft.app.mapper.AppTicketReplyMapper;
|
||||
import com.gxwebsoft.app.param.AppTicketParam;
|
||||
import com.gxwebsoft.app.service.AppTicketService;
|
||||
import com.gxwebsoft.app.service.AppUserService;
|
||||
import com.gxwebsoft.common.core.web.PageParam;
|
||||
import com.gxwebsoft.common.core.web.PageResult;
|
||||
import com.gxwebsoft.common.system.entity.User;
|
||||
import com.gxwebsoft.common.system.service.UserService;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.scheduling.annotation.Async;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
@@ -37,6 +42,14 @@ import java.util.stream.Collectors;
|
||||
@Service
|
||||
public class AppTicketServiceImpl extends ServiceImpl<AppTicketMapper, AppTicket> implements AppTicketService {
|
||||
|
||||
/** 企业微信群机器人 Webhook,留空则不发送 */
|
||||
@Value("${notify.wecom-webhook:}")
|
||||
private String wecomWebhook;
|
||||
|
||||
/** 飞书群机器人 Webhook,留空则不发送 */
|
||||
@Value("${notify.feishu-webhook:}")
|
||||
private String feishuWebhook;
|
||||
|
||||
@Resource
|
||||
private AppTicketReplyMapper replyMapper;
|
||||
|
||||
@@ -56,10 +69,8 @@ public class AppTicketServiceImpl extends ServiceImpl<AppTicketMapper, AppTicket
|
||||
// ─── 客户端:查自己的工单 ─────────────────────────────────────
|
||||
@Override
|
||||
public PageResult<AppTicket> myPage(AppTicketParam param, Integer userId) {
|
||||
com.baomidou.mybatisplus.extension.plugins.pagination.Page<AppTicket> page =
|
||||
new com.baomidou.mybatisplus.extension.plugins.pagination.Page<>(
|
||||
param.getPageNum() != null ? param.getPageNum() : 1,
|
||||
param.getPageSize() != null ? param.getPageSize() : 15);
|
||||
// 用 PageParam 包装分页参数(getCurrent/getSize 来自父类 Page<T>)
|
||||
Page<AppTicket> page = new Page<>(param.getCurrent(), param.getSize() > 0 ? param.getSize() : 15);
|
||||
|
||||
LambdaQueryWrapper<AppTicket> wrapper = new LambdaQueryWrapper<AppTicket>()
|
||||
.eq(AppTicket::getDeleted, 0)
|
||||
@@ -79,10 +90,7 @@ public class AppTicketServiceImpl extends ServiceImpl<AppTicketMapper, AppTicket
|
||||
// ─── 技术端:查所有工单 ───────────────────────────────────────
|
||||
@Override
|
||||
public PageResult<AppTicket> allPage(AppTicketParam param) {
|
||||
com.baomidou.mybatisplus.extension.plugins.pagination.Page<AppTicket> page =
|
||||
new com.baomidou.mybatisplus.extension.plugins.pagination.Page<>(
|
||||
param.getPageNum() != null ? param.getPageNum() : 1,
|
||||
param.getPageSize() != null ? param.getPageSize() : 20);
|
||||
Page<AppTicket> page = new Page<>(param.getCurrent(), param.getSize() > 0 ? param.getSize() : 20);
|
||||
|
||||
LambdaQueryWrapper<AppTicket> wrapper = new LambdaQueryWrapper<AppTicket>()
|
||||
.eq(AppTicket::getDeleted, 0)
|
||||
@@ -132,6 +140,10 @@ public class AppTicketServiceImpl extends ServiceImpl<AppTicketMapper, AppTicket
|
||||
autoAssign(ticket);
|
||||
|
||||
save(ticket);
|
||||
|
||||
// 异步推送:新工单通知(通知分配到的技术人员)
|
||||
sendTicketCreatedAsync(ticket);
|
||||
|
||||
return ticket;
|
||||
}
|
||||
|
||||
@@ -173,6 +185,12 @@ public class AppTicketServiceImpl extends ServiceImpl<AppTicketMapper, AppTicket
|
||||
}
|
||||
}
|
||||
update(wrapper);
|
||||
|
||||
// 异步推送:状态变更通知提交人(已解决/已关闭)
|
||||
if ("resolved".equals(status) || "closed".equals(status)) {
|
||||
AppTicket t = getById(ticketId);
|
||||
if (t != null) sendStatusChangedAsync(t, status);
|
||||
}
|
||||
}
|
||||
|
||||
// ─── 分配处理人 ───────────────────────────────────────────────
|
||||
@@ -188,6 +206,10 @@ public class AppTicketServiceImpl extends ServiceImpl<AppTicketMapper, AppTicket
|
||||
.set(AppTicket::getStatus, "assigned")
|
||||
.set(AppTicket::getUpdateTime, LocalDateTime.now());
|
||||
update(wrapper);
|
||||
|
||||
// 异步推送:通知新分配到的技术人员
|
||||
AppTicket t = getById(ticketId);
|
||||
if (t != null) sendTicketAssignedAsync(t);
|
||||
}
|
||||
|
||||
// ─── 用户关闭工单 ─────────────────────────────────────────────
|
||||
@@ -248,15 +270,17 @@ public class AppTicketServiceImpl extends ServiceImpl<AppTicketMapper, AppTicket
|
||||
|
||||
replyMapper.insert(reply);
|
||||
|
||||
// 更新工单回复数 & 更新时间
|
||||
// 更新工单回复数 & 更新时间,若状态是 assigned 则推进为 processing
|
||||
update(new LambdaUpdateWrapper<AppTicket>()
|
||||
.eq(AppTicket::getTicketId, reply.getTicketId())
|
||||
.setSql("reply_count = reply_count + 1")
|
||||
.set(AppTicket::getUpdateTime, LocalDateTime.now())
|
||||
// 若状态是 assigned,更新为 processing
|
||||
.eq(AppTicket::getStatus, "assigned")
|
||||
.set(AppTicket::getStatus, "processing"));
|
||||
|
||||
// 异步推送:有新回复时通知对方
|
||||
if (ticket != null) sendReplyNotifyAsync(ticket, reply);
|
||||
|
||||
return reply;
|
||||
}
|
||||
|
||||
@@ -269,11 +293,11 @@ public class AppTicketServiceImpl extends ServiceImpl<AppTicketMapper, AppTicket
|
||||
.eq(ObjectUtil.isNotNull(userId), AppTicket::getSubmitUserId, userId);
|
||||
|
||||
Map<String, Long> result = new HashMap<>();
|
||||
result.put("total", count(base.clone()));
|
||||
result.put("pending", count(base.clone().in(AppTicket::getStatus, "pending", "assigned")));
|
||||
result.put("processing", count(base.clone().eq(AppTicket::getStatus, "processing")));
|
||||
result.put("resolved", count(base.clone().eq(AppTicket::getStatus, "resolved")));
|
||||
result.put("closed", count(base.clone().eq(AppTicket::getStatus, "closed")));
|
||||
result.put("total", (long) count(base.clone()));
|
||||
result.put("pending", (long) count(base.clone().in(AppTicket::getStatus, "pending", "assigned")));
|
||||
result.put("processing", (long) count(base.clone().eq(AppTicket::getStatus, "processing")));
|
||||
result.put("resolved", (long) count(base.clone().eq(AppTicket::getStatus, "resolved")));
|
||||
result.put("closed", (long) count(base.clone().eq(AppTicket::getStatus, "closed")));
|
||||
return result;
|
||||
}
|
||||
|
||||
@@ -300,4 +324,256 @@ public class AppTicketServiceImpl extends ServiceImpl<AppTicketMapper, AppTicket
|
||||
return map;
|
||||
}).collect(Collectors.toList());
|
||||
}
|
||||
|
||||
// ════════════════════════════════════════════════════════════
|
||||
// 异步消息推送
|
||||
// ════════════════════════════════════════════════════════════
|
||||
|
||||
/**
|
||||
* 场景1:新工单提交 → 通知分配到的技术人员
|
||||
*/
|
||||
@Async
|
||||
public void sendTicketCreatedAsync(AppTicket ticket) {
|
||||
String now = now();
|
||||
String title = String.format("🎫 新工单待处理(%s)", now);
|
||||
|
||||
String assigneeLine = StrUtil.isNotBlank(ticket.getAssigneeName())
|
||||
? "**处理人:** " + ticket.getAssigneeName()
|
||||
: "**处理人:** 待分配";
|
||||
|
||||
String wecomContent = String.format(
|
||||
"## %s\n" +
|
||||
"> **工单号:** %s\n" +
|
||||
"> **标题:** %s\n" +
|
||||
"> **分类:** %s **优先级:** %s\n" +
|
||||
"> **提交人:** %s\n" +
|
||||
"> %s\n" +
|
||||
"> **描述:** %s",
|
||||
title,
|
||||
nullSafe(ticket.getTicketNo()),
|
||||
nullSafe(ticket.getTitle()),
|
||||
categoryLabel(ticket.getCategory()), priorityLabel(ticket.getPriority()),
|
||||
nullSafe(ticket.getSubmitUserName()),
|
||||
assigneeLine,
|
||||
truncate(ticket.getContent(), 120)
|
||||
);
|
||||
|
||||
List<String[]> feishuLines = Arrays.asList(
|
||||
new String[]{"工单号:" + nullSafe(ticket.getTicketNo())},
|
||||
new String[]{"标 题:" + nullSafe(ticket.getTitle())},
|
||||
new String[]{"分 类:" + categoryLabel(ticket.getCategory()) + " 优先级:" + priorityLabel(ticket.getPriority())},
|
||||
new String[]{"提交人:" + nullSafe(ticket.getSubmitUserName())},
|
||||
new String[]{assigneeLine.replace("**", "")},
|
||||
new String[]{"描 述:" + truncate(ticket.getContent(), 120)}
|
||||
);
|
||||
|
||||
doSend(title, wecomContent, feishuLines, "ticket_created");
|
||||
}
|
||||
|
||||
/**
|
||||
* 场景2:工单被重新分配 → 通知新处理人
|
||||
*/
|
||||
@Async
|
||||
public void sendTicketAssignedAsync(AppTicket ticket) {
|
||||
String now = now();
|
||||
String title = String.format("📋 工单已分配给您(%s)", now);
|
||||
|
||||
String wecomContent = String.format(
|
||||
"## %s\n" +
|
||||
"> **工单号:** %s\n" +
|
||||
"> **标题:** %s\n" +
|
||||
"> **分配给:** <font color=\"warning\">%s</font>\n" +
|
||||
"> **分类:** %s **优先级:** %s\n" +
|
||||
"> **描述:** %s",
|
||||
title,
|
||||
nullSafe(ticket.getTicketNo()),
|
||||
nullSafe(ticket.getTitle()),
|
||||
nullSafe(ticket.getAssigneeName()),
|
||||
categoryLabel(ticket.getCategory()), priorityLabel(ticket.getPriority()),
|
||||
truncate(ticket.getContent(), 100)
|
||||
);
|
||||
|
||||
List<String[]> feishuLines = Arrays.asList(
|
||||
new String[]{"工单号:" + nullSafe(ticket.getTicketNo())},
|
||||
new String[]{"标 题:" + nullSafe(ticket.getTitle())},
|
||||
new String[]{"分配给:" + nullSafe(ticket.getAssigneeName())},
|
||||
new String[]{"分 类:" + categoryLabel(ticket.getCategory()) + " 优先级:" + priorityLabel(ticket.getPriority())},
|
||||
new String[]{"描 述:" + truncate(ticket.getContent(), 100)}
|
||||
);
|
||||
|
||||
doSend(title, wecomContent, feishuLines, "ticket_assigned");
|
||||
}
|
||||
|
||||
/**
|
||||
* 场景3:有新回复 → 技术人员回复则通知客户,客户回复则通知技术人员
|
||||
*/
|
||||
@Async
|
||||
public void sendReplyNotifyAsync(AppTicket ticket, AppTicketReply reply) {
|
||||
boolean isStaff = Integer.valueOf(1).equals(reply.getIsStaff());
|
||||
String now = now();
|
||||
String who = isStaff ? "技术人员" : "用户";
|
||||
String notifyRole = isStaff ? "您的工单有新回复" : "工单有用户新回复";
|
||||
String title = String.format("💬 %s(%s)", notifyRole, now);
|
||||
|
||||
String wecomContent = String.format(
|
||||
"## %s\n" +
|
||||
"> **工单号:** %s\n" +
|
||||
"> **标题:** %s\n" +
|
||||
"> **回复人:** %s(%s)\n" +
|
||||
"> **回复内容:** %s",
|
||||
title,
|
||||
nullSafe(ticket.getTicketNo()),
|
||||
nullSafe(ticket.getTitle()),
|
||||
nullSafe(reply.getUserName()), who,
|
||||
truncate(reply.getContent(), 200)
|
||||
);
|
||||
|
||||
List<String[]> feishuLines = Arrays.asList(
|
||||
new String[]{"工单号:" + nullSafe(ticket.getTicketNo())},
|
||||
new String[]{"标 题:" + nullSafe(ticket.getTitle())},
|
||||
new String[]{"回复人:" + nullSafe(reply.getUserName()) + "(" + who + ")"},
|
||||
new String[]{"回复内容:" + truncate(reply.getContent(), 200)}
|
||||
);
|
||||
|
||||
doSend(title, wecomContent, feishuLines, "ticket_reply");
|
||||
}
|
||||
|
||||
/**
|
||||
* 场景4:状态变更(已解决/已关闭)→ 通知提交人
|
||||
*/
|
||||
@Async
|
||||
public void sendStatusChangedAsync(AppTicket ticket, String status) {
|
||||
String now = now();
|
||||
String emoji = "resolved".equals(status) ? "✅" : "🔒";
|
||||
String statusLabel = "resolved".equals(status) ? "已解决" : "已关闭";
|
||||
String title = String.format("%s 工单%s(%s)", emoji, statusLabel, now);
|
||||
|
||||
String wecomContent = String.format(
|
||||
"## %s\n" +
|
||||
"> **工单号:** %s\n" +
|
||||
"> **标题:** %s\n" +
|
||||
"> **提交人:** %s\n" +
|
||||
"> **处理人:** %s\n" +
|
||||
"> **状态:** <font color=\"%s\">%s</font>",
|
||||
title,
|
||||
nullSafe(ticket.getTicketNo()),
|
||||
nullSafe(ticket.getTitle()),
|
||||
nullSafe(ticket.getSubmitUserName()),
|
||||
nullSafe(ticket.getAssigneeName()),
|
||||
"resolved".equals(status) ? "info" : "comment",
|
||||
statusLabel
|
||||
);
|
||||
|
||||
List<String[]> feishuLines = Arrays.asList(
|
||||
new String[]{"工单号:" + nullSafe(ticket.getTicketNo())},
|
||||
new String[]{"标 题:" + nullSafe(ticket.getTitle())},
|
||||
new String[]{"提交人:" + nullSafe(ticket.getSubmitUserName())},
|
||||
new String[]{"处理人:" + nullSafe(ticket.getAssigneeName())},
|
||||
new String[]{"状 态:" + statusLabel}
|
||||
);
|
||||
|
||||
doSend(title, wecomContent, feishuLines, "ticket_status");
|
||||
}
|
||||
|
||||
/**
|
||||
* 统一发送入口:企业微信 + 飞书
|
||||
*/
|
||||
private void doSend(String title, String wecomContent, List<String[]> feishuLines, String scene) {
|
||||
if (StrUtil.isNotBlank(wecomWebhook)) {
|
||||
try {
|
||||
sendWecom(wecomContent);
|
||||
} catch (Exception e) {
|
||||
log.warn("[工单通知][{}] 企业微信发送失败: {}", scene, e.getMessage());
|
||||
}
|
||||
}
|
||||
if (StrUtil.isNotBlank(feishuWebhook)) {
|
||||
try {
|
||||
sendFeishu(title, feishuLines);
|
||||
} catch (Exception e) {
|
||||
log.warn("[工单通知][{}] 飞书发送失败: {}", scene, e.getMessage());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 企业微信群机器人 - markdown 消息
|
||||
* 文档:https://developer.work.weixin.qq.com/document/path/91770
|
||||
*/
|
||||
private void sendWecom(String content) {
|
||||
Map<String, Object> textMap = new HashMap<>();
|
||||
textMap.put("content", content);
|
||||
Map<String, Object> payload = new HashMap<>();
|
||||
payload.put("msgtype", "markdown");
|
||||
payload.put("markdown", textMap);
|
||||
String resp = HttpUtil.post(wecomWebhook, JSON.toJSONString(payload));
|
||||
log.info("[工单通知] 企业微信推送结果: {}", resp);
|
||||
}
|
||||
|
||||
/**
|
||||
* 飞书群机器人 - 富文本(post)消息
|
||||
* 文档:https://open.feishu.cn/document/client-docs/bot-v3/add-custom-bot
|
||||
*/
|
||||
private void sendFeishu(String title, List<String[]> lines) {
|
||||
List<List<Map<String, String>>> content = new ArrayList<>();
|
||||
for (String[] line : lines) {
|
||||
Map<String, String> node = new HashMap<>();
|
||||
node.put("tag", "text");
|
||||
node.put("text", line[0]);
|
||||
content.add(Collections.singletonList(node));
|
||||
}
|
||||
|
||||
Map<String, Object> zhCn = new HashMap<>();
|
||||
zhCn.put("title", title);
|
||||
zhCn.put("content", content);
|
||||
Map<String, Object> postContent = new HashMap<>();
|
||||
postContent.put("zh_cn", zhCn);
|
||||
Map<String, Object> post = new HashMap<>();
|
||||
post.put("content", postContent);
|
||||
Map<String, Object> payload = new HashMap<>();
|
||||
payload.put("msg_type", "post");
|
||||
payload.put("content", post);
|
||||
|
||||
String resp = HttpUtil.post(feishuWebhook, JSON.toJSONString(payload));
|
||||
log.info("[工单通知] 飞书推送结果: {}", resp);
|
||||
}
|
||||
|
||||
// ════════════════════════════════════════════════════════════
|
||||
// 工具方法
|
||||
// ════════════════════════════════════════════════════════════
|
||||
|
||||
private String now() {
|
||||
return LocalDateTime.now().format(DateTimeFormatter.ofPattern("MM-dd HH:mm"));
|
||||
}
|
||||
|
||||
private String nullSafe(String s) {
|
||||
return StrUtil.isBlank(s) ? "—" : s;
|
||||
}
|
||||
|
||||
private String truncate(String s, int max) {
|
||||
if (StrUtil.isBlank(s)) return "—";
|
||||
return s.length() > max ? s.substring(0, max) + "…" : s;
|
||||
}
|
||||
|
||||
private String categoryLabel(String category) {
|
||||
if (category == null) return "其他";
|
||||
return switch (category) {
|
||||
case "bug" -> "Bug反馈";
|
||||
case "feature" -> "功能需求";
|
||||
case "config" -> "配置问题";
|
||||
case "performance" -> "性能问题";
|
||||
case "security" -> "安全问题";
|
||||
default -> category;
|
||||
};
|
||||
}
|
||||
|
||||
private String priorityLabel(String priority) {
|
||||
if (priority == null) return "普通";
|
||||
return switch (priority) {
|
||||
case "urgent" -> "🔴 紧急";
|
||||
case "high" -> "🟠 高";
|
||||
case "normal" -> "🟡 普通";
|
||||
case "low" -> "🟢 低";
|
||||
default -> priority;
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user