VibeCircle (音圈) - MVP 設計與技術規格
一、產品核心定義
VibeCircle 是一款面向 18 至 25 歲年輕族群的音樂狀態社交產品。 以「音樂作為低壓力社交訊號」結合「可控隱私」來降低發文焦慮、提升互動意願,建立更適合日常回訪的私密社交空間。
要驗證的假設
- 當表達成本足夠低時,使用者會更願意持續露出自己的狀態。
- 當可見對象可以被清楚控制時,使用者會更願意分享更真實的音樂與心情。
- 當對話有自然上下文時,使用者更容易開啟私訊互動。
- 低壓力表達與低風險互動會提升回訪頻率與留存。
目標使用者
- 台灣的大學生與年輕白領
- Spotify 重度使用者
- 有個人品味表達需求,但對主流社群的形象管理感到疲乏
- 對隱私邊界與社交壓力高度敏感
核心痛點
- 想保持存在感,但不想承受傳統社群的發文壓力。
- 主流社群把不同關係混在一起,導致分享前必須先自我審查。
- 想找朋友聊天,但缺少自然、不尷尬的開場。
二、產品階段策略
目前階段:Web MVP
先以 Next.js web app 完成核心產品驗證,原因:
- 尚未進入 Apple Developer 與行動端正式上架流程
- Web 開發迭代速度更快,適合驗證假設
- 基礎建設(DB、Realtime、Storage)先以 self-hosted Docker 承載
未來階段:Native App
Web MVP 驗證後,再開發原生 App(React Native 或其他)。架構設計需保留以下可拆性:
- 商業邏輯集中在 server/domain 層,不綁 Next.js
- API 可抽離成獨立 backend
- Auth、DB、background jobs、realtime 邏輯可被 web 與 app 共用
三、MVP 功能範圍
1. Spotify 狀態同步
- 使用者登入後可主動綁定 Spotify(OAuth)
- 後端定期輪詢 Spotify API 取得目前播放狀態
- Spotify 不是主登入方式,是帳號連結功能
2. 朋友分級
- 每一段好友關係都可以由使用者單向設定 tier
- MVP 固定三層:摯友、一般朋友、點頭之交
- tier 是隱私與內容可見性的基礎授權模型
3. 音樂狀態大廳
- 首頁顯示可見好友的目前播放狀態與今日釘選
- 排序以最近活動與基礎規則為主,不做複雜推薦
- 目標是建立陪伴感與回訪動機
4. Pin of the Day
- 從當下播放歌曲中釘選一首歌,保留 24 小時
- 可附上一句短文字心情
- Pin 的可見性獨立於目前播放狀態,可設定可見 tier
5. Music-triggered DM
- 從朋友的播放狀態或 pin 直接開啟私訊
- 訊息可附帶歌曲上下文作為自然開場
- thread 以使用者對使用者為主
6. 私密對話模式(可選 E2EE)
- 一般 DM 與私密 DM 並存
- 私密模式採端對端加密,平台無法讀取內容
- 詳見本文件第十一節
MVP 不做的事
- 公開圖文動態牆
- 演算法推薦 feed
- Apple Music 支援
- 協作歌單
- 品味配對交友
- 複雜的自訂可見名單規則
- 群聊與社群型聊天室
- 群組 E2EE
- 多裝置金鑰驗證與轉移
四、技術 Stack
Web App: Next.js 15 (App Router) → Vercel
Auth: Auth.js (NextAuth) + Magic Link via Mailgun
ORM: Prisma
Background: Trigger.dev (cloud)
Self-hosted Docker:
├── PostgreSQL ← 主資料庫
├── PgBouncer ← connection pooling(Vercel serverless 必需)
├── Soketi ← Pusher-compatible realtime server
├── MinIO ← S3-compatible object storage
└── Redis ← Soketi backend + 通用 cache
各技術選型理由
Next.js 15 (App Router)
- 符合 web-first MVP 階段
- 同時支援頁面、route handlers 與 SSR
- 搭配 Vercel 快速部署
- 未來可保留 web client,後端再逐步抽離
Auth.js + Mailgun
- email magic link 為主登入方式
- Spotify OAuth 作為登入後的 account linking,不是唯一登入方式
- 控制權比 hosted auth 高,適合未來 web 與 app 共用 auth domain
- Prisma adapter 管理 users、accounts、sessions、verification tokens
Prisma
- schema 與 relation 表達清楚
- migration 對開發階段友善
- 現階段 domain 複雜度適合用 Prisma 提高開發效率
Trigger.dev (cloud)
- 補足 Vercel 缺乏 persistent worker 的限制
- Spotify polling、token refresh、presence processing、媒體後處理
- 支援 cron 與 event-driven job
Self-hosted PostgreSQL + PgBouncer
- 完全掌控資料
- PgBouncer 解決 Vercel serverless cold start 連線爆量問題
- Prisma 設定:
url指向 PgBouncer,directUrl指向 Postgres(migration 用)
Soketi (Realtime)
- 開源 Pusher-compatible WebSocket server
- 輕量,Docker 部署簡單
- client 端直接用
pusher-js,生態成熟 - 與 DB 放同一台 Docker host,延遲低
- 後端 publish 事件只需 HTTP call
MinIO (Storage)
- S3-compatible object storage
- 圖片與附件不進 Postgres
- 後續若要遷移到 S3 / Cloudflare R2,成本低
Redis
- 作為 Soketi 的 adapter backend(多 instance 時)
- 通用 cache layer
- 未來可用於 rate limiting、session 等
五、系統設計原則
1. 可見性與授權由後端控制
所有可見性判斷在後端完成。前端只接收已過濾的 feed 結果,不做隱私過濾。
2. 狀態以事件驅動,保留事件與快照
current_presence:目前可供快速讀取的最新狀態快照presence_events:有限期保留的狀態事件,用於排序、通知、分析
3. Realtime 是通知層,不是資料真相來源
所有真實資料以 Postgres 為準。Realtime 只通知前端「某個資源有變動」,client 收到後局部 refetch。
4. 單體架構,不提早拆微服務
MVP 先採 web app + API + background jobs + Docker infra。商業邏輯集中在 src/server/ domain 層,預留未來抽離的空間。
5. 結構化資料與檔案資料分離
使用者、好友、狀態、pin、訊息 metadata 存 Postgres。圖片附件放 MinIO,DB 只存 metadata 與 object key。
六、Docker Compose 架構
# docker-compose.yml(參考結構)
services:
postgres:
image: postgres:16
volumes:
- pgdata:/var/lib/postgresql/data
environment:
POSTGRES_DB: vibecircle
POSTGRES_USER: vibecircle
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
ports:
- "5432:5432"
pgbouncer:
image: edoburu/pgbouncer
environment:
DATABASE_URL: postgres://vibecircle:${POSTGRES_PASSWORD}@postgres:5432/vibecircle
POOL_MODE: transaction
MAX_CLIENT_CONN: 200
DEFAULT_POOL_SIZE: 20
ports:
- "6432:6432"
depends_on:
- postgres
redis:
image: redis:7-alpine
ports:
- "6379:6379"
soketi:
image: quay.io/soketi/soketi:latest
environment:
DEFAULT_APP_ID: vibecircle
DEFAULT_APP_KEY: ${SOKETI_APP_KEY}
DEFAULT_APP_SECRET: ${SOKETI_APP_SECRET}
SOKETI_ADAPTER_DRIVER: redis
SOKETI_CACHE_DRIVER: redis
SOKETI_REDIS_HOST: redis
SOKETI_REDIS_PORT: 6379
ports:
- "6001:6001" # WebSocket
- "9601:9601" # Metrics
depends_on:
- redis
minio:
image: minio/minio
command: server /data --console-address ":9001"
volumes:
- minio_data:/data
environment:
MINIO_ROOT_USER: ${MINIO_ACCESS_KEY}
MINIO_ROOT_PASSWORD: ${MINIO_SECRET_KEY}
ports:
- "9000:9000" # API
- "9001:9001" # Console
volumes:
pgdata:
minio_data:
Prisma 連線設定
datasource db {
provider = "postgresql"
url = env("DATABASE_URL") // → PgBouncer (6432)
directUrl = env("DIRECT_DATABASE_URL") // → Postgres (5432),migration 用
}
七、Canonical 資料模型
以下為合併後的完整資料模型,實作時以此為準。
users
| 欄位 | 說明 | |------|------| | id | PK | | email | 唯一 | | name | 顯示名稱 | | image | 頭像 URL(MinIO object key) | | presence_audience_tier | 目前播放狀態的預設可見層級 | | created_at | | | updated_at | |
Auth.js 會自動建立
Account、Session、VerificationToken表,與此表關聯。
spotify_accounts
| 欄位 | 說明 | |------|------| | id | PK | | user_id | FK → users | | spotify_user_id | Spotify 端使用者 ID | | access_token | | | refresh_token | | | token_expires_at | | | last_synced_at | 最後一次成功輪詢時間 | | is_active | 是否啟用同步 |
Token refresh 統一由 Trigger.dev 負責,Auth.js 只處理初次 OAuth 取得 token。寫回此表,避免與 Auth.js 內建 token rotation 衝突。
friendships
採雙筆對稱記錄,每個方向各一筆。
| 欄位 | 說明 | |------|------| | id | PK | | user_id | 這一側的使用者 | | friend_id | 另一側的使用者 | | status | pending / accepted / blocked / removed | | tier | close_friend / friend / acquaintance(由 user_id 單向設定) | | created_at | | | updated_at | |
查詢時只需
WHERE user_id = ?,不需 OR 條件。 新增好友時同時寫入兩筆(一筆 pending,一筆 pending)。接受時兩筆同時改為 accepted。 tier 各自獨立:A 可以把 B 設為摯友,B 可以把 A 設為點頭之交。
presence_events
| 欄位 | 說明 | |------|------| | id | PK | | user_id | FK → users | | spotify_track_id | | | track_name | | | artist_name | | | album_name | | | album_art_url | Spotify 提供的 artwork URL | | playback_status | playing / paused / stopped | | spotify_timestamp | Spotify 回傳的時間戳 | | created_at | 系統寫入時間 |
保留期限建議 30 天,定期清理。
current_presence
| 欄位 | 說明 | |------|------| | user_id | PK, FK → users | | presence_event_id | FK → presence_events,最新一筆 | | audience_tier | 可見層級(close_friend / friend / acquaintance) | | updated_at | |
pins
| 欄位 | 說明 | |------|------| | id | PK | | user_id | FK → users | | spotify_track_id | | | track_name | | | artist_name | | | album_name | | | album_art_url | | | mood_text | 心情短文(nullable,上限 100 字) | | audience_tier | 可見層級 | | created_at | | | expires_at | created_at + 24h |
dm_threads
| 欄位 | 說明 | |------|------| | id | PK | | is_e2ee | 是否為私密對話 | | created_at | | | last_activity_at | |
dm_thread_participants
| 欄位 | 說明 | |------|------| | thread_id | FK → dm_threads | | user_id | FK → users | | encrypted_conversation_key | nullable,僅 E2EE thread 使用 | | key_version | nullable |
dm_messages
| 欄位 | 說明 | |------|------| | id | PK | | thread_id | FK → dm_threads | | sender_id | FK → users | | body | 明文訊息內容(一般模式) | | encrypted_payload | 密文(E2EE 模式) | | nonce | 96-bit 隨機 nonce(E2EE 模式) | | cipher_version | 加密演算法版本(E2EE 模式) | | presence_event_id | FK → presence_events,nullable | | pin_id | FK → pins,nullable | | created_at | |
一般模式填
body,E2EE 模式填encrypted_payload+nonce+cipher_version。presence_event_id與pin_id作為上下文關聯,在 E2EE 模式下為明文 metadata(server 可見),這是已知的 metadata trade-off。
user_e2ee_keys
| 欄位 | 說明 | |------|------| | id | PK | | user_id | FK → users | | public_key | | | encrypted_private_key | 由使用者解鎖碼保護 | | kdf_algorithm | argon2id | | kdf_params | JSON(memory、iterations、parallelism) | | salt | 獨立 salt | | key_version | | | created_at | | | rotated_at | nullable |
media_objects
| 欄位 | 說明 | |------|------| | id | PK | | owner_user_id | FK → users | | storage_provider | minio / s3 | | bucket | | | object_key | | | mime_type | | | size_bytes | | | width | nullable | | height | nullable | | visibility | public / private | | created_at | |
八、Auth 設計
登入方式
- 主登入:email magic link(Auth.js + Mailgun)
- Spotify 綁定:登入後由使用者主動連結
為什麼 Spotify 不是主登入
- 產品核心是社交,不是 Spotify 工具
- 降低沒有 Spotify 或暫時不想綁定的註冊阻力
- 未來若支援 Apple Music,不需重做 identity model
Token 管理權責劃分
- Auth.js:只負責初次 Spotify OAuth 取得 token,寫入
spotify_accounts - Trigger.dev:統一負責 token refresh,定期檢查
token_expires_at,刷新後寫回spotify_accounts - 兩邊不互相覆蓋,避免 race condition
九、Realtime 設計
架構
後端 (Next.js API / Trigger.dev)
↓ HTTP publish
Soketi (Pusher-compatible WebSocket server)
↓ WebSocket
前端 (pusher-js client)
事件類型
Realtime 只發小事件,不推完整 payload:
| 事件 | Channel | 觸發時機 |
|------|---------|---------|
| feed.invalidate | private-user:{userId} | 好友狀態變更 |
| pin.changed | private-user:{userId} | 好友 pin 新增/移除 |
| friendship.changed | private-user:{userId} | 好友關係或 tier 變更 |
| dm.new_message | private-thread:{threadId} | 新訊息 |
| dm.thread_updated | private-user:{userId} | 未讀數、thread list 更新 |
原則
- Client 收到事件後局部 refetch,不直接使用推送的資料
- 授權與可見性由後端在讀取時完成,Soketi 不做權限判斷本體
- Soketi channel auth 透過 Next.js API route 處理
十、Spotify 輪詢設計
流程
- 使用者完成 Spotify OAuth,token 存入
spotify_accounts - Trigger.dev cron job 每分鐘執行
- 撈出活躍使用者(依 Spotify 端最後活動時間判斷,非 app 前台狀態)
- 分批呼叫 Spotify
/me/player/currently-playing - 比對
current_presence,有變更才寫入presence_events - 更新
current_presence - 透過 Soketi 推送
feed.invalidate給該使用者的好友
Rate Limit 考量
Spotify API 限制約 180 requests/minute(經驗值,官方未公開明確數字)。
| 活躍使用者數 | 每分鐘 API calls | 策略 | |-------------|------------------|------| | < 150 | < 150 | 每分鐘全量輪詢 | | 150 - 500 | 需分批 | 分 3-4 批,每批間隔 15 秒 | | > 500 | 超過限制 | 分層輪詢:摯友多的使用者高頻,其餘低頻 |
活躍使用者定義
- 以
spotify_accounts.last_synced_at回傳的 Spotify 端timestamp判斷 - 若 Spotify 端超過 30 分鐘無播放活動,降為低頻(每 5 分鐘)
- 若超過 2 小時無活動,暫停輪詢
- 使用者重新開啟 App 時,觸發一次立即同步
十一、E2EE 私密對話設計
安全模型
MVP 採 user-level key 模型(非 device-level)。
每位使用者建立一組 key pair:
public_key:存放於 serverprivate_key:由使用者設定的私密解鎖碼保護,server 只存加密後版本
加密層級
| 層級 | 方式 | |------|------| | 解鎖碼 → 保護金鑰 | KDF(Argon2id,WASM 實作) | | 保護金鑰 → private key | AES-256-GCM | | conversation key → 訊息 | AES-256-GCM | | public key → conversation key | RSA-OAEP 或 ECDH |
KDF 設計
- 使用 Argon2id(透過
argon2-browserWASM,約 300KB) - Web Crypto API 不原生支援 Argon2id,需 WASM 依賴
- 每位使用者獨立 salt
- server 儲存:
kdf_algorithm、kdf_params、salt - server 不儲存:原始解鎖碼、解鎖後的 private key
Nonce 策略
每則訊息使用 隨機 96-bit nonce(crypto.getRandomValues(new Uint8Array(12)))。
不使用 counter-based nonce,避免 nonce reuse 導致加密崩潰。
建立私密對話流程
- 使用者 A 啟用私密對話,設定解鎖碼(建議 8 位以上或 passphrase)
- Client 生成 A 的 key pair
- Client 用解鎖碼經 Argon2id 推導保護金鑰
- Client 以保護金鑰加密 private key
- Server 保存 public key、encrypted private key、KDF 參數
- A 與 B 建立私密對話時,client 產生 conversation_key
- Conversation_key 以雙方 public key 各自包裝
- Server 保存 thread metadata 與 participant key envelope
發送私密訊息流程
- 發送者解鎖自己的 private key
- 取得該 thread 的 conversation_key
- 使用 conversation_key + 隨機 nonce 加密 message body
- 上傳密文、nonce、cipher_version 與 metadata 到 server
- Server 透過 Soketi 通知對方有新訊息
接收私密訊息流程
- 接收者登入,從 server 取得 encrypted_private_key 與 KDF 參數
- 輸入解鎖碼,client 推導保護金鑰並解開 private key
- 解開 conversation_key
- 解密訊息內容
解鎖碼重設規則
- 忘記解鎖碼 → 無法恢復舊私密訊息
- 重設時必須重新生成新 key pair
- 重設後只能解密新的私密對話
- 對另一方的影響:A 重設後,A 無法讀取與 B 的舊私密訊息,但 B 仍可(B 的 key 沒變)
- 進行中的 E2EE thread 需重新產生 conversation_key 並用新 public key 包裝
已知限制與 Trade-off
- Web 端金鑰保護能力有限:相較原生 App,web 對私鑰保護較弱,產品文案避免宣稱超過實際能力的安全級別
- Metadata 未加密:
presence_event_id、pin_id為明文,server 可知「A 傳了一則與某首歌相關的訊息」,但看不到內容 - 內容審查受限:平台無法查看私密內容,濫用處理能力受限
- 推播不帶明文:私密訊息推播只顯示「你有一則新的私密訊息」
對外產品說法
- 可以說:私密對話採端對端加密、平台無法直接讀取內容、遺失解鎖碼無法恢復
- 不建議說:絕對安全、與 Signal 同級、軍規安全
十二、Storage 設計
原則
- 前端不依賴硬編碼 storage URL
- 後端提供簽名 URL 或受控媒體讀取邏輯
- DB 以
storage_provider+bucket+object_key為準
上傳流程
- Client 請求上傳 URL(presigned URL)
- Server 產生 MinIO presigned PUT URL
- Client 直傳 MinIO
- Server 寫入
media_objectsmetadata
十三、專案結構
vibeCircle/
├── src/
│ ├── app/ ← Next.js App Router
│ │ ├── (auth)/
│ │ │ ├── sign-in/
│ │ │ └── verify/
│ │ ├── (main)/
│ │ │ ├── layout.tsx
│ │ │ ├── page.tsx ← Feed 首頁
│ │ │ ├── dm/
│ │ │ │ └── [threadId]/
│ │ │ └── profile/
│ │ │ └── [userId]/
│ │ └── api/
│ │ ├── auth/[...nextauth]/
│ │ ├── feed/
│ │ ├── pins/
│ │ ├── friends/
│ │ ├── dm/
│ │ ├── spotify/
│ │ ├── media/
│ │ └── pusher/auth/ ← Soketi channel auth
│ │
│ ├── server/ ← 商業邏輯層(可抽離)
│ │ ├── services/ ← 業務邏輯
│ │ ├── repositories/ ← 資料存取
│ │ ├── realtime/ ← Soketi publish helpers
│ │ ├── storage/ ← MinIO helpers
│ │ └── auth/ ← Auth 相關邏輯
│ │
│ ├── domain/ ← 型別、常數、純函數
│ │ ├── visibility.ts ← tier/可見性判斷
│ │ └── types.ts
│ │
│ ├── lib/ ← 基礎設施連線
│ │ ├── prisma.ts
│ │ ├── soketi.ts
│ │ ├── minio.ts
│ │ └── redis.ts
│ │
│ └── components/ ← React 元件
│ ├── feed/
│ ├── pin/
│ ├── dm/
│ └── common/
│
├── prisma/
│ ├── schema.prisma
│ └── migrations/
│
├── trigger/ ← Trigger.dev jobs
│ ├── spotify-poller.ts
│ ├── token-refresher.ts
│ ├── presence-processor.ts
│ └── media-jobs.ts
│
├── docker/
│ └── docker-compose.yml
│
├── final/ ← 設計文件(本文件)
└── history/ ← 歷史版本
十四、產品規則
Spotify 輪詢策略
- 只輪詢活躍使用者,依 Spotify 端活動時間判斷
- Polling 間隔依活躍度分層(見第十節)
- Rate limit budget: 約 180 req/min
可見性規則
- tier 為單向設定
- 每個好友關係只允許一個 tier
current_presence與pin各自擁有一個 audience tier- 不做單篇白名單與黑名單
好友模型
- 雙向好友確認
- 封鎖後立即中止互相可見
- 降級或刪除好友後,立即依新規則收斂(MVP 不做歷史內容保留)
通知策略
- 一般播放狀態不做 push
- 摯友的 pin 可考慮推播
- 其他更新以 in-app realtime 為主
十五、MVP 成功指標
表達意願
- Spotify 綁定率
- 首次 pin 建立率
- 每日有狀態更新的使用者比例
- pin 附文字比例
互動意願
- 從歌曲或 pin 開啟 DM 的比例
- 首則私訊回覆率
- 每位活躍使用者的日均互動次數
留存與回訪
- Day 1 / Day 7 留存
- 平均每日開啟次數
- 首頁 feed 瀏覽後的互動轉化率
隱私安全感
- tier 設定使用率
- 有調整 audience 的 pin 比例
- 開啟私密對話的使用者比例
十六、Scale 觀點
先遇到的瓶頸
- Spotify polling API 配額
- presence_events 寫入頻率
- feed 可見性查詢(JOIN friendship + tier + presence)
不建議一開始做的事
- 每次換歌都向所有好友推完整 feed item
- 用 realtime channel 當資料來源
- 在前端做可見性過濾
未來可擴展方向
- 活躍使用者分層 polling
- feed projection / materialized view
- cache layer(Redis)
- 把 API backend 從 Next.js 抽離
- Apple Music 支援
- 協作歌單
- 音樂品味配對
十七、實作優先順序
- Docker Compose 基礎建設(Postgres + PgBouncer + Redis + Soketi + MinIO)
- Next.js 專案初始化 + Prisma schema + migration
- Auth.js + Mailgun magic link
- Spotify account linking(OAuth)
- Trigger.dev Spotify poller + token refresh
- Feed API + 可見性查詢
- Soketi realtime invalidate flow
- Pin of the Day
- DM(一般模式)
- MinIO 媒體上傳
- E2EE 私密對話模式