diff --git a/Source/Process.ts b/Source/Process.ts index 38fc031..d8afb56 100644 --- a/Source/Process.ts +++ b/Source/Process.ts @@ -210,7 +210,7 @@ export class Process { return this.DenyBadgeEditList.indexOf(this.Username) !== -1; } public VerifyCaptcha = async (CaptchaToken: string): Promise => { - const ErrorDescriptions: Object = { + const ErrorDescriptions: object = { "missing-input-secret": "密钥为空", "invalid-input-secret": "密钥不正确", "missing-input-response": "验证码令牌为空", @@ -566,16 +566,28 @@ export class Process { "Page": "number", "BoardID": "number" })); + let WhereClause = ""; + const FilterBindData: (string | number)[] = []; + if (Data["ProblemID"] !== 0) { + WhereClause += "WHERE p.problem_id = ? "; + FilterBindData.push(Data["ProblemID"]); + } + if (Data["BoardID"] !== -1) { + WhereClause += (WhereClause === "" ? "WHERE " : "AND ") + "p.board_id = ? "; + FilterBindData.push(Data["BoardID"]); + } + + // Count and page query must share this.RawDatabase's session (rather than + // this.XMOJDatabase's own session) so D1 read replication reads a + // consistent snapshot across both - otherwise the count can observe a + // newer version than the page query, corrupting pagination. + const PostCount = (await this.RawDatabase.prepare( + "SELECT COUNT(*) AS count FROM bbs_post p " + WhereClause + ";" + ).bind(...FilterBindData).all())["results"][0]["count"]; + let ResponseData = { - Posts: new Array, - PageCount: Data["BoardID"] !== -1 ? (Data["ProblemID"] !== 0 ? Math.ceil(ThrowErrorIfFailed(await this.XMOJDatabase.GetTableSize("bbs_post", { - board_id: Data["BoardID"], - problem_id: Data["ProblemID"] - }))["TableSize"] / 15) : Math.ceil(ThrowErrorIfFailed(await this.XMOJDatabase.GetTableSize("bbs_post", { - board_id: Data["BoardID"] - }))["TableSize"] / 15)) : (Data["ProblemID"] !== 0 ? Math.ceil(ThrowErrorIfFailed(await this.XMOJDatabase.GetTableSize("bbs_post", { - problem_id: Data["ProblemID"] - }))["TableSize"] / 15) : Math.ceil(ThrowErrorIfFailed(await this.XMOJDatabase.GetTableSize("bbs_post"))["TableSize"] / 15)) + Posts: new Array, + PageCount: Math.ceil(PostCount / 15) }; if (ResponseData.PageCount === 0) { return new Result(true, "获得讨论列表成功", ResponseData); @@ -583,48 +595,40 @@ export class Process { if (Data["Page"] < 1 || Data["Page"] > ResponseData.PageCount) { return new Result(false, "参数页数不在范围1~" + ResponseData.PageCount + "内"); } - const SearchCondition = {}; - if (Data["ProblemID"] !== 0) { - SearchCondition["problem_id"] = Data["ProblemID"]; - } - if (Data["BoardID"] !== -1) { - SearchCondition["board_id"] = Data["BoardID"]; - } - const Posts = ThrowErrorIfFailed(await this.XMOJDatabase.Select("bbs_post", [], SearchCondition, { - Order: "post_id", - OrderIncreasing: false, - Limit: 15, - Offset: (Data["Page"] - 1) * 15 - })); - for (const Post of Posts) { - const ReplyCount: number = ThrowErrorIfFailed(await this.XMOJDatabase.GetTableSize("bbs_reply", {post_id: Post["post_id"]}))["TableSize"]; - const LastReply = ThrowErrorIfFailed(await this.XMOJDatabase.Select("bbs_reply", ["user_id", "reply_time"], {post_id: Post["post_id"]}, { - Order: "reply_time", - OrderIncreasing: false, - Limit: 1 - })); - if (ReplyCount === 0) { + const BindData: (string | number)[] = [...FilterBindData, 15, (Data["Page"] - 1) * 15]; + + // Single query with correlated subqueries/joins instead of 4 extra + // round trips per post (was causing an N+1 query bottleneck). + const Posts = (await this.RawDatabase.prepare( + "SELECT p.post_id AS post_id, p.user_id AS 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, " + + "(SELECT COUNT(*) FROM bbs_reply r WHERE r.post_id = p.post_id) AS reply_count, " + + "lr.user_id AS last_reply_user_id, lr.reply_time AS last_reply_time, " + + "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 " + + "LEFT JOIN (" + + " SELECT post_id, user_id, reply_time FROM (" + + " SELECT post_id, user_id, reply_time, " + + " ROW_NUMBER() OVER (PARTITION BY post_id ORDER BY reply_time DESC) AS rn " + + " FROM bbs_reply" + + " ) WHERE rn = 1" + + ") lr ON lr.post_id = p.post_id " + + WhereClause + + "ORDER BY p.post_id DESC LIMIT ? OFFSET ?;" + ).bind(...BindData).all())["results"]; + + for (const Post of Posts) { + if (Post["reply_count"] === 0) { await this.XMOJDatabase.Delete("bbs_post", { post_id: Post["post_id"] }); continue; } - const LockData = { - Locked: false, - LockPerson: "", - LockTime: 0 - }; - const Locked = ThrowErrorIfFailed(await this.XMOJDatabase.Select("bbs_lock", [], { - post_id: Post["post_id"] - })); - if (Locked.toString() !== "") { - LockData.Locked = true; - LockData.LockPerson = Locked[0]["lock_person"]; - LockData.LockTime = Locked[0]["lock_time"]; - } - ResponseData.Posts.push({ PostID: Post["post_id"], UserID: Post["user_id"], @@ -632,13 +636,15 @@ export class Process { Title: Post["title"], PostTime: Post["post_time"], BoardID: Post["board_id"], - BoardName: ThrowErrorIfFailed(await this.XMOJDatabase.Select("bbs_board", ["board_name"], { - board_id: Post["board_id"] - }))[0]["board_name"], - ReplyCount: ReplyCount, - LastReplyUserID: LastReply[0]["user_id"], - LastReplyTime: LastReply[0]["reply_time"], - Lock: LockData + BoardName: Post["board_name"], + ReplyCount: Post["reply_count"], + LastReplyUserID: Post["last_reply_user_id"], + LastReplyTime: Post["last_reply_time"], + Lock: { + Locked: Post["lock_person"] !== null, + LockPerson: Post["lock_person"] ?? "", + LockTime: Post["lock_time"] ?? 0 + } }); } return new Result(true, "获得讨论列表成功", ResponseData); @@ -655,7 +661,7 @@ export class Process { BoardID: 0, BoardName: "", PostTime: 0, - Reply: new Array(), + Reply: new Array(), PageCount: 0, Lock: { Locked: false, @@ -870,7 +876,7 @@ export class Process { GetBBSMentionList: async (Data: object): Promise => { ThrowErrorIfFailed(this.CheckParams(Data, {})); const ResponseData = { - MentionList: new Array() + MentionList: new Array() }; const Mentions = ThrowErrorIfFailed(await this.XMOJDatabase.Select("bbs_mention", ["bbs_mention_id", "post_id", "bbs_mention_time", "reply_id"], { to_user_id: this.Username @@ -896,7 +902,7 @@ export class Process { GetMailMentionList: async (Data: object): Promise => { ThrowErrorIfFailed(this.CheckParams(Data, {})); const ResponseData = { - MentionList: new Array() + MentionList: new Array() }; const Mentions = ThrowErrorIfFailed(await this.XMOJDatabase.Select("short_message_mention", ["mail_mention_id", "from_user_id", "mail_mention_time"], { to_user_id: this.Username @@ -959,7 +965,7 @@ export class Process { GetMailList: async (Data: object): Promise => { ThrowErrorIfFailed(this.CheckParams(Data, {})); const ResponseData = { - MailList: new Array() + MailList: new Array() }; let OtherUsernameList = new Array(); let Mails = ThrowErrorIfFailed(await this.XMOJDatabase.Select("short_message", ["message_from"], {message_to: this.Username}, {}, true)); @@ -988,7 +994,7 @@ export class Process { OrderIncreasing: false, Limit: 1 })); - let LastMessage: Object; + let LastMessage: object; if (LastMessageFrom.toString() === "") { LastMessage = LastMessageTo; @@ -1069,7 +1075,7 @@ export class Process { "OtherUser": "string" })); const ResponseData = { - Mail: new Array() + Mail: new Array() }; let Mails = ThrowErrorIfFailed(await this.XMOJDatabase.Select("short_message", [], { message_from: Data["OtherUser"], @@ -1385,7 +1391,7 @@ export class Process { }, GetBoards: async (Data: object): Promise => { ThrowErrorIfFailed(this.CheckParams(Data, {})); - const Boards: Array = new Array(); + const Boards: Array = new Array(); const BoardsData = ThrowErrorIfFailed(await this.XMOJDatabase.Select("bbs_board", [])); for (const Board of BoardsData) { Boards.push({