From a4d5c861343d01b464524d5bacabb63e603c8fdb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E8=B5=B5=E5=BF=A0=E6=9E=97?= <170083662@qq.com> Date: Mon, 30 Mar 2026 11:59:41 +0800 Subject: [PATCH] =?UTF-8?q?feat(app):=20=E6=B7=BB=E5=8A=A0=E5=BA=94?= =?UTF-8?q?=E7=94=A8=E5=B7=A5=E5=8D=95=E7=B3=BB=E7=BB=9F=E5=8A=9F=E8=83=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 创建AppTicket实体类定义工单数据结构 - 实现AppTicketController提供工单CRUD接口 - 创建AppTicketReply实体支持工单回复功能 - 实现工单提交、查询、状态更新等核心业务逻辑 - 添加工单统计、分配、回复等功能接口 - 集成MyBatis Plus实现数据库操作 - 支持工单分类、优先级、状态流转管理 - 实现自动分配工单给技术成员机制 - 添加工单搜索、分页、权限控制功能 --- .../app/controller/AppTicketController.java | 150 +++++++++ .../com/gxwebsoft/app/entity/AppTicket.java | 97 ++++++ .../gxwebsoft/app/entity/AppTicketReply.java | 58 ++++ .../gxwebsoft/app/mapper/AppTicketMapper.java | 11 + .../app/mapper/AppTicketReplyMapper.java | 10 + .../gxwebsoft/app/param/AppTicketParam.java | 33 ++ .../app/service/AppTicketService.java | 46 +++ .../service/impl/AppTicketServiceImpl.java | 303 ++++++++++++++++++ 8 files changed, 708 insertions(+) create mode 100644 src/main/java/com/gxwebsoft/app/controller/AppTicketController.java create mode 100644 src/main/java/com/gxwebsoft/app/entity/AppTicket.java create mode 100644 src/main/java/com/gxwebsoft/app/entity/AppTicketReply.java create mode 100644 src/main/java/com/gxwebsoft/app/mapper/AppTicketMapper.java create mode 100644 src/main/java/com/gxwebsoft/app/mapper/AppTicketReplyMapper.java create mode 100644 src/main/java/com/gxwebsoft/app/param/AppTicketParam.java create mode 100644 src/main/java/com/gxwebsoft/app/service/AppTicketService.java create mode 100644 src/main/java/com/gxwebsoft/app/service/impl/AppTicketServiceImpl.java diff --git a/src/main/java/com/gxwebsoft/app/controller/AppTicketController.java b/src/main/java/com/gxwebsoft/app/controller/AppTicketController.java new file mode 100644 index 0000000..68c718b --- /dev/null +++ b/src/main/java/com/gxwebsoft/app/controller/AppTicketController.java @@ -0,0 +1,150 @@ +package com.gxwebsoft.app.controller; + +import com.gxwebsoft.app.entity.AppTicket; +import com.gxwebsoft.app.entity.AppTicketReply; +import com.gxwebsoft.app.param.AppTicketParam; +import com.gxwebsoft.app.service.AppTicketService; +import com.gxwebsoft.common.core.web.ApiResult; +import com.gxwebsoft.common.core.web.BaseController; +import com.gxwebsoft.common.core.web.PageResult; +import com.gxwebsoft.common.system.entity.User; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import lombok.extern.slf4j.Slf4j; +import org.springframework.web.bind.annotation.*; + +import javax.annotation.Resource; +import java.util.List; +import java.util.Map; + +/** + * 应用工单控制器 + * + * @author 科技小王子 + * @since 2026-03-30 + */ +@Slf4j +@Tag(name = "应用工单管理") +@RestController +@RequestMapping("/api/app/ticket") +public class AppTicketController extends BaseController { + + @Resource + private AppTicketService appTicketService; + + // ─── 客户端接口 ──────────────────────────────────────────────── + + @Operation(summary = "查询我的工单(分页)") + @GetMapping("/my") + public ApiResult> myTickets(AppTicketParam param) { + User loginUser = getLoginUser(); + if (loginUser == null) return fail("请先登录"); + return success(appTicketService.myPage(param, loginUser.getUserId())); + } + + @Operation(summary = "提交工单") + @PostMapping("/submit") + public ApiResult submit(@RequestBody AppTicket ticket) { + User loginUser = getLoginUser(); + if (loginUser == null) return fail("请先登录"); + try { + AppTicket result = appTicketService.submit(ticket, loginUser.getUserId()); + return success("工单提交成功", result); + } catch (Exception e) { + return fail(e.getMessage()); + } + } + + @Operation(summary = "关闭工单(提交人)") + @PutMapping("/{ticketId}/close") + public ApiResult close(@PathVariable Long ticketId) { + User loginUser = getLoginUser(); + if (loginUser == null) return fail("请先登录"); + try { + appTicketService.closeByUser(ticketId, loginUser.getUserId()); + return success("工单已关闭"); + } catch (Exception e) { + return fail(e.getMessage()); + } + } + + // ─── 技术端接口 ──────────────────────────────────────────────── + + @Operation(summary = "查询所有工单(技术人员)") + @GetMapping("/list") + public ApiResult> allTickets(AppTicketParam param) { + User loginUser = getLoginUser(); + if (loginUser == null) return fail("请先登录"); + return success(appTicketService.allPage(param)); + } + + @Operation(summary = "获取工单详情") + @GetMapping("/{ticketId}") + public ApiResult detail(@PathVariable Long ticketId) { + return success(appTicketService.getById(ticketId)); + } + + @Operation(summary = "更新工单状态(技术人员)") + @PutMapping("/status") + public ApiResult updateStatus(@RequestBody Map body) { + User loginUser = getLoginUser(); + if (loginUser == null) return fail("请先登录"); + Long ticketId = Long.valueOf(body.get("ticketId").toString()); + String status = body.get("status").toString(); + appTicketService.updateStatus(ticketId, status, loginUser.getUserId()); + return success("状态已更新"); + } + + @Operation(summary = "分配处理人(管理员)") + @PutMapping("/assign") + public ApiResult assign(@RequestBody Map body) { + User loginUser = getLoginUser(); + if (loginUser == null) return fail("请先登录"); + Long ticketId = Long.valueOf(body.get("ticketId").toString()); + Integer assigneeId = Integer.valueOf(body.get("assigneeId").toString()); + appTicketService.assign(ticketId, assigneeId); + return success("分配成功"); + } + + // ─── 回复接口 ───────────────────────────────────────────────── + + @Operation(summary = "获取工单回复列表") + @GetMapping("/{ticketId}/replies") + public ApiResult> replies(@PathVariable Long ticketId) { + return success(appTicketService.getReplies(ticketId)); + } + + @Operation(summary = "提交工单回复") + @PostMapping("/reply") + public ApiResult reply(@RequestBody AppTicketReply reply) { + User loginUser = getLoginUser(); + if (loginUser == null) return fail("请先登录"); + if (reply.getContent() == null || reply.getContent().trim().isEmpty()) { + return fail("回复内容不能为空"); + } + try { + AppTicketReply result = appTicketService.addReply(reply, loginUser.getUserId()); + return success("回复成功", result); + } catch (Exception e) { + return fail(e.getMessage()); + } + } + + // ─── 统计 & 辅助 ───────────────────────────────────────────── + + @Operation(summary = "工单统计数据") + @GetMapping("/stats") + public ApiResult> stats( + @RequestParam(required = false) Long websiteId) { + User loginUser = getLoginUser(); + if (loginUser == null) return fail("请先登录"); + // 技术端不限制用户维度;客户端通过路由区分 + return success(appTicketService.stats(websiteId, null)); + } + + @Operation(summary = "获取技术人员列表(用于分配)") + @GetMapping("/staff-list") + public ApiResult>> staffList() { + return success(appTicketService.getTechStaffList()); + } +} diff --git a/src/main/java/com/gxwebsoft/app/entity/AppTicket.java b/src/main/java/com/gxwebsoft/app/entity/AppTicket.java new file mode 100644 index 0000000..1d62783 --- /dev/null +++ b/src/main/java/com/gxwebsoft/app/entity/AppTicket.java @@ -0,0 +1,97 @@ +package com.gxwebsoft.app.entity; + +import com.baomidou.mybatisplus.annotation.IdType; +import com.baomidou.mybatisplus.annotation.TableId; +import com.baomidou.mybatisplus.annotation.TableName; +import com.fasterxml.jackson.annotation.JsonFormat; +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; +import lombok.EqualsAndHashCode; + +import java.io.Serializable; +import java.time.LocalDateTime; + +/** + * 应用工单 + * + * @author 科技小王子 + * @since 2026-03-30 + */ +@Data +@EqualsAndHashCode(callSuper = false) +@TableName("app_ticket") +@Schema(name = "AppTicket对象", description = "应用工单") +public class AppTicket implements Serializable { + private static final long serialVersionUID = 1L; + + @Schema(description = "工单ID") + @TableId(value = "ticket_id", type = IdType.AUTO) + private Long ticketId; + + @Schema(description = "工单编号(TK-yyyyMMddHHmmss+4位随机)") + private String ticketNo; + + @Schema(description = "工单标题") + private String title; + + @Schema(description = "工单内容描述") + private String content; + + @Schema(description = "关联应用ID") + private Long websiteId; + + @Schema(description = "应用名称(冗余)") + private String websiteName; + + @Schema(description = "工单分类: bug/feature/consultation/complaint/other") + private String category; + + @Schema(description = "优先级: low/normal/high/urgent") + private String priority; + + @Schema(description = "状态: pending/assigned/processing/resolved/closed/rejected") + private String status; + + @Schema(description = "附件JSON数组") + private String attachments; + + @Schema(description = "提交人用户ID") + private Integer submitUserId; + + @Schema(description = "提交人昵称(冗余)") + private String submitUserName; + + @Schema(description = "提交人头像(冗余)") + private String submitUserAvatar; + + @Schema(description = "分配的处理人用户ID") + private Integer assigneeId; + + @Schema(description = "处理人昵称(冗余)") + private String assigneeName; + + @Schema(description = "处理人头像(冗余)") + private String assigneeAvatar; + + @Schema(description = "回复数量") + private Integer replyCount; + + @Schema(description = "解决时间") + @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss") + private LocalDateTime resolvedTime; + + @Schema(description = "关闭时间") + @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss") + private LocalDateTime closedTime; + + @Schema(description = "是否删除: 0否 1是") + private Integer deleted; + + @Schema(description = "创建时间") + @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss") + private LocalDateTime createTime; + + @Schema(description = "更新时间") + @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss") + private LocalDateTime updateTime; +} diff --git a/src/main/java/com/gxwebsoft/app/entity/AppTicketReply.java b/src/main/java/com/gxwebsoft/app/entity/AppTicketReply.java new file mode 100644 index 0000000..fb13ca9 --- /dev/null +++ b/src/main/java/com/gxwebsoft/app/entity/AppTicketReply.java @@ -0,0 +1,58 @@ +package com.gxwebsoft.app.entity; + +import com.baomidou.mybatisplus.annotation.IdType; +import com.baomidou.mybatisplus.annotation.TableId; +import com.baomidou.mybatisplus.annotation.TableName; +import com.fasterxml.jackson.annotation.JsonFormat; +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; +import lombok.EqualsAndHashCode; + +import java.io.Serializable; +import java.time.LocalDateTime; + +/** + * 工单回复 + * + * @author 科技小王子 + * @since 2026-03-30 + */ +@Data +@EqualsAndHashCode(callSuper = false) +@TableName("app_ticket_reply") +@Schema(name = "AppTicketReply对象", description = "工单回复") +public class AppTicketReply implements Serializable { + private static final long serialVersionUID = 1L; + + @Schema(description = "回复ID") + @TableId(value = "reply_id", type = IdType.AUTO) + private Long replyId; + + @Schema(description = "关联工单ID") + private Long ticketId; + + @Schema(description = "回复内容") + private String content; + + @Schema(description = "附件JSON数组") + private String attachments; + + @Schema(description = "回复人用户ID") + private Integer userId; + + @Schema(description = "回复人昵称(冗余)") + private String userName; + + @Schema(description = "回复人头像(冗余)") + private String userAvatar; + + @Schema(description = "是否是技术人员/客服: 0否 1是") + private Integer isStaff; + + @Schema(description = "是否删除: 0否 1是") + private Integer deleted; + + @Schema(description = "创建时间") + @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss") + private LocalDateTime createTime; +} diff --git a/src/main/java/com/gxwebsoft/app/mapper/AppTicketMapper.java b/src/main/java/com/gxwebsoft/app/mapper/AppTicketMapper.java new file mode 100644 index 0000000..63e30e8 --- /dev/null +++ b/src/main/java/com/gxwebsoft/app/mapper/AppTicketMapper.java @@ -0,0 +1,11 @@ +package com.gxwebsoft.app.mapper; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import com.gxwebsoft.app.entity.AppTicket; +import org.apache.ibatis.annotations.Param; + +/** + * 应用工单 Mapper + */ +public interface AppTicketMapper extends BaseMapper { +} diff --git a/src/main/java/com/gxwebsoft/app/mapper/AppTicketReplyMapper.java b/src/main/java/com/gxwebsoft/app/mapper/AppTicketReplyMapper.java new file mode 100644 index 0000000..3fc0a16 --- /dev/null +++ b/src/main/java/com/gxwebsoft/app/mapper/AppTicketReplyMapper.java @@ -0,0 +1,10 @@ +package com.gxwebsoft.app.mapper; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import com.gxwebsoft.app.entity.AppTicketReply; + +/** + * 工单回复 Mapper + */ +public interface AppTicketReplyMapper extends BaseMapper { +} diff --git a/src/main/java/com/gxwebsoft/app/param/AppTicketParam.java b/src/main/java/com/gxwebsoft/app/param/AppTicketParam.java new file mode 100644 index 0000000..a10144c --- /dev/null +++ b/src/main/java/com/gxwebsoft/app/param/AppTicketParam.java @@ -0,0 +1,33 @@ +package com.gxwebsoft.app.param; + +import com.gxwebsoft.common.core.web.PageParam; +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; +import lombok.EqualsAndHashCode; + +/** + * 工单查询参数 + */ +@Data +@EqualsAndHashCode(callSuper = true) +@Schema(description = "工单查询参数") +public class AppTicketParam extends PageParam { + + @Schema(description = "关联应用ID") + private Long websiteId; + + @Schema(description = "工单状态") + private String status; + + @Schema(description = "工单分类") + private String category; + + @Schema(description = "优先级") + private String priority; + + @Schema(description = "处理人ID(传0=未分配)") + private Integer assigneeId; + + @Schema(description = "关键词(标题/工单编号)") + private String keywords; +} diff --git a/src/main/java/com/gxwebsoft/app/service/AppTicketService.java b/src/main/java/com/gxwebsoft/app/service/AppTicketService.java new file mode 100644 index 0000000..d81bf6e --- /dev/null +++ b/src/main/java/com/gxwebsoft/app/service/AppTicketService.java @@ -0,0 +1,46 @@ +package com.gxwebsoft.app.service; + +import com.baomidou.mybatisplus.extension.service.IService; +import com.gxwebsoft.app.entity.AppTicket; +import com.gxwebsoft.app.entity.AppTicketReply; +import com.gxwebsoft.app.param.AppTicketParam; +import com.gxwebsoft.common.core.web.PageResult; + +import java.util.List; +import java.util.Map; + +/** + * 应用工单 Service + */ +public interface AppTicketService extends IService { + + /** 客户端:查自己提交的工单(分页) */ + PageResult myPage(AppTicketParam param, Integer userId); + + /** 技术端:查询所有工单(分页) */ + PageResult allPage(AppTicketParam param); + + /** 提交工单(自动分配) */ + AppTicket submit(AppTicket ticket, Integer userId); + + /** 更新状态(技术人员操作) */ + void updateStatus(Long ticketId, String status, Integer operatorId); + + /** 分配处理人 */ + void assign(Long ticketId, Integer assigneeId); + + /** 关闭工单(提交人操作) */ + void closeByUser(Long ticketId, Integer userId); + + /** 获取工单回复列表 */ + List getReplies(Long ticketId); + + /** 添加回复 */ + AppTicketReply addReply(AppTicketReply reply, Integer userId); + + /** 统计数据 */ + Map stats(Long websiteId, Integer userId); + + /** 获取技术人员列表(角色:developer/admin 的应用成员) */ + List> getTechStaffList(); +} diff --git a/src/main/java/com/gxwebsoft/app/service/impl/AppTicketServiceImpl.java b/src/main/java/com/gxwebsoft/app/service/impl/AppTicketServiceImpl.java new file mode 100644 index 0000000..dd98f05 --- /dev/null +++ b/src/main/java/com/gxwebsoft/app/service/impl/AppTicketServiceImpl.java @@ -0,0 +1,303 @@ +package com.gxwebsoft.app.service.impl; + +import cn.hutool.core.util.IdUtil; +import cn.hutool.core.util.ObjectUtil; +import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; +import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper; +import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl; +import com.gxwebsoft.app.entity.AppTicket; +import com.gxwebsoft.app.entity.AppTicketReply; +import com.gxwebsoft.app.entity.AppUser; +import com.gxwebsoft.app.mapper.AppTicketMapper; +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.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import javax.annotation.Resource; +import java.time.LocalDateTime; +import java.time.format.DateTimeFormatter; +import java.util.*; +import java.util.stream.Collectors; + +/** + * 应用工单 Service 实现 + * + * @author 科技小王子 + * @since 2026-03-30 + */ +@Slf4j +@Service +public class AppTicketServiceImpl extends ServiceImpl implements AppTicketService { + + @Resource + private AppTicketReplyMapper replyMapper; + + @Resource + private AppUserService appUserService; + + @Resource + private UserService userService; + + // ─── 生成工单编号 ───────────────────────────────────────────── + private String generateTicketNo() { + String ts = LocalDateTime.now().format(DateTimeFormatter.ofPattern("yyyyMMddHHmmss")); + String rand = String.format("%04d", new Random().nextInt(10000)); + return "TK-" + ts + rand; + } + + // ─── 客户端:查自己的工单 ───────────────────────────────────── + @Override + public PageResult 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); + + LambdaQueryWrapper wrapper = new LambdaQueryWrapper() + .eq(AppTicket::getDeleted, 0) + .eq(AppTicket::getSubmitUserId, userId) + .eq(ObjectUtil.isNotNull(param.getWebsiteId()), AppTicket::getWebsiteId, param.getWebsiteId()) + .eq(ObjectUtil.isNotEmpty(param.getStatus()), AppTicket::getStatus, param.getStatus()) + .eq(ObjectUtil.isNotEmpty(param.getCategory()), AppTicket::getCategory, param.getCategory()) + .and(ObjectUtil.isNotEmpty(param.getKeywords()), q -> + q.like(AppTicket::getTitle, param.getKeywords()) + .or().like(AppTicket::getTicketNo, param.getKeywords())) + .orderByDesc(AppTicket::getCreateTime); + + baseMapper.selectPage(page, wrapper); + return new PageResult<>(page.getRecords(), page.getTotal()); + } + + // ─── 技术端:查所有工单 ─────────────────────────────────────── + @Override + public PageResult 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); + + LambdaQueryWrapper wrapper = new LambdaQueryWrapper() + .eq(AppTicket::getDeleted, 0) + .eq(ObjectUtil.isNotNull(param.getWebsiteId()), AppTicket::getWebsiteId, param.getWebsiteId()) + .eq(ObjectUtil.isNotEmpty(param.getStatus()), AppTicket::getStatus, param.getStatus()) + .eq(ObjectUtil.isNotEmpty(param.getCategory()), AppTicket::getCategory, param.getCategory()) + .eq(ObjectUtil.isNotEmpty(param.getPriority()), AppTicket::getPriority, param.getPriority()) + .and(ObjectUtil.isNotNull(param.getAssigneeId()) && param.getAssigneeId() == 0, + q -> q.isNull(AppTicket::getAssigneeId)) + .eq(ObjectUtil.isNotNull(param.getAssigneeId()) && param.getAssigneeId() > 0, + AppTicket::getAssigneeId, param.getAssigneeId()) + .and(ObjectUtil.isNotEmpty(param.getKeywords()), q -> + q.like(AppTicket::getTitle, param.getKeywords()) + .or().like(AppTicket::getTicketNo, param.getKeywords())) + .orderByAsc(AppTicket::getStatus) // pending 排前面 + .orderByDesc(AppTicket::getCreateTime); + + baseMapper.selectPage(page, wrapper); + return new PageResult<>(page.getRecords(), page.getTotal()); + } + + // ─── 提交工单(自动分配) ───────────────────────────────────── + @Override + @Transactional(rollbackFor = Exception.class) + public AppTicket submit(AppTicket ticket, Integer userId) { + // 补充提交人信息 + ticket.setSubmitUserId(userId); + ticket.setTicketNo(generateTicketNo()); + ticket.setStatus("pending"); + ticket.setReplyCount(0); + ticket.setDeleted(0); + ticket.setCreateTime(LocalDateTime.now()); + ticket.setUpdateTime(LocalDateTime.now()); + + // 从用户服务取昵称/头像冗余存储 + try { + User user = userService.getById(userId); + if (user != null) { + ticket.setSubmitUserName(user.getNickname() != null ? user.getNickname() : user.getUsername()); + ticket.setSubmitUserAvatar(user.getAvatar()); + } + } catch (Exception e) { + log.warn("获取提交人信息失败", e); + } + + // 自动分配:查找该应用的 admin/developer 成员,随机分配 + autoAssign(ticket); + + save(ticket); + return ticket; + } + + /** 自动分配工单给应用的技术成员 */ + private void autoAssign(AppTicket ticket) { + if (ticket.getWebsiteId() == null) return; + try { + List members = appUserService.list( + new LambdaQueryWrapper() + .eq(AppUser::getWebsiteId, ticket.getWebsiteId()) + .eq(AppUser::getStatus, 0) + .in(AppUser::getRole, "admin", "developer", "owner")); + if (!members.isEmpty()) { + // 简单轮询:按工单数最少分配(此处随机) + AppUser assigned = members.get(new Random().nextInt(members.size())); + ticket.setAssigneeId(assigned.getUserId()); + ticket.setAssigneeName(assigned.getNickname() != null ? assigned.getNickname() : assigned.getUsername()); + ticket.setAssigneeAvatar(assigned.getAvatar()); + ticket.setStatus("assigned"); + } + } catch (Exception e) { + log.warn("自动分配工单失败,保持 pending 状态", e); + } + } + + // ─── 更新状态 ───────────────────────────────────────────────── + @Override + public void updateStatus(Long ticketId, String status, Integer operatorId) { + LambdaUpdateWrapper wrapper = new LambdaUpdateWrapper() + .eq(AppTicket::getTicketId, ticketId) + .set(AppTicket::getStatus, status) + .set(AppTicket::getUpdateTime, LocalDateTime.now()); + if ("resolved".equals(status)) { + wrapper.set(AppTicket::getResolvedTime, LocalDateTime.now()); + // 如果没有分配人,自动将操作人设为处理人 + AppTicket t = getById(ticketId); + if (t != null && t.getAssigneeId() == null) { + wrapper.set(AppTicket::getAssigneeId, operatorId); + } + } + update(wrapper); + } + + // ─── 分配处理人 ─────────────────────────────────────────────── + @Override + public void assign(Long ticketId, Integer assigneeId) { + User user = userService.getById(assigneeId); + LambdaUpdateWrapper wrapper = new LambdaUpdateWrapper() + .eq(AppTicket::getTicketId, ticketId) + .set(AppTicket::getAssigneeId, assigneeId) + .set(user != null, AppTicket::getAssigneeName, + user != null ? (user.getNickname() != null ? user.getNickname() : user.getUsername()) : null) + .set(user != null, AppTicket::getAssigneeAvatar, user != null ? user.getAvatar() : null) + .set(AppTicket::getStatus, "assigned") + .set(AppTicket::getUpdateTime, LocalDateTime.now()); + update(wrapper); + } + + // ─── 用户关闭工单 ───────────────────────────────────────────── + @Override + public void closeByUser(Long ticketId, Integer userId) { + AppTicket ticket = getById(ticketId); + if (ticket == null || !ticket.getSubmitUserId().equals(userId)) { + throw new RuntimeException("无权操作该工单"); + } + update(new LambdaUpdateWrapper() + .eq(AppTicket::getTicketId, ticketId) + .set(AppTicket::getStatus, "closed") + .set(AppTicket::getClosedTime, LocalDateTime.now()) + .set(AppTicket::getUpdateTime, LocalDateTime.now())); + } + + // ─── 获取回复列表 ───────────────────────────────────────────── + @Override + public List getReplies(Long ticketId) { + return replyMapper.selectList( + new LambdaQueryWrapper() + .eq(AppTicketReply::getTicketId, ticketId) + .eq(AppTicketReply::getDeleted, 0) + .orderByAsc(AppTicketReply::getCreateTime)); + } + + // ─── 添加回复 ───────────────────────────────────────────────── + @Override + @Transactional(rollbackFor = Exception.class) + public AppTicketReply addReply(AppTicketReply reply, Integer userId) { + reply.setUserId(userId); + reply.setDeleted(0); + reply.setCreateTime(LocalDateTime.now()); + + // 补充用户信息 + try { + User user = userService.getById(userId); + if (user != null) { + reply.setUserName(user.getNickname() != null ? user.getNickname() : user.getUsername()); + reply.setUserAvatar(user.getAvatar()); + } + } catch (Exception e) { + log.warn("获取回复人信息失败", e); + } + + // 判断是否是技术人员(该应用的 admin/developer/owner) + AppTicket ticket = getById(reply.getTicketId()); + if (ticket != null) { + boolean isStaff = appUserService.count(new LambdaQueryWrapper() + .eq(AppUser::getWebsiteId, ticket.getWebsiteId()) + .eq(AppUser::getUserId, userId) + .in(AppUser::getRole, "owner", "admin", "developer") + .eq(AppUser::getStatus, 0)) > 0; + reply.setIsStaff(isStaff ? 1 : 0); + } else { + reply.setIsStaff(0); + } + + replyMapper.insert(reply); + + // 更新工单回复数 & 更新时间 + update(new LambdaUpdateWrapper() + .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")); + + return reply; + } + + // ─── 统计 ───────────────────────────────────────────────────── + @Override + public Map stats(Long websiteId, Integer userId) { + LambdaQueryWrapper base = new LambdaQueryWrapper() + .eq(AppTicket::getDeleted, 0) + .eq(ObjectUtil.isNotNull(websiteId), AppTicket::getWebsiteId, websiteId) + .eq(ObjectUtil.isNotNull(userId), AppTicket::getSubmitUserId, userId); + + Map 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"))); + return result; + } + + // ─── 获取技术人员列表 ───────────────────────────────────────── + @Override + public List> getTechStaffList() { + List members = appUserService.list( + new LambdaQueryWrapper() + .eq(AppUser::getStatus, 0) + .in(AppUser::getRole, "owner", "admin", "developer") + .select(AppUser::getUserId, AppUser::getNickname, AppUser::getAvatar)); + + // 去重(一个用户可能在多个应用里) + Map dedupMap = new LinkedHashMap<>(); + for (AppUser m : members) { + dedupMap.put(m.getUserId(), m); + } + + return dedupMap.values().stream().map(m -> { + Map map = new HashMap<>(); + map.put("userId", m.getUserId()); + map.put("nickname", m.getNickname() != null ? m.getNickname() : "用户" + m.getUserId()); + map.put("avatar", m.getAvatar()); + return map; + }).collect(Collectors.toList()); + } +}