Skip to content

6.4 评论、点赞与分页改造 ​

评论、点赞和分页看起来都是小功能,却会同时影响数据表、接口和页面。本节沿着一次读者互动的过程,说明怎样防止重复点赞、怎样展示评论,以及列表改成分页后需要一起调整哪些地方。


社交互动与性能分页 ​

在进入社交与分页重构前,我们通过日常生活中的通俗场景理解三大核心机制:

💡 一、社交互动与性能分页

  • 👍 重复点赞控制(唯一约束 + 幂等):数据库对 (post_id, user_id) 建立唯一约束,避免同一用户对同一文章产生多条点赞记录。它能处理重复点击,不能单独防止批量账号刷量;
  • 💬 楼层评论(一对多关联):就像是摩天大楼的房号登记表。评论按发布时间先来后到严谨排序,最早留言的是 1楼,后续依次递增。评论作者本人或管理员有权删楼,其他用户严禁越权删除;
  • 📑 接口分页重构(Page & PageSize):就像是餐厅的翻页菜单。如果把一千道菜一口气打印在十米长的纸卷上直接塞给顾客,不仅客人眼花缭乱,后厨也会瞬间崩溃。分页将文章按每页 9 篇结构化返回,并附带总数与页码元信息,大幅减轻数据库压力与网络开销。

开启 Trae Solo 模式:连续执行任务 ​

在第一阶段,我们体验了 Plan 模式下与 AI 一问一答的交互确认;而在本阶段,我们要体验更加的“完整流程全自动交付”。

1. 切换至 Solo 模式 ​

在 Trae 顶部或 Agent 面板切换为 SOLO 模式:

Trae SOLO 模式切换

💡 原理解析:Solo 模式类似于 OpenAI Codex 的 Goal 模式。开启后,智能体不再每走一步都停下来等待人类确认,而是获得了完整的自主推进权限——它会自省、自测、自动调用终端命令并自动修复报错,直到把最终任务完整交付。


2. 派发宏观需求说明 ​

在 Solo 模式输入框中输入以下指令并敲下回车:

实现阶段二实战:评论与点赞社交系统 + 接口分页重构

先列计划到docs/下,然后按规范就行开发和验收
Solo 模式任务输入与内置浏览器初始化

3. 规范规划与基线自动验证 ​

Agent 接收到指令后,会立刻展开以下自主动作(如下图所示):

  1. 自动调用 OpenSpec 规格工具与技能包,深度阅读既有代码与阶段规范;
  2. 自动在终端执行 uv run pytest -q,确认当前系统 42 个用例全部处于健康绿灯状态;
  3. 自动调用 CodeGraph MCP(codegraph_explore)扫描项目调用拓扑图谱;
  4. 自动在 docs/ 目录下生成完整的实施蓝图:docs/phase06_评论点赞系统与接口分页重构.md。
Agent 基线验证与 CodeGraph 工具调用

自主推进中的实战细节与高效体验 ​

任务开始后,可以通过执行记录查看正在改哪些文件、运行哪些检查。遇到错误时,先确认它是否找到原因,而不是只看任务是否继续推进。

1. 探索 Agent 的“思考链路” ​

感兴趣的同学可以点击面板上的 「思考中 >」 展开折叠菜单。在这里你可以清晰地看到大模型的内部思维链:

  • 它是如何权衡分页数据结构的破坏性影响;
  • 它是如何设计 IN + GROUP BY 批量查询来规避 N+1 性能陷阱;
  • 它是如何遵循 ATDD 规则,先改写测试使其变红、再编写业务代码让测试变绿。

2. 常见现象处理:模型思考次数达上限(点击「继续」) ​

在复杂任务的长链路推进过程中,有时面板上会弹出一条黄色提示:

模型思考次数上限提示

💡 官方机制解析:在同一段对话中,大模型推理轮次有预设的安全限制。当任务较为庞大、多文件改动较多时,为了避免模型产生幻觉导致死循环与 Token 无效浪费,系统会自动触发阶段性熔断机制。

👉 应对方法:遇到该提示完全不用慌张,直接点击按钮中的 「继续」 即可。Agent 会自动承接上一轮的上下文断点,继续推进后续任务。


3. 内置浏览器实时全自动验收 ​

在等待 Agent 完成的过程中,我们可以实时观察右侧 Trae 内置浏览器 的运行情况:

Trae 内置浏览器实时自动化验收效果

你会发现 AI 在不需要人类操控鼠标键盘的情况下,全自动在浏览器中执行了一系列实际的完整流程验收:

  • 自动在浏览器中创建测试文章(如 分页测试文章1 ~ 分页测试文章4);
  • 自动点击分页控件进行翻页与分类筛选;
  • 自动登录 admin 账号并点开文章详情;
  • 自动执行点赞交互与发布评论测试。

浏览器自动化可以重复执行固定操作,适合检查页面流程。开发者仍需确认用例是否覆盖需求、测试数据是否被清理,以及自动化结果与实际接口行为是否一致。


核心架构与落地代码说明 ​

在阶段二中,Agent 为博客系统注入了强大的社交与分页能力,整体架构依然遵循清晰的分层规范:

1. ORM 模型层:models.py(新增 Comment 与 Like 表) ​

python
from sqlalchemy import ForeignKey, Integer, String, Text, UniqueConstraint

# 评论表:一对多关联文章与用户
class Comment(Base):
    __tablename__ = "comments"

    id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
    post_id: Mapped[int] = mapped_column(Integer, ForeignKey("posts.id"), nullable=False, index=True)
    user_id: Mapped[int] = mapped_column(Integer, ForeignKey("users.id"), nullable=False, index=True)
    content: Mapped[str] = mapped_column(Text, nullable=False)
    created_at: Mapped[datetime] = mapped_column(default=datetime.now)

# 点赞表:唯一约束防刷
class Like(Base):
    __tablename__ = "likes"

    id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
    post_id: Mapped[int] = mapped_column(Integer, ForeignKey("posts.id"), nullable=False, index=True)
    user_id: Mapped[int] = mapped_column(Integer, ForeignKey("users.id"), nullable=False, index=True)
    created_at: Mapped[datetime] = mapped_column(default=datetime.now)

    __table_args__ = (
        UniqueConstraint("post_id", "user_id", name="uq_like_post_user"),  # 数据库级防刷保证
    )

2. 认证中枢扩展:security.py(新增可选登录依赖) ​

为了让公开的博客列表与详情页能够展示“当前用户是否已点赞”,同时又不破坏“未登录用户也能正常浏览”的原则,新增了 get_optional_user:

python
oauth2_scheme_optional = OAuth2PasswordBearer(tokenUrl="/api/auth/login", auto_error=False)

def get_optional_user(
    token: str | None = Depends(oauth2_scheme_optional),
    db: Session = Depends(database.get_db),
) -> User | None:
    """可选登录守卫:有有效 Token 返回 User 对象,无 Token 返回 None(不抛 401 异常)"""
    if not token:
        return None
    try:
        payload = jwt.decode(token, SECRET_KEY, algorithms=[ALGORITHM])
        user_id = int(payload.get("sub"))
    except (jwt.PyJWTError, TypeError, ValueError):
        return None
    return db.query(User).filter(User.id == user_id).first()

3. 数据契约层:schemas.py(分页与社交 DTO) ​

python
# PostResponse 扩展点赞数、评论数与当前用户点赞态
class PostResponse(BaseModel):
    id: int
    title: str
    content: str
    category: str
    tags: str
    status: str
    views: int
    likes: int = 0             # 点赞总数
    comment_count: int = 0     # 评论总数
    liked: bool = False        # 当前访问者是否已点赞
    created_at: datetime
    updated_at: datetime
    model_config = {"from_attributes": True}

# 文章分页包装模型
class PaginatedPosts(BaseModel):
    items: list[PostResponse]
    total: int
    page: int
    page_size: int

# 评论返回模型(含楼层用户名)
class CommentResponse(BaseModel):
    id: int
    post_id: int
    user_id: int
    username: str
    content: str
    created_at: datetime

# 点赞响应模型
class LikeResponse(BaseModel):
    liked: bool
    likes: int

4. 业务与路由层:main.py(批量统计与接口重构) ​

针对列表查询可能引发的 N+1 性能问题,通过 SQL GROUP BY 进行批量聚合统计,单次查询搞定全部社交计数:

python
def _post_counts(db: Session, post_ids: list[int]) -> dict[int, dict[str, int]]:
    """批量高效统计文章的点赞数与评论数(告别 N+1 查询)"""
    if not post_ids:
        return {}
    likes_q = db.query(Like.post_id, func.count(Like.id)).filter(Like.post_id.in_(post_ids)).group_by(Like.post_id).all()
    comments_q = db.query(Comment.post_id, func.count(Comment.id)).filter(Comment.post_id.in_(post_ids)).group_by(Comment.post_id).all()
    counts = {pid: {"likes": 0, "comments": 0} for pid in post_ids}
    for pid, count in likes_q: counts[pid]["likes"] = count
    for pid, count in comments_q: counts[pid]["comments"] = count
    return counts

# 分页文章列表接口(返回 PaginatedPosts)
@app.get("/api/posts", response_model=PaginatedPosts)
def get_posts(
    page: int = Query(1, ge=1),
    page_size: int = Query(9, ge=1, le=50),
    category: str | None = Query(None),
    status: str | None = Query(None),
    search: str | None = Query(None),
    db: Session = Depends(database.get_db),
    current_user: User | None = Depends(security.get_optional_user),
):
    query = db.query(Post)
    if category: query = query.filter(Post.category == category)
    if status: query = query.filter(Post.status == status)
    if search: query = query.filter(Post.title.contains(search) | Post.content.contains(search))

    total = query.count()
    posts = query.order_by(Post.created_at.desc()).offset((page - 1) * page_size).limit(page_size).all()

    counts = _post_counts(db, [p.id for p in posts])
    liked_ids = _liked_post_ids(db, current_user)
    items = [_post_response(p, counts, liked_ids) for p in posts]
    return PaginatedPosts(items=items, total=total, page=page, page_size=page_size)

5. 前端社交与分页集成:index.html ​

  • 首页分页条控件:卡片网格底部自动渲染页码组件(上一页、当前页/总页数、下一页),切换分类或搜索自动重置到第 1 页;
  • 卡片社交徽标:每张文章卡片底部直观呈现阅读量 👁、点赞数 🤍 与评论数 💬;
  • 阅读器交互闭环:
    • 文章详情顶部提供高亮心形点赞按钮,支持一键点赞/取消点赞;
    • 详情底部渲染楼层评论区(如 1楼、2楼),展示评论者用户名、相对时间及删除按钮(仅作者本人与管理员可见);
    • 全站对用户输入内容一律采用 escapeHtml 严密防范 XSS 注入攻击。

进阶原理说明(面试高频考点) ​

1. 三种主流分页方案横向对比(Offset vs Cursor vs Keyset) ​

方案实现方式优点缺点适用场景
Offset 分页(本教程采用)LIMIT page_size OFFSET (page-1)*page_size + 统计 total实现最简单、支持“跳页”,能显示总页数数据量大时翻越深越慢;期间数据变动会出现重复/遗漏中小规模数据(本博客完全够用)
Cursor 分页携带上一页最后一条记录的 ID/时间戳,WHERE id < cursor LIMIT n深层翻页恒定快、数据稳定不漂移实现复杂、无法直接跳页,需要维护游标海量数据 + 无限滚动(朋友圈/微博流)
Keyset 分页基于唯一有序键(如 created_at, id)做范围查询索引利用好、性能优秀同样不支持跳页、排序键必须唯一高并发大表列表流

🧠 一句话决策:项目小、要翻页控件 → Offset;体量大、要无限流 → Cursor/Keyset。我们的博客选择了 Offset + 参数化分页,并严格遵守 {items,total,page,page_size} 契约,便于未来平滑升级。


2. “N+1 查询”是什么?为什么 IN + GROUP BY 能根治它? ​

假设首页展示 9 篇文章,如果按“新手直觉”写:

python
for post in posts:                     # 1 次查询取 9 篇文章
    post.like_count = db.query(...).filter(post_id=post.id).count()   # 每篇再查 1 次点赞
    post.comment_count = db.query(...).filter(post_id=post.id).count() # 每篇再查 1 次评论

总共执行 1 + 9×2 = 19 次数据库查询——这就是著名的 N+1 问题(1 次主查询 + N 次子查询)。当文章涨到 100 篇/页时,就是 201 次查询,数据库直接被拖垮。

而 _post_counts 用 一次 IN 过滤 + 一次 GROUP BY 聚合 就能同时拿到 9 篇文章的全部点赞数/评论数:

python
db.query(Like.post_id, func.count(Like.id))
  .filter(Like.post_id.in_(post_ids))   # 一次把所有文章的点赞一次性捞出
  .group_by(Like.post_id)               # 再按文章 ID 分组计数

不管文章有多少篇,始终只要 2 次查询(点赞 1 次 + 评论 1 次)。复杂度从 O(N) 骤降到 O(1),这就是“批量聚合统计”的威力。


3. 点赞为什么“狂按不叠加”?—— 唯一约束 + 幂等设计的本质 ​

数据库层面用 UniqueConstraint("post_id", "user_id") 建立了复合唯一约束,从根上保证“同一用户对同一文章只能有一条点赞记录”:

  • 第一次点赞:往 likes 表插入 (post_id=1, user_id=1) → 成功,点赞数 +1;
  • 疯狂再点:再次插入 (post_id=1, user_id=1) → 违反唯一约束,数据库直接报错,业务层捕获后按“已点赞”幂等处理(或走取消点赞分支)→ 点赞数纹丝不动;
  • 再点取消:删除该行 → 点赞数 -1。

💡 相比“老土写法” post.likes += 1(在文章表上放一个整数字段无脑累加),唯一约束方案天然免疫并发刷量:即使 100 个请求同时打过来,数据库也只允许一条记录存在。这正是红线清单第 11 条的精神——点赞必须基于唯一约束幂等实现,严禁无约束累加。


4. 为什么删文章要“级联删除”相关评论与点赞? ​

当文章被删除时,如果它的评论和点赞记录还残留在 comments / likes 表中,就会产生孤儿数据(数据库里躺着永远没人引用的死数据)。因此删除文章时,必须一并删除其关联的评论与点赞(cascade 级联 或 业务层先删子表再删主表),保持数据一致性。面试时常考的问题“删除主表数据时如何同步清理子表”就对应这个点。


5. XSS 注入:为什么评论内容必须 escapeHtml? ​

如果直接把用户输入的评论原样塞进 HTML,攻击者可以提交:

html
<script>alert('你的 Cookie 被盗了')</script>

浏览器执行后就能窃取用户登录态(Cookie / localStorage 里的 Token)。escapeHtml 的本质是把 <、>、"、'、& 等特殊字符转义成 HTML 实体(如 < → &lt;),让浏览器把它当作纯文本展示而不是可执行代码:

  • ✅ &lt;script&gt;alert(...)&lt;/script&gt; —— 只显示文本,绝不当代码执行;
  • 🚫 直接拼接原始输入 —— 被注入,用户数据被盗。

输入处理原则:外部输入必须经过校验和安全输出。前端展示时正确转义,后端用 Pydantic 校验字段,数据库查询采用参数化方式;同时根据内容类型和使用位置决定具体处理方法。


全流程自主验收成果与实测展示 ​

案例中的任务完成后,工具输出了执行与验收记录,下面是当时的截图:

1. Agent 阶段完成看板汇总 ​

Agent 自动完成实施蓝图保存、OpenSpec 规格归档、71 个 pytest 用例全部绿灯通过(42 基线 + 29 新增)、23 项 API 契约实测全通,并同步更新了 agents.md 与规则文件:

阶段二自主完成验收看板

2. 人工上手验收:点赞与楼层评论实测 ​

打开文章阅读器,可以看到文章详情顶部清晰展示 「💖 已点赞 1」 高亮按钮,底部 「💬 评论 (1)」 区域精准渲染了 1楼 admin 刚刚 发表的评论内容与删除按钮:

文章详情点赞与楼层评论区实测

3. 人工上手验收:首页 3×3 分页导航实测 ​

回到首页,底部显示分页控制器:[← 上一页] 第 1 / 2 页 · 共 12 篇 [下一页 →]。继续检查首页、分类列表和搜索结果是否都能正确翻页。

首页分页导航控制器实测

深度复盘:为什么感觉 Solo 模式与普通 Plan 模式的差距变小了? ​

很多细心的同学在体验完阶段一(Plan 模式)和阶段二(Solo 模式)后,可能会产生一个疑问:

🤔 同学疑惑:“为什么我觉得在咱们这个项目里,普通 Plan 模式和 Solo 模式好像都能很稳地把功能做出来,两者差距并没有想象中那么巨大?”

答案非常深刻 —— 并不是 Solo 模式不强,而是我们在项目前期的工程规范设计,主动帮普通模式缩短了与 Solo 模式的差距。

🧠 六、深度复盘:为什么感觉 Solo 模式与普通 Plan 模式的差距变小了?

两大缩短差距的秘密武器剖析: ​

  1. 🧪 秘诀一:ATDD(验收测试驱动)的帮助:
    • 正常来说,普通的非 Solo 对话模式往往容易“写完代码就交差”,不会主动给你补齐严密的测试套件;
    • 而我们在项目 .traerules 和项目规则中强制注入了 ATDD 红绿测试守卫,这使得哪怕是普通模式也必须先写测试再写业务代码;
    • 相比之下,Solo 模式原生内置了更强悍的混沌测试(Chaos Testing)与自省修复能力,甚至支持开启多个子智能体协同干活,抗压上限更高。
  2. 📋 秘诀二:OpenSpec 规格演进与计划先行:
    • 我们在动工前坚持先列计划(docs/ 保存)并遵循 OpenSpec 的 proposal ➔ apply ➔ archive 闭环;
    • 这种严密的规格约束有助于限制修改范围,但仍需验证实现是否符合需求。

🌟 模式选择:

  • 当你需要精雕细琢、针对关键架构做交互式方案推演时,推荐选择 Plan 模式;
  • 当需求和验收条件已经明确,需要连续完成实现、测试和修正时,可以使用 SOLO 模式,并在结束后审查全部差异。

分页和互动功能的边界情况 ​

书店按书架编号找书,如果有人在最前面不断插入新书,后面的编号就会移动。分页列表也会遇到类似情况:连续翻页时有人发布新文章,偏移分页可能出现重复或遗漏。

本例使用页码便于学习,但应至少固定排序,并增加唯一字段作为并列时的排序依据,例如按创建时间和文章编号排序。后续采用游标时,需要说明游标代表什么,以及能否跳到任意页。

评论和点赞还需要处理以下情况:

操作应检查的行为
连点两次点赞不重复计数;并发冲突也得到合理处理
取消一个尚未点过的赞按约定返回,不出现负数
删除某页最后一篇文章页面调整到仍存在的页码或显示空状态
超大页码或非法每页数量明确校验或返回空列表,不无限读取
评论提交后文章被删除返回可理解的错误,不留下孤立数据

唯一约束负责阻止重复记录,接口仍需处理冲突时的事务回滚和响应。它能限制同一用户对同一文章重复点赞,不能单独阻止批量账号刷量。

分页改造还应核对所有调用方。后端把数组改为 {items,total,page,page_size} 后,首页、分类列表和搜索结果若仍按数组读取,就会一起失效。把这些入口列入验收,比只检查新分页按钮更完整。

按重复请求测试互动功能 ​

用户双击按钮、浏览器重试和网络超时都可能让同一请求到达两次。点赞适合用用户与文章的唯一约束保证不重复计数;创建评论是否去重,则要根据产品约定决定,不能直接沿用点赞规则。接口应把冲突转换成稳定响应,而不是把数据库异常原样交给页面。

删除也要沿关联关系检查:删除文章后评论和点赞怎样处理,删除用户后历史内容是否保留,事务失败时是否出现“文章没了但点赞还在”的中间状态。数据库约束与业务代码应保持一致,并通过实际数据验证。

分页回归可准备固定数量的文章,在翻页之间插入或删除一篇,再观察是否重复、遗漏或跳页。若继续使用页码分页,至少保持稳定排序;若改成游标分页,则要同步所有调用方和响应结构,不能只改数据库查询。


本节回顾 ​

互动功能既涉及重复请求,也涉及关联数据;分页则影响所有读取列表的页面。把这些边界一起验收,才能避免只修好一个入口。

继续阅读:6.5 AI 摘要与标签生成。