diff --git a/docs/sql/2026-03-30_app_ticket_tables.sql b/docs/sql/2026-03-30_app_ticket_tables.sql new file mode 100644 index 0000000..6d621e4 --- /dev/null +++ b/docs/sql/2026-03-30_app_ticket_tables.sql @@ -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='工单回复'; diff --git a/src/main/java/com/gxwebsoft/app/controller/AppTicketController.java b/src/main/java/com/gxwebsoft/app/controller/AppTicketController.java index 68c718b..a247aee 100644 --- a/src/main/java/com/gxwebsoft/app/controller/AppTicketController.java +++ b/src/main/java/com/gxwebsoft/app/controller/AppTicketController.java @@ -38,7 +38,7 @@ public class AppTicketController extends BaseController { @GetMapping("/my") public ApiResult> 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 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> 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 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> 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)); } diff --git a/src/main/java/com/gxwebsoft/app/service/impl/AppTicketServiceImpl.java b/src/main/java/com/gxwebsoft/app/service/impl/AppTicketServiceImpl.java index dd98f05..f50fc51 100644 --- a/src/main/java/com/gxwebsoft/app/service/impl/AppTicketServiceImpl.java +++ b/src/main/java/com/gxwebsoft/app/service/impl/AppTicketServiceImpl.java @@ -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 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 myPage(AppTicketParam param, Integer userId) { - com.baomidou.mybatisplus.extension.plugins.pagination.Page 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) + Page page = new Page<>(param.getCurrent(), param.getSize() > 0 ? param.getSize() : 15); LambdaQueryWrapper wrapper = new LambdaQueryWrapper() .eq(AppTicket::getDeleted, 0) @@ -79,10 +90,7 @@ public class AppTicketServiceImpl extends ServiceImpl allPage(AppTicketParam param) { - com.baomidou.mybatisplus.extension.plugins.pagination.Page page = - new com.baomidou.mybatisplus.extension.plugins.pagination.Page<>( - param.getPageNum() != null ? param.getPageNum() : 1, - param.getPageSize() != null ? param.getPageSize() : 20); + Page page = new Page<>(param.getCurrent(), param.getSize() > 0 ? param.getSize() : 20); LambdaQueryWrapper wrapper = new LambdaQueryWrapper() .eq(AppTicket::getDeleted, 0) @@ -132,6 +140,10 @@ public class AppTicketServiceImpl extends ServiceImpl() .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 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 **工单号:** %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 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" + + "> **分配给:** %s\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 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 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" + + "> **状态:** %s", + title, + nullSafe(ticket.getTicketNo()), + nullSafe(ticket.getTitle()), + nullSafe(ticket.getSubmitUserName()), + nullSafe(ticket.getAssigneeName()), + "resolved".equals(status) ? "info" : "comment", + statusLabel + ); + + List 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 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 textMap = new HashMap<>(); + textMap.put("content", content); + Map 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 lines) { + List>> content = new ArrayList<>(); + for (String[] line : lines) { + Map node = new HashMap<>(); + node.put("tag", "text"); + node.put("text", line[0]); + content.add(Collections.singletonList(node)); + } + + Map zhCn = new HashMap<>(); + zhCn.put("title", title); + zhCn.put("content", content); + Map postContent = new HashMap<>(); + postContent.put("zh_cn", zhCn); + Map post = new HashMap<>(); + post.put("content", postContent); + Map 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; + }; + } }