二手交易和普通电商最大的差别是:买卖双方都是个人,平台不持有商品,也不承担发货责任 。这个结构决定了平台必须做一件事------把钱扣在中间,等确认收货再放款。
这套机制叫担保交易。看起来简单(收钱 → 冻住 → 放款),实际做起来涉及资金合规、状态机设计、争议处理三条线。任何一条没做好,都会出现"钱放出去了但货没到"或者"钱扣住了但买家不确认"的僵局。
本文给出可落地的设计方案,示例基于 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,避免并发下出现非法状态;- 放款幂等 ,用订单号做渠道幂等键,定时任务重跑也不会重复打款。
争议和信用是业务层的长期工程,技术上只要保证"钱不提前放、证据能留住、规则可解释",剩下的靠运营慢慢磨。
青海青帝信息科技有限公司在本地二手交易类项目里,会把"资金流水只增不改"和"状态迁移条件更新"作为两条不可妥协的编码规范------这两条一旦破例,后面所有的对账和争议处理都会跟着不可靠。