leancloud/ticket

触发器设计

Closed this issue · 0 comments

sdjdd commented

触发器

触发器由条件和操作组成,在工单被创建、更新时触发,只有条件被满足时才会执行定义的操作。处于关闭状态的工单不会触发触发器,但工单被关闭时可触发触发器。

条件

条件分为 ALL 和 ANY ,定义在 ALL 中的条件必须全部被满足,定义在 ANY 中的条件只需满足一条即可。

Example:

{
  "all": [
    {"field": "updateType", "operator": "is", "value": "create"},
    {"field": "status", "operator": "is", "value": 50}
  ],
  "any": []
}

操作

[
  {"field": "status", "value": 280},
  {"field": "assigneeId", "value": "605303b48629e13ca9449f02"}
]

触发器执行逻辑

每当工单被创建、更新时,所有触发器会按顺序执行。如果一个触发器的条件被满足,将执行其操作并重新执行整个流程(跳过已执行的触发器),直到一次流程中没有触发器被触发。

触发器的操作会触发其他触发器,一个触发器在整个流程中只会被触发一次。

image

实现方案

将当前 Ticket 表 afterUpdate Hook 中的逻辑拆出来,以便在某一云引擎实例内手动执行
在 Ticket 表的 afterSave / afterUpdate hook 中:

  1. 获取 Trigger 表中的所有数据,并解析
  2. 按顺序执行所有触发器
  3. 手动执行 Ticket 表的 afterUpdate hook 中的逻辑,跳过触发器
/**
 * @param {AV.Object} ticket
 * @param {'create' | 'update'} updateType
 */
async function invokeTriggers(ticket, updateType) {
  const status = ticket.get('status')
  if (ticketStatus.isClosed(status) && !ticket.updatedKeys?.includes('status')) {
    return
  }

  const ctx = new TriggerContext(ticket, updateType)
  const triggers = await getActiveTriggers()
  const triggerLogs = []

  let finish = false
  while (!finish) {
    let count = 0
    for (const trigger of triggers) {
      if (trigger.triggered) {
        continue
      }
      if (trigger.test(ctx)) {
        trigger.exec(ctx)
        trigger.triggered = true
        count++
        triggerLogs.push(
          new AV.Object('TriggerLog', {
            ticket: AV.Object.createWithoutData('Ticket', ticket.id),
            trigger: AV.Object.createWithoutData('Trigger', trigger.object.id),
            conditions: trigger.object.get('conditions'),
            actions: trigger.object.get('actions'),
          })
        )
      }
    }
    finish = count == 0
  }

  if (triggerLogs.length) {
    AV.Object.saveAll(triggerLogs).catch(errorHandler.captureException)
  }

  if (!_.isEmpty(ctx.getDirtyAttrs())) {
    const ticketToUpdate = AV.Object.createWithoutData('Ticket', ticket.id)
    ticketToUpdate.disableAfterHook()
    await ticketToUpdate.save(ctx.getDirtyAttrs(), { useMasterKey: true })

    if (!ticket.updatedKeys) {
      ticket.updatedKeys = []
    }
    Object.entries(ctx.getDirtyAttrs()).forEach(([key, value]) => {
      ticket.set(key, value)
      ticket.updatedKeys.push(key)
    })
    ticket.updatedKeys = _.uniq(ticket.updatedKeys)

    afterUpdateTicketHandler(ticket, { skipTriggers: true })
  }
}

/**
 * @param {AV.Object} ticket
 * @param {object} [options]
 * @param {AV.User} [options.user]
 * @param {boolean} [options.skipTriggers]
 */
async function afterUpdateTicketHandler(ticket, options) {
  await ticket.fetch({ include: ['assignee'] }, { useMasterKey: true })
  const user = options?.user || systemUser
  const userInfo = await getTinyUserInfo(user)

  if (ticket.updatedKeys?.includes('category')) {
    addOpsLog(ticket, 'changeCategory', {
      category: ticket.get('category'),
      operator: userInfo,
    })
  }

  if (ticket.updatedKeys?.includes('assignee')) {
    const assigneeInfo = await getTinyUserInfo(ticket.get('assignee'))
    addOpsLog(ticket, 'changeAssignee', {
      assignee: assigneeInfo,
      operator: userInfo,
    })
    notification.changeAssignee(ticket, user, ticket.get('assignee'))
  }

  if (ticket.updatedKeys?.includes('status')) {
    if (ticketStatus.isClosed(ticket.get('status'))) {
      AV.Cloud.run('statsTicket', { ticketId: ticket.id })
    }
  }

  if (ticket.updatedKeys?.includes('evaluation') && options?.user) {
    notification.ticketEvaluation(ticket, options.user, ticket.get('assignee'))
  }

  invokeWebhooks('ticket.update', {
    ticket: ticket.toJSON(),
    updatedKeys: ticket.updatedKeys,
  })

  if (!options?.skipTriggers) {
    invokeTriggers(ticket, 'update', afterUpdateTicketHandler)
  }
}