Cloudflare Workers 零成本搭建 V免签个人收款系统(实战教程)
本文记录一次从零到一,用 Cloudflare Workers + D1 数据库搭建 V免签支付系统的完整过程,包括监控端(Python + OCR)、服务端(Workers + D1)、博客端(对接支付)三部分的部署与踩坑全记录。
一、前言:为什么要自己搭 V免签
个人博客想要做”付费文章”功能,通常有两条路:
- 申请官方支付接口:需要企业资质、营业执照,个人开发者走不通
- 用第三方支付平台:有手续费、有跑路风险、体验差
V免签是个人开发者领域非常流行的免签约收款方案,原理是:
用户扫码付款 → 手机收到收款通知 → 监控端监听通知 → 推送到服务端
↓
服务端按金额匹配订单 → 回调商户系统 → 完成交易闭环
原版 V免签(szvone/Vmqphp)是 PHP 版,需要一台服务器 + MySQL。本文用 Cloudflare Workers + D1 复刻了服务端,实现零服务器成本搭建 V免签。
二、整体架构
┌──────────────┐ ① 下单 ┌────────────────┐
│ 博客系统 │ ───────────────→ │ │
│ (cfblog) │ ←─────────────── │ V免签 Workers │
│ │ ⑥ 异步回调 │ │
└──────────────┘ └────────────────┘
↑
│ ④ appPush
│
┌──────────────┐
│ Python 监控端 │
│ (OCR识别金额) │
└──────────────┘
↑
│ ③ 微信收款通知
│
┌──────────────┐
│ 微信电脑版 │
│ 收款助手窗口 │
└──────────────┘
三个角色
| 角色 | 部署位置 | 作用 |
|---|---|---|
| 博客系统 | Cloudflare Workers | 展示文章、发起订单、接收回调、解锁付费内容 |
| V免签服务端 | Cloudflare Workers + D1 | 管理订单、匹配支付、通知博客 |
| Python 监控端 | Windows 本地 | 截图微信收款助手窗口、OCR 识别金额、推送服务端 |
三、准备工作
3.1 环境要求
- Cloudflare 账号(免费)
- Windows 电脑(运行监控端脚本)
- 微信电脑版(保持”微信收款助手”窗口可见)
- Node.js + wrangler CLI(部署 Workers)
- Python 3.8+ + Tesseract-OCR(本地监控端)
⚠️ 关键坑预警:优选 IP 域名在 Worker 内部
fetch时会打到 CDN 中转服务器,导致 8 秒超时。Worker 之间通信必须用原生 Workers 域名。这是本文最核心的踩坑之一。
四、部署 V免签 Workers 服务端
4.1 创建项目
mkdir cloudflare-vmq
cd cloudflare-vmq
npm init -y
4.2 创建 D1 数据库
wrangler d1 create vmq-db
记下返回的 database_id,稍后填入 wrangler.toml。
4.3 wrangler.toml 配置
name = "vmq-workers"
main = "src/index.js"
compatibility_date = "2024-01-01"
[[d1_databases]]
binding = "DB"
database_name = "vmq-db"
database_id = "你的数据库ID"
⚠️ 注意:
database_id的值结尾必须有双引号,否则 wrangler 会报 TOML 解析错误。
4.4 数据库结构 schema.sql
CREATE TABLE IF NOT EXISTS orders (
id INTEGER PRIMARY KEY AUTOINCREMENT,
pay_id TEXT UNIQUE NOT NULL,
order_id TEXT UNIQUE NOT NULL,
type INTEGER NOT NULL,
price REAL NOT NULL,
really_price REAL DEFAULT 0,
state INTEGER DEFAULT 0, -- 0待支付 1已支付 2通知失败 3过期 4补单
param TEXT DEFAULT '',
notify_url TEXT DEFAULT '',
return_url TEXT DEFAULT '',
create_time INTEGER NOT NULL,
pay_time INTEGER DEFAULT 0,
notify_time INTEGER DEFAULT 0,
notify_count INTEGER DEFAULT 0
);
CREATE TABLE IF NOT EXISTS config (
key TEXT PRIMARY KEY,
value TEXT,
updated_at INTEGER
);
CREATE TABLE IF NOT EXISTS monitor_status (
id INTEGER PRIMARY KEY CHECK (id = 1),
last_heartbeat INTEGER DEFAULT 0,
version TEXT DEFAULT '',
status INTEGER DEFAULT 0
);
INSERT OR IGNORE INTO config (key, value, updated_at) VALUES
('vmq_key', 'changeme_' || hex(randomblob(8)), strftime('%s','now')),
('admin_user', 'admin', strftime('%s','now')),
('admin_pass', 'admin123', strftime('%s','now')),
('site_name', 'V免签', strftime('%s','now')),
('notify_url', '', strftime('%s','now')),
('order_timeout', '300', strftime('%s','now'));
INSERT OR IGNORE INTO monitor_status (id, last_heartbeat, status) VALUES (1, 0, 0);
4.5 初始化数据库
# 本地初始化
wrangler d1 execute vmq-db --file=./schema.sql
# 远程初始化(重要!部署后必须执行)
wrangler d1 execute vmq-db --file=./schema.sql --remote
⚠️ 关键坑:不加
--remote只初始化本地模拟库,Cloudflare 线上库是空的。必须执行--remote版本。
4.6 核心代码结构
src/
├── index.js # 主入口 + 路由
├── admin.js # 管理后台
├── api.js # V免签核心 API
├── db.js # D1 数据库操作
├── md5.js # 纯 JS MD5 实现
└── templates.js # HTML 模板
核心:md5.js(纯 JS 实现)
⚠️ 关键坑:Cloudflare Workers 的
crypto.subtle.digest不支持 MD5!必须用纯 JS 实现。
export function md5(string) {
// ... 完整 MD5 实现(代码较长,见文末链接)
// 关键:返回 32 位小写十六进制字符串
return (WordToHex(a) + WordToHex(b) + WordToHex(c) + WordToHex(d)).toLowerCase();
}
核心:api.js 的 appPush(监控端推送)
export async function appPush(request, env, ctx) {
let typeRaw, priceRaw, t, sign;
// 兼容 POST form(Python)和 GET query(原版App)
const contentType = request.headers.get('content-type') || '';
if (request.method === 'POST' && contentType.includes('form')) {
const form = await request.formData();
typeRaw = form.get('type');
priceRaw = form.get('price');
t = form.get('t');
sign = form.get('sign');
} else {
const url = new URL(request.url);
typeRaw = url.searchParams.get('type');
priceRaw = url.searchParams.get('price');
t = url.searchParams.get('t');
sign = url.searchParams.get('sign');
}
// ★★★ 关键:全部转字符串,避免 JS 数字相加 ★★★
const typeStr = String(typeRaw); // "1"
const priceStr = String(priceRaw); // "0.02"
const tStr = String(t);
const vmqKey = await DB.getConfig(env.DB, 'vmq_key');
const expectSign = md5(typeStr + priceStr + tStr + vmqKey);
if (sign !== expectSign) {
return textResp('sign error');
}
const type = parseInt(typeStr);
const price = parseFloat(priceStr);
// 查找匹配的待支付订单
const pending = await DB.getPendingOrders(env.DB);
let matched = null;
for (const o of (pending.results || [])) {
if (o.type === type && o.state === 0 && Math.abs(o.price - price) < 0.001) {
matched = o;
break;
}
}
if (!matched) return textResp('no matching order');
await DB.updateOrderState(env.DB, matched.order_id, 1, price);
// ★★★ 关键:用 waitUntil 异步通知,不阻塞返回 ★★★
ctx.waitUntil(notifyMerchant(env, matched, price));
return textResp('success');
}
核心:notifyMerchant(通知博客)
async function notifyMerchant(env, order, reallyPrice) {
const vmqKey = await DB.getConfig(env.DB, 'vmq_key');
let notifyUrl = order.notify_url || await DB.getConfig(env.DB, 'notify_url');
if (!notifyUrl) return;
const payIdStr = String(order.pay_id);
const paramStr = String(order.param || '');
const typeStr = String(order.type);
const priceStr = String(order.price);
const rpStr = String(reallyPrice);
const sign = md5(payIdStr + paramStr + typeStr + priceStr + rpStr + vmqKey);
const params = new URLSearchParams({
payId: payIdStr, param: paramStr, type: typeStr,
price: priceStr, reallyPrice: rpStr, sign
});
const fullUrl = notifyUrl + '?' + params.toString();
console.log('=== 通知商户 URL ===', fullUrl);
try {
const resp = await fetch(fullUrl, { method: 'GET' });
const text = await resp.text();
const success = text.toLowerCase().includes('success');
await DB.updateNotifyResult(env.DB, order.order_id, success);
console.log(success ? '✅ 通知商户成功' : '❌ 通知商户失败: ' + text);
} catch (e) {
console.error('❌ 通知商户异常:', e);
await DB.updateNotifyResult(env.DB, order.order_id, false);
}
}
4.7 部署
wrangler deploy
访问 https://你的域名/admin/login,默认账号 admin / admin123。首次登录后立即修改密码!
五、部署 Python 监控端
5.1 安装依赖
pip install pytesseract requests pygetwindow pywin32 pillow
Tesseract-OCR 下载:https://github.com/UB-Mannheim/tesseract/wiki
5.2 核心配置
# ==================== 配置区 ====================
VMQ_URL = "https://workers V免签地址/appPush"
VMQ_HEART_URL = "https://workers V免签地址/appHeart"
VMQ_KEY = "密钥" # 从管理后台复制
pytesseract.pytesseract.tesseract_cmd = r'C:\Program Files\Tesseract-OCR\tesseract.exe'
WINDOW_TITLE = "微信收款助手"
SCAN_INTERVAL = 1.0
HEARTBEAT_EVERY = 10
5.3 OCR 识别(简化版,去掉时间去重)
def extract_amount(image):
"""精准识别弹窗中的金额"""
global last_push_timestamp
# 裁剪区域
w, h = image.size
left = int(w * 0.15)
right = int(w * 0.85)
top = int(h * 0.10)
bottom = int(h * 0.55)
crop = image.crop((left, top, right, bottom))
# 灰度化 + 增强对比 + 放大
crop = crop.convert('L')
crop = ImageOps.autocontrast(crop)
crop = crop.resize((crop.width * 2, crop.height * 2), Image.LANCZOS)
# OCR 识别
text = pytesseract.image_to_string(crop, lang='chi_sim+eng', config='--psm 6')
clean_text = text.replace(' ', '').replace('\n', '')
# ★★★ 关键:只用 3 秒防抖,不用时间去重 ★★★
# 时间去重会导致"同分钟内的多笔付款被跳过"
if time.time() - last_push_timestamp < 3:
return None
# 抓取金额
matches = re.findall(r'[¥¥]\s*(\d+\.\d{2})', clean_text)
if matches:
return matches[0]
matches = re.findall(r'(\d+\.\d{2})', clean_text)
if matches:
return matches[0]
return None
5.4 推送签名
def send_http_request(amount):
global last_push_timestamp
pay_type = 1
price = amount
t = int(time.time())
# ★ 签名公式必须和服务端一致
sign_str = f"{pay_type}{price}{t}{VMQ_KEY}"
sign = hashlib.md5(sign_str.encode('utf-8')).hexdigest()
data = {'type': pay_type, 'price': price, 't': t, 'sign': sign}
response = requests.post(VMQ_URL, data=data, timeout=5)
if 'success' in response.text.lower():
print(f"[V免签] 推送成功: {amount}元")
last_push_timestamp = time.time()
5.5 运行监控端
python 监控端.py
看到 [预热] 完成,开始监听新收款... 就正常了。保持微信电脑版”微信收款助手”窗口可见。
六、博客端对接支付
6.1 环境变量
在 Cloudflare Dashboard → 博客 Workers → Settings → Variables 设置:
| 变量名 | 值 | 说明 |
|---|---|---|
VPAY_URL |
填写你的V免签网址 |
V免签服务端地址 |
VPAY_KEY |
密钥 |
通讯密钥(和后台一致) |
6.2 博客端订单表
关键:博客端也要有 orders 表,用于接收异步回调时匹配订单。
CREATE TABLE IF NOT EXISTS orders (
id INTEGER PRIMARY KEY AUTOINCREMENT,
pay_id TEXT UNIQUE,
article_id TEXT,
amount TEXT,
pay_type TEXT,
block TEXT,
status INTEGER DEFAULT 0,
created_at INTEGER,
paid_at INTEGER,
trade_no TEXT
);
wrangler d1 execute 博客数据库名 --command="CREATE TABLE IF NOT EXISTS orders (...);" --remote
6.3 handle_pay(发起支付)
async function handle_pay(request, db) {
const url = new URL(request.url);
const articleId = url.searchParams.get('id');
const type = url.searchParams.get('type') || '2';
const article = await getArticle(db, articleId);
if (!article) return new Response('无效订单', { status: 400 });
// 计算金额
const urlAmount = url.searchParams.get('amount');
const blockId = url.searchParams.get('block') || '';
let price;
if (urlAmount && parseFloat(urlAmount) > 0) {
price = parseFloat(urlAmount).toFixed(2);
} else if (article.price && parseFloat(article.price) > 0) {
price = parseFloat(article.price).toFixed(2);
} else {
return new Response('无效订单', { status: 400 });
}
const payId = 'CF' + Date.now() + Math.floor(Math.random() * 1000);
const param = articleId;
const sign = md5(payId + param + type + price + ACCOUNT.vpay.key);
// ★★★ 关键1:订单写入博客 D1 ★★★
await db.prepare(
`INSERT INTO orders (pay_id, article_id, amount, pay_type, status, created_at, block)
VALUES (?, ?, ?, ?, 0, ?, ?)`
).bind(payId, articleId, price, type, Date.now(), blockId).run();
// ★★★ 关键2:notifyUrl 用 Cloudflare 原生域名,returnUrl 用优选IP域名 ★★★
const WORKER_NATIVE_ORIGIN = '填写你的博客地址:不能用优选域名';
const notifyUrl = WORKER_NATIVE_ORIGIN + '/pay/notify';
const returnUrl = url.origin + '/pay/return?payId=' + encodeURIComponent(payId);
// 跳转到 V免签
const createUrl = ACCOUNT.vpay.serverUrl + '/createOrder?' + new URLSearchParams({
payId, type, price, sign, param, isHtml: '1', notifyUrl, returnUrl
}).toString();
return Response.redirect(createUrl, 302);
}
6.4 handle_pay_notify(接收异步回调)
async function handle_pay_notify(request, db) {
try {
const url = new URL(request.url);
const params = Object.fromEntries(url.searchParams);
const { payId, param, type, price, reallyPrice, sign } = params;
if (!payId || !type || !price || !sign) {
return new Response('error_param');
}
// 验签:md5(payId + param + type + price + reallyPrice + key)
const rp = reallyPrice || price;
const expectSign = md5(payId + (param || '') + type + price + rp + ACCOUNT.vpay.key);
const expectSign2 = md5(payId + (param || '') + type + price + price + ACCOUNT.vpay.key);
if (sign !== expectSign && sign !== expectSign2) {
return new Response('error_sign');
}
const order = await getOrderByPayId(db, payId);
if (!order) {
// ★ 关键:订单不存在时返回 error_order,不要返回 success
// 否则 V免签会误以为成功,但博客端订单不会被更新
return new Response('error_order');
}
if (order.status === 1) return new Response('success');
// 更新订单状态
await db.prepare(
'UPDATE orders SET status = 1, paid_at = ?, trade_no = ? WHERE pay_id = ?'
).bind(Date.now(), params.tradeNo || '', payId).run();
return new Response('success');
} catch (e) {
console.error('handle_pay_notify error:', e);
return new Response('error');
}
}
6.5 handle_pay_return(接收同步跳转)
async function handle_pay_return(request, db) {
const url = new URL(request.url);
const payId = url.searchParams.get('payId');
if (!payId) return new Response('缺少订单号', { status: 400 });
let order = await getOrderByPayId(db, payId);
if (!order) return new Response('订单不存在', { status: 404 });
// 等待最多 30 秒(原来 3 秒太短)
if (order.status !== 1) {
for (let i = 0; i < 60; i++) {
await new Promise(r => setTimeout(r, 500));
order = await getOrderByPayId(db, payId);
if (order && order.status === 1) break;
}
}
// 种 Cookie:标记此文章已付费
const headers = new Headers();
headers.append('Set-Cookie', `article_${order.article_id}_paid=1; Max-Age=2592000; Path=/`);
if (order.block) {
headers.append('Set-Cookie', `payblock_${order.block}=1; Max-Age=2592000; Path=/`);
}
if (order.status === 1) {
headers.set('Location', `/article/${order.article_id}.html`);
return new Response('', { status: 302, headers });
}
// 支付还没确认,跳转回文章页继续等待
headers.set('Location', `/article/${order.article_id}.html?paying=${payId}`);
return new Response('', { status: 302, headers });
}
6.6 部署博客
cd D:\cfblog
wrangler deploy
七、踩坑全记录(核心经验)
坑 1:Cloudflare Workers 不支持 MD5
现象:crypto.subtle.digest('MD5', ...) 报错 MD5 is not supported。
原因:Workers 的 Web Crypto API 只支持 SHA-1/256/384/512。
解决:用纯 JS 实现的 MD5(网上有很多现成的),把 async function md5() 改成同步的 function md5(),同时去掉所有 await md5(...) 的 await。
坑 2:签名结果是 [object Promise]
现象:URL 里 sign=[object Promise]。
原因:md5() 还是 async 版本,const sign = md5(...) 拿到的是 Promise 对象,没有 await。
解决:改纯 JS 同步 MD5,同时把所有 await md5(...) 改成 md5(...)。
坑 3:1 + 0.02 = 1.02(JS 数字相加)
现象:V免签验签总是失败,sign error。
原因:JS 里 1 + 0.02 是数字相加得到 1.02,但 Python 里 f"{1}{0.02}" 是字符串拼接得到 "10.02"。两边算 MD5 输入不同。
解决:所有参数拼接前强制 String():
const typeStr = String(typeRaw); // "1"
const priceStr = String(priceRaw); // "0.02"
const expectSign = md5(typeStr + priceStr + tStr + vmqKey); // "10.02..."
坑 4:no matching order(匹配不到订单)
现象:appPush 收到推送,但报”未找到匹配订单”。
原因:金额递增机制导致订单价格和实际付款金额不一致。比如:
- 创建订单时 price=0.02
- 因为之前有未支付订单,自动 +0.01 变成 0.03
- 用户扫码付的是 0.02
- 服务端匹配 0.02 找不到
解决:
- 下单前清理过期订单:
UPDATE orders SET state=3 WHERE state=0 - 去掉金额递增逻辑(个人场景不需要并发去重)
坑 5:通知商户超时(8秒)(核心坑)
现象:
- V免签 tail 打印
=== 开始 fetch ===后 8 秒超时 - 博客 tail 完全没收到
/pay/notify请求
原因:优选域名 是优选 IP 域名(走阿里云中转)。
- 用户浏览器访问:走优选 IP → 阿里云中转 → Cloudflare Workers ✅
- Worker 内部 fetch:走 Cloudflare 内部 DNS → 解析到阿里云 IP → 打不到(Worker 出网不走公网)→ 8 秒超时 ❌
解决:Worker 之间 fetch 必须用原生 Workers 域名。
// notifyUrl:给 Worker 用,必须用原生域名
const notifyUrl = 'https://不能填优选域名/pay/notify';
// returnUrl:给用户浏览器用,保持优选 IP 域名
const returnUrl = url.origin + '/pay/return?payId=' + payId;
坑 6:订单状态一直 status=0
现象:V免签 state=1(已支付),但博客 status=0(未支付)。
原因:博客 handle_pay_notify 里,订单找不到时返回了 success(为了防 V免签死循环重试),导致 V免签误以为成功,但博客内部没更新订单。
解决:
if (!order) {
// 返回 error_order,V免签会重试
// 而不是返回 success 让 V免签误以为成功
return new Response('error_order');
}
坑 7:/pay/return 3 秒轮询太短
现象:支付完成,但 /pay/return 查询订单还是 status=0,走”等待页”。
原因:V免签通知博客有几百毫秒延迟,3 秒轮询窗口太小。
解决:轮询时间改成 30 秒:
for (let i = 0; i < 60; i++) {
await new Promise(r => setTimeout(r, 500));
order = await getOrderByPayId(db, payId);
if (order && order.status === 1) break;
}
坑 8:监控端”时间去重”跳过合法付款
现象:Python 控制台反复打印 [OCR] 时间没变(09:21),跳过,付款后不推送。
原因:监控端用时间做去重 key,同一分钟内的多笔付款会被误判为重复。
解决:去掉时间去重,只用 3 秒防抖:
if time.time() - last_push_timestamp < 3:
return None
坑 9:D1 数据库 no such table
现象:登录后台报 D1_ERROR: no such table: config。
原因:wrangler d1 execute 默认操作本地模拟库,线上库是空的。
解决:加 --remote 参数:
wrangler d1 execute vmq-db --file=./schema.sql --remote
八、完整流程验证
一次成功的支付,日志应该是这样:
博客 tail
✅ 博客订单已创建: CF1790131775907326 文章: 000151 金额: 0.02
=== notifyUrl (给V免签Worker调用) === https://博客地址/pay/notify
=== returnUrl (给用户浏览器跳转) === https://博客地址/pay/return?payId=CF1790131775907326
=== 跳转到 V免签 createOrder === https://V免签地址/createOrder?...
GET https://博客地址/pay/notify?... - Ok
=== V免签回调 === {...}
验签 期望1: e1431... 期望2: e1431... 实际: e1431...
=== notify 查订单 === CF1790131775907326 结果: {... "status":0 ...}
✅ V免签支付成功: CF1790131775907326 文章: 000151
GET https://博客地址/pay/return?payId=... - Ok
=== 订单已完成,跳转文章 === 000151
V免签 tail
✅ 订单已支付: V1790131776631957 金额: 0.02
=== 通知商户 URL === https://博客地址/pay/notify?...
=== 开始 fetch ===
=== fetch 返回 === 200 OK
=== fetch 响应内容 === success
✅ 通知商户成功
Python 控制台
=== OCR 原始文本 ===
收款到账通知
09月23日 10:49
¥0.02
====================
[OCR] 识别到金额: 0.02
识别到新收款: ¥0.02
[V免签] 推送成功: 0.02元
九、总结与优化建议
核心优势
- ✅ 零服务器成本:Workers 免费额度足够个人博客使用
- ✅ 零运维:D1 数据库自动备份、Workers 自动扩缩容
- ✅ 全球加速:Cloudflare 边缘节点自动分发
- ✅ 开源透明:所有代码可审计、可二次开发
优化方向
- 订单清理:加定时任务自动清理过期订单
- 管理后台增强:订单统计、收入图表、导出功能
- 多支付方式:同时支持微信 + 支付宝监控
- 通知重试:通知失败后自动重试 3 次
- 安全加固:加 IP 白名单、频率限制
十、参考资料
作者:杨公子
博客:https://blog.hiir.cn
发布时间:2026-09-23
版本:v1.0
💡 如果你在搭建过程中遇到问题,欢迎在博客评论区留言讨论。整套方案已经跑通,祝你搭建顺利!

💬 评论区 0