DevOps平台 — 第十一篇:工作项的设计与实现

工作项是研发管理的最小执行单元------一个需求、一个任务、一个缺陷,都是工作项。但工作项不只是 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)

用户在详情页点击流转按钮时,前端将按钮的 signalCodesignalParams 提交到后端。后端在钉死版本图中,从当前状态出发,查找匹配该信号且参数一致的出边,确定唯一目标状态后写回。

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 = ?  │
  └──────────────────────────────────┘

变更类型后,工作项的 smMachineIdsmVerNostatus 全部替换为目标类型状态机的快照,后续流转走新状态机。

📷 截图位置:工作项详情页 --- 状态流转按钮

四、创建与拆分

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,通过 editablelayout 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 分为两类:

  • 实体固定列 :如 titlepriorityassignee 等,直接映射到 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,而在于:

  1. 统一建模:需求、任务、缺陷共表,通过类型 + 分类 + 扩展字段承载差异
  2. 编号安全:ReentrantLock 串行化 + 应用层二次校验,并发不撞号
  3. 状态钉死:创建时冻结状态机版本,后续流转不受管理员修改影响
  4. 九视图插件:表格/列表/看板/日历通过注册表管理,切换零成本
  5. 行内编辑:草稿快照 + 乐观更新,用户感知即时响应
  6. 看板拖拽:四维度分桶,类型变更支持自动映射 + 手动选择
  7. 回收站:逻辑删除保留关联,支持批量恢复和彻底删除
  8. 动态表单:实体固定列 + JSONB 扩展字段,配置中心驱动
  9. 关系权限:基于负责人/创建人动态判定,不依赖角色系统
相关推荐
衝鋒壹号1 小时前
鸿蒙 PC 能跑 Docker 吗?一次从安装失败到成功运行的实测记录
后端·harmonyos
用户8181870627461 小时前
第27章 消息丢失/重复消费的全链路排查(生产者→Broker→消费者)
java·后端
程序员cxuan1 小时前
ChatGPT 开启无限 token
人工智能·后端·程序员
Gopher_HBo1 小时前
beego ORM 源码(上):模型元数据与注册
后端
索隆zoro2 小时前
Army 对 jOOQ
java·后端
泡海椒2 小时前
规则热更新实现:JQuick-Java无需重启更新业务规则实战
后端
右耳朵猫AI2 小时前
PHP周刊2026W37 | Symfony 三维护版齐发、Laravel AI SDK 0.11、LSP 服务器上线
后端·php·laravel
geovindu2 小时前
CSharp:Condition Variable Pattern
后端·设计模式·c#·.net·.netcore·条件变量模式·同步型模式
Java内核笔记2 小时前
Spring Boot 4 SSRF 防护源码剖析:InetAddressFilter 挡住内网地址与云元数据
java·后端