從通訊軟體到社群與短影片:物件儲存、推薦、mini app 與會員安全

這篇接著〈從裝置出發打造通訊軟體〉談社群和短影片,它們比通訊軟體多了公開內容、大量影片、推薦,還有跑在自家 App 裡的第三方程式。下面照這個順序寫,最後補上會員安全和法規。

公開內容讓伺服器回到中心

通訊軟體可以把資料放回裝置,是因為內容只給收發雙方看。社群貼文和短影片一發出去就是給陌生人看的,伺服器得讀得到內容,才能建索引、算推薦、處理檢舉。端對端加密保留給私訊,公開內容照一般服務設計。

想讓使用者帶得走資料,現有的路是聯邦協定:

  • ActivityPub 在 2018 年 1 月 23 日成為 W3C Recommendation,每個帳號有 inbox 和 outbox,伺服器之間互相投遞。Mastodon 這一系都用它。
  • Bluesky 的 AT Protocol 在 2024 年 2 月 22 日開放聯邦,任何人都能架自己的 PDS(Personal Data Server),一開始有規模上的限制。
  • Threads 在 2025 年 6 月 17 日加上獨立的 fediverse 動態與跨站搜尋,當時還不能從那個動態直接回覆。
  • Matrix 的房間在各 homeserver 之間複製,規格寫明沒有任何一台 homeserver 擁有某個房間,適合社群聊天室。

影片檔放哪:MinIO 之後的選擇

自架 S3 相容儲存,過去幾年最常見的答案是 MinIO,但社群版這一年多變化很大:

  • 2025 年 5 月 24 日的版本把內建管理介面標為 deprecated,移到 object-browser,LDAP/OIDC 登入只留在商業版 AIStor。
  • 2025 年 10 月起,README 寫明社群版只提供原始碼,不再提供預先編譯的執行檔。
  • 2025 年 12 月,README 加上 maintenance mode,不再接受新變更,安全修正個案評估。
  • GitHub 頁面顯示 repo 在 2026 年 4 月 25 日封存為唯讀。

授權一直是 AGPLv3,舊版還能用,但社群版已不再開發。新專案可以考慮這些:

選項授權語言定位與備註
SeaweedFSApache 2.0Go物件儲存、檔案系統與 Iceberg table,強調大量小檔案
GarageAGPL-3.0Rust小規模、跨地點的自架部署,可以跑在機房以外
RustFSApache 2.0Rust2026 年 9 月 16 日發布 1.0 GA,官方稱可用於正式環境,2025 年 7 月才開源
Ceph RGWLGPLC++建在 Ceph 的 RADOS 之上,S3 API 相容,要連同整套 Ceph 叢集一起維運
Apache OzoneApache 2.0Java偏向資料分析的分散式儲存,原生支援 S3 協定

不自架的話,Cloudflare R2 的定價頁寫明出站流量不收費,影片讀多寫少,出站流量這筆值得先算進成本。不管選哪個,程式裡只用 S3 API,不要用到某家獨有的功能,之後換掉的成本就小。

影片本身的流程大致是:上傳原始檔、轉檔成多種解析度、切成 HLS 片段(RFC 8216,2017 年)、放進物件儲存、前面掛 CDN。要壓低延遲可以用 Low-Latency HLS,把片段再切成更小的 partial segment(Apple 文件的例子約 200 毫秒),這部分規則在 HLS 第二版草案(draft-pantos-hls-rfc8216bis)裡,還沒成為 RFC。

推薦:先用 Qdrant 做最小版本

YouTube 2016 年的論文把推薦拆成兩階段:先從大量影片裡挑出幾百個候選,再用另一個模型排序。ByteDance 2022 年的 Monolith 論文處理的是另一個問題,即時訓練:embedding table 沒有雜湊碰撞、會過期淘汰,模型在線上持續訓練,使用者的互動近即時反映到推薦上。

起步不需要這麼複雜,把影片的標題和 hashtag 轉成向量放進 Qdrant,拿使用者看完的影片當正例、滑掉的當負例,就是一個能用的候選產生器:

from importlib.metadata import version
import tempfile

from fastembed import TextEmbedding
from qdrant_client import QdrantClient, models

# Each short video is described by its caption and hashtags.
VIDEOS = {
    1: "十分鐘學會手沖咖啡 #咖啡 #教學",
    2: "冷萃咖啡怎麼做才不苦 #咖啡",
    3: "陽明山夜景散步路線 #登山 #台北",
    4: "新手第一次爬百岳要準備什麼 #登山",
    5: "貓咪第一次看到雪 #貓 #寵物",
    6: "在家做義式濃縮的機器怎麼挑 #咖啡 #開箱",
    7: "股票新手常犯的五個錯 #理財",
    8: "合歡山日出要幾點出發 #登山 #日出",
}

MODEL = "sentence-transformers/paraphrase-multilingual-mpnet-base-v2"
embedder = TextEmbedding(MODEL)
vectors = dict(zip(VIDEOS, embedder.embed(list(VIDEOS.values()))))

liked, skipped = [1, 3], [7]  # watched to the end vs swiped away
seen = liked + skipped

with tempfile.TemporaryDirectory() as path:
    client = QdrantClient(path=path)
    client.create_collection(
        "videos",
        vectors_config=models.VectorParams(
            size=len(vectors[1]), distance=models.Distance.COSINE
        ),
    )
    client.upsert(
        "videos",
        points=[
            models.PointStruct(id=i, vector=v.tolist(), payload={"caption": VIDEOS[i]})
            for i, v in vectors.items()
        ],
    )
    not_seen = models.Filter(must_not=[models.HasIdCondition(has_id=seen)])
    for strategy in (
        models.RecommendStrategy.AVERAGE_VECTOR,
        models.RecommendStrategy.BEST_SCORE,
    ):
        hits = client.query_points(
            "videos",
            query=models.RecommendQuery(
                recommend=models.RecommendInput(
                    positive=liked, negative=skipped, strategy=strategy
                )
            ),
            query_filter=not_seen,
            limit=3,
        ).points
        print(strategy.value)
        for h in hits:
            print(f"  {h.score:.3f} {h.payload['caption']}")
    client.close()

print("qdrant-client", version("qdrant-client"), "fastembed", version("fastembed"))

輸出:

average_vector
  0.548 合歡山日出要幾點出發 #登山 #日出
  0.448 冷萃咖啡怎麼做才不苦 #咖啡
  0.411 在家做義式濃縮的機器怎麼挑 #咖啡 #開箱
best_score
  0.712 合歡山日出要幾點出發 #登山 #日出
  0.707 冷萃咖啡怎麼做才不苦 #咖啡
  0.704 在家做義式濃縮的機器怎麼挑 #咖啡 #開箱
qdrant-client 1.19.1 fastembed 0.8.1

貓的影片沒被推出來(理財那支是負例,本來就排除了),登山和咖啡各有入選。兩種策略的分數尺度不同,不能互相比較:average_vector 把正負例合成一個查詢向量,成本跟一般搜尋一樣;best_score 逐一比對每個例子,例子越多越慢,好處是使用者同時喜歡兩種差很多的內容時,查詢不會被平均到兩邊都不像。同樣是登山,「新手第一次爬百岳」沒有進前三名,可見只靠標題文字的向量很粗,實際上線還得加進觀看時長、互動和熱門度。

這個版本只看內容本身。想做到「看過這支的人也看了那支」,Qdrant 官方的 MovieLens 範例把每個使用者的評分存成 sparse vector,先找相似的使用者,再統計他們看過的影片。冷啟動和多樣性最好從第一版就處理:

  • 冷啟動:TikTok 2020 年的說明是新使用者可以先選興趣類別,跳過的人看到一般熱門影片。
  • 多樣性:同一份說明寫到,動態通常不會連續出現同一個音樂或同一個創作者的影片,偶爾也會放使用者興趣以外的內容。這種規則放在排序之後做就好,跟向量搜尋分開。

透過 WebView 加入第三方 mini app

讓第三方開發者在自家 App 裡放小程式,幾個平台是這樣做的:

  • 微信小程式的渲染層用 WebView,邏輯層在獨立的 JsCore 執行緒跑,兩邊透過原生用戶端溝通,網路請求也由用戶端代發。
  • 支付寶小程式的文件寫明框架不在瀏覽器裡執行,document、window 這些物件不能用。
  • LINE MINI App 2023 年 9 月 27 日在台灣開放申請,本質是 LIFF 網頁跑在 LINE 的 WebView 裡;2025 年 10 月 1 日起所有 MINI App 都能在一般瀏覽器開啟。
  • Telegram Mini Apps 也是 WebView 載入網頁,啟動時由 Telegram 帶入簽過章的使用者資料。

上架 iOS 還要看 App Store 審核指南 4.7。2024 年 1 月 25 日的修訂把 mini app、mini game、chatbot 和 plug-in 都納入,宿主 App 要為這些軟體負責;4.7.2 規定沒有 Apple 事先同意,不能把原生 API 開放給這些程式,4.7.3 規定分享資料或隱私權限要每次都取得使用者明確同意。

技術上最常出事的是 JavaScript bridge。Android 4.2(API 17)之前,addJavascriptInterface 讓網頁可以透過反射呼叫任意 Java 方法(CVE-2012-6636);現在的官方文件仍提醒,注入的物件會出現在 WebView 裡的每個 frame,包括 iframe。建議改用 addWebMessageListener 加上 allowedOriginRules,只接受指定來源的訊息,也不要拿 WebView.getUrl() 當安全判斷。iOS 的 WKScriptMessage 可以從 frameInfo.securityOrigin 確認訊息來自哪個 frame。

另一個常出問題的是身分。mini app 的前端拿到的使用者資料,送到後端之前都可能被改過。LINE 的文件直接寫明,不要把 liff.getProfile() 拿到的資料送到伺服器,要送 ID token,由伺服器向 LINE 驗證。Telegram 的做法是對啟動資料簽章:用 "WebAppData" 當 key 對 bot token 做 HMAC-SHA256 得到 secret key,再對排序後的欄位算 HMAC,跟 hash 欄位比對。下面模擬平台簽章、伺服器驗證,再試竄改和過期兩種情況:

import hashlib
import hmac
import json
import time
from urllib.parse import parse_qsl, urlencode


class InitDataError(Exception):
    pass


def _check_string(fields: dict[str, str]) -> str:
    return "\n".join(f"{k}={v}" for k, v in sorted(fields.items()))


def _secret(bot_token: str) -> bytes:
    return hmac.new(b"WebAppData", bot_token.encode(), hashlib.sha256).digest()


def sign(fields: dict[str, str], bot_token: str) -> str:
    # What the platform does before handing init data to the mini app.
    digest = hmac.new(_secret(bot_token), _check_string(fields).encode(), hashlib.sha256)
    return urlencode({**fields, "hash": digest.hexdigest()})


def verify(init_data: str, bot_token: str, max_age: int = 3600) -> dict[str, str]:
    # What the mini app's own server must do before trusting anything.
    fields = dict(parse_qsl(init_data, strict_parsing=True))
    received = fields.pop("hash", "")
    expected = hmac.new(
        _secret(bot_token), _check_string(fields).encode(), hashlib.sha256
    ).hexdigest()
    if not hmac.compare_digest(received, expected):
        raise InitDataError("bad hash")
    if time.time() - int(fields["auth_date"]) > max_age:
        raise InitDataError("expired")
    return fields


BOT_TOKEN = "123456:TEST-ONLY-TOKEN"  # dummy value for the example
now = str(int(time.time()))
user = json.dumps({"id": 42, "first_name": "Demo"}, separators=(",", ":"))

good = sign({"auth_date": now, "query_id": "AAE", "user": user}, BOT_TOKEN)
print("valid:", json.loads(verify(good, BOT_TOKEN)["user"])["id"])

forged = good.replace("%22id%22%3A42", "%22id%22%3A1")
old = sign({"auth_date": str(int(now) - 7200), "user": user}, BOT_TOKEN)
for name, data in (("forged", forged), ("old", old)):
    try:
        verify(data, BOT_TOKEN)
    except InitDataError as e:
        print(f"{name}: rejected ({e})")

輸出:

valid: 42
forged: rejected (bad hash)
old: rejected (expired)

比對用 hmac.compare_digest,避免逐字元比較洩漏時間差;auth_date 要檢查,否則截到的舊資料可以一直重用。Telegram 在 2024 年 11 月 17 日的 Bot API 8.0 另外加了 Ed25519 簽章,讓不持有 bot token 的第三方也能驗證,自己做平台也可以比照:平台用私鑰簽,mini app 開發者拿公鑰驗。

金鑰放在前端有多常見,CCS 2023 有篇論文實際量過:掃了 3,450,586 個微信小程式,其中 40,880 個把 AppSecret 這種主金鑰放在前端程式碼裡,研究者示範了竄改手機號碼接管帳號、濫用優惠和盜用付費雲端服務。

會員系統夠不夠安全

用平台登入(LINE、Telegram、Apple、Google)只確認了使用者是誰,之後的 session、權限和資料保護都得自己做。可以對照兩份公開標準檢查:

  • NIST SP 800-63B-4(2025 年 7 月 31 日定稿):只用密碼時至少 15 字元,搭配多因素時至少 8 字元;不能強制大小寫符號這類組成規則,不能要求定期改密碼;新密碼要比對常見與已外洩的密碼清單;AAL2 等級要至少提供一種抗釣魚的驗證方式,passkey 屬於這一類。
  • OWASP ASVS 5.0.0(2025 年 5 月 30 日發布):逐項列出驗證、session、存取控制等檢查點,適合拿來當上線前的清單。

做 mini app 時,自家帳號和平台帳號的對應要放在伺服器,用驗證過的平台使用者 ID 當 key;前端送來的任何身分欄位都不能直接寫進資料庫。session token 要短命,敏感操作再驗一次。

法規:台灣與歐盟目前公布的內容

台灣:

  • 個人資料保護法:2023 年 5 月 31 日公布的修正,增訂個人資料保護委員會為主管機關,非公務機關未採取安全措施時,不必先限期改正即可處罰,一般情況新臺幣 2 萬元至 200 萬元,情節重大 15 萬元至 1,500 萬元。2025 年 11 月 11 日公布的修正,規定非公務機關發生個資外洩要通知當事人並通報個資會,並給個資會行政檢查權,施行日期由行政院定。截至本文查詢時,個資會官網仍以籌備處名義運作。
  • 詐欺犯罪危害防制條例:2024 年 7 月 31 日公布,規範網路廣告平臺、電商、第三方支付與網路連線遊戲業者。達一定規模的廣告平臺要驗證廣告主與出資者身分、標示廣告與 AI 生成的肖像,並在期限內下架詐騙廣告,相關子法定為 24 小時。數位發展部在 2024 年 9 月 16 日指定 Google、LY、Meta、TikTok 四家。
  • 數位中介服務法草案:國家通訊傳播委員會在 2022 年 9 月把草案退回內部重新研議,沒有時間表;截至本文查詢時,沒有查到公布的新版本。
  • 資通安全管理法:2025 年 9 月 24 日公布修正,民間部分的適用對象是特定非公務機關,主要是關鍵基礎設施提供者。

歐盟與其他地區:

  • Digital Services Act 在 2024 年 2 月 17 日全面適用,歐盟月活躍使用者 4,500 萬以上的平台有額外義務。歐盟執委會在 2024 年 8 月 5 日接受 TikTok 永久撤回 TikTok Lite 獎勵計畫的承諾;2026 年 2 月 6 日初步認定 TikTok 的成癮設計(無限滑動、自動播放、推播、推薦)違反 DSA。
  • 愛爾蘭資料保護委員會在 2025 年 5 月 2 日依 GDPR 對 TikTok 處以 5.3 億歐元罰款,理由是把歐洲使用者資料傳到中國的方式違法,以及透明度不足。
  • 澳洲的 16 歲以下社群媒體年齡限制在 2025 年 12 月 10 日生效,涵蓋 Facebook、Instagram、Threads、TikTok、YouTube 等平台,最高罰款 4,950 萬澳幣。

補充筆記

  • 公開內容要讓伺服器讀得到,端對端加密只留給私訊;想讓使用者帶得走資料,可以看 ActivityPub、AT Protocol。
  • MinIO 社群版 2025 年起只剩原始碼、進入 maintenance mode,2026 年 4 月封存;替代品有 SeaweedFS、Garage、RustFS、Ceph RGW、Apache Ozone,程式裡只用 S3 API,之後最容易替換。
  • 最小的推薦系統可以用 Qdrant 的 recommend 查詢,正例負例加上排除已看過的過濾條件;多樣性規則放在排序之後。
  • mini app 的 bridge 只開給信任的來源,身分一律由伺服器驗證平台簽發的 token 或簽章,金鑰不放前端。
  • 會員系統可以對照 NIST SP 800-63B-4 和 OWASP ASVS 5.0.0 檢查;台灣的詐騙廣告下架已入法,個資外洩通報義務已修法公布、施行日期未定。

延伸閱讀

想法與技術判斷出自 Sheng,和 Claude 一起起草 · 範例在 Python 3.13.12、qdrant-client 1.19.1、fastembed 0.8.1 實測。