添加反馈按钮很容易,构建反馈系统则困难得多。
关键不在于输入表单,而在于用户点击发送之后发生的事情:
- 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
生产环境中,请添加速率限制、必要时的机器人防护和最大请求体大小。不要信任客户端提供的用户 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
根据团队选择合适策略,例如:
- 每日轮换分类负责人。
- 按产品区域指派。
- 将安全报告送入受限队列。
- 区分支持问题与产品决策。
- 要求每个未关闭条目都有下次行动。
功能请求不必成为路线图工作。当“已审阅并关闭”被明确表达时,它同样是有效结果。
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
这会在临时网络故障时产生重复。同一提交的所有尝试请复用同一个幂等性密钥。
承诺实时支持却不配备人员
实时小组件会改变用户期望。若无法保证即时响应,请显示预期可用时间或使用异步措辞。
将反馈直接混入路线图
报告是证据,而非自动需求。捕获、分类与产品优先级排序应作为独立步骤。
关闭条目却不回复
若用户期待回复,内部状态更新不足以说明问题。应将出站投递作为工作流的一部分跟踪。
队列无人负责
共享责任往往变成无人负责。使用指定人员、轮换或确定性路由规则。
创始人主导实时聊天的托管选项
工作流定义完成后,托管联系层可替代部分自定义捕获和回复渠道代码。
Knocket 是面向创作者和小团队的一个实现示例。它提供可分享的联系页面、可嵌入的网页实时聊天小组件、移动 WebView SDK 和统一收件箱。网站安装仅需脚本标签,无需自定义后端,用户无需账户即可开始聊天。
在 Knocket 设置中,消息可路由至 Telegram,Telegram 的引用回复也能送回网站访客。这可使创始人回复更便捷,但仍需所有权、留存决策、升级规则和独立产品待办。
评估任何托管选项时,请检查:
- 其数据处理是否符合你的隐私政策
- 对话历史是否可按需导出或保留
- 如何暴露中断和投递失败
- 匿名访客的回复路径是否可用
- 渠道是否会制造你无法满足的响应时间预期
发布可运行的闭环
在发布反馈入口前,请验证:
- 提交和通知意图已原子化提交。
- 重试不会产生重复报告。
- 上下文最小化、白名单化且注重隐私。
- 每个活跃报告可分配负责人和下次行动。
- 失败通知可重试且可观测。
- 团队可在承诺时通过原始渠道回复。
- 逾期报告出现在查询、摘要或告警中。
- 关闭反馈与添加路线图工作是两码事。
目标不是收集更多消息,而是创建从用户上下文到已分配决策的短且可靠路径,并在适当时返回给用户。
披露:作者在 Knocket 工作,请将其视为实现示例而非中立推荐。
0 Comments
Log in to join the conversation.No comments yet. Be the first to share your thoughts.