diff --git a/backend/src/main/java/com/designhub/controller/AdminTaskController.java b/backend/src/main/java/com/designhub/controller/AdminTaskController.java index 796bc2b..8ef6000 100644 --- a/backend/src/main/java/com/designhub/controller/AdminTaskController.java +++ b/backend/src/main/java/com/designhub/controller/AdminTaskController.java @@ -24,6 +24,7 @@ public class AdminTaskController { private final AdminTaskService adminTaskService; private final JwtUtil jwtUtil; + private final com.designhub.service.TaskUnlockService taskUnlockService; @Operation(summary = "分配任务") @PostMapping("/{taskId}/assign") @@ -164,5 +165,41 @@ public class AdminTaskController { public String getReason() { return reason; } public void setReason(String reason) { this.reason = reason; } } + + // ==================== 任务排队解锁相关接口 ==================== + + @Operation(summary = "获取任务解锁状态统计") + @GetMapping("/unlock/stats") + public Result> getUnlockStats() { + Map stats = taskUnlockService.getUnlockStats(); + return Result.success(stats); + } + + @Operation(summary = "手动触发任务解锁") + @PostMapping("/unlock/trigger") + public Result> triggerUnlock( + @RequestParam(required = false) Long deptId, + @RequestParam(required = false, defaultValue = "false") Boolean forceUnlock, + @RequestHeader("Authorization") String authHeader + ) { + String token = jwtUtil.extractTokenFromHeader(authHeader); + Long adminId = jwtUtil.getUserIdFromToken(token); + + Map result = new java.util.HashMap<>(); + + if (deptId != null) { + // 指定部门解锁 + int count = taskUnlockService.unlockTasksForDept(deptId, + forceUnlock ? 100 : taskUnlockService.getDailyUnlockMin()); + result.put("deptId", deptId); + result.put("unlockedCount", count); + } else { + // 触发全部部门的每日解锁 + taskUnlockService.dailyUnlockTask(); + result.put("message", "已触发每日解锁任务"); + } + + return Result.success(result); + } } diff --git a/backend/src/main/java/com/designhub/controller/TaskItemController.java b/backend/src/main/java/com/designhub/controller/TaskItemController.java index 7ac68b3..ab0de6e 100644 --- a/backend/src/main/java/com/designhub/controller/TaskItemController.java +++ b/backend/src/main/java/com/designhub/controller/TaskItemController.java @@ -46,6 +46,7 @@ public class TaskItemController { /** * 获取可接单的子任务列表 + * 支持排序和排队状态显示 */ @Operation(summary = "获取可接单的子任务列表") @GetMapping("/available") @@ -54,9 +55,11 @@ public class TaskItemController { @Parameter(description = "每页数量") @RequestParam(defaultValue = "10") Integer pageSize, @Parameter(description = "任务类型ID") @RequestParam(required = false) Long taskTypeId, @Parameter(description = "搜索关键词") @RequestParam(required = false) String keyword, - @Parameter(description = "筛选可接单部门ID") @RequestParam(required = false) Long filterDeptId) { + @Parameter(description = "筛选可接单部门ID") @RequestParam(required = false) Long filterDeptId, + @Parameter(description = "排序方式: oldest(最早优先)/latest(最新优先,默认)") @RequestParam(defaultValue = "latest") String sortBy, + @Parameter(description = "是否包含排队中的任务") @RequestParam(defaultValue = "true") Boolean includeQueuing) { - IPage items = taskItemService.getAvailableItems(page, pageSize, taskTypeId, keyword, filterDeptId); + IPage items = taskItemService.getAvailableItems(page, pageSize, taskTypeId, keyword, filterDeptId, sortBy, includeQueuing); return Result.success(items); } diff --git a/backend/src/main/java/com/designhub/entity/TaskItem.java b/backend/src/main/java/com/designhub/entity/TaskItem.java index 8881cb2..1a8737b 100644 --- a/backend/src/main/java/com/designhub/entity/TaskItem.java +++ b/backend/src/main/java/com/designhub/entity/TaskItem.java @@ -279,8 +279,49 @@ public class TaskItem implements Serializable { */ @TableField(exist = false) private String remark; - - // Getter和Setter - public String getRemark() { return remark; } - public void setRemark(String remark) { this.remark = remark; } + + // ========== 排队解锁相关字段(非数据库字段)========== + + /** + * 解锁状态:queuing(排队中) / unlocked(可抢单) + * 仅对招募类任务有效,指定任务不显示此字段 + */ + @TableField(exist = false) + private String unlockStatus; + + /** + * 排队位置(仅排队中有值) + */ + @TableField(exist = false) + private Integer queuePosition; + + /** + * 当前部门排队总数 + */ + @TableField(exist = false) + private Integer totalQueueSize; + + /** + * 预估解锁日期(格式:MM月DD日) + */ + @TableField(exist = false) + private String estimatedUnlockDate; + + /** + * 排队进度百分比 (0-100) + */ + @TableField(exist = false) + private Integer queueProgress; + + /** + * 是否可以抢单 + */ + @TableField(exist = false) + private Boolean canGrab; + + /** + * 主任务发布类型(非数据库字段,用于判断是否为指定任务) + */ + @TableField(exist = false) + private String releaseType; } diff --git a/backend/src/main/java/com/designhub/entity/TaskUnlockStatus.java b/backend/src/main/java/com/designhub/entity/TaskUnlockStatus.java new file mode 100644 index 0000000..2019f54 --- /dev/null +++ b/backend/src/main/java/com/designhub/entity/TaskUnlockStatus.java @@ -0,0 +1,64 @@ +package com.designhub.entity; + +import com.baomidou.mybatisplus.annotation.*; +import lombok.Data; + +import java.time.LocalDate; +import java.time.LocalDateTime; + +/** + * 任务排队解锁状态实体 + * 用于控制任务的可见性,实现任务排队解锁机制 + */ +@Data +@TableName("task_unlock_status") +public class TaskUnlockStatus { + + /** + * 主键ID + */ + @TableId(type = IdType.AUTO) + private Long id; + + /** + * 子任务ID + */ + private Long taskItemId; + + /** + * 接单部门ID + */ + private Long deptId; + + /** + * 任务发布日期(用于排队排序) + */ + private LocalDate publishDate; + + /** + * 是否已解锁: 0-未解锁(排队中), 1-已解锁(可抢单) + */ + private Integer isUnlocked; + + /** + * 解锁时间 + */ + private LocalDateTime unlockTime; + + /** + * 排队位置(动态计算,缓存用) + */ + private Integer queuePosition; + + /** + * 创建时间 + */ + @TableField(fill = FieldFill.INSERT) + private LocalDateTime createTime; + + /** + * 更新时间 + */ + @TableField(fill = FieldFill.INSERT_UPDATE) + private LocalDateTime updateTime; +} diff --git a/backend/src/main/java/com/designhub/mapper/TaskUnlockStatusMapper.java b/backend/src/main/java/com/designhub/mapper/TaskUnlockStatusMapper.java new file mode 100644 index 0000000..6ea67df --- /dev/null +++ b/backend/src/main/java/com/designhub/mapper/TaskUnlockStatusMapper.java @@ -0,0 +1,123 @@ +package com.designhub.mapper; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import com.designhub.entity.TaskUnlockStatus; +import org.apache.ibatis.annotations.*; + +import java.time.LocalDate; +import java.util.List; + +/** + * 任务解锁状态 Mapper + */ +@Mapper +public interface TaskUnlockStatusMapper extends BaseMapper { + + /** + * 获取指定部门已解锁的任务ID列表 + */ + @Select("SELECT task_item_id FROM task_unlock_status WHERE dept_id = #{deptId} AND is_unlocked = 1") + List selectUnlockedTaskItemIds(@Param("deptId") Long deptId); + + /** + * 获取指定部门未解锁的任务ID列表(按发布日期排序) + */ + @Select("SELECT task_item_id FROM task_unlock_status " + + "WHERE dept_id = #{deptId} AND is_unlocked = 0 " + + "ORDER BY publish_date ASC, id ASC") + List selectQueuedTaskItemIds(@Param("deptId") Long deptId); + + /** + * 获取指定部门未解锁任务总数 + */ + @Select("SELECT COUNT(*) FROM task_unlock_status WHERE dept_id = #{deptId} AND is_unlocked = 0") + int countQueuedTasks(@Param("deptId") Long deptId); + + /** + * 获取指定部门已解锁但未被接单的任务数 + */ + @Select("SELECT COUNT(*) FROM task_unlock_status tus " + + "INNER JOIN task_item ti ON tus.task_item_id = ti.id " + + "WHERE tus.dept_id = #{deptId} AND tus.is_unlocked = 1 AND ti.status = 'pending'") + int countUnlockedPendingTasks(@Param("deptId") Long deptId); + + /** + * 批量解锁任务 + */ + @Update("") + int batchUnlock(@Param("deptId") Long deptId, @Param("taskItemIds") List taskItemIds); + + /** + * 获取指定部门按发布日期分组的未解锁任务统计 + */ + @Select("SELECT publish_date, COUNT(*) as count FROM task_unlock_status " + + "WHERE dept_id = #{deptId} AND is_unlocked = 0 " + + "GROUP BY publish_date ORDER BY publish_date ASC") + @Results({ + @Result(column = "publish_date", property = "publishDate"), + @Result(column = "count", property = "count") + }) + List selectQueuedGroupByDate(@Param("deptId") Long deptId); + + /** + * 获取需要解锁的任务ID列表(按日期顺序,限制数量) + */ + @Select("SELECT task_item_id FROM task_unlock_status " + + "WHERE dept_id = #{deptId} AND is_unlocked = 0 " + + "ORDER BY publish_date ASC, id ASC " + + "LIMIT #{limit}") + List selectTasksToUnlock(@Param("deptId") Long deptId, @Param("limit") int limit); + + /** + * 计算任务在队列中的位置 + */ + @Select("SELECT COUNT(*) + 1 FROM task_unlock_status " + + "WHERE dept_id = #{deptId} AND is_unlocked = 0 " + + "AND (publish_date < (SELECT publish_date FROM task_unlock_status WHERE task_item_id = #{taskItemId} AND dept_id = #{deptId}) " + + " OR (publish_date = (SELECT publish_date FROM task_unlock_status WHERE task_item_id = #{taskItemId} AND dept_id = #{deptId}) " + + " AND id < (SELECT id FROM task_unlock_status WHERE task_item_id = #{taskItemId} AND dept_id = #{deptId})))") + int calculateQueuePosition(@Param("taskItemId") Long taskItemId, @Param("deptId") Long deptId); + + /** + * 检查任务是否已解锁 + */ + @Select("SELECT is_unlocked FROM task_unlock_status WHERE task_item_id = #{taskItemId} AND dept_id = #{deptId}") + Integer isUnlocked(@Param("taskItemId") Long taskItemId, @Param("deptId") Long deptId); + + /** + * 删除任务的解锁状态(任务取消/删除时调用) + */ + @Delete("DELETE FROM task_unlock_status WHERE task_item_id = #{taskItemId}") + int deleteByTaskItemId(@Param("taskItemId") Long taskItemId); + + /** + * 日期计数VO(内部类) + */ + class DateCountVO { + private LocalDate publishDate; + private Integer count; + + public LocalDate getPublishDate() { + return publishDate; + } + + public void setPublishDate(LocalDate publishDate) { + this.publishDate = publishDate; + } + + public Integer getCount() { + return count; + } + + public void setCount(Integer count) { + this.count = count; + } + } +} diff --git a/backend/src/main/java/com/designhub/service/TaskItemService.java b/backend/src/main/java/com/designhub/service/TaskItemService.java index 29b86f8..10dfa8c 100644 --- a/backend/src/main/java/com/designhub/service/TaskItemService.java +++ b/backend/src/main/java/com/designhub/service/TaskItemService.java @@ -45,12 +45,16 @@ public class TaskItemService { private final com.designhub.mapper.UserMapper userMapper; private final ObjectMapper objectMapper; private final com.designhub.util.WorkHoursCalculator workHoursCalculator; + + // 排队解锁服务(延迟注入避免循环依赖) + @org.springframework.beans.factory.annotation.Autowired + private TaskUnlockService taskUnlockService; /** * 获取可接单的子任务列表 */ public IPage getAvailableItems(Integer page, Integer pageSize, Long taskTypeId, String keyword) { - return getAvailableItems(page, pageSize, taskTypeId, keyword, null); + return getAvailableItems(page, pageSize, taskTypeId, keyword, null, "latest", true); } /** @@ -58,61 +62,143 @@ public class TaskItemService { * @param filterDeptId 筛选可接单部门ID(可选) */ public IPage getAvailableItems(Integer page, Integer pageSize, Long taskTypeId, String keyword, Long filterDeptId) { + return getAvailableItems(page, pageSize, taskTypeId, keyword, filterDeptId, "latest", true); + } + + /** + * 获取可接单的子任务列表(完整版,支持排序和排队状态) + * @param filterDeptId 筛选可接单部门ID(可选) + * @param sortBy 排序方式:oldest(最早优先) / latest(最新优先,默认) + * @param includeQueuing 是否包含排队中的任务 + */ + public IPage getAvailableItems(Integer page, Integer pageSize, Long taskTypeId, String keyword, + Long filterDeptId, String sortBy, Boolean includeQueuing) { Page pageParam = new Page<>(page, pageSize); + IPage result; // 如果没有指定筛选部门,直接查询 if (filterDeptId == null) { - return taskItemMapper.selectAvailableItems(pageParam, taskTypeId, keyword); - } - - // 🔥 获取筛选部门及其所有子部门的ID列表 - Set filterDeptAndChildIds = getDeptAndChildIds(filterDeptId); - log.info("筛选部门ID: {}, 包含子部门: {}", filterDeptId, filterDeptAndChildIds); - - // 获取该部门可接单的任务类型ID列表 - List allDemandTypes = demandTypeMapper.selectList(null); - Map demandTypeMap = allDemandTypes.stream() - .collect(java.util.stream.Collectors.toMap(com.designhub.entity.DemandType::getId, dt -> dt)); - - List filterDeptAllowedTypeIds = allDemandTypes.stream() - .filter(dt -> { - String allowedDepts = dt.getAllowedDeptIds(); - if ((allowedDepts == null || allowedDepts.isEmpty()) && dt.getParentId() != null) { - com.designhub.entity.DemandType parentType = demandTypeMap.get(dt.getParentId()); - if (parentType != null) { - allowedDepts = parentType.getAllowedDeptIds(); - } - } - // 只筛选明确指定了该部门(或其子部门)的任务类型 - if (allowedDepts == null || allowedDepts.isEmpty()) { - return false; - } - String[] deptIds = allowedDepts.split(","); - for (String deptIdStr : deptIds) { - try { - Long deptId = Long.parseLong(deptIdStr.trim()); - // 🔥 检查是否匹配筛选部门或其子部门 - if (filterDeptAndChildIds.contains(deptId)) { - return true; + result = taskItemMapper.selectAvailableItems(pageParam, taskTypeId, keyword); + } else { + // 🔥 获取筛选部门及其所有子部门的ID列表 + Set filterDeptAndChildIds = getDeptAndChildIds(filterDeptId); + log.debug("筛选部门ID: {}, 包含子部门: {}", filterDeptId, filterDeptAndChildIds); + + // 获取该部门可接单的任务类型ID列表 + List allDemandTypes = demandTypeMapper.selectList(null); + Map demandTypeMap = allDemandTypes.stream() + .collect(java.util.stream.Collectors.toMap(com.designhub.entity.DemandType::getId, dt -> dt)); + + List filterDeptAllowedTypeIds = allDemandTypes.stream() + .filter(dt -> { + String allowedDepts = dt.getAllowedDeptIds(); + if ((allowedDepts == null || allowedDepts.isEmpty()) && dt.getParentId() != null) { + com.designhub.entity.DemandType parentType = demandTypeMap.get(dt.getParentId()); + if (parentType != null) { + allowedDepts = parentType.getAllowedDeptIds(); } - } catch (NumberFormatException e) { - // 忽略无效的部门ID } - } - return false; - }) - .map(com.designhub.entity.DemandType::getId) - .collect(java.util.stream.Collectors.toList()); - - log.info("匹配的任务类型ID: {}", filterDeptAllowedTypeIds); - - // 如果没有找到匹配的任务类型,返回空结果 - if (filterDeptAllowedTypeIds.isEmpty()) { - return new Page<>(page, pageSize); + // 只筛选明确指定了该部门(或其子部门)的任务类型 + if (allowedDepts == null || allowedDepts.isEmpty()) { + return false; + } + String[] deptIds = allowedDepts.split(","); + for (String deptIdStr : deptIds) { + try { + Long deptId = Long.parseLong(deptIdStr.trim()); + // 🔥 检查是否匹配筛选部门或其子部门 + if (filterDeptAndChildIds.contains(deptId)) { + return true; + } + } catch (NumberFormatException e) { + // 忽略无效的部门ID + } + } + return false; + }) + .map(com.designhub.entity.DemandType::getId) + .collect(java.util.stream.Collectors.toList()); + + log.debug("匹配的任务类型ID: {}", filterDeptAllowedTypeIds); + + // 如果没有找到匹配的任务类型,返回空结果 + if (filterDeptAllowedTypeIds.isEmpty()) { + return new Page<>(page, pageSize); + } + + // 查询并筛选 + result = taskItemMapper.selectAvailableItemsByTaskTypes(pageParam, taskTypeId, keyword, filterDeptAllowedTypeIds); } - // 查询并筛选 - return taskItemMapper.selectAvailableItemsByTaskTypes(pageParam, taskTypeId, keyword, filterDeptAllowedTypeIds); + // 🔥 填充排队状态信息 + if (result.getRecords() != null && !result.getRecords().isEmpty()) { + fillUnlockStatusForItems(result.getRecords(), filterDeptId); + + // 🔥 按排序方式和解锁状态排序 + List sortedRecords = new java.util.ArrayList<>(result.getRecords()); + sortedRecords.sort((a, b) -> { + // 首先按解锁状态排序:已解锁的排前面 + boolean aUnlocked = "unlocked".equals(a.getUnlockStatus()) || a.getUnlockStatus() == null; + boolean bUnlocked = "unlocked".equals(b.getUnlockStatus()) || b.getUnlockStatus() == null; + if (aUnlocked != bUnlocked) { + return aUnlocked ? -1 : 1; + } + + // 然后按时间排序 + LocalDateTime timeA = a.getCreateTime(); + LocalDateTime timeB = b.getCreateTime(); + if (timeA == null && timeB == null) return 0; + if (timeA == null) return 1; + if (timeB == null) return -1; + + if ("oldest".equals(sortBy)) { + return timeA.compareTo(timeB); // 最早优先 + } else { + return timeB.compareTo(timeA); // 最新优先(默认) + } + }); + + // 更新结果 + ((Page) result).setRecords(sortedRecords); + } + + return result; + } + + /** + * 为任务列表填充排队解锁状态 + */ + private void fillUnlockStatusForItems(List items, Long deptId) { + if (items == null || items.isEmpty()) { + return; + } + + for (TaskItem item : items) { + // 获取主任务的发布类型 + String releaseType = item.getReleaseType(); + if (releaseType == null && item.getTaskId() != null) { + Task mainTask = taskMapper.selectById(item.getTaskId()); + if (mainTask != null) { + releaseType = mainTask.getReleaseType(); + item.setReleaseType(releaseType); + } + } + + // 确定目标部门 + Long targetDeptId = deptId; + if (targetDeptId == null) { + targetDeptId = item.getReceiverDeptId(); + } + + // 填充解锁状态 + if (targetDeptId != null) { + taskUnlockService.fillUnlockStatus(item, targetDeptId, releaseType); + } else { + // 无法确定部门,默认可抢 + item.setUnlockStatus("unlocked"); + item.setCanGrab(true); + } + } } /** @@ -358,6 +444,18 @@ public class TaskItemService { throw new BusinessException(ResultCode.TASK_STATUS_ERROR, "该子任务已被锁定,请等待前序任务完成"); } + // 🔥 检查排队解锁状态:非指定任务需要检查是否已解锁 + Task mainTaskForUnlockCheck = taskMapper.selectById(item.getTaskId()); + String releaseType = mainTaskForUnlockCheck != null ? mainTaskForUnlockCheck.getReleaseType() : null; + + if (!"assigned".equals(releaseType)) { + // 非指定任务,检查是否可抢单 + Long targetDeptId = item.getReceiverDeptId() != null ? item.getReceiverDeptId() : user.getDeptId(); + if (!taskUnlockService.canGrab(itemId, targetDeptId, releaseType)) { + throw new BusinessException(ResultCode.TASK_STATUS_ERROR, "该任务正在排队中,暂不可接单"); + } + } + // 🔥 检查是否是指定任务:如果子任务已预先指定了接收人 boolean isAssignedItem = false; if (item.getReceiverId() != null) { @@ -446,6 +544,17 @@ public class TaskItemService { log.info("已通知一级发起人 {} 子任务被接单", mainTask.getPrimaryPublisherId()); } } + + // 🔥 任务被抢单后,检查并补充解锁下一批任务 + Long grabberDeptId = user.getDeptId(); + if (grabberDeptId != null) { + try { + taskUnlockService.onTaskGrabbed(grabberDeptId); + } catch (Exception e) { + log.warn("抢单后补充解锁任务失败: {}", e.getMessage()); + // 不影响抢单流程 + } + } } /** diff --git a/backend/src/main/java/com/designhub/service/TaskStatusNotificationService.java b/backend/src/main/java/com/designhub/service/TaskStatusNotificationService.java new file mode 100644 index 0000000..b784327 --- /dev/null +++ b/backend/src/main/java/com/designhub/service/TaskStatusNotificationService.java @@ -0,0 +1,267 @@ +package com.designhub.service; + +import com.designhub.entity.*; +import com.designhub.mapper.UserMapper; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.scheduling.annotation.Async; +import org.springframework.stereotype.Service; + +import java.time.LocalDateTime; +import java.time.format.DateTimeFormatter; +import java.util.HashSet; +import java.util.Set; + +/** + * 任务状态变更通知服务 + * 负责在任务状态变化时通知需求人 + */ +@Service +@Slf4j +@RequiredArgsConstructor +public class TaskStatusNotificationService { + + private final NotificationService notificationService; + private final UserMapper userMapper; + + private static final DateTimeFormatter DATE_TIME_FORMATTER = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm"); + + /** + * 通知需求人:任务已解锁(排队中 → 抢单中) + */ + @Async + public void notifyTaskUnlocked(TaskItem item, Task mainTask) { + if (mainTask == null) { + log.warn("通知任务解锁失败:主任务为空"); + return; + } + + Set demandUserIds = getDemandUserIds(mainTask); + if (demandUserIds.isEmpty()) { + log.warn("通知任务解锁失败:没有找到需求人"); + return; + } + + String content = String.format( + "您发布的任务《%s》已完成排队,现已开放设计师抢单。\n" + + "任务编号:%s\n" + + "发布时间:%s\n" + + "设计师正在抢单中,请耐心等待...", + mainTask.getTitle(), + item.getItemNo(), + formatTime(mainTask.getCreateTime()) + ); + + for (Long userId : demandUserIds) { + try { + Notification notification = new Notification(); + notification.setReceiverId(userId); + notification.setNotificationType("task_status"); + notification.setTitle("📢 任务已开放抢单"); + notification.setContent(content); + notification.setRelatedId(item.getId()); + notification.setRelatedType("task_item"); + notification.setPriority(1); // 普通优先级 + notification.setPushDingtalk(1); // 推送钉钉 + notificationService.sendNotification(notification); + + log.info("已通知需求人 {} 任务 {} 已解锁开放抢单", userId, item.getItemNo()); + } catch (Exception e) { + log.error("通知需求人 {} 任务解锁失败: {}", userId, e.getMessage()); + } + } + } + + /** + * 通知需求人:任务已被接单(抢单中 → 进行中) + */ + @Async + public void notifyTaskGrabbed(TaskItem item, Task mainTask, User designer) { + if (mainTask == null || designer == null) { + log.warn("通知任务接单失败:主任务或设计师信息为空"); + return; + } + + Set demandUserIds = getDemandUserIds(mainTask); + if (demandUserIds.isEmpty()) { + return; + } + + String content = String.format( + "您发布的任务《%s》已被设计师接单。\n" + + "任务编号:%s\n" + + "接单设计师:%s\n" + + "预计完成:%s\n" + + "如有问题可联系设计师沟通。", + mainTask.getTitle(), + item.getItemNo(), + designer.getRealName(), + item.getDeadline() != null ? formatTime(item.getDeadline()) : "待定" + ); + + for (Long userId : demandUserIds) { + // 不通知自己(如果设计师就是需求人) + if (userId.equals(designer.getId())) { + continue; + } + + try { + Notification notification = new Notification(); + notification.setReceiverId(userId); + notification.setNotificationType("task_status"); + notification.setTitle("🎉 任务已被接单"); + notification.setContent(content); + notification.setRelatedId(item.getId()); + notification.setRelatedType("task_item"); + notification.setPriority(1); + notification.setPushDingtalk(1); + notificationService.sendNotification(notification); + + log.info("已通知需求人 {} 任务 {} 已被 {} 接单", userId, item.getItemNo(), designer.getRealName()); + } catch (Exception e) { + log.error("通知需求人 {} 任务接单失败: {}", userId, e.getMessage()); + } + } + } + + /** + * 通知需求人:任务已完成 + */ + @Async + public void notifyTaskCompleted(TaskItem item, Task mainTask, User designer) { + if (mainTask == null || designer == null) { + log.warn("通知任务完成失败:主任务或设计师信息为空"); + return; + } + + Set demandUserIds = getDemandUserIds(mainTask); + if (demandUserIds.isEmpty()) { + return; + } + + String content = String.format( + "您发布的任务《%s》已完成。\n" + + "任务编号:%s\n" + + "完成设计师:%s\n" + + "完成时间:%s\n" + + "请及时查收并确认交付物。", + mainTask.getTitle(), + item.getItemNo(), + designer.getRealName(), + formatTime(item.getCompleteTime() != null ? item.getCompleteTime() : LocalDateTime.now()) + ); + + for (Long userId : demandUserIds) { + try { + Notification notification = new Notification(); + notification.setReceiverId(userId); + notification.setNotificationType("task_status"); + notification.setTitle("✅ 任务已完成"); + notification.setContent(content); + notification.setRelatedId(item.getId()); + notification.setRelatedType("task_item"); + notification.setPriority(2); // 重要 + notification.setPushDingtalk(1); + notificationService.sendNotification(notification); + + log.info("已通知需求人 {} 任务 {} 已完成", userId, item.getItemNo()); + } catch (Exception e) { + log.error("通知需求人 {} 任务完成失败: {}", userId, e.getMessage()); + } + } + } + + /** + * 批量通知任务解锁 + */ + @Async + public void batchNotifyTasksUnlocked(java.util.List items, java.util.Map taskMap) { + if (items == null || items.isEmpty()) { + return; + } + + // 按主任务分组,避免同一任务的多个子任务重复通知 + java.util.Map> groupedByTask = items.stream() + .collect(java.util.stream.Collectors.groupingBy(TaskItem::getTaskId)); + + for (java.util.Map.Entry> entry : groupedByTask.entrySet()) { + Long taskId = entry.getKey(); + java.util.List taskItems = entry.getValue(); + Task mainTask = taskMap.get(taskId); + + if (mainTask == null) { + continue; + } + + Set demandUserIds = getDemandUserIds(mainTask); + if (demandUserIds.isEmpty()) { + continue; + } + + // 构建通知内容 + String itemNos = taskItems.stream() + .map(TaskItem::getItemNo) + .collect(java.util.stream.Collectors.joining(", ")); + + String content = String.format( + "您发布的任务《%s》有 %d 个子任务已完成排队,现已开放设计师抢单。\n" + + "子任务编号:%s\n" + + "设计师正在抢单中,请耐心等待...", + mainTask.getTitle(), + taskItems.size(), + itemNos + ); + + for (Long userId : demandUserIds) { + try { + Notification notification = new Notification(); + notification.setReceiverId(userId); + notification.setNotificationType("task_status"); + notification.setTitle("📢 任务已开放抢单"); + notification.setContent(content); + notification.setRelatedId(taskId); + notification.setRelatedType("task"); + notification.setPriority(1); + notification.setPushDingtalk(1); + notificationService.sendNotification(notification); + } catch (Exception e) { + log.error("批量通知任务解锁失败: {}", e.getMessage()); + } + } + } + } + + /** + * 获取任务的所有需求人(发布者 + 详情填写人 + 一级发起人) + */ + private Set getDemandUserIds(Task task) { + Set userIds = new HashSet<>(); + + // 发布者 + if (task.getPublisherId() != null) { + userIds.add(task.getPublisherId()); + } + + // 详情填写人 + if (task.getDetailCompleterId() != null) { + userIds.add(task.getDetailCompleterId()); + } + + // 一级发起人 + if (task.getPrimaryPublisherId() != null) { + userIds.add(task.getPrimaryPublisherId()); + } + + return userIds; + } + + /** + * 格式化时间 + */ + private String formatTime(LocalDateTime time) { + if (time == null) { + return "未知"; + } + return time.format(DATE_TIME_FORMATTER); + } +} diff --git a/backend/src/main/java/com/designhub/service/TaskUnlockService.java b/backend/src/main/java/com/designhub/service/TaskUnlockService.java new file mode 100644 index 0000000..ed9d6a1 --- /dev/null +++ b/backend/src/main/java/com/designhub/service/TaskUnlockService.java @@ -0,0 +1,515 @@ +package com.designhub.service; + +import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; +import com.designhub.entity.*; +import com.designhub.mapper.*; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.time.format.DateTimeFormatter; +import java.util.*; + +/** + * 任务排队解锁服务 + * 实现任务排队机制,确保先发布的任务优先被处理 + */ +@Service +@Slf4j +public class TaskUnlockService { + + @Autowired + private TaskUnlockStatusMapper unlockStatusMapper; + + @Autowired + private TaskItemMapper taskItemMapper; + + @Autowired + private TaskMapper taskMapper; + + @Autowired + private DepartmentMapper departmentMapper; + + @Autowired + private SystemConfigService systemConfigService; + + @Autowired + private DemandTypeMapper demandTypeMapper; + + @Autowired + private TaskStatusNotificationService taskStatusNotificationService; + + private static final int DEFAULT_DAILY_UNLOCK_MIN = 5; + private static final int DEFAULT_BATCH_SIZE = 5; + + // ==================== 配置相关 ==================== + + /** + * 检查排队解锁功能是否启用 + */ + public boolean isUnlockEnabled() { + String enabled = systemConfigService.getConfigValue("task_unlock_enabled", "false"); + return "true".equalsIgnoreCase(enabled); + } + + /** + * 获取每日最少解锁数 + */ + public int getDailyUnlockMin() { + String value = systemConfigService.getConfigValue("task_unlock_daily_min", String.valueOf(DEFAULT_DAILY_UNLOCK_MIN)); + try { + return Integer.parseInt(value); + } catch (NumberFormatException e) { + return DEFAULT_DAILY_UNLOCK_MIN; + } + } + + /** + * 获取解锁批次大小 + */ + public int getBatchSize() { + String value = systemConfigService.getConfigValue("task_unlock_batch_size", String.valueOf(DEFAULT_BATCH_SIZE)); + try { + return Integer.parseInt(value); + } catch (NumberFormatException e) { + return DEFAULT_BATCH_SIZE; + } + } + + // ==================== 定时任务 ==================== + + /** + * 每日解锁任务(每天凌晨1点执行) + */ + @Scheduled(cron = "0 0 1 * * ?") + public void dailyUnlockTask() { + if (!isUnlockEnabled()) { + log.info("任务排队解锁功能已关闭,跳过每日解锁任务"); + return; + } + + log.info("========== 开始执行每日任务解锁 =========="); + + // 获取所有接单部门 + List receivingDepts = departmentMapper.selectList( + new LambdaQueryWrapper() + .eq(Department::getDeptType, "receiving") + .eq(Department::getStatus, 1) + ); + + int totalUnlocked = 0; + int dailyMin = getDailyUnlockMin(); + + for (Department dept : receivingDepts) { + try { + int count = unlockTasksForDept(dept.getId(), dailyMin); + totalUnlocked += count; + if (count > 0) { + log.info("部门 {} ({}) 解锁了 {} 个任务", dept.getDeptName(), dept.getId(), count); + } + } catch (Exception e) { + log.error("部门 {} 解锁任务失败: {}", dept.getId(), e.getMessage(), e); + } + } + + log.info("========== 每日任务解锁完成,共解锁 {} 个任务 ==========", totalUnlocked); + } + + // ==================== 核心解锁逻辑 ==================== + + /** + * 为指定部门解锁任务 + * @param deptId 部门ID + * @param minCount 最少解锁数量 + * @return 实际解锁数量 + */ + @Transactional(rollbackFor = Exception.class) + public int unlockTasksForDept(Long deptId, int minCount) { + // 1. 检查当前已解锁但未接单的任务数 + int currentUnlockedPending = unlockStatusMapper.countUnlockedPendingTasks(deptId); + + // 如果已有足够的可接任务,不需要解锁更多 + if (currentUnlockedPending >= minCount) { + log.debug("部门 {} 已有 {} 个可接任务,无需解锁更多", deptId, currentUnlockedPending); + return 0; + } + + // 2. 计算需要解锁的数量 + int needToUnlock = minCount - currentUnlockedPending; + + // 3. 获取待解锁的任务ID列表(按发布日期排序) + List tasksToUnlock = unlockStatusMapper.selectTasksToUnlock(deptId, needToUnlock); + + if (tasksToUnlock.isEmpty()) { + log.debug("部门 {} 没有待解锁的任务", deptId); + return 0; + } + + // 4. 批量解锁 + int unlocked = unlockStatusMapper.batchUnlock(deptId, tasksToUnlock); + + log.info("部门 {} 成功解锁 {} 个任务", deptId, unlocked); + + // 5. 发送解锁通知给需求人 + try { + notifyUnlockedTasks(tasksToUnlock); + } catch (Exception e) { + log.error("发送解锁通知失败: {}", e.getMessage()); + // 不影响解锁流程 + } + + return unlocked; + } + + /** + * 任务被抢单后,检查并补充解锁 + */ + @Transactional(rollbackFor = Exception.class) + public void onTaskGrabbed(Long deptId) { + if (!isUnlockEnabled()) { + return; + } + + // 检查剩余可接任务数 + int remainingUnlocked = unlockStatusMapper.countUnlockedPendingTasks(deptId); + int batchSize = getBatchSize(); + + // 如果少于批次大小,补充解锁 + if (remainingUnlocked < batchSize) { + int unlocked = unlockTasksForDept(deptId, batchSize); + if (unlocked > 0) { + log.info("任务被接单后,为部门 {} 补充解锁 {} 个任务", deptId, unlocked); + } + } + } + + // ==================== 任务入队逻辑 ==================== + + /** + * 任务审核通过后,加入排队池 + * @param task 主任务 + * @param items 子任务列表 + */ + @Transactional(rollbackFor = Exception.class) + public void onTaskApproved(Task task, List items) { + if (!isUnlockEnabled()) { + log.debug("排队解锁功能未启用,跳过入队处理"); + return; + } + + // 指定任务不进入排队池 + if ("assigned".equals(task.getReleaseType())) { + log.info("指定任务 {} 不进入排队池,直接可见", task.getTaskNo()); + return; + } + + LocalDate publishDate = task.getCreateTime().toLocalDate(); + int addedCount = 0; + + for (TaskItem item : items) { + // 获取目标接单部门 + Long deptId = getTargetDeptId(item, task); + if (deptId == null) { + log.warn("子任务 {} 无法确定目标部门,跳过入队", item.getItemNo()); + continue; + } + + // 检查是否已存在 + TaskUnlockStatus existing = unlockStatusMapper.selectOne( + new LambdaQueryWrapper() + .eq(TaskUnlockStatus::getTaskItemId, item.getId()) + .eq(TaskUnlockStatus::getDeptId, deptId) + ); + + if (existing != null) { + continue; // 已存在,跳过 + } + + // 创建排队记录 + TaskUnlockStatus unlockStatus = new TaskUnlockStatus(); + unlockStatus.setTaskItemId(item.getId()); + unlockStatus.setDeptId(deptId); + unlockStatus.setPublishDate(publishDate); + unlockStatus.setIsUnlocked(0); // 未解锁 + unlockStatus.setCreateTime(LocalDateTime.now()); + + unlockStatusMapper.insert(unlockStatus); + addedCount++; + } + + if (addedCount > 0) { + log.info("任务 {} 的 {} 个子任务已加入排队池", task.getTaskNo(), addedCount); + } + } + + /** + * 流水线解锁时,自动标记为已解锁(不走排队) + */ + @Transactional(rollbackFor = Exception.class) + public void onPipelineUnlock(TaskItem item, Long deptId) { + if (!isUnlockEnabled()) { + return; + } + + // 检查是否已有记录 + TaskUnlockStatus existing = unlockStatusMapper.selectOne( + new LambdaQueryWrapper() + .eq(TaskUnlockStatus::getTaskItemId, item.getId()) + .eq(TaskUnlockStatus::getDeptId, deptId) + ); + + if (existing != null) { + // 更新为已解锁 + existing.setIsUnlocked(1); + existing.setUnlockTime(LocalDateTime.now()); + unlockStatusMapper.updateById(existing); + } else { + // 创建已解锁记录 + TaskUnlockStatus unlockStatus = new TaskUnlockStatus(); + unlockStatus.setTaskItemId(item.getId()); + unlockStatus.setDeptId(deptId); + unlockStatus.setPublishDate(LocalDate.now()); + unlockStatus.setIsUnlocked(1); // 直接解锁 + unlockStatus.setUnlockTime(LocalDateTime.now()); + unlockStatus.setCreateTime(LocalDateTime.now()); + + unlockStatusMapper.insert(unlockStatus); + } + + log.info("流水线子任务 {} 已自动标记为已解锁", item.getItemNo()); + } + + // ==================== 查询方法 ==================== + + /** + * 获取已解锁的任务ID列表 + */ + public List getUnlockedTaskItemIds(Long deptId) { + return unlockStatusMapper.selectUnlockedTaskItemIds(deptId); + } + + /** + * 获取排队中的任务ID列表 + */ + public List getQueuedTaskItemIds(Long deptId) { + return unlockStatusMapper.selectQueuedTaskItemIds(deptId); + } + + /** + * 填充任务的排队状态信息 + */ + public void fillUnlockStatus(TaskItem item, Long deptId, String releaseType) { + // 指定任务不显示排队状态 + if ("assigned".equals(releaseType)) { + item.setUnlockStatus(null); + item.setCanGrab(true); + return; + } + + // 非 pending 状态不需要排队信息 + if (!"pending".equals(item.getStatus())) { + item.setUnlockStatus(null); + item.setCanGrab(false); + return; + } + + // 功能未启用时,所有任务都可抢 + if (!isUnlockEnabled()) { + item.setUnlockStatus("unlocked"); + item.setCanGrab(true); + return; + } + + // 查询解锁状态 + Integer isUnlocked = unlockStatusMapper.isUnlocked(item.getId(), deptId); + + if (isUnlocked == null) { + // 没有记录,视为已解锁(兼容旧数据) + item.setUnlockStatus("unlocked"); + item.setCanGrab(true); + return; + } + + if (isUnlocked == 1) { + // 已解锁 = 抢单中 + item.setUnlockStatus("unlocked"); + item.setCanGrab(true); + } else { + // 未解锁 = 排队中 + item.setUnlockStatus("queuing"); + item.setCanGrab(false); + + // 计算排队位置 + int position = unlockStatusMapper.calculateQueuePosition(item.getId(), deptId); + item.setQueuePosition(position); + + // 计算总排队数 + int totalQueue = unlockStatusMapper.countQueuedTasks(deptId); + item.setTotalQueueSize(totalQueue); + + // 预估解锁日期(每天解锁 dailyMin 个) + int dailyMin = getDailyUnlockMin(); + int daysToUnlock = (int) Math.ceil((double) position / dailyMin); + LocalDate unlockDate = LocalDate.now().plusDays(daysToUnlock); + item.setEstimatedUnlockDate(unlockDate.format(DateTimeFormatter.ofPattern("MM月dd日"))); + + // 计算进度百分比 + int progress = totalQueue > 0 ? + (int) Math.round((totalQueue - position + 1.0) / totalQueue * 100) : 0; + item.setQueueProgress(Math.min(progress, 99)); // 最高99%,100%表示已解锁 + } + } + + /** + * 批量填充任务的排队状态 + */ + public void batchFillUnlockStatus(List items, Long deptId) { + if (items == null || items.isEmpty()) { + return; + } + + for (TaskItem item : items) { + String releaseType = item.getReleaseType(); + fillUnlockStatus(item, deptId, releaseType); + } + } + + /** + * 检查任务是否可以抢单 + */ + public boolean canGrab(Long taskItemId, Long deptId, String releaseType) { + // 指定任务始终可抢(由其他逻辑控制权限) + if ("assigned".equals(releaseType)) { + return true; + } + + // 功能未启用时,都可抢 + if (!isUnlockEnabled()) { + return true; + } + + // 检查解锁状态 + Integer isUnlocked = unlockStatusMapper.isUnlocked(taskItemId, deptId); + + // 没有记录或已解锁 = 可抢 + return isUnlocked == null || isUnlocked == 1; + } + + // ==================== 辅助方法 ==================== + + /** + * 获取任务的目标接单部门ID + */ + private Long getTargetDeptId(TaskItem item, Task task) { + // 优先使用子任务的接单部门 + if (item.getReceiverDeptId() != null) { + return item.getReceiverDeptId(); + } + + // 从任务类型获取 + if (task.getDemandTypeId() != null) { + DemandType demandType = demandTypeMapper.selectById(task.getDemandTypeId()); + if (demandType != null && demandType.getAllowedDeptIds() != null) { + String[] deptIds = demandType.getAllowedDeptIds().split(","); + if (deptIds.length > 0) { + try { + return Long.parseLong(deptIds[0].trim()); + } catch (NumberFormatException e) { + // 忽略 + } + } + } + } + + return null; + } + + /** + * 删除任务的解锁状态(任务取消/删除时调用) + */ + @Transactional(rollbackFor = Exception.class) + public void deleteUnlockStatus(Long taskItemId) { + unlockStatusMapper.deleteByTaskItemId(taskItemId); + } + + /** + * 通知任务解锁 + */ + private void notifyUnlockedTasks(List taskItemIds) { + if (taskItemIds == null || taskItemIds.isEmpty()) { + return; + } + + // 查询子任务和主任务信息 + List items = taskItemMapper.selectBatchIds(taskItemIds); + if (items.isEmpty()) { + return; + } + + // 获取主任务信息 + Set taskIds = new HashSet<>(); + for (TaskItem item : items) { + if (item.getTaskId() != null) { + taskIds.add(item.getTaskId()); + } + } + + Map taskMap = new HashMap<>(); + if (!taskIds.isEmpty()) { + List tasks = taskMapper.selectBatchIds(taskIds); + for (Task task : tasks) { + taskMap.put(task.getId(), task); + } + } + + // 批量通知 + taskStatusNotificationService.batchNotifyTasksUnlocked(items, taskMap); + } + + // ==================== 统计方法 ==================== + + /** + * 获取解锁状态统计 + */ + public Map getUnlockStats() { + Map stats = new HashMap<>(); + + // 总待解锁数 + long totalQueued = unlockStatusMapper.selectCount( + new LambdaQueryWrapper() + .eq(TaskUnlockStatus::getIsUnlocked, 0) + ); + stats.put("totalQueued", totalQueued); + + // 总已解锁数 + long totalUnlocked = unlockStatusMapper.selectCount( + new LambdaQueryWrapper() + .eq(TaskUnlockStatus::getIsUnlocked, 1) + ); + stats.put("totalUnlocked", totalUnlocked); + + // 按部门统计 + List depts = departmentMapper.selectList( + new LambdaQueryWrapper() + .eq(Department::getDeptType, "receiving") + .eq(Department::getStatus, 1) + ); + + List> deptStats = new ArrayList<>(); + for (Department dept : depts) { + Map deptStat = new HashMap<>(); + deptStat.put("deptId", dept.getId()); + deptStat.put("deptName", dept.getDeptName()); + deptStat.put("queuedCount", unlockStatusMapper.countQueuedTasks(dept.getId())); + deptStat.put("unlockedPendingCount", unlockStatusMapper.countUnlockedPendingTasks(dept.getId())); + deptStats.add(deptStat); + } + stats.put("deptStats", deptStats); + + return stats; + } +} diff --git a/database/V15__task_unlock_queue.sql b/database/V15__task_unlock_queue.sql new file mode 100644 index 0000000..69c6680 --- /dev/null +++ b/database/V15__task_unlock_queue.sql @@ -0,0 +1,64 @@ +-- ===================================================== +-- 任务排队解锁机制 - 数据库迁移脚本 +-- 版本: V15 +-- 日期: 2026-01-26 +-- 说明: 实现任务排队解锁功能,确保先发布的任务优先被处理 +-- ===================================================== + +-- 1. 创建任务解锁状态表 +CREATE TABLE IF NOT EXISTS `task_unlock_status` ( + `id` BIGINT NOT NULL AUTO_INCREMENT COMMENT '主键ID', + `task_item_id` BIGINT NOT NULL COMMENT '子任务ID', + `dept_id` BIGINT NOT NULL COMMENT '接单部门ID', + `publish_date` DATE NOT NULL COMMENT '任务发布日期(用于排队)', + `is_unlocked` TINYINT(1) DEFAULT 0 COMMENT '是否已解锁: 0-未解锁(排队中), 1-已解锁(可抢单)', + `unlock_time` DATETIME NULL COMMENT '解锁时间', + `queue_position` INT DEFAULT 0 COMMENT '排队位置(动态计算,缓存用)', + `create_time` DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', + `update_time` DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间', + + PRIMARY KEY (`id`), + UNIQUE KEY `uk_task_dept` (`task_item_id`, `dept_id`), + INDEX `idx_dept_unlock` (`dept_id`, `is_unlocked`, `publish_date`), + INDEX `idx_publish_date` (`publish_date`, `is_unlocked`), + INDEX `idx_task_item` (`task_item_id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='任务排队解锁状态表'; + +-- 2. 添加系统配置项(默认关闭,灰度测试后开启) +INSERT INTO sys_config (config_key, config_value, config_group, config_name, description, value_type, built_in, sort_order) VALUES +('task_unlock_enabled', 'false', 'task', '任务排队解锁开关', '是否启用任务排队解锁机制,启用后新任务需排队等待解锁', 'boolean', 1, 100), +('task_unlock_daily_min', '5', 'task', '每日最少解锁数', '每个部门每天最少解锁的任务数量', 'number', 1, 101), +('task_unlock_batch_size', '5', 'task', '解锁批次大小', '每次补充解锁的任务数量', 'number', 1, 102) +ON DUPLICATE KEY UPDATE + config_value = VALUES(config_value), + description = VALUES(description); + +-- 3. 初始化现有可接任务为已解锁状态(确保上线不影响现有业务) +-- 注意:只处理招募类任务(release_type != 'assigned'),指定任务不进入排队池 +INSERT IGNORE INTO task_unlock_status (task_item_id, dept_id, publish_date, is_unlocked, unlock_time, create_time) +SELECT + ti.id AS task_item_id, + COALESCE( + ti.receiver_dept_id, + (SELECT CAST(SUBSTRING_INDEX(dt.allowed_dept_ids, ',', 1) AS UNSIGNED) + FROM demand_type dt WHERE dt.id = t.demand_type_id LIMIT 1), + t.publisher_dept_id + ) AS dept_id, + DATE(ti.create_time) AS publish_date, + 1 AS is_unlocked, -- 已解锁 + NOW() AS unlock_time, + NOW() AS create_time +FROM task_item ti +INNER JOIN task t ON ti.task_id = t.id +WHERE ti.status = 'pending' + AND ti.is_locked = 0 + AND t.deleted = 0 + AND t.approval_status = 'approved' + AND (t.release_type IS NULL OR t.release_type != 'assigned'); -- 排除指定任务 + +-- 4. 输出初始化结果 +SELECT + '初始化完成' AS status, + COUNT(*) AS initialized_count, + SUM(CASE WHEN is_unlocked = 1 THEN 1 ELSE 0 END) AS unlocked_count +FROM task_unlock_status; diff --git a/docs/任务排队解锁机制开发文档.md b/docs/任务排队解锁机制开发文档.md new file mode 100644 index 0000000..167fa32 --- /dev/null +++ b/docs/任务排队解锁机制开发文档.md @@ -0,0 +1,1450 @@ +# 任务排队解锁机制开发文档 + +## 1. 需求背景 + +### 1.1 现状问题 +- 当前可接任务列表默认按发布时间倒序排列(最新发布的排最前) +- 导致发布时间较早的任务容易被忽略,形成任务积压 +- 设计师倾向于接新任务,老任务长期无人处理 + +### 1.2 目标 +- 实现任务排队机制,确保先发布的任务优先被处理 +- 按接单部门维度控制每天解锁的任务数量 +- 每个部门每天至少解锁5个任务,保证工作量均衡 +- 当某天任务不足5个时,自动从后续日期补充 + +## 2. 功能设计 + +### 2.1 核心规则 + +#### 2.1.1 排序规则 +- **默认排序**:按发布时间正序排列(时间越早越靠前) +- **优先处理**:发布日期早的任务优先显示 + +#### 2.1.2 解锁机制 +以「可接单部门」为维度,按以下规则解锁任务: + +``` +1. 按发布日期分组所有可接任务 +2. 从最早的日期开始,依次解锁任务 +3. 每个部门每天最少解锁 5 个任务(可配置) +4. 如果某天任务数 < 5,则从下一天补充 +5. 已解锁的任务对该部门用户可见 +``` + +#### 2.1.3 解锁示例 + +假设部门A有以下可接任务: + +| 日期 | 任务数 | 解锁数 | 说明 | +|------|--------|--------|------| +| 1月20日 | 3个 | 3个 | 不足5个,需从后续日期补充 | +| 1月21日 | 8个 | 2个(补1月20日) + 5个 = 7个 | 先补前一天,再解锁当天5个 | +| 1月22日 | 4个 | 0个 | 前面已有10个任务,暂不解锁 | +| 1月23日 | 6个 | 0个 | 暂不解锁 | + +**解锁结果**:部门A用户可见任务 = 1月20日(3个) + 1月21日(7个) = 10个 + +### 2.2 配置参数 + +| 参数名 | 默认值 | 说明 | +|--------|--------|------| +| `DAILY_UNLOCK_MIN` | 5 | 每个部门每天最少解锁任务数 | +| `UNLOCK_ENABLED` | true | 是否启用解锁机制 | +| `UNLOCK_BATCH_SIZE` | 5 | 每次解锁的批次大小 | + +## 3. 数据库设计 + +### 3.1 新增表:task_unlock_status(任务解锁状态表) + +```sql +CREATE TABLE task_unlock_status ( + id BIGINT PRIMARY KEY AUTO_INCREMENT, + task_item_id BIGINT NOT NULL COMMENT '子任务ID', + dept_id BIGINT NOT NULL COMMENT '接单部门ID', + unlock_date DATE NOT NULL COMMENT '解锁日期', + is_unlocked TINYINT(1) DEFAULT 0 COMMENT '是否已解锁: 0-未解锁, 1-已解锁', + unlock_time DATETIME COMMENT '解锁时间', + create_time DATETIME DEFAULT CURRENT_TIMESTAMP, + + UNIQUE KEY uk_task_dept (task_item_id, dept_id), + INDEX idx_dept_unlock (dept_id, is_unlocked, unlock_date), + INDEX idx_unlock_date (unlock_date, is_unlocked) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='任务解锁状态表'; +``` + +### 3.2 系统配置表新增配置项 + +```sql +INSERT INTO sys_config (config_key, config_value, config_group, description) VALUES +('task_unlock_daily_min', '5', 'task', '每个部门每天最少解锁任务数'), +('task_unlock_enabled', 'true', 'task', '是否启用任务解锁机制'), +('task_unlock_batch_size', '5', 'task', '每次解锁批次大小'); +``` + +## 4. 接口设计 + +### 4.1 获取可接任务列表(修改现有接口) + +**请求**:`GET /api/tasks/items/available` + +**新增参数**: +| 参数 | 类型 | 默认值 | 说明 | +|------|------|--------|------| +| sortBy | String | latest | 排序方式:`oldest`(最早优先) / `latest`(最新优先) | +| includeQueuing | Boolean | true | 是否包含排队中的任务(设计师可见但不可抢单) | + +**响应变更**: +- 返回已解锁 + 排队中的任务(根据 includeQueuing 参数) +- 新增字段 `unlockStatus`:queuing(排队中) / unlocked(可抢单) +- 新增字段 `queuePosition`:排队位置 +- 新增字段 `estimatedUnlockDate`:预估解锁日期 +- 新增字段 `canGrab`:是否可以抢单(true/false) + +### 4.2 手动触发解锁(管理员接口) + +**请求**:`POST /api/admin/tasks/unlock` + +**参数**: +```json +{ + "deptId": 106, // 可选,指定部门,不传则全部部门 + "forceUnlock": false // 是否强制解锁(忽略每日限制) +} +``` + +### 4.3 获取解锁状态统计(管理员接口) + +**请求**:`GET /api/admin/tasks/unlock/stats` + +**响应**: +```json +{ + "code": 200, + "data": { + "totalPending": 150, + "totalUnlocked": 45, + "deptStats": [ + { + "deptId": 106, + "deptName": "平面视觉部", + "pendingCount": 50, + "unlockedCount": 15, + "oldestPendingDate": "2026-01-15" + } + ] + } +} +``` + +### 4.4 获取我发布的任务(修改现有接口,增加排队状态) + +**请求**:`GET /api/tasks/my-published` + +**响应新增字段**: + +```json +{ + "code": 200, + "data": { + "records": [ + { + "id": 123, + "title": "品牌VI设计", + "status": "pending", + "approvalStatus": "approved", + + // 🔥 新增:排队相关字段 + "unlockStatus": "queuing", // queuing=排队中, unlocked=已解锁(抢单中) + "queuePosition": 15, // 排队位置(第几个) + "totalQueueSize": 50, // 当前排队总数 + "estimatedUnlockDate": "2026-01-28", // 预估解锁日期 + "queueProgress": 70, // 排队进度百分比 + + // 子任务排队信息(流水线任务) + "items": [ + { + "id": 456, + "itemNo": "TASK-001-01-01", + "status": "pending", + "unlockStatus": "queuing", + "queuePosition": 8, + "receiverDeptName": "平面视觉部" + } + ] + } + ] + } +} +``` + +### 4.5 状态说明 + +| releaseType | status | unlockStatus | 前端显示 | 说明 | +|-------------|--------|--------------|----------|------| +| **assigned** | pending | - | 🔵 待接单 | 指定任务,不走排队,直接可见 | +| recruitment/default | pending | queuing | 🟠 排队中 | 招募任务,在排队池中等待 | +| recruitment/default | pending | unlocked | 🟢 抢单中 | 招募任务,已解锁等待抢单 | +| - | in_progress | - | 🔵 进行中 | 已被设计师接单,正在处理 | +| - | completed | - | ⚪ 已完成 | 任务已完结 | + +> **注意**:指定任务(assigned)不显示排队状态,直接显示「待接单」 + +## 5. 后端实现 + +### 5.1 新增服务类 + +```java +// TaskUnlockService.java +@Service +public class TaskUnlockService { + + /** + * 定时任务:每天凌晨1点执行解锁 + */ + @Scheduled(cron = "0 0 1 * * ?") + public void dailyUnlockTask() { + // 1. 获取所有接单部门 + // 2. 对每个部门执行解锁逻辑 + // 3. 记录解锁日志 + } + + /** + * 为指定部门解锁任务 + */ + public int unlockTasksForDept(Long deptId, int minCount) { + // 1. 查询该部门未解锁的任务,按发布日期正序 + // 2. 按日期分组 + // 3. 从最早日期开始解锁,确保至少minCount个 + // 4. 不足时从后续日期补充 + } + + /** + * 获取已解锁的任务列表 + */ + public List getUnlockedTaskIds(Long deptId) { + // 返回该部门已解锁的任务ID列表 + } +} +``` + +### 5.2 修改现有查询逻辑 + +```java +// TaskItemService.java - 修改 getAvailableItems 方法 + +public Page getAvailableItems(TaskItemQueryDTO query) { + // 1. 获取用户所属部门 + Long userDeptId = getCurrentUserDeptId(); + + // 2. 如果启用解锁机制 + if (isUnlockEnabled()) { + // 获取已解锁的任务ID列表 + List unlockedIds = taskUnlockService.getUnlockedTaskIds(userDeptId); + // 只查询已解锁的任务 + query.setTaskItemIds(unlockedIds); + } + + // 3. 默认按发布时间正序排序 + if (query.getSortBy() == null || "oldest".equals(query.getSortBy())) { + query.setOrderBy("create_time ASC"); + } + + // 4. 执行查询 + return taskItemMapper.selectAvailableItems(query); +} +``` + +### 5.3 获取任务排队状态(需求方视角) + +```java +// TaskUnlockService.java - 获取排队状态信息 + +/** + * 获取子任务的排队状态信息 + * @param itemId 子任务ID + * @return 排队状态VO + */ +public TaskUnlockStatusVO getUnlockStatus(Long itemId) { + TaskUnlockStatusVO vo = new TaskUnlockStatusVO(); + + TaskItem item = taskItemMapper.selectById(itemId); + if (item == null) return null; + + // 非 pending 状态不需要排队信息 + if (!"pending".equals(item.getStatus())) { + vo.setUnlockStatus("unlocked"); + return vo; + } + + // 查询解锁状态 + Long deptId = item.getReceiverDeptId(); + if (deptId == null) { + deptId = getTargetDeptIdFromTaskType(item.getTaskTypeId()); + } + + TaskUnlockStatus unlockStatus = unlockStatusMapper.selectOne( + new LambdaQueryWrapper() + .eq(TaskUnlockStatus::getTaskItemId, itemId) + .eq(TaskUnlockStatus::getDeptId, deptId) + ); + + if (unlockStatus != null && unlockStatus.getIsUnlocked() == 1) { + // 已解锁 = 抢单中 + vo.setUnlockStatus("unlocked"); + } else { + // 未解锁 = 排队中 + vo.setUnlockStatus("queuing"); + + // 计算排队位置 + int position = calculateQueuePosition(itemId, deptId); + vo.setQueuePosition(position); + + // 计算总排队数 + int totalQueue = countTotalQueue(deptId); + vo.setTotalQueueSize(totalQueue); + + // 预估解锁日期(每天解锁5个) + int daysToUnlock = (int) Math.ceil(position / 5.0); + vo.setEstimatedUnlockDate(LocalDate.now().plusDays(daysToUnlock)); + + // 计算进度百分比 + int progress = totalQueue > 0 ? + (int) Math.round((totalQueue - position + 1.0) / totalQueue * 100) : 0; + vo.setQueueProgress(progress); + } + + return vo; +} + +/** + * 计算排队位置 + * 规则:按发布时间排序,返回当前任务在未解锁队列中的位置 + */ +private int calculateQueuePosition(Long itemId, Long deptId) { + // 获取该部门所有未解锁的任务,按发布时间排序 + List queuedItemIds = unlockStatusMapper.selectQueuedItemIds(deptId); + + for (int i = 0; i < queuedItemIds.size(); i++) { + if (queuedItemIds.get(i).equals(itemId)) { + return i + 1; // 位置从1开始 + } + } + return queuedItemIds.size() + 1; +} +``` + +### 5.4 排队状态VO + +```java +// TaskUnlockStatusVO.java +@Data +public class TaskUnlockStatusVO { + /** + * 解锁状态:queuing=排队中, unlocked=已解锁 + */ + private String unlockStatus; + + /** + * 排队位置(仅排队中有值) + */ + private Integer queuePosition; + + /** + * 当前部门排队总数 + */ + private Integer totalQueueSize; + + /** + * 预估解锁日期 + */ + private LocalDate estimatedUnlockDate; + + /** + * 排队进度百分比 (0-100) + */ + private Integer queueProgress; +} + +## 6. 前端实现 + +### 6.1 TaskHall.vue 修改(设计师可接任务页) + +```typescript +// 默认排序改为最早优先 +const sortBy = ref<'oldest' | 'latest'>('oldest') + +// 排序选项 +const sortOptions = [ + { value: 'oldest', label: '最早发布' }, + { value: 'latest', label: '最新发布' } +] +``` + +### 6.2 任务卡片排队状态显示(需求部门视角) + +需求部门在「我发布的任务」或「任务详情」页面需要看到任务的排队进度状态: + +#### 6.2.1 状态定义 + +| 状态值 | 显示文本 | 颜色 | 说明 | +|--------|----------|------|------| +| `queuing` | 排队中 | 橙色 #fa8c16 | 任务已审核通过,在排队池中等待解锁 | +| `grabbing` | 抢单中 | 绿色 #52c41a | 任务已解锁,等待设计师抢单 | +| `in_progress` | 进行中 | 蓝色 #1890ff | 已有设计师接单,正在处理 | +| `completed` | 已完成 | 灰色 #8c8c8c | 任务已完结 | + +#### 6.2.2 任务卡片状态组件 + +```vue + + + + + + +``` + +#### 6.2.3 任务卡片中使用 + +```vue + +
+
+ {{ task.title }} + + +
+ + +
+
+ 排队进度 + 预计 {{ task.estimatedUnlockDate }} 解锁 +
+ +
+ + +
+``` + +### 6.3 抢单按钮状态设计(重要) + +#### 6.3.1 设计师可接任务页面 + +**方案选择**:设计师**可以看到**排队中的任务,但**不能抢单** + +| 任务状态 | 按钮显示 | 按钮状态 | 点击效果 | +|----------|----------|----------|----------| +| 🟢 抢单中(unlocked) | 「抢单」 | ✅ 可点击(绿色) | 正常抢单 | +| 🟠 排队中(queuing) | 「排队中」 | ❌ 禁用(灰色) | 无响应 / 提示 | +| 🔵 待接单(assigned) | 「接单」 | ✅ 可点击 | 指定任务接单 | + +#### 6.3.2 排队中任务的按钮样式 + +```vue + + + + +``` + +#### 6.3.3 点击排队中按钮的提示 + +```typescript +// 如果用户强行点击(通过其他方式绕过disabled),显示提示 +function handleGrabClick(task: TaskItem) { + if (task.unlockStatus === 'queuing') { + message.info(`该任务正在排队中,预计 ${task.estimatedUnlockDate} 开放抢单`) + return + } + // 正常抢单逻辑 + grabTask(task.id) +} +``` + +#### 6.3.4 可接任务列表显示策略 + +```typescript +// TaskHall.vue - 可接任务查询 +// 设计师可以看到两类任务:已解锁的 + 排队中的(用于展示进度) + +async function loadAvailableTasks() { + const params = { + ...filters, + includeQueuing: true // 🔥 新增参数:是否包含排队中的任务 + } + + const res = await taskApi.getAvailableTaskItems(params) + + // 排序:已解锁的排前面,排队中的排后面 + tasks.value = res.data.records.sort((a, b) => { + if (a.unlockStatus === 'unlocked' && b.unlockStatus === 'queuing') return -1 + if (a.unlockStatus === 'queuing' && b.unlockStatus === 'unlocked') return 1 + return 0 + }) +} +``` + +#### 6.3.5 视觉区分效果 + +``` +┌─────────────────────────────────────────────┐ +│ 🟢 可抢单任务 │ +├─────────────────────────────────────────────┤ +│ ┌─────────────────────────────────────────┐ │ +│ │ 品牌VI设计 🟢 抢单中 │ │ +│ │ 积分: 150 工时: 8h │ │ +│ │ [ 抢单 ] │ │ ← 绿色可点击 +│ └─────────────────────────────────────────┘ │ +│ ┌─────────────────────────────────────────┐ │ +│ │ 产品详情页设计 🟢 抢单中 │ │ +│ │ 积分: 80 工时: 4h │ │ +│ │ [ 抢单 ] │ │ ← 绿色可点击 +│ └─────────────────────────────────────────┘ │ +├─────────────────────────────────────────────┤ +│ 🟠 排队中任务 │ +├─────────────────────────────────────────────┤ +│ ┌─────────────────────────────────────────┐ │ +│ │ 主图设计 🟠 排队中 (第3位) │ │ +│ │ 积分: 50 工时: 2h │ │ +│ │ 排队进度 ████████░░ 预计01月28日解锁 │ │ +│ │ [ 排队中(第3位) ] │ │ ← 灰色禁用 +│ └─────────────────────────────────────────┘ │ +│ ┌─────────────────────────────────────────┐ │ +│ │ Banner设计 🟠 排队中 (第5位) │ │ +│ │ 积分: 60 工时: 3h │ │ +│ │ 排队进度 ██████░░░░ 预计01月29日解锁 │ │ +│ │ [ 排队中(第5位) ] │ │ ← 灰色禁用 +│ └─────────────────────────────────────────┘ │ +└─────────────────────────────────────────────┘ +``` + +#### 6.2.4 排队进度计算逻辑 + +```typescript +// 计算排队进度百分比 +function calculateQueueProgress(task: TaskItem): number { + if (!task.queuePosition || !task.totalQueueSize) return 0 + // 进度 = (总数 - 当前位置 + 1) / 总数 * 100 + return Math.round((task.totalQueueSize - task.queuePosition + 1) / task.totalQueueSize * 100) +} + +// 预估解锁日期 +function estimateUnlockDate(task: TaskItem): string { + if (!task.queuePosition) return '待定' + const daysToUnlock = Math.ceil(task.queuePosition / 5) // 每天解锁5个 + const unlockDate = dayjs().add(daysToUnlock, 'day') + return unlockDate.format('MM月DD日') +} +``` + +### 6.3 需求部门「我发布的任务」页面 + +```vue + + +``` + +## 7. 钉钉通知设计 + +### 7.1 通知触发场景 + +| 状态变化 | 通知对象 | 通知内容 | 触发时机 | +|----------|----------|----------|----------| +| 排队中 → 抢单中 | 需求人 | 任务已开放抢单 | 定时解锁 / 实时解锁 | +| 抢单中 → 进行中 | 需求人 | 任务已被设计师接单 | 设计师抢单成功 | +| 进行中 → 已完成 | 需求人 | 任务已完成 | 设计师提交完成 | + +### 7.2 通知内容模板 + +#### 7.2.1 任务解锁通知(排队中 → 抢单中) + +``` +📢 任务已开放抢单 + +您发布的任务《{taskTitle}》已完成排队,现已开放设计师抢单。 + +任务编号:{taskNo} +发布时间:{createTime} +预计工时:{estimatedHours}小时 + +设计师正在抢单中,请耐心等待... +``` + +#### 7.2.2 任务被接单通知(抢单中 → 进行中) + +``` +🎉 任务已被接单 + +您发布的任务《{taskTitle}》已被设计师接单。 + +任务编号:{taskNo} +接单设计师:{designerName} +接单部门:{deptName} +预计完成:{deadline} + +如有问题可联系设计师沟通。 +``` + +#### 7.2.3 任务完成通知 + +``` +✅ 任务已完成 + +您发布的任务《{taskTitle}》已完成。 + +任务编号:{taskNo} +完成设计师:{designerName} +完成时间:{completeTime} + +请及时查收并确认交付物。 +``` + +### 7.3 通知服务实现 + +```java +// TaskNotificationService.java +@Service +@Slf4j +public class TaskNotificationService { + + @Autowired + private NotificationService notificationService; + + @Autowired + private DingtalkService dingtalkService; + + /** + * 通知需求人:任务已解锁(排队中 → 抢单中) + */ + public void notifyTaskUnlocked(TaskItem item, Task mainTask) { + // 获取所有需求人(发布者 + 详情填写人) + Set demandUserIds = getDemandUserIds(mainTask); + + String content = String.format( + "您发布的任务《%s》已完成排队,现已开放设计师抢单。\n" + + "任务编号:%s\n" + + "发布时间:%s", + mainTask.getTitle(), + item.getItemNo(), + formatTime(mainTask.getCreateTime()) + ); + + for (Long userId : demandUserIds) { + // 1. 站内通知 + Notification notification = new Notification(); + notification.setReceiverId(userId); + notification.setNotificationType("task_status"); + notification.setTitle("📢 任务已开放抢单"); + notification.setContent(content); + notification.setRelatedId(item.getId()); + notification.setRelatedType("task_item"); + notification.setPriority(1); // 普通优先级 + notification.setPushDingtalk(1); // 推送钉钉 + notificationService.sendNotification(notification); + + log.info("已通知需求人 {} 任务 {} 已解锁", userId, item.getItemNo()); + } + } + + /** + * 通知需求人:任务已被接单(抢单中 → 进行中) + */ + public void notifyTaskGrabbed(TaskItem item, Task mainTask, User designer) { + Set demandUserIds = getDemandUserIds(mainTask); + + String content = String.format( + "您发布的任务《%s》已被设计师接单。\n" + + "任务编号:%s\n" + + "接单设计师:%s\n" + + "预计完成:%s", + mainTask.getTitle(), + item.getItemNo(), + designer.getRealName(), + formatTime(item.getDeadline()) + ); + + for (Long userId : demandUserIds) { + Notification notification = new Notification(); + notification.setReceiverId(userId); + notification.setNotificationType("task_status"); + notification.setTitle("🎉 任务已被接单"); + notification.setContent(content); + notification.setRelatedId(item.getId()); + notification.setRelatedType("task_item"); + notification.setPriority(1); + notification.setPushDingtalk(1); + notificationService.sendNotification(notification); + + log.info("已通知需求人 {} 任务 {} 已被接单", userId, item.getItemNo()); + } + } + + /** + * 通知需求人:任务已完成 + */ + public void notifyTaskCompleted(TaskItem item, Task mainTask, User designer) { + Set demandUserIds = getDemandUserIds(mainTask); + + String content = String.format( + "您发布的任务《%s》已完成。\n" + + "任务编号:%s\n" + + "完成设计师:%s\n" + + "完成时间:%s\n" + + "请及时查收并确认交付物。", + mainTask.getTitle(), + item.getItemNo(), + designer.getRealName(), + formatTime(item.getCompleteTime()) + ); + + for (Long userId : demandUserIds) { + Notification notification = new Notification(); + notification.setReceiverId(userId); + notification.setNotificationType("task_status"); + notification.setTitle("✅ 任务已完成"); + notification.setContent(content); + notification.setRelatedId(item.getId()); + notification.setRelatedType("task_item"); + notification.setPriority(2); // 重要 + notification.setPushDingtalk(1); + notificationService.sendNotification(notification); + + log.info("已通知需求人 {} 任务 {} 已完成", userId, item.getItemNo()); + } + } + + /** + * 获取任务的所有需求人(发布者 + 详情填写人) + */ + private Set getDemandUserIds(Task task) { + Set userIds = new HashSet<>(); + + // 发布者 + if (task.getCreatorId() != null) { + userIds.add(task.getCreatorId()); + } + + // 详情填写人 + if (task.getDetailCompleterId() != null) { + userIds.add(task.getDetailCompleterId()); + } + + return userIds; + } +} +``` + +### 7.4 通知调用位置 + +| 方法位置 | 添加通知调用 | 说明 | +|----------|--------------|------| +| `TaskUnlockService.unlockTasksForDept()` | `notifyTaskUnlocked()` | 定时解锁时通知 | +| `TaskUnlockService.unlockSingleTask()` | `notifyTaskUnlocked()` | 实时解锁时通知 | +| `TaskItemService.grabItem()` | `notifyTaskGrabbed()` | 设计师抢单成功时通知 | +| `TaskItemService.completeItem()` | `notifyTaskCompleted()` | 任务完成时通知 | + +## 8. 定时任务设计 + +### 8.1 每日解锁任务 + +```java +@Scheduled(cron = "0 0 1 * * ?") // 每天凌晨1点 +public void dailyUnlockTask() { + log.info("开始执行每日任务解锁..."); + + // 获取所有接单部门 + List receivingDepts = departmentMapper.selectByType("receiving"); + + int totalUnlocked = 0; + for (Department dept : receivingDepts) { + int count = unlockTasksForDept(dept.getId(), DAILY_UNLOCK_MIN); + totalUnlocked += count; + log.info("部门 {} 解锁了 {} 个任务", dept.getDeptName(), count); + } + + log.info("每日任务解锁完成,共解锁 {} 个任务", totalUnlocked); +} +``` + +### 7.2 实时解锁(任务被接单后) + +```java +// 当任务被接单后,自动解锁下一批任务 +@EventListener +public void onTaskGrabbed(TaskGrabbedEvent event) { + Long deptId = event.getReceiverDeptId(); + + // 检查该部门剩余解锁任务数 + int remainingUnlocked = getUnlockedRemainingCount(deptId); + + // 如果少于阈值,自动补充解锁 + if (remainingUnlocked < UNLOCK_BATCH_SIZE) { + unlockTasksForDept(deptId, UNLOCK_BATCH_SIZE); + } +} +``` + +## 9. 实施计划 + +### 阶段一:数据库准备 +1. 创建 task_unlock_status 表 +2. 添加系统配置项 +3. 初始化历史数据(将现有可接任务标记为已解锁) + +### 阶段二:后端开发 +1. 实现 TaskUnlockService 服务 +2. 修改可接任务查询逻辑 +3. 实现定时任务 +4. 添加管理接口 + +### 阶段三:前端开发 +1. 修改默认排序为「最早优先」 +2. 添加解锁状态显示(可选) +3. 添加排序切换功能 + +### 阶段四:测试上线 +1. 功能测试 +2. 性能测试 +3. 灰度发布 +4. 全量上线 + +## 10. 与现有流水线自动解锁机制的兼容 + +### 9.1 两种解锁机制的区别 + +系统中存在**两种不同层面的"解锁"**,需要明确区分: + +| 机制 | 层面 | 触发条件 | 控制字段 | 作用 | +|------|------|----------|----------|------| +| **流水线解锁** | 子任务状态 | 前一部门所有子任务完成 | `is_locked`, `status` | 控制流水线内的执行顺序 | +| **排队解锁(新增)** | 可见性 | 定时任务 / 每日配额 | `task_unlock_status` 表 | 控制每个部门看到的任务数量 | + +### 9.2 流水线自动解锁(现有机制) + +``` +顺序流水线任务:部门A → 部门B → 部门C + +1. 任务创建时: + - 部门A的子任务:status='pending', is_locked=0 ✅ 可接单 + - 部门B/C的子任务:status='locked', is_locked=1 ❌ 锁定 + +2. 部门A所有子任务完成后,自动解锁部门B: + - 部门B的子任务:status='pending'/'pending_approval', is_locked=0 ✅ 可接单 + +3. 部门B所有子任务完成后,自动解锁部门C... +``` + +**关键代码位置**:`TaskItemService.completeItem()` 方法(约第657-823行) + +### 9.3 排队解锁(新增机制) + +``` +所有可接任务(status='pending', is_locked=0)按发布日期排队: + +1. 任务发布后: + - 自动进入排队池 + - task_unlock_status 表记录:is_unlocked=0(未解锁) + +2. 定时任务执行: + - 按发布日期从早到晚解锁 + - 每个部门每天至少解锁5个 + - 更新 is_unlocked=1 +``` + +### 9.4 兼容策略:两层过滤 + +**可接任务最终查询逻辑**: + +```sql +-- 第一层:流水线状态过滤(现有逻辑,保持不变) +WHERE ti.status = 'pending' + AND ti.is_locked = 0 + AND t.deleted = 0 + AND t.approval_status = 'approved' + +-- 第二层:排队解锁过滤(新增逻辑) + AND ti.id IN ( + SELECT task_item_id + FROM task_unlock_status + WHERE dept_id = #{userDeptId} AND is_unlocked = 1 + ) +``` + +### 9.5 流水线子任务的特殊处理 + +流水线任务的后续部门子任务(初始状态为 `locked`)需要特殊处理: + +#### 策略:流水线解锁时自动进入排队池 + +```java +// TaskItemService.completeItem() 中,解锁下一部门时 +// 新增:将新解锁的子任务加入排队池 + +for (TaskItem nextItem : nextDeptItems) { + // 现有逻辑:解锁子任务 + nextItem.setStatus("pending"); // 或 "pending_approval" + nextItem.setIsLocked(0); + taskItemMapper.updateById(nextItem); + + // 🔥 新增逻辑:将子任务加入排队池(标记为未解锁) + taskUnlockService.addToUnlockQueue(nextItem.getId(), nextItem.getReceiverDeptId()); +} +``` + +#### 立即解锁 vs 进入排队 + +对于流水线解锁的子任务,有两种策略选择: + +| 策略 | 描述 | 优点 | 缺点 | +|------|------|------|------| +| **策略A:立即解锁** | 流水线解锁的子任务自动标记为已解锁 | 不影响现有流程,设计师立即可见 | 可能打乱排队顺序 | +| **策略B:进入排队** | 流水线解锁的子任务进入排队池,等待排队解锁 | 保持严格的时间顺序 | 可能导致流水线任务等待时间变长 | + +**推荐策略A**:流水线解锁的子任务**立即可见**,原因: +1. 流水线任务有明确的依赖关系,前序已完成意味着后序应及时处理 +2. 避免因排队机制导致整个流水线任务延误 +3. 对需求方体验更好 + +### 9.6 代码修改点总结 + +| 文件 | 修改内容 | 影响范围 | +|------|----------|----------| +| `TaskItemService.getAvailableItems()` | 新增排队解锁过滤 | 可接任务列表查询 | +| `TaskItemService.completeItem()` | 流水线解锁时自动标记已解锁 | 子任务完结流程 | +| `TaskApprovalService.approveTask()` | 任务审核通过后加入排队池 | 任务审核流程 | +| 新增 `TaskUnlockService` | 排队解锁核心服务 | 新增服务 | + +### 9.7 指定任务的特殊处理(重要) + +**指定任务**(`release_type = 'assigned'`)**不受排队解锁机制限制**,理由: +1. 已明确指定接单设计师,无需排队等待 +2. 通常有紧急性或特殊要求 +3. 被指定的设计师应立即看到任务 + +#### 9.7.1 判断规则 + +```java +// 判断是否为指定任务 +boolean isAssignedTask = "assigned".equals(mainTask.getReleaseType()); + +// 或者判断子任务是否已预先指定设计师 +boolean isPreAssignedItem = item.getReceiverId() != null; +``` + +#### 9.7.2 排队解锁过滤逻辑调整 + +```sql +-- 可接任务查询:指定任务直接可见,招募任务才走排队过滤 +WHERE ti.status = 'pending' + AND ti.is_locked = 0 + AND t.deleted = 0 + AND t.approval_status = 'approved' + AND ( + -- 🔥 条件1:指定任务 - 被指定的设计师直接可见 + (t.release_type = 'assigned' AND ti.receiver_id = #{currentUserId}) + + OR + + -- 🔥 条件2:招募任务 - 走排队解锁机制 + (t.release_type != 'assigned' AND ( + #{unlockMode} = false + OR ti.id IN ( + SELECT task_item_id FROM task_unlock_status + WHERE dept_id = #{userDeptId} AND is_unlocked = 1 + ) + )) + ) +``` + +#### 9.7.3 任务发布时的处理 + +| 发布类型 | 处理方式 | 排队状态 | 状态显示 | +|----------|----------|----------|----------| +| **assigned(指定)** | 不进入排队池 | 无 | 直接显示「待接单」 | +| recruitment(招募) | 进入排队池 | queuing/unlocked | 「排队中」或「抢单中」 | +| default(默认) | 进入排队池 | queuing/unlocked | 「排队中」或「抢单中」 | + +#### 9.7.4 代码实现 + +```java +// TaskUnlockService.java + +/** + * 任务审核通过后,根据发布类型决定是否进入排队池 + */ +public void onTaskApproved(Task task, List items) { + // 🔥 指定任务不进入排队池 + if ("assigned".equals(task.getReleaseType())) { + log.info("指定任务 {} 不进入排队池,直接可见", task.getTaskNo()); + return; + } + + // 招募/默认任务进入排队池 + for (TaskItem item : items) { + addToUnlockQueue(item.getId(), getTargetDeptId(item)); + } + log.info("任务 {} 的 {} 个子任务已加入排队池", task.getTaskNo(), items.size()); +} +``` + +#### 9.7.5 前端状态显示 + +```typescript +// 计算任务显示状态 +function getDisplayStatus(task: TaskItem, mainTask: Task): string { + // 指定任务:不显示排队状态 + if (mainTask.releaseType === 'assigned') { + return task.status === 'pending' ? '待接单' : statusMap[task.status] + } + + // 招募任务:显示排队/抢单状态 + if (task.status === 'pending') { + return task.unlockStatus === 'queuing' ? '排队中' : '抢单中' + } + + return statusMap[task.status] +} +``` + +### 9.8 不受影响的功能 + +以下功能**不会受到**排队解锁机制的影响: + +- ✅ **指定任务模式**(直接指定设计师的任务,不进入排队池) +- ✅ 流水线任务的执行顺序 +- ✅ 任务状态流转(pending → in_progress → completed) +- ✅ 积分结算、通知推送 +- ✅ 任务撤回、取消 + +## 11. 注意事项 + +1. **向后兼容**:初次上线时需将所有现有可接任务标记为已解锁 +2. **性能优化**:解锁状态查询需要添加合适的索引 +3. **异常处理**:定时任务执行失败时需要告警通知 +4. **配置灵活**:每日解锁数量可按部门单独配置 +5. **数据一致性**:任务状态变更时需同步更新解锁状态 +6. **流水线优先**:流水线解锁的子任务立即可见,不受排队限制 + +## 12. 与现有代码的兼容性分析 + +### 12.1 后端接口对比 + +#### 现有接口签名 +```java +// TaskItemController.java +@GetMapping("/available") +public Result> getAvailableItems( + @RequestParam(defaultValue = "1") Integer page, + @RequestParam(defaultValue = "10") Integer pageSize, + @RequestParam(required = false) Long taskTypeId, + @RequestParam(required = false) String keyword, + @RequestParam(required = false) Long filterDeptId) +``` + +#### 设计文档变更 +| 变更项 | 现有 | 设计 | 兼容性 | +|--------|------|------|--------| +| sortBy 参数 | ❌ 无 | ✅ 新增 | ✅ 兼容(新增可选参数) | +| unlockMode 参数 | ❌ 无 | ✅ 新增 | ✅ 兼容(新增可选参数,默认true) | +| 默认排序 | DESC(最新优先) | ASC(最早优先) | ⚠️ **需调整设计** | + +#### 调整建议 +```java +// 保持现有默认行为,只有明确指定 sortBy=oldest 时才按最早排序 +@RequestParam(required = false, defaultValue = "latest") String sortBy +``` + +### 12.2 现有 SQL 查询分析 + +#### 现有 Mapper SQL +```sql +-- TaskItemMapper.selectAvailableItems (第24-77行) +SELECT ti.*, ... +FROM task_item ti +LEFT JOIN task t ON ti.task_id = t.id +... +WHERE ti.status = 'pending' + AND ti.is_locked = 0 + AND t.deleted = 0 + AND t.approval_status = 'approved' +ORDER BY ti.create_time DESC -- 🔥 现有:最新优先 +``` + +#### 设计方案变更 +```sql +-- 新增排队解锁过滤(需要新增) + + AND ti.id IN ( + SELECT task_item_id FROM task_unlock_status + WHERE dept_id = #{userDeptId} AND is_unlocked = 1 + ) + + +-- 排序改为动态 +ORDER BY ti.create_time + + ASC + DESC + +``` + +### 12.3 前端排序选项对比 + +#### 现有排序选项(TaskHall.vue) +```typescript +const availableSortOptions = [ + { value: 'latest', label: '最新发布' }, // ✅ 保留 + { value: 'score', label: '积分最高' }, // ✅ 保留 + { value: 'deadline', label: '时效最短' }, // ✅ 保留 + { value: 'hot', label: '最热门' } // ✅ 保留 +] +``` + +#### 设计方案变更 +```typescript +const availableSortOptions = [ + { value: 'oldest', label: '最早发布' }, // 🔥 新增 + { value: 'latest', label: '最新发布' }, // ✅ 保留 + { value: 'score', label: '积分最高' }, + { value: 'deadline', label: '时效最短' }, + { value: 'hot', label: '最热门' } +] + +// 默认值改为 oldest +const filters = reactive({ + ... + sortBy: 'oldest' as 'oldest' | 'latest' | 'score' | 'deadline' | 'hot', + ... +}) +``` + +### 12.4 实体类字段对比 + +#### 现有 TaskItem 非数据库字段 +```java +// TaskItem.java - @TableField(exist = false) 字段 +- taskTypeName, mainTask, mainTaskTitle, pipelineMode +- totalItems, brandName, categoryName, platformName +- publisherName, spuInfo, taskSubType, attachmentUrl +- previewImage, isPipeline, subTasks, subTaskTotal +- isPrimaryTask, primaryPublisherName, primaryPublisherDeptName +- detailCompleterName, remark +``` + +#### 需要新增的字段 +```java +// TaskItem.java - 新增 @TableField(exist = false) 字段 +@TableField(exist = false) +private String unlockStatus; // queuing/unlocked + +@TableField(exist = false) +private Integer queuePosition; // 排队位置 + +@TableField(exist = false) +private Integer totalQueueSize; // 排队总数 + +@TableField(exist = false) +private String estimatedUnlockDate; // 预估解锁日期 + +@TableField(exist = false) +private Integer queueProgress; // 排队进度百分比 +``` + +### 12.5 通知表字段映射 + +#### 现有 sys_notification 表结构 +| 表字段 | 类型 | 代码中使用的字段名 | +|--------|------|-------------------| +| type | VARCHAR(50) | notificationType | +| related_task_id | BIGINT | relatedId | +| ding_enabled | TINYINT | pushDingtalk | +| ding_priority | INT | priority | + +#### Notification 实体映射(需确认) +```java +@TableName("sys_notification") +public class Notification { + @TableField("type") + private String notificationType; + + @TableField("related_task_id") + private Long relatedId; + + @TableField("ding_enabled") + private Integer pushDingtalk; +} +``` + +### 12.6 风险点与规避措施 + +| 风险点 | 影响 | 规避措施 | +|--------|------|----------| +| 默认排序变更 | 用户习惯改变 | 保持默认 latest,新增 oldest 选项 | +| 查询结果减少 | 部分任务不可见 | 初始化脚本:现有可接任务全部标记已解锁 | +| 接口参数变更 | 前端报错 | 新增参数设为可选,提供默认值 | +| 定时任务影响 | 凌晨1点执行 | 添加开关,可临时禁用 | +| 流水线任务 | 解锁后不可见 | 流水线解锁时自动标记已解锁 | + +### 12.7 初始化数据迁移脚本 + +```sql +-- 1. 创建 task_unlock_status 表 +CREATE TABLE task_unlock_status ( + id BIGINT PRIMARY KEY AUTO_INCREMENT, + task_item_id BIGINT NOT NULL COMMENT '子任务ID', + dept_id BIGINT NOT NULL COMMENT '接单部门ID', + unlock_date DATE NOT NULL COMMENT '解锁日期', + is_unlocked TINYINT(1) DEFAULT 0 COMMENT '是否已解锁: 0-未解锁, 1-已解锁', + unlock_time DATETIME COMMENT '解锁时间', + create_time DATETIME DEFAULT CURRENT_TIMESTAMP, + + UNIQUE KEY uk_task_dept (task_item_id, dept_id), + INDEX idx_dept_unlock (dept_id, is_unlocked, unlock_date), + INDEX idx_unlock_date (unlock_date, is_unlocked) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='任务解锁状态表'; + +-- 2. 🔥 关键:将现有所有【招募类】可接任务标记为已解锁(确保上线后不影响现有业务) +-- 注意:指定任务(assigned)不进入排队池,无需初始化 +INSERT INTO task_unlock_status (task_item_id, dept_id, unlock_date, is_unlocked, unlock_time, create_time) +SELECT + ti.id, + COALESCE(ti.receiver_dept_id, dt.allowed_dept_id) as dept_id, + DATE(ti.create_time), + 1, -- 已解锁 + NOW(), + NOW() +FROM task_item ti +LEFT JOIN task t ON ti.task_id = t.id +LEFT JOIN demand_type dt ON t.demand_type_id = dt.id +WHERE ti.status = 'pending' + AND ti.is_locked = 0 + AND t.deleted = 0 + AND t.approval_status = 'approved' + AND t.release_type != 'assigned'; -- 🔥 排除指定任务 + +-- 3. 添加系统配置项 +INSERT INTO sys_config (config_key, config_value, config_group, description) VALUES +('task_unlock_enabled', 'false', 'task', '是否启用任务排队解锁机制'), -- 🔥 默认关闭,灰度测试后开启 +('task_unlock_daily_min', '5', 'task', '每个部门每天最少解锁任务数'), +('task_unlock_batch_size', '5', 'task', '每次解锁批次大小'); +``` + +### 12.8 灰度上线策略 + +``` +阶段1: 数据准备(不影响业务) +├── 创建 task_unlock_status 表 +├── 初始化现有任务为已解锁 +└── task_unlock_enabled = false(关闭功能) + +阶段2: 功能部署(不影响业务) +├── 部署后端代码 +├── 部署前端代码 +└── 功能仍处于关闭状态 + +阶段3: 灰度测试 +├── 选择1个部门开启测试 +├── 监控任务查询、接单流程 +└── 收集用户反馈 + +阶段4: 全量上线 +├── task_unlock_enabled = true +├── 定时任务开始执行 +└── 持续监控 +``` + +## 13. 效果预期 + +- 发布时间早的任务能优先被设计师看到和处理 +- 每个部门每天有稳定的任务供给(至少5个) +- 避免任务积压,提高任务周转效率 +- 公平分配任务,按时间顺序处理 +- 流水线任务不受排队限制,保证流畅执行 +- **上线过程平滑,不影响现有业务** diff --git a/frontend/src/api/task.ts b/frontend/src/api/task.ts index 41b8a54..19270eb 100644 --- a/frontend/src/api/task.ts +++ b/frontend/src/api/task.ts @@ -170,7 +170,7 @@ export interface TaskHallParams { keyword?: string publisherName?: string timeRange?: 'today' | 'week' | 'month' - sortBy?: 'latest' | 'score' | 'deadline' | 'hot' + sortBy?: 'oldest' | 'latest' | 'score' | 'deadline' | 'hot' // 🔥 新增 oldest 选项 page?: number pageSize?: number } diff --git a/frontend/src/components/TaskCard.vue b/frontend/src/components/TaskCard.vue index 2b8ac2c..9818538 100644 --- a/frontend/src/components/TaskCard.vue +++ b/frontend/src/components/TaskCard.vue @@ -133,19 +133,19 @@