@rei-standard/amsg-server 1.2.0 → 2.0.0-pre1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,1633 +0,0 @@
1
- import { randomBytes, createHash, createDecipheriv, createCipheriv, randomUUID } from 'crypto';
2
-
3
- /**
4
- * Adapter Factory
5
- * ReiStandard SDK v1.2.0
6
- *
7
- * Creates a database adapter instance based on the supplied configuration.
8
- *
9
- * @typedef {'neon'|'pg'} DriverName
10
- *
11
- * @typedef {Object} AdapterConfig
12
- * @property {DriverName} driver - Which database driver to use.
13
- * @property {string} connectionString - Database connection URL.
14
- */
15
-
16
- /**
17
- * Create a database adapter.
18
- *
19
- * @param {AdapterConfig} config
20
- * @returns {Promise<import('./interface.js').DbAdapter>}
21
- */
22
- async function createAdapter(config) {
23
- if (!config || !config.driver) {
24
- throw new Error(
25
- '[rei-standard-amsg-server] "driver" is required in the db config. ' +
26
- 'Supported drivers: neon, pg'
27
- );
28
- }
29
-
30
- if (!config.connectionString) {
31
- throw new Error(
32
- '[rei-standard-amsg-server] "connectionString" is required in the db config.'
33
- );
34
- }
35
-
36
- switch (config.driver) {
37
- case 'neon': {
38
- const { NeonAdapter } = await import('./neon-Drv0o9hb.d.cts');
39
- return new NeonAdapter(config.connectionString);
40
- }
41
-
42
- case 'pg': {
43
- const { PgAdapter } = await import('./pg-BHyMmb-s.d.cts');
44
- return new PgAdapter(config.connectionString);
45
- }
46
-
47
- default:
48
- throw new Error(
49
- `[rei-standard-amsg-server] Unsupported driver "${config.driver}". ` +
50
- 'Supported drivers: neon, pg'
51
- );
52
- }
53
- }
54
-
55
- /**
56
- * Shared SQL schema constants
57
- * ReiStandard SDK v1.2.0
58
- */
59
-
60
- const TABLE_SQL = `
61
- CREATE TABLE IF NOT EXISTS scheduled_messages (
62
- id SERIAL PRIMARY KEY,
63
- user_id VARCHAR(255) NOT NULL,
64
- uuid VARCHAR(36),
65
- encrypted_payload TEXT NOT NULL,
66
- message_type VARCHAR(50) NOT NULL CHECK (message_type IN ('fixed', 'prompted', 'auto', 'instant')),
67
- next_send_at TIMESTAMP WITH TIME ZONE NOT NULL,
68
- status VARCHAR(50) NOT NULL DEFAULT 'pending' CHECK (status IN ('pending', 'sent', 'failed')),
69
- retry_count INTEGER DEFAULT 0,
70
- created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
71
- updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
72
- )
73
- `;
74
-
75
- const SYSTEM_CONFIG_SQL = `
76
- CREATE TABLE IF NOT EXISTS system_config (
77
- key TEXT PRIMARY KEY,
78
- value TEXT NOT NULL,
79
- created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
80
- updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
81
- )
82
- `;
83
-
84
- const INDEXES = [
85
- {
86
- name: 'idx_pending_tasks_optimized',
87
- sql: `CREATE INDEX IF NOT EXISTS idx_pending_tasks_optimized
88
- ON scheduled_messages (status, next_send_at, id, retry_count)
89
- WHERE status = 'pending'`,
90
- description: 'Main query index (Cron Job finds pending tasks)'
91
- },
92
- {
93
- name: 'idx_cleanup_completed',
94
- sql: `CREATE INDEX IF NOT EXISTS idx_cleanup_completed
95
- ON scheduled_messages (status, updated_at)
96
- WHERE status IN ('sent', 'failed')`,
97
- description: 'Cleanup query index'
98
- },
99
- {
100
- name: 'idx_failed_retry',
101
- sql: `CREATE INDEX IF NOT EXISTS idx_failed_retry
102
- ON scheduled_messages (status, retry_count, next_send_at)
103
- WHERE status = 'failed' AND retry_count < 3`,
104
- description: 'Failed retry index'
105
- },
106
- {
107
- name: 'idx_user_id',
108
- sql: `CREATE INDEX IF NOT EXISTS idx_user_id
109
- ON scheduled_messages (user_id)`,
110
- description: 'User task query index'
111
- },
112
- {
113
- name: 'uidx_uuid',
114
- sql: `CREATE UNIQUE INDEX IF NOT EXISTS uidx_uuid
115
- ON scheduled_messages (uuid)
116
- WHERE uuid IS NOT NULL`,
117
- description: 'UUID uniqueness guard',
118
- critical: true
119
- }
120
- ];
121
-
122
- const VERIFY_TABLE_SQL = `
123
- SELECT table_name
124
- FROM information_schema.tables
125
- WHERE table_schema = 'public'
126
- AND table_name = 'scheduled_messages'
127
- `;
128
-
129
- const VERIFY_SYSTEM_CONFIG_SQL = `
130
- SELECT table_name
131
- FROM information_schema.tables
132
- WHERE table_schema = 'public'
133
- AND table_name = 'system_config'
134
- `;
135
-
136
- const COLUMNS_SQL = `
137
- SELECT column_name, data_type, is_nullable
138
- FROM information_schema.columns
139
- WHERE table_schema = 'public'
140
- AND table_name = 'scheduled_messages'
141
- ORDER BY ordinal_position
142
- `;
143
-
144
- const SYSTEM_CONFIG_COLUMNS_SQL = `
145
- SELECT column_name, data_type, is_nullable
146
- FROM information_schema.columns
147
- WHERE table_schema = 'public'
148
- AND table_name = 'system_config'
149
- ORDER BY ordinal_position
150
- `;
151
-
152
- const REQUIRED_COLUMNS = [
153
- 'id', 'user_id', 'uuid', 'encrypted_payload',
154
- 'message_type', 'next_send_at', 'status', 'retry_count'
155
- ];
156
-
157
- /**
158
- * Handler: init-database
159
- * ReiStandard SDK v1.2.0
160
- *
161
- * @param {Object} ctx - Server context injected by createReiServer.
162
- * @returns {{ GET: function, POST: function }}
163
- */
164
-
165
-
166
- function createInitDatabaseHandler(ctx) {
167
- async function GET() {
168
- const result = await ctx.db.initSchema();
169
-
170
- const columnNames = result.columns.map(c => c.name);
171
- const missingColumns = REQUIRED_COLUMNS.filter(col => !columnNames.includes(col));
172
- if (missingColumns.length > 0) {
173
- console.warn('[init-database] ⚠️ Missing columns:', missingColumns);
174
- }
175
-
176
- return {
177
- status: 200,
178
- body: {
179
- success: true,
180
- message: '数据库初始化成功!建议立即删除此 API 文件。',
181
- data: {
182
- tables: ['scheduled_messages', 'system_config'],
183
- columnsCreated: result.columnsCreated,
184
- indexesCreated: result.indexesCreated,
185
- indexesFailed: result.indexesFailed,
186
- details: { columns: result.columns, indexes: result.indexes },
187
- nextSteps: [
188
- '1. 调用 /api/v1/init-master-key 一次性生成主密钥并离线保存',
189
- '2. 客户端使用 UUID v4 作为 X-User-Id',
190
- '3. 客户端调用 /api/v1/get-user-key 获取 userKey 并缓存'
191
- ]
192
- }
193
- }
194
- };
195
- }
196
-
197
- return { GET };
198
- }
199
-
200
- /**
201
- * Handler: init-master-key
202
- * ReiStandard SDK v1.2.0
203
- *
204
- * @param {Object} ctx - Server context.
205
- * @returns {{ POST: function }}
206
- */
207
-
208
-
209
- function makeFingerprint(masterKey) {
210
- return createHash('sha256').update(masterKey).digest('hex').slice(0, 16);
211
- }
212
-
213
- function createInitMasterKeyHandler(ctx) {
214
- async function POST() {
215
- const masterKey = randomBytes(32).toString('hex');
216
- const inserted = await ctx.db.setMasterKeyOnce(masterKey);
217
-
218
- if (!inserted) {
219
- return {
220
- status: 409,
221
- body: {
222
- success: false,
223
- error: {
224
- code: 'MASTER_KEY_ALREADY_INITIALIZED',
225
- message: '主密钥已初始化,无法再次获取'
226
- }
227
- }
228
- };
229
- }
230
-
231
- return {
232
- status: 201,
233
- body: {
234
- success: true,
235
- data: {
236
- masterKey,
237
- fingerprint: makeFingerprint(masterKey),
238
- version: 1
239
- }
240
- }
241
- };
242
- }
243
-
244
- return { POST };
245
- }
246
-
247
- /**
248
- * Encryption utility library (SDK version)
249
- * ReiStandard SDK v1.2.0
250
- *
251
- * Wraps AES-256-GCM operations for request/response and storage encryption.
252
- */
253
-
254
-
255
- /**
256
- * Derive a user-specific encryption key from the master key.
257
- *
258
- * @param {string} userId - Unique user identifier.
259
- * @param {string} masterKey - 64-char hex master key.
260
- * @returns {string} 64-char hex key.
261
- */
262
- function deriveUserEncryptionKey(userId, masterKey) {
263
- return createHash('sha256')
264
- .update(masterKey + userId)
265
- .digest('hex')
266
- .slice(0, 64);
267
- }
268
-
269
- /**
270
- * Decrypt a client-encrypted request body (AES-256-GCM, base64 encoded).
271
- *
272
- * @param {{ iv: string, authTag: string, encryptedData: string }} encryptedPayload
273
- * @param {string} encryptionKey - 64-char hex key.
274
- * @returns {Object} Decrypted JSON object.
275
- */
276
- function decryptPayload(encryptedPayload, encryptionKey) {
277
- const { iv, authTag, encryptedData } = encryptedPayload;
278
-
279
- const decipher = createDecipheriv(
280
- 'aes-256-gcm',
281
- Buffer.from(encryptionKey, 'hex'),
282
- Buffer.from(iv, 'base64')
283
- );
284
-
285
- decipher.setAuthTag(Buffer.from(authTag, 'base64'));
286
-
287
- const decrypted = Buffer.concat([
288
- decipher.update(Buffer.from(encryptedData, 'base64')),
289
- decipher.final()
290
- ]);
291
-
292
- return JSON.parse(decrypted.toString('utf8'));
293
- }
294
-
295
- /**
296
- * Encrypt a JSON payload for API transfer (AES-256-GCM, base64 encoded).
297
- *
298
- * @param {string|Object} payload
299
- * @param {string} encryptionKey - 64-char hex key.
300
- * @returns {{ iv: string, authTag: string, encryptedData: string }}
301
- */
302
- function encryptPayload(payload, encryptionKey) {
303
- const plaintext = typeof payload === 'string' ? payload : JSON.stringify(payload);
304
- const iv = randomBytes(12);
305
- const cipher = createCipheriv('aes-256-gcm', Buffer.from(encryptionKey, 'hex'), iv);
306
- const encrypted = Buffer.concat([cipher.update(plaintext, 'utf8'), cipher.final()]);
307
- const authTag = cipher.getAuthTag();
308
-
309
- return {
310
- iv: iv.toString('base64'),
311
- authTag: authTag.toString('base64'),
312
- encryptedData: encrypted.toString('base64')
313
- };
314
- }
315
-
316
- /**
317
- * Encrypt data for database storage (hex encoded, colon-separated).
318
- *
319
- * @param {string} text - Plaintext string.
320
- * @param {string} encryptionKey - 64-char hex key.
321
- * @returns {string} Format: iv:authTag:encryptedData
322
- */
323
- function encryptForStorage(text, encryptionKey) {
324
- const iv = randomBytes(16);
325
- const cipher = createCipheriv('aes-256-gcm', Buffer.from(encryptionKey, 'hex'), iv);
326
- const encrypted = cipher.update(text, 'utf8', 'hex') + cipher.final('hex');
327
- const authTag = cipher.getAuthTag();
328
- return `${iv.toString('hex')}:${authTag.toString('hex')}:${encrypted}`;
329
- }
330
-
331
- /**
332
- * Decrypt data from database storage format.
333
- *
334
- * @param {string} encryptedText - Format: iv:authTag:encryptedData
335
- * @param {string} encryptionKey - 64-char hex key.
336
- * @returns {string} Plaintext string.
337
- */
338
- function decryptFromStorage(encryptedText, encryptionKey) {
339
- const [ivHex, authTagHex, encryptedDataHex] = encryptedText.split(':');
340
- const decipher = createDecipheriv(
341
- 'aes-256-gcm',
342
- Buffer.from(encryptionKey, 'hex'),
343
- Buffer.from(ivHex, 'hex')
344
- );
345
- decipher.setAuthTag(Buffer.from(authTagHex, 'hex'));
346
- return decipher.update(encryptedDataHex, 'hex', 'utf8') + decipher.final('utf8');
347
- }
348
-
349
- /**
350
- * Validation utility library (SDK version)
351
- * ReiStandard SDK v1.2.0
352
- */
353
-
354
- /**
355
- * Validate ISO 8601 date string.
356
- * @param {string} dateString
357
- * @returns {boolean}
358
- */
359
- function isValidISO8601(dateString) {
360
- const date = new Date(dateString);
361
- return date instanceof Date && !isNaN(date.getTime());
362
- }
363
-
364
- /**
365
- * Validate URL format.
366
- * @param {string} urlString
367
- * @returns {boolean}
368
- */
369
- function isValidUrl(urlString) {
370
- try {
371
- new URL(urlString);
372
- return true;
373
- } catch {
374
- return false;
375
- }
376
- }
377
-
378
- /**
379
- * Validate UUID format.
380
- * @param {string} uuid
381
- * @returns {boolean}
382
- */
383
- function isValidUUID(uuid) {
384
- const uuidRegex = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
385
- return uuidRegex.test(uuid);
386
- }
387
-
388
- /**
389
- * Validate UUID v4 format.
390
- * @param {string} uuid
391
- * @returns {boolean}
392
- */
393
- function isValidUUIDv4(uuid) {
394
- const uuidV4Regex = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
395
- return uuidV4Regex.test(uuid);
396
- }
397
-
398
- /**
399
- * Validate the schedule-message request payload.
400
- *
401
- * @param {Object} payload
402
- * @returns {{ valid: boolean, errorCode?: string, errorMessage?: string, details?: Object }}
403
- */
404
- function validateScheduleMessagePayload(payload) {
405
- if (!payload.contactName || typeof payload.contactName !== 'string') {
406
- return { valid: false, errorCode: 'INVALID_PARAMETERS', errorMessage: '缺少必需参数或参数格式错误', details: { missingFields: ['contactName'] } };
407
- }
408
-
409
- if (!payload.messageType || !['fixed', 'prompted', 'auto', 'instant'].includes(payload.messageType)) {
410
- return { valid: false, errorCode: 'INVALID_MESSAGE_TYPE', errorMessage: '消息类型无效', details: { providedType: payload.messageType, allowedTypes: ['fixed', 'prompted', 'auto', 'instant'] } };
411
- }
412
-
413
- if (!payload.firstSendTime || !isValidISO8601(payload.firstSendTime)) {
414
- return { valid: false, errorCode: 'INVALID_TIMESTAMP', errorMessage: '时间格式无效', details: { field: 'firstSendTime' } };
415
- }
416
-
417
- if (payload.firstSendTime && new Date(payload.firstSendTime) <= new Date()) {
418
- return { valid: false, errorCode: 'INVALID_TIMESTAMP', errorMessage: '时间必须在未来', details: { field: 'firstSendTime', reason: 'must be in the future' } };
419
- }
420
-
421
- if (!payload.pushSubscription || typeof payload.pushSubscription !== 'object') {
422
- return { valid: false, errorCode: 'INVALID_PARAMETERS', errorMessage: '缺少必需参数或参数格式错误', details: { missingFields: ['pushSubscription'] } };
423
- }
424
-
425
- if (payload.recurrenceType && !['none', 'daily', 'weekly'].includes(payload.recurrenceType)) {
426
- return { valid: false, errorCode: 'INVALID_PARAMETERS', errorMessage: '缺少必需参数或参数格式错误', details: { invalidFields: ['recurrenceType'] } };
427
- }
428
-
429
- if (payload.messageType === 'fixed') {
430
- if (!payload.userMessage) {
431
- return { valid: false, errorCode: 'INVALID_PARAMETERS', errorMessage: '缺少必需参数或参数格式错误', details: { missingFields: ['userMessage (required for fixed type)'] } };
432
- }
433
- }
434
-
435
- if (payload.messageType === 'prompted' || payload.messageType === 'auto') {
436
- const missingAiFields = [];
437
- if (!payload.completePrompt) missingAiFields.push('completePrompt');
438
- if (!payload.apiUrl) missingAiFields.push('apiUrl');
439
- if (!payload.apiKey) missingAiFields.push('apiKey');
440
- if (!payload.primaryModel) missingAiFields.push('primaryModel');
441
- if (missingAiFields.length > 0) {
442
- return { valid: false, errorCode: 'INVALID_PARAMETERS', errorMessage: '缺少必需参数或参数格式错误', details: { missingFields: missingAiFields } };
443
- }
444
- }
445
-
446
- if (payload.messageType === 'instant') {
447
- if (payload.recurrenceType && payload.recurrenceType !== 'none') {
448
- return { valid: false, errorCode: 'INVALID_PARAMETERS', errorMessage: 'instant 类型的 recurrenceType 必须为 none', details: { invalidFields: ['recurrenceType (must be "none" for instant type)'] } };
449
- }
450
- const hasAiConfig = payload.completePrompt && payload.apiUrl && payload.apiKey && payload.primaryModel;
451
- const hasUserMessage = payload.userMessage;
452
- if (!hasAiConfig && !hasUserMessage) {
453
- return { valid: false, errorCode: 'INVALID_PARAMETERS', errorMessage: 'instant 类型必须提供 userMessage 或完整的 AI 配置', details: { missingFields: ['userMessage or (completePrompt + apiUrl + apiKey + primaryModel)'] } };
454
- }
455
- }
456
-
457
- if (payload.avatarUrl && !isValidUrl(payload.avatarUrl)) {
458
- return { valid: false, errorCode: 'INVALID_PARAMETERS', errorMessage: '缺少必需参数或参数格式错误', details: { invalidFields: ['avatarUrl (invalid URL format)'] } };
459
- }
460
- if (payload.uuid && !isValidUUID(payload.uuid)) {
461
- return { valid: false, errorCode: 'INVALID_PARAMETERS', errorMessage: '缺少必需参数或参数格式错误', details: { invalidFields: ['uuid (invalid UUID format)'] } };
462
- }
463
- if (payload.messageSubtype && !['chat', 'forum', 'moment'].includes(payload.messageSubtype)) {
464
- return { valid: false, errorCode: 'INVALID_PARAMETERS', errorMessage: '缺少必需参数或参数格式错误', details: { invalidFields: ['messageSubtype'] } };
465
- }
466
-
467
- return { valid: true };
468
- }
469
-
470
- /**
471
- * Handler: get-user-key
472
- * ReiStandard SDK v1.2.0
473
- *
474
- * @param {Object} ctx - Server context.
475
- * @returns {{ GET: function }}
476
- */
477
-
478
-
479
- function createGetUserKeyHandler(ctx) {
480
- async function GET(headers) {
481
- const userId = headers['x-user-id'];
482
-
483
- if (!userId) {
484
- return {
485
- status: 400,
486
- body: { success: false, error: { code: 'USER_ID_REQUIRED', message: '缺少用户标识符' } }
487
- };
488
- }
489
-
490
- if (!isValidUUIDv4(userId)) {
491
- return {
492
- status: 400,
493
- body: { success: false, error: { code: 'INVALID_USER_ID_FORMAT', message: 'X-User-Id 必须是 UUID v4 格式' } }
494
- };
495
- }
496
-
497
- const masterKey = await ctx.db.getMasterKey();
498
- if (!masterKey) {
499
- return {
500
- status: 503,
501
- body: { success: false, error: { code: 'MASTER_KEY_NOT_INITIALIZED', message: '主密钥尚未初始化,请先调用 /api/v1/init-master-key' } }
502
- };
503
- }
504
-
505
- return {
506
- status: 200,
507
- body: {
508
- success: true,
509
- data: {
510
- userKey: deriveUserEncryptionKey(userId, masterKey),
511
- version: 1
512
- }
513
- }
514
- };
515
- }
516
-
517
- return { GET };
518
- }
519
-
520
- /**
521
- * Database error helpers.
522
- */
523
-
524
- /**
525
- * Check whether an error is caused by a unique-constraint violation.
526
- *
527
- * @param {unknown} error
528
- * @returns {boolean}
529
- */
530
- function isUniqueViolation(error) {
531
- if (!error || typeof error !== 'object') return false;
532
-
533
- const code = error.code;
534
- if (code === '23505') return true;
535
-
536
- const message = typeof error.message === 'string' ? error.message.toLowerCase() : '';
537
- return message.includes('duplicate key') || message.includes('unique constraint');
538
- }
539
-
540
- /**
541
- * Request payload utilities.
542
- * Keeps body parsing and shape validation consistent across handlers.
543
- */
544
-
545
- const REQUEST_ERRORS = {
546
- INVALID_JSON: { code: 'INVALID_JSON', message: '请求体不是有效的 JSON' },
547
- INVALID_REQUEST_BODY: { code: 'INVALID_REQUEST_BODY', message: '请求体格式无效' },
548
- INVALID_ENCRYPTED_PAYLOAD: { code: 'INVALID_ENCRYPTED_PAYLOAD', message: '加密数据格式错误' }
549
- };
550
-
551
- /**
552
- * @typedef {{ code: string, message: string }} ValidationError
553
- */
554
-
555
- /**
556
- * @typedef {{
557
- * invalidJson?: ValidationError,
558
- * invalidType?: ValidationError
559
- * }} ParseBodyOptions
560
- */
561
-
562
- /**
563
- * @typedef {{
564
- * ok: true,
565
- * data: Record<string, any>
566
- * } | {
567
- * ok: false,
568
- * error: ValidationError
569
- * }} ParseBodyResult
570
- */
571
-
572
- /**
573
- * Parse body into a JSON object.
574
- *
575
- * @param {unknown} body
576
- * @param {ParseBodyOptions} [options]
577
- * @returns {ParseBodyResult}
578
- */
579
- function parseBodyAsObject(body, options = {}) {
580
- const invalidJson = options.invalidJson || REQUEST_ERRORS.INVALID_JSON;
581
- const invalidType = options.invalidType || REQUEST_ERRORS.INVALID_REQUEST_BODY;
582
-
583
- let parsed = body;
584
- if (typeof parsed === 'string') {
585
- try {
586
- parsed = JSON.parse(parsed);
587
- } catch {
588
- return { ok: false, error: invalidJson };
589
- }
590
- }
591
-
592
- if (!isPlainObject(parsed)) {
593
- return { ok: false, error: invalidType };
594
- }
595
-
596
- return { ok: true, data: parsed };
597
- }
598
-
599
- /**
600
- * Check if a value is a plain object (and not null/array).
601
- *
602
- * @param {unknown} value
603
- * @returns {value is Record<string, any>}
604
- */
605
- function isPlainObject(value) {
606
- return typeof value === 'object' && value !== null && !Array.isArray(value);
607
- }
608
-
609
- /**
610
- * Check if an object follows the encrypted payload envelope shape.
611
- *
612
- * @param {unknown} payload
613
- * @returns {payload is { iv: string, authTag: string, encryptedData: string }}
614
- */
615
- function isEncryptedEnvelope(payload) {
616
- if (!isPlainObject(payload)) return false;
617
-
618
- return (
619
- typeof payload.iv === 'string' &&
620
- typeof payload.authTag === 'string' &&
621
- typeof payload.encryptedData === 'string'
622
- );
623
- }
624
-
625
- /**
626
- * Parse and validate an encrypted payload envelope.
627
- *
628
- * @param {unknown} body
629
- * @returns {ParseBodyResult}
630
- */
631
- function parseEncryptedBody(body) {
632
- const parsedBody = parseBodyAsObject(body, {
633
- invalidJson: REQUEST_ERRORS.INVALID_ENCRYPTED_PAYLOAD,
634
- invalidType: REQUEST_ERRORS.INVALID_ENCRYPTED_PAYLOAD
635
- });
636
-
637
- if (!parsedBody.ok) {
638
- return parsedBody;
639
- }
640
-
641
- if (!isEncryptedEnvelope(parsedBody.data)) {
642
- return { ok: false, error: REQUEST_ERRORS.INVALID_ENCRYPTED_PAYLOAD };
643
- }
644
-
645
- return parsedBody;
646
- }
647
-
648
- /**
649
- * Message Processor (SDK version)
650
- * ReiStandard SDK v1.2.0
651
- *
652
- * Handles single message content generation and Web Push delivery.
653
- * Receives its dependencies (encryption helpers, webpush, VAPID config)
654
- * via a context object so that it stays free of process.env references.
655
- */
656
-
657
-
658
- /**
659
- * @typedef {Object} ProcessorContext
660
- * @property {Object} webpush - The web-push module instance (already VAPID-configured).
661
- * @property {Object} vapid - { email, publicKey, privateKey }
662
- * @property {import('../adapters/interface.js').DbAdapter} db
663
- */
664
-
665
- /**
666
- * Process a single database task row: decrypt → generate content → push.
667
- *
668
- * @param {import('../adapters/interface.js').TaskRow} task
669
- * @param {ProcessorContext} ctx
670
- * @param {string} [providedMasterKey]
671
- * @returns {Promise<{ success: boolean, messagesSent: number, error?: string }>}
672
- */
673
- async function processSingleMessage(task, ctx, providedMasterKey) {
674
- try {
675
- const masterKey = providedMasterKey || await ctx.db.getMasterKey();
676
- if (!masterKey) {
677
- return { success: false, messagesSent: 0, error: 'MASTER_KEY_NOT_INITIALIZED' };
678
- }
679
-
680
- const userKey = deriveUserEncryptionKey(task.user_id, masterKey);
681
- const decryptedPayload = JSON.parse(decryptFromStorage(task.encrypted_payload, userKey));
682
-
683
- let messageContent;
684
-
685
- if (decryptedPayload.messageType === 'fixed') {
686
- messageContent = decryptedPayload.userMessage;
687
-
688
- } else if (decryptedPayload.messageType === 'instant') {
689
- if (decryptedPayload.completePrompt && decryptedPayload.apiUrl && decryptedPayload.apiKey && decryptedPayload.primaryModel) {
690
- messageContent = await _callAI(decryptedPayload);
691
- } else if (decryptedPayload.userMessage) {
692
- messageContent = decryptedPayload.userMessage;
693
- } else {
694
- throw new Error('Invalid instant message: no content source available');
695
- }
696
-
697
- } else if (decryptedPayload.messageType === 'prompted' || decryptedPayload.messageType === 'auto') {
698
- messageContent = await _callAI(decryptedPayload);
699
- } else {
700
- throw new Error('Invalid message configuration: no content source available');
701
- }
702
-
703
- // Sentence splitting
704
- const sentences = messageContent
705
- .split(/([。!?!?]+)/)
706
- .reduce((acc, part, i, arr) => {
707
- if (i % 2 === 0 && part.trim()) {
708
- const punctuation = arr[i + 1] || '';
709
- acc.push(part.trim() + punctuation);
710
- }
711
- return acc;
712
- }, [])
713
- .filter(s => s.length > 0);
714
-
715
- const messages = sentences.length > 0 ? sentences : [messageContent];
716
-
717
- if (!ctx.vapid.email || !ctx.vapid.publicKey || !ctx.vapid.privateKey) {
718
- throw new Error('VAPID configuration missing - push notifications cannot be sent');
719
- }
720
-
721
- const pushSubscription = decryptedPayload.pushSubscription;
722
-
723
- for (let i = 0; i < messages.length; i++) {
724
- const notificationPayload = {
725
- title: `来自 ${decryptedPayload.contactName}`,
726
- message: messages[i],
727
- contactName: decryptedPayload.contactName,
728
- messageId: `msg_${randomUUID()}_${task.id || 'instant'}_${i}`,
729
- messageIndex: i + 1,
730
- totalMessages: messages.length,
731
- messageType: decryptedPayload.messageType,
732
- messageSubtype: decryptedPayload.messageSubtype || 'chat',
733
- taskId: task.id || null,
734
- timestamp: new Date().toISOString(),
735
- source: decryptedPayload.messageType === 'instant' ? 'instant' : 'scheduled',
736
- avatarUrl: decryptedPayload.avatarUrl || null,
737
- metadata: decryptedPayload.metadata || {}
738
- };
739
-
740
- await ctx.webpush.sendNotification(pushSubscription, JSON.stringify(notificationPayload));
741
-
742
- if (i < messages.length - 1) {
743
- await new Promise(resolve => setTimeout(resolve, 1500));
744
- }
745
- }
746
-
747
- return { success: true, messagesSent: messages.length };
748
-
749
- } catch (error) {
750
- return { success: false, messagesSent: 0, error: error.message };
751
- }
752
- }
753
-
754
- /**
755
- * Process a single message identified by UUID (used for instant type).
756
- *
757
- * @param {string} uuid
758
- * @param {ProcessorContext} ctx
759
- * @param {number} [maxRetries=2]
760
- * @param {string} [userId]
761
- * @param {string} [providedMasterKey]
762
- * @returns {Promise<{ success: boolean, messagesSent?: number, retriesUsed?: number, error?: Object }>}
763
- */
764
- async function processMessagesByUuid(uuid, ctx, maxRetries = 2, userId, providedMasterKey) {
765
- let retryCount = 0;
766
- const masterKey = providedMasterKey || await ctx.db.getMasterKey();
767
-
768
- if (!masterKey) {
769
- return {
770
- success: false,
771
- error: { code: 'MASTER_KEY_NOT_INITIALIZED', message: '主密钥尚未初始化' }
772
- };
773
- }
774
-
775
- while (retryCount <= maxRetries) {
776
- let task;
777
- try {
778
- task = userId
779
- ? await ctx.db.getTaskByUuid(uuid, userId)
780
- : await ctx.db.getTaskByUuidOnly(uuid);
781
- } catch (error) {
782
- if (retryCount < maxRetries) {
783
- retryCount++;
784
- await new Promise(resolve => setTimeout(resolve, 1000 * retryCount));
785
- continue;
786
- }
787
-
788
- return {
789
- success: false,
790
- error: { code: 'INTERNAL_ERROR', message: error.message, retriesAttempted: retryCount }
791
- };
792
- }
793
-
794
- if (!task) {
795
- return { success: false, error: { code: 'TASK_NOT_FOUND', message: '任务不存在或已处理' } };
796
- }
797
-
798
- const result = await processSingleMessage(task, ctx, masterKey);
799
-
800
- if (!result.success) {
801
- if (retryCount < maxRetries) {
802
- retryCount++;
803
- await new Promise(resolve => setTimeout(resolve, 1000 * retryCount));
804
- continue;
805
- }
806
-
807
- try {
808
- await ctx.db.updateTaskById(task.id, { status: 'failed', retry_count: retryCount });
809
- } catch (_updateError) {
810
- // best-effort status update; keep original processing error as primary signal
811
- }
812
-
813
- return {
814
- success: false,
815
- error: { code: 'PROCESSING_ERROR', message: result.error, retriesAttempted: retryCount }
816
- };
817
- }
818
-
819
- try {
820
- await ctx.db.deleteTaskById(task.id);
821
- } catch (error) {
822
- try {
823
- await ctx.db.updateTaskById(task.id, { status: 'sent', retry_count: 0 });
824
- } catch (_markSentError) {
825
- // best effort: avoid re-sending if storage mutation partially fails
826
- }
827
-
828
- return {
829
- success: false,
830
- error: {
831
- code: 'POST_SEND_CLEANUP_FAILED',
832
- message: '消息已发送,但任务清理失败',
833
- details: { error: error.message }
834
- }
835
- };
836
- }
837
-
838
- return { success: true, messagesSent: result.messagesSent, retriesUsed: retryCount };
839
- }
840
- }
841
-
842
- /**
843
- * Call an OpenAI-compatible API.
844
- * @private
845
- */
846
- async function _callAI(payload) {
847
- const aiResponse = await fetch(payload.apiUrl, {
848
- method: 'POST',
849
- headers: {
850
- 'Content-Type': 'application/json',
851
- 'Authorization': `Bearer ${payload.apiKey}`
852
- },
853
- body: JSON.stringify({
854
- model: payload.primaryModel,
855
- messages: [{ role: 'user', content: payload.completePrompt }],
856
- max_tokens: 500,
857
- temperature: 0.8
858
- }),
859
- signal: AbortSignal.timeout(300000)
860
- });
861
-
862
- if (!aiResponse.ok) {
863
- throw new Error(`AI API error: ${aiResponse.status} ${aiResponse.statusText}`);
864
- }
865
-
866
- const aiData = await aiResponse.json();
867
- return aiData.choices[0].message.content.trim();
868
- }
869
-
870
- /**
871
- * Handler: schedule-message
872
- * ReiStandard SDK v1.2.0
873
- *
874
- * @param {Object} ctx - Server context.
875
- * @returns {{ POST: function }}
876
- */
877
-
878
-
879
- function createScheduleMessageHandler(ctx) {
880
- async function POST(headers, body) {
881
- const isEncrypted = headers['x-payload-encrypted'] === 'true';
882
- const encryptionVersion = headers['x-encryption-version'];
883
- const userId = headers['x-user-id'];
884
-
885
- if (!isEncrypted) {
886
- return { status: 400, body: { success: false, error: { code: 'ENCRYPTION_REQUIRED', message: '请求体必须加密' } } };
887
- }
888
- if (!userId) {
889
- return { status: 400, body: { success: false, error: { code: 'USER_ID_REQUIRED', message: '缺少用户标识符' } } };
890
- }
891
- if (!isValidUUIDv4(userId)) {
892
- return { status: 400, body: { success: false, error: { code: 'INVALID_USER_ID_FORMAT', message: 'X-User-Id 必须是 UUID v4 格式' } } };
893
- }
894
- if (encryptionVersion !== '1') {
895
- return { status: 400, body: { success: false, error: { code: 'UNSUPPORTED_ENCRYPTION_VERSION', message: '加密版本不支持' } } };
896
- }
897
-
898
- const masterKey = await ctx.db.getMasterKey();
899
- if (!masterKey) {
900
- return { status: 503, body: { success: false, error: { code: 'MASTER_KEY_NOT_INITIALIZED', message: '主密钥尚未初始化,请先调用 /api/v1/init-master-key' } } };
901
- }
902
-
903
- // Decrypt request body
904
- const parsedBody = parseEncryptedBody(body);
905
- if (!parsedBody.ok) {
906
- return { status: 400, body: { success: false, error: parsedBody.error } };
907
- }
908
-
909
- const encryptedBody = parsedBody.data;
910
-
911
- let payload;
912
- try {
913
- const userKey = deriveUserEncryptionKey(userId, masterKey);
914
- payload = decryptPayload(encryptedBody, userKey);
915
- } catch (error) {
916
- if (error instanceof SyntaxError) {
917
- return { status: 400, body: { success: false, error: { code: 'INVALID_PAYLOAD_FORMAT', message: '解密后的数据不是有效 JSON' } } };
918
- }
919
-
920
- const message = typeof error.message === 'string' ? error.message : '';
921
- if (message.includes('auth') || message.includes('Unsupported state')) {
922
- return { status: 400, body: { success: false, error: { code: 'DECRYPTION_FAILED', message: '请求体解密失败' } } };
923
- }
924
-
925
- return { status: 400, body: { success: false, error: { code: 'DECRYPTION_FAILED', message: '请求体解密失败' } } };
926
- }
927
-
928
- if (!isPlainObject(payload)) {
929
- return { status: 400, body: { success: false, error: { code: 'INVALID_PAYLOAD_FORMAT', message: '解密后的数据必须是 JSON 对象' } } };
930
- }
931
-
932
- // Validate
933
- const validationResult = validateScheduleMessagePayload(payload);
934
- if (!validationResult.valid) {
935
- return { status: 400, body: { success: false, error: { code: validationResult.errorCode, message: validationResult.errorMessage, details: validationResult.details } } };
936
- }
937
-
938
- const taskUuid = payload.uuid || randomUUID();
939
- const userKey = deriveUserEncryptionKey(userId, masterKey);
940
-
941
- const fullTaskData = {
942
- contactName: payload.contactName,
943
- avatarUrl: payload.avatarUrl || null,
944
- messageType: payload.messageType,
945
- messageSubtype: payload.messageSubtype || 'chat',
946
- userMessage: payload.userMessage || null,
947
- firstSendTime: payload.firstSendTime,
948
- recurrenceType: payload.recurrenceType || 'none',
949
- apiUrl: payload.apiUrl || null,
950
- apiKey: payload.apiKey || null,
951
- primaryModel: payload.primaryModel || null,
952
- completePrompt: payload.completePrompt || null,
953
- pushSubscription: payload.pushSubscription,
954
- metadata: payload.metadata || {}
955
- };
956
-
957
- const encryptedPayload = encryptForStorage(JSON.stringify(fullTaskData), userKey);
958
-
959
- // Instant type: check VAPID before creating the task to avoid orphaned rows
960
- if (payload.messageType === 'instant') {
961
- if (!ctx.vapid.email || !ctx.vapid.publicKey || !ctx.vapid.privateKey) {
962
- return {
963
- status: 500,
964
- body: {
965
- success: false,
966
- error: {
967
- code: 'VAPID_CONFIG_ERROR',
968
- message: 'VAPID 配置缺失,无法发送即时消息',
969
- details: {
970
- missingKeys: [
971
- !ctx.vapid.email && 'VAPID_EMAIL',
972
- !ctx.vapid.publicKey && 'NEXT_PUBLIC_VAPID_PUBLIC_KEY',
973
- !ctx.vapid.privateKey && 'VAPID_PRIVATE_KEY'
974
- ].filter(Boolean)
975
- }
976
- }
977
- }
978
- };
979
- }
980
- }
981
-
982
- // Insert into database
983
- let dbResult;
984
- try {
985
- dbResult = await ctx.db.createTask({
986
- user_id: userId,
987
- uuid: taskUuid,
988
- encrypted_payload: encryptedPayload,
989
- next_send_at: payload.firstSendTime,
990
- message_type: payload.messageType
991
- });
992
- } catch (error) {
993
- if (isUniqueViolation(error)) {
994
- return {
995
- status: 409,
996
- body: {
997
- success: false,
998
- error: {
999
- code: 'TASK_UUID_CONFLICT',
1000
- message: '任务 UUID 已存在,请使用新的 uuid 重新提交'
1001
- }
1002
- }
1003
- };
1004
- }
1005
- throw error;
1006
- }
1007
-
1008
- if (!dbResult) {
1009
- return { status: 500, body: { success: false, error: { code: 'TASK_CREATE_FAILED', message: '创建任务失败' } } };
1010
- }
1011
-
1012
- // Instant type: send immediately
1013
- if (payload.messageType === 'instant') {
1014
- try {
1015
- const sendResult = await processMessagesByUuid(taskUuid, ctx, 2, userId, masterKey);
1016
-
1017
- if (!sendResult.success) {
1018
- return { status: 500, body: { success: false, error: { code: 'MESSAGE_SEND_FAILED', message: '消息发送失败', details: sendResult.error } } };
1019
- }
1020
-
1021
- return {
1022
- status: 200,
1023
- body: {
1024
- success: true,
1025
- data: {
1026
- uuid: taskUuid,
1027
- contactName: payload.contactName,
1028
- messagesSent: sendResult.messagesSent,
1029
- sentAt: new Date().toISOString(),
1030
- status: 'sent',
1031
- retriesUsed: sendResult.retriesUsed || 0
1032
- }
1033
- }
1034
- };
1035
- } catch (error) {
1036
- return { status: 500, body: { success: false, error: { code: 'MESSAGE_SEND_FAILED', message: '消息发送失败', details: { error: error.message } } } };
1037
- }
1038
- }
1039
-
1040
- // Non-instant: return scheduled response
1041
- return {
1042
- status: 201,
1043
- body: {
1044
- success: true,
1045
- data: {
1046
- id: dbResult.id,
1047
- uuid: dbResult.uuid,
1048
- contactName: payload.contactName,
1049
- nextSendAt: dbResult.next_send_at,
1050
- status: dbResult.status,
1051
- createdAt: dbResult.created_at
1052
- }
1053
- }
1054
- };
1055
- }
1056
-
1057
- return { POST };
1058
- }
1059
-
1060
- /**
1061
- * Handler: send-notifications
1062
- * ReiStandard SDK v1.2.0
1063
- *
1064
- * @param {Object} ctx - Server context.
1065
- * @returns {{ POST: function }}
1066
- */
1067
-
1068
-
1069
- function createSendNotificationsHandler(ctx) {
1070
- async function POST(headers) {
1071
- if (!ctx.vapid.email || !ctx.vapid.publicKey || !ctx.vapid.privateKey) {
1072
- return {
1073
- status: 500,
1074
- body: {
1075
- success: false,
1076
- error: {
1077
- code: 'VAPID_CONFIG_ERROR',
1078
- message: 'VAPID 配置缺失,无法发送推送通知',
1079
- details: {
1080
- missingKeys: [
1081
- !ctx.vapid.email && 'VAPID_EMAIL',
1082
- !ctx.vapid.publicKey && 'NEXT_PUBLIC_VAPID_PUBLIC_KEY',
1083
- !ctx.vapid.privateKey && 'VAPID_PRIVATE_KEY'
1084
- ].filter(Boolean)
1085
- }
1086
- }
1087
- }
1088
- };
1089
- }
1090
-
1091
- // Verify Cron Secret
1092
- const authHeader = (headers['authorization'] || '').trim();
1093
- const expectedAuth = `Bearer ${ctx.cronSecret}`;
1094
-
1095
- if (authHeader !== expectedAuth) {
1096
- return { status: 401, body: { success: false, error: { code: 'UNAUTHORIZED', message: 'Cron Secret 验证失败' } } };
1097
- }
1098
-
1099
- const masterKey = await ctx.db.getMasterKey();
1100
- if (!masterKey) {
1101
- return {
1102
- status: 503,
1103
- body: {
1104
- success: false,
1105
- error: {
1106
- code: 'MASTER_KEY_NOT_INITIALIZED',
1107
- message: '主密钥尚未初始化,请先调用 /api/v1/init-master-key'
1108
- }
1109
- }
1110
- };
1111
- }
1112
-
1113
- const startTime = Date.now();
1114
- const tasks = await ctx.db.getPendingTasks(50);
1115
-
1116
- const MAX_CONCURRENT = 8;
1117
- const results = {
1118
- totalTasks: tasks.length,
1119
- successCount: 0,
1120
- failedCount: 0,
1121
- deletedOnceOffTasks: 0,
1122
- updatedRecurringTasks: 0,
1123
- failedTasks: []
1124
- };
1125
-
1126
- async function handleDeliveryFailure(task, reason) {
1127
- results.failedCount++;
1128
-
1129
- try {
1130
- if (task.retry_count >= 3) {
1131
- await ctx.db.updateTaskById(task.id, { status: 'failed' });
1132
- results.failedTasks.push({ taskId: task.id, reason, retryCount: task.retry_count, status: 'permanently_failed' });
1133
- } else {
1134
- const nextRetryTime = new Date(Date.now() + (task.retry_count + 1) * 2 * 60 * 1000);
1135
- await ctx.db.updateTaskById(task.id, { next_send_at: nextRetryTime.toISOString(), retry_count: task.retry_count + 1 });
1136
- results.failedTasks.push({ taskId: task.id, reason, retryCount: task.retry_count + 1, nextRetryAt: nextRetryTime.toISOString() });
1137
- }
1138
- } catch (updateError) {
1139
- results.failedTasks.push({
1140
- taskId: task.id,
1141
- reason,
1142
- status: 'retry_update_failed',
1143
- updateError: updateError.message
1144
- });
1145
- }
1146
- }
1147
-
1148
- async function handlePostSendPersistenceFailure(task, reason) {
1149
- results.failedCount++;
1150
-
1151
- let markedSent = false;
1152
- try {
1153
- await ctx.db.updateTaskById(task.id, { status: 'sent', retry_count: 0 });
1154
- markedSent = true;
1155
- } catch (_markSentError) {
1156
- markedSent = false;
1157
- }
1158
-
1159
- results.failedTasks.push({
1160
- taskId: task.id,
1161
- reason,
1162
- status: markedSent ? 'post_send_cleanup_failed_marked_sent' : 'post_send_cleanup_failed',
1163
- messageDelivered: true
1164
- });
1165
- }
1166
-
1167
- async function processTask(task) {
1168
- let sendResult;
1169
- try {
1170
- sendResult = await processSingleMessage(task, ctx);
1171
- } catch (error) {
1172
- await handleDeliveryFailure(task, error.message || '消息发送失败');
1173
- return;
1174
- }
1175
-
1176
- if (!sendResult.success) {
1177
- await handleDeliveryFailure(task, sendResult.error || '消息发送失败');
1178
- return;
1179
- }
1180
-
1181
- try {
1182
- const userKey = deriveUserEncryptionKey(task.user_id, masterKey);
1183
- const decryptedPayload = JSON.parse(decryptFromStorage(task.encrypted_payload, userKey));
1184
-
1185
- if (decryptedPayload.recurrenceType === 'none') {
1186
- await ctx.db.deleteTaskById(task.id);
1187
- results.deletedOnceOffTasks++;
1188
- } else {
1189
- let nextSendAt;
1190
- const currentSendAt = new Date(task.next_send_at);
1191
- if (decryptedPayload.recurrenceType === 'daily') {
1192
- nextSendAt = new Date(currentSendAt.getTime() + 24 * 60 * 60 * 1000);
1193
- } else if (decryptedPayload.recurrenceType === 'weekly') {
1194
- nextSendAt = new Date(currentSendAt.getTime() + 7 * 24 * 60 * 60 * 1000);
1195
- }
1196
- await ctx.db.updateTaskById(task.id, { next_send_at: nextSendAt.toISOString(), retry_count: 0 });
1197
- results.updatedRecurringTasks++;
1198
- }
1199
-
1200
- results.successCount++;
1201
- } catch (error) {
1202
- await handlePostSendPersistenceFailure(task, error.message || '发送后状态更新失败');
1203
- }
1204
- }
1205
-
1206
- // Dynamic task pool
1207
- const taskQueue = [...tasks];
1208
- const processing = [];
1209
-
1210
- while (taskQueue.length > 0 || processing.length > 0) {
1211
- while (processing.length < MAX_CONCURRENT && taskQueue.length > 0) {
1212
- const task = taskQueue.shift();
1213
- const promise = processTask(task);
1214
- processing.push(promise);
1215
- promise.finally(() => {
1216
- const index = processing.indexOf(promise);
1217
- if (index > -1) processing.splice(index, 1);
1218
- });
1219
- }
1220
- if (processing.length > 0) {
1221
- await Promise.race(processing);
1222
- }
1223
- }
1224
-
1225
- // Cleanup old tasks
1226
- await ctx.db.cleanupOldTasks(7);
1227
-
1228
- const executionTime = Date.now() - startTime;
1229
-
1230
- return {
1231
- status: 200,
1232
- body: {
1233
- success: true,
1234
- data: {
1235
- totalTasks: results.totalTasks,
1236
- successCount: results.successCount,
1237
- failedCount: results.failedCount,
1238
- processedAt: new Date().toISOString(),
1239
- executionTime,
1240
- details: {
1241
- deletedOnceOffTasks: results.deletedOnceOffTasks,
1242
- updatedRecurringTasks: results.updatedRecurringTasks,
1243
- failedTasks: results.failedTasks
1244
- }
1245
- }
1246
- }
1247
- };
1248
- }
1249
-
1250
- return { POST };
1251
- }
1252
-
1253
- /**
1254
- * Handler: update-message
1255
- * ReiStandard SDK v1.2.0
1256
- *
1257
- * @param {Object} ctx - Server context.
1258
- * @returns {{ PUT: function }}
1259
- */
1260
-
1261
-
1262
- function createUpdateMessageHandler(ctx) {
1263
- async function PUT(url, headers, body) {
1264
- const u = new URL(url, 'https://dummy');
1265
- const taskUuid = u.searchParams.get('id');
1266
-
1267
- if (!taskUuid) {
1268
- return { status: 400, body: { success: false, error: { code: 'TASK_ID_REQUIRED', message: '缺少任务ID' } } };
1269
- }
1270
-
1271
- const userId = headers['x-user-id'];
1272
- if (!userId) {
1273
- return { status: 400, body: { success: false, error: { code: 'USER_ID_REQUIRED', message: '缺少用户标识符' } } };
1274
- }
1275
- if (!isValidUUIDv4(userId)) {
1276
- return { status: 400, body: { success: false, error: { code: 'INVALID_USER_ID_FORMAT', message: 'X-User-Id 必须是 UUID v4 格式' } } };
1277
- }
1278
-
1279
- const isEncrypted = headers['x-payload-encrypted'] === 'true';
1280
- const encryptionVersion = headers['x-encryption-version'];
1281
-
1282
- if (!isEncrypted) {
1283
- return { status: 400, body: { success: false, error: { code: 'ENCRYPTION_REQUIRED', message: '请求体必须加密' } } };
1284
- }
1285
-
1286
- if (encryptionVersion !== '1') {
1287
- return { status: 400, body: { success: false, error: { code: 'UNSUPPORTED_ENCRYPTION_VERSION', message: '加密版本不支持' } } };
1288
- }
1289
-
1290
- const masterKey = await ctx.db.getMasterKey();
1291
- if (!masterKey) {
1292
- return { status: 503, body: { success: false, error: { code: 'MASTER_KEY_NOT_INITIALIZED', message: '主密钥尚未初始化,请先调用 /api/v1/init-master-key' } } };
1293
- }
1294
-
1295
- const parsedBody = parseEncryptedBody(body);
1296
- if (!parsedBody.ok) {
1297
- return { status: 400, body: { success: false, error: parsedBody.error } };
1298
- }
1299
-
1300
- const encryptedBody = parsedBody.data;
1301
- const userKey = deriveUserEncryptionKey(userId, masterKey);
1302
- let updates;
1303
-
1304
- try {
1305
- updates = decryptPayload(encryptedBody, userKey);
1306
- } catch (_error) {
1307
- return { status: 400, body: { success: false, error: { code: 'DECRYPTION_FAILED', message: '请求体解密失败' } } };
1308
- }
1309
-
1310
- if (!isPlainObject(updates)) {
1311
- return { status: 400, body: { success: false, error: { code: 'INVALID_UPDATE_DATA', message: '更新数据格式错误' } } };
1312
- }
1313
-
1314
- if (updates.nextSendAt && !isValidISO8601(updates.nextSendAt)) {
1315
- return { status: 400, body: { success: false, error: { code: 'INVALID_UPDATE_DATA', message: '更新数据格式错误', details: { invalidFields: ['nextSendAt'] } } } };
1316
- }
1317
-
1318
- if (updates.recurrenceType && !['none', 'daily', 'weekly'].includes(updates.recurrenceType)) {
1319
- return { status: 400, body: { success: false, error: { code: 'INVALID_UPDATE_DATA', message: '更新数据格式错误', details: { invalidFields: ['recurrenceType'] } } } };
1320
- }
1321
-
1322
- // Fetch existing task
1323
- const existingTask = await ctx.db.getTaskByUuid(taskUuid, userId);
1324
-
1325
- if (!existingTask) {
1326
- const taskStatus = await ctx.db.getTaskStatus(taskUuid, userId);
1327
- if (!taskStatus) {
1328
- return { status: 404, body: { success: false, error: { code: 'TASK_NOT_FOUND', message: '指定的任务不存在或已被删除' } } };
1329
- }
1330
- return { status: 409, body: { success: false, error: { code: 'TASK_ALREADY_COMPLETED', message: '任务已完成或已失败,无法更新' } } };
1331
- }
1332
-
1333
- const existingData = JSON.parse(decryptFromStorage(existingTask.encrypted_payload, userKey));
1334
-
1335
- const updatedData = {
1336
- ...existingData,
1337
- ...(updates.completePrompt && { completePrompt: updates.completePrompt }),
1338
- ...(updates.userMessage && { userMessage: updates.userMessage }),
1339
- ...(updates.recurrenceType && { recurrenceType: updates.recurrenceType }),
1340
- ...(updates.avatarUrl && { avatarUrl: updates.avatarUrl }),
1341
- ...(updates.metadata && { metadata: updates.metadata })
1342
- };
1343
-
1344
- const encryptedPayload = encryptForStorage(JSON.stringify(updatedData), userKey);
1345
- const extraFields = updates.nextSendAt ? { next_send_at: updates.nextSendAt } : undefined;
1346
-
1347
- const result = await ctx.db.updateTaskByUuid(taskUuid, userId, encryptedPayload, extraFields);
1348
-
1349
- if (!result) {
1350
- return { status: 409, body: { success: false, error: { code: 'UPDATE_CONFLICT', message: '任务更新失败,任务可能已被修改或删除' } } };
1351
- }
1352
-
1353
- return {
1354
- status: 200,
1355
- body: {
1356
- success: true,
1357
- data: {
1358
- uuid: taskUuid,
1359
- updatedFields: Object.keys(updates),
1360
- updatedAt: result.updated_at
1361
- }
1362
- }
1363
- };
1364
- }
1365
-
1366
- return { PUT };
1367
- }
1368
-
1369
- /**
1370
- * Handler: cancel-message
1371
- * ReiStandard SDK v1.2.0
1372
- *
1373
- * @param {Object} ctx - Server context.
1374
- * @returns {{ DELETE: function }}
1375
- */
1376
-
1377
-
1378
- function createCancelMessageHandler(ctx) {
1379
- async function DELETE(url, headers) {
1380
- const u = new URL(url, 'https://dummy');
1381
- const taskUuid = u.searchParams.get('id');
1382
-
1383
- if (!taskUuid) {
1384
- return { status: 400, body: { success: false, error: { code: 'TASK_ID_REQUIRED', message: '缺少任务ID' } } };
1385
- }
1386
-
1387
- const userId = headers['x-user-id'];
1388
- if (!userId) {
1389
- return { status: 400, body: { success: false, error: { code: 'USER_ID_REQUIRED', message: '缺少用户标识符' } } };
1390
- }
1391
- if (!isValidUUIDv4(userId)) {
1392
- return { status: 400, body: { success: false, error: { code: 'INVALID_USER_ID_FORMAT', message: 'X-User-Id 必须是 UUID v4 格式' } } };
1393
- }
1394
-
1395
- const deleted = await ctx.db.deleteTaskByUuid(taskUuid, userId);
1396
-
1397
- if (!deleted) {
1398
- return {
1399
- status: 404,
1400
- body: { success: false, error: { code: 'TASK_NOT_FOUND', message: '指定的任务不存在或已被删除' } }
1401
- };
1402
- }
1403
-
1404
- return {
1405
- status: 200,
1406
- body: {
1407
- success: true,
1408
- data: { uuid: taskUuid, message: '任务已成功取消', deletedAt: new Date().toISOString() }
1409
- }
1410
- };
1411
- }
1412
-
1413
- return { DELETE };
1414
- }
1415
-
1416
- /**
1417
- * Handler: messages
1418
- * ReiStandard SDK v1.2.0
1419
- *
1420
- * @param {Object} ctx - Server context.
1421
- * @returns {{ GET: function }}
1422
- */
1423
-
1424
-
1425
- function createMessagesHandler(ctx) {
1426
- async function GET(url, headers) {
1427
- const userId = headers['x-user-id'];
1428
-
1429
- if (!userId) {
1430
- return {
1431
- status: 400,
1432
- body: { success: false, error: { code: 'USER_ID_REQUIRED', message: '必须提供 X-User-Id 请求头' } }
1433
- };
1434
- }
1435
- if (!isValidUUIDv4(userId)) {
1436
- return {
1437
- status: 400,
1438
- body: { success: false, error: { code: 'INVALID_USER_ID_FORMAT', message: 'X-User-Id 必须是 UUID v4 格式' } }
1439
- };
1440
- }
1441
-
1442
- const u = new URL(url, 'https://dummy');
1443
- const status = u.searchParams.get('status') || 'all';
1444
- const limit = Math.min(parseInt(u.searchParams.get('limit') || '20', 10), 100);
1445
- const offset = parseInt(u.searchParams.get('offset') || '0', 10);
1446
-
1447
- if (isNaN(limit) || limit < 1) {
1448
- return { status: 400, body: { success: false, error: { code: 'INVALID_PARAMETERS', message: 'limit 参数无效,必须为正整数' } } };
1449
- }
1450
-
1451
- if (isNaN(offset) || offset < 0) {
1452
- return { status: 400, body: { success: false, error: { code: 'INVALID_PARAMETERS', message: 'offset 参数无效,必须为非负整数' } } };
1453
- }
1454
-
1455
- const { tasks, total } = await ctx.db.listTasks(userId, { status, limit, offset });
1456
-
1457
- const masterKey = await ctx.db.getMasterKey();
1458
- if (!masterKey) {
1459
- return {
1460
- status: 503,
1461
- body: { success: false, error: { code: 'MASTER_KEY_NOT_INITIALIZED', message: '主密钥尚未初始化,请先调用 /api/v1/init-master-key' } }
1462
- };
1463
- }
1464
-
1465
- const userKey = deriveUserEncryptionKey(userId, masterKey);
1466
-
1467
- const decryptedTasks = tasks.map(task => {
1468
- const decrypted = JSON.parse(decryptFromStorage(task.encrypted_payload, userKey));
1469
- return {
1470
- id: task.id,
1471
- uuid: task.uuid,
1472
- contactName: decrypted.contactName,
1473
- messageType: task.message_type,
1474
- messageSubtype: decrypted.messageSubtype,
1475
- nextSendAt: task.next_send_at,
1476
- recurrenceType: decrypted.recurrenceType,
1477
- status: task.status,
1478
- retryCount: task.retry_count,
1479
- createdAt: task.created_at,
1480
- updatedAt: task.updated_at
1481
- };
1482
- });
1483
-
1484
- const responsePayload = {
1485
- tasks: decryptedTasks,
1486
- pagination: { total, limit, offset, hasMore: offset + limit < total }
1487
- };
1488
- const encryptedResponse = encryptPayload(responsePayload, userKey);
1489
-
1490
- return {
1491
- status: 200,
1492
- body: {
1493
- success: true,
1494
- encrypted: true,
1495
- version: 1,
1496
- data: encryptedResponse
1497
- }
1498
- };
1499
- }
1500
-
1501
- return { GET };
1502
- }
1503
-
1504
- /**
1505
- * ReiStandard Server SDK Entry Point
1506
- * v1.2.0
1507
- *
1508
- * Usage:
1509
- * import { createReiServer } from '@rei-standard/amsg-server';
1510
- *
1511
- * const rei = createReiServer({
1512
- * db: { driver: 'neon', connectionString: process.env.DATABASE_URL },
1513
- * cronSecret: process.env.CRON_SECRET,
1514
- * vapid: {
1515
- * email: process.env.VAPID_EMAIL,
1516
- * publicKey: process.env.NEXT_PUBLIC_VAPID_PUBLIC_KEY,
1517
- * privateKey: process.env.VAPID_PRIVATE_KEY,
1518
- * },
1519
- * });
1520
- *
1521
- * // rei.handlers – object with standard route handler factories
1522
- * // rei.adapter – the underlying database adapter
1523
- */
1524
-
1525
-
1526
- function normalizeVapidSubject(email) {
1527
- const trimmedEmail = String(email || '').trim();
1528
- if (!trimmedEmail) return '';
1529
- return /^mailto:/i.test(trimmedEmail) ? trimmedEmail : `mailto:${trimmedEmail}`;
1530
- }
1531
-
1532
- /**
1533
- * @typedef {Object} VapidConfig
1534
- * @property {string} [email] - VAPID contact email (e.g. mailto:…).
1535
- * @property {string} [publicKey] - VAPID public key.
1536
- * @property {string} [privateKey] - VAPID private key.
1537
- */
1538
-
1539
- /**
1540
- * @typedef {'neon'|'pg'} DriverName
1541
- */
1542
-
1543
- /**
1544
- * @typedef {Object} DbConfig
1545
- * @property {DriverName} driver - Database driver name.
1546
- * @property {string} connectionString - Database connection URL.
1547
- */
1548
-
1549
- /**
1550
- * @typedef {Object} ReiServerConfig
1551
- * @property {DbConfig} db - Database configuration.
1552
- * @property {string} [cronSecret] - Bearer token for cron-triggered endpoints.
1553
- * @property {VapidConfig} [vapid] - VAPID keys for Web Push.
1554
- */
1555
-
1556
- /**
1557
- * @typedef {Object} ReiHandlers
1558
- * @property {{ GET: function }} initDatabase
1559
- * @property {{ POST: function }} initMasterKey
1560
- * @property {{ GET: function }} getUserKey
1561
- * @property {{ POST: function }} scheduleMessage
1562
- * @property {{ POST: function }} sendNotifications
1563
- * @property {{ PUT: function }} updateMessage
1564
- * @property {{ DELETE: function }} cancelMessage
1565
- * @property {{ GET: function }} messages
1566
- */
1567
-
1568
- /**
1569
- * @typedef {Object} ReiServer
1570
- * @property {ReiHandlers} handlers - The 7 standard API route handler objects.
1571
- * @property {import('./adapters/interface.js').DbAdapter} adapter - The database adapter instance.
1572
- */
1573
-
1574
- /**
1575
- * Initialise the ReiStandard server.
1576
- *
1577
- * @param {ReiServerConfig} config
1578
- * @returns {Promise<ReiServer>}
1579
- */
1580
- async function createReiServer(config) {
1581
- if (!config) throw new Error('[rei-standard-amsg-server] config is required');
1582
-
1583
- const adapter = await createAdapter(config.db);
1584
-
1585
- // web-push is a hard dependency for ReiStandard server features
1586
- let webpushModule;
1587
- try {
1588
- const webpushImport = await import('web-push');
1589
- webpushModule = webpushImport.default || webpushImport;
1590
- } catch (_err) {
1591
- throw new Error(
1592
- '[rei-standard-amsg-server] web-push is required. Install it with: npm install web-push'
1593
- );
1594
- }
1595
-
1596
- const vapid = config.vapid || {};
1597
-
1598
- if (vapid.email && vapid.publicKey && vapid.privateKey) {
1599
- webpushModule.setVapidDetails(
1600
- normalizeVapidSubject(vapid.email),
1601
- vapid.publicKey,
1602
- vapid.privateKey
1603
- );
1604
- }
1605
-
1606
- /** @type {Object} Shared context injected into every handler */
1607
- const ctx = {
1608
- db: adapter,
1609
- cronSecret: config.cronSecret || '',
1610
- vapid: {
1611
- email: vapid.email || '',
1612
- publicKey: vapid.publicKey || '',
1613
- privateKey: vapid.privateKey || ''
1614
- },
1615
- webpush: webpushModule
1616
- };
1617
-
1618
- return {
1619
- handlers: {
1620
- initDatabase: createInitDatabaseHandler(ctx),
1621
- initMasterKey: createInitMasterKeyHandler(ctx),
1622
- getUserKey: createGetUserKeyHandler(ctx),
1623
- scheduleMessage: createScheduleMessageHandler(ctx),
1624
- sendNotifications: createSendNotificationsHandler(ctx),
1625
- updateMessage: createUpdateMessageHandler(ctx),
1626
- cancelMessage: createCancelMessageHandler(ctx),
1627
- messages: createMessagesHandler(ctx)
1628
- },
1629
- adapter
1630
- };
1631
- }
1632
-
1633
- export { COLUMNS_SQL as C, INDEXES as I, SYSTEM_CONFIG_SQL as S, TABLE_SQL as T, VERIFY_TABLE_SQL as V, VERIFY_SYSTEM_CONFIG_SQL as a, SYSTEM_CONFIG_COLUMNS_SQL as b, createAdapter as c, createReiServer as d, decryptFromStorage as e, decryptPayload as f, deriveUserEncryptionKey as g, encryptForStorage as h, isValidISO8601 as i, isValidUUID as j, isValidUUIDv4 as k, isValidUrl as l, validateScheduleMessagePayload as v };