非常好的选择。实现全文搜索可以大大提升用户体验,使他们能够更快速、准确地找到所需的内容。我们将使用 SQLite 的 FTS 5 (Full Text Search) 扩展来实现这个功能。
第二十七部分:实现全文搜索
- 首先,我们需要创建一个新的 FTS 5 虚拟表。更新
src/db/schema.ts:
import { sqliteTable, text, integer } from "drizzle-orm/sqlite-core";
// ... 保留现有的表定义
export const postsSearch = sqliteTable("posts_search", {
id: integer("id").primaryKey(),
title: text("title"),
content: text("content"),
});- 创建新的迁移文件
src/db/migrations/0011_add_fts_table.ts:
export async function up(db) {
await db.run(sql`
CREATE VIRTUAL TABLE posts_search USING fts5(title, content, content='posts', content_rowid='id');
`);
// 填充现有数据
await db.run(sql`
INSERT INTO posts_search(rowid, title, content)
SELECT id, title, content FROM posts;
`);
// 创建触发器以保持搜索表同步
await db.run(sql`
CREATE TRIGGER posts_ai AFTER INSERT ON posts BEGIN
INSERT INTO posts_search(rowid, title, content) VALUES (new.id, new.title, new.content);
END;
`);
await db.run(sql`
CREATE TRIGGER posts_ad AFTER DELETE ON posts BEGIN
INSERT INTO posts_search(posts_search, rowid, title, content) VALUES('delete', old.id, old.title, old.content);
END;
`);
await db.run(sql`
CREATE TRIGGER posts_au AFTER UPDATE ON posts BEGIN
INSERT INTO posts_search(posts_search, rowid, title, content) VALUES('delete', old.id, old.title, old.content);
INSERT INTO posts_search(rowid, title, content) VALUES (new.id, new.title, new.content);
END;
`);
}
export async function down(db) {
await db.run(sql`DROP TRIGGER IF EXISTS posts_au;`);
await db.run(sql`DROP TRIGGER IF EXISTS posts_ad;`);
await db.run(sql`DROP TRIGGER IF EXISTS posts_ai;`);
await db.run(sql`DROP TABLE IF EXISTS posts_search;`);
}- 运行迁移:
bun run migrate- 创建一个新文件
src/services/search.ts来处理搜索逻辑:
import { db } from "../db";
import { posts, postsSearch } from "../db/schema";
import { eq, sql } from "drizzle-orm";
export async function searchPosts(query: string) {
const searchResults = await db
.select({
id: postsSearch.id,
title: postsSearch.title,
content: postsSearch.content,
rank: sql`bm25(posts_search)`.as("rank"),
})
.from(postsSearch)
.where(sql`posts_search MATCH ${query}`)
.orderBy(sql`rank`)
.all();
const postIds = searchResults.map(result => result.id);
const fullPosts = await db
.select()
.from(posts)
.where(sql`id IN ${postIds}`)
.all();
return fullPosts.map(post => {
const searchResult = searchResults.find(result => result.id === post.id);
return {
...post,
rank: searchResult?.rank,
};
}).sort((a, b) => (a.rank || 0) - (b.rank || 0));
}- 更新
src/routes/posts.ts文件,添加搜索路由:
import { searchPosts } from "../services/search";
export const postRoutes = new Elysia()
// ... 保留现有的路由
.get("/search", async ({ query, render }) => {
const searchQuery = query.q as string;
if (!searchQuery) {
return render("search", { results: [], query: "" });
}
const results = await searchPosts(searchQuery);
return render("search", { results, query: searchQuery });
});- 创建一个新的视图文件
src/views/search.eta:
<% layout('./layouts/main.eta', { title: 'Search Results' }) %>
<h1>Search Results</h1>
<form action="/search" method="GET">
<input type="text" name="q" value="<%= it.query %>" placeholder="Enter search terms...">
<button type="submit">Search</button>
</form>
<% if (it.results.length > 0) { %>
<ul>
<% it.results.forEach(post => { %>
<li>
<h2><a href="/posts/<%= post.id %>"><%= post.title %></a></h2>
<p><%= post.content.substring(0, 200) %>...</p>
</li>
<% }) %>
</ul>
<% } else { %>
<p>No results found.</p>
<% } %>- 更新
src/views/layouts/main.eta文件,添加搜索框到导航栏:
<header>
<nav>
<!-- ... 其他导航项 ... -->
<form action="/search" method="GET" style="display: inline;">
<input type="text" name="q" placeholder="Search...">
<button type="submit">Search</button>
</form>
</nav>
</header>- 更新
src/routes/posts.ts文件中的创建、更新和删除路由,以确保搜索表与主表同步:
import { db } from "../db";
import { posts, postsSearch } from "../db/schema";
export const postRoutes = new Elysia()
// ... 其他路由保持不变
.post("/posts", async ({ body, session, set }) => {
const { title, content } = body;
const [newPost] = await db.insert(posts).values({
title,
content,
authorId: session.user.id,
}).returning();
// 不需要手动插入到 postsSearch,触发器会处理这个
await logActivity(session.user.id, "create_post", `Created post: ${newPost.title}`);
set.redirect = `/posts/${newPost.id}`;
})
.post("/posts/:id", async ({ params, body, session, set }) => {
const { title, content } = body;
await db.update(posts)
.set({ title, content })
.where(eq(posts.id, parseInt(params.id)));
// 不需要手动更新 postsSearch,触发器会处理这个
await logActivity(session.user.id, "update_post", `Updated post: ${params.id}`);
set.redirect = `/posts/${params.id}`;
})
.delete("/posts/:id", async ({ params, session, set }) => {
await db.delete(posts).where(eq(posts.id, parseInt(params.id)));
// 不需要手动删除 postsSearch 中的记录,触发器会处理这个
await logActivity(session.user.id, "delete_post", `Deleted post: ${params.id}`);
set.redirect = "/";
});这些更改实现了全文搜索功能:
- 我们创建了一个 FTS 5 虚拟表来存储文章的标题和内容。
- 使用触发器来保持主表和搜索表的同步。
- 实现了搜索服务,使用 BM 25 算法来排序搜索结果。
- 添加了搜索路由和搜索结果页面。
- 在导航栏中添加了搜索框,方便用户随时搜索。
主要改进包括:
- 用户现在可以快速搜索整个博客的内容。
- 搜索结果按相关性排序,提供更好的用户体验。
- 搜索功能与现有的文章管理无缝集成。
这个实现为博客系统提供了强大的搜索能力,大大提升了内容的可发现性和用户体验。用户可以更容易地找到他们感兴趣的文章,而不必浏览整个博客。
接下来,我们可以考虑以下方向来进一步完善系统:
- 实现文章的协作编辑功能。
- 添加文章版本控制系统。
- 实现用户通知系统。
- 添加文章推荐功能,基于用户的搜索和阅读历史。
你希望继续哪个方向,或者有其他想法吗?