添加反馈按钮很容易,构建反馈系统则困难得多。

关键不在于输入表单,而在于用户点击发送之后发生的事情:

  • 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

生产环境中,请添加速率限制、必要时的机器人防护和最大请求体大小。不要信任客户端提供的用户 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 工作,请将其视为实现示例而非中立推荐。