diy-artitalk-server

项目背景:

由于LeanCloud已于2026年1月12日起开始逐步停止服务,Artitalk暂时没有更新,所以本才成立此项目


🗺️ 整体迁移架构

博客(Hexo + Meow主题)保持不变,但Artitalk的初始化配置需指向自己自行部署的Cloudflare Worker。整个流程变为:
博客前端 → Cloudflare Worker (适配层) → TiDB Cloud (MySQL数据库)

该架构将原本依赖LeanCloud的服务完全替换为自主可控的开源技术栈,同时利用Cloudflare的全球边缘网络降低延迟,利用TiDB Serverless的自动弹性与免费额度控制成本。


📝 分步实施指南

第一步:TiDB Cloud 数据库准备

1. 创建集群

  • 登录 TiDB Cloud Console,创建一个 Serverless Tier 集群。
  • 选择区域时,建议与Cloudflare Worker的部署区域接近(例如 aws-us-east-1),以减少跨区网络延迟。
  • 获取集群的连接信息:Host、端口(默认为4000)、用户名、密码。

2. 建库建表
连接集群后(可使用MySQL客户端或TiDB Cloud自带的Web SQL工具),创建数据库与表结构。需完全复现LeanCloud中三个Class的字段定义。以下是推荐的表结构定义,已添加必要索引及字符集设置。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
CREATE DATABASE artitalk_db CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
USE artitalk_db;

-- 说说表 (对应 LeanCloud 的 shuoshuo Class)
CREATE TABLE `shuoshuo` (
`objectId` VARCHAR(32) NOT NULL, -- LeanCloud 格式的 objectId,建议使用 UUID 或雪花算法生成
`content` TEXT NOT NULL,
`img` JSON DEFAULT NULL, -- 可能包含多张图片的URL数组,用JSON存储
`like` INT DEFAULT 0,
`comments` JSON DEFAULT NULL, -- 评论列表,可按需结构化
`createdAt` TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
`updatedAt` TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
`ACL` JSON DEFAULT NULL, -- 可选,若用到LeanCloud ACL
PRIMARY KEY (`objectId`),
INDEX `idx_createdAt` (`createdAt`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;

-- 评论表 (对应 atComment Class)
CREATE TABLE `atComment` (
`objectId` VARCHAR(32) NOT NULL,
`content` TEXT NOT NULL,
`nickname` VARCHAR(100) DEFAULT NULL,
`email` VARCHAR(255) DEFAULT NULL,
`url` VARCHAR(500) DEFAULT NULL,
`shuoshuoId` VARCHAR(32) NOT NULL, -- 关联的说说的 objectId
`createdAt` TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
`updatedAt` TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
`ACL` JSON DEFAULT NULL,
PRIMARY KEY (`objectId`),
INDEX `idx_shuoshuoId` (`shuoshuoId`),
INDEX `idx_createdAt` (`createdAt`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;

-- 用户表 (对应 _User Class)
CREATE TABLE `_User` (
`objectId` VARCHAR(32) NOT NULL,
`username` VARCHAR(100) NOT NULL UNIQUE,
`password` VARCHAR(64) NOT NULL, -- 存储加密后的密码,见下文
`email` VARCHAR(255) DEFAULT NULL,
`img` VARCHAR(500) DEFAULT NULL, -- 用户头像URL
`sessionToken` VARCHAR(64) DEFAULT NULL, -- 可选,用于管理登录态
`createdAt` TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
`updatedAt` TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
`ACL` JSON DEFAULT NULL,
PRIMARY KEY (`objectId`),
UNIQUE INDEX `idx_username` (`username`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;

3. 初始化用户数据

  • 插入一条管理员用户记录,密码需使用与LeanCloud相同的加密方式。LeanCloud默认使用加盐MD5md5(md5(password) + salt),其中salt通常为随机字符串,存储在_User表的salt字段中。需要确认原有LeanCloud的salt存储方式(可通过导出数据查看)。
  • 若没有现有数据,可自行生成一条记录。以下示例假设salt字段单独存储,也可以在_User表中增加salt列(若LeanCloud有此字段)。
1
2
3
4
5
-- 假设盐值为随机字符串 'abc123',密码原文为 'mypassword'
-- 计算过程:md5(md5('mypassword')) = '5f4dcc3b5aa765d61d8327deb882cf99',再与盐拼接后做第二次md5
-- 实际加密方式请务必通过抓包或导出原数据确认
INSERT INTO `_User` (`objectId`, `username`, `password`, `salt`, `img`, `sessionToken`)
VALUES ('5a7b8c9d0e1f2a3b4c5d6e7f', 'admin', '9d0e1f2a3b4c5d6e7f8a9b0c1d2e3f4a', 'abc123', 'https://example.com/avatar.jpg', NULL);

注意:密码加密逻辑是认证的关键,强烈建议先通过浏览器抓取一次正常LeanCloud登录的请求,分析其_User表结构及密码字段的生成规则。


📦 第二步:Cloudflare Worker 开发与部署

有两种方法:1.本地Wrangler部署 2.上传cloudflare的worker部署

2.1 准备工作

方法一

2.1.1 安装工具与创建项目

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
# 安装 Wrangler CLI(若未安装)
npm install -g wrangler

# 登录 Cloudflare 账号
wrangler login

# 创建项目目录
mkdir artitalk-proxy
cd artitalk-proxy

# 初始化 TypeScript 项目
npm init -y
npm install --save-dev typescript @types/node wrangler
npm install mysql2 @types/mysql2 # MySQL 驱动
npm install nanoid # 生成 objectId(可选)
npm install itty-router # 轻量路由(可选,本方案手工路由)

# 创建 tsconfig.json
npx tsc --init --target es2021 --module es2022 --moduleResolution node --outDir dist --rootDir src --strict

2.1.2 配置 Wrangler

创建 wrangler.toml

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
name = "artitalk-proxy"
main = "src/index.ts"
compatibility_date = "2025-02-20"
compatibility_flags = ["nodejs_compat"]

# 环境变量(生产环境通过 wrangler secret 设置)
[vars]
TIDB_HOST = "your-tidb-host"
TIDB_PORT = "4000"
TIDB_USER = "your-user"
TIDB_DATABASE = "artitalk_db"
# PASSWORD_SALT 可选,当用户无独立 salt 时使用
ALLOWED_ORIGIN = "https://your-blog.com" # 严格限制 CORS

# 开发环境配置(可覆盖)
[env.production]
vars = { TIDB_HOST = "prod-tidb-host" }

2.1.3 设置敏感变量(通过 wrangler secret)

1
2
wrangler secret put TIDB_PASSWORD
wrangler secret put PASSWORD_SALT # 如果使用全局盐

2.2 Worker 核心代码

创建 src/index.ts,包含完整的路由、认证、CRUD 实现。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
import { createPool, Pool, RowDataPacket, OkPacket, ResultSetHeader } from 'mysql2/promise';
import { customAlphabet } from 'nanoid'; // 用于生成类似 LeanCloud 的 objectId

// 环境变量类型声明
export interface Env {
TIDB_HOST: string;
TIDB_PORT: string;
TIDB_USER: string;
TIDB_PASSWORD: string;
TIDB_DATABASE: string;
PASSWORD_SALT?: string; // 全局盐(当用户无独立盐时使用)
ALLOWED_ORIGIN?: string; // 允许的 CORS 来源,默认为 '*'
}

// 全局连接池(每个 isolate 复用)
let pool: Pool;

function getPool(env: Env): Pool {
if (!pool) {
pool = createPool({
host: env.TIDB_HOST,
port: parseInt(env.TIDB_PORT) || 4000,
user: env.TIDB_USER,
password: env.TIDB_PASSWORD,
database: env.TIDB_DATABASE,
waitForConnections: true,
connectionLimit: 5, // Worker 并发限制,通常 5-10 足够
queueLimit: 0,
enableKeepAlive: true,
keepAliveInitialDelay: 0,
});
}
return pool;
}

// 生成类似 LeanCloud 的 objectId(10 位大小写字母+数字)
const generateObjectId = customAlphabet('0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ', 10);

// 辅助:统一 JSON 响应 + CORS 头
function jsonResponse(data: any, status = 200, headers: Record<string, string> = {}): Response {
const corsHeaders = {
'Access-Control-Allow-Origin': '*', // 可在环境变量中限制
'Access-Control-Allow-Methods': 'GET, POST, PUT, DELETE, OPTIONS',
'Access-Control-Allow-Headers': 'Content-Type, X-LC-Id, X-LC-Key, X-LC-Session',
'Access-Control-Max-Age': '86400',
'Content-Type': 'application/json',
...headers,
};
return new Response(JSON.stringify(data), { status, headers: corsHeaders });
}

// 错误响应(符合 LeanCloud 格式)
function errorResponse(message: string, code = 500, status = 500): Response {
return jsonResponse({ code, error: message }, status);
}

// 验证 sessionToken
async function authenticate(request: Request, pool: Pool): Promise<string | null> {
const sessionToken = request.headers.get('X-LC-Session');
if (!sessionToken) return null;
const [rows] = await pool.execute<RowDataPacket[]>(
'SELECT objectId FROM _User WHERE sessionToken = ?',
[sessionToken]
);
return rows.length > 0 ? rows[0].objectId : null;
}

// 解析 LeanCloud 查询参数(简化版,支持 limit, skip, order)
function parseQueryParams(url: URL): { sql: string; params: any[] } {
const { searchParams } = url;
let sql = '';
const params: any[] = [];

// 处理 order
const order = searchParams.get('order');
if (order) {
const dir = order.startsWith('-') ? 'DESC' : 'ASC';
const field = order.replace(/^-/, '');
sql += ` ORDER BY ${field} ${dir}`;
}

// 处理 limit
const limit = searchParams.get('limit');
if (limit) {
sql += ' LIMIT ?';
params.push(parseInt(limit, 10));
}

// 处理 skip (offset)
const skip = searchParams.get('skip');
if (skip) {
sql += ' OFFSET ?';
params.push(parseInt(skip, 10));
}

// where 条件暂不实现,可后续按需扩展
return { sql, params };
}

// 密码验证:假设 LeanCloud 算法为 md5(md5(password) + salt)
async function verifyPassword(inputPwd: string, storedPwd: string, salt: string | null, globalSalt?: string): Promise<boolean> {
const crypto = require('crypto');
const md5 = (s: string) => crypto.createHash('md5').update(s).digest('hex');
const saltToUse = salt || globalSalt || '';
const hashed = md5(md5(inputPwd) + saltToUse);
return hashed === storedPwd;
}

// ---------- 路由处理 ----------
export default {
async fetch(request: Request, env: Env): Promise<Response> {
const url = new URL(request.url);
const path = url.pathname;
const method = request.method;

// CORS 预检
if (method === 'OPTIONS') {
return new Response(null, {
headers: {
'Access-Control-Allow-Origin': env.ALLOWED_ORIGIN || '*',
'Access-Control-Allow-Methods': 'GET, POST, PUT, DELETE, OPTIONS',
'Access-Control-Allow-Headers': 'Content-Type, X-LC-Id, X-LC-Key, X-LC-Session',
'Access-Control-Max-Age': '86400',
},
});
}

// 获取数据库连接池
let pool: Pool;
try {
pool = getPool(env);
} catch (e) {
console.error('Failed to create DB pool', e);
return errorResponse('Database connection failed', 503, 503);
}

try {
// 健康检查
if (path === '/health') {
await pool.execute('SELECT 1');
return jsonResponse({ status: 'ok' });
}

// 登录
if (path === '/1.1/login' && method === 'POST') {
return handleLogin(request, pool, env);
}

// 说说路由
if (path.startsWith('/1.1/classes/shuoshuo')) {
return handleShuoshuo(request, pool, path, url);
}

// 评论路由
if (path.startsWith('/1.1/classes/atComment')) {
return handleComment(request, pool, path, url);
}

// 用户相关(暂只支持登录,不开放注册)
if (path === '/1.1/users' && method === 'POST') {
return errorResponse('User registration not supported', 400, 400);
}

return errorResponse('Not found', 404, 404);
} catch (err) {
console.error('Unhandled error', err);
return errorResponse('Internal server error', 500, 500);
}
},
};

// ---------- 登录处理 ----------
async function handleLogin(request: Request, pool: Pool, env: Env): Promise<Response> {
try {
const body = await request.json<{ username: string; password: string }>();
const { username, password } = body;

const [rows] = await pool.execute<RowDataPacket[]>(
'SELECT objectId, username, password, salt, img, sessionToken FROM _User WHERE username = ?',
[username]
);
if (rows.length === 0) {
return errorResponse('Invalid username or password', 401, 401);
}

const user = rows[0];
const isValid = await verifyPassword(password, user.password, user.salt || null, env.PASSWORD_SALT);
if (!isValid) {
return errorResponse('Invalid username or password', 401, 401);
}

// 生成新 sessionToken
const crypto = require('crypto');
const sessionToken = crypto.randomBytes(32).toString('hex');
await pool.execute('UPDATE _User SET sessionToken = ? WHERE objectId = ?', [sessionToken, user.objectId]);

// 返回 LeanCloud 格式
return jsonResponse({
objectId: user.objectId,
username: user.username,
img: user.img,
sessionToken,
createdAt: user.createdAt,
updatedAt: user.updatedAt,
});
} catch (e) {
console.error('Login error', e);
return errorResponse('Login failed', 500, 500);
}
}

// ---------- 说说处理 ----------
async function handleShuoshuo(request: Request, pool: Pool, path: string, url: URL): Promise<Response> {
const objectId = path.replace('/1.1/classes/shuoshuo', '').replace(/^\//, '');
const method = request.method;

// 写操作需要认证
if (['POST', 'PUT', 'DELETE'].includes(method)) {
const userId = await authenticate(request, pool);
if (!userId) {
return errorResponse('Unauthorized', 401, 401);
}
}

try {
switch (method) {
case 'GET':
if (objectId && objectId.length > 0) {
// 获取单个
const [rows] = await pool.execute<RowDataPacket[]>('SELECT * FROM shuoshuo WHERE objectId = ?', [objectId]);
if (rows.length === 0) return errorResponse('Object not found', 404, 404);
return jsonResponse(rows[0]);
} else {
// 批量查询
let sql = 'SELECT * FROM shuoshuo';
const { sql: orderLimit, params } = parseQueryParams(url);
sql += orderLimit;
const [rows] = await pool.execute<RowDataPacket[]>(sql, params);
return jsonResponse({ results: rows });
}

case 'POST': {
const body = await request.json<Record<string, any>>();
const newId = generateObjectId();
const now = new Date().toISOString().slice(0, 19).replace('T', ' ');
await pool.execute(
`INSERT INTO shuoshuo
(objectId, content, img, \`like\`, comments, createdAt, updatedAt, ACL)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
[
newId,
body.content || '',
body.img ? JSON.stringify(body.img) : null,
body.like || 0,
body.comments ? JSON.stringify(body.comments) : null,
now,
now,
body.ACL ? JSON.stringify(body.ACL) : null,
]
);
return jsonResponse({ objectId: newId, createdAt: now, updatedAt: now });
}

case 'PUT': {
if (!objectId) return errorResponse('objectId required', 400, 400);
const body = await request.json<Record<string, any>>();
const sets: string[] = [];
const values: any[] = [];
for (const [key, value] of Object.entries(body)) {
if (key !== 'objectId' && key !== 'createdAt' && key !== 'updatedAt') {
sets.push(`\`${key}\` = ?`);
// 如果是 img 或 comments 等 JSON 字段,需要序列化
if (key === 'img' || key === 'comments' || key === 'ACL') {
values.push(value ? JSON.stringify(value) : null);
} else {
values.push(value);
}
}
}
if (sets.length === 0) return errorResponse('No fields to update', 400, 400);
values.push(objectId);
await pool.execute(`UPDATE shuoshuo SET ${sets.join(', ')}, updatedAt = NOW() WHERE objectId = ?`, values);
return jsonResponse({ updatedAt: new Date().toISOString() });
}

case 'DELETE': {
if (!objectId) return errorResponse('objectId required', 400, 400);
await pool.execute('DELETE FROM shuoshuo WHERE objectId = ?', [objectId]);
return jsonResponse({});
}

default:
return errorResponse('Method not allowed', 405, 405);
}
} catch (e) {
console.error('Shuoshuo error', e);
return errorResponse('Database operation failed', 500, 500);
}
}

// ---------- 评论处理 ----------
async function handleComment(request: Request, pool: Pool, path: string, url: URL): Promise<Response> {
const objectId = path.replace('/1.1/classes/atComment', '').replace(/^\//, '');
const method = request.method;

// 写操作需要认证(可根据需要开放匿名评论,通过环境变量控制)
if (['POST', 'PUT', 'DELETE'].includes(method)) {
const userId = await authenticate(request, pool);
if (!userId) {
return errorResponse('Unauthorized', 401, 401);
}
}

try {
switch (method) {
case 'GET':
if (objectId && objectId.length > 0) {
const [rows] = await pool.execute<RowDataPacket[]>('SELECT * FROM atComment WHERE objectId = ?', [objectId]);
if (rows.length === 0) return errorResponse('Object not found', 404, 404);
return jsonResponse(rows[0]);
} else {
let sql = 'SELECT * FROM atComment';
const { sql: orderLimit, params } = parseQueryParams(url);
sql += orderLimit;
const [rows] = await pool.execute<RowDataPacket[]>(sql, params);
return jsonResponse({ results: rows });
}

case 'POST': {
const body = await request.json<Record<string, any>>();
const newId = generateObjectId();
const now = new Date().toISOString().slice(0, 19).replace('T', ' ');
await pool.execute(
`INSERT INTO atComment
(objectId, content, nickname, email, url, shuoshuoId, createdAt, updatedAt, ACL)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`,
[
newId,
body.content || '',
body.nickname || null,
body.email || null,
body.url || null,
body.shuoshuoId || null,
now,
now,
body.ACL ? JSON.stringify(body.ACL) : null,
]
);
return jsonResponse({ objectId: newId, createdAt: now, updatedAt: now });
}

case 'PUT': {
if (!objectId) return errorResponse('objectId required', 400, 400);
const body = await request.json<Record<string, any>>();
const sets: string[] = [];
const values: any[] = [];
for (const [key, value] of Object.entries(body)) {
if (key !== 'objectId' && key !== 'createdAt' && key !== 'updatedAt') {
sets.push(`\`${key}\` = ?`);
values.push(value);
}
}
if (sets.length === 0) return errorResponse('No fields to update', 400, 400);
values.push(objectId);
await pool.execute(`UPDATE atComment SET ${sets.join(', ')}, updatedAt = NOW() WHERE objectId = ?`, values);
return jsonResponse({ updatedAt: new Date().toISOString() });
}

case 'DELETE': {
if (!objectId) return errorResponse('objectId required', 400, 400);
await pool.execute('DELETE FROM atComment WHERE objectId = ?', [objectId]);
return jsonResponse({});
}

default:
return errorResponse('Method not allowed', 405, 405);
}
} catch (e) {
console.error('Comment error', e);
return errorResponse('Database operation failed', 500, 500);
}
}

2.3 部署与验证

2.3.1 安装依赖并构建

1
2
npm install
npm run build # 若 package.json 中配置了 "build": "tsc"

2.3.2 本地测试(可选)

使用 wrangler dev 启动本地开发服务器,模拟 Worker 环境:

1
wrangler dev --env dev

需要本地能访问 TiDB Cloud,注意防火墙设置。

2.3.3 部署到生产

1
2
3
4
5
6
# 设置生产环境敏感变量
wrangler secret put TIDB_PASSWORD --env production
wrangler secret put PASSWORD_SALT --env production # 如果需要

# 部署
wrangler deploy --env production

2.3.4 验证

  • 访问 https://artitalk-proxy.your-subdomain.workers.dev/health 应返回 {"status":"ok"}
  • 使用 API 工具测试登录:POST /1.1/login{"username":"admin","password":"..."}
  • 测试说说增删改查。

2.4 生产环境增强配置

2.4.1 连接池调优

根据 Worker 并发数调整 connectionLimit,一般 5-10 足够。TiDB Serverless 有连接数限制(当前 100),无需过大。

2.4.2 缓存策略

对于频繁读取的说说列表,可在 Worker 中使用 caches.default 缓存 GET 响应,设置适当的 Cache-Control 头。但需注意数据更新后及时失效(可通过更新时删除缓存实现)。此处未实现,可按需添加。

2.4.3 限流保护

可集成 rate-limiter 中间件,防止恶意请求耗尽免费额度。

2.4.4 日志与监控

Cloudflare Dashboard 提供请求日志,也可将错误日志发送到 Sentry:

1
// 在 catch 块中添加 Sentry 上报

2.4.5 备份与恢复

TiDB Cloud 自动备份,但建议定期使用 mysqldump 导出数据到 R2 存储。


🔧 完整项目文件清单

创建以下文件,放置于同一目录:

  1. package.json
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
{
"name": "artitalk-proxy",
"version": "1.0.0",
"scripts": {
"build": "tsc",
"dev": "wrangler dev",
"deploy": "wrangler deploy"
},
"devDependencies": {
"@types/node": "^20.0.0",
"typescript": "^5.0.0",
"wrangler": "^3.0.0"
},
"dependencies": {
"mysql2": "^3.9.0",
"nanoid": "^5.0.0"
}
}
  1. tsconfig.json
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
{
"compilerOptions": {
"target": "es2021",
"module": "es2022",
"moduleResolution": "node",
"outDir": "./dist",
"rootDir": "./src",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true
},
"include": ["src/**/*"],
"exclude": ["node_modules", "dist"]
}
  1. wrangler.toml(见上文)

  2. src/index.ts(见上文)


✅ 验证清单(生产环境)

  • 数据库表结构与索引正确创建(使用文档第一步的 SQL)。
  • 用户表中存在初始管理员,且 salt 字段正确(若原 LeanCloud 使用独立 salt)。
  • 环境变量 ALLOWED_ORIGIN 设置为博客域名,防止 CSRF。
  • 在博客配置中将 serverURL 指向 Worker 域名。
  • 测试所有 API 端点,包括分页、排序、登录、写操作。
  • 监控 Worker 错误率,设置告警。

方法二

✅ 修正要点(基于方法一)

  1. 登录接口返回敏感信息
    handleLogin 直接返回了整个 user 对象,包含了 passwordsalt。现已改为仅返回必要字段(objectId, username, img, sessionToken, createdAt, updatedAt)。

  2. CORS 响应头未使用 ALLOWED_ORIGIN
    jsonResponse 中硬编码了 Access-Control-Allow-Origin: '*',导致环境变量设置失效。现已改为从请求上下文动态获取 env.ALLOWED_ORIGIN

  3. PUT 请求中 JSON 字段未序列化
    handleShuoshuohandleComment 的 PUT 处理中,对 imgcommentsACL 等 JSON 类型字段未调用 JSON.stringify,可能导致数据库存储错误。现已在循环中增加类型判断并自动序列化。

  4. 密码加密依赖明确
    保留了 require('crypto') 以支持 MD5,确保与 LeanCloud 算法兼容(需用户确认原 salt 策略)。若使用上传部署,需确保兼容性标志开启。

  5. 环境变量传递一致性
    所有需要读取 env.ALLOWED_ORIGIN 的地方均已正确传递。


📦 新增:通过 Cloudflare Dashboard 上传部署(替代 wrangler)

若你希望直接在 Cloudflare Web 界面创建 Worker,请按以下步骤操作。

1. 准备打包后的 Worker 脚本

在本地项目目录中安装依赖并打包:

1
2
3
4
5
# 安装 esbuild(若未安装)
npm install --save-dev esbuild

# 打包 TypeScript 入口,生成单个文件 dist/worker.js
npx esbuild src/index.ts --bundle --platform=node --target=es2021 --outfile=dist/worker.js

说明

  • --platform=node 使 esbuild 包含 Node.js 兼容模块(如 cryptonet)。
  • 打包后的文件包含所有依赖(mysql2nanoid),可直接上传。

2. 在 Cloudflare Dashboard 创建 Worker

  • 登录 Cloudflare Dashboard → Workers 和 Pages → 创建应用程序 → 创建 Worker。
  • dist/worker.js 的全部内容复制到在线编辑器中,覆盖默认代码。
  • 点击“保存并部署”。

3. 设置环境变量与兼容性标志

在 Worker 的 设置 页面:

  • 变量 → 环境变量:添加以下键值对(生产环境值)

    • TIDB_HOST:TiDB 集群主机
    • TIDB_PORT:4000
    • TIDB_USER:数据库用户名
    • TIDB_DATABASEartitalk_db
    • ALLOWED_ORIGIN:你的博客域名(如 https://yourblog.com
    • (可选)PASSWORD_SALT:若用户表无独立 salt,可设置全局盐
  • 变量 → 加密环境变量(敏感信息):

    • TIDB_PASSWORD:数据库密码
    • (可选)PASSWORD_SALT(如果已作为普通变量设置,则无需重复)
  • 兼容性标志:在“常规”设置中添加 nodejs_compat 标志(必须,否则 crypto 等模块不可用)。

4. 验证部署

  • 访问 https://你的worker子域名.workers.dev/health,应返回 {"status":"ok"}
  • 使用 Postman 或浏览器测试登录接口 POST /1.1/login 及说说增删改查。

📝 修正后的核心代码(src/index.ts)

为保持文档简洁,此处仅列出需替换的完整文件内容。你只需将以下代码保存为 src/index.ts,再按上述步骤打包上传即可。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
import { createPool, Pool, RowDataPacket } from 'mysql2/promise';
import { customAlphabet } from 'nanoid';

export interface Env {
TIDB_HOST: string;
TIDB_PORT: string;
TIDB_USER: string;
TIDB_PASSWORD: string;
TIDB_DATABASE: string;
PASSWORD_SALT?: string;
ALLOWED_ORIGIN?: string;
}

let pool: Pool;

function getPool(env: Env): Pool {
if (!pool) {
pool = createPool({
host: env.TIDB_HOST,
port: parseInt(env.TIDB_PORT) || 4000,
user: env.TIDB_USER,
password: env.TIDB_PASSWORD,
database: env.TIDB_DATABASE,
waitForConnections: true,
connectionLimit: 5,
enableKeepAlive: true,
});
}
return pool;
}

const generateObjectId = customAlphabet('0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ', 10);

// 统一响应助手(支持动态 CORS 头)
function jsonResponse(data: any, status = 200, env?: Env, additionalHeaders: Record<string, string> = {}): Response {
const origin = env?.ALLOWED_ORIGIN || '*';
const headers = {
'Access-Control-Allow-Origin': origin,
'Access-Control-Allow-Methods': 'GET, POST, PUT, DELETE, OPTIONS',
'Access-Control-Allow-Headers': 'Content-Type, X-LC-Id, X-LC-Key, X-LC-Session',
'Access-Control-Max-Age': '86400',
'Content-Type': 'application/json',
...additionalHeaders,
};
return new Response(JSON.stringify(data), { status, headers });
}

function errorResponse(message: string, code = 500, status = 500, env?: Env): Response {
return jsonResponse({ code, error: message }, status, env);
}

async function authenticate(request: Request, pool: Pool): Promise<string | null> {
const sessionToken = request.headers.get('X-LC-Session');
if (!sessionToken) return null;
const [rows] = await pool.execute<RowDataPacket[]>(
'SELECT objectId FROM _User WHERE sessionToken = ?',
[sessionToken]
);
return rows.length > 0 ? rows[0].objectId : null;
}

function parseQueryParams(url: URL): { sql: string; params: any[] } {
const { searchParams } = url;
let sql = '';
const params: any[] = [];

const order = searchParams.get('order');
if (order) {
const dir = order.startsWith('-') ? 'DESC' : 'ASC';
const field = order.replace(/^-/, '');
sql += ` ORDER BY ${field} ${dir}`;
}

const limit = searchParams.get('limit');
if (limit) {
sql += ' LIMIT ?';
params.push(parseInt(limit, 10));
}

const skip = searchParams.get('skip');
if (skip) {
sql += ' OFFSET ?';
params.push(parseInt(skip, 10));
}

return { sql, params };
}

async function verifyPassword(inputPwd: string, storedPwd: string, salt: string | null, globalSalt?: string): Promise<boolean> {
const crypto = require('crypto');
const md5 = (s: string) => crypto.createHash('md5').update(s).digest('hex');
const saltToUse = salt || globalSalt || '';
const hashed = md5(md5(inputPwd) + saltToUse);
return hashed === storedPwd;
}

// JSON 字段列表(用于序列化)
const SHUOSHUO_JSON_FIELDS = new Set(['img', 'comments', 'ACL']);
const COMMENT_JSON_FIELDS = new Set(['ACL']);

export default {
async fetch(request: Request, env: Env): Promise<Response> {
const url = new URL(request.url);
const path = url.pathname;
const method = request.method;

// CORS 预检
if (method === 'OPTIONS') {
return new Response(null, {
headers: {
'Access-Control-Allow-Origin': env.ALLOWED_ORIGIN || '*',
'Access-Control-Allow-Methods': 'GET, POST, PUT, DELETE, OPTIONS',
'Access-Control-Allow-Headers': 'Content-Type, X-LC-Id, X-LC-Key, X-LC-Session',
'Access-Control-Max-Age': '86400',
},
});
}

let pool: Pool;
try {
pool = getPool(env);
} catch (e) {
console.error('Failed to create DB pool', e);
return errorResponse('Database connection failed', 503, 503, env);
}

try {
if (path === '/health') {
await pool.execute('SELECT 1');
return jsonResponse({ status: 'ok' }, 200, env);
}

if (path === '/1.1/login' && method === 'POST') {
return handleLogin(request, pool, env);
}

if (path.startsWith('/1.1/classes/shuoshuo')) {
return handleShuoshuo(request, pool, path, url, env);
}

if (path.startsWith('/1.1/classes/atComment')) {
return handleComment(request, pool, path, url, env);
}

if (path === '/1.1/users' && method === 'POST') {
return errorResponse('User registration not supported', 400, 400, env);
}

return errorResponse('Not found', 404, 404, env);
} catch (err) {
console.error('Unhandled error', err);
return errorResponse('Internal server error', 500, 500, env);
}
},
};

async function handleLogin(request: Request, pool: Pool, env: Env): Promise<Response> {
try {
const body = await request.json() as { username: string; password: string };
const { username, password } = body;

const [rows] = await pool.execute<RowDataPacket[]>(
'SELECT objectId, username, password, salt, img, sessionToken, createdAt, updatedAt FROM _User WHERE username = ?',
[username]
);
if (rows.length === 0) {
return errorResponse('Invalid username or password', 401, 401, env);
}

const user = rows[0];
const isValid = await verifyPassword(password, user.password, user.salt || null, env.PASSWORD_SALT);
if (!isValid) {
return errorResponse('Invalid username or password', 401, 401, env);
}

const crypto = require('crypto');
const sessionToken = crypto.randomBytes(32).toString('hex');
await pool.execute('UPDATE _User SET sessionToken = ? WHERE objectId = ?', [sessionToken, user.objectId]);

// 仅返回安全字段
return jsonResponse({
objectId: user.objectId,
username: user.username,
img: user.img,
sessionToken,
createdAt: user.createdAt,
updatedAt: user.updatedAt,
}, 200, env);
} catch (e) {
console.error('Login error', e);
return errorResponse('Login failed', 500, 500, env);
}
}

async function handleShuoshuo(request: Request, pool: Pool, path: string, url: URL, env: Env): Promise<Response> {
const objectId = path.replace('/1.1/classes/shuoshuo', '').replace(/^\//, '');
const method = request.method;

if (['POST', 'PUT', 'DELETE'].includes(method)) {
const userId = await authenticate(request, pool);
if (!userId) {
return errorResponse('Unauthorized', 401, 401, env);
}
}

try {
switch (method) {
case 'GET':
if (objectId) {
const [rows] = await pool.execute<RowDataPacket[]>('SELECT * FROM shuoshuo WHERE objectId = ?', [objectId]);
if (rows.length === 0) return errorResponse('Object not found', 404, 404, env);
return jsonResponse(rows[0], 200, env);
} else {
let sql = 'SELECT * FROM shuoshuo';
const { sql: orderLimit, params } = parseQueryParams(url);
sql += orderLimit;
const [rows] = await pool.execute<RowDataPacket[]>(sql, params);
return jsonResponse({ results: rows }, 200, env);
}

case 'POST': {
const body = await request.json() as Record<string, any>;
const newId = generateObjectId();
const now = new Date().toISOString().slice(0, 19).replace('T', ' ');
await pool.execute(
`INSERT INTO shuoshuo
(objectId, content, img, \`like\`, comments, createdAt, updatedAt, ACL)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
[
newId,
body.content || '',
body.img ? JSON.stringify(body.img) : null,
body.like ?? 0,
body.comments ? JSON.stringify(body.comments) : null,
now,
now,
body.ACL ? JSON.stringify(body.ACL) : null,
]
);
return jsonResponse({ objectId: newId, createdAt: now, updatedAt: now }, 200, env);
}

case 'PUT': {
if (!objectId) return errorResponse('objectId required', 400, 400, env);
const body = await request.json() as Record<string, any>;
const sets: string[] = [];
const values: any[] = [];
for (const [key, value] of Object.entries(body)) {
if (key !== 'objectId' && key !== 'createdAt' && key !== 'updatedAt') {
sets.push(`\`${key}\` = ?`);
// JSON 字段需要序列化
if (SHUOSHUO_JSON_FIELDS.has(key)) {
values.push(value ? JSON.stringify(value) : null);
} else {
values.push(value);
}
}
}
if (sets.length === 0) return errorResponse('No fields to update', 400, 400, env);
values.push(objectId);
await pool.execute(`UPDATE shuoshuo SET ${sets.join(', ')}, updatedAt = NOW() WHERE objectId = ?`, values);
return jsonResponse({ updatedAt: new Date().toISOString() }, 200, env);
}

case 'DELETE': {
if (!objectId) return errorResponse('objectId required', 400, 400, env);
await pool.execute('DELETE FROM shuoshuo WHERE objectId = ?', [objectId]);
return jsonResponse({}, 200, env);
}

default:
return errorResponse('Method not allowed', 405, 405, env);
}
} catch (e) {
console.error('Shuoshuo error', e);
return errorResponse('Database operation failed', 500, 500, env);
}
}

async function handleComment(request: Request, pool: Pool, path: string, url: URL, env: Env): Promise<Response> {
const objectId = path.replace('/1.1/classes/atComment', '').replace(/^\//, '');
const method = request.method;

if (['POST', 'PUT', 'DELETE'].includes(method)) {
const userId = await authenticate(request, pool);
if (!userId) {
return errorResponse('Unauthorized', 401, 401, env);
}
}

try {
switch (method) {
case 'GET':
if (objectId) {
const [rows] = await pool.execute<RowDataPacket[]>('SELECT * FROM atComment WHERE objectId = ?', [objectId]);
if (rows.length === 0) return errorResponse('Object not found', 404, 404, env);
return jsonResponse(rows[0], 200, env);
} else {
let sql = 'SELECT * FROM atComment';
const { sql: orderLimit, params } = parseQueryParams(url);
sql += orderLimit;
const [rows] = await pool.execute<RowDataPacket[]>(sql, params);
return jsonResponse({ results: rows }, 200, env);
}

case 'POST': {
const body = await request.json() as Record<string, any>;
const newId = generateObjectId();
const now = new Date().toISOString().slice(0, 19).replace('T', ' ');
await pool.execute(
`INSERT INTO atComment
(objectId, content, nickname, email, url, shuoshuoId, createdAt, updatedAt, ACL)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`,
[
newId,
body.content || '',
body.nickname || null,
body.email || null,
body.url || null,
body.shuoshuoId || null,
now,
now,
body.ACL ? JSON.stringify(body.ACL) : null,
]
);
return jsonResponse({ objectId: newId, createdAt: now, updatedAt: now }, 200, env);
}

case 'PUT': {
if (!objectId) return errorResponse('objectId required', 400, 400, env);
const body = await request.json() as Record<string, any>;
const sets: string[] = [];
const values: any[] = [];
for (const [key, value] of Object.entries(body)) {
if (key !== 'objectId' && key !== 'createdAt' && key !== 'updatedAt') {
sets.push(`\`${key}\` = ?`);
if (COMMENT_JSON_FIELDS.has(key)) {
values.push(value ? JSON.stringify(value) : null);
} else {
values.push(value);
}
}
}
if (sets.length === 0) return errorResponse('No fields to update', 400, 400, env);
values.push(objectId);
await pool.execute(`UPDATE atComment SET ${sets.join(', ')}, updatedAt = NOW() WHERE objectId = ?`, values);
return jsonResponse({ updatedAt: new Date().toISOString() }, 200, env);
}

case 'DELETE': {
if (!objectId) return errorResponse('objectId required', 400, 400, env);
await pool.execute('DELETE FROM atComment WHERE objectId = ?', [objectId]);
return jsonResponse({}, 200, env);
}

default:
return errorResponse('Method not allowed', 405, 405, env);
}
} catch (e) {
console.error('Comment error', e);
return errorResponse('Database operation failed', 500, 500, env);
}
}

📋 验证清单(更新)

除方法一清单外,额外确认:

  • 登录接口返回的 JSON 不包含 passwordsalt 字段。
  • 跨域请求时,响应头 Access-Control-Allow-Origin 严格等于 ALLOWED_ORIGIN
  • 更新说说或评论时,JSON 字段(如图片数组)能正确存储。
  • 在 Cloudflare Dashboard 中已添加 nodejs_compat 兼容性标志。

第三步:修改 Artitalk 前端配置

在Hexo Meow主题的配置文件(通常是_config.meow.yml)中找到Artitalk相关配置(在essay模块),修改如下:

1
2
3
4
5
6
artitalk:
enable: true
appId: 'safe' # 安全模式下随意填写
appKey: 'mode' # 同上
serverURL: 'https://artitalk-proxy.your-subdomain.workers.dev' # 替换为你的Worker域名
# 其他选项(如页面大小、评论开关等)按需保留

验证:部署后打开博客,打开浏览器开发者工具,查看网络请求是否指向你的Worker,且功能正常(登录、发说说、评论等)。


⚠️ 关键注意事项(方法一)

主要是方法一。

  1. 用户认证算法
    必须精确复现LeanCloud的密码加密逻辑。可通过以下步骤确认:

    • 在原LeanCloud应用后台,创建一个测试用户,密码设为简单值(如test123)。
    • 使用浏览器登录,抓取登录请求,查看其传输的密码(明文),同时从数据库导出该用户的saltpassword字段。
    • 本地编写脚本验证加密过程。若无法获取原salt,可考虑所有用户使用统一盐(但安全性降低)。推荐在Worker中支持两种模式。
  2. 数据迁移脚本
    若已有历史数据,需编写Node.js脚本将LeanCloud数据导出为JSON,然后转换后插入TiDB。注意处理:

    • 日期格式转换(LeanCloud返回ISO字符串,数据库需存储为TIMESTAMP)。
    • 关联关系(如评论中的shuoshuoId)。
    • 用户表密码字段(需保持原样,无需重新加密)。
  3. ACL与权限控制
    LeanCloud默认使用ACL控制数据读写权限。如果您的应用依赖ACL,需要在Worker中解析ACL字段并实施行级权限检查。实现较为复杂,可先简化:所有操作仅允许已认证用户(管理员)执行,公众只能读取公开数据。在表中增加ACL字段存储JSON,并在查询时过滤。

  4. CORS安全性
    生产环境中应将Access-Control-Allow-Origin设置为您的博客域名,而非*,防止其他网站恶意调用您的Worker。

  5. 错误处理与重试
    Worker应返回符合LeanCloud规范的错误信息,格式为{code: xxx, error: "message"},以便Artitalk前端正确处理。数据库超时应重试1-2次。

  6. 性能优化

    • shuoshuo表的createdAtatComment表的shuoshuoId建立索引(已在建表语句中)。
    • 对于频繁的查询,可考虑在Worker层使用Cloudflare KV存储缓存热门数据,但需注意缓存失效策略。
  7. 备份策略
    TiDB Cloud Serverless提供自动备份,但建议定期(如每日)导出数据到外部存储(如R2、S3),防止误操作或集群故障。

  8. 监控告警
    设置Worker的错误率监控(Cloudflare Dashboard提供),当5xx错误率超过阈值时发送邮件通知。同时监控数据库连接数、CPU使用率,确保免费额度不被耗尽。

  9. 测试清单
    部署后务必测试以下场景:

    • 用户登录成功/失败
    • 发表说说(含图片)
    • 编辑说说
    • 删除说说
    • 添加评论
    • 评论显示
    • 分页加载
    • 跨域请求
    • session过期(重新登录)
  10. 升级与维护
    后续若需修改Worker逻辑,可通过Wrangler零 downtime部署。数据库结构变更时,需编写迁移脚本并测试兼容性。


总结

通过以上步骤,你就可以将Artitalk从LeanCloud无缝迁移到TiDB Cloud + Cloudflare Worker的自托管方案。该方案具备高可用、低成本、可控性强的特点,同时保留原Artitalk的所有功能。关键在于准确复现LeanCloud的API行为和认证机制,并在Worker中实现健壮的错误处理和安全防护。建议在正式切换前,先在测试博客上全面验证,确保用户体验一致。


注意:未经作者允许 🈲 禁止转载、分享等 !!!

Icon喜欢这篇作品的话,奖励一下我吧~
💗感谢你的喜欢与支持!
致谢名单
本作品由 MISUXU 于 2026-03-03 18:22:41 发布
作品地址:diy-artitalk-server
除特别声明外,本站作品均采用 CC BY-NC-SA 4.0 许可协议,转载请注明来自 MISUXU
Logo
上一篇artitalk-to-tidb下一篇diy-musicserver