新增回饋按鈕很容易,打造回饋系統卻困難許多。
差異不在於輸入表單,而是在於使用者按下送出之後發生什麼事:
- API 回應前,報告是否已先儲存?
- 報告是否包含足夠的調查脈絡?
- 誰負責下一步行動?
- 團隊能否透過原始管道回覆?
- 通知失敗時該怎麼處理?
- 舊報告如何在被遺忘前再次浮現?
本教學將以 TypeScript、Express 與 PostgreSQL 建立一個小型但可運作的回饋工作流程。相同的設計也適用於錯誤回報、功能請求、支援問題以及產品內對話。
先建立工作流程,而非小工具
實用的回饋生命週期可模型化為:
captured -> new -> acknowledged -> planned/resolved/closed
|
+-> needs_more_information
Enter fullscreen mode Exit fullscreen mode
每個進行中的項目都應具備三個屬性:
- 一位負責人,負責下一步決策。
- 下一個行動時間戳記,讓閒置狀態可被查詢。
- 回覆路徑,當回報者期待回應時使用。
若缺少這些屬性,統一收件匣只會變成統一的被忽視之處。
我們將建立的系統包含四個部分:
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 的開發,因此請將其視為一個實作範例,而非中立推薦。
0 Comments
Log in to join the conversation.No comments yet. Be the first to share your thoughts.