从零用 Cloudflare Workers + D1 搭建骆驼 IPTV 系统:完整踩坑记录
一篇踩了无数坑的实战复盘,给后来者少走弯路。
项目地址示例:https://iptv.777171.xyz
客户端:杨公子TV(com.qhtv.powerful,versionName6.3.6)
技术栈:Cloudflare Workers + D1 + TypeScript + crypto-js + pako
一、为什么要做这件事
手里有一套 PHP 写的骆驼 IPTV 后台系统(login3.php、data.php、epgadmin.php、channeladmin.php 等),跑在传统 VPS 上。因为:
- 服务器到期、成本高
- 想享受 Cloudflare 的全球加速
- 想用 Serverless 架构省心维护
于是决定用 Cloudflare Workers + D1 重写整套系统。
本文记录从登录到频道加载这一条主链路上踩过的所有坑,包括:
- PHP 逻辑如何翻译成 TypeScript
- AES-ECB 加密如何与 PHP
openssl_encrypt对齐 - gzip 压缩为什么不能用
pako.gzip - Cloudflare WAF / Bot Fight Mode 怎么拦截了 APP 请求
- APP 用
appname而不是数据库配置做密钥 - 版本更新提示怎么屏蔽
二、整体架构对比
| 模块 | 原 PHP 架构 | Workers + D1 架构 |
|---|---|---|
| 运行环境 | LNMP(Linux + Nginx + MySQL + PHP) | V8 隔离沙箱(JS/TS) |
| 数据库 | MySQL | Cloudflare D1(SQLite) |
| 加密 | openssl_encrypt(AES-128-ECB) |
crypto-js AES-ECB |
| 压缩 | gzcompress(zlib 流) |
pako.deflate |
| Session | PHP $_SESSION |
JWT(HMAC-SHA256) |
| 静态资源 | Nginx 直接服务 | Cloudflare Assets 绑定 |
| 定时任务 | crontab | Workers Cron Triggers |
三、踩坑全过程
🕳 坑 1:clientLogin.ts 文件不存在导致 1101
现象:
- APP 请求
login3.php,返回500 error code: 1101 wrangler tail里看不到任何login3.php的日志
原因:
index.ts 里 import { handleClientLogin } from './handlers/clientLogin';,但 src/handlers/ 下根本没有 clientLogin.ts。
esbuild 在构建时不报错,但运行时找不到函数,直接抛 1101。
修复:
新建 src/handlers/clientLogin.ts,导出 handleClientLogin。
🕳 坑 2:Cloudflare WAF / Bot Fight Mode 拦截 APP 请求
现象:
curl打login3.php→ 200 OK- APP 打
login3.php→ 403 Forbidden wrangler tail里看不到这条请求
原因:
APP 的 User-Agent 是 MSIE(伪装的),Cloudflare 的 Browser Integrity Check / Bot Fight Mode 把这个 UA 判定为自动化工具,直接在边缘层返回 403,请求根本没到 Worker。
curl 的 UA 是 curl/8.13.0,Cloudflare 对 curl 反而放行,所以对比测试会误导。
证据:
- 响应头里有
cf-nel/Report-To,只有 Cloudflare 边缘层返回错误才会加 wrangler tail里完全没有login3.php日志- 403 响应体是
error code: 1020(Cloudflare 的 Bot 拦截码)
修复:
- Cloudflare 控制台 → Security → Bots → Bot Fight Mode → OFF
- Security → Settings → Browser Integrity Check → OFF
- Security → Settings → Security Level → Essentially Off
改完后,wrangler tail 里立刻出现 POST .../login3.php - Ok。
🕳 坑 3:APP 用 form-urlencoded,不是 JSON
现象:
data.php 里 await request.json() 抛错:
SyntaxError: Unexpected token 'd', "data={"reg"... is not valid JSON
原因:
APP 发的 Content-Type 是 application/x-www-form-urlencoded,body 是 data={...} 而不是裸 JSON。
修复:
写一个兼容三种格式的请求解析:
let body: any;
const contentType = request.headers.get('Content-Type') || '';
if (contentType.includes('application/json')) {
body = await request.json();
} else if (contentType.includes('application/x-www-form-urlencoded')) {
const form = await request.formData();
body = {};
form.forEach((v, k) => { body[k] = v; });
} else {
const text = await request.text();
try {
body = JSON.parse(text);
} catch {
const params = new URLSearchParams(text);
body = {};
params.forEach((v, k) => { body[k] = v; });
}
}
注意: login3.php 和 data.php 都要加这段。
🕳 坑 4:crypto-js MD5 与 PHP md5() 中文编码不一致
现象:
/debug/encrypt里算出的key跟 PHP 算出的不一致
原因:
一开始用了 Web Crypto API 模拟 MD5(Web Crypto 不支持 MD5,用 SHA-256 代替),结果自然不对。
修复:
直接 import MD5 from 'crypto-js/md5':
import MD5 from 'crypto-js/md5';
export function md5(input: string): string {
return MD5(input).toString();
}
crypto-js 的 MD5 对 UTF-8 中文处理和 PHP 一致。
🕳 坑 5:AES-ECB 不能用 Web Crypto 模拟
现象:
key和shortkey都对齐了- 但
encrypted跟 PHP 不一致
原因:
Web Crypto API 不支持 AES-ECB,只支持 CBC / CTR / GCM / KW。
之前用「CBC + 全零 IV + 单块加密」模拟 ECB:
// ❌ 错误做法
const encrypted = await crypto.subtle.encrypt(
{ name: 'AES-CBC', iv: zeroIV },
keyObj,
block
);
问题: crypto.subtle.encrypt 的 CBC 内部会做一次 PKCS7 填充,你又手动 pkcs7Pad 了一次 → 双重填充,结果跟 PHP 不同。
修复:
用 crypto-js 的 AES-ECB:
import AES from 'crypto-js/aes';
import ECB from 'crypto-js/mode-ecb';
import Pkcs7 from 'crypto-js/pad-pkcs7';
import Utf8 from 'crypto-js/enc-utf8';
export function aesEncrypt(plaintext: string, key: string): string {
const encrypted = AES.encrypt(Utf8.parse(plaintext), Utf8.parse(key), {
mode: ECB,
padding: Pkcs7
});
return encrypted.toString(); // 返回 base64
}
验证方式:
用 PHP 和 Worker 分别加密同一个测试字符串,比对结果:
Worker encrypted = B27oSn/ir3ooA2bNrfidvl8hMieSdibqFPdUQXb3LVM=
PHP encrypted = B27oSn/ir3ooA2bNrfidvl8hMieSdibqFPdUQXb3LVM= ✅
🕳 坑 6:APP 用 appname 而不是数据库配置做密钥
现象:
/debug/encrypt用硬编码"群晖电视"测试,PHP 和 Worker 结果完全一致- 真实登录仍报「登录时发生错误」
原因:
通过 HttpCanary 抓包发现 APP 传的 login 参数是:
{"region":"","mac":"00:db:61:5b:59:f9","androidid":"...","model":"ALN-AL00","nettype":"","appname":"杨公子TV"}
APP 用它自己传上来的 appname 算密钥!
但数据库里是 app_appname = "群晖电视" → 两边密钥不同 → APP 解密失败。
修复:
UPDATE iptv_config SET value = '杨公子TV' WHERE name = 'app_appname';
教训: 数据库里的 app_appname 必须跟 APP 内硬编码的名字一致,否则密钥对不上。
🕳 坑 7:gzcompress 不能用 pako.gzip
现象:
data.php请求进去了,[client data] error也没了- APP 还是「获取节目信息失败」
原因:
PHP 的 gzcompress() 产生的是 zlib 流(RFC 1950),不是 gzip 流(RFC 1952)。
| PHP 函数 | 对应 pako 函数 | 格式 |
|---|---|---|
gzcompress |
pako.deflate |
zlib |
gzencode |
pako.gzip |
gzip |
gzdeflate |
pako.deflateRaw |
raw deflate |
必须用 pako.deflate,不能用 pako.gzip。
修复:
import { deflate } from 'pako';
const compressed = deflate(jsonStr);
const base64Str = uint8ToBase64(compressed);
注意: pako 的 ESM 版本没有 default 导出,必须命名导入:
// ❌ 错误
import pako from 'pako';
// ✅ 正确
import { deflate } from 'pako';
🕳 坑 8:data.php 加密逻辑跟 login3.php 完全不同
原 PHP login3.php 的加密流程:
$key = md5($sig.$appname.$packagename."AD80F93B542B");
$key = md5($key.$appname.$packagename);
$shortkey = substr($key, 5, 16); // 起点 5
$encrypted = openssl_encrypt($json, 'AES-128-ECB', $shortkey, 0, '');
echo $encrypted;
原 PHP data.php 的加密流程:
$str = json_encode($contents, JSON_UNESCAPED_UNICODE);
$str = preg_replace('#null,#', '', $str);
$str = base64_encode(gzcompress($str)); // ★ gzip + base64
$key = md5($key.$randkey); // ★ 加 randkey
$key = substr($key, 7, 16); // ★ 起点 7
$encrypted = openssl_encrypt($str, 'AES-128-ECB', $key, 0, '');
// ★ 字符替换
$encrypted = str_replace("f", "&", $encrypted);
$encrypted = str_replace("b", "f", $encrypted);
$encrypted = str_replace("&", "b", $encrypted);
$encrypted = str_replace("t", "#", $encrypted);
$encrypted = str_replace("y", "t", $encrypted);
$encrypted = str_replace("#", "y", $encrypted);
// ★ substr + strrev
$coded = substr($encrypted, 44, 128);
$coded = strrev($coded);
$str = $coded . $encrypted;
echo $str;
对应 TypeScript:
let jsonStr = JSON.stringify(categories);
jsonStr = jsonStr.replace(/null,/g, '');
const compressed = deflate(jsonStr);
const base64Str = uint8ToBase64(compressed);
let key = md5(`${sig}${appAppname}${packageName}AD80F93B542B`);
key = md5(`${key}${appAppname}${packageName}`);
key = md5(`${key}${randkey}`); // ★ 加 randkey
const shortKey = key.substring(7, 23); // ★ 起点 7
let encrypted = aesEncrypt(base64Str, shortKey);
// ★ 字符替换
encrypted = encrypted.replace(/f/g, '&');
encrypted = encrypted.replace(/b/g, 'f');
encrypted = encrypted.replace(/&/g, 'b');
encrypted = encrypted.replace(/t/g, '#');
encrypted = encrypted.replace(/y/g, 't');
encrypted = encrypted.replace(/#/g, 'y');
// ★ substr(44, 128) + strrev + 拼接
const coded = encrypted.substring(44, 44 + 128).split('').reverse().join('');
const finalStr = coded + encrypted;
顺序不能乱,任何一处差异都会导致 APP 解不出来。
🕳 坑 9:版本更新提示怎么屏蔽
现象:
APP 启动后弹「版本更新」对话框。
原因:
getver.php 返回的 appver = "7.7.7.8",比 APP 本地版本 6.3.6 高。
修复:
UPDATE iptv_config SET value = '6.3.6' WHERE name = 'appver';
UPDATE iptv_config SET value = '' WHERE name = 'appurl';
要点: appver 改成和 APP 本地版本完全一致的值(6.3.6),appurl 清空(即使 APP 想更新也下不了)。
四、验证加密是否对齐的万能方法
任何加密相关的问题,都用这个方法定位:
第 1 步:写一个 PHP 测试脚本
用 https://onecompiler.com/php 或 https://3v4l.org/:
<?php
$shortkey = '6ee44eb901a69c76';
$test = '{"test":"hello","num":123}';
$enc = openssl_encrypt($test, 'AES-128-ECB', $shortkey, 0, '');
echo "PHP encrypted = " . $enc . "\n";
第 2 步:在 Worker 里加一个 /debug/encrypt 路由
if (path === '/debug/encrypt') {
const { md5, aesEncrypt } = await import('./crypto');
const sig = '12315';
const appname = '群晖电视';
const packagename = 'com.qhtv.powerful';
let key = md5(`${sig}${appname}${packagename}AD80F93B542B`);
key = md5(`${key}${appname}${packagename}`);
const shortkey = key.substring(5, 21);
const test = '{"test":"hello","num":123}';
const enc = aesEncrypt(test, shortkey);
return new Response(JSON.stringify({ key, shortkey, encrypted: enc }, null, 2));
}
第 3 步:对比三项
| 对比项 | 一致 | 不一致 |
|---|---|---|
key |
MD5 + 拼接顺序一致 | 中文编码或拼接顺序问题 |
shortkey |
substr 起点/长度一致 |
起点错了 |
encrypted |
AES 模式/填充/密钥一致 | AES 实现不对 |
这个对比是定位加密问题最快的方法。
五、最终的目录结构
iptv-worker/
├── src/
│ ├── index.ts # Worker 入口 + 路由
│ ├── crypto.ts # md5 + AES-ECB(crypto-js)
│ ├── db.ts # D1 封装
│ └── handlers/
│ ├── clientLogin.ts # login3.php 重写
│ ├── clientData.ts # data.php 重写
│ ├── clientMisc.ts # getver/bg/getIpInfo
│ ├── adminAuth.ts # 后台登录
│ ├── adminDashboard.ts # 后台首页
│ ├── adminUsers.ts # 用户管理
│ ├── adminAuthor.ts # 授权管理
│ ├── adminChannel.ts # 频道管理
│ ├── adminEpg.ts # EPG 管理
│ ├── adminMeal.ts # 套餐管理
│ ├── adminMovie.ts # 点播管理
│ ├── adminOrder.ts # 订单管理
│ ├── adminSys.ts # 系统设置
│ └── adminException.ts # 异常管理
├── public/ # 静态资源(CSS/JS/图片)
├── schema.sql # D1 建表 + 初始化数据
├── wrangler.toml
└── package.json
六、wrangler.toml 关键配置
name = "iptv-worker"
main = "src/index.ts"
compatibility_date = "2024-09-23"
[[d1_databases]]
binding = "DB"
database_name = "iptv_db"
database_id = "你的数据库ID"
[vars]
PANEL_MD5_KEY = "tvkey_"
[assets]
directory = "./public"
binding = "ASSETS"
七、几个必须记住的坑
| 坑 | 现象 | 解法 |
|---|---|---|
| Cloudflare WAF 拦 APP | curl 能过,APP 403 | 关 Bot Fight Mode + Browser Integrity Check |
| APP 用 MSIE UA | 被 Cloudflare 判定为 Bot | 同上 |
| form-urlencoded 不是 JSON | Unexpected token 'd' |
兼容三种 Content-Type |
| Web Crypto 不支持 AES-ECB | 加密结果不一致 | 用 crypto-js |
gzcompress ≠ pako.gzip |
APP 解不出来 | 用 pako.deflate |
APP 用 appname 做密钥 |
解密失败 | 数据库 app_appname 改成 APP 内硬编码值 |
| data.php 加密流程跟 login3.php 不同 | APP 解密失败 | 加 randkey、substr(7,16)、字符替换、substr+strrev |
| pako 无 default 导出 | Build failed | import { deflate } from 'pako' |
| 版本更新提示 | APP 弹更新 | appver 改成 APP 版本号,appurl 清空 |
八、调试工具清单
| 工具 | 用途 |
|---|---|
npx wrangler tail --format pretty |
实时看 Worker 日志 |
| HttpCanary(安卓) | 抓 APP 请求 |
| curl(Windows cmd) | 手动模拟请求 |
| https://onecompiler.com/php | 在线跑 PHP 加密脚本对比 |
| Cloudflare D1 Console | 直接执行 SQL |
/debug/encrypt 路由 |
对比加密结果 |
九、总结
整个项目从零到跑通,最大的时间黑洞是加密对齐:
- MD5 中文编码(crypto-js vs Web Crypto)
- AES-ECB 模式(Web Crypto 不支持,必须用 crypto-js)
gzcompress的格式(zlib 而不是 gzip)- 字符替换顺序(f→&→b→f 顺序不能乱)
- substr 起点(login3 用 5,data 用 7)
第二大坑是 Cloudflare 的边缘拦截——因为 APP 用的是 MSIE UA,被 Bot Fight Mode 拦了。这个坑最难查,因为:
curl能通,误导你以为 Worker 没问题wrangler tail里看不到请求,误导你以为没到 Worker- 403 响应里有
cf-nel,是 Cloudflare 边缘层返回的标志
遇到类似问题,先看 wrangler tail 里有没有这条请求的日志:
- 有 → Worker 收到了,问题在 Worker 代码
- 没有 → Cloudflare 边缘拦的,去关 WAF / Bot 防护
希望这篇踩坑记录能帮到后续做类似项目的人。
附录:常用 SQL 速查
-- 查看关键配置
SELECT name, value FROM iptv_config
WHERE name IN ('app_sign','app_appname','app_packagename','randkey','appver','appurl');
-- 改 app_appname(对齐 APP 内硬编码)
UPDATE iptv_config SET value = '杨公子TV' WHERE name = 'app_appname';
-- 屏蔽版本更新
UPDATE iptv_config SET value = '6.3.6' WHERE name = 'appver';
UPDATE iptv_config SET value = '' WHERE name = 'appurl';
-- 查用户
SELECT name, mac, deviceid, status, exp, meal FROM iptv_users LIMIT 10;
-- 查频道分类
SELECT name, type, enable FROM iptv_category ORDER BY id;
-- 查套餐
SELECT id, name, amount, days, content, status FROM iptv_meals;
完。 后续遇到新问题,直接翻这篇文档查。

💬 评论区 0