工作项是研发管理的最小执行单元------一个需求、一个任务、一个缺陷,都是工作项。但工作项不只是 CRUD:它需要编号、需要绑定状态机、需要支持拆分和回收站、需要在看板上拖拽迁态、需要行内编辑和动态表单。本篇记录工作项的统一实体设计、编号生成、状态机钉死、九视图插件体系、行内编辑、看板拖拽、回收站、权限控制,以及前后端协作的整体实现。
一、为什么是「统一工作项」
1.1 多类型共表的取舍
传统做法是需求一张表、任务一张表、缺陷一张表,各自独立。这在初期看似清晰,但随着类型增多会暴露三个问题:
- 拆分跨类型无门:需求拆出任务时,要往两张表写数据,父子关系维护成本高
- 看板无法统一:不同表的数据要合并到一块看板上展示,每次查询都要 JOIN 或 UNION
- 字段扩展受限:每种类型加一个字段就要改表结构,运维成本高
本平台采用统一工作项表 (biz_work_item),需求、任务、缺陷共用同一张表,通过 work_item_type_id 区分类型,通过 custom_fields(JSONB)承载类型扩展字段。分类(category)只有三种:requirement / task / defect,而类型(type)可以是「用户故事」「技术任务」「功能缺陷」等任意细分,挂在分类之下。
1.2 统一表结构概览
javascript
biz_work_item 核心字段
┌──────────────────────────────────────────────────────────┐
│ item_code 工作项编号(不可变) │
│ project_id 所属项目(不可变) │
│ work_item_type_id 工作项类型(不可变,变更类型时另换) │
│ title / description 标题 / 描述 │
│ status 当前状态码(钉死版本图中的状态) │
│ priority / severity 优先级 / 严重程度 │
│ assignee / reporter 负责人 / 报告人(username) │
│ parent_id 父工作项(根项为 null,仅一级可拆分) │
│ sm_machine_id 创建时钉死的状态机 ID(不可变) │
│ sm_ver_no 创建时钉死的状态机发布版本号(不可变) │
│ custom_fields 类型扩展字段(JSONB) │
│ version 乐观锁版本号 │
└──────────────────────────────────────────────────────────┘

二、工作项编号生成
2.1 编号规则
工作项编号在创建时由服务端生成,格式为:
yaml
{分类前缀}-{短项目编号}-{序号}
分类前缀:requirement → REQ,task → TASK,defect → DEFECT
短项目编号:PRJ-2026-002 → 2026-002(去掉 PRJ- 前缀)
序号:四位零填充,从 0001 开始
示例:REQ-2026-002-0001
每种分类在同一项目下独立递增,即需求、任务、缺陷各有自己的序号序列。
2.2 并发安全:ReentrantLock 串行化
编号生成采用 ReentrantLock 串行化,保证「生成编号 + 写入数据库」两步操作的原子性:
scss
编号生成流程
┌──────────┐ ┌───────────────┐ ┌──────────────┐ ┌────────────┐
│ 创建请求 │───▶│ 获取编号锁 │───▶│ 查 MAX 编号 │───▶| 计算下一号 │
└──────────┘ │ ITEM_CODE_LOCK │ │ (LIKE prefix) │ │ %04d 格式化 │
└───────────────┘ └──────────────┘ └─────┬──────┘
│
┌───────────────┘
▼
┌──────────────┐ ┌───────────┐
│ 应用层唯一校验 │───▶| save 入库 │
│ (撞号则递增) │ └─────┬─────┘
└──────────────┘ │
▼
┌────────────────┐
│ 释放编号锁 │
│ ITEM_CODE_LOCK │
└────────────────┘
核心伪代码如下:
vbnet
function generateItemCode(projectCode, category):
prefix = categoryPrefix(category) + "-" + shortProjectCode(projectCode) + "-"
maxCode = SELECT MAX(item_code) FROM biz_work_item WHERE item_code LIKE prefix%
next = parseSuffix(maxCode, prefix) + 1 // 无记录则 1
loop:
code = prefix + format("%04d", next)
if SELECT COUNT(*) WHERE item_code = code == 0:
return code // 应用层二次校验唯一
next++
编号一旦生成即标记
FieldStrategy.NEVER,后续编辑不可变更。
三、状态机钉死机制
3.1 为什么不直接用当前状态机
状态机会被管理员修改和重新发布。如果一个工作项创建时用 v1 版本的状态机,管理员随后发布了 v2,工作项的状态流转逻辑不应该被影响------否则可能导致工作项「卡」在一个在新版本图中不存在的状态。
钉死机制 :创建工作项时,将当前状态机的 machineId 和已发布的版本号 verNo 冻结写入工作项,后续所有状态流转都基于这个钉死的版本快照执行。
3.2 钉死流程
ini
状态机钉死流程
┌──────────────┐ ┌────────────────┐ ┌──────────────────┐
│ 创建工作项请求 │───▶| 校验类型属于 │───▶| 查工作项类型绑定的│
│ │ │ 项目模板 │ │ 状态机 ID │
└──────────────┘ └────────────────┘ └────────┬─────────┘
│
▼
┌──────────────────────────────────────────────────────────┐
│ pinLatestPublished(machineId) │
│ 1. 查状态机,校验 status = "published" │
│ 2. 查已发布版本快照 snap │
│ 3. 解析版本图,找到 initial 状态 │
│ 4. 返回 { machineId, verNo, initialStateCode } │
└──────────────────────────────────────────────────────────┘
│
▼
┌───────────────────────────────────────────────┐
│ WorkItem 写入: │
│ sm_machine_id = pin.machineId │
│ sm_ver_no = pin.verNo │
│ status = pin.initialStateCode │
└───────────────────────────────────────────────┘
如果工作项类型未绑定状态机,或绑定了但尚未发布,创建将被拒绝。
3.3 状态流转的两种方式
工作项状态流转支持两种路径:
| 方式 | 触发场景 | 后端逻辑 | 校验维度 |
|---|---|---|---|
| 信号触发(fireTransition) | 详情页点击流转按钮 | 查可用变迁 → 校验信号 → 执行状态机 fire → 写回 status | 仅负责人可流转 |
| 直接迁态(moveStatus) | 看板拖拽到目标列 | 校验有向可达 → 直接写 status | 仅负责人可流转 |
两种方式都会通过乐观锁写回 status 字段,并递增 version 版本号。
3.4 可用变迁查询
详情页打开时,前端会请求当前工作项在钉死版本下的可用变迁列表。后端解析版本图快照,找到当前状态节点的所有出边(outgoing edges),每条出边携带一个触发器(signal)和目标状态,组成一个「流转按钮」返回给前端。
ini
可用变迁查询流程
┌─────────────────────────────┐
│ 工作项详情打开 │
│ item.smMachineId + smVerNo │
│ + item.status │
└──────────────┬──────────────┘
│
▼
┌──────────────────────────────────┐
│ listAvailableTransitions( │
│ machineId, verNo, currentState)│
│ │
│ 1. 加载版本快照 snap │
│ 2. 解析为 ParsedGraph │
│ 3. 找到当前状态节点 │
│ 4. 取 outgoing edges │
│ 5. 批量查信号名称 signalNames │
│ 6. 组装 VO 列表返回 │
└──────────────────────────────────┘
│
▼
┌──────────────────────────────────┐
│ 前端渲染为流转按钮 │
│ │
│ [开始开发] [驳回] [完成] │
│ signalCode = btn_click │
│ params.label = "开始开发" │
└──────────────────────────────────┘
核心伪代码如下:
kotlin
function listAvailableTransitions(machineId, verNo, currentStateCode):
graph = loadGraph(machineId, verNo) // 加载版本快照并解析
if graph == null: return []
current = graph.resolveState(currentStateCode)
if current == null: return []
if current.kind == "final": return [] // 终态无出边
edges = graph.outgoing(current) // 取所有出边
.filter(e -> e.signal != null) // 仅保留有触发器的边
signalNames = batchLoadSignalNames(edges.map(e -> e.signal))
return edges.map(edge -> {
toState = graph.resolveState(edge.to)
return {
transitionId: edge.id,
signalCode: edge.signal,
signalName: signalNames[edge.signal],
toStateCode: toState.statusKey,
toStateName: toState.name,
params: edge.params, // 如 { label: "开始开发" }
actionCodes: edge.actionCodes // 一期仅带回不执行
}
})
3.5 信号触发流转(fireTransition)
用户在详情页点击流转按钮时,前端将按钮的 signalCode 和 signalParams 提交到后端。后端在钉死版本图中,从当前状态出发,查找匹配该信号且参数一致的出边,确定唯一目标状态后写回。
scss
信号触发流转流程
┌──────────────┐ ┌──────────────────────┐
│ 用户点击按钮 │───▶│ 前端提交 DTO │
│ │ │ { signalCode, params }│
└──────────────┘ └──────────┬───────────┘
│
▼
┌──────────────────────────────────────────────┐
│ WorkItemServiceImpl.fireTransitionOf(item, dto)│
│ │
│ 1. assertAssigneeCanFire(item) │
│ → 仅负责人可流转 │
│ 2. 校验 smMachineId / smVerNo 存在 │
│ 3. smRuntimeService.fireTransition( │
│ machineId, verNo, │
│ item.status, │
│ dto.signalCode, dto.signalParams) │
└────────────────────────┬───────────────────────┘
│
▼
┌──────────────────────────────────────────────┐
│ SmRuntimeServiceImpl.fireTransition( │
│ machineId, verNo, fromStateCode, │
│ signalCode, signalParams) │
│ │
│ 1. 加载版本图 graph │
│ 2. 找到当前状态节点 current │
│ 3. 校验 current 不是终态 │
│ 4. 过滤出 signal 匹配的出边 candidates │
│ 5. matchTransition(candidates, signalParams) │
│ → 唯一确定一条边 matched │
│ 6. 返回 { fromCode, toCode, toName, │
│ signalCode, actionCodes } │
└────────────────────────┬───────────────────────┘
│
▼
┌──────────────────────────────────────────────┐
│ applyStatusUpdate(item, result.toStateCode) │
│ │
│ UPDATE biz_work_item │
│ SET status = toStateCode, │
│ version = version + 1 │
│ WHERE id = ? AND version = ? │
│ │
│ → 乐观锁:版本不匹配则抛错 │
└──────────────────────────────────────────────┘
当同一信号从当前状态出发有多条出边时(如 btn_click 信号同时通向「完成」和「驳回」),matchTransition 方法通过 signalParams 消歧:
kotlin
function matchTransition(candidates, signalParams):
if candidates.size == 1:
return candidates[0] // 仅一条边,直接命中
// 按参数匹配过滤
filtered = candidates.filter(edge -> paramsMatch(edge.params, signalParams))
if filtered.size == 1:
return filtered[0] // 参数消歧后唯一
if filtered.isEmpty:
throw "触发参数无法匹配到唯一变迁"
throw "存在多条相同触发器的变迁,请通过 signalParams 消歧"
function paramsMatch(edgeParams, requestParams):
// 配置中声明的每个 key-value,请求参数须完全一致
for (key, val) in edgeParams:
if requestParams[key] != val:
return false
return true
3.6 看板拖拽迁态(moveStatus)
看板拖拽不经过信号触发,而是直接校验目标状态在钉死版本图中是否可达。可达性判断使用 BFS 遍历有向边,不要求相邻------只要从当前状态沿出边能走到目标状态即可。
scss
看板拖拽迁态流程
┌──────────────┐ ┌──────────────────────┐
│ 拖拽到目标列 │───▶│ 前端提交 DTO │
│ │ │ { toStateCode } │
└──────────────┘ └──────────┬───────────┘
│
▼
┌──────────────────────────────────────────────┐
│ WorkItemServiceImpl.moveStatusOf(item, dto) │
│ │
│ 1. assertAssigneeCanFire(item) │
│ 2. smRuntimeService.moveToState( │
│ machineId, verNo, │
│ item.status, dto.toStateCode) │
└────────────────────────┬───────────────────────┘
│
▼
┌──────────────────────────────────────────────┐
│ SmRuntimeServiceImpl.moveToState( │
│ machineId, verNo, fromCode, toCode) │
│ │
│ 1. 加载版本图 graph │
│ 2. 校验 from/to 节点存在 │
│ 3. 同态? → 幂等返回,不写库 │
│ 4. from 是终态? → 拒绝 │
│ 5. BFS 可达性校验 │
│ → 不可达抛错 │
│ 6. 返回 { fromCode, toCode, toName } │
└────────────────────────┬───────────────────────┘
│
▼
┌──────────────────────────────────────────────┐
│ applyStatusUpdate(item, result.toStateCode) │
│ → 乐观锁写回 status + version │
└──────────────────────────────────────────────┘
BFS 可达性校验的核心逻辑:
vbnet
function bfsReachable(graph, startState, targetStatusKey):
visited = Set()
queue = [startState]
visited.add(startState.statusKey)
while queue not empty:
cur = queue.poll()
for edge in graph.outgoing(cur):
next = graph.resolveState(edge.to)
if next == null: continue
if next.statusKey == targetStatusKey:
return true // 可达
if next.statusKey not in visited:
visited.add(next.statusKey)
queue.add(next)
return false // 不可达
3.7 变更类型时的状态重钉
变更工作项类型时,目标类型可能绑定了不同的状态机。此时需要重新钉死目标类型的状态机版本,并将工作项落到目标状态机的某个状态上:
ini
变更类型状态重钉流程
┌──────────────────────────────────┐
│ changeTypeOf(item, dto) │
│ dto = { workItemTypeId, │
│ toStateCode } │
└──────────────┬───────────────────┘
│
▼
┌──────────────────────────────────┐
│ 1. 校验子项类型一致 / 无子项 │
│ 2. pinStateMachineAtCreate( │
│ projectId, targetTypeId) │
│ → 重新钉死目标类型状态机 │
│ 3. loadStateIndex(machineId,verNo)│
│ → 加载目标版本图状态索引 │
│ 4. 校验 toStateCode 属于该图 │
│ 5. UPDATE biz_work_item SET │
│ work_item_type_id = target, │
│ sm_machine_id = pin.machine,│
│ sm_ver_no = pin.verNo, │
│ status = toState, │
│ version = version + 1 │
│ WHERE id = ? AND version = ? │
└──────────────────────────────────┘
变更类型后,工作项的 smMachineId、smVerNo、status 全部替换为目标类型状态机的快照,后续流转走新状态机。
📷 截图位置:工作项详情页 --- 状态流转按钮

四、创建与拆分
4.1 创建流程
创建工作项是一个完整的校验链路:
markdown
创建工作项流程
┌──────────────┐
│ 前端提交 DTO │
└──────┬───────┘
│
▼
┌──────────────────────────────────────────────┐
│ 1. 校验标题非空、类型非空 │
│ 2. 校验项目存在 + 当前用户是项目成员 │
│ 3. 校验工作项类型属于项目模板 │
│ 4. 加载类型表单字段,校验必填字段 │
│ 5. 钉死状态机版本(pinStateMachineAtCreate) │
│ 6. 解析 parentId(拆分时校验父项为一级 + 类型一致) │
│ 7. 组装 WorkItem 实体 │
│ 8. 加锁生成编号 + save │
│ 9. 写入标签(全量替换) │
│ 10. 写入协作人(全量替换) │
└──────────────────────────────────────────────┘
│
▼
┌──────────────┐
│ 返回工作项 ID │
└──────────────┘
4.2 拆分与批量拆分
拆分是创建的特例:传入 parentId,系统会校验:
- 父工作项必须是一级工作项(
parentId为 null),不允许二级拆分 - 子工作项的类型必须与父工作项一致
批量拆分允许用户一次提交多行标题,系统在事务内循环创建子工作项。批量拆分模式跳过类型表单中除标题外的必填校验(titleOnlyRequired = true),因为这些字段可以后续补填。

五、视图插件体系
5.1 九视图注册表
工作项列表页支持四种主视图 × 多种展示形式,共九种组合,全部通过插件注册表管理:
| 主视图 | 展示形式 | 插件 ID | 渲染组件 | 特性 |
|---|---|---|---|---|
| 表格 | 平铺 | table-flat |
TableViewPlugin | 行内编辑 |
| 表格 | 树状 | table-tree |
TableViewPlugin | 父子折叠 + 行内编辑 |
| 列表 | 平铺 | list-flat |
TableViewPlugin | 只读 |
| 列表 | 树状 | list-tree |
TableViewPlugin | 父子折叠 + 只读 |
| 看板 | 按状态 | kanban-status |
KanbanViewPlugin | 拖拽迁态 |
| 看板 | 按类型 | kanban-type |
KanbanViewPlugin | 拖拽变更类型 |
| 看板 | 按成员 | kanban-member |
KanbanViewPlugin | 拖拽改负责人 |
| 看板 | 按标签 | kanban-tag |
KanbanViewPlugin | 只读 |
| 日历 | --- | calendar |
CalendarPlugin | 计划时间线 |
表格和列表共用 TableViewPlugin,通过 editable 和 layout props 区分行为;四种看板共用 KanbanViewPlugin,通过 dimension prop 切换分桶逻辑。
5.2 插件解析流程
ini
视图切换流程
┌──────────────────┐
│ WorkItemViewSwitcher│
│ 表格/列表/看板/日历 │
└────────┬─────────┘
│ mode + form
▼
┌──────────────────┐ ┌─────────────────┐
│ resolvePluginId │───▶│ getWorkItemPlugin │
│ (mode, form) → id │ │ (id) → 插件定义 │
└──────────────────┘ └────────┬────────┘
│
▼
┌────────────────┐
│ activePlugin │
│ .component │
└────────┬───────┘
│
▼
┌─────────────────────┐
│ <component │
│ :list="list" │
│ :layout="..." │
│ :editable="..." │
│ :dimension="..." │
│ @detail="..." │
│ /> │
└─────────────────────┘
切换主视图时自动重置展示形式为该视图的第一个选项(如切到看板默认「按状态」),并同步计算插件附加 props。

六、行内编辑
6.1 双击编辑与失焦保存
表格视图支持双击行内编辑,覆盖六个字段:标题、优先级、标签、负责人、协作人、计划时间。
scss
行内编辑生命周期
┌─────────┐ 双击 ┌──────────────────┐ 失焦/面板关闭
│ 只读展示 │─────────▶│ beginEdit() │─────────────────▶ commitOnBlur()
└─────────┘ │ ├─ loadDraft() │ ├─ 无改动? → stopEdit()
│ ├─ 设 editingKey │ ├─ 有改动? → buildPatch()
│ └─ 拍快照 │ │
└──────────────────┘ ├─ 乐观写回行数据
│ applyRowField()
├─ 调更新接口
├─ 成功 → stopEdit()
└─ 失败 → 回滚 snapshot
6.2 草稿快照与乐观更新
进入编辑时拍摄一份草稿快照(draftSnapshot),失焦时对比当前草稿与快照:
- 无改动:直接退出编辑,不发请求
- 有改动 :先乐观写回行数据(UI 立即更新),再发请求
- 成功:退出编辑
- 失败:用快照回滚行数据
这种设计让用户感知「即时响应」,同时保证数据一致性。

七、看板拖拽
7.1 四维度分桶
看板根据维度不同,用不同的策略构建列:
| 维度 | 分桶逻辑 | 拖拽行为 | 权限要求 |
|---|---|---|---|
| 按状态 | 分类 + 状态码拆列 | 迁态(moveStatus) | 仅负责人 |
| 按类型 | 类型 ID 拆列 | 变更类型(changeType) | 负责人或创建人 |
| 按成员 | 负责人拆列 + 未指派列 | 改负责人 | 负责人或创建人 |
| 按标签 | 仅「无标签」单列 | 只读,不可拖 | --- |
7.2 类型变更的自动映射
拖拽变更类型时,目标类型有自己的状态机,需要选择落到哪个状态。系统优先尝试同名属性匹配:在目标类型的状态列表中,查找与当前状态名(name)、状态类别(kind)、终态类型(finalType)完全一致的状态。如果匹配成功,直接落到该状态;如果无法匹配,弹出选择弹窗让用户手动选择目标状态。
scss
类型变更流程
┌─────────────┐
│ 拖入目标类型列 │
└──────┬──────┘
│
▼
┌──────────────────────┐
│ 加载目标类型状态列表 │
│ (listChangeTypeStates)│
└──────────┬───────────┘
│
▼
┌──────────────────┐ ┌────────────────────┐
│ 同名属性匹配? │─── 否 ──▶│ 弹出选择弹窗 │
│ matchSameNameAttr│ │ WorkItemChangeType │
│ State() │ │ Dialog │
└────────┬─────────┘ └─────────┬──────────┘
│ 是 │
▼ ▼
┌──────────────────┐ ┌────────────────────┐
│ 直接落盘 │◀────────│ 用户选择目标状态 │
│ changeMineWork │ │ 返回 toStateCode │
│ ItemType() │ └────────────────────┘
└──────────────────┘
7.3 拖拽约束
- 状态看板:禁止跨分类拖拽(需求不能拖到任务的状态列)
- 所有看板 :不可拖拽到当前所在列(
fromKey === toKey时忽略) - 权限校验:状态迁态仅负责人可操作;类型变更和改负责人需负责人或创建人


八、回收站
8.1 逻辑删除与恢复
工作项删除采用逻辑删除(deleted = true),进入回收站。回收站保留工作项的全部关联数据(标签、协作人),确保恢复时完整还原。
ini
回收站生命周期
┌──────────────┐ 删除 ┌──────────────────┐
│ 正常工作项 │───────────▶│ 逻辑删除(回收站) │
│ deleted=false │ │ deleted=true │
└──────────────┘ └────────┬─────────┘
│
┌─────────┴─────────┐
│ │
恢复 彻底删除
│ │
▼ ▼
┌────────────────┐ ┌──────────────────┐
│ deleted = false │ │ 清理关联 │
│ 回到正常列表 │ │ 物理删除记录 │
└────────────────┘ │ purgeByIds() │
└──────────────────┘
8.2 回收站操作
回收站支持单条和批量操作:
- 恢复 :将
deleted改回false,工作项回到正常列表 - 彻底删除:先清理标签和协作人关联,再物理删除记录,不可恢复
回收站列表使用独立的 Mapper XML 查询(selectRecyclePage),确保只查到 deleted = true 的记录。

九、动态表单
9.1 字段来源与渲染
工作项创建/编辑表单的字段由配置中心驱动(详见第九篇)。前端通过 listProjectWorkItemTypeForm 接口获取类型的字段定义列表,然后根据 fieldCode 分为两类:
- 实体固定列 :如
title、priority、assignee等,直接映射到WorkItem实体字段,在表单布局中固定渲染 - 扩展字段 :不在此集合中的字段,通过
WorkItemDynamicFields组件动态渲染,值存入custom_fields(JSONB)
9.2 字段拆分逻辑
提交时,前端将动态字段值 Map 拆分为实体 DTO 字段和 customFields JSON:
ini
字段拆分逻辑(splitFieldValues)
输入:values = { title: "xx", story_point: 5, custom_field_a: "abc" }
字段定义:formFields = [{ fieldCode: "title", fixedColumn: true },
{ fieldCode: "story_point", fixedColumn: true },
{ fieldCode: "custom_field_a" }]
┌─────────────────────────────┐
│ 遍历 values 的每个 entry │
│ ├─ isEntityField? │
│ │ ├─ 是 → entity[key] │
│ │ └─ 否 → custom[code] │
└─────────────────────────────┘
│
▼
输出:{ title: "xx", storyPoint: 5, customFields: '{"custom_field_a":"abc"}' }
这套机制让不同项目模板、不同工作项类型的表单可以完全不同,而前端和后端都不需要写额外的适配代码。

十、权限控制矩阵
工作项操作权限不依赖 RBAC 角色系统,而是基于工作项关系动态判定:
| 操作 | 权限要求 | 校验方法 |
|---|---|---|
| 编辑 | 负责人或创建人 | assertCanEditOrDelete |
| 删除 | 负责人或创建人 | assertCanEditOrDelete |
| 状态流转 | 仅负责人 | assertAssigneeCanFire |
| 变更类型 | 负责人或创建人 | assertCanEditOrDelete |
| 看板改负责人 | 负责人或创建人 | canManageWorkItem |
| 创建 | 项目成员 | assertProjectMember |
核心判定逻辑:
java
function canManageWorkItem(row, username):
isAssignee = row.assignee != null && username == row.assignee
isReporter = row.reporter != null && username == row.reporter
return isAssignee || isReporter
function assertAssigneeCanFire(item):
if item.assignee == null: throw "请先指定负责人"
if username != item.assignee: throw "仅负责人可执行状态流转"
这意味着:一个工作项的负责人换人后,原负责人立即失去流转权限,新负责人获得权限。权限始终跟随工作项的当前状态,而非创建时的快照。
十一、Service 辅助类拆分
WorkItemServiceImpl 是工作项的核心服务,但并非所有逻辑都堆在一个类中。为控制代码复杂度,将辅助逻辑拆分到独立的 Helper 类:
Service 辅助类
┌─────────────────────────────────────────────────────────────┐
│ WorkItemServiceImpl(核心编排) │
│ ├─ WorkItemCodeGenerator 编号生成 │
│ ├─ WorkItemKanbanMetaHelper 看板元数据构建 │
│ ├─ WorkItemRecycleHelper 回收站校验 │
│ ├─ WorkItemRelationHelper 标签/协作人关联 │
│ ├─ WorkItemOverviewHelper 工作台概览统计 │
│ ├─ WorkItemCategoryHelper 分类归一化 │
│ └─ ISmRuntimeService 状态机运行时(钉死/流转/迁态) │
└─────────────────────────────────────────────────────────────┘
这些 Helper 类不持有事务,由 Service 在事务内调用。拆分后,Service 只负责编排,每个 Helper 可独立测试。
十二、总结
工作项是本平台最复杂的功能模块之一,它的复杂度不在于 CRUD,而在于:
- 统一建模:需求、任务、缺陷共表,通过类型 + 分类 + 扩展字段承载差异
- 编号安全:ReentrantLock 串行化 + 应用层二次校验,并发不撞号
- 状态钉死:创建时冻结状态机版本,后续流转不受管理员修改影响
- 九视图插件:表格/列表/看板/日历通过注册表管理,切换零成本
- 行内编辑:草稿快照 + 乐观更新,用户感知即时响应
- 看板拖拽:四维度分桶,类型变更支持自动映射 + 手动选择
- 回收站:逻辑删除保留关联,支持批量恢复和彻底删除
- 动态表单:实体固定列 + JSONB 扩展字段,配置中心驱动
- 关系权限:基于负责人/创建人动态判定,不依赖角色系统