首页
学习
活动
专区
圈层
工具
发布
社区首页 >专栏 >官网表单“明明提交成功,销售却说没收到”,还重复两条?询盘线索防丢防重的前端兜底与后端幂等实战

官网表单“明明提交成功,销售却说没收到”,还重复两条?询盘线索防丢防重的前端兜底与后端幂等实战

原创
作者头像
数字化落地笔记
修改于 2026-10-10 14:44:49
修改于 2026-10-10 14:44:49
460
举报

做企业官网,最怕的不是没流量,是流量来了、客户也填了表单,线索却在半路丢了或者重复。这类问题表面看是"表单不好用",拆开其实是三处:弱网下请求根本没到服务器、请求超时其实已经入库但客户以为没成功又点了一次、以及通知被折叠销售误以为没收到。前端做防重和本地暂存、后端用幂等键去重、再加一道线索对账,三处补齐,漏单和重复基本能清零。

先说背景

客户是一家做本地企业服务的公司,官网主要承接搜索和线下推广带来的询盘。站点的内容管理和表单后台跑在一套托管式建站的SaaS底座上,日常的页面发布、留言接收和数据入库都在后台正常完成。这篇不聊后台怎么配置,只复盘一次"客户明明点了提交、销售却没收到,偶尔还冒出两条重复线索"的表单故障。问题最后定位在前端弱网交互、网关超时重试和通知对账这几处,下面把排查和改造过程记下来。

一、先对账:到底是没提交、没入库,还是没看到通知

销售一口咬定"客户填了但我没收到",先别急着改代码,把数据拉出来对一遍。我们把后台线索导出,和销售自己登记的跟进表逐条比对了两周:

  • 后台实际收到线索 47 条,销售登记跟进 41 条,差 6 条。
  • 这 6 条里,3 条是通知邮件进了垃圾箱或被折叠,后台其实都有;2 条是客户在弱网下提交超时,请求压根没到服务器;还有 1 条是客户连点了两次,后台真的进了两条,销售以为是两个人。

这一步很关键:"没收到通知"不等于"没提交成功"。通知(邮件、短信、企微)和数据入库是两条独立的链路,很多所谓漏单其实是通知链路的问题。先把真假丢单分清楚,后面才知道改哪里。

二、前端:弱网不丢、连点不重

真正在传输层丢掉的请求,靠用户多戳几次是没用的,反而会制造重复。前端要解决两件事:提交期间锁住入口,以及失败时先把数据存在本地、网络恢复再补发。

代码语言:javascript
复制
// 进入填写页就生成一次幂等令牌,同一份填写反复提交都是同一个键
const formKey = 'lead_' + Date.now() + '_' + Math.random().toString(36).slice(2, 8);
const pendingKey = 'pending_lead';
const btn = document.querySelector('#submit');
let submitting = false;

async function submitLead(data) {
  if (submitting) return;          // 防连点
  submitting = true;
  btn.disabled = true;
  const payload = { ...data, idemKey: formKey };
  try {
    const res = await fetch('/api/lead', {
      method: 'POST',
      headers: { 'Content-Type': 'application/json', 'Idempotency-Key': formKey },
      body: JSON.stringify(payload),
    });
    if (!res.ok) throw new Error('http ' + res.status);
    localStorage.removeItem(pendingKey);   // 只有确认成功才清本地
    alert('提交成功,我们会尽快联系您');
  } catch (e) {
    // 弱网/超时:先落地本地,恢复后补发,绝不让客户白填
    localStorage.setItem(pendingKey, JSON.stringify(payload));
    scheduleRetry();
  } finally {
    submitting = false;
    btn.disabled = false;
  }
}

// 网络恢复或定时轮询时补发;后端靠 idemKey 去重,补发也不会产生两条
function scheduleRetry() {
  window.addEventListener('online', retryPending);
  setInterval(retryPending, 30000);
}
async function retryPending() {
  const raw = localStorage.getItem(pendingKey);
  if (!raw || !navigator.onLine) return;
  const payload = JSON.parse(raw);
  const res = await fetch('/api/lead', {
    method: 'POST',
    headers: { 'Content-Type': 'application/json', 'Idempotency-Key': payload.idemKey },
    body: JSON.stringify(payload),
  });
  if (res.ok) localStorage.removeItem(pendingKey);
}

这里有个容易忽略的点:幂等令牌要在开始填写时生成并跟着数据一起存进本地,而不是每次点击都新生成。否则弱网暂存后补发,换了新令牌,后端就认不出是同一条了。

三、后端:幂等键才是防重的闸门

前端按钮锁只能挡住正常操作,挡不住刷新页面、杀进程重进、以及网关重放。同一条线索会不会变成两条,最终由后端说了算。做法是给线索表加唯一约束,再配一张幂等记录表,重放请求直接返回第一次的结果。

代码语言:sql
复制
-- 线索表:幂等键加唯一约束,同一次提交无论重放几次只落一条
CREATE TABLE customer_lead (
  id          BIGINT PRIMARY KEY AUTO_INCREMENT,
  idem_key    VARCHAR(64) NOT NULL COMMENT '前端生成的幂等令牌',
  name        VARCHAR(50),
  phone       VARCHAR(20),
  content     VARCHAR(1000),
  created_at  DATETIME DEFAULT CURRENT_TIMESTAMP,
  UNIQUE KEY uk_idem_key (idem_key)
);

-- 幂等记录表:先占坑再处理,处理结果可复用
CREATE TABLE idempotent_record (
  idem_key    VARCHAR(64) PRIMARY KEY,
  status      TINYINT COMMENT '0处理中 1成功 2失败',
  response    VARCHAR(1000),
  created_at  DATETIME DEFAULT CURRENT_TIMESTAMP
);

接收接口的逻辑是:先查有没有处理过,有就直接回第一次的结果并标记重复;没有就先占坑,再写库,写库时即使并发撞上唯一约束,也当作重复返回,而不是报错。

代码语言:python
复制
from flask import Flask, request, jsonify

app = Flask(__name__)

@app.post('/api/lead')
def create_lead():
    idem = request.headers.get('Idempotency-Key') or request.json.get('idemKey')
    if not idem:
        return jsonify(msg='missing idempotency key'), 400

    # 已处理过:重放直接返回首次结果,不再插入
    cached = get_idempotent(idem)
    if cached and cached['status'] == 1:
        return jsonify(data=cached['response'], duplicated=True)

    # 占坑失败说明正在并发处理或重放
    if not try_insert_idempotent(idem, status=0):
        return jsonify(msg='processing, retry later'), 409

    try:
        lead_id = insert_lead(request.json)   # 数据库唯一键是最后一道闸
        resp = {'lead_id': lead_id}
        mark_idempotent(idem, 1, resp)
        return jsonify(data=resp)
    except DuplicateKeyError:
        return jsonify(data=get_idempotent(idem)['response'], duplicated=True)
    except Exception as e:
        mark_idempotent(idem, 2, str(e))
        raise

四、网关层:别让超时重试替用户"连点"

有一类重复非常隐蔽:客户只点了一次,但反向代理把 POST 转发给后端时超时,网关按默认策略把请求又转发了一遍,后端于是收到两次。GET 重试无所谓,POST 重试就会造数据,必须在网关显式关掉。

代码语言:nginx
复制
location /api/lead {
    proxy_pass http://backend;
    proxy_next_upstream off;        # 失败不切换、不重放 POST
    proxy_connect_timeout 5s;
    proxy_read_timeout 15s;
    # 失败交给前端带着幂等键安全重试,网关自己绝不重放
}
client_body_timeout 15s;

五、通知和对账:把"以为丢了"也堵上

传输层补齐后,剩下的就是通知链路。我们做了两件事:一是把通知收件箱加白、关键渠道做双通道(邮件之外再推一条企微/短信),避免进垃圾箱;二是每天早上跑一个对账,比对后台线索数和销售跟进数,出现"入库了但没人跟进"的记录就告警。

改造后连续观察了 6 周:前端本地暂存补发成功 11 条,这 11 条在改造前就是石沉大海的漏单;后端幂等拦截重复提交 9 次;每天对账线索入库数和跟进数 47 比 47 对齐,再没出现过"客户说提交了、销售说没收到"的扯皮。

几个容易踩的坑

  • 坑1:只在前端禁用按钮。 刷新、杀进程、弱网回调丢失都会让按钮锁失效,防重必须以后端幂等为准,前端锁只是体验优化。
  • 坑2:幂等令牌在服务端或点击时才生成。 那样弱网暂存后补发会换新键,后端无法判重;令牌要在填写时生成并随数据持久化。
  • 坑3:放任网关重试 POST。 反向代理默认的失败转移可能把一次提交转发两遍,写接口要显式关闭,重试交给带幂等键的前端。
  • 坑4:把通知问题当成丢单改。 通知和入库是两条链路,先导数据对账、分清真假丢单,再动手,否则改错地方还解决不了问题。

结语

官网询盘表单的可靠性,说穿了就三层:前端保证弱网不丢、连点不重,后端用幂等键保证同一条数据只落一次,再用对账兜底通知和跟进。对中小企业来说,表单里躺着的不是一条数据,是一条真金白银的销售线索,把这条链路当成交易接口来做防丢防重,比多投一点推广更划算。

原创声明:本文系作者授权腾讯云开发者社区发表,未经许可,不得转载。

如有侵权,请联系 cloudcommunity@tencent.com 删除。

目录
  • 先说背景
  • 一、先对账:到底是没提交、没入库,还是没看到通知
  • 二、前端:弱网不丢、连点不重
  • 三、后端:幂等键才是防重的闸门
  • 四、网关层:别让超时重试替用户"连点"
  • 五、通知和对账:把"以为丢了"也堵上
  • 几个容易踩的坑
  • 结语
问题归档专栏文章快讯文章归档关键词归档开发者手册归档开发者手册 Section 归档