每次出遠門釣魚都會遇到同一個問題。不熟悉當地的釣點,不知道什麼釣法有效,也不想只為了一個週末的行程買一整套裝備。通常最後都是在論壇上發文,希望在 出發前有當地人回覆。
CastLoop 想解決的就是這個麻煩。
概念很直接:熟悉當地的釣友,幫助外地來的釣客。出借釣具、分享幾個釣點、甚至一起去釣。這種共享模式在衝浪板、露營裝備上都有人做,但我沒找到一個真正以釣魚文化為核心設計的平台。
這個產品真正要做的事
選技術之前,我先花時間把這個產品真正要完成的「工作」寫下來——不是功能列表,是目的。
平台的核心是釣友之間可以互相提供的四種幫助。我把這些設計成不同的 Cast 類型:
釣具出借(borrow_gear)。 在地釣客列出手邊可以借出的裝備和時間。旅遊釣客瀏覽、提出請求、協調取件。聽起來簡單,但有真實的信任問題在裡面——有人要把一支要價幾千塊的竿子交給陌生人。
釣魚建議(fishing_tip)。 分享特定地區的技巧、釣組搭配、目標魚種在地知識。不只是座標——而是讓這個釣點值得去的脈絡。
找釣伴(find_buddy)。 有些人釣魚喜歡有伴,或想找熟悉當地水域的人帶路。這個類型讓釣友可以約好一起出釣。
新手指導(beginner_guide)。 專門給新手的類別。有經驗的釣客如果想回饋社群,可以發布給剛入門的人看的系統性建議。
站內訊息。 沒有溝通管道,其他功能都會垮。釣具交換需要協調,約釣伴需要安排。所以訊息功能是非做不可的。
信譽積分。 把昂貴的釣具借給陌生人需要信任,信任需要累積。平台用 total_points 和 help_count 追蹤每位使用者的信譽,每一個助人行為背後都有 point transaction 紀錄。這不是為了遊戲化而設計——而是讓借用方能判斷出借方是否可靠,反之亦然的核心機制。
就這樣。我很努力克制自己,沒有在第一天就把範疇擴大成「釣魚版的 Airbnb」,但信譽積分這層不是可選的——沒有它,沒有人會願意出借裝備。
為什麼選 PostgreSQL 而不是 SQLite
第一直覺是 SQLite。零設定、本地開發很舒服,對於個人小專案通常綽綽有餘。
但 CastLoop 是社群平台。多個使用者會同時寫入——有人送出租借請求的同時,另一個人在發布評論。SQLite 的寫入鎖在這種情況下很快就會變成瓶頸。PostgreSQL 對並發寫入的支援才是對的選擇。
最後選擇部署在 Railway。有一件事是親身踩過才學到的:Prisma 跟持久連線的資料庫配合得比無伺服器模式好得多。Railway 給我一個真實的 PostgreSQL 實例,不用擔心 serverless 冷啟動的連線池問題。
我用的 Prisma 工作流程
我不手動寫型別,而是直接從資料庫 schema 產生:
npx prisma db pull && npx prisma generate
這會自動產生 types/database.ts。這個檔案我從不手動修改。如果 schema 有變動,我更新 schema.prisma,跑 migration,再重新產生一次。
// types/database.ts — 自動產生,不要手動編輯
export type Prisma = {
gear: {
id: string;
title: string;
ownerId: string;
available: boolean;
createdAt: Date;
};
// ...
};
這聽起來很理所當然,但我花了幾個專案的教訓才停止跟 Prisma schema 和手寫介面之間的型別落差搏鬥。讓 Prisma 產生所有東西,然後繼續往前走。
DDD 讓層級保持乾淨
我用了 Domain-Driven Design,並設了一條硬規則:domain 層不能有任何框架的 import。Prisma 型別不能滲漏進 domain entity。Next.js server action 的邏輯不能直接坐在 domain service 裡面。
邊界長這樣:
// domain/gear/GearListing.ts — 沒有 Prisma,沒有 Next.js
export type GearListing = {
id: GearListingId;
title: string;
ownerId: UserId;
isAvailable: boolean;
};
// infra/prisma/GearListingMapper.ts — Prisma 只住在這裡
import type { gear } from "@prisma/client";
import type { GearListing } from "@/domain/gear/GearListing";
export const toGearListing = (row: gear): GearListing => ({
id: row.id as GearListingId,
title: row.title,
ownerId: row.ownerId as UserId,
isAvailable: row.available,
});
Mapper 是唯一同時認識兩個層的地方。Server action 呼叫 app service,app service 呼叫 repository,repository 才跟 Prisma 說話。比直接在 server action 裡呼叫 Prisma 多了一些程式碼,但每一層都乾淨,也可以獨立測試。
認證這件事不值得自己刻
我大概花了兩個小時在考慮要不要從頭自己做 email 密碼登入。然後我回想起每次這樣做的下場——三週後一定會在 token 過期或 email 驗證某個邊界情況卡住。
Auth.js 處理 email magic link 和 Google OAuth。我設定一次就好:
// auth.config.ts
export const authConfig = {
providers: [
Google({
clientId: process.env.GOOGLE_CLIENT_ID,
clientSecret: process.env.GOOGLE_CLIENT_SECRET,
}),
Resend({ from: "no-reply@castloop.app" }),
],
// ...
};
完成,繼續。這個產品的核心價值不在認證邏輯上。
選 Next.js App Router 是因為真的適合
選 Next.js 14 App Router 不是因為它流行,是因為 server component 確實能減少送到瀏覽器的 JavaScript。一個釣具列表頁不需要是互動的——它只需要快速渲染。用 server component 可以直接從資料庫抓資料、輸出 HTML,那個頁面根本不需要 client bundle。
代價是心智模型要花時間適應。"use client" 邊界、async server component、資料在哪個時間點被抓取——我花了一段時間才停止不小心把不需要的東西做成 client side。
但對於一個大部分頁面都是讀取為主的社群平台來說,這個架構跟使用情境很合。