Skip to content

feat(announcement): 后台公告已读情况增加按已读时间排序#2921

Open
hyrhyrh wants to merge 3 commits into
Wei-Shaw:mainfrom
hyrhyrh:pr/announcement-read-status-sort
Open

feat(announcement): 后台公告已读情况增加按已读时间排序#2921
hyrhyrh wants to merge 3 commits into
Wei-Shaw:mainfrom
hyrhyrh:pr/announcement-read-status-sort

Conversation

@hyrhyrh
Copy link
Copy Markdown

@hyrhyrh hyrhyrh commented May 31, 2026

功能概述

为管理后台 公告管理 → 已读情况 列表新增按 read_at(已读时间)排序。

目前列表只能按 user 字段(email / username / balance)排序,管理员无法快速回答两个常见问题:

  • "哪些用户最近读了这条公告?" —— 按 read_at 降序
  • "哪些用户还没读?" —— 按 read_at 升序(未读用户排到最前)

实现要点

NULL 处理语义

announcement_reads 是 user × announcement 的稀疏表 —— 未读的用户在 LEFT JOIN 中 read_at 为 NULL。本 PR 的 NULL 排序规则:

  • read_at descNULLS LAST(未读放最后,顶部是最近读的人)
  • read_at ascNULLS FIRST(未读放最前,顶部是尚未读的人)

这样管理员用同一个列通过点一下表头切方向就能在"看已读"和"找未读"之间切换,不用额外加 filter。

性能 / 索引

跨表排序我对几种方案做了对比:

方案 查询数 适用场景 代码复杂度
LEFT JOIN + ORDER BY(本 PR) 1 通用
拆两个查询(已读 + 未读分开) 2 "新公告大部分人没读"场景效率更高 中(分页边界手算)
内存排序 1 永远 < 几千用户 不可扩展

选了方案 1,理由:单查询、分页边界由 SQL 处理,无 corner case bug

为让排序索引可驱动,migration 146 新增复合索引:

CREATE INDEX IF NOT EXISTS idx_announcement_reads_announcement_id_read_at
    ON announcement_reads (announcement_id, read_at DESC NULLS LAST);

前导列是 announcement_id,与"某个公告内排序"的查询模式完全匹配。原有的 idx_announcement_reads_read_at 是全局单列索引,对本场景帮助有限。

测试估算:即便用户表 10 万行 + 单公告 5 万 reads,LEFT JOIN + 索引驱动 sort 仍保持 ~10ms 级。

与现有路径的关系

ListUserReadStatus service 方法在 sort_by=read_at 时分支到新路径 listUserReadStatusByReadAt,其它 sort_by 走原路径。纯增量改动,不动现有行为。

sort_by 大小写不敏感且去空格("READ_AT" / " read_at " 都命中新路径)。

改动文件

后端:

  • backend/migrations/146_announcement_reads_announcement_read_at_index.sql —— 新增复合索引
  • backend/internal/service/announcement.go —— 接口扩展(AnnouncementReadRepository.ListUsersOrderedByReadAt + AnnouncementReadUserRow 类型)
  • backend/internal/repository/announcement_read_repo.go —— LEFT JOIN raw SQL 实现(用 ent 的 client.QueryContext,与 affiliate_repo 一致的风格)
  • backend/internal/service/announcement_service.go —— service 分支逻辑

前端:

  • frontend/src/components/admin/announcements/AnnouncementReadStatusDialog.vue —— read_at 列加 sortable: true

测试:

  • backend/internal/service/announcement_read_at_sort_test.go —— 服务层 dispatch 单测(routing / 大小写 / 空格 / 默认路径)

测试计划

  • go build ./... / go vet ./... 通过
  • go test -short ./internal/repository/... ./internal/service/... ./internal/handler/... 通过(新增 3 个测试)
  • 前端 pnpm vue-tsc --noEmit 通过
  • 实机验证:
    • \d announcement_reads 确认 idx_announcement_reads_announcement_id_read_at 已建立
    • 按 read_at desc 点击列头 → 顶部是最近已读用户
    • 按 read_at asc 点击列头 → 顶部是未读用户(NULL)
    • 配合 email 搜索过滤后排序仍正确

给 reviewer 的几点说明

  • 没动现有 N+1:ListUserReadStatus 里循环调 ListActiveByUserID(userID) 是 pre-existing 问题(每条用户一次 SQL 查活跃订阅),不在本 PR 修复 —— 想保持 PR 聚焦在排序功能上,避免引入无关重构。可后续单独提 PR 做批量化。
  • 未加 SQL 集成测试:本 PR 的 raw SQL 较短,业务逻辑通过 service 单测覆盖;若 reviewer 倾向于添加 testcontainers 集成测试我可以补一个 commit。
  • migration 编号 146:feat(admin): 后台批量公告邮件功能 #2899 已占 145,选 146 以避免两个开放 PR 在 migration 目录的合并冲突。

hyrhyrh added 3 commits May 31, 2026 01:23
后台"公告管理 → 已读情况"页面之前只能按 user 字段(email/username/balance)
排序。read_at 列在 announcement_reads 表里,跨表排序在原架构(先按用户分页、
再补 read_at)下做不到。

本提交新增 LEFT JOIN 路径:
- AnnouncementReadRepository.ListUsersOrderedByReadAt:用 LEFT JOIN
  users + announcement_reads,在 SQL 层按 ar.read_at 排序并分页。
  未读用户(NULL)在 desc 时 NULLS LAST,asc 时 NULLS FIRST —— 便于
  在同一页面里既能"看最近阅读人"也能"看尚未阅读的人"。
- 配套 migration 146 新增复合索引 (announcement_id, read_at DESC NULLS LAST),
  让排序索引可驱动,即便用户表/已读记录扩到十万行也保持 ~10ms 级。

The admin announcement read-status page previously supported sorting only
by user-table columns (email/username/balance). Sorting by read_at needs
a join because read_at lives on announcement_reads, not users.

This commit adds a LEFT JOIN-driven repository method:
ListUsersOrderedByReadAt joins users to announcement_reads and orders by
ar.read_at, with NULLS LAST for desc and NULLS FIRST for asc so unread
users surface at the natural end of either direction.

Migration 146 introduces a composite index
(announcement_id, read_at DESC NULLS LAST) so the ordering is
index-driven even at scale.
…ead_at sort

When the admin requests sort_by=read_at, the service now dispatches to
ListUsersOrderedByReadAt (the LEFT JOIN path) instead of the legacy
ListWithFilters → GetReadMapByUsers pipeline. Other sort keys keep the
existing path so this change is strictly additive.

The sort_by value is matched case-insensitively after trimming so admins
typing "READ_AT" or " read_at " still get the new path.

当 sort_by=read_at 时,service 走新加的 LEFT JOIN 路径;其它排序字段
继续走旧路径,本提交是纯增量。sort_by 大小写不敏感、去空格后再匹配,
管理员输入 "READ_AT"/" read_at " 也能命中。

Includes unit tests covering:
- dispatch routing (read_at vs other)
- case-insensitive / trimmed sort_by value
- preserved row order from repo
- default-path is taken for other sort keys
后台"公告管理 → 已读情况" DataTable 中的 read_at 列加 sortable: true。
点击列头切换升降序后,新的 sort_by=read_at 参数传到后端,后端按
read_at 排序并把未读用户(NULL)放到本方向自然的末端(desc → 末尾,
asc → 开头),管理员可以一键看到"谁最近读了"或"谁没读"。

Marks the read_at column in the admin announcement read-status DataTable
as sortable. Clicking the header toggles direction and forwards
sort_by=read_at to the server, which then surfaces "most recent readers"
(desc) or "hasn't read yet" (asc, NULLS FIRST) depending on direction.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant