CLAUDE.md 23 KB

CLAUDE.md

wikipali —— 开放的、基于语料库的巴利语学习与翻译平台。本仓库是 monorepo。

目录状态(重要)

活跃维护:

目录 是什么 技术栈
api-v13/ 后端,唯一在维护的 API Laravel 13 / PHP 8.5 / Pest 4 / Inertia 3
dashboard-v6/ 前端主站 React 19 / Vite / antd 6 / Redux Toolkit
openapi/ API 规格与在线文档(Swagger UI) 规格由脚本从 api-v13 源码生成
docker/ 容器工具链与开发环境
magnolia/、ai-translate/、langchain-server/、open-ai-server/、sutta-pitaka-importer/、scripts/ 周边服务与工具

同一后端的第三个前端(不在本仓库):

  • /home/deploy/workspace/wikipali-mobile —— 移动端,独立 git 仓库,开发阶段未上线。 用 openapi-fetch 调 /api/v3/*,有自己的 src/api/schema.d.ts。 改 v3 端点前要 grep 它,否则会漏掉消费者(2026-09 就漏过一次)。

冻结但仍在线上运行(代码不要改,但它的依赖决定了后端能动什么):

  • dashboard-v4/ —— 线上生产前端,依赖 161 条 /api/v2 路径。 破坏 v2 就是线上事故,详见下面「过渡期」一节。真实代码在 dashboard-v4/dashboard/src,不是 dashboard-v4/src。

已冻结,不再维护,不要改动、不要参照其写法、不要当作现状依据:

  • api-v8/
  • coconut/
  • tulip/

用户说“后端”指 api-v13,说“前端”指 dashboard-v6。在这两个目录之外动手前先确认。

后端 api-v13

  • 详细的 Laravel/Pest/Pint 约定见 api-v13/CLAUDE.md(Laravel Boost 生成),在该目录工作时必须遵守。
  • 路由集中在 routes/api.php:/api/v2/* 是主力业务接口(约 140 个 apiResource), /api/v3/* 是新版检索与阅读接口,/api/ops/* 是运维接口,需 Authorization: Bearer <APP_OPS_TOKEN>。
  • 响应统一封装 {ok, message, data}(见 app/Http/Controllers/Controller.php 的 ok() / error())。业务失败时 HTTP 状态码仍可能是 200,以 ok 字段为准, 新代码沿用这个约定。
  • 列表接口的通用查询参数:search、order、dir、limit、offset。
  • 响应结构尽量走 app/Http/Resources/*Resource。
  • 改完 PHP 跑 vendor/bin/pint --dirty --format agent;测试 php artisan test --compact, 测试连独立的 mint_test 库(不要让 RefreshDatabase 打到开发库)。
  • storage/resources 是 git submodule(clove)。
  • 面向用户的文案一律走 __()。翻译文件在 resources/lang/{locale}/(不是 lang/),共 8 个语言。不要新建语言文件,合并进已有的 11 个 (site/labels/buttons/home/library/auth/… 没有 messages.php); 服务状态类文案放 site.php。至少补 en 与 zh-Hans,其余自动回退 en。

前端 dashboard-v6

  • src/request.ts 是唯一的请求封装(原生 fetch,导出 get/post/put/patch/delete_/upload/download/graphql)。 没有 baseURL:每个调用点硬编码完整路径 /api/v2/...,开发期由 vite.config.ts 的 proxy 转发到 http://127.0.0.1:8000。新增调用请沿用这个写法。
  • src/api/index.ts 里的 api_url() 和 VITE_API_BASE 是死代码,没有任何地方使用。
  • 目录:features/(业务模块)、pages/、components/、reducers/ + store.ts(RTK)、 locales/(react-intl 多语言)。
  • 命令:npm run dev / npm run build / npm run lint。
  • 接口类型不要手写,从 OpenAPI 生成(见下)。RTK 只用了普通 slice, 没有 RTK Query / react-query / SWR,不要擅自引入数据层。

接口类型生成(agent 自动执行,不用问)

schema.d.ts 由 OpenAPI 规格生成,已提交进版本库,不要加进 .gitignore。 有两个目标,每次都要生成两份(第二个在另一个仓库,别漏):

# 在 mint 仓库根目录执行
npx openapi-typescript openapi/public/assets/protocol/main.yaml -o dashboard-v6/src/api/schema.d.ts
npx openapi-typescript openapi/public/assets/protocol/main.yaml -o /home/deploy/workspace/wikipali-mobile/src/api/schema.d.ts

两份内容完全相同,只是分发到两个消费者。mobile 那份的改动留在它自己的工作区, 由用户决定何时提交。它也是一道闸:cd /home/deploy/workspace/wikipali-mobile && npx tsc --noEmit 会把被后端改动打断的调用点精确报出来。

何时必须重跑(满足任一条就直接跑,不必询问用户):

  • 跑过 openapi/scripts/generate.php 之后 —— 规格变了,类型就得跟着变
  • 改了 api-v13 的路由、控制器 docblock、FormRequest 规则、或 Resource 的 @return array{...} 声明
  • 前端要用某个接口或字段,但 schema.d.ts 里查不到它
  • 前端构建报接口类型相关的错误,怀疑类型是旧的

跑完记得把 schema.d.ts 的改动一并留在工作区,它是这次改动的一部分。

src/api/<资源>.ts 里的类型从它引用,不要再手写字段:

import type { paths } from "./schema";   // 注意:不写 .d.ts 后缀

type ChannelResponse =
  paths["/v2/channel-name/{name}"]["get"]["responses"][200]["content"]["application/json"];
type ChannelListQuery = paths["/v2/channel"]["get"]["parameters"]["query"];

后端改了字段而前端没跟 → npm run build 直接报错。 验证类型时用 npx tsc -b --force(tsc -b 有增量缓存,不加 --force 可能不重新检查)。

v3 的调用走 src/api/client.ts(基于 openapi-fetch,已安装):

import { api, unwrap } from "./client";
const data = unwrap(await api.GET("/v3/heartbeat"));

路径字面量、HTTP 方法、路径参数名、响应字段类型全部编译期检查。client 的 baseUrl 是 /api,所以路径写规格里的 path;token 由中间件注入;非 2xx 时 openapi-fetch 不抛异常,用 unwrap() 转成 HttpError。

v2 的调用继续用 src/request.ts,不要动。

OpenAPI 规格

openapi/public/assets/protocol/ 下的规格全部自动生成,不要手改。

cd openapi
php scripts/generate.php   # 从 api-v13 的 route:list + 控制器 AST 重新生成
npm run lint               # redocly 校验,应为 0 error
  • 生成器解析控制器的文档注释、$request->input()/query()、switch($request->input('view')) 的 case、validate() 规则、以及返回的 *Resource::toArray() 键。
  • 接口参数说明写在控制器的文档注释里,用 @queryParam / @bodyParam / @urlParam 标签(@标签 名字 类型 [required] 描述,描述里可写 Enum: / Default: / Example:), 生成器会解析成精确的 OpenAPI 参数。示例见 ChannelController@index。
  • 响应字段的类型来自 Resource 的 @return array{...} 声明;没写声明就只能按 字段名猜(会猜错,如 channels.type 是 varchar 却被猜成 integer)。 范例见 api-v13/app/Http/Resources/ChannelResource.php。
  • 不适合写进注释的长篇补充才放 openapi/public/assets/protocol/overrides/<同名 slug>.yaml, 深度合并到自动结果之上(注意数组是整体替换)。
  • 后端路由或控制器有改动,就重跑生成器,否则规格立刻过时。
  • 细节见 openapi/README.md。

为什么是自研脚本,什么时候该换掉

社区有成熟方案(Scribe、swagger-php / L5-Swagger、Dedoc Scramble),当初没用是因为 v2 有三个它们处理不了的形态:switch ($request->input('view')) 抽枚举、 {ok, message, data} 信封、业务失败也返回 HTTP 200。

v3 的规范正在消除这三点。等 v3 资源铺开,就评估换成 Scramble(零注解、自动推断 FormRequest 与 Resource),自研脚本退化为只服务 v2 遗留部分;v4 下线、v2 删完后 一并删掉。在那之前不要提议换工具,也不要在 v3 成熟后还抱着自研脚本不放。

过渡期:v2 是线上冻结面(最高优先级,先读这节)

dashboard-v4 正在线上运行,已冻结不再升级,它依赖 161 条 v2 路径。 dashboard-v6 还在测试上线阶段。等 v6 稳定后才会下线 v4。在那之前:

破坏 v2 = 线上崩,而且不会有人发现 —— v4 无人维护、没有测试、改了也没人跑。

调用面分区(2026-09 盘点)

分区 谁在用 条数 策略
A 只有 v6 3 可自由迁 v3。heartbeat、chapter-content、paragraph-content
B v4 + v6 118 v2 原地冻结,在 v3 另建新端点,v6 灰度切过去
C 只有 v4 43 什么都不做。不补注释、不写测试、不迁移,等 v4 下线整块删

判断某条路径属于哪区:

grep -rn "/v2/channel" dashboard-v4/dashboard/src   # 命中 = v4 在用,动不得
grep -rn "/api/v2/channel" dashboard-v6/src

⚠️ 这张分区表只盘点了 v2 路径的消费者。改 v3 端点时它帮不上忙—— v3 另有第三个消费者 wikipali-mobile(见上面「目录状态」)。 动 v3 之前三个都要 grep:

> grep -rn "v3/tipitaka" dashboard-v4/dashboard/src dashboard-v6/src \
>     /home/deploy/workspace/wikipali-mobile/src
> ```
>
> 2026-09 就是漏了第三个,把 mobile 正在用的两个端点删掉了。

**v4 的真实代码在 `dashboard-v4/dashboard/src`**(不是 `dashboard-v4/src`),
且有一批绕过请求封装的裸 URL(antd Upload 的 `action`、导出 `href`),grep 时别漏。

### 铁律

1. **不得改变 v2 的响应形状、字段名、状态码。** 加字段是安全的,改/删不是。
2. **改 v2 只有两个理由,其余一律迁 v3。** 常规做法是「bug 报告 = 迁移触发器」:
   与其在一份马上要删的代码里修,不如把这一刀花在往前推进上。

发现 bug ├─ ① 必须 1 小时内修好,或 │ ② 会污染 / 丢失数据库里的数据(哪怕不急) │ └─ 直接改 v2。这是唯一允许改 v2 的两个理由。 └─ 其余一律 → 迁 v3、在 v3 修、v6 同 commit 切过去。v2 那份不碰、不管。

             C 类(只有 v4 用)没有 v6 可切,等同于不管。

   第 ② 条不是为了照顾 v4 用户——**v4 和 v6 共用同一个数据库**,v2 的写入端点
   污染的是 v6 也在读的那批行。这一条即使 v4 明天下线也仍然成立。
   (v4 是公示过的冻结旧版,首页入口指向 v6,角落才是「旧版入口」。普通 bug
   不回补到旧版,与 Ubuntu LTS / Windows ESU 只修安全和数据问题是同一个口径。)

   **不要为了共享逻辑去改 v2。** 规则按代码种类分:

   | 种类 | 怎么办 | 放哪 |
   | --- | --- | --- |
   | 契约相关:查询构造、权限判定、响应组装、view 分支 | **不共享,v3 重写** | `App\Services\V3\` |
   | 纯能力:与请求/响应形状无关,进去模型出来数据 | **共享现成的** | `App\Services\`(根):`PaliContentService`、`OpenSearchService`、`UserService` |

   现成的纯能力 Service 直接注入用;v3 需要而它没有的,v3 自己写一份,
   v2 里那份同名逻辑原地烂着,等 v4 下线一起删。**两个都照顾,两个都照顾不好。**

   **「v3 重写」是重写进 `App\Services\V3\`,不是写进控制器。**
   控制器只是胶水层:取 `validated()`、调 Service、包 Resource、`abort()`,
   除入口方法外不该有第二个 `protected function`。详见 `v3-resource` skill 硬规范第 5 条。
3. **改 v2 之前先录快照测试**(characterization test)。只有上面那两条分支会走到
   这一步——常规路径永远不改 v2,也就不需要给 161 条都补。
4. **migration 只准加,不准改删**:加列必须 nullable 或带默认值;
   禁止删列、改列名、改类型、加 NOT NULL。v4 的代码在读这些列,而你不会去改 v4。
5. **删除任何 v2 路由前**,必须先枚举 `dashboard-v4/dashboard/src` 里两处动态调用
   (`components/corpus/BookTreeWithTags.tsx:44`、`components/dict/SearchVocabulary.tsx:102`
   的 `/v2/${api}`)可能传入的资源名。在枚举完成前,v2 路由一律不准删。

### v4 与 v3 的关系(已澄清)

v4 有两处引用 v3,**均不构成生产依赖**:

- `components/chat/ChatInput.tsx:127` 调 `/v3/search-suggest`——所在的三个页面
  线上不可达,用户会彻底封闭该组件。
- `services/agentApi.ts:14` 硬编码 `http://localhost:8000/api/v3`,是未完成的开发残留,
  生产环境必然请求失败。

所以 **v3 端点可以自由改**。仍建议改 v3 时顺手 grep 一下 v4,成本近乎为零。

### 退出条件

v6 全量稳定 → 下线 v4 → 删掉 C 类与 B 类的 v2 残留 → v2 前缀整体下线。

## v3 重构

正在把资源从 `/api/v2` 逐个迁到 `/api/v3`。原则是 `声明 → 生成 → 契约测试`:
FormRequest 定入参、Resource 的 `@return array{...}` 定出参、契约测试卡住漂移、
spec 与前端类型全部生成。v3 端点不准有 `view=` 开关、不准 `ok:false` + HTTP 200。

**后端 v2 原地保留,前端切干净。** v2 与 v3 在后端共存到 v4 下线;但前端每迁
一个资源就要**彻底**切过去——删掉该资源的 v2 调用,不留开关、不留双分支、
不写抹平两种响应的归一化层。**重构是为了提升,不是为了兼容**;兼容代码以后
删起来很麻烦,没人敢确认还有没有人在用。

回退靠 `git revert` 那个 commit,不靠代码里的开关——这也是「一次只迁一个资源、
一个资源一个 commit」的理由。生产上本来也没有「运行时切换」这回事:前端是静态
构建产物,改代码、改 `.env`、改 ops 配置都得重新构建部署,没有热更新。

v3 前端代码不要 import v2 的东西:v3 用 `src/api/error.ts` 的 `ApiError`,
v2 用 `src/request.ts` 的 `HttpError`。等 v2 调用点全部消失,`request.ts`
整个删掉,不牵连 v3。

**v3 响应信封(已定死)**:就是 Laravel Resource 的原生形状——单个 `{data: {...}}`,
列表 `{data: [...], meta: {...}}`,失败 `{type, title, status, detail, instance, errors?}`
+ `application/problem+json`。**判断成败只看 HTTP 状态码。**

控制器里**没有任何响应 helper**:成功直接 `return XxxResource::make()/::collection()`,
失败 `abort()` / `throw ValidationException` / `throw BusinessException`,
由 `bootstrap/app.php` 统一渲染(只接管 `api/v3/*`)。
v3 的 Resource 继承 `App\Http\Resources\V3\BaseResource`,它负责裁掉分页里的绝对 URL。
**v2 的 `ok()` / `error()` 不准出现在 v3 控制器里。**

前端对应(**只管 v3**,v2 的 `request.ts` 错误语义不同,迁移期内不碰):

- `unwrap()` —— 失败时弹提示并抛出。业务代码只写成功路径,**不用 try/catch、
  不用判断 `ok` 字段**(v2 那套在全仓库重复了 221 处)
- `unwrapQuiet()` —— 只抛不弹,给自己渲染失败状态的调用用(如心跳轮询)
- `ApiError` —— v3 专用错误类型,与 v2 的 `HttpError` 互不依赖
- `src/api/error.ts` —— 提示的唯一出口,将来换 Notification 只改这里;
  `installApiErrorHandler()` 在 `main.tsx` 装全局兜底,消掉未捕获 promise 的噪音
- 表单要字段级错误时自己 catch,用 `fieldErrorsOf(e)` 取 422 的 `errors`

**v3 的类全部放在 `V3\` 子命名空间里,名字里不再带 `V3`:**

app/Http/Controllers/V3/HeartbeatController.php → App\Http\Controllers\V3\HeartbeatController app/Http/Resources/V3/ReactionResource.php → App\Http\Resources\V3\ReactionResource app/Http/Requests/V3/IndexReactionRequest.php → App\Http\Requests\V3\IndexReactionRequest ```

命名空间已经把版本说清楚了,类名再带一次是冗余;而且跟 v2 同名也不冲突—— App\Http\Controllers\HeartbeatController(v2)与 …\V3\HeartbeatController 并存。 目录名用 StudlyCase 的 V3(PSR-4 要求目录与命名空间段一致,仓库里的先例是 Library/)。 判据:/v3/* 路由指向的控制器,全都应该在 Controllers/V3/ 下。

基类在 App\Http\Resources\V3\BaseResource / BaseResourceCollection (原来叫 V3Resource / V3ResourceCollection)。测试文件保持 tests/Feature/ 扁平。

默认沿用 v2 的资源名:HeartbeatController → V3\HeartbeatController, Resource / FormRequest / 测试同理;URL 只换版本前缀 (/v2/heartbeat → /v3/heartbeat),集合复数化(/v2/channel → /v3/channels)。 名字对得上,排查时两边能互相找到。

但改名是允许的,不需要审批。 v3 是破坏性重构,v2 的名字本来就有很多名不副实的; 遇到该改的就改,输入输出也可以重新设计——反正前后端一起改,没改到的资源继续走 v2 老代码。

改了就往下面这张表加一行。 这张表不是门禁,是对照表:改名越多,v2↔v3 对不上号 的排查成本越高,所以每迁一个改了名的资源都要留下映射。

v2 → v3 对照表

v2 v3 说明
like / LikeController reactions / V3\ReactionController 等 likes 表早已不只存点赞:type 的实际取值有 like、dislike、favorite、watch、bookmark、download 六种
(无对应 v2 端点) tipitaka-reading/{channel} / V3\TipitakaReadingController 新能力:单 channel 的只读渲染 + 游标式下载。曾叫 tipitaka-read-para / tipitaka-read-chapter(两条),也短暂叫过 channels/{c}/books/{b}/paragraphs\|chapters,2026-09-24 并成一条
related-paragraph / RelatedParagraphController tipitaka-related-paragraphs (+/aggregate) / V3\TipitakaRelatedParagraphController + V3\TipitakaRelatedParagraphAggregateController 字段改名 book_title_pali→title、cs6_para→cs_para;响应 {ok,…}→原生 Resource;消除逐行 N+1(批量 whereIn);新增 book_name/cs_para 过滤器与独立 /aggregate 聚合端点(group_by=book_id→book_name 列表 / group_by=book_name→cs_para 列表,分页)

注意:tipitaka-reading 不是 v2 paragraph-content / chapter-content 的替代品。 那两个 v2 端点支持多 channel(channels=a,b,c)与 edit 模式、返回 sentenceIds 供编辑器用; v3 这条只服务单 channel 的只读渲染。前端 dashboard-v6/src/api/pali-text.ts 仍在调 v2, 要真正取代它们还得先把多 channel 与 edit 口径补上。

补偿手段要写进代码:v3 控制器的类注释写明「取代 GET /v2/xxx」,模型注释写明底层表名 (如 Reaction 写明仍是 likes 表),这样从任一端都能找到另一端。

做迁移时使用 v3-resource skill,里面有完整的七步链路与模板。 一次只迁一个资源。试点顺序:先 A 类 3 条(零线上风险,用来打磨样板与 CI 三道闸), 再从 channel 开始啃 B 类。

渐进式还债

这个代码库有六年历史,欠债很多。规则是碰到什么还什么,但必须让每次的还债量 小到人能 review 完。三条硬约束:

1. 只还你这次真正改到的那个方法的债,不扩散。 改 ChannelController@index 就只清理 index,同文件的 store、destroy 一律不动, 哪怕一眼就看到问题。看到了就加 // FIXME:,留给下次碰到它的人。

2. 功能改动和还债拆成两个 commit。 先 feat/fix 提交功能本身,再 refactor 提交清理。这样 review 时能分开看, 清理动作出了问题也能单独回滚。

3. 每次改动结尾,用一句话列出还了哪些债。 比如「顺带:index 补了 docblock、switch 加了 default 保护」。没列出来的清理动作 等于没做过 review。

碰到旧方法时按这个顺序还(做得到就做,做不到就 FIXME):

  • 补中文 docblock 与 @queryParam / @bodyParam 标签(规格直接受益)
  • 返回值走 app/Http/Resources/ 的 Resource,而不是 return $this->ok($model) 吐原始模型
  • 给碰到的 Resource 的 toArray() 补 @return array{...} 声明(响应类型直接受益)
  • $this->error() 的参数位置写对:error(string $message, mixed $data, int $status)
  • switch 补 default 分支,避免 $table 未定义直接 500
  • $this->error(...) 后面漏掉的 return 补上
  • 给你这次碰到的 view 值补测试

不要做的事: 不要为了"顺手"重写整个控制器、不要改动你没读懂的逻辑、 不要在功能改动里夹带大范围重构。拿不准就写 FIXME,成本最低。

现有的 FIXME 清单:grep -rn FIXME api-v13/app/Http/Controllers/

测试约定

每个 view 值都要有测试覆盖。 列表接口的 view 参数是业务路径分叉,每个值背后 是一段独立的查询与权限逻辑,新增的那段代码没人跑过,最可能藏 bug。

  • 新增 view 值 → 必须补 Pest 测试,至少两条:一条验证它返回预期数据, 一条验证权限不足时被拒。模板见 tests/Feature/ChannelUserEditListTest.php, makeStudio / makeChannel / authHeader 是现成 helper。
  • 新增字段 → 不用改测试。项目里的断言都是 toHaveKeys(...) 这种「包含即可」 的写法,没有 assertExactJson / assertJsonStructure,多返回一个字段不会打破谁。
  • 需要改已有测试的只有:改了现有字段的名字或类型、改了权限规则、改了现有 view 的返回口径。
  • 测试连独立的 mint_test 库,RefreshDatabase 不会清开发库。

分支与发布(先读这条)

线上生产跑的就是本仓库的 development 分支。 没有单独的发布分支, 也没有 CI 部署流程(api-v13/.github/workflows/ 只有 lint 和 tests)。

所以:提交到 development 就等于把代码推向生产路径。这让下面两条约定 不只是习惯问题,而是安全边界:

  • 改完留在工作区,永远不要自动 git commit,等用户 review
  • 永远不要 git push,除非用户明确要求

dashboard-v4/deploy/ 里那套 ansible 是 v4 时代的遗留:它指向 iapt-platform/mint 的 laravel 分支,而本仓库的 remote 是 visuddhinanda/mint。不要把它当作现在的发布流程依据。

工作约定

  • 改完代码留在工作区,等用户 review,不要自动 git commit。
  • 文档文件只在用户明确要求时创建。
  • 不要擅自安装或升级任何依赖(npm、composer 都一样)。需要新依赖就说明理由, 等用户自己装完通知你。
  • 不要自动新建 migration。 加列是不可逆的库结构变更,由用户决定。发现缺列 (如软删除的 deleted_at、乐观锁的 version)就停下来说明用途,等用户确认再写。 改动本身仍受铁律 4 约束:只准加,加列必须 nullable 或带默认值。