feat(app): 添加应用工单系统功能
- 创建AppTicket实体类定义工单数据结构 - 实现AppTicketController提供工单CRUD接口 - 创建AppTicketReply实体支持工单回复功能 - 实现工单提交、查询、状态更新等核心业务逻辑 - 添加工单统计、分配、回复等功能接口 - 集成MyBatis Plus实现数据库操作 - 支持工单分类、优先级、状态流转管理 - 实现自动分配工单给技术成员机制 - 添加工单搜索、分页、权限控制功能
This commit is contained in:
@@ -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<PageResult<AppTicket>> myTickets(AppTicketParam param) {
|
||||||
|
User loginUser = getLoginUser();
|
||||||
|
if (loginUser == null) return fail("请先登录");
|
||||||
|
return success(appTicketService.myPage(param, loginUser.getUserId()));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Operation(summary = "提交工单")
|
||||||
|
@PostMapping("/submit")
|
||||||
|
public ApiResult<AppTicket> 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<PageResult<AppTicket>> allTickets(AppTicketParam param) {
|
||||||
|
User loginUser = getLoginUser();
|
||||||
|
if (loginUser == null) return fail("请先登录");
|
||||||
|
return success(appTicketService.allPage(param));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Operation(summary = "获取工单详情")
|
||||||
|
@GetMapping("/{ticketId}")
|
||||||
|
public ApiResult<AppTicket> detail(@PathVariable Long ticketId) {
|
||||||
|
return success(appTicketService.getById(ticketId));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Operation(summary = "更新工单状态(技术人员)")
|
||||||
|
@PutMapping("/status")
|
||||||
|
public ApiResult<?> updateStatus(@RequestBody Map<String, Object> 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<String, Object> 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<List<AppTicketReply>> replies(@PathVariable Long ticketId) {
|
||||||
|
return success(appTicketService.getReplies(ticketId));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Operation(summary = "提交工单回复")
|
||||||
|
@PostMapping("/reply")
|
||||||
|
public ApiResult<AppTicketReply> 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<Map<String, Long>> 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<List<Map<String, Object>>> staffList() {
|
||||||
|
return success(appTicketService.getTechStaffList());
|
||||||
|
}
|
||||||
|
}
|
||||||
97
src/main/java/com/gxwebsoft/app/entity/AppTicket.java
Normal file
97
src/main/java/com/gxwebsoft/app/entity/AppTicket.java
Normal file
@@ -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;
|
||||||
|
}
|
||||||
58
src/main/java/com/gxwebsoft/app/entity/AppTicketReply.java
Normal file
58
src/main/java/com/gxwebsoft/app/entity/AppTicketReply.java
Normal file
@@ -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;
|
||||||
|
}
|
||||||
11
src/main/java/com/gxwebsoft/app/mapper/AppTicketMapper.java
Normal file
11
src/main/java/com/gxwebsoft/app/mapper/AppTicketMapper.java
Normal file
@@ -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<AppTicket> {
|
||||||
|
}
|
||||||
@@ -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<AppTicketReply> {
|
||||||
|
}
|
||||||
33
src/main/java/com/gxwebsoft/app/param/AppTicketParam.java
Normal file
33
src/main/java/com/gxwebsoft/app/param/AppTicketParam.java
Normal file
@@ -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;
|
||||||
|
}
|
||||||
@@ -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<AppTicket> {
|
||||||
|
|
||||||
|
/** 客户端:查自己提交的工单(分页) */
|
||||||
|
PageResult<AppTicket> myPage(AppTicketParam param, Integer userId);
|
||||||
|
|
||||||
|
/** 技术端:查询所有工单(分页) */
|
||||||
|
PageResult<AppTicket> 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<AppTicketReply> getReplies(Long ticketId);
|
||||||
|
|
||||||
|
/** 添加回复 */
|
||||||
|
AppTicketReply addReply(AppTicketReply reply, Integer userId);
|
||||||
|
|
||||||
|
/** 统计数据 */
|
||||||
|
Map<String, Long> stats(Long websiteId, Integer userId);
|
||||||
|
|
||||||
|
/** 获取技术人员列表(角色:developer/admin 的应用成员) */
|
||||||
|
List<Map<String, Object>> getTechStaffList();
|
||||||
|
}
|
||||||
@@ -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<AppTicketMapper, AppTicket> 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<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);
|
||||||
|
|
||||||
|
LambdaQueryWrapper<AppTicket> wrapper = new LambdaQueryWrapper<AppTicket>()
|
||||||
|
.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<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);
|
||||||
|
|
||||||
|
LambdaQueryWrapper<AppTicket> wrapper = new LambdaQueryWrapper<AppTicket>()
|
||||||
|
.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<AppUser> members = appUserService.list(
|
||||||
|
new LambdaQueryWrapper<AppUser>()
|
||||||
|
.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<AppTicket> wrapper = new LambdaUpdateWrapper<AppTicket>()
|
||||||
|
.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<AppTicket> wrapper = new LambdaUpdateWrapper<AppTicket>()
|
||||||
|
.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<AppTicket>()
|
||||||
|
.eq(AppTicket::getTicketId, ticketId)
|
||||||
|
.set(AppTicket::getStatus, "closed")
|
||||||
|
.set(AppTicket::getClosedTime, LocalDateTime.now())
|
||||||
|
.set(AppTicket::getUpdateTime, LocalDateTime.now()));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── 获取回复列表 ─────────────────────────────────────────────
|
||||||
|
@Override
|
||||||
|
public List<AppTicketReply> getReplies(Long ticketId) {
|
||||||
|
return replyMapper.selectList(
|
||||||
|
new LambdaQueryWrapper<AppTicketReply>()
|
||||||
|
.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<AppUser>()
|
||||||
|
.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<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"));
|
||||||
|
|
||||||
|
return reply;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── 统计 ─────────────────────────────────────────────────────
|
||||||
|
@Override
|
||||||
|
public Map<String, Long> stats(Long websiteId, Integer userId) {
|
||||||
|
LambdaQueryWrapper<AppTicket> base = new LambdaQueryWrapper<AppTicket>()
|
||||||
|
.eq(AppTicket::getDeleted, 0)
|
||||||
|
.eq(ObjectUtil.isNotNull(websiteId), AppTicket::getWebsiteId, websiteId)
|
||||||
|
.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")));
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── 获取技术人员列表 ─────────────────────────────────────────
|
||||||
|
@Override
|
||||||
|
public List<Map<String, Object>> getTechStaffList() {
|
||||||
|
List<AppUser> members = appUserService.list(
|
||||||
|
new LambdaQueryWrapper<AppUser>()
|
||||||
|
.eq(AppUser::getStatus, 0)
|
||||||
|
.in(AppUser::getRole, "owner", "admin", "developer")
|
||||||
|
.select(AppUser::getUserId, AppUser::getNickname, AppUser::getAvatar));
|
||||||
|
|
||||||
|
// 去重(一个用户可能在多个应用里)
|
||||||
|
Map<Integer, AppUser> dedupMap = new LinkedHashMap<>();
|
||||||
|
for (AppUser m : members) {
|
||||||
|
dedupMap.put(m.getUserId(), m);
|
||||||
|
}
|
||||||
|
|
||||||
|
return dedupMap.values().stream().map(m -> {
|
||||||
|
Map<String, Object> 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());
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user