在浏览器中完成支付通常意味着要把订单金额、商品信息和支付结果都交给服务端处理。但如果应用本身是离线优先的,或者只是要做原型验证,完全可以把订单和交易记录保存在本地 SQLite 中,再借助浏览器原生的 Payment Request API 发起标准支付界面。这样既能保留用户在支付过程中的所有行为记录,也能避免一开始就引入复杂的后端结算逻辑。

这个项目的大致流程是:用户浏览商品,把选中的商品写入 SQLite 的购物车表;点击支付按钮后,前端根据购物车总金额构造 Payment Request API 所需的支付参数;用户在弹出的支付界面中确认支付信息;支付完成后,前端把支付返回的方法、邮箱、原始详情等信息写回 SQLite,并更新支付请求的状态。整个过程除了浏览器本地环境,不需要任何远程服务器参与。
一、项目需求与SQLite数据表设计
要支撑这个流程,SQLite 中至少需要三张表。第一张表保存购物车明细,记录用户选择的商品、单价、数量和币种。第二张表保存每一次支付请求,生成一个唯一请求编号,并记录请求时的总额和状态。第三张表保存支付结果,与支付请求表一对一关联,用于审计和后续同步。
这里之所以选择 SQLite 而不是浏览器的 IndexedDB,原因在于 SQLite 提供了完整的事务能力和 SQL 查询语义。对于支付这种对一致性要求较高的场景,能够用BEGIN TRANSACTION和COMMIT来控制多条写入的原子性非常重要。而且 SQLite 的数据库文件可以方便地导出或备份,便于排查支付问题。
在浏览器中使用 SQLite 需要借助 WebAssembly 编译的 SQLite 实现,比如 sql.js 或 wa-sqlite。下面这段 SQL 展示了项目初始化时创建数据表的基本结构。
CREATE TABLE cart_items (
id INTEGER PRIMARY KEY AUTOINCREMENT,
product_id TEXT NOT NULL,
title TEXT NOT NULL,
amount REAL NOT NULL,
currency TEXT NOT NULL DEFAULT 'CNY',
quantity INTEGER NOT NULL DEFAULT 1,
created_at TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE TABLE payment_requests (
id TEXT PRIMARY KEY,
total REAL NOT NULL,
currency TEXT NOT NULL,
status TEXT NOT NULL DEFAULT 'created',
created_at TEXT NOT NULL DEFAULT (datetime('now')),
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE TABLE payment_results (
id INTEGER PRIMARY KEY AUTOINCREMENT,
request_id TEXT NOT NULL UNIQUE,
method_name TEXT,
payer_email TEXT,
payer_name TEXT,
raw_details TEXT,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
FOREIGN KEY(request_id) REFERENCES payment_requests(id)
);
其中 payment_requests 表的 id 就是支付请求的唯一编号,它会在前端生成,并作为 Payment Request API 中 details.id 的值传入。payment_results 表对 request_id 建立了 UNIQUE 约束,这是后续实现幂等写入的关键。即使同一支付结果被重复写入,数据库也不会产生重复记录。
购物车表的 amount 字段用 REAL 类型保存金额,对于演示项目足够。但如果要处理更严谨的金融计算,建议把金额转换为最小货币单位,比如用整数分来存储,避免浮点数误差。本文示例为了方便阅读,仍然使用带有小数的金额表示。
二、Payment Request API支付流程实现
Payment Request API 的核心入口是 PaymentRequest 构造函数。它接收三个参数:支付方式列表、支付详情以及可选配置。支付方式列表用来声明支持的支付方法,支付详情包含订单编号、总金额和展示条目,可选配置用来决定是否收集付款人邮箱或姓名。
支付按钮通常是一个 <button> 元素,点击后调用 show 方法。必须注意,show 方法要求必须在用户激活事件中调用,否则浏览器会拒绝并抛出 NotAllowedError。下面是一段完整的支付发起代码。
async function startPayment() {
const requestId = crypto.randomUUID();
const methodData = [
{
supportedMethods: 'https://ipipp.com/pay',
data: {
merchantId: 'local-demo-merchant'
}
}
];
const details = {
id: requestId,
total: {
label: '订单总计',
amount: { currency: 'CNY', value: '199.00' }
},
displayItems: [
{ label: 'SQLite实战课程', amount: { currency: 'CNY', value: '199.00' } }
]
};
const options = {
requestPayerEmail: true,
requestPayerName: true
};
if (!window.PaymentRequest) {
document.getElementById('status').textContent = '当前浏览器不支持 Payment Request API';
return;
}
const request = new PaymentRequest(methodData, details, options);
try {
const canMake = await request.canMakePayment();
if (!canMake) {
document.getElementById('status').textContent = '当前环境不支持所选支付方式';
return;
}
const response = await request.show();
await savePaymentResult(requestId, response);
await response.complete('success');
} catch (err) {
if (err.name === 'AbortError') {
console.log('用户取消了支付');
} else {
console.error('支付失败', err);
}
}
}
这段代码中,crypto.randomUUID() 用来生成唯一的支付请求编号。这个编号同时保存在 payment_requests 表和 payment_results 表中,成为本地审计的关联键。在 methodData 中,supportedMethods 使用了自定义的支付方法标识。真实项目中这里可以换成已接入的第三方支付方式,或者使用信用卡支付时需要根据浏览器支持情况调整参数。
canMakePayment 方法用来提前判断当前环境是否可能完成支付,但它并不保证 show 一定能够成功。比如用户可能没有可用的支付方式,或者浏览器策略限制。因此 try-catch 中的错误处理仍然必不可少。用户取消支付时,show 会抛出 AbortError,这种状态不应该被当成异常失败记录到日志,只是表示用户主动终止了流程。
三、支付结果落库与幂等控制
当用户确认支付后,response 对象会包含 methodName、details、payerEmail 等信息。接下来要把这些信息写入 SQLite。由于浏览器中的 SQLite 操作是异步的,通常需要先获取数据库实例,再执行 SQL 语句。下面这段代码演示了如何把支付结果和请求状态更新放进同一个事务里。
async function savePaymentResult(requestId, response) {
const db = await getDatabase();
const methodName = response.methodName;
const payerEmail = response.payerEmail || '';
const payerName = response.payerName || '';
const rawDetails = JSON.stringify(response.details);
db.run('BEGIN TRANSACTION;');
try {
db.run(
'INSERT OR IGNORE INTO payment_results (request_id, method_name, payer_email, payer_name, raw_details) VALUES (?, ?, ?, ?, ?)',
[requestId, methodName, payerEmail, payerName, rawDetails]
);
db.run(
"UPDATE payment_requests SET status = 'paid', updated_at = datetime('now') WHERE id = ?",
[requestId]
);
db.run('COMMIT;');
} catch (e) {
db.run('ROLLBACK;');
throw e;
}
}
这里使用了 INSERT OR IGNORE 语句。如果同一个 request_id 已经被写入过 payment_results 表,那么这条插入会被忽略,但后面的 UPDATE 依然会把支付请求状态更新为 paid。这样即使因为网络波动或用户重复点击导致 savePaymentResult 被调用多次,数据库也不会出现重复的支付结果记录,同时状态也能保持一致。这就是幂等处理的核心。
除了成功支付,还需要考虑取消和失败的状态。用户取消支付时,前端可以执行一条 UPDATE 语句,把 payment_requests 的 status 改为 aborted。对于支付失败,则可以记录 fail,并保留具体的错误信息到日志表或 raw_details 字段中。这样所有支付生命周期中的行为都能在 SQLite 里追溯,方便后续排查问题或同步到远端。
在真实业务中,本地 SQLite 中的支付结果通常是临时记录。后续可以通过后台同步任务把新的支付记录批量发送到服务端,由服务端向支付网关确认后,再回写正式状态。这个过程中,本地唯一 request_id 仍然可以作为对账凭证,避免重复入账。对于纯前端演示项目,这种本地落库的方式已经足够展示完整的支付闭环。
通过把 SQLite 的事务能力与 Payment Request API 的标准化支付交互结合起来,可以快速搭建一个离线优先的支付流程原型。后续无论是替换真实的支付方法标识,还是接入服务端确认逻辑,本地数据层都不用做太大调整,这也是这种组合在实战项目中的价值所在。

