vibecircle dev

VibeCircle (音圈) - MVP 設計與技術規格

一、產品核心定義

VibeCircle 是一款面向 18 至 25 歲年輕族群的音樂狀態社交產品。 以「音樂作為低壓力社交訊號」結合「可控隱私」來降低發文焦慮、提升互動意願,建立更適合日常回訪的私密社交空間。

要驗證的假設

  1. 當表達成本足夠低時,使用者會更願意持續露出自己的狀態。
  2. 當可見對象可以被清楚控制時,使用者會更願意分享更真實的音樂與心情。
  3. 當對話有自然上下文時,使用者更容易開啟私訊互動。
  4. 低壓力表達與低風險互動會提升回訪頻率與留存。

目標使用者

  • 台灣的大學生與年輕白領
  • Spotify 重度使用者
  • 有個人品味表達需求,但對主流社群的形象管理感到疲乏
  • 對隱私邊界與社交壓力高度敏感

核心痛點

  1. 想保持存在感,但不想承受傳統社群的發文壓力。
  2. 主流社群把不同關係混在一起,導致分享前必須先自我審查。
  3. 想找朋友聊天,但缺少自然、不尷尬的開場。

二、產品階段策略

目前階段:Web MVP

先以 Next.js web app 完成核心產品驗證,原因:

  • 尚未進入 Apple Developer 與行動端正式上架流程
  • Web 開發迭代速度更快,適合驗證假設
  • 基礎建設(DB、Realtime、Storage)先以 self-hosted Docker 承載

未來階段:Native App

Web MVP 驗證後,再開發原生 App(React Native 或其他)。架構設計需保留以下可拆性:

  1. 商業邏輯集中在 server/domain 層,不綁 Next.js
  2. API 可抽離成獨立 backend
  3. 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 會自動建立 AccountSessionVerificationToken 表,與此表關聯。

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_versionpresence_event_idpin_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 更新 |

原則

  1. Client 收到事件後局部 refetch,不直接使用推送的資料
  2. 授權與可見性由後端在讀取時完成,Soketi 不做權限判斷本體
  3. Soketi channel auth 透過 Next.js API route 處理

十、Spotify 輪詢設計

流程

  1. 使用者完成 Spotify OAuth,token 存入 spotify_accounts
  2. Trigger.dev cron job 每分鐘執行
  3. 撈出活躍使用者(依 Spotify 端最後活動時間判斷,非 app 前台狀態)
  4. 分批呼叫 Spotify /me/player/currently-playing
  5. 比對 current_presence,有變更才寫入 presence_events
  6. 更新 current_presence
  7. 透過 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:存放於 server
  • private_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-browser WASM,約 300KB)
  • Web Crypto API 不原生支援 Argon2id,需 WASM 依賴
  • 每位使用者獨立 salt
  • server 儲存:kdf_algorithmkdf_paramssalt
  • server 不儲存:原始解鎖碼、解鎖後的 private key

Nonce 策略

每則訊息使用 隨機 96-bit noncecrypto.getRandomValues(new Uint8Array(12)))。 不使用 counter-based nonce,避免 nonce reuse 導致加密崩潰。

建立私密對話流程

  1. 使用者 A 啟用私密對話,設定解鎖碼(建議 8 位以上或 passphrase)
  2. Client 生成 A 的 key pair
  3. Client 用解鎖碼經 Argon2id 推導保護金鑰
  4. Client 以保護金鑰加密 private key
  5. Server 保存 public key、encrypted private key、KDF 參數
  6. A 與 B 建立私密對話時,client 產生 conversation_key
  7. Conversation_key 以雙方 public key 各自包裝
  8. Server 保存 thread metadata 與 participant key envelope

發送私密訊息流程

  1. 發送者解鎖自己的 private key
  2. 取得該 thread 的 conversation_key
  3. 使用 conversation_key + 隨機 nonce 加密 message body
  4. 上傳密文、nonce、cipher_version 與 metadata 到 server
  5. Server 透過 Soketi 通知對方有新訊息

接收私密訊息流程

  1. 接收者登入,從 server 取得 encrypted_private_key 與 KDF 參數
  2. 輸入解鎖碼,client 推導保護金鑰並解開 private key
  3. 解開 conversation_key
  4. 解密訊息內容

解鎖碼重設規則

  • 忘記解鎖碼 → 無法恢復舊私密訊息
  • 重設時必須重新生成新 key pair
  • 重設後只能解密新的私密對話
  • 對另一方的影響:A 重設後,A 無法讀取與 B 的舊私密訊息,但 B 仍可(B 的 key 沒變)
  • 進行中的 E2EE thread 需重新產生 conversation_key 並用新 public key 包裝

已知限制與 Trade-off

  1. Web 端金鑰保護能力有限:相較原生 App,web 對私鑰保護較弱,產品文案避免宣稱超過實際能力的安全級別
  2. Metadata 未加密presence_event_idpin_id 為明文,server 可知「A 傳了一則與某首歌相關的訊息」,但看不到內容
  3. 內容審查受限:平台無法查看私密內容,濫用處理能力受限
  4. 推播不帶明文:私密訊息推播只顯示「你有一則新的私密訊息」

對外產品說法

  • 可以說:私密對話採端對端加密、平台無法直接讀取內容、遺失解鎖碼無法恢復
  • 不建議說:絕對安全、與 Signal 同級、軍規安全

十二、Storage 設計

原則

  • 前端不依賴硬編碼 storage URL
  • 後端提供簽名 URL 或受控媒體讀取邏輯
  • DB 以 storage_provider + bucket + object_key 為準

上傳流程

  1. Client 請求上傳 URL(presigned URL)
  2. Server 產生 MinIO presigned PUT URL
  3. Client 直傳 MinIO
  4. Server 寫入 media_objects metadata

十三、專案結構

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_presencepin 各自擁有一個 audience tier
  • 不做單篇白名單與黑名單

好友模型

  • 雙向好友確認
  • 封鎖後立即中止互相可見
  • 降級或刪除好友後,立即依新規則收斂(MVP 不做歷史內容保留)

通知策略

  • 一般播放狀態不做 push
  • 摯友的 pin 可考慮推播
  • 其他更新以 in-app realtime 為主

十五、MVP 成功指標

表達意願

  • Spotify 綁定率
  • 首次 pin 建立率
  • 每日有狀態更新的使用者比例
  • pin 附文字比例

互動意願

  • 從歌曲或 pin 開啟 DM 的比例
  • 首則私訊回覆率
  • 每位活躍使用者的日均互動次數

留存與回訪

  • Day 1 / Day 7 留存
  • 平均每日開啟次數
  • 首頁 feed 瀏覽後的互動轉化率

隱私安全感

  • tier 設定使用率
  • 有調整 audience 的 pin 比例
  • 開啟私密對話的使用者比例

十六、Scale 觀點

先遇到的瓶頸

  1. Spotify polling API 配額
  2. presence_events 寫入頻率
  3. feed 可見性查詢(JOIN friendship + tier + presence)

不建議一開始做的事

  • 每次換歌都向所有好友推完整 feed item
  • 用 realtime channel 當資料來源
  • 在前端做可見性過濾

未來可擴展方向

  • 活躍使用者分層 polling
  • feed projection / materialized view
  • cache layer(Redis)
  • 把 API backend 從 Next.js 抽離
  • Apple Music 支援
  • 協作歌單
  • 音樂品味配對

十七、實作優先順序

  1. Docker Compose 基礎建設(Postgres + PgBouncer + Redis + Soketi + MinIO)
  2. Next.js 專案初始化 + Prisma schema + migration
  3. Auth.js + Mailgun magic link
  4. Spotify account linking(OAuth)
  5. Trigger.dev Spotify poller + token refresh
  6. Feed API + 可見性查詢
  7. Soketi realtime invalidate flow
  8. Pin of the Day
  9. DM(一般模式)
  10. MinIO 媒體上傳
  11. E2EE 私密對話模式