iT邦幫忙

2026 iThome 鐵人賽

DAY 27
0
佛心分享-SideProject30

為你自己蓋一座會複利的知識庫——WikiBrain系列 第 27 篇

Day 27 - 設一個保留期清掉舊快照,刪帳號之後實際去數筆數

  • 分享至 

  • xImage
  •  

前言

之前我們已經講過備份,這一篇講另一半:該消失的資料有沒有真的消失。

備份回答的是「你會不會弄丟我的東西」。這一篇回答的是「我想走的時候你會不會留著」。要別人把自己的知識庫放進來,兩個問題都得答得出來。後面這一半比較難,因為沒有人會來檢查。備份壞掉你自己會痛,刪除沒刪乾淨只有使用者會痛,而他多半不會發現。

版本快照只會長大

之前我們已經講過,每次寫入都存一份完整快照。版本歷史、比較差異、復原都變成查詢。代價是快照只會長大。一頁改一百次就有一百份完整內容。

所以有一支清理。server.ts 啟動時呼叫它,先跑一次,之後每二十四小時再跑。刪的是 note_versions 裡的舊列,頁面本身在 notes.content_md,這句 DELETE 碰不到。

  const { rowCount } = await pool.query(
    `DELETE FROM note_versions v
      USING notes n, workspaces w
      WHERE n.id = v.note_id AND w.id = n.workspace_id
        AND v.version <> n.version
        AND v.created_at < now() - (CASE WHEN w.plan = 'pro' OR (w.trial_ends_at IS NOT NULL AND w.trial_ends_at > now()) THEN $2::int ELSE $1::int END * interval '1 day')`,
    [free, pro],
  );
  return rowCount ?? 0;

出處:src/retention.ts:11-19

$1 是免費方案的天數,$2 是付費的。三個條件各留一件東西:

v.version <> n.version 留下目前這一版的快照。少了它,一頁放著不動、超過保留期之後,歷史裡連「現在這一版」的那一列也沒了。頁面內容還在 notes,不會一起消失,但你不能再拿這一版跟別的版本對。

created_at 超過保留期才刪。預設是免費七天、付費九十天,limitsFor 的 retentionDays。

CASE 讓試用期間用付費的天數。試用是註冊起十四天,欄位預設 now() + interval '14 days'。這十四天裡,前十次沒有自己的 key 時,編纂和對話會用平台的 OpenRouter key。正在試用的人如果只留七天快照,評估的時候看到的會是比較短的歷史。

七天和九十天

這兩個數字沒有技術上的理由。

七天夠救回前幾天手滑改掉的一段,那是快照最常被用到的時候。九十天夠回答三個月前這頁的結論是什麼時候變的。中間不再切一級。

說明頁的方案段寫了免費七天、付費與試用九十天,隱私權政策也寫了。升級方案那句只寫付費的九十天。保留期過了不會另外通知,使用者是在想找舊版的時候才發現沒有。

刪帳號之後要去數

刪帳號是認證套件的一項設定:user: { deleteUser: { enabled: true } }。使用者用密碼打 POST /api/auth/delete-user。工作區對使用者、筆記對工作區,都是 ON DELETE CASCADE。版本、連結、標籤跟著筆記走。分享、附件、訂閱、模版套用紀錄跟著工作區走。模型 API key 在 ai_providers,對使用者連帶刪除。agent 的工作紀錄在 ingest_jobs,對工作區和使用者都是連帶刪除。

確認這件事的測試目前只做了這些:建帳號、寫一頁 wiki/a.md、開一把 MCP token、用對的密碼刪掉,然後數四張表。

  assert.equal((await pool.query('SELECT 1 FROM "user" WHERE id = $1', [userId])).rowCount, 0);
  assert.equal((await pool.query('SELECT 1 FROM workspaces WHERE id = $1', [wsId])).rowCount, 0);
  assert.equal((await pool.query('SELECT 1 FROM notes WHERE workspace_id = $1', [wsId])).rowCount, 0);
  assert.equal((await pool.query('SELECT 1 FROM mcp_tokens WHERE workspace_id = $1', [wsId])).rowCount, 0);

出處:test/account.test.ts:36-39

沒有匯入、沒有編纂、沒有分享連結、沒有模型 API key。版本、分享、附件、ai_providers、ingest_jobs 的外鍵是設了,這支測試沒去數。

events 不一樣。user_id 和 workspace_id 只是文字欄位,沒有外鍵。刪帳號不會清掉那些列。流量統計的瀏覽也寫在這張表。

分享連結的外鍵是設了的

分享是 /s/<token>,token 就是全部的憑證。shares 對 notes 和 workspaces 都是 ON DELETE CASCADE。帳號刪了,那一列會跟著走,網址下一次讀應該 404。

這張表就在上面沒被數到的那一組裡。它是唯一會把一頁留在產品外面的。若只補一項斷言,補這個。

raw/ 單頁不能刪,整個帳號可以

raw/ 的來源不能用刪除那條路清掉,只能封存。封存把路徑改到 raw/archive/,同一則筆記、版本和連結都還在,不再算待編纂。單頁刪掉的話,引用它的 wiki 頁會指向一個不存在的路徑。

整個帳號刪除時,那些 raw/ 頁跟著工作區一起走。那時候引用它們的 wiki 頁也不在了。單頁留下來源,是為了庫裡面的連結還指得到。整戶刪除沒有這個問題。

公開頁的計數

公開頁由這個行程提供,流量統計沒有再掛第三方腳本。訪客代碼是位址、瀏覽器字串和當天日期的雜湊:

   A visitor is identified by a salted hash of the address and user agent that changes every day. It cannot be reversed
   into an address, it is not stored anywhere else, and yesterday's visitor cannot be linked to today's — enough to
   count people rather than requests, and nothing more.

出處:src/analytics.ts:10-12

鹽是加密金鑰,不會每天換。每天不一樣,是因為被雜湊的字串裡帶了當天日期。同一個人隔天是另一個代碼,兩個代碼對不回同一個人,也還原不出位址。寫進 events 的是頁面、來源網站或活動標籤,和這個代碼。位址本身不存。

哪些請求不算:

export function shouldCount(req: Request): boolean {
  if (req.method !== 'GET') return false;
  const cookie = String(req.headers.cookie ?? '');
  if (cookie.includes('session_token')) return false;   // already a user
  if (cookie.includes(OPT_OUT_COOKIE)) return false;    // opted out on this browser
  const ua = String(req.headers['user-agent'] ?? '');
  if (!ua || BOT.test(ua)) return false;
  if (req.ip && ignored().includes(req.ip)) return false;
  return true;
}

出處:src/analytics.ts:34-43

已登入的不算,獲客要看的是還沒有帳號的人。爬蟲不算。ANALYTICS_IGNORE_IPS 裡的位址不算。

計人次的那一筆不用 cookie。判斷已登入會讀 session cookie。選擇退出會種 wb_noanalytics,否則下一頁又開始算。這個 cookie 的名稱在 OPT_OUT_COOKIE。

沒有可還原的識別碼、沒有把瀏覽交給第三方,同意橫幅和追蹤清單就沒有東西要維護。

隱私政策照實作寫

抄一份範本再填公司名,很容易寫進沒做的事,例如廣告識別碼、第三方分享。寫了沒做,使用者會高估你收集的東西。做了沒寫,才是違規。

我先把會碰到使用者資料的地方列出來,再照著寫。

資料 在哪裡 留多久
帳號(信箱、密碼雜湊) 自己的資料庫 刪帳號時從主資料庫消失
知識庫內容 自己的資料庫 同上
模型 API key ai_providers,AES-256-GCM 同上
版本快照 note_versions 舊版七天或九十天,目前這一版不刪
每日備份 物件儲存 預設三十天
訂閱 自己的資料庫存狀態、客戶與訂閱編號、到期日;信用卡在 Paddle 交易紀錄依 Paddle 的義務
流量統計 events 裡的頁面、來源、當日雜湊 沒有另外的保留期,刪帳號也不會清

編纂、對話、健檢會把相關筆記送到你在設定頁選的供應商,請求帶的是你的 key。試用的前十次沒有自己的 key 時,走的是平台的 OpenRouter。這段要寫在政策裡。沒寫的話,使用者會以為內容沒有離開這台機器。

備份那一列也容易漏。刪帳號之後,主資料庫的列沒了,物件儲存裡的 dump 還會留到保留期過完。政策寫的是最多三十天。

小結

舊快照由 purgeOldVersions 刪。目前這一版的列留著,頁面內容在 notes。預設免費七天、付費和試用九十天。行程起來先清一次,之後每二十四小時再清。

刪帳號靠外鍵把工作區以下的筆記、版本、分享、附件、API key、工作紀錄一起帶走。測試只數了 user、workspaces、notes、mcp_tokens。events 沒有外鍵,瀏覽紀錄和產品事件會留下。


上一篇
Day 26 - 開源選 AGPL,加上唯讀分享連結與 Obsidian 匯出
下一篇
Day 28 - 選 Paddle 做金流串接
系列文
為你自己蓋一座會複利的知識庫——WikiBrain 共 28 篇
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言