# 自助账户删除后仍残留指向已删除用户 ID 的数据行

**URL:** <https://meta.discourse.org/t/self-service-account-deletion-leaves-rows-that-still-point-at-the-deleted-user-id/413339>\
**Category:** Bug\
**Tags:** gdpr, privacy\
**Created:** [2026年九月25日 15:39 UTC](https://meta.discourse.org/t/self-service-account-deletion-leaves-rows-that-still-point-at-the-deleted-user-id/413339 "2026-09-25T15:39:41Z")\
**Posts on this page:** 1\
**Page:** 1

<div class="post-metadata">

**Author:** ![kio](https://sea3.discourse-cdn.com/meta/user_avatar/meta.discourse.org/kio/32/579007_2.png) [@kio](https://meta.discourse.org/u/kio)\
**Post date:** [2026年九月25日 15:39 UTC](https://meta.discourse.org/t/self-service-account-deletion-leaves-rows-that-still-point-at-the-deleted-user-id/413339/1 "2026-09-25T15:39:41Z")

</div>

## 摘要

`DELETE /u/<username>.json` 请求成功执行，`users` 表中的对应行已被删除，但仍有多个表保留了 `user_id` 指向已销毁用户 ID 的行。这些表均未设置外键、`dependent:` 关联、清理任务或文档化的保留原因。

其中一个是销毁器自身声明意图的明显遗漏：`UserDestroyer#delete_posts` 旨在将 `topics.user_id` 置空，但它遍历的是 `user.posts`，而该集合的默认作用域排除了已废弃（trashed）的帖子——因此，如果某个主题的首帖已被删除，该主题仍会指向已销毁的用户。

另外两个表 `incoming_links` 和 `search_logs` 是 Discourse 自身的匿名化处理路径中视为与用户关联的个人数据（参见 `Jobs::AnonymizeUser#anonymize_ips`）的表，且 `UserMerger` 会明确重新指向目标用户——但 `UserDestroyer` 完全未触及这些表。

在 `v2026.7.1` 版本上复现，使用 **默认设置** 且该账户不拥有任何帖子。截至 2026-09-25，`main` 分支上的 `UserDestroyer#delete_posts` 未发生变更。

## 版本

Discourse `v2026.7.1`（自托管，本地授权测试实例）。主要涉及核心功能；一个插件的行记录在下方单独列出。

## 复现步骤

最小复现案例无需更改站点设置，也无需帖子，因此在默认的 `delete user self max post count` 设置下即可工作：

1. 注册一个普通测试账户。

2. 以该账户登录时，执行搜索：`GET /search.json?q=<unique marker>`。

3. 以登录状态访问某个主题的 HTML 页面，来源为外部引用（referer），并附带他人的分享参数：`GET /t/<slug>/<topic_id>?u=<other-username>`，请求头包含 `Referer: https://example.invalid/x`。

4. 从未登录的浏览器访问任何主题，附带 **测试账户** 的分享参数：`GET /t/<slug>/<topic_id>?u=<test-account>`，并带有外部 `Referer`。

5. 以测试账户身份删除账户：`DELETE /u/<test-account>.json`，请求头包含 `context=/my/preferences/account`。接口返回 `{"success":"OK"}`，且 `users` 行消失。

6. 执行查询：

附带的 `poc.py` 脚本通过 HTTP 驱动步骤 1–5，并打印步骤 6 的确切 SQL。

## 预期行为

用户删除自己的账户后，标识该用户的行应被删除、置空或重新分配——正如 `UserDestroyer` 已经对 `posts.user_id`（置空）、`categories.user_id`（重新分配给系统用户）和 `topics.user_id`（意图置空）所做的那样。

## 实际行为

以下所有数据均在同一次运行中测量，此时 `DELETE /u/<username>.json` 已返回 200，且 `SELECT count(*) FROM users WHERE id = <id>` 返回 0。

| 表 / 列 | 剩余行数 | 行内容 | 备注 |
| --- | --- | --- | --- |
| `incoming_links.user_id` | 1 | 已删除用户的 ID + 访客的 `ip_address` + `post_id` | 归属于已删除用户的分享链接点击 |
| `incoming_links.current_user_id` | 1 | 已删除用户的 ID + `post_id` + 引用来源 | 已删除用户自身的点击记录 |
| `search_logs.user_id` | 1 | 已删除用户的 ID + 他们输入的 **搜索词** | 保留 `search query log max retention days` 天，默认 365 天 |
| `topics.user_id` | 1 | 已删除用户的 ID | 仅针对首帖已被废弃的主题——见下文 |
| `post_revisions.user_id` | 2 | 已删除用户的 ID + `modifications['raw']`，即其早期帖子正文 | |
| `custom_emojis.user_id` | 1 | 已删除用户的 ID | 全站表情符号的上传者归属 |
| `topic_localizations.localizer_user_id` | 1 | 已删除用户的 ID | |
| `policy_users.user_id` | 1 | 已删除用户的 ID + `accepted_at` | `discourse-policy` 插件 |

随后运行内置的清理任务不会改变任何结果：我在 `Jobs::UpdateScoresForToday`、`Jobs::CleanUpUnusedRegisteredUserApiKeyClients` 和 `PostDestroyer.destroy_stubs` 之后重新读取了所有行，它们仍然存在。

作为对比，在同一次运行中，以下部分 **确实** 正常工作：`users`、`user_profiles` 和 `email_tokens` 行已消失，指向该账户的 `chat_mentions` 行已被删除，且 `posts.user_id` 和大多数 `topics.user_id` 已被置空。因此，这是一份具体遗漏的清单，而非声称删除功能完全无效。

这些行的创建方式：上述步骤 1–5 通过普通浏览创建了 `incoming_links` 和 `search_logs` 行。`topics.user_id`、`post_revisions.user_id` 和 `policy_users` 行来自普通发帖、自行删除主题以及接受策略。`custom_emojis` 和 `topic_localizations` 行直接在测试夹具中播种，因为上传自定义表情符号和编写主题本地化属于管理员/插件路径，而非普通账户的操作；针对这些行的删除行为测量结果在其他方面是相同的。

### `topics.user_id` 案例是一个具体的作用域偏移（off-by-scope）错误

`UserDestroyer#delete_posts` 编写如下：

```ruby
user.posts.find_each do |post|
  ...
  if post.topic && post.is_first_post?
    Topic.unscoped.where(id: post.topic_id).update_all(user_id: nil)
  end
end

```

`user.posts` 使用默认作用域，该作用域排除了已废弃的帖子。如果用户已删除自己的某个主题——该桩（stub）随后被 `PostDestroyer.destroy_stubs` 废弃——则该帖子不会被遍历，因此针对其主题的置空操作永远不会执行。在我的运行中，该用户的三个主题中有两个最终 `user_id IS NULL`，而第三个（其首帖在账户删除前已被废弃）仍保留 `user_id = <deleted id>`。

几行后的 `Post.unscoped.where(user_id: result.id).update_all(user_id: nil)` 确实使用了 `unscoped`，因此帖子本身被正确分离——只有主题被遗漏。

### 为什么 `incoming_links` 和 `search_logs` 格外突出

它们不仅仅是孤立的 ID。Discourse 已在其他地方将它们归类为与用户关联的个人数据：

- `app/jobs/regular/anonymize_user.rb:43` — `IncomingLink.where(current_user_id: …).update_all(ip_address: new_ip)`，与 `SearchLog`、`TopicLinkClick`、`TopicViewItem`、`UserProfileView` 并列。
- `app/services/user_merger.rb:348` — `IncomingLink.where(user_id: …)` 和 `.where(current_user_id: …)` 均被重新指向目标用户。

用户匿名化和用户合并都处理这些表。用户删除则不处理。

## 影响

无未授权访问：这些行均未向匿名用户或普通用户提供服务，我也未作此声称。影响在于数据保留和引用完整性——在用户行使自助删除权后，站点仍保留以该用户为键的行，包括他们搜索的内容和点击的链接，且没有与删除绑定的过期机制，也没有操作员工具来清除它们。

这对于任何处理删除请求的站点来说都是尴尬的，并且与删除路径对 `posts`、`categories` 和 `topics` 自身的处理方式不一致。

## 建议修复

1. 在 `UserDestroyer#delete_posts` 中，遍历 `user.posts.with_deleted`（或在单独的 `Topic.unscoped.where(user_id: user.id).update_all(user_id: nil)` 路径中置空主题），以便已废弃的首帖不会使其主题保留归属。
2. 按照 `Jobs::AnonymizeUser` 已经列举的方式，将与用户关联的分析表添加到销毁路径中：删除或置空 `IncomingLink.where(user_id:)`、`IncomingLink.where(current_user_id:)` 和 `SearchLog.where(user_id:)`。
3. 与 `posts.user_id` 保持一致地置空剩余的归属列——`post_revisions.user_id`、`custom_emojis.user_id`、`topic_localizations.localizer_user_id`。
4. `policy_users` 属于 `discourse-policy`；它可以挂钩 `user_destroyer_on_content_deletion_callbacks` 或添加 `dependent: :destroy`。如果您愿意，我很乐意在插件仓库中单独开启此问题。

回归测试规范可以断言，在 `UserDestroyer#destroy` 之后，该集合中的任何行都不再保留已销毁用户的 ID。
