diff --git a/Source/Process.ts b/Source/Process.ts index 892c9a3..38fc031 100644 --- a/Source/Process.ts +++ b/Source/Process.ts @@ -1492,6 +1492,78 @@ export class Process { const responseJSON = await response.json(); return new Result(true, "获得统计数据成功", responseJSON); }, + SetUserSettings: async (Data: object): Promise => { + // Enforce a maximum allowed size for the settings payload to avoid + // excessively large entries being written to D1. + const MAX_SETTINGS_LENGTH = 10000; + + ThrowErrorIfFailed(this.CheckParams(Data, { + "Settings": "string" + })); + + const SettingsString = Data["Settings"]; + if (typeof SettingsString !== "string") { + return new Result(false, "设置格式有误"); + } + if (SettingsString.length > MAX_SETTINGS_LENGTH) { + return new Result(false, "设置内容过大"); + } + + let SettingsObject: object; + try { + SettingsObject = JSON.parse(SettingsString); + } catch (_) { + return new Result(false, "设置格式有误"); + } + if (typeof SettingsObject !== "object" || Array.isArray(SettingsObject) || SettingsObject === null) { + return new Result(false, "设置格式有误"); + } + try { + // Try to insert first. If a unique/primary key constraint is hit (row already exists), + // fall back to updating the existing row. This avoids a non-atomic check-then-insert flow. + ThrowErrorIfFailed(await this.XMOJDatabase.Insert("user_settings", { + user_id: this.Username, + settings: SettingsString + })); + } catch (e) { + const errorMessage = e instanceof Error ? e.message : (typeof e === "object" && e !== null && "Message" in e ? String((e as { Message?: unknown }).Message) : String(e)); + if (/UNIQUE|constraint|duplicate/i.test(errorMessage)) { + // Row for this user_id already exists, perform an update instead. + ThrowErrorIfFailed(await this.XMOJDatabase.Update("user_settings", { + settings: SettingsString + }, { + user_id: this.Username + })); + } else { + // Propagate non-uniqueness errors. + throw e; + } + } + return new Result(true, "保存设置成功"); + }, + GetUserSettings: async (Data: object): Promise => { + ThrowErrorIfFailed(this.CheckParams(Data, {})); + const SettingsData = ThrowErrorIfFailed(await this.XMOJDatabase.Select("user_settings", ["settings"], { + user_id: this.Username + })); + if (SettingsData.length === 0) { + return new Result(true, "获得设置成功", { + "Settings": {} + }); + } + let SettingsObject: object; + try { + SettingsObject = JSON.parse(SettingsData[0]["settings"]); + } catch (_) { + return new Result(false, "设置数据损坏"); + } + if (typeof SettingsObject !== "object" || Array.isArray(SettingsObject) || SettingsObject === null) { + return new Result(false, "设置数据损坏"); + } + return new Result(true, "获得设置成功", { + "Settings": SettingsObject + }); + }, LastOnline: async (Data: object): Promise => { ThrowErrorIfFailed(this.CheckParams(Data, { "Username": "string" diff --git a/migrations/0004_add_user_settings.sql b/migrations/0004_add_user_settings.sql new file mode 100644 index 0000000..6c62c06 --- /dev/null +++ b/migrations/0004_add_user_settings.sql @@ -0,0 +1,6 @@ +-- Migration number: 0004 2026-03-22 + +CREATE TABLE IF NOT EXISTS user_settings ( + user_id TEXT PRIMARY KEY NOT NULL, + settings TEXT NOT NULL DEFAULT '{}' +); diff --git a/test/process.test.js b/test/process.test.js index 58cf9d4..1ada256 100644 --- a/test/process.test.js +++ b/test/process.test.js @@ -399,3 +399,107 @@ test('AddBBSMention pushes full mention payload for websocket clients', async () }); assert.strictEqual(typeof pushedBody.notification.data.MentionTime, 'number'); }); + +test('SetUserSettings creates new settings row when none exists', async () => { + const insertedRows = []; + const proc = createProcess({ + db: { + GetTableSize: async () => new Result(true, '', { TableSize: 0 }), + Insert: async (table, data) => { + insertedRows.push({ table, data }); + return new Result(true, '', { InsertID: 1 }); + } + } + }); + const result = await proc.ProcessFunctions['SetUserSettings']({ Settings: '{"Discussion":"true","Theme":"dark"}' }); + assert.ok(result.Success); + assert.strictEqual(result.Message, '保存设置成功'); + assert.strictEqual(insertedRows.length, 1); + assert.strictEqual(insertedRows[0].table, 'user_settings'); + assert.strictEqual(insertedRows[0].data.user_id, 'testuser'); + assert.strictEqual(insertedRows[0].data.settings, '{"Discussion":"true","Theme":"dark"}'); +}); + +test('SetUserSettings updates existing settings row', async () => { + const updatedRows = []; + const proc = createProcess({ + db: { + Insert: async () => { + // Simulate a primary key conflict (row already exists) + throw new Error('UNIQUE constraint failed: user_settings.user_id'); + }, + Update: async (table, data, where) => { + updatedRows.push({ table, data, where }); + return new Result(true, ''); + } + } + }); + const result = await proc.ProcessFunctions['SetUserSettings']({ Settings: '{"Discussion":"false"}' }); + assert.ok(result.Success); + assert.strictEqual(result.Message, '保存设置成功'); + assert.strictEqual(updatedRows.length, 1); + assert.strictEqual(updatedRows[0].table, 'user_settings'); + assert.strictEqual(updatedRows[0].data.settings, '{"Discussion":"false"}'); + assert.deepStrictEqual(updatedRows[0].where, { user_id: 'testuser' }); +}); + +test('SetUserSettings fails with invalid JSON', async () => { + const proc = createProcess(); + const result = await proc.ProcessFunctions['SetUserSettings']({ Settings: 'not-json' }); + assert.strictEqual(result.Success, false); + assert.strictEqual(result.Message, '设置格式有误'); +}); + +test('SetUserSettings fails when Settings is an array', async () => { + const proc = createProcess(); + const result = await proc.ProcessFunctions['SetUserSettings']({ Settings: '["a","b"]' }); + assert.strictEqual(result.Success, false); + assert.strictEqual(result.Message, '设置格式有误'); +}); + +test('GetUserSettings returns empty object when no settings stored', async () => { + const proc = createProcess({ + db: { + Select: async () => new Result(true, '', []) + } + }); + const result = await proc.ProcessFunctions['GetUserSettings']({}); + assert.ok(result.Success); + assert.strictEqual(result.Message, '获得设置成功'); + assert.deepStrictEqual(result.Data.Settings, {}); +}); + +test('GetUserSettings returns stored settings', async () => { + const proc = createProcess({ + db: { + Select: async () => new Result(true, '', [{ settings: '{"Discussion":"true","Theme":"dark"}' }]) + } + }); + const result = await proc.ProcessFunctions['GetUserSettings']({}); + assert.ok(result.Success); + assert.strictEqual(result.Message, '获得设置成功'); + assert.deepStrictEqual(result.Data.Settings, { Discussion: 'true', Theme: 'dark' }); +}); + +test('GetUserSettings fails when stored settings JSON is corrupted', async () => { + const proc = createProcess({ + db: { + Select: async () => new Result(true, '', [{ settings: 'corrupted{json' }]) + } + }); + const result = await proc.ProcessFunctions['GetUserSettings']({}); + assert.strictEqual(result.Success, false); + assert.strictEqual(result.Message, '设置数据损坏'); +}); + +test('GetUserSettings fails when stored settings JSON is valid but not an object', async () => { + const proc = createProcess({ + db: { + // settings is valid JSON (an array) but not an object + Select: async () => new Result(true, '', [{ settings: '["a","b"]' }]) + } + }); + const result = await proc.ProcessFunctions['GetUserSettings']({}); + assert.strictEqual(result.Success, false); + assert.strictEqual(result.Message, '设置数据损坏'); +});