- 新增任务排队解锁功能,确保先发布的任务优先被处理 - 添加系统设置中的任务排队配置(启用开关、每日解锁数、补充解锁数) - 新增 TaskUnlockStatus 实体和 Mapper - 新增 TaskUnlockService 处理排队解锁逻辑 - 新增 TaskStatusNotificationService 发送钉钉通知 - 更新 TaskItemService 和 TaskItemController 支持排队状态 - 前端新增 TaskStatusBadge 组件显示排队/抢单状态 - 更新 TaskCard 和 TaskHall 支持排队状态展示 - 添加数据库迁移脚本 V15
45 KiB
45 KiB
任务排队解锁机制开发文档
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. 实施计划
阶段一:数据库准备
- 创建 task_unlock_status 表
- 添加系统配置项
- 初始化历史数据(将现有可接任务标记为已解锁)
阶段二:后端开发
- 实现 TaskUnlockService 服务
- 修改可接任务查询逻辑
- 实现定时任务
- 添加管理接口
阶段三:前端开发
- 修改默认排序为「最早优先」
- 添加解锁状态显示(可选)
- 添加排序切换功能
阶段四:测试上线
- 功能测试
- 性能测试
- 灰度发布
- 全量上线
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:流水线解锁的子任务立即可见,原因:
- 流水线任务有明确的依赖关系,前序已完成意味着后序应及时处理
- 避免因排队机制导致整个流水线任务延误
- 对需求方体验更好
9.6 代码修改点总结
| 文件 | 修改内容 | 影响范围 |
|---|---|---|
TaskItemService.getAvailableItems() |
新增排队解锁过滤 | 可接任务列表查询 |
TaskItemService.completeItem() |
流水线解锁时自动标记已解锁 | 子任务完结流程 |
TaskApprovalService.approveTask() |
任务审核通过后加入排队池 | 任务审核流程 |
新增 TaskUnlockService |
排队解锁核心服务 | 新增服务 |
9.7 指定任务的特殊处理(重要)
指定任务(release_type = 'assigned')不受排队解锁机制限制,理由:
- 已明确指定接单设计师,无需排队等待
- 通常有紧急性或特殊要求
- 被指定的设计师应立即看到任务
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. 注意事项
- 向后兼容:初次上线时需将所有现有可接任务标记为已解锁
- 性能优化:解锁状态查询需要添加合适的索引
- 异常处理:定时任务执行失败时需要告警通知
- 配置灵活:每日解锁数量可按部门单独配置
- 数据一致性:任务状态变更时需同步更新解锁状态
- 流水线优先:流水线解锁的子任务立即可见,不受排队限制
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个)
- 避免任务积压,提高任务周转效率
- 公平分配任务,按时间顺序处理
- 流水线任务不受排队限制,保证流畅执行
- 上线过程平滑,不影响现有业务