From dc05c2a63c701ff94e8cc46636e000261a2aa4cf Mon Sep 17 00:00:00 2001 From: boomzero Date: Mon, 27 Jul 2026 13:54:49 +0800 Subject: [PATCH] Fetch an entire discussion page in one D1 query GetPost issued five awaited queries in series - the post row, a COUNT over bbs_reply, the board name, the lock row, and finally the page of replies. On Workers each is a separate round trip to D1, so opening a discussion cost five times the network latency no matter how little data came back. Collapse them into a single statement built from two CTEs: `post` (the post row left-joined to bbs_board and bbs_lock) and `page` (the fifteen replies for the requested page), cross-joined so every row carries the post metadata alongside one reply, with the total reply count as a scalar subquery. The page's LIMIT/OFFSET has to be bound before PageCount is known, so the offset is clamped at zero and the rows are discarded when the range check rejects the page. Responses are unchanged, including the out-of-range and empty-discussion cases. Co-Authored-By: Claude Opus 5 --- Source/Process.ts | 69 ++++++++++++------- test/process.test.js | 161 +++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 205 insertions(+), 25 deletions(-) diff --git a/Source/Process.ts b/Source/Process.ts index d8afb56..ebc8f2e 100644 --- a/Source/Process.ts +++ b/Source/Process.ts @@ -669,13 +669,38 @@ export class Process { LockTime: 0 } }; - const Post = ThrowErrorIfFailed(await this.XMOJDatabase.Select("bbs_post", [], { - post_id: Data["PostID"] - })); - if (Post.toString() == "") { + // Post metadata, board name, lock state, total reply count and the + // requested page of replies all come back in one round trip instead of + // five sequential ones - the round trips dominated the time to open a + // discussion. The page CTE is bound before PageCount is known, so the + // offset is clamped here and the rows are simply discarded when the + // range check below rejects the page. + const Offset = Math.max(0, (Data["Page"] - 1) * 15); + const Rows: Array> = (await this.RawDatabase.prepare( + "WITH post AS (" + + " SELECT p.user_id AS post_user_id, p.problem_id AS problem_id, p.title AS title, " + + " p.post_time AS post_time, p.board_id AS board_id, " + + " b.board_name AS board_name, l.lock_person AS lock_person, l.lock_time AS lock_time " + + " FROM bbs_post p " + + " LEFT JOIN bbs_board b ON b.board_id = p.board_id " + + " LEFT JOIN bbs_lock l ON l.post_id = p.post_id " + + " WHERE p.post_id = ?" + + "), page AS (" + + " SELECT reply_id, user_id AS reply_user_id, content, reply_time, edit_time, edit_person " + + " FROM bbs_reply WHERE post_id = ? ORDER BY reply_time ASC LIMIT ? OFFSET ?" + + ") " + + "SELECT post.*, " + + "(SELECT COUNT(*) FROM bbs_reply WHERE post_id = ?) AS reply_count, " + + "page.reply_id AS reply_id, page.reply_user_id AS reply_user_id, page.content AS content, " + + "page.reply_time AS reply_time, page.edit_time AS edit_time, page.edit_person AS edit_person " + + "FROM post LEFT JOIN page ON 1 = 1;" + ).bind(Data["PostID"], Data["PostID"], 15, Offset, Data["PostID"]).all())["results"]; + + if (Rows.length === 0) { return new Result(false, "该讨论不存在"); } - ResponseData.PageCount = Math.ceil(ThrowErrorIfFailed(await this.XMOJDatabase.GetTableSize("bbs_reply", {post_id: Data["PostID"]}))["TableSize"] / 15); + const Post = Rows[0]; + ResponseData.PageCount = Math.ceil(Post["reply_count"] / 15); if (ResponseData.PageCount === 0) { return new Result(true, "获得讨论成功", ResponseData); } @@ -686,34 +711,28 @@ export class Process { post_id: Data["PostID"], to_user_id: this.Username }); - ResponseData.UserID = Post[0]["user_id"]; - ResponseData.ProblemID = Post[0]["problem_id"]; - ResponseData.Title = Post[0]["title"]; - ResponseData.PostTime = Post[0]["post_time"]; - ResponseData.BoardID = Post[0]["board_id"]; - ResponseData.BoardName = ThrowErrorIfFailed(await this.XMOJDatabase.Select("bbs_board", ["board_name"], {board_id: Post[0]["board_id"]}))[0]["board_name"]; + ResponseData.UserID = Post["post_user_id"]; + ResponseData.ProblemID = Post["problem_id"]; + ResponseData.Title = Post["title"]; + ResponseData.PostTime = Post["post_time"]; + ResponseData.BoardID = Post["board_id"]; + ResponseData.BoardName = Post["board_name"]; - const Locked = ThrowErrorIfFailed(await this.XMOJDatabase.Select("bbs_lock", [], { - post_id: Data["PostID"] - })); - if (Locked.toString() !== "") { + if (Post["lock_person"] !== null && Post["lock_person"] !== undefined) { ResponseData.Lock.Locked = true; - ResponseData.Lock.LockPerson = Locked[0]["lock_person"]; - ResponseData.Lock.LockTime = Locked[0]["lock_time"]; + ResponseData.Lock.LockPerson = Post["lock_person"]; + ResponseData.Lock.LockTime = Post["lock_time"]; } - const Reply = ThrowErrorIfFailed(await this.XMOJDatabase.Select("bbs_reply", [], {post_id: Data["PostID"]}, { - Order: "reply_time", - OrderIncreasing: true, - Limit: 15, - Offset: (Data["Page"] - 1) * 15 - })); - for (const ReplyItem of Reply) { + for (const ReplyItem of Rows) { + if (ReplyItem["reply_id"] === null || ReplyItem["reply_id"] === undefined) { + continue; + } let processedContent: string = ReplyItem["content"]; processedContent = processedContent.replace(/xmoj-bbs\.tech/g, "xmoj-bbs.me"); ResponseData.Reply.push({ ReplyID: ReplyItem["reply_id"], - UserID: ReplyItem["user_id"], + UserID: ReplyItem["reply_user_id"], Content: processedContent, ReplyTime: ReplyItem["reply_time"], EditTime: ReplyItem["edit_time"], diff --git a/test/process.test.js b/test/process.test.js index 1ada256..c4785b2 100644 --- a/test/process.test.js +++ b/test/process.test.js @@ -503,3 +503,164 @@ test('GetUserSettings fails when stored settings JSON is valid but not an object assert.strictEqual(result.Success, false); assert.strictEqual(result.Message, '设置数据损坏'); }); + +function stubGetPostQuery(proc, rows) { + const calls = []; + proc.RawDatabase = { + prepare: (query) => ({ + bind: (...args) => ({ + all: async () => { + calls.push({ query, args }); + return { results: rows }; + } + }) + }) + }; + return calls; +} + +function postRow(overrides = {}) { + return Object.assign({ + post_user_id: 'alice', + problem_id: 1000, + title: 'Post one', + post_time: 111, + board_id: 2, + board_name: '学术版', + lock_person: null, + lock_time: null, + reply_count: 2, + reply_id: null, + reply_user_id: null, + content: null, + reply_time: null, + edit_time: null, + edit_person: null + }, overrides); +} + +test('GetPost fetches the whole discussion in a single query', async () => { + const proc = createProcess(); + const calls = stubGetPostQuery(proc, [ + postRow({ reply_id: 1, reply_user_id: 'u1', content: 'hello', reply_time: 1001 }), + postRow({ reply_id: 2, reply_user_id: 'u2', content: 'world', reply_time: 1002 }) + ]); + + const result = await proc.ProcessFunctions['GetPost']({ PostID: 1, Page: 1 }); + + assert.ok(result.Success); + assert.strictEqual(result.Message, '获得讨论成功'); + assert.strictEqual(calls.length, 1, 'expected exactly one SQL query'); + assert.deepStrictEqual(calls[0].args, [1, 1, 15, 0, 1]); + assert.strictEqual(result.Data.UserID, 'alice'); + assert.strictEqual(result.Data.ProblemID, 1000); + assert.strictEqual(result.Data.Title, 'Post one'); + assert.strictEqual(result.Data.PostTime, 111); + assert.strictEqual(result.Data.BoardID, 2); + assert.strictEqual(result.Data.BoardName, '学术版'); + assert.strictEqual(result.Data.PageCount, 1); + assert.deepStrictEqual(result.Data.Lock, { Locked: false, LockPerson: '', LockTime: 0 }); + assert.deepStrictEqual(result.Data.Reply, [ + { ReplyID: 1, UserID: 'u1', Content: 'hello', ReplyTime: 1001, EditTime: null, EditPerson: null }, + { ReplyID: 2, UserID: 'u2', Content: 'world', ReplyTime: 1002, EditTime: null, EditPerson: null } + ]); +}); + +test('GetPost binds the offset for the requested page', async () => { + const proc = createProcess(); + const calls = stubGetPostQuery(proc, [ + postRow({ reply_count: 20, reply_id: 16, reply_user_id: 'u16', content: 'x', reply_time: 1016 }) + ]); + + const result = await proc.ProcessFunctions['GetPost']({ PostID: 7, Page: 2 }); + + assert.ok(result.Success); + assert.deepStrictEqual(calls[0].args, [7, 7, 15, 15, 7]); + assert.strictEqual(result.Data.PageCount, 2); + assert.strictEqual(result.Data.Reply.length, 1); +}); + +test('GetPost reports a locked discussion', async () => { + const proc = createProcess(); + stubGetPostQuery(proc, [ + postRow({ lock_person: 'admin', lock_time: 999, reply_count: 1, reply_id: 1, reply_user_id: 'u1', content: 'hi', reply_time: 1001 }) + ]); + + const result = await proc.ProcessFunctions['GetPost']({ PostID: 1, Page: 1 }); + + assert.ok(result.Success); + assert.deepStrictEqual(result.Data.Lock, { Locked: true, LockPerson: 'admin', LockTime: 999 }); +}); + +test('GetPost rewrites legacy domain in reply content', async () => { + const proc = createProcess(); + stubGetPostQuery(proc, [ + postRow({ reply_count: 1, reply_id: 1, reply_user_id: 'u1', content: 'see https://xmoj-bbs.tech/a and xmoj-bbs.tech/b', reply_time: 1001 }) + ]); + + const result = await proc.ProcessFunctions['GetPost']({ PostID: 1, Page: 1 }); + + assert.strictEqual(result.Data.Reply[0].Content, 'see https://xmoj-bbs.me/a and xmoj-bbs.me/b'); +}); + +test('GetPost fails when the discussion does not exist', async () => { + const proc = createProcess(); + stubGetPostQuery(proc, []); + + const result = await proc.ProcessFunctions['GetPost']({ PostID: 999, Page: 1 }); + + assert.strictEqual(result.Success, false); + assert.strictEqual(result.Message, '该讨论不存在'); +}); + +test('GetPost returns an empty discussion when it has no replies', async () => { + const proc = createProcess(); + stubGetPostQuery(proc, [postRow({ reply_count: 0 })]); + + const result = await proc.ProcessFunctions['GetPost']({ PostID: 2, Page: 1 }); + + assert.ok(result.Success); + assert.strictEqual(result.Data.PageCount, 0); + assert.deepStrictEqual(result.Data.Reply, []); + assert.strictEqual(result.Data.Title, '', 'metadata is withheld for an empty discussion, as before'); +}); + +test('GetPost rejects a page outside the available range', async () => { + const proc = createProcess(); + const calls = stubGetPostQuery(proc, [postRow({ reply_count: 2 })]); + + const result = await proc.ProcessFunctions['GetPost']({ PostID: 1, Page: 5 }); + + assert.strictEqual(result.Success, false); + assert.strictEqual(result.Message, '参数页数不在范围1~1内'); + assert.strictEqual(calls.length, 1); +}); + +test('GetPost clamps a negative offset for a non-positive page', async () => { + const proc = createProcess(); + const calls = stubGetPostQuery(proc, [postRow({ reply_count: 2 })]); + + const result = await proc.ProcessFunctions['GetPost']({ PostID: 1, Page: 0 }); + + assert.strictEqual(result.Success, false); + assert.strictEqual(calls[0].args[3], 0, 'offset must never be negative'); +}); + +test('GetPost clears mentions for the reader', async () => { + const deleted = []; + const proc = createProcess({ + db: { + Delete: async (table, where) => { + deleted.push({ table, where }); + return new Result(true, ''); + } + } + }); + stubGetPostQuery(proc, [ + postRow({ reply_count: 1, reply_id: 1, reply_user_id: 'u1', content: 'hi', reply_time: 1001 }) + ]); + + await proc.ProcessFunctions['GetPost']({ PostID: 1, Page: 1 }); + + assert.deepStrictEqual(deleted, [{ table: 'bbs_mention', where: { post_id: 1, to_user_id: 'testuser' } }]); +});