✕
开启精彩搜索
热门搜索
emby EMBY 解析 免流 源码 IPV6 阿里 阿里云 青龙 音乐 网心 网心云 webdav 阿里云盘 unraid 洗白 Rclone 刮削 云梦

Cloudflare Workers 零成本搭建 V免签个人收款系统(实战教程)

本文记录一次从零到一,用 Cloudflare Workers + D1 数据库搭建 V免签支付系统的完整过程,包括监控端(Python + OCR)、服务端(Workers + D1)、博客端(对接支付)三部分的部署与踩坑全记录。


一、前言:为什么要自己搭 V免签

个人博客想要做”付费文章”功能,通常有两条路:

  1. 申请官方支付接口:需要企业资质、营业执照,个人开发者走不通
  2. 用第三方支付平台:有手续费、有跑路风险、体验差

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 找不到

解决:

  1. 下单前清理过期订单:UPDATE orders SET state=3 WHERE state=0
  2. 去掉金额递增逻辑(个人场景不需要并发去重)

坑 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 边缘节点自动分发
  • ✅ 开源透明:所有代码可审计、可二次开发

优化方向

  1. 订单清理:加定时任务自动清理过期订单
  2. 管理后台增强:订单统计、收入图表、导出功能
  3. 多支付方式:同时支持微信 + 支付宝监控
  4. 通知重试:通知失败后自动重试 3 次
  5. 安全加固:加 IP 白名单、频率限制

十、参考资料


作者:杨公子
博客:https://blog.hiir.cn
发布时间:2026-09-23
版本:v1.0

💡 如果你在搭建过程中遇到问题,欢迎在博客评论区留言讨论。整套方案已经跑通,祝你搭建顺利!

💬 评论区 0