非常好的选择。实现全文搜索可以大大提升用户体验,使他们能够更快速、准确地找到所需的内容。我们将使用 SQLite 的 FTS 5 (Full Text Search) 扩展来实现这个功能。

第二十七部分:实现全文搜索

  1. 首先,我们需要创建一个新的 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"),
});
  1. 创建新的迁移文件 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;`);
}
  1. 运行迁移:
bun run migrate
  1. 创建一个新文件 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));
}
  1. 更新 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 });
  });
  1. 创建一个新的视图文件 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>
<% } %>
  1. 更新 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>
  1. 更新 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 = "/";
  });

这些更改实现了全文搜索功能:

  1. 我们创建了一个 FTS 5 虚拟表来存储文章的标题和内容。
  2. 使用触发器来保持主表和搜索表的同步。
  3. 实现了搜索服务,使用 BM 25 算法来排序搜索结果。
  4. 添加了搜索路由和搜索结果页面。
  5. 在导航栏中添加了搜索框,方便用户随时搜索。

主要改进包括:

  • 用户现在可以快速搜索整个博客的内容。
  • 搜索结果按相关性排序,提供更好的用户体验。
  • 搜索功能与现有的文章管理无缝集成。

这个实现为博客系统提供了强大的搜索能力,大大提升了内容的可发现性和用户体验。用户可以更容易地找到他们感兴趣的文章,而不必浏览整个博客。

接下来,我们可以考虑以下方向来进一步完善系统:

  1. 实现文章的协作编辑功能。
  2. 添加文章版本控制系统。
  3. 实现用户通知系统。
  4. 添加文章推荐功能,基于用户的搜索和阅读历史。

你希望继续哪个方向,或者有其他想法吗?