qiangdanxitong/docs/任务排队解锁机制开发文档.md
Your Name 7bb3c953ef feat: 实现任务排队解锁机制
- 新增任务排队解锁功能,确保先发布的任务优先被处理
- 添加系统设置中的任务排队配置(启用开关、每日解锁数、补充解锁数)
- 新增 TaskUnlockStatus 实体和 Mapper
- 新增 TaskUnlockService 处理排队解锁逻辑
- 新增 TaskStatusNotificationService 发送钉钉通知
- 更新 TaskItemService 和 TaskItemController 支持排队状态
- 前端新增 TaskStatusBadge 组件显示排队/抢单状态
- 更新 TaskCard 和 TaskHall 支持排队状态展示
- 添加数据库迁移脚本 V15
2026-01-29 10:36:38 +08:00

45 KiB
Raw Blame History

任务排队解锁机制开发文档

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(任务解锁状态表)

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 系统配置表新增配置项

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

参数:

{
    "deptId": 106,        // 可选,指定部门,不传则全部部门
    "forceUnlock": false  // 是否强制解锁(忽略每日限制)
}

4.3 获取解锁状态统计(管理员接口)

请求:GET /api/admin/tasks/unlock/stats

响应:

{
    "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

响应新增字段:

{
    "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 新增服务类

// 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<Long> getUnlockedTaskIds(Long deptId) {
        // 返回该部门已解锁的任务ID列表
    }
}

5.2 修改现有查询逻辑

// TaskItemService.java - 修改 getAvailableItems 方法

public Page<TaskItem> getAvailableItems(TaskItemQueryDTO query) {
    // 1. 获取用户所属部门
    Long userDeptId = getCurrentUserDeptId();
    
    // 2. 如果启用解锁机制
    if (isUnlockEnabled()) {
        // 获取已解锁的任务ID列表
        List<Long> 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 获取任务排队状态(需求方视角)

// 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<TaskUnlockStatus>()
            .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<Long> 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

// 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 任务卡片状态组件

<!-- TaskStatusBadge.vue - 任务状态徽章组件 -->
<template>
  <div class="task-status-badge" :class="statusClass">
    <span class="status-dot"></span>
    <span class="status-text">{{ statusText }}</span>
    <span v-if="queuePosition" class="queue-position">
      (第{{ queuePosition }}位)
    </span>
  </div>
</template>

<script setup lang="ts">
import { computed } from 'vue'

interface Props {
  status: string
  unlockStatus?: 'queuing' | 'unlocked'
  queuePosition?: number  // 排队位置(可选)
}

const props = defineProps<Props>()

const statusClass = computed(() => {
  if (props.status === 'pending') {
    // pending 状态需要根据解锁状态区分
    return props.unlockStatus === 'queuing' ? 'status-queuing' : 'status-grabbing'
  }
  return `status-${props.status}`
})

const statusText = computed(() => {
  if (props.status === 'pending') {
    return props.unlockStatus === 'queuing' ? '排队中' : '抢单中'
  }
  const statusMap: Record<string, string> = {
    'draft': '草稿',
    'pending_approval': '审核中',
    'in_progress': '进行中',
    'completed': '已完成',
    'cancelled': '已取消'
  }
  return statusMap[props.status] || props.status
})
</script>

<style scoped>
.task-status-badge {
  display: inline-flex;
  align-items: center;
  padding: 4px 12px;
  border-radius: 12px;
  font-size: 12px;
  font-weight: 500;
}

.status-dot {
  width: 6px;
  height: 6px;
  border-radius: 50%;
  margin-right: 6px;
}

/* 排队中 - 橙色 */
.status-queuing {
  background: rgba(250, 140, 22, 0.1);
  color: #fa8c16;
}
.status-queuing .status-dot {
  background: #fa8c16;
  animation: pulse 1.5s infinite;
}

/* 抢单中 - 绿色 */
.status-grabbing {
  background: rgba(82, 196, 26, 0.1);
  color: #52c41a;
}
.status-grabbing .status-dot {
  background: #52c41a;
  animation: pulse 1s infinite;
}

/* 进行中 - 蓝色 */
.status-in_progress {
  background: rgba(24, 144, 255, 0.1);
  color: #1890ff;
}
.status-in_progress .status-dot {
  background: #1890ff;
}

/* 已完成 - 灰色 */
.status-completed {
  background: rgba(140, 140, 140, 0.1);
  color: #8c8c8c;
}
.status-completed .status-dot {
  background: #8c8c8c;
}

/* 脉冲动画 */
@keyframes pulse {
  0%, 100% { opacity: 1; transform: scale(1); }
  50% { opacity: 0.5; transform: scale(1.2); }
}

.queue-position {
  margin-left: 4px;
  opacity: 0.8;
}
</style>

6.2.3 任务卡片中使用

<!-- TaskCard.vue 或 MyTasks.vue -->
<div class="task-card">
  <div class="card-header">
    <span class="task-title">{{ task.title }}</span>
    <!-- 🔥 新增:状态徽章 -->
    <TaskStatusBadge 
      :status="task.status"
      :unlock-status="task.unlockStatus"
      :queue-position="task.queuePosition"
    />
  </div>
  
  <!-- 排队进度条(可选,仅排队中状态显示) -->
  <div v-if="task.unlockStatus === 'queuing'" class="queue-progress">
    <div class="progress-info">
      <span>排队进度</span>
      <span>预计 {{ task.estimatedUnlockDate }} 解锁</span>
    </div>
    <a-progress 
      :percent="task.queueProgress" 
      :stroke-color="'#fa8c16'"
      :show-info="false"
      size="small"
    />
  </div>
  
  <!-- 其他卡片内容... -->
</div>

6.3 抢单按钮状态设计(重要)

6.3.1 设计师可接任务页面

方案选择:设计师可以看到排队中的任务,但不能抢单

任务状态 按钮显示 按钮状态 点击效果
🟢 抢单中(unlocked) 「抢单」 ✅ 可点击(绿色) 正常抢单
🟠 排队中(queuing) 「排队中」 ❌ 禁用(灰色) 无响应 / 提示
🔵 待接单(assigned) 「接单」 ✅ 可点击 指定任务接单

6.3.2 排队中任务的按钮样式

<!-- TaskCard.vue - 抢单按钮 -->
<template>
  <div class="task-card-actions">
    <!-- 排队中:禁用按钮 -->
    <a-button 
      v-if="task.unlockStatus === 'queuing'"
      disabled
      class="grab-btn-disabled"
    >
      <template #icon>
        <ClockCircleOutlined />
      </template>
      排队中 (第{{ task.queuePosition }}位)
    </a-button>
    
    <!-- 抢单中:正常按钮 -->
    <a-button 
      v-else-if="task.status === 'pending'"
      type="primary"
      class="grab-btn"
      @click="handleGrab"
    >
      <template #icon>
        <ThunderboltOutlined />
      </template>
      抢单
    </a-button>
  </div>
</template>

<style scoped>
/* 排队中按钮样式 */
.grab-btn-disabled {
  background: #f5f5f5 !important;
  border-color: #d9d9d9 !important;
  color: #bfbfbf !important;
  cursor: not-allowed;
}

/* 抢单中按钮样式 */
.grab-btn {
  background: linear-gradient(135deg, #52c41a 0%, #73d13d 100%);
  border: none;
  font-weight: 600;
}
</style>

6.3.3 点击排队中按钮的提示

// 如果用户强行点击(通过其他方式绕过disabled),显示提示
function handleGrabClick(task: TaskItem) {
  if (task.unlockStatus === 'queuing') {
    message.info(`该任务正在排队中,预计 ${task.estimatedUnlockDate} 开放抢单`)
    return
  }
  // 正常抢单逻辑
  grabTask(task.id)
}

6.3.4 可接任务列表显示策略

// 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 排队进度计算逻辑

// 计算排队进度百分比
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 需求部门「我发布的任务」页面

<!-- MyPublishedTasks.vue -->
<template>
  <div class="my-tasks-page">
    <!-- 状态筛选 tabs -->
    <a-tabs v-model:activeKey="activeStatus">
      <a-tab-pane key="all" tab="全部" />
      <a-tab-pane key="queuing" tab="排队中">
        <template #tab>
          排队中 <a-badge :count="queuingCount" :number-style="{ backgroundColor: '#fa8c16' }" />
        </template>
      </a-tab-pane>
      <a-tab-pane key="grabbing" tab="抢单中">
        <template #tab>
          抢单中 <a-badge :count="grabbingCount" :number-style="{ backgroundColor: '#52c41a' }" />
        </template>
      </a-tab-pane>
      <a-tab-pane key="in_progress" tab="进行中" />
      <a-tab-pane key="completed" tab="已完成" />
    </a-tabs>
    
    <!-- 任务列表 -->
    <div class="task-list">
      <TaskCard 
        v-for="task in filteredTasks" 
        :key="task.id"
        :task="task"
      />
    </div>
  </div>
</template>

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 通知服务实现

// TaskNotificationService.java
@Service
@Slf4j
public class TaskNotificationService {
    
    @Autowired
    private NotificationService notificationService;
    
    @Autowired
    private DingtalkService dingtalkService;
    
    /**
     * 通知需求人:任务已解锁(排队中 → 抢单中)
     */
    public void notifyTaskUnlocked(TaskItem item, Task mainTask) {
        // 获取所有需求人(发布者 + 详情填写人)
        Set<Long> 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<Long> 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<Long> 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<Long> getDemandUserIds(Task task) {
        Set<Long> 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 每日解锁任务

@Scheduled(cron = "0 0 1 * * ?") // 每天凌晨1点
public void dailyUnlockTask() {
    log.info("开始执行每日任务解锁...");
    
    // 获取所有接单部门
    List<Department> 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 实时解锁(任务被接单后)

// 当任务被接单后,自动解锁下一批任务
@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 兼容策略:两层过滤

可接任务最终查询逻辑:

-- 第一层:流水线状态过滤(现有逻辑,保持不变)
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)需要特殊处理:

策略:流水线解锁时自动进入排队池

// 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 判断规则

// 判断是否为指定任务
boolean isAssignedTask = "assigned".equals(mainTask.getReleaseType());

// 或者判断子任务是否已预先指定设计师
boolean isPreAssignedItem = item.getReceiverId() != null;

9.7.2 排队解锁过滤逻辑调整

-- 可接任务查询:指定任务直接可见,招募任务才走排队过滤
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 代码实现

// TaskUnlockService.java

/**
 * 任务审核通过后,根据发布类型决定是否进入排队池
 */
public void onTaskApproved(Task task, List<TaskItem> 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 前端状态显示

// 计算任务显示状态
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 后端接口对比

现有接口签名

// TaskItemController.java
@GetMapping("/available")
public Result<IPage<TaskItem>> 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(最早优先) ⚠️ 需调整设计

调整建议

// 保持现有默认行为,只有明确指定 sortBy=oldest 时才按最早排序
@RequestParam(required = false, defaultValue = "latest") String sortBy

12.2 现有 SQL 查询分析

现有 Mapper 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  -- 🔥 现有:最新优先

设计方案变更

-- 新增排队解锁过滤(需要新增)
<if test="unlockMode == true">
  AND ti.id IN (
    SELECT task_item_id FROM task_unlock_status 
    WHERE dept_id = #{userDeptId} AND is_unlocked = 1
  )
</if>

-- 排序改为动态
ORDER BY ti.create_time 
<choose>
  <when test="sortBy == 'oldest'">ASC</when>
  <otherwise>DESC</otherwise>
</choose>

12.3 前端排序选项对比

现有排序选项(TaskHall.vue)

const availableSortOptions = [
  { value: 'latest', label: '最新发布' },  // ✅ 保留
  { value: 'score', label: '积分最高' },    // ✅ 保留
  { value: 'deadline', label: '时效最短' }, // ✅ 保留
  { value: 'hot', label: '最热门' }         // ✅ 保留
]

设计方案变更

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 非数据库字段

// 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

需要新增的字段

// 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 实体映射(需确认)

@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 初始化数据迁移脚本

-- 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个)
  • 避免任务积压,提高任务周转效率
  • 公平分配任务,按时间顺序处理
  • 流水线任务不受排队限制,保证流畅执行
  • 上线过程平滑,不影响现有业务