# 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 `。 - 响应统一封装 `{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 个语言。**不要新建语言文件,合并进已有的 12 个** (`site`/`messages`/`labels`/`buttons`/`home`/`library`/`auth`/…); 服务状态类文案(停机维护、404 等)放 `site.php`,操作结果提示(如「邮件发送失败」) 放 `messages.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`。 **有两个目标,每次都要生成两份**(第二个在另一个仓库,别漏): ```bash # 在 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` 里的类型从它引用,不要再手写字段: ```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`,已安装): ```ts 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/` 下的规格**全部自动生成,不要手改**。 ```bash 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 下线整块删 | 判断某条路径属于哪区: ```bash grep -rn "/v2/channel" dashboard-v4/dashboard/src # 命中 = v4 在用,动不得 grep -rn "/api/v2/channel" dashboard-v6/src ``` > ⚠️ **这张分区表只盘点了 v2 路径的消费者。改 v3 端点时它帮不上忙—— > v3 另有第三个消费者 `wikipali-mobile`(见上面「目录状态」)。** > 动 v3 之前三个都要 grep: > > ```bash > 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 前缀整体下线。 ### 认证迁移待办(2026-10 定) v2 认证端点**一律不动**(v4 部署已冻结,修了也上不去),漏洞随 v4 下线消失。 找回密码、注册、登录在 v3 新建端点(见「v2 → v3 对照表」),dashboard-v6 与 wikipali-mobile 都已切到 v3,**v2 认证端点只剩 dashboard-v4 在用**。还剩一件: - **TODO v4 下线后:md5 → bcrypt,删 v2 auth 全家**。v2 登录在 SQL 里比对 `md5(password)`,v4 在线时密码只能存 md5;v3 写密码统一走 `App\Services\V3\PasswordHasher`,届时只改那一处(登录时校验 md5 并就地 rehash,或加新列——加列由用户决定)。随后删 `sign-in`、`sign-up`、 `auth/*`、`email-certification`、`invite` 读取等 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。 **agent 协作(2026-10-15 定):每迁一个资源,先问用户这个模块要不要支持 agent 写入。** 用户会让 AI(Claude 等,经 wikipali-plugins)代为写入,迁 v3 时不能把这条路堵死 (例:discussions 的批注要允许账号下的 ai model 创建、允许他人回复)。现有两种写入方式: 1. **模型 token**:`ai_models` 一行就是一个身份,用它签出的 token 以「普通用户」身份写, `editor_uid` = 模型 uid。它自己没有任何 channel / 文章权限。 2. **access token**:人类用**自己的登录 token** 调 `POST /v2/access-token` 为某个 channel (可限 book)签一张 7 天的 JWT;写入时模型用**模型 token** 登录、把 access token 放在请求体里, 过 `ChecksChannelEditPower` 的第三条分支(sentences、术语用这种;wikipali-plugins `cmd_write.py`)。 JWT 里不记签发人、也不绑定模型,谁拿到都能用。 两种都只记模型、不记指使它的人。 **v3 的做法(2026-10-15 定,discussions 已落地)**:仿 git 的 Co-Authored-By。人类用自己的 登录 token 调 `POST /v3/agent-grants`,为自己名下的模型签一张 channel(可限 book)授权; 模型用模型 token 登录、写入时带请求头 `X-Agent-Grant`。记录上 editor = 授权人、 `co_editor_uid` = 模型,权限按授权人判,只能写授权覆盖的 channel / book。模型不带授权写 v3 一律 403。 授权人、模型、范围全由服务端写进 token(独立签名密钥,v2 access token 冒充不了),撤销模型 token 时授权一并失效。实现在 `App\Services\V3\AgentGrantService`,新模块照用,不要另发明。 存量数据(editor_uid 直接是模型 uid)不回填;v2 access token 的漏洞暂不修(用户决定)。 **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 列表,分页) | | `auth/forgot-password` + `auth/reset-password` / `ForgotPasswordController` + `ResetPasswordController` | `password-resets` (+`/{token}`) / `V3\PasswordResetController` | 三条并成一个资源:POST 发信、GET 看账号、PATCH 设密码(204)。库里只存 token 的 sha256、60 分钟过期、邮箱未注册也 204、不收 `dashboard` 参数(链接用 `mint.server.dashboard_v6_base_path` 拼到 v6 的 `/anonymous/reset-password/{token}`)、限流 | | `email-certification` + `invite/{id}`(读)+ `sign-up` / `EmailCertificationController` + `InviteController@show` + `SignUpController@store` | `email-certifications` + `invites` (+`/{invite}`) + `users` / `V3\EmailCertificationController` + `V3\InviteController` + `V3\UserController` | 注册。验证码改在服务端比对:v2 的 `GET email-certification/{id}`(把码交给前端)在 v3 没有对应端点,换成 `POST invites {email, code}` 签发 invite;自助与邀请注册都凭 invite 走 `POST users`。验证码 6 位、30 分钟、试错 5 次作废;验证通过前不写 invites 表。studio 发邀请(`POST /v2/invite`)不在此列,将来迁成 `studios/{studio}/invites` | | `discussion`(列表、单条、新建、修改、删除)/ `DiscussionController` | `discussions` (+`/summary`、`/{d}/replies`) / `V3\DiscussionController` + `V3\DiscussionSummaryController` + `V3\DiscussionReplyController` | 管评论线程(type `discussion` / `qa` / `help` / `issue`)与批注(`note` 边注、`commentary` 义注 / 复注对应——巴利注释书,不是普通评论)。批注只挂句子、只有该句管理者能建(AI 凭授权代写按授权人判)、带锚点 `pos_*`/`quote_*`、可回复、无开关、新建不发通知;列表与 summary 不带 `type` 时只算评论线程。写入支持 agent 代写(`X-Agent-Grant`),新增 `co_editor`(人亲自改内容时清空)。字段改名 `res_type`/`res_id`→`target_type`/`target_id`、`parent`→`parent_id`、`children_count`→`reply_count`;`view=question/res_id/question-by-topic`→列表,`view=answer`+带 parent 的 POST→`replies`,响应里的 `active`/`close`/`can_create`→`summary`;每条带 `abilities`;只有 PATCH;软删除(2026-10-15 起 v2 也是),有回复的话题留作墓碑。`view=topic-by-user`/`all`、课程参数、`discussion-anchor`、`sent-discussion-tree` 未迁;`discussion-count` 拆开:讨论角标迁成 `POST discussions/batch-count`(只给计数,入参改为对象 uid,套 v3 可见性与类型口径),标签部分仍走 v2、待迁标签时处理;课程模式的「学生的讨论」待课程迁移后补(TODO 在 `DiscussionService::batchCount`) | | `access-token`(agent 用途)/ `AccessTokenController@store` | `agent-grants` / `V3\AgentGrantController` | 人类为自己名下的 AI 模型签 channel 写入授权。载荷由服务端决定(v2 原样签客户端 payload)、绑定模型(act)与签发人(grantor)、带模型 token_version、独立密钥;写入时放 `X-Agent-Grant` 头而不是请求体 | | `sign-in` + `auth/current` / `AuthController@signIn` + `@getUserInfoByToken` | `sessions` + `me` / `V3\SessionController` + `V3\MeController` | 登录一次返回 `{token, user}`,不再二次查询;`login` 字段收用户名或邮箱(邮箱不区分大小写),失败统一 422 `errors.login`;按账号 + IP 限流。`me` 的字段沿用 v2 的驼峰用户摘要(`UserService::profile()`),`roles` 恒为数组,另加 `email`,不再回显 token。token 与 v2 完全相同、两边互认 | **注意**:`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**,里面有完整的九步链路与模板。 **每迁一个资源,两套外部测试都要跟着更新**(skill 第 8、9 步),没更新就不算迁完: 1. **`api-test/`(只测 v3)**:`bash api-test/tools/regen.sh` 重生客户端,在 `cases/` 加该资源的 用例(公开读、422、无 token 401、带 token 的建改删一轮),`php api-test/run.php` 跑绿。 2. **`dashboard-test/`(只测 v6)**:给切过去的页面加 `src/tests/<资源>.spec.ts`, `npx playwright test <资源>` 跑绿。迁移牵出的「有链接没页面」在 v6 补上页面一起测。 一次只迁一个资源。试点顺序:先 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 或带默认值。