主题
Lampio Admin — 项目指南
本文件是 Claude Code 的项目控制中心(嵌套 CLAUDE.md,工作在
admin/下时自动加载)。 修改时间以git log -1 -- admin/CLAUDE.md为准(手写「更新日期」必然漂移)。📎 同目录还有一份
admin/AGENTS.md,5 行,不要并进来 —— 它是 Next.js 工具链自己维护的注入块(<!-- BEGIN:nextjs-agent-rules -->…END), 内容是「本版 Next.js 有破坏性变更,写代码前先读 next 包自带的 dist/docs/ 目录」。 (那个路径刻意不写进反引号 —— 它在node_modules下,CI 不装 admin 依赖, 写成反引号会让check:claude-paths在 CI 上恒红而本机恒绿。2026-08-30 踩过一次。) 带 BEGIN/END 标记的块会被工具重新写回,手工删掉多半下次next升级又长回来 —— 所以这里只引用。(2026-08-30 裁定,原 backlog 条目建议「并入」,实测后改判。)⚠️ 2026-07-19 合仓:本项目已
git subtree迁入 Lampio 单仓,物理位置 =admin/(原独立仓ttfishnet/readvocab-admin归档——历史仓名,非当前品牌)。Supabase 的 SQL/edge functions 就在同仓supabase/——schema 契约可同会话原子改,不再跨仓手抄。在admin/下改代码用 scoped skilladmin:code-review/admin:doc-sync-check/deploy(RB 的 arch-check/build-check/code-review 已排除 admin/)。详见docs/plans/archive/admin-merge-plan.md。
1. 项目概览
产品定位
Lampio 产品的管理后台。
/— 已下线:重定向到/admin。公开落地页 2026-07-19 拆为独立子项目landing/(独立 Vercel 部署,不与本后台同体,见landing/CLAUDE.mddocs/plans/archive/landing-split-plan.md)。
/admin— 管理后台(需登录,内容管理 / 数据分析 / 配置管理 / 词库管理)- 使用者:个人开发者(1 人)
技术栈
| 层 | 技术 |
|---|---|
| 框架 | Next.js 16.2.3 (App Router) |
| 语言 | TypeScript (strict mode) |
| 样式 | Tailwind CSS 4 + shadcn/ui |
| 图表 | Recharts |
| 数据层 | @supabase/supabase-js (service_role for admin) |
| Auth | Supabase Auth + @supabase/ssr(cookie session —— 服务端要能鉴权,见 §3 红线 6);admin email 白名单 |
| 部署 | Vercel(静态 + Serverless) |
| 包管理 | pnpm |
项目结构
admin/
├── app/
│ ├── layout.tsx ← 全局 layout
│ ├── page.tsx ← 根路径,redirect('/admin')(landing 已拆出)
│ ├── actions/ ← Server Actions
│ │ ├── recommendations.ts ← 站点/feed 写 + pin/批准/否决/恢复/OPML
│ │ ├── recommend-config.ts ← 阈值配置/黑名单写 + 手动触发 edge
│ │ └── vocabulary.ts
│ └── admin/
│ ├── layout.tsx ← Admin 根 layout (pass-through)
│ ├── (auth)/login/page.tsx ← 登录页(无侧边栏)
│ └── (dashboard)/
│ ├── layout.tsx ← Dashboard layout(Sidebar + AuthGuard)
│ ├── page.tsx ← Dashboard 概览
│ ├── recommendations/page.tsx ← 推荐内容管理(sites 含 pin 列)
│ ├── observability/page.tsx ← 运营监控 (force-dynamic, 6 tab)
│ ├── ops/page.tsx ← 基础设施运维(**空壳页**,数据走鉴权 Action —— 不依赖 URL 门的兜底)
│ ├── analytics/page.tsx ← 数据分析
│ ├── vocabulary/page.tsx ← 词库管理 (force-dynamic)
│ └── settings/page.tsx ← 配置管理 (force-dynamic)
├── components/
│ ├── ui/ ← shadcn/ui 组件
│ └── admin/ ← Admin 专用组件(landing 组件已拆到 landing/)
├── lib/
│ ├── supabase-server.ts ← Server-side Supabase client (service_role)
│ ├── supabase-client.ts ← 浏览器 Supabase client(anon,@supabase/ssr → cookie session)
│ ├── auth.ts ← 🔒 白名单 + `requireAdmin()`(从 cookie 验签,见 §3 红线 6)
│ ├── queries.ts ← 产品运营查询(推荐/成本/遥测)
│ ├── queries-ops.ts ← 基础设施运维查询(备份/巡检/容量/凭据)
│ ├── ops-external.ts ← 外部平台只读探查(GitHub/Sentry/health),不 throw
│ ├── ops-constants.ts ← ops 纯常量(客户端可安全 import,见 §3.7)
│ ├── ops-status-rules.ts ← 🔒 ops 页**判据**唯一真相源(见 §3 红线 9)
│ ├── security-headers.ts ← 🔒 安全头/CSP 单一定义处(3 个消费者共用)
│ ├── sentry-scrub.ts ← Sentry 脱敏层 + 三处 init 共用配置
│ └── utils.ts ← cn() helper
├── proxy.ts ← Next 16 中间件:🔒 `/admin/**` 服务端鉴权门 + 下发带 nonce 的 CSP
├── instrumentation.ts ← Sentry 按 runtime 分派 + onRequestError
├── instrumentation-client.ts ← Sentry 浏览器侧 init
├── sentry.{server,edge}.config.ts ← Sentry Node / Edge runtime init
├── playwright.config.ts ← E2E 配置(webServer 跑生产构建,非 dev)
├── tests/
│ ├── unit/sentry-scrub.spec.ts ← 脱敏函数
│ ├── unit/ops-status-rules.spec.ts ← ops 判据(每组成对:正向 + 🔴 旧判据反向断言)
│ ├── unit/release-consistency.spec.ts ← 发布链路三路比对
│ └── e2e/ ← 安全头 / CSP 运行时 / 密钥不外泄 / 🔒 鉴权门 auth-gate
├── docs/ ← 项目文档
├── .env.example ← 变量清单模板(入库,无值)
├── .env.local ← 环境变量(不入库)
└── CLAUDE.md ← 本文件加固细节(为什么 CSP 拆两处下发、为什么
app/layout.tsx必须force-dynamic、 为什么不用'strict-dynamic'、E2E 为什么对生产构建跑)见docs/plans/admin-hardening-plan.md。
2. 跨项目协作
关联项目
| 项目 | 路径 | 技术栈 | 关系 |
|---|---|---|---|
| Lampio (RB) | 仓根 | Tauri 2 + Rust + React + SQLite | 桌面客户端 |
| Lampio 移动端 (RVH) | rvh(2026-08-28 filter-repo 合入) | Flutter/Dart | 移动客户端 |
| 本项目 | admin(2026-07 subtree 迁入) | Next.js 16.2.3 | 管理后台 |
RB + admin + RVH 现同处一个 git 仓(Supabase 契约共享;RVH 于 2026-08-28 合入,见 docs/plans/archive/rvh-merge-plan.md)。三者通过 Supabase 共享数据库和 Auth。
Supabase Schema 源
- DDL 源文件(权威):
supabase/sql/sync-tables.sql(10 张 user_ 同步表 + 3 张推荐表 + RLS)+supabase/sql/{recommend-pool-governance,observability-tables}.sql(治理表)。现在同仓,相对路径../supabase/sql/。 - vocabulary 表:Supabase 侧是「预装库外缓冲池」(PK=word,无 id 列;发版清空)。权威词库=RB/RVH 共享预装
lampio_dict.db。 - Schema 变更规则:涉及 Supabase 同步表结构变更需同时评估 RB 和 RVH(RVH 已同仓,但工具链是 flutter/dart ⇒ 改
rvh/lib/**仍需新会话,判据见根CLAUDE.md§9)。改动 admin 手抄的表/列前,先核对../supabase/sql/sync-tables.sql当前列集——2026-07 表名重命名 + 2026-04-28 vocabulary word-PK 改造都曾让 admin 静默漂移。 - 单一 schema 真相源(gen-types,2026-07-20 起):
lib/database.types.ts由pnpm gen:types从 live 库自动生成(supabase gen types typescript --linked,无需 Docker)。它是 admin 侧列/类型的权威——vocabulary无id、pos_definitions: Json等已由类型编码,谁再手抄错列编译期即报错。schema 改动后重跑pnpm gen:types→tsc --noEmit兜底。- ✅ P1 ② 完成(2026-07-20):
lib/supabase-{server,client}.ts用createClient<Database>;lib/queries.ts手抄的行/表 interface 全部改派生(Row<...>/Pick<Row<...>>),纯计算/投影 DTO 保留显式 interface;admin/docs/database.md瘦身为「Admin 使用」用法映射 +pos_definitionsJSON 形状领域知识(列明细改指生成类型)。真实类型暴露的可空列 drift 已在 queries + 6 组件收窄。见docs/plans/archive/admin-integration-followups.md§五。
- ✅ P1 ② 完成(2026-07-20):
跨项目决策参考
当需要理解全局架构时,读取:
- RB 全貌:仓根
CLAUDE.md(含仓库地图、双端契约红线、git 纪律) - RVH 全貌:
rvh/CLAUDE.md(含 Clean Architecture 和 14 模块结构) - 数据库全貌:
docs/database-schema.md - 本项目架构:
docs/architecture.md
3. 技术红线
以下规则必须遵守,违反可能导致安全事故或数据损坏:
service_role key 绝不暴露给浏览器
- 只在 Server Components / Server Actions / Route Handlers 中使用
- Client Components 只用 anon key(仅做 Auth 登录/登出)
- 绝不在
"use client"文件中 importsupabase-server.ts - ⚠️ 破坏这条不需要谁真的写错 import:只要 Server Component 把含 key 的对象(或一条带 key 的报错)当 prop 传给 Client Component,Next 就会把它序列化进 HTML 的 RSC flight payload。
- 回归守门:
tests/e2e/no-secret-leak.spec.ts扫实际下发到浏览器的字节(HTML + 全部 JS chunk)。 改动涉及 server→client 边界后跑一次pnpm test:e2e。
隐私数据只做聚合查询
- 分析页只用 COUNT / AVG / GROUP BY,不返回单个用户数据
- 不展示用户学习的具体单词、笔记内容
🔒
app/layout.tsx的force-dynamic是 nonce-CSP 的硬前提,不是性能取舍- nonce 每请求现生成,而静态预渲染发生在 build 期(那时没有请求头、拿不到 nonce)。
- 某路由一旦被静态化,产物 HTML 里 Next 的内联 bootstrap 脚本(
self.__next_f.push, 承载 RSC payload)就没有 nonce,却要在带 nonce 的 CSP 下执行 → 被拦 → JS 文件能加载 但无法 hydrate。 - ⚠️ 表现是白屏或「点什么都没反应」,HTTP 依然 200 —— header 断言完全抓不到这类故障。
- 回归守门:
tests/e2e/csp-runtime.spec.ts用真浏览器 +「输入框真的能打字」来验。 - 📌 不要因为「这页数据量小、不必 dynamic」就摘掉它 —— 本条与页面大小无关。 (完整推理与另两个刻意取舍见
docs/plans/admin-hardening-plan.md; 该 plan 归档后本条仍以此处为准。)
Server Components 优先
- 默认使用 Server Components,需要交互时才用 Client Components
所有 Supabase 查询封装在
lib/queries*.ts- 不在页面组件中直接写 Supabase 调用
- 写操作通过
app/actions/中的 Server Actions - 运维相关的查询在
lib/queries-ops.ts(与产品运营查询分文件,读者与改动频率不同)
🔒 鉴权是两层:
proxy.ts的门 + 每个 Server Action 里的锁。两层都不能省。session 存在 cookie(
lib/supabase-client.ts用@supabase/ssr的createBrowserClient)→ 服务端每个请求都拿得到 auth 上下文。层 在哪 管什么 门 proxy.ts/admin/**在渲染之前拦截:getUser()验签 + 邮箱白名单,不过就 307 到登录页锁 app/actions/**每个导出动作首行await requireAdmin()动作执行前再验一次,不依赖 URL 匹配 - 改
lib/supabase-client.ts回普通createClient= 把洞重新打开。 session 一旦回到 localStorage,服务端又变成「无凭据可查」,Server Component 渲染的数据照样进 未登录请求也能拿到的 RSC flight payload(2026-08-14 实测curl /admin/observability无凭据即取到运营数据 ——AuthGuard挡界面不挡字节)。 - 门为什么不够:它按 URL 前缀拦,而 Server Action 的 POST 只是恰好打在
/admin/**上。换个路由布局这个「恰好」就没了。Next 官方口径:Server Action 按公开 HTTP 端点对待,在函数内部鉴权。 - 锁为什么不够:它拦不住 Server Component —— 页面渲染不经过任何 action。
- 验签必须用
getUser(),不能用getSession():后者只解本地 cookie 的内容, 那是攻击者可以随便捏造的字节。 AuthGuard不是安全边界,只是「session 过期就跳走」的交互层,且不再接收adminEmails—— 白名单是服务端策略,序列化给客户端既无用又多泄一份。- 新增敏感页:直接写常规 Server Component 即可,门会保护它;新增 Server Action: 首行必须
await requireAdmin()。两者都有回归断言守着,见tests/e2e/auth-gate.spec.ts(其中一条会扫源码,漏写 requireAdmin 直接红)。 - ⚠️ 新增
app/api/下的 Route Handler 不在门的覆盖范围内(只有/admin/**过门)—— 要么放到/admin前缀下,要么自己调requireAdmin()。/api/health是刻意公开的。 - as-built:
docs/plans/product-launch-todo.md§C5。
- 改
🔒 外部平台 token 只在服务端读,且必须同步三处
SENTRY_READ_TOKEN/GITHUB_READ_TOKEN绝不加NEXT_PUBLIC_,只在 Server Component / Action 里读,只把结论(数字/版本号)交给渲染层。- 新增任何持 token 的外部调用时,必须同时:① 往
lib/sentry-scrub.ts的SECRET_VALUE_PATTERNS加该 token 的形状(否则它被拼进异常 message / URL 就直接进 Sentry,且不会报错);② 补tests/unit/sentry-scrub.spec.ts断言; ③ 补tests/e2e/no-secret-leak.spec.ts的字节扫描。 - Client Component 要用的常量放
lib/ops-constants.ts—— 从lib/queries-ops.tsimport 值会把supabase-server(持 service_role)拖进 client graph。
🔒 ops 页每个状态判据只能有一份,写在
lib/ops-status-rules.ts卡片、总览条、首页横幅调用同一个函数。展示组件里不许出现自己算颜色的 三元表达式。
- 这条不是洁癖,是本页最贵的缺陷来源。 判据一旦有两份就会漂:卡片三档、 总览两档 → 一条 present 的 warn finding 卡片黄、总览绿;容量卡看 DB + Storage 两条水位、总览只看 DB → Storage 涨到 95% 卡片红、总览绿; 发布链路则反过来,卡片 warn、总览 critical。三个方向都出现过(2026-08-25 实测)。
- ⚠️ 失效是静默的:两处各自都「看起来对」,没有任何东西会报错。 修一次同步一次没有用 —— 下次照样漂。要点是让它们没有第二份判据可用。
- 该模块必须保持纯函数、零 IO、不 import 任何服务端模块(它被 Client Component 引用,拖进
queries-ops就把 service_role 带进 client graph, 见红线 1 与 7)。入参用结构类型,时间由调用方传now。 - 改判据必须成对补断言:正向(故障输入会变色)+ 🔴 反向(把旧判据的表达式 原样写进用例,钉住它当时会说
ok)。见tests/unit/ops-status-rules.spec.ts。 只验通过路径 = 没验。 - 判据变化同步到
docs/verification/ops-monitoring.md的 J 行 —— 那份是反复重跑的清单,判据换了而清单没换,下一轮会照着旧问法验一遍。
环境变量安全
.env.local不入库(已在 .gitignore)NEXT_PUBLIC_前缀变量会暴露给浏览器,只放 anon key / URL / Sentry DSN
4. 开发工作流
日常流程
改代码 → pnpm dev 本地调试 → pnpm typecheck → pnpm test → /code-review → /doc-sync-check → git commit验证命令
| 命令 | 说明 |
|---|---|
pnpm typecheck | tsc --noEmit(不是 RB 的 cargo / vite) |
pnpm lint | eslint(当前 0 error,保持住) |
pnpm test | Playwright 全套(unit + e2e,60 tests:58 pass + 2 条按需 skip) |
pnpm test:unit | 只跑脱敏函数等纯逻辑 |
pnpm test:e2e | 只跑浏览器侧 |
E2E 的
webServer跑的是next build && next start(生产构建),不是next dev—— dev 与 prod 的 CSP 刻意不同,对着 dev server 跑等于什么都没验。故首次跑要等一次 build。
部署(现状)
Vercel project lampio-admin 已接本仓 Git(Root Directory=admin), push 到 main 即自动部署。.github/workflows/ci-admin.yml 在 admin/** 变更时跑质量门, 但 Vercel 不等它 —— CI 是事后告警。要真正卡住得走 PR 流程(分支 → PR → CI 绿 → 合 main)。 /deploy skill 里的 vercel deploy --prod 是合仓前的路径,现在只在需要手动补发时用。
Commit 格式
<type>(<scope>): <subject>
Co-Authored-By: Claude <noreply@anthropic.com>type: feat / fix / refactor / docs / chore scope: dashboard / recommendations / vocabulary / analytics / settings / auth
Plan 持久化
进入 Plan Mode 时,将计划保存到 docs/plans/<plan-name>.md,确保跨会话可追溯。
部署流程
使用 /deploy skill 一键完成:build 检查 → git commit → push → vercel deploy --prod
5. 安全操作规则
需要用户确认的操作
| 类别 | 操作 |
|---|---|
| Git | git reset --hard, git push --force, git branch -D |
| 文件 | 删除非临时文件、修改 .env.local |
| 数据库 | 修改 Supabase 表结构、删除数据、修改 RLS 策略 |
| 部署 | vercel deploy --prod(通过 /deploy skill 自动触发,需确认) |
安全操作(无需确认)
pnpm dev, pnpm build, git status/log/diff, 读取文件, 运行 ESLint
6. 项目 Skills
| Skill | 触发 | 用途 |
|---|---|---|
/code-review | 代码变更后 | 深度审查:Next.js 规范、安全、性能 |
/doc-sync-check | 功能完成后 | 检查哪些文档需要同步更新 |
/deploy | 准备发布时 | build + commit + push + vercel deploy |
推荐链条:/code-review → /doc-sync-check → /deploy
7. Supabase 信息
连接信息
- Project URL:
https://jdtbyteiwnciqnfppztz.supabase.co - anon key: 存在
.env.local(不入库) - service_role key: 存在
.env.local(不入库,admin 后台使用)
表结构概览
用户同步表(10 张,RLS per-user)— 2026-07 全端重命名后的当前名
权威列集见
../supabase/sql/sync-tables.sql。旧名(notebook_entries / reading_sources / word_sources / excluded_words 等)已废弃——admin 若还引用旧名即为漂移。
| 表 | 说明 | admin 是否读 |
|---|---|---|
| user_learning_entries | SM-2 学习状态(原 user_notebook_entries;以 word 关联 vocabulary,无 vocabulary_id 列) | ✅ 分析页 |
| user_reading_pages | 页面来源(原 user_reading_sources) | ✅ source_type 分布 |
| user_reading_notes | 阅读笔记容器 | — |
| user_word_page_links | 词-来源关联(原 user_word_sources) | — |
| user_word_cloze_contexts | 复习卡 cloze 语境(v24 起同步) | — |
| user_known_words | 已认识词(原 user_excluded_words) | — |
| user_favorite_sites | 收藏站点 | — |
| user_rss_feeds | RSS 订阅 | — |
| user_domain_prefs | 域名偏好 | — |
| user_page_annotations | 页面标注 | — |
user_reference_words已废弃(reference_words 改纯系统共享表,不再同步);user_word_contexts旧表已下线。
推荐公开表(3 张)
| 表 | 说明 |
|---|---|
| recommended_sites | 推荐站点池(稳定核心,Phase 2 加 is_pinned/admitted_at/cefr_band) |
| recommended_feeds | 推荐 RSS 源 |
| recommended_articles | AI 推荐文章(7 天过期) |
推荐治理表(5 张,service-only,DDL 源在 RB)
Phase 1+2 由 RB edge 写入,admin(Phase 3)只读 + 写治理表。仅 service_role 可读,绝不进 client bundle。
| 表 | 说明 |
|---|---|
| llm_call_log | 每次 LLM 调用:token/cost/latency/status |
| edge_run_log | 每次 function 运行:mode/trigger/status(含 budget_skipped)/summary/cost |
| recommend_audit_log | recommend 决策审计:action/reason/score_detail(多维分 jsonb) |
| recommend_config | 单行(id=1) typed 阈值表(pool_target_size / min_admit_quality / monthly_llm_budget_usd 等 8 字段) |
| recommended_blocklist | 持久拒绝记忆:domain UNIQUE + source(auto_reject/eliminated_dead/manual) |
词库表
| 表 | 说明 |
|---|---|
| vocabulary | 预装库外缓冲池(发版清空),PK=word,无 id 列(2026-04-28 word-PK 改造)。列:word / primary_cefr_level / cefr_inferred / cefr_source / pos_definitions(jsonb) / etymology / word_tags / frequency_rank / ipa_pronunciation / pronunciation_url / source / word_forms / word_family / audio_local_path / 时间戳。权威词库=RB/RVH 预装 lampio_dict.db |
RPC 函数
| 函数 | 用途 |
|---|---|
| get_user_count | 查询 auth.users 总数 |
| get_registration_trend | 查询近 N 天注册趋势 |
8. 环境信息
| 项目 | 版本 |
|---|---|
| Node.js | 22.14.0 |
| pnpm | 10.8.0 |
| Next.js | 16.2.3 |
9. 代码规范
- 使用 TypeScript strict mode
- 组件使用 PascalCase,文件名 kebab-case
- Server Components 优先,需交互时用 Client Components
- shadcn/ui 新版不支持
asChild,使用buttonVariants+ Link 替代 - Select
onValueChange传入string | null,需处理 null - Recharts label 函数需用
PieLabelRenderProps类型