[CODE]

CastLoop:信任是這個平台的基礎建設

台灣釣魚圈靠關係傳遞真正有用的資訊,不靠平台。CastLoop 的整個架構——從 Zod 邊界驗證到 Helper 核准前的聯絡方式門檻——都從同一個問題出發:如何讓陌生人的可信度在接觸前就變得可讀?

1 min read AI 生成
product nextjs typescript software-engineering

台灣釣魚圈解決資訊稀缺的方式,是讓真正有用的情報不上網。一個釣點被論壇曝光,三個週末後擠滿外來客,魚跑光了。所以真正的情報——高漲潮窗口、魚汛走向、這個月在哪裡爆魚——留在 Line 群裡,留在釣具店的閒談裡,留在「你認識誰就問誰」的人際鏈裡。進入這個圈子的通行證是關係,不是搜尋。

CastLoop 要解決的問題不是「如何配對釣客」,是「如何讓陌生人的可信度在實際接觸前就變得可讀」。平台有五種 Wish 類型:借釣具、分享釣點知識、找釣伴、老手帶新手、以及其他。每一種都要求一個人對陌生人做平常只對熟人才做的事。NT$8,000 的竿子借給沒見過面的人,默默釣了多年的私點分享出去,同意花一整天跟不認識的人並肩釣魚——這些行為的前提不是介面設計,是某種足夠可信的紀錄。

整個架構從這個前提出發。

兩條信任軌道,有順序,不是競爭

信任層跑兩個機制:point_transactionsreviews。兩者解決同個問題的不同半段,成熟速度不同。

評論需要完整的交換閉環:Wish 被接受、幫助完成、雙方確認。剛加入的用戶評論數是零,需要等到第一筆完整交易結束才有第一則評論。純評論系統的問題在這裡——新成員要先有足夠信譽才有人願意給機會,但要有信譽就得先有機會。這是一個卡死的循環,懲罰的正是社群最需要吸引進來的人。

點數從第一天開始累積。point_transactions 記錄每一個貢獻行為:welcome(加入時的起始點數)、help_givenhelp_receivedreview_given。一個人加入第一週提供了三次借具機會,點數紀錄就在那裡,不需要等評論累積。users 資料表上的 total_pointshelp_count 說的是一個時間維度的故事,評論告訴你的是互動質量,兩者互補,不是替代。點數提供早期信號,評論隨著活動量累積提供更高解析度的信號。

聯絡方式的門檻

多數社群平台沒有這個機制:在 Helper 被接受前,應用層會執行 validateSocialContacts。用戶必須在 social_contacts 欄位填寫至少一個真實世界的聯絡方式——Line ID、WhatsApp 號碼、Instagram、Facebook、Telegram,任一個都算。這個驗證在 CommunityService 層執行,不在資料庫層。

選擇這一層有具體原因。資料庫約束負責永遠必須成立的資料完整性規則。社群行為規則——尤其是會隨平台成熟而演化的規則——屬於應用層,才能獨立測試、獨立修改,不需要每次調整都帶著 migration。但更根本的理由是產品邏輯:信任轉移需要一個平台外的協調管道。借具要約地點、分享釣點要溝通時間、找釣伴要確認集合——Helper 沒有任何可聯絡的方式,這筆交易在現實世界根本無法完成。

聯絡方式門檻不是表單完整度的問題,是讓 Wish 能在現實世界閉環的基礎條件。

Domain 層的命名與資料庫的命名

資料庫資料表叫 castsloops,domain 層叫同一件事 WishHand。這個分離存在的原因是:平台的品牌語言和 domain 的通用語言不必相同,把兩個混在一起只會讓之後的修改成本彼此綁架。品牌改名時不應該觸及任何業務邏輯,業務規則改動時也不應該觸及品牌層的命名。

Infra 邊界上有一個 toWish() mapper,收到 Prisma 的 casts row 後呼叫 WishSchema.parse(),在回傳任何東西給應用層之前先驗證。parse() 在邊界上出錯,遠比資料帶著錯誤默默流入 domain 邏輯要好得多。Prisma 生成的型別會因為欄位改動而與現實脫節,WishSchema.parse() 把那個不一致轉成顯性錯誤,在它造成損害之前攔下來。

Mapper 還處理一個細節:資料庫儲存 locale 值是 "zh-TW"(連字號),Prisma 生成的 enum variant 是 zh_TW(底線)。這個轉換住在一個函式裡,不擴散到其他地方。

狀態機的正確性

WishService.approveHand() 在 commit 前執行幾個不變量的驗證:Wish 必須是 open、Hand 必須是 pending、這個 Wish 下不能已有一個 active Hand。最後一個檢查是最容易被忽略的那個。在並發情況下,兩個核准請求可能在任一筆寫入前同時通過前兩個條件——應用層的 LoopAlreadyTakenError 處理常見情況,但應用層防不了 DB 層的 race。

資料庫用 partial unique index 封死這個缺口:uq_loops_cast_active 在每個 cast_id 下只允許一筆 status = 'active' 的 loop。Schema 另外也在 (cast_id, helper_id) 上設了涵蓋 pendingactive 狀態的複合 partial unique,防止同一個 Helper 重複送出申請。

兩道防線,兩個失敗面。應用層處理可讀的錯誤路徑,資料庫防止在並發寫入下漏接的 race condition。

基礎建設

Prisma v7 移除了內建的資料庫驅動。應用程式在啟動時明確初始化一個 PrismaPg adapter(@prisma/adapter-pg),連線行為因此變成顯性的,不再藏在框架預設值背後。Adapter 直接處理連線池,不會在連線數達到上限時出現意外行為。

部署是 Docker 容器(next.config.mjs 設定 output: 'standalone'),跑在 Railway 上。容器持有持久的資料庫連線,不是每個請求新建一個。在一個寫入正確性有要求的平台上——Hand 核准不能觸發兩次、點數交易不能記一次扣兩次——持久連線消除了一整類在 benchmark 看不到、卻在深夜突然浮現的 timing bug。

Auth 範疇

Auth 用 Next Auth v5 beta,只接 Google OAuth。沒有 magic link、沒有密碼重設流程、沒有 token 過期的邊界情況要自己處理。signIn callback 目前刻意最小化;首次登入建立用戶資料列是一個單一、可追蹤的擴展點,不是一套要自建和維護的系統。

沒有花在 auth 基礎建設上的工程時間,全部進了信任 schema 和 domain model——讓這個平台有存在理由的地方。

還沒有答案的問題

Schema 建好了,信任機制在跑,狀態機在應用層和資料庫層都確保了正確性。還沒有答案的是文化假設:量化的信譽——點數、交易次數、一個可聯絡的真實帳號——是否足以讓人真的把 NT$8,000 的竿子借給陌生人,或把一個人默默釣了多年的私點分享出去。

台灣釣魚圈靠關係保護情報已經幾十年了。CastLoop 在測試另一種模型是否可行。這個問題,架構回答不了。