新增回饋按鈕很容易,打造回饋系統卻困難許多。

差異不在於輸入表單,而是在於使用者按下送出之後發生什麼事:

  • API 回應前,報告是否已先儲存?
  • 報告是否包含足夠的調查脈絡?
  • 誰負責下一步行動?
  • 團隊能否透過原始管道回覆?
  • 通知失敗時該怎麼處理?
  • 舊報告如何在被遺忘前再次浮現?

本教學將以 TypeScript、Express 與 PostgreSQL 建立一個小型但可運作的回饋工作流程。相同的設計也適用於錯誤回報、功能請求、支援問題以及產品內對話。

先建立工作流程,而非小工具

實用的回饋生命週期可模型化為:

captured -> new -> acknowledged -> planned/resolved/closed
                    |
                    +-> needs_more_information

Enter fullscreen mode Exit fullscreen mode

每個進行中的項目都應具備三個屬性:

  1. 一位負責人,負責下一步決策。
  2. 下一個行動時間戳記,讓閒置狀態可被查詢。
  3. 回覆路徑,當回報者期待回應時使用。

若缺少這些屬性,統一收件匣只會變成統一的被忽視之處。

我們將建立的系統包含四個部分:

Browser or app
      |
      v
Feedback API -----> PostgreSQL
                         |
                         v
                   Transactional outbox
                         |
                         v
                 Notification worker
                         |
                         v
               Chat, email, or issue tracker

Enter fullscreen mode Exit fullscreen mode

PostgreSQL 是單一事實來源。聊天工具與電子郵件只是傳遞介面,而非資料庫。

1. 儲存回饋、脈絡與傳遞狀態

啟用 UUID 產生器並建立三個資料表:

CREATE EXTENSION IF NOT EXISTS pgcrypto;

CREATE TABLE feedback (
  id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
  idempotency_key uuid NOT NULL UNIQUE,
  source text NOT NULL CHECK (
    source IN ('web', 'mobile', 'chat', 'email', 'api')
  ),
  message text NOT NULL CHECK (char_length(message) BETWEEN 1 AND 5000),
  context jsonb NOT NULL DEFAULT '{}'::jsonb,

  reporter_id text,
  reply_channel text,
  reply_ref text,

  status text NOT NULL DEFAULT 'new' CHECK (
    status IN (
      'new',
      'acknowledged',
      'needs_more_information',
      'planned',
      'resolved',
      'closed'
    )
  ),
  owner_id text,
  next_action_at timestamptz,

  created_at timestamptz NOT NULL DEFAULT now(),
  updated_at timestamptz NOT NULL DEFAULT now()
);

CREATE TABLE feedback_event (
  id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
  feedback_id uuid NOT NULL REFERENCES feedback(id) ON DELETE CASCADE,
  event_type text NOT NULL,
  actor_id text,
  data jsonb NOT NULL DEFAULT '{}'::jsonb,
  created_at timestamptz NOT NULL DEFAULT now()
);

CREATE TABLE feedback_outbox (
  id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
  feedback_id uuid NOT NULL REFERENCES feedback(id) ON DELETE CASCADE,
  kind text NOT NULL,
  payload jsonb NOT NULL,
  attempts integer NOT NULL DEFAULT 0,
  available_at timestamptz NOT NULL DEFAULT now(),
  claimed_at timestamptz,
  delivered_at timestamptz,
  created_at timestamptz NOT NULL DEFAULT now(),
  UNIQUE (feedback_id, kind)
);

CREATE INDEX feedback_open_next_action_idx
  ON feedback (next_action_at)
  WHERE status NOT IN ('resolved', 'closed');

CREATE INDEX feedback_outbox_pending_idx
  ON feedback_outbox (available_at)
  WHERE delivered_at IS NULL;

Enter fullscreen mode Exit fullscreen mode

context 應包含少量允許的診斷欄位,例如:

  • 應用程式版本
  • 目前路由(不含查詢參數)
  • 功能名稱
  • 瀏覽器家族
  • 請求或追蹤 ID
  • 帳戶方案或角色(若確實有用且經允許)

請勿將脈絡回饋變成意外監控。避免存取權杖、表單內容、完整 URL、任意本機儲存或敏感使用者屬性。

reply_ref 應為不透明的管道識別碼,而非複製的對話。若它授予存取權或包含個人資料,請加以加密。

2. 捕捉最有用的脈絡

收集技術脈絡的最佳時機是在使用者提交報告時。稍後再詢問使用者使用的版本或畫面,會製造不必要的工作。

以下是具重試安全性的最小瀏覽器用戶端範例:

const release = document
  .querySelector<HTMLMetaElement>('meta[name="app-release"]')
  ?.content ?? 'unknown';

export async function submitFeedback(message: string): Promise<string> {
  // Generated once and reused across retries of this submission.
  const idempotencyKey = crypto.randomUUID();

  const body = {
    idempotencyKey,
    source: 'web',
    message,
    context: {
      pagePath: location.pathname,
      release,
      feature: document.body.dataset.feature ?? 'unknown'
    }
  };

  for (let attempt = 0; attempt < 3; attempt++) {
    try {
      const response = await fetch('/api/feedback', {
        method: 'POST',
        headers: { 'content-type': 'application/json' },
        body: JSON.stringify(body)
      });

      if (response.ok) {
        const result = await response.json();
        return result.id;
      }

      if (response.status < 500) {
        throw new Error(`Feedback rejected with ${response.status}`);
      }
    } catch (error) {
      if (attempt === 2) throw error;
    }

    await new Promise(resolve => setTimeout(resolve, 250 * 2 ** attempt));
  }

  throw new Error('Feedback could not be submitted');
}

Enter fullscreen mode Exit fullscreen mode

冪等金鑰可避免網路逾時產生多份相同報告。若為真正新的提交,請產生新的金鑰。

3. 以原子方式持久化報告與通知

常見的失敗情境如下:

await database.insert(feedback);
await sendChatNotification(feedback); // Process crashes here.

Enter fullscreen mode Exit fullscreen mode

回饋已存在,但沒有人收到通知。反轉順序同樣不安全,因為通知可能成功而資料庫插入卻失敗。

請改用交易式 outbox。報告與通知請求會一同提交。

import express from 'express';
import { z } from 'zod';
import { pool } from './db.js';

const app = express();
app.use(express.json({ limit: '16kb' }));

const feedbackInput = z.object({
  idempotencyKey: z.string().uuid(),
  source: z.enum(['web', 'mobile', 'chat', 'email', 'api']),
  message: z.string().trim().min(1).max(5000),
  context: z.object({
    pagePath: z.string().max(500).optional(),
    release: z.string().max(100).optional(),
    feature: z.string().max(100).optional(),
    traceId: z.string().max(200).optional()
  }).strict().default({})
});

function removeQueryAndFragment(path = ''): string {
  return path.split(/[?#]/, 1)[0].slice(0, 500);
}

app.post('/api/feedback', async (req, res) => {
  const parsed = feedbackInput.safeParse(req.body);

  if (!parsed.success) {
    return res.status(400).json({ error: 'Invalid feedback payload' });
  }

  const input = parsed.data;
  const context = {
    ...input.context,
    pagePath: removeQueryAndFragment(input.context.pagePath)
  };

  const client = await pool.connect();

  try {
    await client.query('BEGIN');

    const inserted = await client.query<{ id: string }>(
      `INSERT INTO feedback (
         idempotency_key, source, message, context, reporter_id
       )
       VALUES ($1, $2, $3, $4::jsonb, $5)
       ON CONFLICT (idempotency_key) DO NOTHING
       RETURNING id`,
      [
        input.idempotencyKey,
        input.source,
        input.message,
        JSON.stringify(context),
        // Derive identity from trusted authentication middleware.
        req.user?.id ?? null
      ]
    );

    let feedbackId: string;

    if (inserted.rowCount === 1) {
      feedbackId = inserted.rows[0].id;

      await client.query(
        `INSERT INTO feedback_outbox (feedback_id, kind, payload)
         VALUES ($1, 'feedback.created', $2::jsonb)`,
        [feedbackId, JSON.stringify({ source: input.source })]
      );
    } else {
      const existing = await client.query<{ id: string }>(
        `SELECT id FROM feedback WHERE idempotency_key = $1`,
        [input.idempotencyKey]
      );
      feedbackId = existing.rows[0].id;
    }

    await client.query('COMMIT');
    return res.status(202).json({ id: feedbackId });
  } catch (error) {
    await client.query('ROLLBACK');
    console.error(error);
    return res.status(500).json({ error: 'Feedback could not be stored' });
  } finally {
    client.release();
  }
});

Enter fullscreen mode Exit fullscreen mode

在正式環境中,請加入速率限制、適當的機器人防護,以及最大請求本文大小。不要信任用戶端提供的 user ID,請從已驗證的工作階段中取得。

4. 使用可回收的工作者傳遞通知

多個工作者可安全地透過 FOR UPDATE SKIP LOCKED 取得工作:

async function claimJob() {
  const result = await pool.query(
    `WITH candidate AS (
       SELECT id
       FROM feedback_outbox
       WHERE delivered_at IS NULL
         AND available_at <= now()
         AND (
           claimed_at IS NULL OR
           claimed_at < now() - interval '5 minutes'
         )
       ORDER BY created_at
       FOR UPDATE SKIP LOCKED
       LIMIT 1
     )
     UPDATE feedback_outbox AS job
     SET claimed_at = now(), attempts = attempts + 1
     FROM candidate
     WHERE job.id = candidate.id
     RETURNING job.*`
  );

  return result.rows[0] ?? null;
}

async function runWorker() {
  while (true) {
    const job = await claimJob();

    if (!job) {
      await new Promise(resolve => setTimeout(resolve, 1000));
      continue;
    }

    try {
      await notificationAdapter.send({
        eventId: job.id,
        type: job.kind,
        feedbackId: job.feedback_id,
        payload: job.payload
      });

      await pool.query(
        `UPDATE feedback_outbox
         SET delivered_at = now()
         WHERE id = $1`,
        [job.id]
      );
    } catch (error) {
      const delaySeconds = Math.min(300, 2 ** job.attempts);

      await pool.query(
        `UPDATE feedback_outbox
         SET claimed_at = NULL,
             available_at = now() + ($2 * interval '1 second')
         WHERE id = $1`,
        [job.id, delaySeconds]
      );
    }
  }
}

Enter fullscreen mode Exit fullscreen mode

這是至少一次傳遞。若工作者在傳送後、更新 delivered_at 前當機,通知可能重複。請在傳出請求中包含 eventId,並在目的地端使用冪等機制。若無法做到,請確保重複通知不會造成困擾。

經過合理次數的失敗後,請將工作移至死信狀態並發出警示。無限靜默重試只是另一種被遺棄的收件匣。

5. 將負責人納入資料模型

一則「有新回饋」的通知並不等於指派。

請建立一個分類操作,同時記錄狀態變更與其歷史:

BEGIN;

UPDATE feedback
SET status = 'acknowledged',
    owner_id = $2,
    next_action_at = $3,
    updated_at = now()
WHERE id = $1
  AND status = 'new';

INSERT INTO feedback_event (
  feedback_id,
  event_type,
  actor_id,
  data
)
VALUES (
  $1,
  'feedback.assigned',
  $2,
  jsonb_build_object('nextActionAt', $3)
);

COMMIT;

Enter fullscreen mode Exit fullscreen mode

請依團隊情況選擇適當政策,例如:

  • 每日輪值分類負責人。
  • 依產品領域指派。
  • 將安全性報告送至受限制的佇列。
  • 將支援問題與產品決策分開。
  • 要求每個未結案項目皆有下一步行動。

功能請求不一定要成為 roadmap 工作。「已審閱並結案」是明確的合法結果。

6. 查詢閒置狀態,而非依賴記憶

當疏忽可被量測時,回饋管道才能保持健康。先從幾個運作查詢開始,而非大型分析儀表板。

-- New reports without an owner
SELECT id, source, message, created_at
FROM feedback
WHERE status = 'new'
  AND owner_id IS NULL
ORDER BY created_at;

-- Active reports whose next action is overdue
SELECT id, owner_id, status, next_action_at
FROM feedback
WHERE status NOT IN ('resolved', 'closed')
  AND next_action_at < now()
ORDER BY next_action_at;

-- Oldest pending notification
SELECT min(created_at) AS oldest_pending_delivery
FROM feedback_outbox
WHERE delivered_at IS NULL;

Enter fullscreen mode Exit fullscreen mode

有用的健康指標包括:

  • 最舊未指派報告的年齡
  • 逾期下一步行動的數量
  • 通知傳遞失敗次數
  • 從捕捉到確認的時間
  • 等待使用者提供資訊的報告

請依據您實際能維持的服務承諾設定門檻。若無人預期即時監控,請勿將管道標示為「即時」。

7. 保留回覆路徑

回饋往往是對話,而非單向事件。請透過轉接器表示外傳回覆:

interface ReplyTarget {
  channel: 'web-chat' | 'email' | 'mobile' | 'none';
  reference?: string;
}

interface ReplyAdapter {
  send(target: ReplyTarget, message: string): Promise<void>;
}

Enter fullscreen mode Exit fullscreen mode

當回覆成功時,附加 feedback.reply_sent 事件。若失敗,請透過 outbox 排隊,就像原始通知一樣。

對於沒有回覆路徑的匿名表單,請在 UI 中說明。對於聊天或已驗證的應用程式,請保留繼續對話所需的管道參考。除非確實需要回覆,否則請避免索取電子郵件地址。

常見失敗模式

將聊天視為持久儲存

聊天記錄可能被刪除、存取權可能變更、訊息可能被捲動消失。請在通知聊天前,先儲存正規報告。

無限制地收集脈絡

不受限制的用戶端酬載可能洩漏機密或產生過大資料列。請使用嚴格的結構描述、欄位長度限制與允許清單。

每次重試都產生新 ID

這會在暫時性網路故障時產生重複報告。請為同一提交的所有嘗試重用同一個冪等金鑰。

承諾即時支援卻未配置人力

即時小工具會改變使用者期望。請顯示預期可用性,或在無法保證即時回應時使用非同步用語。

將回饋直接混入 roadmap

報告是證據,而非自動成為需求。請將捕捉、分類與產品優先順序視為獨立步驟。

結案卻未回應

若使用者期待回覆,僅更新內部狀態是不夠的。請將外傳傳遞視為工作流程的一部分。

佇列沒有負責人

共同責任往往變成無人負責。請指定負責人、輪值或確定性路由規則。

創辦人主導即時聊天的託管選項

工作流程定義完成後,託管聯絡層可取代部分自訂捕捉與回覆管道程式碼。

Knocket 是創作者與小型團隊的一個實作範例。它提供可分享的聯絡頁面、可嵌入的網頁即時聊天小工具、行動 WebView SDK 以及統一收件匣。網站安裝只需 script 標籤,無需自訂後端,訪客也無需帳戶即可開始聊天。

在 Knocket 設定中,訊息可路由至 Telegram,而 Telegram 的回覆可再傳回網站訪客。這讓創辦人主導的回覆更方便,但仍需負責人、保留決策、升級規則以及獨立的產品待辦清單。

評估任何託管選項時,請檢查:

  • 其資料處理是否符合您的隱私政策
  • 對話歷史是否可依需求匯出或保留
  • 如何呈現中斷與傳遞失敗
  • 回覆路徑是否適用於匿名訪客
  • 管道是否會產生您無法滿足的回應時間期望

發佈運作迴圈

在發佈回饋入口前,請確認:

  • 提交與通知意圖已原子提交。
  • 重試不會產生重複報告。
  • 脈絡最小化、列入允許清單且注重隱私。
  • 每個進行中的報告皆可有負責人與下一步行動。
  • 失敗的通知會重試且可觀測。
  • 團隊可在承諾時透過原始管道回覆。
  • 逾期報告會出現在查詢、摘要或警示中。
  • 結案回饋與新增 roadmap 工作是不同的。

目標不是收集更多訊息,而是建立從使用者脈絡到負責決策的短而可靠路徑——並在適當時回傳給使用者。

揭露:我參與 Knocket 的開發,因此請將其視為一個實作範例,而非中立推薦。