artitalk-to-tidb

前言

项目背景:

继承diy-artitalk-server项目,确保项目落地运行

部分功能正常,项目存在bug


大概流程: 建立cloudflare与tidb连接 -> 本地部署cloudflare worker -> 编辑worker并完成部署

前提条件:

1.一个 TiDB Cloud 账号

2.一个 Cloudflare 账号

3.本地安装 Node.js 和 npm 或 nvm(作者使用nvm)

4.本地安装 Wrangler CLI:npm install -g wrangler

1. 建立cloudflare与tidb连接

1.1 在 TiDB Cloud 上创建 Serverless 集群

1.1.1.登录 TiDB Cloud 控制台。

1.1.2.点击 “Create Cluster”,选择 “Serverless” 模式(Starter 或 Essential)。

1.1.3.选择云提供商和区域(建议与 Cloudflare Workers 区域相近,如 aws-ap-southeast-1)。

1.1.4.设置集群名称,点击 “Create”。

1.1.5.等待集群创建完成(约 2-5 分钟)。

1.1.6.在集群详情页,点击 “Connect”,获取连接字符串。

连接字符串格式为:mysql://:@/?sslaccept=strict

复制完整的连接字符串,稍后将用于 DATABASE_URL 环境变量。

1.2 在 TiDB Cloud 中创建数据库和表

你可以使用 TiDB Cloud 内置的 Chat2Query 或任何 MySQL 客户端执行以下 SQL:

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
-- 创建数据库(如果连接字符串中指定了数据库,通常自动创建,但也可以手动)
CREATE DATABASE IF NOT EXISTS your_database_name;
USE your_database_name;

-- 用户表
CREATE TABLE IF NOT EXISTS _User (
objectId VARCHAR(10) PRIMARY KEY,
username VARCHAR(255) NOT NULL UNIQUE,
password VARCHAR(32) NOT NULL,
salt VARCHAR(32),
img TEXT,
sessionToken VARCHAR(64),
createdAt DATETIME,
updatedAt DATETIME
);

-- 说说表
CREATE TABLE IF NOT EXISTS shuoshuo (
objectId VARCHAR(10) PRIMARY KEY,
content TEXT,
img JSON,
`like` INT DEFAULT 0,
comments JSON,
createdAt DATETIME,
updatedAt DATETIME,
ACL JSON,
atContentMd TEXT,
atContentHtml TEXT
);

-- 评论表
CREATE TABLE IF NOT EXISTS atComment (
objectId VARCHAR(10) PRIMARY KEY,
content TEXT,
nickname VARCHAR(255),
email VARCHAR(255),
url VARCHAR(512),
shuoshuoId VARCHAR(10),
createdAt DATETIME,
updatedAt DATETIME,
ACL JSON,
INDEX idx_shuoshuoId (shuoshuoId)
);

1.3 初始化 Cloudflare Worker 项目

在本地终端中分别执行以下命令:

1
2
3
4
5
6
7
8
9
# 1.3.1. 登录 Cloudflare(浏览器会打开授权页面)
wrangler login

# 1.3.2. 创建一个新的 Worker 项目(使用默认选项)
wrangler init artitalk-server
cd artitalk-server

# 1.3.3. 安装 TiDB Cloud Serverless Driver(cloudflare与tidb的连接枢纽)
npm install @tidbcloud/serverless

2. 部署 Worker 项目

2.1 编写 Worker 项目代码

新建的worker项目可能是src/index.ts,需要修改为src/index.js,并在wrangler.jsonc中修改”main”项

将下面代码复制到 src/index.js 文件中

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
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
// -------------------- MD5 纯 JS 实现 (保留Artitalk原有加盐逻辑) --------------------
function md5(string) {
function rotateLeft(lValue, iShiftBits) {
return (lValue << iShiftBits) | (lValue >>> (32 - iShiftBits));
}
function addUnsigned(lX, lY) {
var lX4, lY4, lX8, lY8, lResult;
lX8 = (lX & 0x80000000);
lY8 = (lY & 0x80000000);
lX4 = (lX & 0x40000000);
lY4 = (lY & 0x40000000);
lResult = (lX & 0x3FFFFFFF) + (lY & 0x3FFFFFFF);
if (lX4 & lY4) return (lResult ^ 0x80000000 ^ lX8 ^ lY8);
if (lX4 | lY4) {
if (lResult & 0x40000000) return (lResult ^ 0xC0000000 ^ lX8 ^ lY8);
else return (lResult ^ 0x40000000 ^ lX8 ^ lY8);
} else return (lResult ^ lX8 ^ lY8);
}
function F(x, y, z) { return (x & y) | ((~x) & z); }
function G(x, y, z) { return (x & z) | (y & (~z)); }
function H(x, y, z) { return (x ^ y ^ z); }
function I(x, y, z) { return (y ^ (x | (~z))); }
function FF(a, b, c, d, x, s, ac) {
a = addUnsigned(a, addUnsigned(addUnsigned(F(b, c, d), x), ac));
return addUnsigned(rotateLeft(a, s), b);
}
function GG(a, b, c, d, x, s, ac) {
a = addUnsigned(a, addUnsigned(addUnsigned(G(b, c, d), x), ac));
return addUnsigned(rotateLeft(a, s), b);
}
function HH(a, b, c, d, x, s, ac) {
a = addUnsigned(a, addUnsigned(addUnsigned(H(b, c, d), x), ac));
return addUnsigned(rotateLeft(a, s), b);
}
function II(a, b, c, d, x, s, ac) {
a = addUnsigned(a, addUnsigned(addUnsigned(I(b, c, d), x), ac));
return addUnsigned(rotateLeft(a, s), b);
}
function convertToWordArray(string) {
var lWordCount;
var lMessageLength = string.length;
var lNumberOfWords_temp1 = lMessageLength + 8;
var lNumberOfWords_temp2 = (lNumberOfWords_temp1 - (lNumberOfWords_temp1 % 64)) / 64;
var lNumberOfWords = (lNumberOfWords_temp2 + 1) * 16;
var lWordArray = Array(lNumberOfWords - 1);
var lBytePosition = 0;
var lByteCount = 0;
while (lByteCount < lMessageLength) {
lWordCount = (lByteCount - (lByteCount % 4)) / 4;
lBytePosition = (lByteCount % 4) * 8;
lWordArray[lWordCount] = (lWordArray[lWordCount] | (string.charCodeAt(lByteCount) << lBytePosition));
lByteCount++;
}
lWordCount = (lByteCount - (lByteCount % 4)) / 4;
lBytePosition = (lByteCount % 4) * 8;
lWordArray[lWordCount] = lWordArray[lWordCount] | (0x80 << lBytePosition);
lWordArray[lNumberOfWords - 2] = lMessageLength << 3;
lWordArray[lNumberOfWords - 1] = lMessageLength >>> 29;
return lWordArray;
}
function wordToHex(lValue) {
var WordToHexValue = "", WordToHexValue_temp = "", lByte, lCount;
for (lCount = 0; lCount <= 3; lCount++) {
lByte = (lValue >>> (lCount * 8)) & 255;
WordToHexValue_temp = "0" + lByte.toString(16);
WordToHexValue = WordToHexValue + WordToHexValue_temp.substr(WordToHexValue_temp.length - 2, 2);
}
return WordToHexValue;
}
var x = Array();
var k, AA, BB, CC, DD, a, b, c, d;
var S11 = 7, S12 = 12, S13 = 17, S14 = 22;
var S21 = 5, S22 = 9, S23 = 14, S24 = 20;
var S31 = 4, S32 = 11, S33 = 16, S34 = 23;
var S41 = 6, S42 = 10, S43 = 15, S44 = 20;
x = convertToWordArray(string);
a = 0x67452301;
b = 0xEFCDAB89;
c = 0x98BADCFE;
d = 0x10325476;
for (k = 0; k < x.length; k += 16) {
AA = a;
BB = b;
CC = c;
DD = d;
a = FF(a, b, c, d, x[k + 0], S11, 0xD76AA478);
d = FF(d, a, b, c, x[k + 1], S12, 0xE8C7B756);
c = FF(c, d, a, b, x[k + 2], S13, 0x242070DB);
b = FF(b, c, d, a, x[k + 3], S14, 0xC1BDCEEE);
a = FF(a, b, c, d, x[k + 4], S11, 0xF57C0FAF);
d = FF(d, a, b, c, x[k + 5], S12, 0x4787C62A);
c = FF(c, d, a, b, x[k + 6], S13, 0xA8304613);
b = FF(b, c, d, a, x[k + 7], S14, 0xFD469501);
a = FF(a, b, c, d, x[k + 8], S11, 0x698098D8);
d = FF(d, a, b, c, x[k + 9], S12, 0x8B44F7AF);
c = FF(c, d, a, b, x[k + 10], S13, 0xFFFF5BB1);
b = FF(b, c, d, a, x[k + 11], S14, 0x895CD7BE);
a = FF(a, b, c, d, x[k + 12], S11, 0x6B901122);
d = FF(d, a, b, c, x[k + 13], S12, 0xFD987193);
c = FF(c, d, a, b, x[k + 14], S13, 0xA679438E);
b = FF(b, c, d, a, x[k + 15], S14, 0x49B40821);
a = GG(a, b, c, d, x[k + 1], S21, 0xF61E2562);
d = GG(d, a, b, c, x[k + 6], S22, 0xC040B340);
c = GG(c, d, a, b, x[k + 11], S23, 0x265E5A51);
b = GG(b, c, d, a, x[k + 0], S24, 0xE9B6C7AA);
a = GG(a, b, c, d, x[k + 5], S21, 0xD62F105D);
d = GG(d, a, b, c, x[k + 10], S22, 0x2441453);
c = GG(c, d, a, b, x[k + 15], S23, 0xD8A1E681);
b = GG(b, c, d, a, x[k + 4], S24, 0xE7D3FBC8);
a = GG(a, b, c, d, x[k + 9], S21, 0x21E1CDE6);
d = GG(d, a, b, c, x[k + 14], S22, 0xC33707D6);
c = GG(c, d, a, b, x[k + 3], S23, 0xF4D50D87);
b = GG(b, c, d, a, x[k + 8], S24, 0x455A14ED);
a = GG(a, b, c, d, x[k + 13], S21, 0xA9E3E905);
d = GG(d, a, b, c, x[k + 2], S22, 0xFCEFA3F8);
c = GG(c, d, a, b, x[k + 7], S23, 0x676F02D9);
b = GG(b, c, d, a, x[k + 12], S24, 0x8D2A4C8A);
a = HH(a, b, c, d, x[k + 5], S31, 0xFFFA3942);
d = HH(d, a, b, c, x[k + 8], S32, 0x8771F681);
c = HH(c, d, a, b, x[k + 11], S33, 0x6D9D6122);
b = HH(b, c, d, a, x[k + 14], S34, 0xFDE5380C);
a = HH(a, b, c, d, x[k + 1], S31, 0xA4BEEA44);
d = HH(d, a, b, c, x[k + 4], S32, 0x4BDECFA9);
c = HH(c, d, a, b, x[k + 7], S33, 0xF6BB4B60);
b = HH(b, c, d, a, x[k + 10], S34, 0xBEBFBC70);
a = HH(a, b, c, d, x[k + 13], S31, 0x289B7EC6);
d = HH(d, a, b, c, x[k + 0], S32, 0xEAA127FA);
c = HH(c, d, a, b, x[k + 3], S33, 0xD4EF3085);
b = HH(b, c, d, a, x[k + 6], S34, 0x4881D05);
a = HH(a, b, c, d, x[k + 9], S31, 0xD9D4D039);
d = HH(d, a, b, c, x[k + 12], S32, 0xE6DB99E5);
c = HH(c, d, a, b, x[k + 15], S33, 0x1FA27CF8);
b = HH(b, c, d, a, x[k + 2], S34, 0xC4AC5665);
a = II(a, b, c, d, x[k + 0], S41, 0xF4292244);
d = II(d, a, b, c, x[k + 7], S42, 0x432AFF97);
c = II(c, d, a, b, x[k + 14], S43, 0xAB9423A7);
b = II(b, c, d, a, x[k + 5], S44, 0xFC93A039);
a = II(a, b, c, d, x[k + 12], S41, 0x655B59C3);
d = II(d, a, b, c, x[k + 3], S42, 0x8F0CCC92);
c = II(c, d, a, b, x[k + 10], S43, 0xFFEFF47D);
b = II(b, c, d, a, x[k + 1], S44, 0x85845DD1);
a = II(a, b, c, d, x[k + 8], S41, 0x6FA87E4F);
d = II(d, a, b, c, x[k + 15], S42, 0xFE2CE6E0);
c = II(c, d, a, b, x[k + 6], S43, 0xA3014314);
b = II(b, c, d, a, x[k + 13], S44, 0x4E0811A1);
a = II(a, b, c, d, x[k + 4], S41, 0xF7537E82);
d = II(d, a, b, c, x[k + 11], S42, 0xBD3AF235);
c = II(c, d, a, b, x[k + 2], S43, 0x2AD7D2BB);
b = II(b, c, d, a, x[k + 9], S44, 0xEB86D391);
a = addUnsigned(a, AA);
b = addUnsigned(b, BB);
c = addUnsigned(c, CC);
d = addUnsigned(d, DD);
}
var temp = wordToHex(a) + wordToHex(b) + wordToHex(c) + wordToHex(d);
return temp.toLowerCase();
}

// -------------------- 辅助函数 --------------------
function generateObjectId() {
const alphabet = '0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ';
const length = 10;
const bytes = new Uint8Array(length);
crypto.getRandomValues(bytes);
let result = '';
for (let i = 0; i < length; i++) {
result += alphabet[bytes[i] % alphabet.length];
}
return result;
}

function randomHex(length) {
const bytes = new Uint8Array(length);
crypto.getRandomValues(bytes);
return Array.from(bytes).map(b => b.toString(16).padStart(2, '0')).join('');
}

// -------------------- TiDB Cloud Serverless Driver 连接 --------------------
import { connect } from '@tidbcloud/serverless';

let connection = null;
function getConnection(env) {
if (!connection) {
// 从环境变量读取完整的连接字符串
const databaseUrl = env.DATABASE_URL;
if (!databaseUrl) {
throw new Error('DATABASE_URL environment variable is not set');
}
connection = connect({ url: databaseUrl });
}
return connection;
}

// -------------------- HTTP 响应工具 --------------------
function jsonResponse(data, status = 200, env, additionalHeaders = {}) {
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, X-LC-Sign, X-LC-Prod, x-lc-ua",
"Access-Control-Max-Age": "86400",
"Content-Type": "application/json",
...additionalHeaders
};
return new Response(JSON.stringify(data), { status, headers });
}

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

// -------------------- 身份验证 --------------------
async function authenticate(request, conn) {
const sessionToken = request.headers.get("X-LC-Session");
if (!sessionToken) return null;
// const { rows } = await conn.execute(
// "SELECT objectId FROM _User WHERE sessionToken = ?",
// [sessionToken]
// );
const result = await conn.execute(
"SELECT objectId FROM _User WHERE sessionToken = ?",
[sessionToken]
);

// 安全获取 rows(兼容数组或 { rows } 对象)
let rows;
if (Array.isArray(result)) {
rows = result;
} else if (result && result.rows) {
rows = result.rows;
} else {
console.error("Unexpected result format in authenticate:", result);
return null; // 无法识别时视为未认证
}
return rows.length > 0 ? rows[0].objectId : null;
}

// -------------------- 查询参数解析(带白名单)--------------------
function parseQueryParams(url, allowedOrderFields = []) {
const { searchParams } = url;
let sql = "";
const params = [];
const order = searchParams.get("order");
if (order) {
const dir = order.startsWith("-") ? "DESC" : "ASC";
const field = order.replace(/^-/, "");
if (/^[a-zA-Z0-9_]+$/.test(field) && (allowedOrderFields.length === 0 || allowedOrderFields.includes(field))) {
sql += ` ORDER BY ${field} ${dir}`;
} else {
console.warn(`Invalid order field: ${field}`);
}
}
const limit = searchParams.get("limit");
if (limit) {
const lim = parseInt(limit, 10);
if (!isNaN(lim) && lim > 0) {
sql += " LIMIT ?";
params.push(lim);
}
}
const skip = searchParams.get("skip");
if (skip) {
const sk = parseInt(skip, 10);
if (!isNaN(sk) && sk >= 0) {
sql += " OFFSET ?";
params.push(sk);
}
}
return { sql, params };
}

// -------------------- 密码验证(保留加盐 MD5)--------------------
async function verifyPassword(inputPwd, storedPwd, salt, globalSalt) {
const saltToUse = salt || globalSalt || "";
const hashed = md5(md5(inputPwd) + saltToUse);
return hashed === storedPwd;
}

// -------------------- 表字段白名单 --------------------
const SHUOSHUO_FIELDS = {
insert: ['content', 'img', 'like', 'comments', 'ACL', 'atContentMd', 'atContentHtml'],
update: ['content', 'img', 'like', 'comments', 'ACL', 'atContentMd', 'atContentHtml'],
order: ['objectId', 'content', 'like', 'createdAt', 'updatedAt']
};
const COMMENT_FIELDS = {
insert: ['content', 'nickname', 'email', 'url', 'shuoshuoId', 'ACL'],
update: ['content', 'nickname', 'email', 'url', 'shuoshuoId', 'ACL'],
order: ['objectId', 'content', 'nickname', 'createdAt', 'updatedAt']
};
const SHUOSHUO_JSON_FIELDS = new Set(['img', 'comments', 'ACL']);
const COMMENT_JSON_FIELDS = new Set(['ACL']);

const MAX_BODY_SIZE = 1024 * 1024;

// -------------------- 主入口 --------------------
export default {
async fetch(request, env) {
const url = new URL(request.url);
const path = url.pathname;
const method = request.method;

// 预检请求
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, X-LC-Sign, X-LC-Prod, x-lc-ua",
"Access-Control-Max-Age": "86400"
}
});
}

let conn;
try {
conn = getConnection(env);
} catch (e) {
console.error("Failed to create DB connection", e);
return errorResponse("Database connection failed", 503, 503, env);
}

try {
// 健康检查
if (path === "/health") {
await conn.execute("SELECT 1");
return jsonResponse({ status: "ok" }, 200, env);
}

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

// 说说
if (path.startsWith("/1.1/classes/shuoshuo")) {
return handleShuoshuo(request, conn, path, url, env);
}

// 评论
if (path.startsWith("/1.1/classes/atComment")) {
return handleComment(request, conn, path, url, env);
}

// 用户注册
if (path === "/1.1/users" && method === "POST") {
// 检查注册开关(环境变量 ALLOW_USER_REGISTRATION 为 'true' 时允许)
const allowRegistration = env.ALLOW_USER_REGISTRATION === 'true' || env.ALLOW_USER_REGISTRATION === true;
if (!allowRegistration) {
return errorResponse("User registration is disabled", 403, 403, env);
}
return handleUserRegistration(request, conn, 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, conn, env) {
try {
const contentLength = parseInt(request.headers.get('Content-Length') || '0', 10);
if (contentLength > MAX_BODY_SIZE) {
return errorResponse("Request entity too large", 413, 413, env);
}
const body = await request.json();
const { username, password } = body;
// const { rows } = await conn.execute(
// "SELECT objectId, username, password, salt, img, sessionToken, createdAt, updatedAt FROM _User WHERE username = ?",
// [username]
// );
// 修改点:先执行查询,再取 rows
const result = await conn.execute(
"SELECT objectId, username, password, salt, img, sessionToken, createdAt, updatedAt FROM _User WHERE username = ?",
[username]
);
// console.log("Login query result:", result); // 临时日志,查看返回结构

// console.log("Login query result:", JSON.stringify(result));
// const rows = result.rows; // 关键:从 result 中获取 rows
console.log("===== DB result =====");
console.log("typeof result:", typeof result);
console.log("result is array?", Array.isArray(result));
if (result && typeof result === 'object') {
console.log("result keys:", Object.keys(result));
console.log("result has own property 'rows'?", result.hasOwnProperty('rows'));
}
console.log("result value:", JSON.stringify(result, null, 2));
console.log("=====================");

// 安全获取 rows
let rows;
if (Array.isArray(result)) {
rows = result; // 如果直接返回数组
} else if (result && result.rows) {
rows = result.rows; // 如果返回 { rows: [...] }
} else {
console.error("Unexpected result format:", result);
return errorResponse("Database query returned unexpected format", 500, 500, env);
}

if (!rows || rows.length === 0) {
return errorResponse("Invalid username or password", 401, 401, env);
}

// 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 sessionToken = randomHex(32);
await conn.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);
console.error("Login error details:", e.stack); // 打印堆栈
return errorResponse("Login failed: " + e.message, 500, 500, env);
}
}

async function handleUserRegistration(request, conn, env) {
try {
// 限制请求体大小
const contentLength = parseInt(request.headers.get('Content-Length') || '0', 10);
if (contentLength > MAX_BODY_SIZE) {
return errorResponse("Request entity too large", 413, 413, env);
}

const body = await request.json();
const { username, password, img } = body;

// 必填字段校验
if (!username || !password || !img) {
return errorResponse("username and password and img are required", 400, 400, env);
}

// 检查用户名是否已存在
const checkResult = await conn.execute("SELECT objectId FROM _User WHERE username = ?", [username]);
let rows;
if (Array.isArray(checkResult)) {
rows = checkResult;
} else if (checkResult && checkResult.rows) {
rows = checkResult.rows;
} else {
return errorResponse("Database query returned unexpected format", 500, 500, env);
}
if (rows.length > 0) {
return errorResponse("Username already exists", 409, 409, env);
}

// 生成新用户所需字段
const objectId = generateObjectId();
const salt = randomHex(16); // 生成32位十六进制盐
const hashedPassword = md5(md5(password) + salt); // 加盐哈希
const now = new Date().toISOString().slice(0, 19).replace("T", " ");
const sessionToken = randomHex(32); // 自动生成会话令牌(实现注册即登录)

// 插入用户记录
await conn.execute(
`INSERT INTO _User (objectId, username, password, salt, img, sessionToken, createdAt, updatedAt)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
[
objectId,
username,
hashedPassword,
salt,
img,
sessionToken,
now,
now
]
);

// 返回与登录接口一致的响应格式
return jsonResponse({
objectId,
username,
img,
sessionToken,
createdAt: now,
updatedAt: now
}, 200, env);

} catch (e) {
console.error("Registration error details:", e.stack);
return errorResponse("Registration failed: " + e.message, 500, 500, env);
}
}

async function handleShuoshuo(request, conn, path, url, env) {
const objectId = path.replace("/1.1/classes/shuoshuo", "").replace(/^\//, "");
const method = request.method;

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

try {
switch (method) {
case "GET":
if (objectId) {
// const { rows } = await conn.execute("SELECT * FROM shuoshuo WHERE objectId = ?", [objectId]);
const result = await conn.execute("SELECT * FROM shuoshuo WHERE objectId = ?", [objectId]);
const rows = Array.isArray(result) ? result : result?.rows || [];
function parseJSONFields(row, jsonFields) {
const parsed = { ...row };
for (const field of jsonFields) {
if (parsed[field] && typeof parsed[field] === 'string') {
try {
parsed[field] = JSON.parse(parsed[field]);
} catch (e) {
// 忽略解析错误,保留原值
}
}
}
return parsed;
}
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, SHUOSHUO_FIELDS.order);
sql += orderLimit;
// const { rows } = await conn.execute(sql, params);
// return jsonResponse({ results: rows }, 200, env);
// const result = await conn.execute(sql, params);
// console.log("Query result:", result); // 查看实际结构
// return jsonResponse({ results: result.rows }, 200, env);
const listResult = await conn.execute(sql, params);
const rows = Array.isArray(listResult) ? listResult : listResult?.rows || [];
return jsonResponse({ results: rows }, 200, env);
}

case "POST": {
const contentLength = parseInt(request.headers.get('Content-Length') || '0', 10);
if (contentLength > MAX_BODY_SIZE) return errorResponse("Request entity too large", 413, 413, env);
const body = await request.json();
const newId = generateObjectId();
const now = new Date().toISOString().slice(0, 19).replace("T", " ");
const data = {};
for (const field of SHUOSHUO_FIELDS.insert) {
if (field in body) data[field] = body[field];
}
// // 特殊处理:将 atContentHtml 作为 content(如果没有 content 字段)
// if (!data.content && body.atContentHtml) {
// data.content = body.atContentHtml;
// }
// // 备用:如果没有 atContentHtml,使用 atContentMd
// if (!data.content && body.atContentMd) {
// data.content = body.atContentMd;
// }
// // 确保 content 至少有值(默认为空字符串)
// if (!data.content) {
// data.content = "";
// }

// 如果前端只发送了 atContentMd 和 atContentHtml,而没有 content,可以同时保存两者
if (!data.content && body.atContentHtml) {
data.content = body.atContentHtml; // 可选,保留旧字段兼容
}
if (!data.atContentMd && body.atContentMd) {
data.atContentMd = body.atContentMd;
}
if (!data.atContentHtml && body.atContentHtml) {
data.atContentHtml = body.atContentHtml;
}

await conn.execute(
`INSERT INTO shuoshuo
(objectId, content, img, \`like\`, comments, createdAt, updatedAt, ACL, atContentMd, atContentHtml)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
[
newId,
data.content || "",
data.img ? JSON.stringify(data.img) : null,
data.like ?? 0,
data.comments ? JSON.stringify(data.comments) : null,
now,
now,
data.ACL ? JSON.stringify(data.ACL) : null,
data.atContentMd || null,
data.atContentHtml || null
]
);
// 构造完整返回对象(保持与数据库一致的类型)
const newObj = {
objectId: newId,
content: data.content || "",
img: data.img || null,
like: data.like ?? 0,
comments: data.comments || null,
createdAt: now,
updatedAt: now,
ACL: data.ACL || null,
atContentMd: data.atContentMd || null,
atContentHtml: data.atContentHtml || null
};

return jsonResponse(newObj, 200, env);
// return jsonResponse({ objectId: newId, createdAt: now, updatedAt: now }, 200, env);
}

case "PUT": {
if (!objectId) return errorResponse("objectId required", 400, 400, env);
const contentLength = parseInt(request.headers.get('Content-Length') || '0', 10);
if (contentLength > MAX_BODY_SIZE) return errorResponse("Request entity too large", 413, 413, env);
const body = await request.json();
const sets = [];
const values = [];
for (const field of SHUOSHUO_FIELDS.update) {
if (field in body) {
sets.push(`\`${field}\` = ?`);
if (SHUOSHUO_JSON_FIELDS.has(field)) {
values.push(body[field] ? JSON.stringify(body[field]) : null);
} else {
values.push(body[field]);
}
}
}
if (sets.length === 0) return errorResponse("No fields to update", 400, 400, env);
values.push(objectId);
await conn.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 conn.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, conn, path, url, env) {
const objectId = path.replace("/1.1/classes/atComment", "").replace(/^\//, "");
const method = request.method;

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

try {
switch (method) {
case "GET":
if (objectId) {
// const { rows } = await conn.execute("SELECT * FROM atComment WHERE objectId = ?", [objectId]);
const result = await conn.execute("SELECT * FROM atComment WHERE objectId = ?", [objectId]);
const rows = result.rows;
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, COMMENT_FIELDS.order);
sql += orderLimit;
// const { rows } = await conn.execute(sql, params);
// return jsonResponse({ results: rows }, 200, env);
// const listResult = await conn.execute(sql, params);
// return jsonResponse({ results: listResult.rows }, 200, env);
const listResult = await conn.execute(sql, params);
const rows = Array.isArray(listResult) ? listResult : listResult?.rows || [];
return jsonResponse({ results: rows }, 200, env);
}

case "POST": {
const contentLength = parseInt(request.headers.get('Content-Length') || '0', 10);
if (contentLength > MAX_BODY_SIZE) return errorResponse("Request entity too large", 413, 413, env);
const body = await request.json();
const newId = generateObjectId();
const now = new Date().toISOString().slice(0, 19).replace("T", " ");
const data = {};
for (const field of COMMENT_FIELDS.insert) {
if (field in body) data[field] = body[field];
}
await conn.execute(
`INSERT INTO atComment
(objectId, content, nickname, email, url, shuoshuoId, createdAt, updatedAt, ACL)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`,
[
newId,
data.content || "",
data.nickname || null,
data.email || null,
data.url || null,
data.shuoshuoId || null,
now,
now,
data.ACL ? JSON.stringify(data.ACL) : null
]
);
return jsonResponse({ objectId: newId, createdAt: now, updatedAt: now }, 200, env);
}

case "PUT": {
if (!objectId) return errorResponse("objectId required", 400, 400, env);
const contentLength = parseInt(request.headers.get('Content-Length') || '0', 10);
if (contentLength > MAX_BODY_SIZE) return errorResponse("Request entity too large", 413, 413, env);
const body = await request.json();
const sets = [];
const values = [];
for (const field of COMMENT_FIELDS.update) {
if (field in body) {
sets.push(`\`${field}\` = ?`);
if (COMMENT_JSON_FIELDS.has(field)) {
values.push(body[field] ? JSON.stringify(body[field]) : null);
} else {
values.push(body[field]);
}
}
}
if (sets.length === 0) return errorResponse("No fields to update", 400, 400, env);
values.push(objectId);
await conn.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 conn.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);
}
}

2.2 修改 Worker 项目配置

通过 wrangler.jsonc 设置普通变量和修改配置
找到并打开项目中的 wrangler.jsonc 文件

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
{
"$schema": "node_modules/wrangler/config-schema.json",
"name": "artitalk-server", // worker项目名称
"main": "src/index.js", // 需要修改的主文件路径
"compatibility_date": "2026-03-03",
"compatibility_flags": [
// 兼容项
"nodejs_compat",
"global_fetch_strictly_public"
],
"assets": {
"directory": "./public"
},
"observability": {
"enabled": true
},
// 自定义域名配置
"routes": [
{
"pattern": "你的自定义域名",
"custom_domain": true // 是否开启配置
}
],

// 普通环境变量(非敏感)
"vars": {
// "ALLOWED_ORIGIN": "https://yourblog.com"
"ALLOW_USER_REGISTRATION": "false" // 动态页的注册开关
}
}

2.3 配置 Worker 项目环境变量

通过 Wrangler 设置以下 secrets(敏感信息)变量:

在项目根目录终端上运行以下命令:

1
2
3
4
5
6
7
8
9
10
11
# 设置 DATABASE_URL(必须,使用之前复制的连接字符串)
wrangler secret put DATABASE_URL
# 粘贴你的连接字符串,例如:mysql://user:pass@host/db?sslaccept=strict

# 设置 PASSWORD_SALT(必须,任意随机字符串,全局盐)
wrangler secret put PASSWORD_SALT
# 输入一个随机字符串,例如:aB3dEfGhIjK1LmNoPqRsTuVwXyZ;可使用函数创建,如:randomHex(16)

# 设置 ALLOWED_ORIGIN(可选,默认为 *。在这里设置是必须变量,在上方的vars中是普通变量)
wrangler secret put ALLOWED_ORIGIN
# 输入你的博客域名,例如:https://yourblog.com

注意:如果使用 wrangler.jsonc 配置文件,可以在 [vars] 中添加普通变量,但 secrets 必须通过 wrangler secret 设置。

2.4 部署 Worker

2.4.1 安装依赖并构建

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

2.4.2 本地测试(可选)

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

1
wrangler dev --env dev

2.4.3 部署到生产

部署到cloudflare上

建议本地与云端同步,每次修改项目都在本地完成再运行命令部署到cloudflare上

1
wrangler deploy

部署成功后,终端会显示 Worker 的 URL,例如 https://artitalk-server.your-subdomain.workers.dev。

2.5 验证部署

访问以下 URL 测试是否正常工作:

https://你的worker域名/health 应返回 {“status”:”ok”}

https://你的worker域名/1.1/classes/shuoshuo 应返回 {“results”:[]}(空数组)

3 使用项目

3.1 配置博客前端

需要查看博客主题配置

在 Artitalk 插件配置中,将服务器地址设置为你的 Worker URL,例如 https://artitalk-server.your-subdomain.workers.dev,并确保前端请求的路径与 Worker 路由匹配(例如 /1.1/classes/shuoshuo)。

3.2 注册

需要环境变量 ALLOW_USER_REGISTRATION 为 ‘true’

注意:注册完成一定要记得恢复环境变量 ALLOW_USER_REGISTRATION 为 ‘false 

创建一个js文件,添加一下代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
// 注册用户
fetch('https://artitalk.misuxu.com/1.1/users', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
username: '用户名',
password: '密码',
img: '头像URL'
})
})
.then(res => res.json())
.then(console.log)
.catch(console.error);

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

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