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/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。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。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。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 之后 —— 规格变了,类型就得跟着变@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/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。@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 成熟后还抱着自研脚本不放。
dashboard-v4 正在线上运行,已冻结不再升级,它依赖 161 条 v2 路径。
dashboard-v6 还在测试上线阶段。等 v6 稳定后才会下线 v4。在那之前:
破坏 v2 = 线上崩,而且不会有人发现 —— v4 无人维护、没有测试、改了也没人跑。
| 分区 | 谁在用 | 条数 | 策略 |
|---|---|---|---|
| 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 | 说明 |
|---|---|---|
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):
@queryParam / @bodyParam 标签(规格直接受益)app/Http/Resources/ 的 Resource,而不是 return $this->ok($model) 吐原始模型toArray() 补 @return array{...} 声明(响应类型直接受益)$this->error() 的参数位置写对:error(string $message, mixed $data, int $status)switch 补 default 分支,避免 $table 未定义直接 500$this->error(...) 后面漏掉的 return 补上不要做的事: 不要为了"顺手"重写整个控制器、不要改动你没读懂的逻辑、 不要在功能改动里夹带大范围重构。拿不准就写 FIXME,成本最低。
现有的 FIXME 清单:grep -rn FIXME api-v13/app/Http/Controllers/
每个 view 值都要有测试覆盖。 列表接口的 view 参数是业务路径分叉,每个值背后
是一段独立的查询与权限逻辑,新增的那段代码没人跑过,最可能藏 bug。
tests/Feature/ChannelUserEditListTest.php,
makeStudio / makeChannel / authHeader 是现成 helper。toHaveKeys(...) 这种「包含即可」
的写法,没有 assertExactJson / assertJsonStructure,多返回一个字段不会打破谁。mint_test 库,RefreshDatabase 不会清开发库。线上生产跑的就是本仓库的 development 分支。 没有单独的发布分支,
也没有 CI 部署流程(api-v13/.github/workflows/ 只有 lint 和 tests)。
所以:提交到 development 就等于把代码推向生产路径。这让下面两条约定
不只是习惯问题,而是安全边界:
git commit,等用户 reviewgit push,除非用户明确要求dashboard-v4/deploy/ 里那套 ansible 是 v4 时代的遗留:它指向
iapt-platform/mint 的 laravel 分支,而本仓库的 remote 是
visuddhinanda/mint。不要把它当作现在的发布流程依据。
git commit。deleted_at、乐观锁的 version)就停下来说明用途,等用户确认再写。
改动本身仍受铁律 4 约束:只准加,加列必须 nullable 或带默认值。