|
|
@@ -1,9 +1,12 @@
|
|
|
# WikiPali 写入型 Skill 设计文档
|
|
|
|
|
|
-> 目标:提供一个可复用的 Claude Code Skill,使**任意外部项目**都能通过 Claude Code 以「AI 模型身份」把句子写入 WikiPali 数据库(`SentenceController`),并保持正确的作者署名与权限边界。
|
|
|
+> 目标:提供一个 Claude Code Skill,通过 Claude Code 以「AI 模型身份」把句子写入 WikiPali 数据库(`SentenceController`),并保持正确的作者署名与权限边界。
|
|
|
>
|
|
|
-> 状态:设计草案(未实施)
|
|
|
+> 分发路径:在本仓库开发调试,成熟后以**整目录复制**方式装到其他项目(§6.7)。不做独立仓库——API 仍需频繁修改,Skill 契约必须与 `api-v13` 同仓演进。
|
|
|
+>
|
|
|
+> 状态:设计已定案;服务端 P0 已完成(§5.1 端点 + §5.2 abdefg),Skill 尚未开始
|
|
|
> 对应后端:`api-v13`(Laravel 13,路由前缀 `/api/v2`)
|
|
|
+> 决策定案:2026-08-04(见 §9)
|
|
|
|
|
|
---
|
|
|
|
|
|
@@ -27,7 +30,7 @@
|
|
|
|
|
|
## 2. 现有 API 盘点
|
|
|
|
|
|
-以下均已对照源码核实。基址记为 `{API}`,形如 `https://host/api`(参见 `ai-translate/config.orig.toml` 的 `api-url`)。
|
|
|
+以下均已对照源码核实。基址记为 `{API}`,形如 `https://host/api`(参见 `ai-translate/config.orig.toml` 的 `api-url`)。**`{API}` 不是唯一的**:线上四个地址共享同一套数据,差别只在地区(`.org`/`.cc`)与代码版本(`www` 稳定 / `next` 最新)——见 §6.1.2。下文契约以稳定版为准。
|
|
|
|
|
|
### 2.1 登录 —— 可用 ✅
|
|
|
|
|
|
@@ -54,27 +57,39 @@
|
|
|
- `view` 仅支持 `all` / `studio` / `usable` / `chat`;`keyword` 是 `like %kw%` **模糊**匹配,客户端需自行做精确 `name` 比对
|
|
|
- ⚠️ 缺陷:`view` 传入非法值时 `$table` 未定义 → 500(`AiModelController.php:29-45`)
|
|
|
|
|
|
-`POST {API}/v2/ai-model`(Bearer)body `{name, studio_name}`
|
|
|
+`POST {API}/v2/ai-model`(Bearer)body `{name, studio_name, model?, url?, key?, privacy?, description?, system_prompt?}`
|
|
|
|
|
|
-- 实现:`AiModelController::store()`(`AiModelController.php:75`)
|
|
|
-- ⚠️ 只写入 `name / uid / real_name / owner_id / editor_id`,**不接受 `model` / `url` / `key` / `privacy` / `description`**
|
|
|
-- ⚠️ 无重名校验,重复调用会产生多条同名记录
|
|
|
-- 鉴权:`canEdit($user_uid, $studioId)` 要求 `user_uid === studioId`,即**只有个人 studio 可用,group studio 会 403**(`AiModelController.php:157`)
|
|
|
+- 实现:`AiModelController::store()`
|
|
|
+- ✅ 已接受全部字段;`privacy` 缺省为 `private`
|
|
|
+- ✅ 同一 studio 内 `name` 重复返回 **409**(客户端据此判定「已存在」)
|
|
|
+- `name` 必填、`studio_name` 必填,违反则 **422**(`StoreAiModelRequest`)
|
|
|
+- 鉴权:`canEdit($user_uid, $studioId)` 要求 `user_uid === studioId`,即**只有个人 studio 可用,group studio 会 403**
|
|
|
|
|
|
`PUT {API}/v2/ai-model/{uid}`(Bearer)
|
|
|
|
|
|
- 路由模型绑定按 `uid`(`AiModel::$primaryKey = 'uid'`)
|
|
|
-- ⚠️ 用 `$request->input()` 无保护地整体覆盖,**未提供的字段会被置为 null**(`AiModelController.php:125-132`)。客户端必须一次性提交全部字段
|
|
|
+- ✅ **增量更新**:只改请求里出现的字段,未提交的保持原值,客户端可以只发一两个字段
|
|
|
+- 显式传 `null` 仍可清空字段(判定用 `has()` 而非 `filled()`)
|
|
|
+- 改名撞上同 studio 内已有名字 → **409**
|
|
|
+
|
|
|
+### 2.3 获取 AI Model 的 user token —— 可用 ✅(本次新增)
|
|
|
+
|
|
|
+`GET {API}/v2/ai-model-token/{uid}`(Bearer = **用户 token**)→ `data: { uid, name, token }`
|
|
|
|
|
|
-### 2.3 获取 AI Model 的 user token —— **缺失** ❌
|
|
|
+- 实现:`AiModelTokenController::show()`,见 §5.1
|
|
|
+- 鉴权:仅模型 owner 本人(`canEdit`),否则 403;未登录 401
|
|
|
+- 底层是 `AuthService::getUserToken()`(`app/Services/AuthService.php`):先查 `ai_models.uid`,命中即签模型 token,否则按人类用户签发
|
|
|
+- **有效期 30 天**(人类登录 token 仍是 365 天),payload 带 `typ: "ai-model"` 与 `ver`(版本号)
|
|
|
+- ⚠️ 仍属最高敏感凭据,但已**可撤销**,见 §2.3b
|
|
|
|
|
|
-`AuthService::getUserToken(string $userUid)`(`app/Services/AuthService.php:14`)会:
|
|
|
-- 先查 `UserApi::getByUuid`,查不到再查 `AiAssistantApi::getByUuid`(后者查 `ai_models.uid`);
|
|
|
-- 签发与用户 token 同构、有效期 365 天的 JWT。
|
|
|
+### 2.3b 撤销 AI Model 的全部 token —— 可用 ✅(本次新增)
|
|
|
|
|
|
-目前**没有任何 HTTP 路由暴露它**,调用点全在服务端内部(`AiTaskPrepare.php:112`、`AiTranslateService.php:596`、`UpgradeAITranslation.php:108` 等)。
|
|
|
+`DELETE {API}/v2/ai-model-token/{uid}`(Bearer = **用户 token**)→ `data: { uid, name, token_version }`
|
|
|
|
|
|
-**这是本方案唯一的阻塞性缺口,必须新增端点。**
|
|
|
+- 实现:`AiModelTokenController::destroy()`,见 §5.1
|
|
|
+- 鉴权同 `show`:仅 owner 本人,否则 403;未登录 401
|
|
|
+- 语义是「作废该模型已签出的**所有** token」,不能只废其中一张——凭据泄漏时本就该全废
|
|
|
+- 客户端处理:撤销后旧凭据请求一律 401,Skill 应提示重新 `ensure-model` 取 token
|
|
|
|
|
|
### 2.4 签发 channel access token —— 可用 ✅
|
|
|
|
|
|
@@ -88,8 +103,9 @@
|
|
|
|
|
|
- 实现:`AccessTokenController::store()`(`app/Http/Controllers/AccessTokenController.php:33`)
|
|
|
- 鉴权:`ChannelApi::userCanEdit(user_uid, res_id)`,无权则该条被静默跳过(`continue 2`)→ **rows 可能为空数组,客户端必须判空**
|
|
|
-- 签名密钥:`AccessToken.token`(uuid)**重复两次拼接**,算法 HS512(`AccessTokenController.php:78`)
|
|
|
-- ⚠️ payload 中不含 `exp`,**签出的 token 永不过期**
|
|
|
+- 签名密钥:`AccessToken.token`(uuid)**重复两次拼接**,算法 HS512
|
|
|
+- ✅ payload 现在带 `nbf` / `exp`,**有效期 7 天**(`AccessTokenController::TOKEN_TTL`)。返回的 `payload` 里含 `exp`,客户端可据此判断何时需要重签
|
|
|
+- 过期后写句子会得到 403(而非 500)
|
|
|
|
|
|
### 2.5 写入句子 —— 可用 ✅
|
|
|
|
|
|
@@ -144,8 +160,8 @@
|
|
|
| |<-- rows(精确匹配 name)---|
|
|
|
| | 未命中 → POST /v2/ai-model → PUT /v2/ai-model/{uid}
|
|
|
| | |
|
|
|
- | |-- GET /v2/ai-model/{uid}/token ★新增 -->
|
|
|
- | |<-- modelToken (365d) ------|
|
|
|
+ | |-- GET /v2/ai-model-token/{uid} ★新增 -->
|
|
|
+ | |<-- modelToken (30d, 可撤销) |
|
|
|
|-- 提供 channel_id ->| |
|
|
|
| |-- POST /v2/access-token(Bearer=userToken)-->
|
|
|
| |<-- accessToken ------------|
|
|
|
@@ -182,56 +198,113 @@
|
|
|
|
|
|
## 5. 需要的服务端改动
|
|
|
|
|
|
-### 5.1 新增:获取 AI Model 的 user token(P0,阻塞)
|
|
|
+### 5.1 新增:获取 AI Model 的 user token(P0,阻塞)—— ✅ 已实施
|
|
|
|
|
|
-路由(`routes/api.php` v2 组内):
|
|
|
+`GET {API}/v2/ai-model-token/{uid}`(Bearer = 用户 token)
|
|
|
+
|
|
|
+路由(`routes/api.php` v2 组内,紧跟 `ai-model` 的 apiResource):
|
|
|
|
|
|
```php
|
|
|
-Route::get('ai-model/{ai_model}/token', [AiModelController::class, 'token']);
|
|
|
+Route::get('ai-model-token/{ai_model}', [AiModelTokenController::class, 'show']);
|
|
|
```
|
|
|
|
|
|
-控制器(`AiModelController`):
|
|
|
+独立控制器 `AiModelTokenController::show()`,而非挂在 `AiModelController` 上——签发身份凭据与模型的 CRUD 是两件事,分开后前者的鉴权与日志不会被 CRUD 的改动波及。路由参数名 `{ai_model}` 与 apiResource 生成的一致,隐式模型绑定按 `AiModel::$primaryKey = 'uid'` 解析。
|
|
|
|
|
|
```php
|
|
|
-/**
|
|
|
- * 签发 AI 模型的身份 token,供外部客户端以模型身份写入数据。
|
|
|
- */
|
|
|
-public function token(Request $request, AiModel $aiModel): JsonResponse
|
|
|
+public function show(Request $request, AiModel $aiModel): JsonResponse
|
|
|
{
|
|
|
$user = AuthService::current($request);
|
|
|
if (! $user) {
|
|
|
- return $this->error(__('auth.failed'), 401, 401);
|
|
|
+ return $this->error(__('auth.failed'), null, 401);
|
|
|
}
|
|
|
- if (! StudioApi::userCanManage($user['user_uid'], $aiModel->owner_id)) {
|
|
|
- return $this->error(__('auth.failed'), 403, 403);
|
|
|
+ if (! AiModelController::canEdit($user['user_uid'], $aiModel->owner_id)) {
|
|
|
+ return $this->error(__('auth.failed'), null, 403);
|
|
|
}
|
|
|
|
|
|
+ $token = AuthService::getUserToken($aiModel->uid);
|
|
|
+ if (! $token) {
|
|
|
+ return $this->error('ai model not found', null, 404);
|
|
|
+ }
|
|
|
+
|
|
|
+ OpsLog::debug($user['user_uid'], [
|
|
|
+ 'action' => 'ai-model-token.issue',
|
|
|
+ 'model_uid' => $aiModel->uid,
|
|
|
+ 'model_name' => $aiModel->name,
|
|
|
+ ]);
|
|
|
+
|
|
|
return $this->ok([
|
|
|
- 'uid' => $aiModel->uid,
|
|
|
- 'name' => $aiModel->name,
|
|
|
- 'token' => AuthService::getUserToken($aiModel->uid),
|
|
|
+ 'uid' => $aiModel->uid,
|
|
|
+ 'name' => $aiModel->name,
|
|
|
+ 'token' => $token,
|
|
|
]);
|
|
|
}
|
|
|
```
|
|
|
|
|
|
+返回 `data: { uid, name, token }`。
|
|
|
+
|
|
|
+注意错误响应用的是 `$this->error($msg, null, $status)`。仓库里多数旧代码写成 `$this->error(__('auth.failed'), 401, 401)`,把状态码误当成了 `$data` 参数(`Controller::error(string $message, mixed $data, int $status)`),响应体里因此多一个 `"data": 401`。新代码不沿用这个写法。
|
|
|
+
|
|
|
要点:
|
|
|
-- 鉴权用 `StudioApi::userCanManage()` 而非 `AiModelController::canEdit()`,以支持 group studio;
|
|
|
-- 该 token 等价于一个 365 天的模型身份凭据,**应记入 ops 日志**(参考 `App\Tools\OpsLog`);
|
|
|
-- 建议后续支持 `?ttl=` 缩短有效期(当前 `getUserToken` 硬编码 365 天,`AuthService.php:21`)。
|
|
|
+- 鉴权用 `AiModelController::canEdit()`(仅 owner 本人)。**依据 §9 决策 1:模型记录只挂个人 studio**,不支持 group studio,因此无需 `StudioApi::userCanManage()`。与 `show/update/destroy` 口径一致;
|
|
|
+- 该 token 是模型身份凭据,签发与撤销都**记入 ops 日志**(`App\Tools\OpsLog`,action 为 `ai-model-token.issue` / `ai-model-token.revoke`);
|
|
|
+- **有效期 30 天**:`AuthService::AI_MODEL_TOKEN_TTL`。人类登录 token 的 365 天不变(`USER_TOKEN_TTL`),两者分开是因为模型 token 要落到外部客户端的凭据文件里,泄漏面大得多;
|
|
|
+- **撤销机制**(推翻 §9 决策 2):`ai_models.token_version` 自增即作废该模型全部已签出 token。
|
|
|
+ - 签发时 payload 带 `typ: "ai-model"` + `ver`;
|
|
|
+ - `AuthService::current()` 只对带 `typ` 的 token 查一次 `token_version` 比对,人类 token 不额外查库;
|
|
|
+ - 迁移 `2026_08_05_110345_add_token_version_to_ai_models_table`,默认值 1。
|
|
|
+
|
|
|
+**兼容性**:引入版本号之前签出的模型 token(无 `typ`/`ver`,payload 里 `id` 恒为 0)一律失效。名义上是破坏性变更,实际破坏面为零——本轮改动尚未部署,线上没有任何存量模型凭据;仓库内部的 `ai-translate`、`app/Console/Commands/*`、`AiTaskPrepare` 都是每次任务现签现用。人类 token 的 `id` 是 `user_infos.id`(≥1),不受影响。
|
|
|
|
|
|
### 5.2 修补(P1,强烈建议)
|
|
|
|
|
|
-| # | 位置 | 问题 | 建议 |
|
|
|
-|---|---|---|---|
|
|
|
-| a | `AiModelController::show()` (`:102`) | **完全没有鉴权**,任何人可读任意模型 | 加 `AuthService::current` + 权限判定 |
|
|
|
-| b | `AiModelResource::toArray()` | `parent::toArray()` 把 `key`(第三方 API key)原样返回 | 白名单字段,移除 `key` / `system_prompt`;仅 owner 请求时才附带 |
|
|
|
-| c | `AiModelController::index()` (`:29-45`) | 非法 `view` → `$table` 未定义 → 500 | `default:` 分支返回 400 |
|
|
|
-| d | `AiModelController::store()` (`:75`) | 不接受完整字段;无重名校验 | 接受 `model/url/key/privacy/description`;同 studio 内 `name` 唯一(`firstOrCreate` + 唯一索引) |
|
|
|
-| e | `AiModelController::update()` (`:125`) | 未提供字段被置 null | 改用 `$request->filled()` / `only()` 增量更新 |
|
|
|
-| f | `AccessTokenController::store()` (`:78`) | 签出的 token **永不过期** | payload 注入 `exp`(如 7 天),并在 `UserCanEdit` 中校验 |
|
|
|
-| g | `AiModelController` 的 `Store/UpdateAiModelRequest` | `rules()` 为空,无任何校验 | 补 `name` 必填、`privacy` in 枚举等 |
|
|
|
+| # | 状态 | 位置 | 问题 | 建议 |
|
|
|
+|---|---|---|---|---|
|
|
|
+| a | ✅ 已修 | `AiModelController::show()` (`:103`) | **完全没有鉴权**,任何人可读任意模型 | 已加 `AuthService::current` + `canEdit` 判定(见下方遗留项) |
|
|
|
+| b | ✅ 已修 | `AiModelResource::toArray()` | `parent::toArray()` 把 `key`(第三方 API key)原样返回 | 改为字段白名单;`key` / `system_prompt` 仅 owner 请求时附带 |
|
|
|
+| c | ⬜ 待修 | `AiModelController::index()` | 非法 `view` → `$table` 未定义 → 500 | `default:` 分支返回 400 |
|
|
|
+| d | ✅ 已修 | `AiModelController::store()` | 不接受完整字段;无重名校验 | 已接受 `model/url/key/privacy/description`;同 studio 内重名返回 409 |
|
|
|
+| e | ✅ 已修 | `AiModelController::update()` | 未提供字段被置 null | 改为按 `$request->has()` 增量更新 |
|
|
|
+| f | ✅ 已修 | `AccessTokenController::store()` | 签出的 token **永不过期** | payload 注入 `exp`(7 天);`UserCanEdit` 捕获解码异常 |
|
|
|
+| g | ✅ 已修 | `AiModelController` 的 `Store/UpdateAiModelRequest` | `rules()` 为空,无任何校验 | 已补 `name` 必填、`privacy` 枚举、各字段长度上限 |
|
|
|
+
|
|
|
+(g) 本属 P2,但 (d) 的重名校验依赖 `name` 必填,只好一并做掉。剩下的 (c) 与 Skill 流程无关,留在 P2。
|
|
|
+
|
|
|
+#### (b) 的实施要点
|
|
|
+
|
|
|
+不能简单删掉 `key` / `system_prompt`:dashboard 的模型编辑页(`AiModelEdit.tsx`)靠 `GET /v2/ai-model/{uid}` 回填这两个字段,删了会导致用户一保存就把 key 清空。故按请求者是否 owner 分别返回。
|
|
|
+
|
|
|
+真正的泄漏面其实比 (a) 大得多:`index()` 的 `view=all` / `view=usable` 分支对**任何登录用户**返回全部模型记录,key 就在里面——(a) 只堵住了 `show` 一个口子。
|
|
|
+
|
|
|
+`isRequestedByOwner()` 的结果按请求缓存在 `$request->attributes` 上:`index()` 一次最多返回 1000 行,逐行解一次 JWT 不可接受。
|
|
|
+
|
|
|
+#### (f) 的连带改动
|
|
|
+
|
|
|
+给 access token 加上 `exp` 之后,`SentenceController::UserCanEdit()` 里的 `JWT::decode()` 会在 token 过期时抛 `ExpiredException`。原代码没有 try/catch,**过期 token 会变成 500 而不是 403**。已补捕获,并顺带处理了 `AccessToken` 查不到记录时 `new Key(null)` 抛异常的情况。
|
|
|
+
|
|
|
+#### (a) 的遗留项
|
|
|
|
|
|
-**(b) 和 (f) 是真实的安全问题,应优先于 Skill 本身完成。**
|
|
|
+当前实现:
|
|
|
+
|
|
|
+```php
|
|
|
+public function show(Request $request, AiModel $aiModel)
|
|
|
+{
|
|
|
+ $user = AuthService::current($request);
|
|
|
+ if (! $user) {
|
|
|
+ return $this->error(__('auth.failed'), 401, 401);
|
|
|
+ }
|
|
|
+ if (! self::canEdit($user['user_uid'], $aiModel->owner_id)) {
|
|
|
+ return $this->error(__('auth.failed'), 403, 403);
|
|
|
+ }
|
|
|
+
|
|
|
+ return $this->ok(new AiModelResource($aiModel));
|
|
|
+}
|
|
|
+```
|
|
|
+
|
|
|
+1. ~~**签名缺 `$request`**~~——已修复:初版方法体用了 `$request` 但参数列表没有它,该端点必然 500;现已补上 `Request $request`(路由模型绑定不受影响,Laravel 按类型而非位置注入)。
|
|
|
+
|
|
|
+2. ~~**鉴权口径**~~——已定:§9 决策 1 选个人 studio,`canEdit()` 就是正确口径,与 `token()` / `update()` / `destroy()` 一致,无需改。副作用是 `privacy = public` 的模型对非 owner 也不可读;这与 `index()` 的 `view=usable`(返回 public 模型)不一致,但由于 (b) 落地后 `index` 不再泄漏敏感字段,且 Skill 只读自己的模型,暂不处理。
|
|
|
+
|
|
|
+3. **对 Skill 的影响**——§6.3 第 3 步 `POST` 之后如需回读,以及任何走 `GET /v2/ai-model/{uid}` 的路径,现在都必须带 userToken;沿用 §6.3 的 `view=studio` 列表比对方式则不受影响。
|
|
|
|
|
|
---
|
|
|
|
|
|
@@ -239,17 +312,65 @@ public function token(Request $request, AiModel $aiModel): JsonResponse
|
|
|
|
|
|
### 6.1 目录结构
|
|
|
|
|
|
+**开发地点:本仓库。分发方式:整目录复制。**(§9 决策 4)
|
|
|
+
|
|
|
+理由:API 尚不完善,Skill 与服务端要同步改(§5 的每一项都会反映到 `references/api.md`)。放在 mint 仓库内,一次提交就能同时改 Laravel 代码和 Skill 契约;独立仓库会让两者版本漂移,且改 API 时无法在同一个 Claude Code 会话里读写后端代码。
|
|
|
+
|
|
|
+放在仓库根的 `.claude/skills/` 下:
|
|
|
+
|
|
|
```
|
|
|
-wikipali-write/
|
|
|
+.claude/skills/wikipali-write/
|
|
|
├── SKILL.md # 触发条件 + 流程说明(给模型读)
|
|
|
+├── VERSION # 日历版本号,随 API 契约变更递增
|
|
|
├── references/
|
|
|
│ └── api.md # 本文 §2 的精简版:端点、字段、陷阱
|
|
|
-└── scripts/
|
|
|
- ├── wp_login.py # 交互式登录,仅此脚本接触密码
|
|
|
- └── wp.py # 客户端:ensure-model / grant / write
|
|
|
+├── scripts/
|
|
|
+│ ├── wp_login.py # 交互式登录,仅此脚本接触密码
|
|
|
+│ └── wp.py # 客户端:ensure-model / channels / grant / write
|
|
|
+└── install.sh # 复制自身到目标项目或 ~/.claude/skills/
|
|
|
```
|
|
|
|
|
|
-选 Python 是为了与既有 `ai-translate` 保持一致(同一套请求格式,便于复用与对照)。
|
|
|
+注意放在**仓库根**而非 `api-v13/.claude/skills/`:后者已有 `laravel-best-practices` 等目录级 skill,只在编辑 `api-v13/` 下文件时激活;而本 Skill 是对线上 API 的客户端操作,与当前编辑哪个子目录无关。
|
|
|
+
|
|
|
+### 6.1.1 可分发性约束
|
|
|
+
|
|
|
+「能复制给别的项目用」是硬需求,因此以下几条是**约束而非偏好**:
|
|
|
+
|
|
|
+1. **目录自包含**——不引用 `.claude/skills/wikipali-write/` 之外的任何路径。SKILL.md 里不能出现 `api-v13/...` 这类仓库内引用;需要的 API 事实全部落在 `references/api.md` 里。
|
|
|
+2. **零安装依赖,只用 Python 标准库**——用 `urllib.request` 而非 `requests`,`json` / `getpass` / `argparse` 均为内置。**不跟随 `ai-translate` 的 venv + `pip install -e` 模式**(`ai-translate/pyproject.toml` 依赖 `pika`/`requests`/`redis`/`openai`):那套在目标项目里要求用户先建虚拟环境,与「复制即用」冲突。代价是要自己处理 `urllib` 的 HTTPError/超时/JSON 编码,比 `requests` 啰嗦,但换来 `python3 scripts/wp.py` 开箱可跑。
|
|
|
+3. **API 地址不硬编码**——见 §6.1.2。复制到别的项目后无需改代码。
|
|
|
+4. **凭据与 Skill 解耦**——凭据在 `~/.wikipali/`(§6.2),多个项目里的 Skill 副本共用同一份登录态,登录一次即可。
|
|
|
+
|
|
|
+依然选 Python(而非 shell)是为了与 `ai-translate` 的请求语义保持一致,便于对照排查。
|
|
|
+
|
|
|
+### 6.1.2 多站点:四个地址,一套数据
|
|
|
+
|
|
|
+线上四个站点**共享同一个数据库和同一把 `jwt_secrets_key`**,区别只有两个维度(2026-08-05 用户确认):
|
|
|
+
|
|
|
+| api_url | 域名 | 代码版本 |
|
|
|
+|---|---|---|
|
|
|
+| `https://www.wikipali.org/api` | .org | 稳定版 |
|
|
|
+| `https://www.wikipali.cc/api` | .cc | 稳定版 |
|
|
|
+| `https://next.wikipali.org/api` | .org | 最新版 |
|
|
|
+| `https://next.wikipali.cc/api` | .cc | 最新版 |
|
|
|
+| `http://127.0.0.1:8000/api` | 开发机 | 工作副本 |
|
|
|
+
|
|
|
+- `.org` / `.cc` —— 地区可达性,用户按网络情况选;
|
|
|
+- `www` / `next` —— **代码版本**,不是数据环境。`next` 跑最新版,出问题可随时降级到 `www`,数据不受影响。
|
|
|
+
|
|
|
+因此 Skill **不需要「按站点分桶」这类结构**——四个地址在数据上是同一个后端:
|
|
|
+
|
|
|
+1. **线上凭据只有一份**。四个地址通用:userToken / modelToken 用同一把密钥签;channel access token 的密钥存在同一张 `access_tokens` 表里;`ai_models` 也是同一张表,模型 uid 在四个地址上都是同一个。§6.2 的凭据文件退化为 `{ online: {...}, local: {...} }` 两桶——`local` 单独一桶是因为开发机是另一个库、另一把密钥。
|
|
|
+2. **任意切换,不需要重新登录、不需要重跑 ensure-model、不需要重签 access token**。换地址只是换一条网络路径 + 换一版服务端代码。
|
|
|
+3. **允许自动 fallback**。四个线上地址之间连不通就换下一个是安全的(同一套数据)。顺序建议:用户选定的 → 同版本的另一域名 → 另一版本的同域名。**唯独不能自动回退到 `local`**,那是另一套库。
|
|
|
+4. **真正的风险不是写错库,是写到不同版本的代码上**。`next` 与 `www` 的 API 契约可能不一致:新端点、新字段、新校验会先上 `next`,`www` 落后一段时间。所以:
|
|
|
+ - Skill 依赖的新端点(如 §2.3b 的 `DELETE /v2/ai-model-token/{uid}`)在 `www` 上可能还是 404,遇到 404 要提示「当前站点代码版本较旧,请切到 next 或稍后再试」,而不是当成「模型不存在」;
|
|
|
+ - `references/api.md` 记录的契约以 **`www`(稳定版)** 为准,`next` 独有的能力标注出来。Skill 默认连 `www`。
|
|
|
+5. **写入前仍要回显 api_url**,但理由变了:不是怕写错库(写不错),而是出问题时要知道是哪一版代码写的。
|
|
|
+
|
|
|
+地址来源优先级:`--api` 参数 > `WIKIPALI_API_URL` 环境变量 > 凭据文件里的 `online.api_url`(上次选定的)> 默认 `https://www.wikipali.org/api`。开发机地址不做特殊照顾——`http://` 明文只在 `127.0.0.1` 放行,其余一律要求 `https://`。
|
|
|
+
|
|
|
+上表是**内置的已知站点清单**,与 §6.1.1 第 3 条(地址不硬编码)不冲突:清单只用于交互选择和 fallback 排序;`--api` / 环境变量给出的任意地址仍然接受,只是不在清单里的地址自成一桶,不与线上凭据互通。
|
|
|
|
|
|
### 6.2 凭据存储
|
|
|
|
|
|
@@ -257,19 +378,29 @@ wikipali-write/
|
|
|
|
|
|
```json
|
|
|
{
|
|
|
- "api_url": "https://.../api",
|
|
|
- "user": { "uid": "...", "username": "...", "token": "..." },
|
|
|
- "model": { "uid": "...", "name": "claude-opus-5", "token": "..." },
|
|
|
- "access_tokens": {
|
|
|
- "<channel_uid>": { "token": "...", "book": 0, "granted_at": "2026-08-03T10:00:00Z" }
|
|
|
+ "current": "online",
|
|
|
+ "online": {
|
|
|
+ "api_url": "https://www.wikipali.org/api",
|
|
|
+ "user": { "uid": "...", "username": "...", "token": "..." },
|
|
|
+ "model": { "uid": "...", "name": "claude-opus-5", "token": "..." },
|
|
|
+ "access_tokens": {
|
|
|
+ "<channel_uid>": { "token": "...", "book": 0, "granted_at": "2026-08-03T10:00:00Z" }
|
|
|
+ }
|
|
|
+ },
|
|
|
+ "local": {
|
|
|
+ "api_url": "http://127.0.0.1:8000/api",
|
|
|
+ "user": {}, "model": {}, "access_tokens": {}
|
|
|
}
|
|
|
}
|
|
|
```
|
|
|
|
|
|
+只有 `online` / `local` 两桶,理由见 §6.1.2:四个线上地址共享库与密钥,凭据通用。`online.api_url` 只记「上次选的是哪个地址」,换地区或在 www / next 之间切换就是改这一个字段,`user` / `model` / `access_tokens` 全部原样沿用。`local` 单独一桶是因为开发机是另一个库、另一把 `jwt_secrets_key`。
|
|
|
+
|
|
|
原则:
|
|
|
- **Claude 永不接触明文密码**。登录由用户自己执行 `python scripts/wp_login.py`(`getpass` 读取),或在 Claude Code 中用 `! python .../wp_login.py` 前缀运行;
|
|
|
- Skill 读取凭据文件时只取 token,不回显到对话中(日志里 token 一律打码);
|
|
|
-- 任一 token 收到 401 → 提示重新登录,而不是自动重试。
|
|
|
+- 任一 token 收到 401 → 提示重新登录,而不是自动重试;
|
|
|
+- 缓存的 `model.token` 有效期只有 30 天,且可能被 owner 主动撤销(§2.3b)。两种情况的表现都是 401,处理一致:重跑 §6.3 第 4 步重取,仍 401 才提示重新登录。
|
|
|
|
|
|
### 6.3 幂等 model 记录
|
|
|
|
|
|
@@ -278,61 +409,140 @@ wikipali-write/
|
|
|
1. `GET /v2/ai-model?view=studio&name={username}&keyword={modelName}`;
|
|
|
2. 在 `rows` 中做 `name` **精确**比对;
|
|
|
3. 命中 → 用其 `uid`;未命中 → `POST` 创建,再 `PUT` 补齐 `model` / `privacy` 等字段(在 §5.2d 落地前必须这么做两步);
|
|
|
-4. `GET /v2/ai-model/{uid}/token` 取 modelToken,写入凭据文件缓存。
|
|
|
+4. `GET /v2/ai-model-token/{uid}` 取 modelToken,写入凭据文件缓存。
|
|
|
|
|
|
-### 6.4 写入前的确认
|
|
|
+### 6.4 channel 的交互式选择
|
|
|
+
|
|
|
+§9 决策 3:**不要求用户手工提供 channel uid**,由 Skill 列出可编辑 channel 供选择。
|
|
|
+
|
|
|
+接口:`GET /v2/channel?view=user-edit`(Bearer = **用户 token**)
|
|
|
+
|
|
|
+```
|
|
|
+ChannelController::index() 的 'user-edit' 分支:
|
|
|
+ ShareApi::getResList(user_uid, 2) 中 power >= 20 的 res_id
|
|
|
+ ∪ owner_uid == user_uid 的 channel
|
|
|
+```
|
|
|
+
|
|
|
+返回列 `uid / name / summary / type / owner_uid / lang / status / is_system / updated_at / created_at`。
|
|
|
+
|
|
|
+流程:
|
|
|
+1. 用户未指定 channel → `GET /v2/channel?view=user-edit`,按 `updated_at` 倒序展示 `name` + `lang` + `uid` 前 8 位;
|
|
|
+2. 用户从中选择(或用 `name` 模糊匹配后确认);
|
|
|
+3. 选定的 uid 进入 §3 流程的 `access-token` 签发步骤。
|
|
|
+
|
|
|
+要点:
|
|
|
+- 该 view **已经是「可编辑」语义**,与 `access-token` 签发用的 `ChannelApi::userCanEdit()` 是同一套 share 权限(power ≥ 20),所以列表里的 channel 正常都能签出 token。但两者代码路径不同,仍须按 §6.6 判 `count: 0`;
|
|
|
+- 若用户显式给了 uid,跳过列表直接用,但仍应 `GET /v2/channel/{uid}` 回显 name 供 §6.5 确认——避免写错 channel;
|
|
|
+- 列表为空 → 明确提示「当前账号没有任何可编辑的 channel」,而不是继续走签发流程。
|
|
|
+
|
|
|
+### 6.5 写入前的确认
|
|
|
|
|
|
写库属于对外的、不易回滚的操作。Skill 必须在 `POST /v2/sentence` 之前:
|
|
|
-- 展示:目标 channel(uid + name)、book、句子条数、前若干条的 `id` 与 content 摘要;
|
|
|
+- 展示:**当前 api_url**(§6.1.2:四个线上地址写的是同一个库,但代码版本不同,出问题时要知道是哪一版写的)、目标 channel(uid + name)、book、句子条数、前若干条的 `id` 与 content 摘要;
|
|
|
- 明确提示「已存在的相同句子将被覆盖」(`firstOrNew` 语义);
|
|
|
- 取得用户确认后才发送。批量写入建议分批(如每批 50 条)并报告累计 `count`。
|
|
|
|
|
|
-### 6.5 错误处理约定
|
|
|
+### 6.6 错误处理约定
|
|
|
|
|
|
| 现象 | 含义 | 处置 |
|
|
|
|---|---|---|
|
|
|
-| 401 | token 失效/过期 | 引导重新登录,勿自动重试 |
|
|
|
+| 401 | token 失效/过期,或把 `local` 的 token 发到了线上(§6.1.2;四个线上地址之间不会出这个问题) | 引导重新登录,勿自动重试 |
|
|
|
| 403 | 无 channel 编辑权,或非 studio owner | 明确指出缺哪一项权限 |
|
|
|
| `access-token` 返回 `count: 0` | 用户对该 channel 无编辑权(被静默跳过) | 当作 403 报错,**不可继续写入** |
|
|
|
| `sentence` 返回 `count` 小于提交条数 | 部分句子鉴权失败被 `continue` 跳过 | 逐条对比返回的 rows,报告被跳过的句子 id |
|
|
|
| `no date` (200) | 请求缺 `sentences` 字段 | 客户端 bug |
|
|
|
+| 新端点 404(如 §2.3b 的 `DELETE /v2/ai-model-token/{uid}`) | 当前站点跑的是稳定版代码,该端点尚未上线(§6.1.2-4) | 提示切到 `next` 或稍后再试,**不要**当成「资源不存在」 |
|
|
|
|
|
|
注意 `SentenceController::store()` 对**逐句失败是静默跳过**的(`:341`),所以「HTTP 200」不等于「全部写入成功」,必须核对 `count`。
|
|
|
|
|
|
+### 6.7 打包与分发
|
|
|
+
|
|
|
+目标:Skill 在本仓库调通后,能整体复制给其他项目使用,且不脱离本仓库维护。
|
|
|
+
|
|
|
+**安装方式**——`install.sh` 把自身目录复制到目标位置:
|
|
|
+
|
|
|
+```bash
|
|
|
+# 装到某个项目(项目级,只在该项目激活)
|
|
|
+.claude/skills/wikipali-write/install.sh ~/work/other-project
|
|
|
+
|
|
|
+# 装到用户级(所有项目可用)
|
|
|
+.claude/skills/wikipali-write/install.sh --user
|
|
|
+```
|
|
|
+
|
|
|
+行为:`cp -r` 自身到 `<target>/.claude/skills/wikipali-write/` 或 `~/.claude/skills/wikipali-write/`;若目标已存在,比对 `VERSION` 并要求 `--force` 才覆盖。不复制 `install.sh` 以外的任何仓库文件。
|
|
|
+
|
|
|
+**版本与漂移**——`VERSION` 用日历版本(如 `2026.08.04`),**在每次 API 契约变更时递增**(§5 的任何一项落地都算)。副本里的 `VERSION` 是判断「这份拷贝是否过期」的唯一依据。
|
|
|
+
|
|
|
+这是 copy-based 分发的固有代价:**副本不会自动更新**。API 一改,各处副本就静默过期,直到用户重新 `install.sh --force`。缓解手段:
|
|
|
+
|
|
|
+- `wp.py` 启动时调 `GET /v2/auth/current`(或任一轻量端点),若服务端返回的错误形态与 `references/api.md` 记录的不符,提示「Skill 可能已过期,请重新安装」;
|
|
|
+- 不做自动更新——那需要副本知道 mint 仓库的位置,违反 §6.1.1 的自包含约束。
|
|
|
+
|
|
|
+**先后顺序**:先在本仓库把流程跑通(P1 全部完成),再写 `install.sh`。过早打包会把未定型的 API 契约固化到副本里。
|
|
|
+
|
|
|
---
|
|
|
|
|
|
## 7. 安全考量
|
|
|
|
|
|
1. **权限不放大**:AI 模型自身不是任何 channel 的 owner/协作者,其全部写权限来自用户签发的 access token,且受 book 范围限制。用户无权的 channel,签发阶段就会失败。
|
|
|
-2. **模型 token 是长效凭据**(365 天)。落地 §5.1 时应记 ops 日志;后续支持可撤销(例如在 `ai_models` 上加 `token_version` 参与 JWT payload,改版即失效)。
|
|
|
-3. **access token 永不过期**是当前实现的既有风险(§5.2f)。在修复前,Skill 应把它视为高敏感数据,仅存本地、不进日志、不进对话。
|
|
|
+2. **模型 token 有效期 30 天且可撤销**(§5.1):泄漏时 owner 调 `DELETE /v2/ai-model-token/{uid}` 即可让该模型全部已签出 token 立刻失效,不必轮换全局 `jwt_secrets_key`(那会踢掉所有用户)。撤销是全量的,不能只废一张。`~/.wikipali/credentials.json` 仍须 `0600`、不进日志/不进对话——撤销是止损手段,不是防线。
|
|
|
+3. **access token 有效期 7 天**(§5.2f 已修)。注意签名密钥是 `access_tokens` 表里按 `res_type + res_id` 存的 uuid,`firstOrNew` 只在首次创建,**同一 channel 的密钥不轮换**——所以 7 天只限制单张 token 的窗口,没有「立即吊销」能力。Skill 仍应把它视为高敏感数据,仅存本地、不进日志、不进对话。
|
|
|
4. **密码零留存**:不写入任何文件,不进入对话上下文。
|
|
|
5. **审计**:所有写入都会进 `sent_history`(`SentenceService::saveHistory`),`editor_uid` 为模型 uid,可追溯。
|
|
|
+6. **部署前提**:`token_version` 的校验在 `AuthService::current()` 里,故撤销只被跑了该版本代码的站点认账。本轮改动尚未部署到任何服务器,部署时四个站点一起上即可,不存在版本差窗口。若日后单独灰度某个站点,需记得这条。
|
|
|
|
|
|
---
|
|
|
|
|
|
## 8. 实施计划
|
|
|
|
|
|
-| 阶段 | 内容 | 依赖 |
|
|
|
-|---|---|---|
|
|
|
-| P0 | 服务端:新增 `GET /v2/ai-model/{uid}/token` + 测试 | — |
|
|
|
-| P0 | 服务端安全修补:§5.2 (a)(b)(f) | — |
|
|
|
-| P1 | Skill:`wp_login.py` + 凭据存储 + `auth/current` 校验 | P0 |
|
|
|
-| P1 | Skill:`ensure-model`(查/建/补字段/取 token) | P0 |
|
|
|
-| P1 | Skill:`grant`(签 access token,缓存) | — |
|
|
|
-| P1 | Skill:`write`(分批 + 确认 + count 核对) | 以上全部 |
|
|
|
-| P2 | 服务端质量修补:§5.2 (c)(d)(e)(g) | — |
|
|
|
-| P2 | Skill 扩展:读取能力(`GET /v2/sentence`、channel 列表、`sentences-in-chapter`)与 `sentpr` PR 提交 | P1 |
|
|
|
+| 阶段 | 状态 | 内容 | 依赖 |
|
|
|
+|---|---|---|---|
|
|
|
+| P0 | ✅ | 服务端:新增 `GET /v2/ai-model-token/{uid}`(`AiModelTokenController::show`,用 `canEdit`)+ 测试 | — |
|
|
|
+| P0 | ✅ | 服务端安全修补:§5.2 (a)(b)(f) | — |
|
|
|
+| P0 | ✅ | 服务端:§5.2 (d)(e)(g) —— 从 P2 上提,否则 §6.3 的「POST 建档再 PUT 补字段」会被 (e) 的 null 覆盖打断 | — |
|
|
|
+| P0 | ✅ | 服务端:模型 token TTL 收到 30 天 + `token_version` 撤销机制 + `DELETE /v2/ai-model-token/{uid}`(推翻 §9 决策 2)| — |
|
|
|
+| P1 | ⬜ | Skill:`wp_login.py` + 凭据存储 + `auth/current` 校验 | P0 |
|
|
|
+| P1 | ⬜ | Skill:`ensure-model`(查/建/补字段/取 token) | P0 |
|
|
|
+| P1 | ⬜ | Skill:`channels`(`view=user-edit` 列表 + 交互选择) | P0 |
|
|
|
+| P1 | ⬜ | Skill:`grant`(签 access token,缓存) | `channels` |
|
|
|
+| P1 | ⬜ | Skill:`write`(分批 + 确认 + count 核对) | 以上全部 |
|
|
|
+| P2 | ⬜ | 服务端质量修补:§5.2 (c) | — |
|
|
|
+| P1 | ⬜ | Skill:`install.sh` + `VERSION`(打包分发,§6.7) | P1 全部跑通 |
|
|
|
+| P2 | ⬜ | Skill 扩展:读取能力(`GET /v2/sentence`、`sentences-in-chapter`)与 `sentpr` PR 提交 | P1 |
|
|
|
+
|
|
|
+(d)(e) 上提到 P0 的理由:§6.3 第 3 步在 (d) 落地前必须走「POST 创建 → PUT 补齐字段」两步,而 (e) 未修时那个 PUT 会把未传字段一律置 null,两个缺陷叠加使 ensure-model 无法可靠工作。
|
|
|
|
|
|
测试要求(`api-v13` 使用 Pest):
|
|
|
-- Feature test 覆盖新端点的 401 / 403 / 200 三条路径;
|
|
|
-- 一条端到端 test:登录 → 建模型 → 取 model token → 签 access token → 写句子 → 断言 `editor_uid == 模型 uid`。
|
|
|
+- ✅ Feature test 覆盖新端点的 401 / 403 / 200 / 404 四条路径(`tests/Feature/AiModelTokenTest.php`);
|
|
|
+- ✅ `AiModelResourceTest` 断言 key / system_prompt 不外泄、owner 仍可取;
|
|
|
+- ✅ `AiModelCrudTest` 断言 store 全字段、重名 409、update 增量不清空;
|
|
|
+- ✅ `AccessTokenExpiryTest` 断言签出的 token 带 `exp` 且无权时 `count: 0`;
|
|
|
+- ⬜ 端到端 test:登录 → 建模型 → 取 model token → 签 access token → 写句子 → 断言 `editor_uid == 模型 uid`(留待 Skill 落地时补,需要 sentences / pali_texts 等一批表的夹具)。
|
|
|
+
|
|
|
+#### 测试环境
|
|
|
+
|
|
|
+迁移文件含 Postgres 专有语句(`CREATE EXTENSION "uuid-ossp"`、`enum` 列等),**无法在 sqlite 上跑**,所以 `phpunit.xml` 里 `DB_CONNECTION=pgsql`、`DB_DATABASE=mint_test`。
|
|
|
+
|
|
|
+`RefreshDatabase` 会清空目标库,**测试库必须与开发库 `visuddhinanda_20260311` 严格分离**——后者装着完整生产数据集(`sentences` 3.3 GB、`sent_sims` 3.6 GB)。库名写死在 `phpunit.xml` 里正是为了不让它跟着 `.env` 漂移。
|
|
|
+
|
|
|
+数据库需由具备 `CREATEDB` 权限的角色创建(应用角色 `www` 没有该权限):
|
|
|
+
|
|
|
+```bash
|
|
|
+sudo -u postgres createdb -O www mint_test
|
|
|
+```
|
|
|
|
|
|
---
|
|
|
|
|
|
-## 9. 待确认问题
|
|
|
+## 9. 决策记录
|
|
|
+
|
|
|
+四个待确认问题已于 2026-08-04 定案:
|
|
|
+
|
|
|
+| # | 问题 | 决策 | 影响 |
|
|
|
+|---|---|---|---|
|
|
|
+| 1 | 模型记录挂个人还是 group studio | **个人 studio** | §5.1 用 `canEdit()`,不引入 `StudioApi::userCanManage`;(a) 的遗留项 2 关闭 |
|
|
|
+| 2 | 是否提供 token 撤销机制 | ~~不做~~ → **2026-08-05 推翻,改为做** | 加 `ai_models.token_version`,模型 token payload 增 `typ`/`ver`,TTL 从 365 天收到 30 天;旧模型 token 全部失效(见 §5.1、§7-2) |
|
|
|
+| 3 | channel uid 如何获取 | **Skill 交互式选择** | 用 `GET /v2/channel?view=user-edit`,见 §6.4 |
|
|
|
+| 4 | Skill 分发形态 | **在本仓库开发,以复制方式分发**;不做独立仓库 | 放仓库根 `.claude/skills/wikipali-write/`,目录自包含、零依赖,可整体复制到其他项目;见 §6.1、§6.7 |
|
|
|
+| 5 | 多站点(4 个线上 + 开发机)如何处理 | 四个线上地址**共享库与 `jwt_secrets_key`**,凭据只存一份(`online` / `local` 两桶),可任意切换与自动 fallback(2026-08-05 补) | 见 §6.1.2、§6.2。`.org`/`.cc` 是地区,`www`/`next` 是**代码版本**不是数据环境;随之而来的是 API 契约版本差,见 §6.1.2-4 |
|
|
|
|
|
|
-1. 模型记录挂在**个人 studio** 还是**共享 group studio** 下?前者简单(现有 `canEdit` 即可),后者更适合团队复用,但需要 §5.1 采用 `StudioApi::userCanManage`。
|
|
|
-2. 是否需要为「AI 模型」提供撤销 token 的机制?若需要,应在 P0 就把 `token_version` 设计进去,避免后续 JWT 结构变更。
|
|
|
-3. channel uid 的获取方式:用户直接提供,还是由 Skill 通过 `GET /v2/channel`(列出可编辑 channel)交互式选择?后者体验更好,需先确认该接口的 `view` 参数取值。
|
|
|
-4. Skill 的分发形态:随本仓库发布,还是独立成一个可 `git clone` 到 `~/.claude/skills/` 的仓库?
|
|
|
+决策 2 原本是「不做」,理由是省掉 `token_version` 可以不动 `ai_models` 表结构、不改 `getUserToken` 的 payload。2026-08-05 推翻:趁 Skill 尚未分发、代码尚未部署、外面一份真实凭据都没有的时候补,代价最小;再往后每多一份副本,「已签出 token 全部失效」的破坏面就大一分。
|