我不在 DEV 團隊。我沒有管理後台、正式環境資料庫權限,也沒有內部 Slack。關於 DEV 的運作方式,我知道的一切都來自閱讀公開的 forem/forem 儲存庫。🕵️♂️
今天,光靠這些就足夠診斷出為什麼 DEV 上沒有人收到多次提交的貢獻者徽章、把服務修正往上游送出,還拿到 @benhalpern 本人回覆的 「Thank you for this」 和合併。🚀

4x DEV Contributor Club 徽章:「因為對 Forem 合併了 4 次以上提交,獲得此徽章——非常感謝你的貢獻!」
這也讓我學到了一個謙卑的教訓:有些測試雖然通過了,卻是因為錯誤的原因。🧪
過去幾天,我在 forem/forem 來了一波貢獻衝刺,合併了四個 pull request:
aria-describedby 屬性aria-describedby 參照user_subscription tag 的 src 加上引號,並補上缺少的 alt我的 1x(dev-contributor)徽章在 PR #1 合併後就立刻出現了。✅
當 PR #4 合併時,我根據 config/schedule.yml 知道 BadgeAchievements::BadgeAwardWorker 會在每小時的 :20 執行(cron: "20 * * * *")。我查了 GitHub 的 Contributors API(/repos/forem/forem/contributors),確認我的帳號在第 2 頁,contributions: 4。由於 Forem 設定了 Octokit.auto_paginate = true,worker 肯定有抓到第 2 頁。
等了一個整點 cron。又等兩個。再等三個。還是沒有 4x-commit-club 徽章。💀

每個小時的 :20,我都像這樣刷新個人檔案,彷彿它欠我錢。
與其再刷新個人檔案 24 小時,我乾脆直接打開 app/services/badges/award_contributor_from_github.rb。🔍
Badges::AwardContributorFromGithub 在 main 上長這樣:
BADGE_SLUGS = {
"dev-contributor": 1,
"4x-commit-club": 4,
"8x-commit-club": 8,
"16x-commit-club": 16,
"32x-commit-club": 32
}.freeze
當我把 award_single_commit_contributors(它有發給我的 1x 徽章)跟 award_multi_commit_contributors(負責 4x、8x、16x 和 32x)對比時,三個 bug 立刻跳進眼裡:
BADGE_SLUGS 裡的每個 key 都會經由 Badge.id_for_slug(slug) 解析。如果 production 的 badges 資料表裡某個 slug 跟硬編碼常數不一致,id_for_slug 就會回傳 nil。這個 service 接著對結果 hash 呼叫 .compact 然後繼續往下跑。沒有錯誤。沒有 log。只有某個徽章等級默默消失在虛空中。👻
因為 Forem 只公開 resources :badges, only: [:index],沒有 /badges/:slug 這種公開 show 路由,所以你沒辦法直接查單一徽章 slug。不過,查看公開的 dev.to/badges 型錄時,我立刻得到一個超大的線索:
/badges上的第 423 個徽章:
- 標題:
4x DEV Contributor Club- 描述:
4x-commit-club
等等——為什麼 4x-commit-club 會出現在 description 欄位,而標題卻是 4x DEV Contributor Club?因為當沒有明確設定 slug 時,Badge#generate_slug 會把標題做 parameterize(4x-dev-contributor-club),所以 production 裡儲存的 slug 欄位,根本就跟硬編碼的 "4x-commit-club" 常數對不上!💡

早上 9 點,零資料庫權限,卻把 Badge#generate_slug 和 /badges 的 DOM element IDs 串起來。
單次提交徽章是這樣發的:
BadgeAchievement
.where(user_id: i.user_id, badge_id: dev_contributor_badge_id)
.first_or_create(rewarding_context_message_markdown: msg)
但多次提交徽章卻只是直接 .create:
BadgeAchievement.create(user_id: user_id, badge_id: slug_id)
就算多次提交徽章真的建立成功了,它也漏掉了 rewarding_context_message_markdown: msg("Thank you so much for your contributions!")。提交了四次,卻一點感謝訊息都沒有。😭
create_badges_for_user 每小時執行時,都直接呼叫 BadgeAchievement.create(...),而不是 .where(...).first_or_create(...)。每小時它都會替清單中的每位貢獻者重新嘗試建立已經拿過的徽章,仰賴 ActiveRecord 的 uniqueness 驗證 默默失敗。更糟的是,如果某個徽章曾經設定 allow_multiple_awards: true,那麼單純的 .create 會每小時都插入一筆重複的 achievement——每次都觸發 after_create_commit :notify_recipient 和 send_email_notification(從 [email protected] 把你的信箱灌爆)。📬💥
我開了 Issue #24114,把 badge ID 423–426 的 slug 不匹配問題整理清楚,並搭配 PR #24113 來修正這個 service:
rewarding_context_message_markdown: msg。BadgeAchievement.create 改成 .where(...).first_or_create(...)。在 @benhalpern 本人帳號上的初次深入 review,是由 Forem 的自動化 review agent(OpenHands)發出的,而且它做了一件超讚的事——它對 badges 資料表做了唯讀查詢,並確認了我在 Issue #24114 裡的診斷完全正確:
「我對
badges資料表中五個BADGE_SLUGSkey 做了唯讀查詢。只有其中一個 slug 能解析成功。那四個Nx-commit-clubslug 都沒有對應資料列,所以create_badges_for_user會略過它們,今天沒有人能拿到這些徽章。issue 回報者的推測是正確的。」
中了。🎯
更棒的是,review 還抓到了我 PR 裡另外兩個微妙的缺陷,讓我學到的東西甚至比原本的 bug 還多:
Rails.logger.warn 會造成 log 噪音我一開始是在每個缺少的 slug 都各自記一條 Rails.logger.warn。為什麼這樣不好?
warn log。warn 只會變成永遠存在的背景噪音。修正(5e36999): 收集所有缺少的 slug,然後每次執行只輸出一條 Rails.logger.info。🧹
main 上也通過了(但原因錯了!)為了測試冪等性,我寫了一個 spec,連跑兩次 described_class.call,並斷言使用者對 4x-commit-club 仍然只有 1 筆 badge achievement。
它通過了!滿滿的綠點。🟢
但 review 指出了一個很殘酷的事實:這個測試在我沒有修正的 origin/main 上也會通過。

當你那個閃亮的新迴歸測試,在沒有 bugfix 的 main 上也通過時。
為什麼?因為 badge factory 預設 allow_multiple_awards: false。在 main 上,第二次 BadgeAchievement.create 會撞上 validates :badge_id, uniqueness: { scope: :user_id, if: :single_award_badge? },驗證默默失敗,數量維持在 1。我的測試根本沒有在測 first_or_create,而是在測 ActiveRecord 的 uniqueness validator!🤦♂️
修正(5e36999): 我把 spec 改成建立 allow_multiple_awards: true 的徽章(這樣在 main 上裸 .create 才會真的插入重複資料),並且在第二次執行時斷言 expect { described_class.call }.not_to change(BadgeAchievement, :count)。
當我 push 了 5e36999、34 項檢查全綠之後,@benhalpern 本人親自出手,留下了這則評論:

@benhalpern 本人的那句 「Thank you for this」 真的讓我一整天都值回票價!🙌 緊接著,他把 #24113 合併進 forem:main,成為 d1b962e(我第 5 個被合併的 PR!)。🎉

不久之後,來自 [email protected] 的徽章信就進了我的信箱,4x DEV Contributor Club 徽章也出現在我的個人檔案上——連同我 PR 裡補上的 「Thank you so much for your contributions!」 內容訊息一起。程式碼更乾淨了,PR 也合併了,而那塊綠色的 4x 電路板,終於回家了。💚
main 上失敗時,才算真的成立。 在你慶祝綠燈 spec 之前,先 git stash 你的變更,並在 main 上跑一次 spec。如果它還是過了,那你寫的就不是迴歸測試,而是一個只是在附和你的測試。warn 表示應該有人要採取行動。如果平台支援可選的資料 seed,那麼缺少可選資料列應該記 info,不是 warn。換你說: 你遇過最狡猾的一次測試,居然是因為完全錯誤的原因才通過的嗎?留言告訴我吧 👇