logo

运营

用户反馈与系统通知

本页面涵盖两个轻量级运营功能:用户反馈和系统通知。两者都是简单的管理员驱动 CRUD,没有复杂的业务逻辑,不需要单独的文件。

数据归属

数据库 作用域
feedbacks Tenant Shard DB 每个用户一行,存在用户自己的分片中
notifications Meta DB 全局,管理员发布,对所有用户或指定用户可见
notification_reads Tenant Shard DB 每用户已读状态,存在用户自己的分片中

反馈是随用户规模增长的用户级数据,因此放在分片中。通知是管理员写入、所有用户读取的全局状态,因此放在 Meta 中。已读状态是每用户级别的,因此放在分片中。

notifications 表有一个 target_user_id 列,关联 user.id 并设置了 onDelete: cascade。删除用户时,该用户的定向通知也会被级联删除。全局通知(target_user_id IS NULL)不受影响。

反馈

提交反馈

POST /api/submit_feedback

需要登录。通过 ctx.get('tenantDb') 直接写入当前用户的 Tenant Shard DB:

{
  "type": "bug",
  "content": "上传按钮没有响应"
}

type 是来自客户端的自由格式字符串。后端不会校验其是否在枚举范围内。content 不能为空。返回 { "id": "feedback_id" }

管理员列出反馈

POST /api/admin/list_feedbacks

仅限管理员。此接口会向所有 Tenant Shard 扇出查询:

createTenantShardAccess(metaDb, env).listShardDbs()
  -> 从 d1_shards 查询所有活跃和排空中的分片
  -> 打开每个分片的 DB binding
  |
  v
对每个分片:
  feedback.findMany({ where, orderBy: createdAt desc })
  |
  v
收集所有行,按 createdAt 全局降序排序
  |
  v
在内存中对 page 和 page_size 进行切片

该接口查询每个分片,收集匹配的行,合并排序后在内存中对 pagepage_size 进行切片。total 是过滤后的 rows.length,而非 SQL count。

这种方式适用于分片数量较少、反馈量合理的场景。不适合高吞吐量的列表场景。如果分片数增长到数百个,则需要在 Meta DB 中建立全局聚合表。

过滤条件:user_idtypecreated_at_startcreated_at_end

通知

管理员创建通知

POST /api/admin/create_notification

仅限管理员。写入 Meta DB:

{
  "type": "system",
  "title": "维护公告",
  "content": "今晚服务将进行升级",
  "target_user_id": null
}

type 省略时默认为 systemtarget_user_id 控制可见性:

  • null 或省略:全局公告,对所有用户可见
  • 设置为某个用户 ID:定向通知,仅对该用户可见

返回 { "id": "notification_id" }

用户列出通知

POST /api/list_notifications

需要登录。这是一次跨数据库读取。接口先从 Meta DB 查询可见通知,再从用户的 Tenant Shard 查询已读状态,最后在内存中合并:

步骤 1:Meta DB
  SELECT * FROM notifications
  WHERE (target_user_id IS NULL OR target_user_id = current_user)
    AND [可选的 type、created_at 过滤]
  ORDER BY created_at DESC

步骤 2:Tenant Shard DB
  SELECT notification_id FROM notification_reads
  WHERE user_id = current_user AND notification_id IN (步骤 1 的 ids)

步骤 3:构建响应,read = (id in readIds)

read 过滤需要特殊处理。由于 read 不是 notifications 的列(它存在于 Shard DB),无法放入 SQL WHERE。接口会先获取所有可见行,从 Shard 查询计算 read,再在内存中过滤:

如果设置了 read 过滤:
  获取所有可见行(无限制)
  计算每行的 read
  按 read === req.read 过滤
  在内存中切片分页
  total = 过滤后的长度

如果没有设置 read 过滤:
  使用 SQL limit + offset 获取
  计算每行的 read(用于展示)
  total = SQL count

这意味着 read=trueread=false 的查询效率低于不过滤的查询,因为需要加载所有可见行。对于通知量来说这是可接受的。

标记已读

POST /api/read_notification

需要登录。写入当前用户的 Tenant Shard DB:

{
  "id": "notification_id"
}

插入使用 onConflictDoNothing,主键为 (notification_id, user_id)。重复调用不会创建重复行,也不会报错。返回 {}

接口不校验通知 ID 是否存在,直接插入已读状态。这是有意为之:已读状态纯粹是客户端关注的事项,在标记已读时没有业务价值去校验通知 ID。

API 汇总

接口 认证 数据库
POST /api/submit_feedback 用户 Tenant Shard
POST /api/admin/list_feedbacks 管理员 所有分片(扇出)
POST /api/admin/create_notification 管理员 Meta
POST /api/list_notifications 用户 Meta + Tenant Shard
POST /api/read_notification 用户 Tenant Shard

常见错误

管理员反馈列表会扇出到所有分片。 接口循环每个活跃分片,并行查询,收集,排序,在内存中分页。totalrows.length,不是 SQL count。分片数量增长到数百时,此方案不具备扩展性,需要全局索引。

read 过滤无法下推到 SQL。 read 布尔值是从 Shard DB 计算得出的,不存储在 Meta 中。请求中设置了 read 时,接口会先获取所有可见行,再在内存中过滤。不要假设 SQL LIMITread 过滤之前生效。

重复标记已读不会报错。 (notification_id, user_id) 上的 onConflictDoNothing 意味着重复调用被静默忽略。这是设计如此。

target_user_iduser.id 共享外键。 删除用户会级联删除其定向通知。全局通知不受影响。

反馈的 type 是自由格式。 后端不校验其是否在固定枚举范围内。如需统一类型,在前端校验或添加 schema 约束。