From ce2af7a4c903bbe5ae69c6094b7e365319bb3bf6 Mon Sep 17 00:00:00 2001 From: PoterPan Date: Sun, 7 Jun 2026 20:47:20 +0800 Subject: [PATCH 01/10] docs(spec): R2 optional design (feature 1) Make Cloudflare R2 optional: detect via binding presence (!!env.BUCKET), guard the 4 R2 usage points so screenshot capture/view/retention degrade gracefully while payment declaration/verification/reconcile stay intact. Backend reports r2_configured / proof_enabled; admin shows a one-time localStorage prompt + a persistent status row. Decisions captured from brainstorming. --- .../specs/2026-06-07-r2-optional-design.md | 105 ++++++++++++++++++ 1 file changed, 105 insertions(+) create mode 100644 docs/superpowers/specs/2026-06-07-r2-optional-design.md diff --git a/docs/superpowers/specs/2026-06-07-r2-optional-design.md b/docs/superpowers/specs/2026-06-07-r2-optional-design.md new file mode 100644 index 0000000..60de861 --- /dev/null +++ b/docs/superpowers/specs/2026-06-07-r2-optional-design.md @@ -0,0 +1,105 @@ +# R2 改為選填 — 設計 + +> 日期:2026-06-07 狀態:設計定稿,待寫實作計畫 +> 功能 1(兩個體驗優化之一;功能 2「成員/訂閱刪除 CRUD」另立 spec) + +## 1. 背景與目標 + +有些部署者不需要「成員上傳繳費截圖」功能,因此不想註冊 Cloudflare R2。目前 `env.BUCKET` +(R2 binding)是**必填**,程式多處直接使用,未綁 R2 會壞。目標: + +1. **R2 變選填**:不註冊 R2 binding 時,整個系統照常運作,只有「截圖佐證」相關功能優雅停用。 +2. **後台首次提示**:未設定 R2 時,管理者第一次進後台彈一次提示,說明哪些功能不可用(只彈一次)。 +3. **常駐狀態**:設定頁顯示一個常駐的「截圖儲存:未啟用 / 已啟用」狀態,方便日後查看。 + +**宣告繳費本身不受影響**:選渠道、填備註、後台審核(verify/reject/改金額)、對帳、Discord 開繳/催繳全部照常。 + +## 2. 關鍵設計決策 + +- **偵測方式=binding 是否存在**(`!!env.BUCKET`),不另設 workspace 開關。R2 是基礎設施設定, + forker 不在 `wrangler.toml` 註冊 `[[r2_buckets]]` 即視為未啟用;零額外狀態、不會「設定說有但實際沒綁」矛盾。 +- **無 R2 時截圖一律隱藏/忽略**(成員端),既有「宣告繳費」路徑全程可用。 +- **首次提示用 localStorage**(admin SPA 目前未用 localStorage);只彈一次。 + +## 3. R2 依賴點與停用後行為 + +`env.BUCKET` 目前 4 處使用 → 改 `BUCKET?: R2Bucket`(選填)並逐處 guard: + +| 位置 | 現行 | 無 R2 時 | +|---|---|---| +| `core/storage.ts` `settleUserPeriod` step 3(存 proof) | `putObject(env.BUCKET, …)` | 有 proof 也跳過儲存,付款宣告照常寫入(`has_proof=0`、`screenshot_key=NULL`),不報錯 | +| `routes/images.ts`(看截圖) | `env.BUCKET.get(key)` | 回 404(截圖功能停用) | +| `routes/admin.ts` `deleteProof` | `env.BUCKET.delete(key)` | 無 BUCKET 或無 proof → 安全 no-op(仍清 DB 欄位) | +| `core/retention.ts`(cron) | `env.BUCKET.delete(...)` | 整段跳過、回 0 | + +> `storage.ts` 的 `deleteObject` 補償清理(:201、:219)也在 `if (key)` 內,key 為 null 時自然略過; +> 但因 step 3 已 guard,無 R2 時 key 恆為 null。 + +## 4. 元件設計 + +### A. 後端偵測旗標 +- `env.ts`:`BUCKET?: R2Bucket`。 +- `routes/admin.ts` `getWorkspace`:回應由 `{ workspace }` 擴充為 `{ workspace, r2_configured: !!env.BUCKET }`。 +- `routes/upload.ts` `handleUploadInfo`(`GET /upload/:token`):回應加 `proof_enabled: !!env.BUCKET`。 + +### B. 成員端截圖 UX +- **web 上傳頁**(`packages/web`):`TokenInfo` 型別加 `proof_enabled?: boolean`;`App.tsx` 依此**隱藏截圖欄位與相關說明**,只留渠道+備註。`api.ts` 的 `submitPayment` 在停用時不附 `screenshot`。 +- **Discord `/繳費`**(`adapters/discord/handler.ts`):附了截圖但無 R2 → 不傳 proof 給 `settleUserPeriod`(或傳了也被 storage guard 跳過),ephemeral 回覆加一句「本站未開啟截圖功能,已記錄你的繳費宣告」。 +- **core 防線**:`settleUserPeriod` step 3 改為 `if (input.proof && env.BUCKET)` 才 `putObject`;這是無論前端如何都成立的單一真相 guard。 + +### C. 後台首次提示(功能 1b/1c) +- admin SPA 頂層(`App.tsx`)載入後:若 `r2_configured===false` 且 `localStorage.getItem("chippot.r2NoticeSeen")` 不存在 → 顯示一次 Modal。 +- Modal 內容:標題「Cloudflare R2 尚未設定」;說明停用的功能(成員上傳/檢視繳費截圖、截圖自動保存清理);一句「不影響:宣告繳費、後台審核、對帳、Discord 通知」;關閉鈕。 +- 關閉時 `localStorage.setItem("chippot.r2NoticeSeen", "1")`,不再彈。 + +### D. 常駐狀態(功能採納項) +- 設定頁(`views/Settings.tsx`)新增一行常駐狀態:**截圖儲存(R2):✅ 已啟用 / ⚠️ 未啟用**,由 `api.workspace()` 回的 `r2_configured` 驅動。未啟用時附一句「成員無法上傳截圖;其餘功能正常」。 + +### E. 設定 / 文件 +- `wrangler.toml`:`[[r2_buckets]]` 區塊上方加註解「選填:不需要成員上傳截圖可移除此區塊(R2 未綁時截圖功能自動停用)」。 +- `docs/DEPLOY.md`:第 2 節 R2 標為**選填**;新增說明「移除 R2 binding 後,成員上傳/檢視截圖與保存清理停用,其餘照常」。 + +## 5. 介面 / 檔案異動清單 + +| 檔案 | 異動 | +|---|---| +| `packages/worker/src/env.ts` | `BUCKET?: R2Bucket`(選填) | +| `packages/worker/src/core/storage.ts` | `settleUserPeriod` step 3 存 proof 加 `&& env.BUCKET` guard | +| `packages/worker/src/routes/images.ts` | 無 BUCKET → 404 | +| `packages/worker/src/routes/admin.ts` | `deleteProof` guard;`getWorkspace` 回 `r2_configured` | +| `packages/worker/src/core/retention.ts` | 無 BUCKET → 跳過、回 0 | +| `packages/worker/src/routes/upload.ts` | `handleUploadInfo` 回 `proof_enabled` | +| `packages/worker/src/adapters/discord/handler.ts` | `/繳費` 無 R2 時忽略附件 + 提示 | +| `packages/web/src/api.ts` | `TokenInfo.proof_enabled`;`submitPayment` 停用時不附截圖 | +| `packages/web/src/App.tsx` | 依 `proof_enabled` 隱藏截圖欄位 | +| `packages/admin/src/api.ts` | `workspace()` 回傳型別加 `r2_configured` | +| `packages/admin/src/App.tsx` | 首次 R2 提示 Modal(localStorage 一次性) | +| `packages/admin/src/views/Settings.tsx` | 常駐 R2 狀態列 | +| `packages/worker/wrangler.toml` | `[[r2_buckets]]` 加「選填」註解 | +| `docs/DEPLOY.md` | R2 標選填 + 停用功能說明 | + +## 6. 測試 + +既有 R2 測試不受影響(vitest 測試環境仍提供 BUCKET binding)。新增(以 `(env as any).BUCKET = undefined` 模擬未綁): +- `settleUserPeriod`:附 proof 但無 BUCKET → 仍成功結算、`has_proof=0`、`screenshot_key=NULL`、不丟例外。 +- `images` 路由:無 BUCKET → 404。 +- `retention`:無 BUCKET → 回 0、不呼叫刪除。 +- `GET /admin/workspace`:回 `r2_configured`(有 BUCKET=true);模擬無 BUCKET=false。 +- `GET /upload/:token`:回 `proof_enabled` 對應 BUCKET 有無。 +- `deleteProof`:無 BUCKET → 仍清 DB 欄位、不丟例外。 + +> 注意:vitest pool 的 BUCKET 來自設定;逐測試以 save/restore `(env as any).BUCKET` 模擬未綁(沿用既有 token save/restore 範式),避免污染其他測試。 + +## 7. 風險與權衡 + +- **既有截圖資料**:本變更只影響「未綁 R2」的部署;已綁 R2 者行為完全不變。 +- **Discord slash option**:`/繳費` 的「截圖」option 是靜態註冊、無法依 R2 動態隱藏,故採「忽略附件 + 提示」而非隱藏(已於 §4B 說明)。 +- **localStorage 一次性**:清掉瀏覽器儲存會再彈一次;可接受(符合「首次提示」語意)。常駐狀態列提供長期可見性。 + +## 8. 決議摘要(brainstorming) + +1. 偵測:**binding-absence**(`!!env.BUCKET`),不另設開關。 +2. 成員截圖:**隱藏/忽略**,宣告繳費照常。 +3. 首次提示:**localStorage 一次性 Modal**。 +4. **加常駐 R2 狀態列**於設定頁。 +5. 與「成員/訂閱刪除 CRUD」分為**兩份獨立 spec/PR**;本 spec 為功能 1。 From f91ab39d4eb6b4a746b69695ce4aa8d0a288cdb0 Mon Sep 17 00:00:00 2001 From: PoterPan Date: Sun, 7 Jun 2026 21:00:04 +0800 Subject: [PATCH 02/10] docs(plan): R2 optional implementation plan 5 tasks (TDD): backend BUCKET-optional + guards + detection flags; Discord screenshot ignore; web hide-screenshot; admin one-time notice + status row; DEPLOY.md. wrangler.toml comment deferred to a controller-only skip-worktree step to avoid leaking owner values. --- .../plans/2026-06-07-r2-optional.md | 621 ++++++++++++++++++ 1 file changed, 621 insertions(+) create mode 100644 docs/superpowers/plans/2026-06-07-r2-optional.md diff --git a/docs/superpowers/plans/2026-06-07-r2-optional.md b/docs/superpowers/plans/2026-06-07-r2-optional.md new file mode 100644 index 0000000..a7b7528 --- /dev/null +++ b/docs/superpowers/plans/2026-06-07-r2-optional.md @@ -0,0 +1,621 @@ +# R2 改為選填 Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** 讓 Cloudflare R2 變成選填——未綁 R2 時截圖相關功能優雅停用,宣告繳費/審核/對帳/通知全照常;後端回報 R2 狀態,後台首次提示一次 + 設定頁常駐狀態。 + +**Architecture:** 以 `!!env.BUCKET` 偵測 R2 是否設定(forker 不在 wrangler.toml 註冊 binding 即為未設定)。`src/env.ts` 的 `BUCKET` 改選填、4 處使用點加 runtime guard;`test/env.d.ts` 把測試用 `BUCKET` 覆寫為必填(測試環境一定有 R2,避免測試檔大量改動)。後端在 `/admin/workspace` 回 `r2_configured`、`/upload/:token` 回 `proof_enabled`,前端據此隱藏截圖 UI 並顯示提示/狀態。 + +**Tech Stack:** Cloudflare Workers + R2 + D1,pnpm monorepo,TypeScript,Vitest(`@cloudflare/vitest-pool-workers`),React SPA(Vite)。 + +**Spec:** `docs/superpowers/specs/2026-06-07-r2-optional-design.md` + +**前置:** 分支 `feat/r2-optional`(已建立,off main)。每個 Task 結束 commit。全程基準:`pnpm -r typecheck`、`pnpm --filter @chippot/worker test`、`pnpm -r build` 維持綠燈;worker 測試在「無 `.dev.vars`」下也須全綠(CI 條件)。 + +--- + +## File Structure + +| 檔案 | 責任 | Task | +|---|---|---| +| `packages/worker/src/env.ts` | `BUCKET?: R2Bucket`(選填) | 1 | +| `packages/worker/test/env.d.ts` | 測試 env 覆寫 `BUCKET: R2Bucket`(必填) | 1 | +| `packages/worker/src/core/storage.ts` | settle 存/補償 proof 加 `env.BUCKET` guard | 1 | +| `packages/worker/src/routes/images.ts` | 無 BUCKET → 404 | 1 | +| `packages/worker/src/core/retention.ts` | 無 BUCKET → 早退回 0 | 1 | +| `packages/worker/src/routes/admin.ts` | deleteProof guard;getWorkspace 回 `r2_configured` | 1 | +| `packages/worker/src/routes/upload.ts` | handleUploadInfo 回 `proof_enabled` | 1 | +| worker 測試(settle/images/retention/admin/upload) | 無 R2 行為測試 | 1 | +| `packages/worker/src/adapters/discord/handler.ts` | `/繳費` 無 R2 忽略附件 + 提示 | 2 | +| `packages/worker/test/adapters/discord-pay.test.ts` | Discord 無 R2 測試 | 2 | +| `packages/web/src/api.ts` + `App.tsx` | `proof_enabled` + 隱藏截圖欄位 | 3 | +| `packages/admin/src/api.ts` + `App.tsx` + `views/Settings.tsx` | workspace 型別 + 首次提示 + 常駐狀態 | 4 | +| `packages/worker/wrangler.toml` + `docs/DEPLOY.md` | R2 標選填 | 5 | + +--- + +## Task 1: 後端 R2 選填(binding optional + guards + 偵測旗標) + +**Files:** +- Modify: `packages/worker/src/env.ts` +- Modify: `packages/worker/test/env.d.ts` +- Modify: `packages/worker/src/core/storage.ts` +- Modify: `packages/worker/src/routes/images.ts` +- Modify: `packages/worker/src/core/retention.ts` +- Modify: `packages/worker/src/routes/admin.ts` +- Modify: `packages/worker/src/routes/upload.ts` +- Test: `test/core/settle.test.ts`, `test/routes/images.test.ts`, `test/core/retention.test.ts`, `test/routes/admin.test.ts`, `test/routes/upload.test.ts` + +### Implementation + +- [ ] **Step 1: `env.ts` 把 BUCKET 改選填** + +`packages/worker/src/env.ts` 第 3 行 ` BUCKET: R2Bucket;` 改為: +```ts + // Optional: omit the [[r2_buckets]] binding in wrangler.toml to run without screenshots. + BUCKET?: R2Bucket; +``` + +- [ ] **Step 2: `test/env.d.ts` 覆寫 BUCKET 為必填(測試環境一定有 R2)** + +`packages/worker/test/env.d.ts` 的 `interface Env extends AppEnv { ... }` 內,新增一行(覆寫 app 的選填為必填,讓既有測試直接存取 `env.BUCKET` 不報 TS 錯): +```ts + interface Env extends AppEnv { + TEST_MIGRATIONS: D1Migration[]; + BUCKET: R2Bucket; // tests always provide R2; override the app's optional binding + } +``` + +- [ ] **Step 3: `storage.ts` 三處 guard** + +`packages/worker/src/core/storage.ts`,`settleUserPeriod` 內: + +(a) step 3 存 proof(約 186-190): +```ts + let key: string | null = null; + if (input.proof) { + key = buildScreenshotKey(workspaceId, period, userId, input.proof.ext, crypto.randomUUID()); + await putObject(env.BUCKET, key, input.proof.body, input.proof.contentType); + } +``` +改為(無 BUCKET 不存、key 維持 null、has_proof=0): +```ts + let key: string | null = null; + if (input.proof && env.BUCKET) { + key = buildScreenshotKey(workspaceId, period, userId, input.proof.ext, crypto.randomUUID()); + await putObject(env.BUCKET, key, input.proof.body, input.proof.contentType); + } +``` + +(b) catch 補償(約 201):`if (key) await deleteObject(env.BUCKET, key).catch(() => {});` → `if (key && env.BUCKET) await deleteObject(env.BUCKET, key).catch(() => {});` + +(c) TOCTOU 補償(約 218):`if (paidRows.results.length === 0 && key) {` → `if (paidRows.results.length === 0 && key && env.BUCKET) {` + +> (b)(c) 的 key 只在 BUCKET 存在時才會非 null,加 `&& env.BUCKET` 純粹讓 TS 在選填型別下通過 narrowing。 + +- [ ] **Step 4: `images.ts` 無 BUCKET → 404** + +`packages/worker/src/routes/images.ts`,在 `const obj = await env.BUCKET.get(key);`(約 24)之前插入: +```ts + if (!env.BUCKET) return errorResponse(404, "not found"); +``` + +- [ ] **Step 5: `retention.ts` 無 BUCKET → 早退** + +`packages/worker/src/core/retention.ts`,`runRetention` 函式體最前(`const cutoffIso = ...` 之前)插入: +```ts + if (!env.BUCKET) return 0; // R2 not configured — nothing to retain +``` + +- [ ] **Step 6: `admin.ts` deleteProof guard + getWorkspace 回 r2_configured** + +`packages/worker/src/routes/admin.ts`: + +(a) `deleteProof`(約 459):`if (p.screenshot_key) await env.BUCKET.delete(p.screenshot_key);` → `if (p.screenshot_key && env.BUCKET) await env.BUCKET.delete(p.screenshot_key);` + +(b) `getWorkspace`(約 52):`return json({ workspace: { ...row, settings: parseSettings(row.settings) } });` → +```ts + return json({ workspace: { ...row, settings: parseSettings(row.settings) }, r2_configured: !!env.BUCKET }); +``` + +- [ ] **Step 7: `upload.ts` handleUploadInfo 回 proof_enabled** + +`packages/worker/src/routes/upload.ts`,`handleUploadInfo` 的 `return json({...})`(約 22-28)加一欄: +```ts + return json({ + valid: true, + period: tok.period, + user: { display_name: user?.display_name ?? "" }, + subscriptions, + channel_tags, + proof_enabled: !!env.BUCKET, + }); +``` + +- [ ] **Step 8: typecheck(確認選填化 + guards 後 src 與 test 皆綠)** + +Run: `cd /Users/poterpan/Documents/Coding/Project/chippot && pnpm --filter @chippot/worker typecheck` +Expected: 無錯(src 因 guards 通過;test 因 env.d.ts 覆寫通過)。 + +### Tests + +- [ ] **Step 9: settle 無 R2 測試** + +`test/core/settle.test.ts`,在 `describe("settleUserPeriod — Discord direct path", ...)` 內新增: +```ts + it("settles without a proof object when R2 is not configured (has_proof=0)", async () => { + const prev = (env as any).BUCKET; + (env as any).BUCKET = undefined; + const r = await settleUserPeriod(env, { + workspaceId: WS, userId: WS, period: "2027-05", source: "user_slash", + proof: { body: new Uint8Array([1, 2, 3]), ext: "png", contentType: "image/png" }, + }); + (env as any).BUCKET = prev; + expect(r.paidCount).toBe(2); + expect(r.screenshotKey).toBeNull(); + const rows = await env.DB.prepare("SELECT has_proof, screenshot_key FROM payments WHERE workspace_id=? AND period='2027-05'").bind(WS).all<{ has_proof: number; screenshot_key: string | null }>(); + expect(rows.results.every((p) => p.has_proof === 0 && p.screenshot_key === null)).toBe(true); + }); +``` + +- [ ] **Step 10: images 無 R2 測試** + +`test/routes/images.test.ts`,在 `describe("protected image endpoint", ...)` 內新增: +```ts + it("404s when R2 is not configured", async () => { + const prev = (env as any).BUCKET; + (env as any).BUCKET = undefined; + const res = await handleImage(new Request("https://x"), env, ctxFor(KEY)); + (env as any).BUCKET = prev; + expect(res.status).toBe(404); + }); +``` + +- [ ] **Step 11: retention 無 R2 測試** + +`test/core/retention.test.ts`,在 `describe("runRetention reference-counting", ...)` 內新增: +```ts + it("is a no-op when R2 is not configured (returns 0, keeps the row + object)", async () => { + const KEY = "noR2-key-9023"; + await env.DB.prepare(`INSERT INTO payments (workspace_id,subscription_id,period,period_start,period_end,due_date,amount,status,has_proof,screenshot_key,paid_at,source,created_at,updated_at) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?)`).bind(WS, SUB, "2023-05", "2023-05-01", "2023-05-31", "2023-05-05", 315, "verified", 1, KEY, OLD, "user_web", TS, TS).run(); + await putObject(env.BUCKET, KEY, new Uint8Array([4]), "image/png"); + const prev = (env as any).BUCKET; + (env as any).BUCKET = undefined; + const cleared = await runRetention(env, WS, 24, NOW); + (env as any).BUCKET = prev; + expect(cleared).toBe(0); + const row = await env.DB.prepare("SELECT screenshot_key FROM payments WHERE workspace_id=? AND period='2023-05'").bind(WS).first<{ screenshot_key: string | null }>(); + expect(row?.screenshot_key).toBe(KEY); + expect(await getObject(env.BUCKET, KEY)).not.toBeNull(); + }); +``` + +- [ ] **Step 12: admin getWorkspace + deleteProof 無 R2 測試** + +`test/routes/admin.test.ts`,在 `describe("admin API", ...)` 內新增兩個測試: +```ts + it("reports r2_configured from the BUCKET binding", async () => { + const on = (await (await call("GET", "/admin/workspace"))!.json()) as any; + expect(on.r2_configured).toBe(true); + const prev = (env as any).BUCKET; + (env as any).BUCKET = undefined; + const off = (await (await call("GET", "/admin/workspace"))!.json()) as any; + (env as any).BUCKET = prev; + expect(off.r2_configured).toBe(false); + }); + + it("delete-proof still clears the DB column when R2 is not configured", async () => { + const key = "1/2026-10/1/noR2.png"; + const ins = await env.DB.prepare( + `INSERT INTO payments (workspace_id,subscription_id,period,period_start,period_end,due_date,amount,status,has_proof,screenshot_key,source,created_at,updated_at) + SELECT 1, s.id, '2026-10', '2026-10-01','2026-10-31','2026-10-05', 315, 'paid', 1, ?, 'user_web', ?, ? + FROM subscriptions s WHERE s.workspace_id = 1 ORDER BY s.id LIMIT 1` + ).bind(key, nowUtcIso(), nowUtcIso()).run(); + const pid = ins.meta.last_row_id as number; + const prev = (env as any).BUCKET; + (env as any).BUCKET = undefined; + const res = await call("POST", `/admin/payments/${pid}/delete-proof`); + (env as any).BUCKET = prev; + expect(res!.status).toBe(200); + const p = await getPayment(env.DB, pid); + expect(p?.screenshot_key).toBeNull(); + }); +``` + +- [ ] **Step 13: upload proof_enabled 測試** + +`test/routes/upload.test.ts`,在 `describe("upload info", ...)` 內新增: +```ts + it("reports proof_enabled per R2 configuration", async () => { + const on = (await (await handleUploadInfo(new Request("https://x"), env, ctxFor(RAW_OK))).json()) as any; + expect(on.proof_enabled).toBe(true); + const prev = (env as any).BUCKET; + (env as any).BUCKET = undefined; + const off = (await (await handleUploadInfo(new Request("https://x"), env, ctxFor(RAW_OK))).json()) as any; + (env as any).BUCKET = prev; + expect(off.proof_enabled).toBe(false); + }); +``` + +- [ ] **Step 14: 跑全 worker 測試(含無 .dev.vars)** + +Run: +```bash +cd /Users/poterpan/Documents/Coding/Project/chippot +mv packages/worker/.dev.vars packages/worker/.dev.vars.off 2>/dev/null; true +pnpm --filter @chippot/worker test 2>&1 | grep -E "Test Files|Tests " +mv packages/worker/.dev.vars.off packages/worker/.dev.vars 2>/dev/null; true +``` +Expected: 全綠、無 failed。基數為合併後 main 的 161,本任務加 6 個測試 → `Tests 167 passed (167)`。ALWAYS restore .dev.vars。(若基數略有出入,以「全綠、無 failed」為準。) + +- [ ] **Step 15: Commit** +```bash +cd /Users/poterpan/Documents/Coding/Project/chippot +git add packages/worker/src/env.ts packages/worker/test/env.d.ts packages/worker/src/core/storage.ts packages/worker/src/routes/images.ts packages/worker/src/core/retention.ts packages/worker/src/routes/admin.ts packages/worker/src/routes/upload.ts packages/worker/test +git commit -m "feat(worker): make R2 optional — guard BUCKET usage + report status + +BUCKET becomes an optional binding; settle skips proof storage (has_proof=0), +images 404, retention no-ops, delete-proof still clears the DB column when R2 is +absent. /admin/workspace reports r2_configured and /upload/:token reports +proof_enabled. Test env overrides BUCKET as required so existing tests are unaffected." +``` + +--- + +## Task 2: Discord `/繳費` 無 R2 時忽略截圖 + 提示 + +**Files:** +- Modify: `packages/worker/src/adapters/discord/handler.ts` +- Test: `packages/worker/test/adapters/discord-pay.test.ts` + +- [ ] **Step 1: 寫失敗測試** + +`packages/worker/test/adapters/discord-pay.test.ts`:把第 2 行 import 補上 `vi`: +```ts +import { beforeAll, describe, expect, it, vi } from "vitest"; +``` +在檔案末端新增 describe: +```ts +describe("/繳費 with no R2 ignores the screenshot", () => { + it("tells the member to use channel/note when only a screenshot is given and R2 is off", async () => { + const prevB = (env as any).BUCKET; + const prevApp = (env as any).DISCORD_APPLICATION_ID; + (env as any).BUCKET = undefined; + (env as any).DISCORD_APPLICATION_ID = "app-9024"; + let captured = ""; + vi.stubGlobal("fetch", vi.fn(async (_url: string, init: any) => { captured = JSON.parse(init.body).content; return new Response("{}", { status: 200 }); })); + const i: DiscordInteraction = { + type: 2, id: "1", token: "tok", guild_id: GUILD, ...member(DISC), + data: { name: "繳費", options: [{ name: "截圖", value: "att1" }], resolved: { attachments: { att1: { url: "https://cdn.discordapp.com/x.png", content_type: "image/png", size: 100 } } } }, + } as any; + const res = await routeInteraction(i, env, CTX); + expect((await res.json() as any).type).toBe(5); // deferred + await Promise.all(tasks.splice(0)); + vi.unstubAllGlobals(); + (env as any).BUCKET = prevB; + (env as any).DISCORD_APPLICATION_ID = prevApp; + expect(captured).toContain("未開啟截圖功能"); + }); +}); +``` + +- [ ] **Step 2: 跑測試確認失敗** + +Run: `cd /Users/poterpan/Documents/Coding/Project/chippot && pnpm --filter @chippot/worker test test/adapters/discord-pay.test.ts 2>&1 | grep -E "未開啟|FAIL|Tests "` +Expected: 失敗(目前無此訊息;附件在無 R2 時仍會嘗試下載/被忽略但無提示)。 + +- [ ] **Step 3: 實作 handler 變更** + +`packages/worker/src/adapters/discord/handler.ts` 的 `computePayResult`,把「Optional screenshot」區塊(約 183-202)改為: +```ts + // Optional screenshot — only when R2 is configured; otherwise ignore the attachment. + let proof: { body: ArrayBuffer; ext: string; contentType: string } | null = null; + const attachOpt = getOption(i, "截圖"); + const attachment = attachOpt?.value ? i.data?.resolved?.attachments?.[attachOpt.value] : undefined; + const screenshotIgnored = !!attachment && !env.BUCKET; + if (attachment && env.BUCKET) { + const ct = attachment.content_type ?? ""; + try { assertImageOk(ct, attachment.size ?? 0); } + catch (e) { if (e instanceof InvalidImage) return "截圖格式不支援或檔案過大,請改用備註或渠道。"; throw e; } + if (!isDiscordCdnUrl(attachment.url)) return "截圖來源無效。"; + const res = await fetch(attachment.url); + if (!res.ok) return "下載截圖失敗,請稍後再試。"; + const body = await res.arrayBuffer(); + try { assertImageOk(ct, body.byteLength); } catch { return "截圖檔案過大。"; } + proof = { body, ext: extForContentType(ct), contentType: ct }; + } + + // At-least-one rule (slash): 渠道 / 截圖 / 備註. + if (!declaredChannelTagId && !proof && !note) { + if (screenshotIgnored) return "本站未開啟截圖功能,請改用「渠道」或「備註」登記繳費。"; + return "請至少選擇「渠道」、附上「截圖」或填寫「備註」其中一項。"; + } +``` +並把成功訊息(約 209): +```ts + return `✅ 已登記本期(${period})繳費 NT$${r.totalAmount.toLocaleString()}(共 ${r.paidCount} 筆)。管理員確認收款後完成。`; +``` +改為: +```ts + const ignoredNote = screenshotIgnored ? "(本站未開啟截圖功能,已記錄你的繳費宣告)" : ""; + return `✅ 已登記本期(${period})繳費 NT$${r.totalAmount.toLocaleString()}(共 ${r.paidCount} 筆)。管理員確認收款後完成。${ignoredNote}`; +``` + +- [ ] **Step 4: 跑測試確認通過(含無 .dev.vars)** + +Run: +```bash +cd /Users/poterpan/Documents/Coding/Project/chippot +pnpm --filter @chippot/worker typecheck +mv packages/worker/.dev.vars packages/worker/.dev.vars.off 2>/dev/null; true +pnpm --filter @chippot/worker test 2>&1 | grep -E "Test Files|Tests " +mv packages/worker/.dev.vars.off packages/worker/.dev.vars 2>/dev/null; true +``` +Expected: typecheck 無錯;全綠、無 failed(167 + 1 → `Tests 168 passed (168)`)。 + +- [ ] **Step 5: Commit** +```bash +cd /Users/poterpan/Documents/Coding/Project/chippot +git add packages/worker/src/adapters/discord/handler.ts packages/worker/test/adapters/discord-pay.test.ts +git commit -m "feat(discord): ignore /繳費 screenshot attachment when R2 is not configured + +Skip the attachment download when there's no R2 and tell the member to use channel/ +note (or append a note on success). The core storage guard already drops the proof; +this avoids a wasted download and gives clear feedback." +``` + +--- + +## Task 3: web 上傳頁依 proof_enabled 隱藏截圖 + +**Files:** +- Modify: `packages/web/src/api.ts` +- Modify: `packages/web/src/App.tsx` + +- [ ] **Step 1: `api.ts` TokenInfo 加 proof_enabled** + +`packages/web/src/api.ts` 的 `TokenInfo` interface(約 14-20)加一欄: +```ts +export interface TokenInfo { + valid: boolean; + period?: string; + user?: { display_name: string }; + subscriptions?: SubscriptionChoice[]; + channel_tags?: ChannelTag[]; + proof_enabled?: boolean; +} +``` + +- [ ] **Step 2: `App.tsx` 隱藏截圖欄位 + 調整文案** + +`packages/web/src/App.tsx`:把截圖上傳區塊(``,約 126-143)包在條件內——僅當 `info.proof_enabled !== false` 時顯示: +```tsx + {info.proof_enabled !== false && ( + + )} +``` +並把底部說明(約 159): +```tsx +

渠道、截圖或備註至少填一項。此連結僅限你本人本期使用,送出後即失效。

+``` +改為依 proof_enabled 切換文案: +```tsx +

{info.proof_enabled === false ? "渠道或備註至少填一項。" : "渠道、截圖或備註至少填一項。"}此連結僅限你本人本期使用,送出後即失效。

+``` +(`info` 在此 scope 為已驗證的 `TokenInfo`;若該段落用的變數名不同,依實際的 token-info 變數調整,但條件與文案如上。) + +> `submitPayment` 不需改:截圖欄位隱藏時 `file` 恆為 null,現有 `if (blob) fd.append(...)` 本就不會附截圖。 + +- [ ] **Step 3: typecheck + build** + +Run: +```bash +cd /Users/poterpan/Documents/Coding/Project/chippot +pnpm --filter @chippot/web typecheck && VITE_API_BASE=https://example.workers.dev pnpm --filter @chippot/web build 2>&1 | tail -2 +``` +Expected: 無錯、`✓ built`。 + +- [ ] **Step 4: Commit** +```bash +cd /Users/poterpan/Documents/Coding/Project/chippot +git add packages/web/src/api.ts packages/web/src/App.tsx +git commit -m "feat(web): hide screenshot upload when R2 is disabled (proof_enabled) + +The upload page reads proof_enabled from the token info and hides the screenshot +field + adjusts the helper copy when R2 is not configured; channel/note still work." +``` + +--- + +## Task 4: admin 首次提示 + 設定頁常駐 R2 狀態 + +**Files:** +- Modify: `packages/admin/src/api.ts` +- Modify: `packages/admin/src/App.tsx` +- Modify: `packages/admin/src/views/Settings.tsx` + +- [ ] **Step 1: `api.ts` workspace() 型別加 r2_configured** + +`packages/admin/src/api.ts` 第 45 行 `workspace: () => req("GET", "/workspace"),` 改為: +```ts + workspace: () => req<{ workspace: any; r2_configured: boolean }>("GET", "/workspace"), +``` + +- [ ] **Step 2: `App.tsx` 加一次性 R2 提示** + +`packages/admin/src/App.tsx`:在 import 區把 `api` 與 `Modal` 引入(目前未引入): +```ts +import { api } from "./api"; +import { IconLogout } from "./ui"; +import { Modal } from "./ui"; +``` +(若 `./ui` 已在同一行 import,合併為 `import { IconLogout, Modal } from "./ui";`。) + +在 `export default function App()` 之外(檔案內)新增元件: +```tsx +function R2Notice() { + const [show, setShow] = useState(false); + useEffect(() => { + if (localStorage.getItem("chippot.r2NoticeSeen")) return; + api.workspace().then((w) => { if (w && w.r2_configured === false) setShow(true); }).catch(() => {}); + }, []); + if (!show) return null; + const dismiss = () => { localStorage.setItem("chippot.r2NoticeSeen", "1"); setShow(false); }; + return ( + +

偵測到尚未綁定 R2 儲存空間,以下功能將無法使用:

+
    +
  • 成員上傳繳費截圖
  • +
  • 後台檢視繳費截圖
  • +
  • 截圖自動保存清理
  • +
+

不影響:宣告繳費、後台審核、對帳、Discord 通知。如需截圖功能,請在 wrangler.toml 註冊 R2 binding 後重新部署。

+ +
+ ); +} +``` +在 App 的 `return (
...
)` 內最上方插入 ``(例如緊接 `
` 之後): +```tsx + return ( +
+ +
+ )} +``` + +- [ ] **Step 4: typecheck + build** + +Run: +```bash +cd /Users/poterpan/Documents/Coding/Project/chippot +pnpm --filter @chippot/admin typecheck && pnpm --filter @chippot/admin build 2>&1 | tail -2 +``` +Expected: 無錯、`✓ built`。 + +- [ ] **Step 5: Commit** +```bash +cd /Users/poterpan/Documents/Coding/Project/chippot +git add packages/admin/src/api.ts packages/admin/src/App.tsx packages/admin/src/views/Settings.tsx +git commit -m "feat(admin): one-time R2 notice + persistent R2 status row + +When the worker reports r2_configured=false, show a one-time (localStorage) modal +listing the disabled screenshot features, and a persistent status row in Settings." +``` + +--- + +## Task 5: DEPLOY.md 標 R2 選填(subagent) + +**Files:** +- Modify: `docs/DEPLOY.md` + +> ⚠️ **不要碰 `packages/worker/wrangler.toml`。** 該檔被 `git update-index --skip-worktree` 保護、本機是 owner 真實值;任何 `git add` 都可能把真實值 commit 進公開 repo(外洩)。`wrangler.toml` 的「R2 選填」註解由 **controller** 在收尾時用 skip-worktree dance 安全處理(見計畫末「Controller-only step」),**不在本任務範圍**。 + +- [ ] **Step 1: `docs/DEPLOY.md` R2 標選填** + +在 `docs/DEPLOY.md` 第 2 節「建立 Cloudflare 資源(D1 / R2)」中,R2 建立步驟標為**選填**,並加一句說明。找到 R2 相關行(建立 R2 bucket 的指令/敘述),在其前後加註: +``` +> R2 為**選填**:若不需要成員上傳繳費截圖,可跳過建立 R2、並移除 wrangler.toml 的 [[r2_buckets]] 區塊。 +> 未綁 R2 時,以下功能停用——成員上傳/後台檢視繳費截圖、截圖自動保存清理;其餘(宣告繳費、審核、對帳、通知)正常。 +> 後台首次進入會提示 R2 未設定,設定頁也會顯示「截圖儲存(R2):未啟用」。 +``` +(具體插入點:第 2 節 R2 段落;保持與該節既有 zh-TW 風格一致。) + +- [ ] **Step 2: 確認 DEPLOY.md 無 owner 值,且未動到 wrangler.toml** + +Run: +```bash +cd /Users/poterpan/Documents/Coding/Project/chippot +grep -n "panspace\|poterpan5466" docs/DEPLOY.md || echo "(DEPLOY.md clean)" +git status --porcelain +``` +Expected: `(DEPLOY.md clean)`;`git status` 只顯示 `docs/DEPLOY.md`(**不得**出現 `packages/worker/wrangler.toml`)。 + +- [ ] **Step 3: Commit(只 DEPLOY.md)** +```bash +cd /Users/poterpan/Documents/Coding/Project/chippot +git add docs/DEPLOY.md +git commit -m "docs(deploy): mark R2 as optional + +Document that skipping R2 (omit the [[r2_buckets]] binding) runs the system without +screenshot upload/view/retention; everything else works. Note the admin one-time +prompt + the persistent status row." +``` + +--- + +## Controller-only step(收尾:wrangler.toml 加 R2 選填註解,安全處理 skip-worktree) + +> 由 controller(持有 owner 真實值)在所有 subagent task 完成後執行。`packages/worker/wrangler.toml` 被 skip-worktree、本機為真實值;必須避免把真實值 commit。安全程序: +> 1. `cp packages/worker/wrangler.toml /tmp/wrangler.local.toml`(備份本機真實值) +> 2. `git update-index --no-skip-worktree packages/worker/wrangler.toml` +> 3. `git checkout HEAD -- packages/worker/wrangler.toml`(工作檔還原為**已提交的佔位值版**) +> 4. 在佔位值版的 `[[r2_buckets]]` 區塊上方加註解「選填:不需要成員上傳截圖可移除此區塊…」 +> 5. `git add packages/worker/wrangler.toml && git commit`(提交=佔位值+註解,無真實值) +> 6. `cp /tmp/wrangler.local.toml packages/worker/wrangler.toml`(還原本機真實值) +> 7. `git update-index --skip-worktree packages/worker/wrangler.toml`(重設保護) +> 8. 驗證:`git show HEAD:packages/worker/wrangler.toml | grep -E "選填|your-d1-database-id"`(提交版有註解且仍佔位);`git status --porcelain`(乾淨,skip-worktree 隱藏本機真實值)。 + +--- + +## Final verification(全部 Task 完成後) + +- [ ] **Step 1: 全 monorepo 綠燈(無 .dev.vars = CI 條件)** + +Run: +```bash +cd /Users/poterpan/Documents/Coding/Project/chippot +mv packages/worker/.dev.vars packages/worker/.dev.vars.off 2>/dev/null; true +pnpm -r typecheck && pnpm -r test 2>&1 | grep -E "Test Files|Tests " && VITE_API_BASE=https://example.workers.dev pnpm -r build 2>&1 | grep -cE "built in" +mv packages/worker/.dev.vars.off packages/worker/.dev.vars 2>/dev/null; true +``` +Expected: typecheck 全過;全綠無 failed(`Tests 168 passed (168)`);build 計數 2(web+admin)。 + +--- + +## Self-Review 對照(spec → task) + +- spec §3 + §4A(BUCKET 選填 + storage/images/retention/deleteProof guard) → Task 1 ✓ +- spec §4A(getWorkspace r2_configured、upload proof_enabled) → Task 1 ✓ +- spec §4B(web 隱藏截圖) → Task 3 ✓ +- spec §4B(Discord 忽略附件 + 提示) → Task 2 ✓ +- spec §4C(後台首次 localStorage 提示) → Task 4 ✓ +- spec §4D(設定頁常駐狀態) → Task 4 ✓ +- spec §4E(wrangler.toml 選填註解 + DEPLOY.md) → Task 5 ✓ +- spec §6(測試:settle/images/retention/getWorkspace/proof_enabled/deleteProof) → Task 1 測試 ✓;Discord → Task 2 ✓ +- 型別:`r2_configured`(getWorkspace/admin api)、`proof_enabled`(upload/web TokenInfo)名稱跨 task 一致 ✓ From f41b331fa65e464aebb18018dde3f06a21a2e548 Mon Sep 17 00:00:00 2001 From: PoterPan Date: Sun, 7 Jun 2026 21:05:00 +0800 Subject: [PATCH 03/10] =?UTF-8?q?feat(worker):=20make=20R2=20optional=20?= =?UTF-8?q?=E2=80=94=20guard=20BUCKET=20usage=20+=20report=20status?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit BUCKET becomes an optional binding; settle skips proof storage (has_proof=0), images 404, retention no-ops, delete-proof still clears the DB column when R2 is absent. /admin/workspace reports r2_configured and /upload/:token reports proof_enabled. Test env overrides BUCKET as required so existing tests are unaffected. --- packages/worker/src/core/retention.ts | 1 + packages/worker/src/core/storage.ts | 6 ++--- packages/worker/src/env.ts | 3 ++- packages/worker/src/routes/admin.ts | 4 +-- packages/worker/src/routes/images.ts | 2 ++ packages/worker/src/routes/upload.ts | 1 + packages/worker/test/core/retention.test.ts | 17 +++++++++++++ packages/worker/test/core/settle.test.ts | 14 +++++++++++ packages/worker/test/env.d.ts | 1 + packages/worker/test/routes/admin.test.ts | 27 +++++++++++++++++++++ packages/worker/test/routes/images.test.ts | 8 ++++++ packages/worker/test/routes/upload.test.ts | 10 ++++++++ 12 files changed, 88 insertions(+), 6 deletions(-) diff --git a/packages/worker/src/core/retention.ts b/packages/worker/src/core/retention.ts index cd73b83..f586de2 100644 --- a/packages/worker/src/core/retention.ts +++ b/packages/worker/src/core/retention.ts @@ -29,6 +29,7 @@ export async function runRetention( retentionMonths: number, now: Date = new Date() ): Promise { + if (!env.BUCKET) return 0; // R2 not configured — nothing to retain const cutoffIso = subMonthsUtc(now, retentionMonths).toISOString(); const { results } = await env.DB diff --git a/packages/worker/src/core/storage.ts b/packages/worker/src/core/storage.ts index f5abada..d964cc2 100644 --- a/packages/worker/src/core/storage.ts +++ b/packages/worker/src/core/storage.ts @@ -184,7 +184,7 @@ export async function settleUserPeriod(env: Env, input: SettleInput): Promise {}); + if (key && env.BUCKET) await deleteObject(env.BUCKET, key).catch(() => {}); throw err; } @@ -215,7 +215,7 @@ export async function settleUserPeriod(env: Env, input: SettleInput): Promise {}); key = null; } diff --git a/packages/worker/src/env.ts b/packages/worker/src/env.ts index 5cbf3e5..993e55f 100644 --- a/packages/worker/src/env.ts +++ b/packages/worker/src/env.ts @@ -1,6 +1,7 @@ export interface Env { DB: D1Database; - BUCKET: R2Bucket; + // Optional: omit the [[r2_buckets]] binding in wrangler.toml to run without screenshots. + BUCKET?: R2Bucket; // Cloudflare Access (secrets / vars; set via wrangler secret put or .dev.vars). ACCESS_TEAM_DOMAIN?: string; // in .cloudflareaccess.com ACCESS_AUD?: string; // Access application AUD tag diff --git a/packages/worker/src/routes/admin.ts b/packages/worker/src/routes/admin.ts index 97ca723..7ecb0d4 100644 --- a/packages/worker/src/routes/admin.ts +++ b/packages/worker/src/routes/admin.ts @@ -49,7 +49,7 @@ async function getWorkspace(_req: Request, env: Env, ctx: RouteCtx): Promise(); if (!row) return errorResponse(404, "not found"); - return json({ workspace: { ...row, settings: parseSettings(row.settings) } }); + return json({ workspace: { ...row, settings: parseSettings(row.settings) }, r2_configured: !!env.BUCKET }); } async function updateWorkspace(req: Request, env: Env, ctx: RouteCtx): Promise { @@ -456,7 +456,7 @@ async function deleteProof(_req: Request, env: Env, ctx: RouteCtx): Promise { expect(await getObject(env.BUCKET, SOLO)).toBeNull(); }); + it("is a no-op when R2 is not configured (returns 0, keeps the row + object)", async () => { + const KEY = "noR2-key-9023"; + await env.DB.prepare(`INSERT INTO payments (workspace_id,subscription_id,period,period_start,period_end,due_date,amount,status,has_proof,screenshot_key,paid_at,source,created_at,updated_at) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?)`).bind(WS, SUB, "2023-05", "2023-05-01", "2023-05-31", "2023-05-05", 315, "verified", 1, KEY, OLD, "user_web", TS, TS).run(); + await putObject(env.BUCKET, KEY, new Uint8Array([4]), "image/png"); + const prev = (env as any).BUCKET; + (env as any).BUCKET = undefined; + const cleared = await runRetention(env, WS, 24, NOW); + (env as any).BUCKET = prev; + expect(cleared).toBe(0); + const row = await env.DB.prepare("SELECT screenshot_key FROM payments WHERE workspace_id=? AND period='2023-05'").bind(WS).first<{ screenshot_key: string | null }>(); + expect(row?.screenshot_key).toBe(KEY); + expect(await getObject(env.BUCKET, KEY)).not.toBeNull(); + // Clean up so this row doesn't affect subsequent retention tests in this file. + await env.DB.prepare("DELETE FROM payments WHERE workspace_id=? AND period='2023-05'").bind(WS).run(); + await env.BUCKET.delete(KEY); + }); + it("keeps the R2 object when a non-expired payment still references the key", async () => { const KEY = "mixed-key-9023"; const RECENT = "2026-04-15T00:00:00.000Z"; // within 24mo of NOW -> NOT eligible diff --git a/packages/worker/test/core/settle.test.ts b/packages/worker/test/core/settle.test.ts index ad6e8d8..44d29c0 100644 --- a/packages/worker/test/core/settle.test.ts +++ b/packages/worker/test/core/settle.test.ts @@ -66,6 +66,20 @@ describe("settleUserPeriod — Discord direct path", () => { expect(after).toBe(before); }); + it("settles without a proof object when R2 is not configured (has_proof=0)", async () => { + const prev = (env as any).BUCKET; + (env as any).BUCKET = undefined; + const r = await settleUserPeriod(env, { + workspaceId: WS, userId: WS, period: "2027-05", source: "user_slash", + proof: { body: new Uint8Array([1, 2, 3]), ext: "png", contentType: "image/png" }, + }); + (env as any).BUCKET = prev; + expect(r.paidCount).toBe(2); + expect(r.screenshotKey).toBeNull(); + const rows = await env.DB.prepare("SELECT has_proof, screenshot_key FROM payments WHERE workspace_id=? AND period='2027-05'").bind(WS).all<{ has_proof: number; screenshot_key: string | null }>(); + expect(rows.results.every((p) => p.has_proof === 0 && p.screenshot_key === null)).toBe(true); + }); + it("shares ONE screenshot key across all settled rows", async () => { const r = await settleUserPeriod(env, { workspaceId: WS, userId: WS, period: "2027-03", source: "user_slash", diff --git a/packages/worker/test/env.d.ts b/packages/worker/test/env.d.ts index 245794a..2e115dc 100644 --- a/packages/worker/test/env.d.ts +++ b/packages/worker/test/env.d.ts @@ -8,6 +8,7 @@ declare global { namespace Cloudflare { interface Env extends AppEnv { TEST_MIGRATIONS: D1Migration[]; + BUCKET: R2Bucket; // tests always provide R2; override the app's optional binding } } } diff --git a/packages/worker/test/routes/admin.test.ts b/packages/worker/test/routes/admin.test.ts index 37b6fe4..1b1aa28 100644 --- a/packages/worker/test/routes/admin.test.ts +++ b/packages/worker/test/routes/admin.test.ts @@ -168,6 +168,33 @@ describe("admin API", () => { const res = await call("POST", `/admin/payments/${id}/reject`, { rejected_reason: "x" }); expect(res!.status).toBe(409); }); + + it("reports r2_configured from the BUCKET binding", async () => { + const on = (await (await call("GET", "/admin/workspace"))!.json()) as any; + expect(on.r2_configured).toBe(true); + const prev = (env as any).BUCKET; + (env as any).BUCKET = undefined; + const off = (await (await call("GET", "/admin/workspace"))!.json()) as any; + (env as any).BUCKET = prev; + expect(off.r2_configured).toBe(false); + }); + + it("delete-proof still clears the DB column when R2 is not configured", async () => { + const key = "1/2026-10/1/noR2.png"; + const ins = await env.DB.prepare( + `INSERT INTO payments (workspace_id,subscription_id,period,period_start,period_end,due_date,amount,status,has_proof,screenshot_key,source,created_at,updated_at) + SELECT 1, s.id, '2026-10', '2026-10-01','2026-10-31','2026-10-05', 315, 'paid', 1, ?, 'user_web', ?, ? + FROM subscriptions s WHERE s.workspace_id = 1 ORDER BY s.id LIMIT 1` + ).bind(key, nowUtcIso(), nowUtcIso()).run(); + const pid = ins.meta.last_row_id as number; + const prev = (env as any).BUCKET; + (env as any).BUCKET = undefined; + const res = await call("POST", `/admin/payments/${pid}/delete-proof`); + (env as any).BUCKET = prev; + expect(res!.status).toBe(200); + const p = await getPayment(env.DB, pid); + expect(p?.screenshot_key).toBeNull(); + }); }); describe("admin notifications", () => { diff --git a/packages/worker/test/routes/images.test.ts b/packages/worker/test/routes/images.test.ts index b22157e..39ad8f0 100644 --- a/packages/worker/test/routes/images.test.ts +++ b/packages/worker/test/routes/images.test.ts @@ -44,4 +44,12 @@ describe("protected image endpoint", () => { const res = await handleImage(new Request("https://x"), env, ctxFor()); expect(res.status).toBe(400); }); + + it("404s when R2 is not configured", async () => { + const prev = (env as any).BUCKET; + (env as any).BUCKET = undefined; + const res = await handleImage(new Request("https://x"), env, ctxFor(KEY)); + (env as any).BUCKET = prev; + expect(res.status).toBe(404); + }); }); diff --git a/packages/worker/test/routes/upload.test.ts b/packages/worker/test/routes/upload.test.ts index 6015f3a..8fdb4af 100644 --- a/packages/worker/test/routes/upload.test.ts +++ b/packages/worker/test/routes/upload.test.ts @@ -58,6 +58,16 @@ describe("upload info", () => { const res = await handleUploadInfo(new Request("https://x"), env, ctxFor("nope")); expect(res.status).toBe(404); }); + + it("reports proof_enabled per R2 configuration", async () => { + const on = (await (await handleUploadInfo(new Request("https://x"), env, ctxFor(RAW_OK))).json()) as any; + expect(on.proof_enabled).toBe(true); + const prev = (env as any).BUCKET; + (env as any).BUCKET = undefined; + const off = (await (await handleUploadInfo(new Request("https://x"), env, ctxFor(RAW_OK))).json()) as any; + (env as any).BUCKET = prev; + expect(off.proof_enabled).toBe(false); + }); }); describe("upload submit", () => { From 5772bd448124224375628b19ab43909f0872e777 Mon Sep 17 00:00:00 2001 From: PoterPan Date: Sun, 7 Jun 2026 21:12:31 +0800 Subject: [PATCH 04/10] =?UTF-8?q?feat(discord):=20ignore=20/=E7=B9=B3?= =?UTF-8?q?=E8=B2=BB=20screenshot=20attachment=20when=20R2=20is=20not=20co?= =?UTF-8?q?nfigured?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Skip the attachment download when there's no R2 and tell the member to use channel/ note (or append a note on success). The core storage guard already drops the proof; this avoids a wasted download and gives clear feedback. --- .../worker/src/adapters/discord/handler.ts | 9 ++++--- .../worker/test/adapters/discord-pay.test.ts | 24 ++++++++++++++++++- 2 files changed, 29 insertions(+), 4 deletions(-) diff --git a/packages/worker/src/adapters/discord/handler.ts b/packages/worker/src/adapters/discord/handler.ts index 3431220..66db31e 100644 --- a/packages/worker/src/adapters/discord/handler.ts +++ b/packages/worker/src/adapters/discord/handler.ts @@ -180,11 +180,12 @@ async function computePayResult(i: DiscordInteraction, env: Env): Promise { expect(body.data.content).toContain("已登記繳費"); }); }); + +describe("/繳費 with no R2 ignores the screenshot", () => { + it("tells the member to use channel/note when only a screenshot is given and R2 is off", async () => { + const prevB = (env as any).BUCKET; + const prevApp = (env as any).DISCORD_APPLICATION_ID; + (env as any).BUCKET = undefined; + (env as any).DISCORD_APPLICATION_ID = "app-9024"; + let captured = ""; + vi.stubGlobal("fetch", vi.fn(async (_url: string, init: any) => { captured = JSON.parse(init.body).content; return new Response("{}", { status: 200 }); })); + const i: DiscordInteraction = { + type: 2, id: "1", token: "tok", guild_id: GUILD, ...member(DISC), + data: { name: "繳費", options: [{ name: "截圖", value: "att1" }], resolved: { attachments: { att1: { url: "https://cdn.discordapp.com/x.png", content_type: "image/png", size: 100 } } } }, + } as any; + const res = await routeInteraction(i, env, CTX); + expect((await res.json() as any).type).toBe(5); // deferred + await Promise.all(tasks.splice(0)); + vi.unstubAllGlobals(); + (env as any).BUCKET = prevB; + (env as any).DISCORD_APPLICATION_ID = prevApp; + expect(captured).toContain("未開啟截圖功能"); + }); +}); From deca5511f0b878871347efa750a0d168a248bc2e Mon Sep 17 00:00:00 2001 From: PoterPan Date: Sun, 7 Jun 2026 21:17:50 +0800 Subject: [PATCH 05/10] =?UTF-8?q?test(discord):=20cover=20/=E7=B9=B3?= =?UTF-8?q?=E8=B2=BB=20success=20path=20with=20ignored=20screenshot=20(no?= =?UTF-8?q?=20R2)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../worker/test/adapters/discord-pay.test.ts | 26 +++++++++++++++++++ 1 file changed, 26 insertions(+) diff --git a/packages/worker/test/adapters/discord-pay.test.ts b/packages/worker/test/adapters/discord-pay.test.ts index bb85e43..c2856d2 100644 --- a/packages/worker/test/adapters/discord-pay.test.ts +++ b/packages/worker/test/adapters/discord-pay.test.ts @@ -115,4 +115,30 @@ describe("/繳費 with no R2 ignores the screenshot", () => { (env as any).DISCORD_APPLICATION_ID = prevApp; expect(captured).toContain("未開啟截圖功能"); }); + + it("settles and notes the ignored screenshot on success (channel + screenshot, no R2)", async () => { + const U2 = 90247, S2 = 90248, DISC2 = "disc2-9024"; + await env.DB.batch([ + env.DB.prepare(`INSERT INTO users (id,workspace_id,discord_id,display_name,created_at,updated_at) VALUES (?,?,?,?,?,?)`).bind(U2, WS, DISC2, "Member2", TS, TS), + env.DB.prepare(`INSERT INTO subscriptions (id,workspace_id,user_id,plan_id,start_date,billing_day,created_at,updated_at) VALUES (?,?,?,?,?,?,?,?)`).bind(S2, WS, U2, WS, "2026-05-01", 5, TS, TS), + ]); + const prevB = (env as any).BUCKET; + const prevApp = (env as any).DISCORD_APPLICATION_ID; + (env as any).BUCKET = undefined; + (env as any).DISCORD_APPLICATION_ID = "app-9024"; + let captured = ""; + vi.stubGlobal("fetch", vi.fn(async (_url: string, init: any) => { captured = JSON.parse(init.body).content; return new Response("{}", { status: 200 }); })); + const i: DiscordInteraction = { + type: 2, id: "1", token: "tok", guild_id: GUILD, ...member(DISC2), + data: { name: "繳費", options: [{ name: "渠道", value: String(TAG) }, { name: "截圖", value: "att1" }], resolved: { attachments: { att1: { url: "https://cdn.discordapp.com/x.png", content_type: "image/png", size: 100 } } } }, + } as any; + const res = await routeInteraction(i, env, CTX); + expect((await res.json() as any).type).toBe(5); + await Promise.all(tasks.splice(0)); + vi.unstubAllGlobals(); + (env as any).BUCKET = prevB; + (env as any).DISCORD_APPLICATION_ID = prevApp; + expect(captured).toContain("已登記本期"); + expect(captured).toContain("已記錄你的繳費宣告"); + }); }); From 47727a3de65bd3244016a11b43db0ebbe8dcd496 Mon Sep 17 00:00:00 2001 From: PoterPan Date: Sun, 7 Jun 2026 21:19:19 +0800 Subject: [PATCH 06/10] feat(web): hide screenshot upload when R2 is disabled (proof_enabled) The upload page reads proof_enabled from the token info and hides the screenshot field + adjusts the helper copy when R2 is not configured; channel/note still work. --- packages/web/src/App.tsx | 4 +++- packages/web/src/api.ts | 1 + 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/packages/web/src/App.tsx b/packages/web/src/App.tsx index 8f1bec2..1b5bdb2 100644 --- a/packages/web/src/App.tsx +++ b/packages/web/src/App.tsx @@ -123,6 +123,7 @@ export default function App() { )} + {info?.proof_enabled !== false && ( + )}