二手交易小程序的担保交易:资金托管、状态机与争议处理

二手交易和普通电商最大的差别是:买卖双方都是个人,平台不持有商品,也不承担发货责任 。这个结构决定了平台必须做一件事------把钱扣在中间,等确认收货再放款。

这套机制叫担保交易。看起来简单(收钱 → 冻住 → 放款),实际做起来涉及资金合规、状态机设计、争议处理三条线。任何一条没做好,都会出现"钱放出去了但货没到"或者"钱扣住了但买家不确认"的僵局。

本文给出可落地的设计方案,示例基于 MySQL 8 + Node.js。

一、资金流向:三段式托管

担保交易的钱要经过三个节点:

支付成功 → 钱进平台商户号,标记为"待放款"买家确认收货 → 进入"可放款"状态,等待结算周期结算周期结束(如 T+1) → 打款给卖家

中间的"结算周期"不是冗余设计,它是争议窗口。买家确认收货后仍可能发现问题(比如电子产品用了两天坏了),需要一段时间的缓冲。

数据模型上,订单和资金要分开两张表。订单描述"交易进展",资金描述"钱在哪":

-- 交易订单

复制代码
CREATE TABLE trade_order (
  id              BIGINT PRIMARY KEY AUTO_INCREMENT,
  order_no        VARCHAR(32) NOT NULL,
  buyer_id        BIGINT NOT NULL,
  seller_id       BIGINT NOT NULL,
  item_id         BIGINT NOT NULL,
  amount_cents    INT NOT NULL COMMENT '成交金额,分',
  status          TINYINT NOT NULL DEFAULT 10 COMMENT '见状态机',
  pay_at          DATETIME NULL,
  ship_at         DATETIME NULL,
  receive_at      DATETIME NULL,
  settle_at       DATETIME NULL,
  close_reason    VARCHAR(100) NULL,
  created_at      DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
  UNIQUE KEY uk_order_no (order_no),
  KEY idx_buyer (buyer_id, status),
  KEY idx_seller (seller_id, status)
);

-- 资金托管流水(只增不改)
CREATE TABLE escrow_flow (
  id            BIGINT PRIMARY KEY AUTO_INCREMENT,
  order_no      VARCHAR(32) NOT NULL,
  flow_type     TINYINT NOT NULL COMMENT '1入账 2冻结 3放款 4退款 5部分退款',
  amount_cents  INT NOT NULL COMMENT '正数入账,负数出账',
  balance_after INT NOT NULL COMMENT '该单托管余额快照',
  channel_txn   VARCHAR(64) NULL COMMENT '渠道流水号',
  created_at    DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
  KEY idx_order (order_no)
);

escrow_flow 只增不改 ,这是对账的基础。任何一笔钱的移动都要留一条记录,balance_after 是当时的余额快照,便于事后追溯。不要用 UPDATE 改余额字段,余额永远由流水加总得出。

二、状态机:把"能不能做"写进状态

二手交易的状态比普通电商多,因为有"买家确认"和"争议"两个环节:

10 待付款
→ 20 待发货(已付款,钱已托管)
→ 30 待收货(卖家已发货)
→ 40 待结算(买家确认收货,进入 T+1 争议期)
→ 50 已完成(放款给卖家)
→ 45 争议中(买家发起,钱继续冻结)
→ 40 待结算(争议解决,继续结算)
→ 55 已退款(争议判定退款)
→ 60 已关闭(超时未付款)

状态迁移必须做成一张显式的表 ,不要散落在业务代码的 if-else 里。散着写的后果是半年后没人说得清"待收货能不能直接退款"。

复制代码
const TRANSITIONS = {
  10: ['20', '60'],           // 待付款 → 待发货 / 已关闭
  20: ['30', '55'],           // 待发货 → 待收货 / 已退款(未发货可退)
  30: ['40', '45'],           // 待收货 → 待结算 / 争议中
  40: ['50', '45'],           // 待结算 → 已完成 / 争议中
  45: ['40', '55'],           // 争议中 → 待结算 / 已退款
};

async function transit(conn, orderNo, from, to, extra = {}) {
  if (!TRANSITIONS[from]?.includes(to)) {
    throw new Error(`非法状态迁移 ${from} -> ${to}`);
  }
  // 条件更新,保证并发下只有一个请求能成功
  const [r] = await conn.query(
    `UPDATE trade_order
        SET status = ?, ${Object.keys(extra).map(k => `${k} = ?`).join(', ')}
      WHERE order_no = ? AND status = ?`,
    [to, ...Object.values(extra), orderNo, from]
  );
  if (r.affectedRows === 0) {
    throw new Error('状态已被其他操作改变,请刷新后重试');
  }
}

WHERE status = from 这一条是整个系统正确性的核心。 买家点"确认收货"的同时卖家点了"取消交易",如果没有这个条件,两条更新都会成功,订单会变成既已完成又已关闭的诡异状态。

三、放款:定时任务 + 条件更新

结算用定时任务扫描"待结算且已过争议期"的订单:

复制代码
async function settleJob() {
  const [orders] = await db.query(
    `SELECT order_no, seller_id, amount_cents
       FROM trade_order
      WHERE status = 40
        AND receive_at < DATE_SUB(NOW(), INTERVAL 24 HOUR)
      LIMIT 100`
  );
  for (const o of orders) {
    const conn = await db.getConnection();
    try {
      await conn.beginTransaction();
      // 1. 再次确认状态(防并发)
      const [chk] = await conn.query(
        'SELECT status FROM trade_order WHERE order_no = ? FOR UPDATE', [o.order_no]
      );
      if (chk[0].status !== 40) { await conn.rollback(); continue; }

      // 2. 调渠道分账/转账(幂等键用 order_no)
      const r = await channel.transfer({
        outTradeNo: o.order_no,
        amount: o.amount_cents,
        account: await getSellerAccount(o.seller_id),
      });

      // 3. 写流水 + 改状态
      await conn.query(
        `INSERT INTO escrow_flow (order_no, flow_type, amount_cents, balance_after, channel_txn)
         VALUES (?, 3, ?, 0, ?)`,
        [o.order_no, -o.amount_cents, r.transactionId]
      );
      await conn.query(
        `UPDATE trade_order SET status = 50, settle_at = NOW()
          WHERE order_no = ? AND status = 40`, [o.order_no]
      );
      await conn.commit();
    } catch (e) {
      await conn.rollback();
      // 记录失败原因,下轮重试;不要静默失败
      await logSettleError(o.order_no, e.message);
    } finally {
      conn.release();
    }
  }
}

关键点: outTradeNo 用订单号做幂等键。 渠道侧如果已经处理过这个单号,会返回原结果而不会重复打款。这是防止"定时任务重跑导致重复放款"的最后一道保险。

另一个坑 :SELECT ... FOR UPDATE 加锁后再调渠道接口,会让事务持有时间变长。更好的做法是先查、再调渠道、最后在事务里只做写操作。但那样要多一次状态校验。折中方案是把渠道调用放在事务外,用"预占"标记:

复制代码
-- 事务外先抢占
UPDATE trade_order SET settle_lock = 1
WHERE order_no = ? AND status = 40 AND settle_lock = 0;

抢到(影响行数 1)才去调渠道,调完在事务里写流水和状态。这样既避免了长事务,又避免了重复放款。

四、争议处理:钱不能提前放

争议是担保交易里最麻烦的部分,因为它涉及"人为判断"。

争议的触发条件 要写清楚:确认收货后 24 小时内可以发起、超时不接受;或者未确认收货但超过预计到货时间 N 天。

争议期间资金状态 :冻结不变,不放款、不退款,等客服裁定。

裁定结果只有三种 :全额放款给卖家、全额退款给买家、部分退款(按比例分)。部分退款是最常见的,因为二手商品往往"货是收到了,但和描述有出入"。

数据结构上,争议记录要独立于订单,且允许一条订单多次争议(虽然少见):

复制代码
CREATE TABLE dispute (
  id           BIGINT PRIMARY KEY AUTO_INCREMENT,
  order_no     VARCHAR(32) NOT NULL,
  initiator    TINYINT NOT NULL COMMENT '1买家 2卖家',
  reason_type  TINYINT NOT NULL COMMENT '1未收到货 2货物不符 3质量问题 4其他',
  description  TEXT NOT NULL,
  evidence     JSON NULL COMMENT '图片/视频 URL 数组',
  status       TINYINT NOT NULL DEFAULT 1 COMMENT '1待处理 2处理中 3已裁定',
  result       TINYINT NULL COMMENT '1放款卖家 2全额退款 3部分退款',
  refund_cents INT NULL,
  handler      VARCHAR(40) NULL,
  created_at   DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
  KEY idx_order (order_no)
);

证据留存是裁定的基础。 争议发起时,要把当时的商品描述、聊天记录、物流信息快照一起存下来。二手交易的很多纠纷,事后去看商品已经被下架或编辑了,没有快照就没法判断。

五、信用体系:比评价更重要的是"行为"

评价可以刷,行为刷不了。信用分应该基于可验证的行为数据:

|---------|----------|------------------|
| 因子 | 权重建议 | 数据来源 |
| 成交完成率 | 30% | 已完成订单 / 总订单 |
| 平均发货时长 | 20% | ship_at - pay_at |
| 争议发生率 | 25% | 争议订单 / 总订单 |
| 描述相符度 | 15% | 买家评价中的描述相符项 |
| 账号活跃与实名 | 10% | 实名认证、注册时长 |

注意"争议发生率"要区分主动发起和被动卷入 。一个买家频繁发起争议,和卖家频繁被投诉,是两种完全不同的问题,不能算在同一个分里。

信用分不要做成"黑箱"。用户看到分数却不知道原因,只会觉得不公。要给出明细:哪一项扣了分、怎么才能加回来。

六、合规的三条红线

一是资金池。 平台代收代付如果形成资金沉淀,可能触及支付业务许可的监管要求。合规做法是接入持牌支付机构的"担保交易"或"分账"产品,让资金在持牌机构侧托管,平台只做指令,不碰钱。

二是二手商品的禁售清单。 涉及食品药品、医疗器械、管制物品、虚拟账号等,平台要有明确的禁售规则和审核机制。这不是技术问题,但技术要能支撑:发布时的关键词过滤、图片识别、人工复审。

三是个人信息保护。 买卖双方的电话、地址属于个人信息,展示要脱敏(用虚拟号中转),存储要加密,访问要留痕。

七、小结

二手交易的担保交易,技术难度不在写代码,在三个设计决策:

  • 资金托管用独立流水表,只增不改 ,余额由流水加总,任何时刻都能回溯;- 状态机显式化 ,迁移条件写进 WHERE,避免并发下出现非法状态;- 放款幂等 ,用订单号做渠道幂等键,定时任务重跑也不会重复打款。

争议和信用是业务层的长期工程,技术上只要保证"钱不提前放、证据能留住、规则可解释",剩下的靠运营慢慢磨。

青海青帝信息科技有限公司在本地二手交易类项目里,会把"资金流水只增不改"和"状态迁移条件更新"作为两条不可妥协的编码规范------这两条一旦破例,后面所有的对账和争议处理都会跟着不可靠。

相关推荐
andrsted19 小时前
练题簿免费刷题小程序:和学习搭子共用一份题库,一起刷题复习
学习·面试·微信小程序·小程序·学习方法·刷题
万岳科技程序员小金21 小时前
教育机构如何搭建在线教学平台?网校系统、小程序与APP开发指南
小程序·在线教育系统源码·教育平台搭建·教育app开发·教育软件开发·教育小程序开发
小小王app小程序开发1 天前
淘宝扭蛋机小程序玩法开发分析
小程序
2501_916008891 天前
马甲包过审卡在哪?Ipa Guard 能做的和做不到的
android·小程序·https·uni-app·iphone·webview
Fxkj8881 天前
佛山企业做小程序:模板定制与源码定制怎么选?
人工智能·小程序
青企通1 天前
电商小程序购物车与库存扣减:SKU 模型、合并规则与防超卖实践
小程序·apache
AI源码1 天前
家乡介绍小程序
小程序·notepad++
2501_915921432 天前
iOS应用耗电测试:使用Energy Log、Keymob和Instruments进行性能监控与能源消耗分析
android·ios·小程序·https·uni-app·iphone·webview
FX1317887KP_2 天前
KET/PET/FCE备考攻略:口语训练的三大误区与科学训练路径
小程序·ket口语·pet口语·fce备考·剑桥英语