feat(ticket): 实现工单系统并集成企业微信飞书通知

- 在控制器中统一返回格式,修复fail方法调用
- 实现工单提交、查询、回复等核心功能
- 添加工单状态管理(待处理、已分配、处理中、已解决、已关闭)
- 集成企业微信群机器人和飞书群机器人实时通知
- 实现异步推送机制支持四种通知场景:新工单、重新分配、新回复、状态变更
- 添加工单统计功能和用户权限控制
- 创建工单主表和回复表的数据库结构定义
This commit is contained in:
2026-03-30 12:28:27 +08:00
parent a4d5c86134
commit 85e0b062b8
3 changed files with 349 additions and 25 deletions

View 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='工单回复';

View File

@@ -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));
}

View File

@@ -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;
};
}
}