目标:提供一个可复用的 Claude Code Skill,使任意外部项目都能通过 Claude Code 以「AI 模型身份」把句子写入 WikiPali 数据库(
SentenceController),并保持正确的作者署名与权限边界。状态:设计草案(未实施) 对应后端:
api-v13(Laravel 13,路由前缀/api/v2)
现状下,只有仓库内部的组件(ai-translate Python worker、app/Console/Commands/*、app/Services/AIAssistant/*)能以 AI 身份写入句子库,因为它们能直接调用 AuthService::getUserToken() 生成模型身份 token。外部项目无此能力。
本 Skill 要达成:
editor_uid 为 AI 模型的 uid(而非操作者本人),保证署名与审计正确;access_token 中的 book 范围约束;以下均已对照源码核实。基址记为 {API},形如 https://host/api(参见 ai-translate/config.orig.toml 的 api-url)。
POST {API}/v2/sign-in
{ "username": "<用户名或邮箱>", "password": "<明文密码>" }
返回 { "ok": true, "data": "<JWT 字符串>", "message": "" }。
AuthController::signIn()(app/Http/Controllers/AuthController.php:70){nbf, exp, uid: userid, id: 主键 id},有效期 365 天(AuthController.php:84)AuthService::current()(app/Services/AuthService.php:43),读取 Authorization: Bearer <token>GET {API}/v2/auth/current(Bearer)→ data: {id, nickName, realName, avatar, token, roles}。
其中 realName 即 user_info.username,后续 studio_name 参数要用它(AuthController.php:101)。
GET {API}/v2/ai-model?view=studio&name={studioName}&keyword={modelName}(Bearer)
AiModelController::index()(app/Http/Controllers/AiModelController.php:22)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}
AiModelController::store()(AiModelController.php:75)name / uid / real_name / owner_id / editor_id,不接受 model / url / key / privacy / descriptioncanEdit($user_uid, $studioId) 要求 user_uid === studioId,即只有个人 studio 可用,group studio 会 403(AiModelController.php:157)PUT {API}/v2/ai-model/{uid}(Bearer)
uid(AiModel::$primaryKey = 'uid')$request->input() 无保护地整体覆盖,未提供的字段会被置为 null(AiModelController.php:125-132)。客户端必须一次性提交全部字段AuthService::getUserToken(string $userUid)(app/Services/AuthService.php:14)会:
UserApi::getByUuid,查不到再查 AiAssistantApi::getByUuid(后者查 ai_models.uid);目前没有任何 HTTP 路由暴露它,调用点全在服务端内部(AiTaskPrepare.php:112、AiTranslateService.php:596、UpgradeAITranslation.php:108 等)。
这是本方案唯一的阻塞性缺口,必须新增端点。
POST {API}/v2/access-token(Bearer = 用户 token)
{ "payload": [ { "res_type": "channel", "res_id": "<channel uid>", "power": "edit", "book": 0 } ] }
返回 data: { rows: [ { payload, token } ], count }。
AccessTokenController::store()(app/Http/Controllers/AccessTokenController.php:33)ChannelApi::userCanEdit(user_uid, res_id),无权则该条被静默跳过(continue 2)→ rows 可能为空数组,客户端必须判空AccessToken.token(uuid)重复两次拼接,算法 HS512(AccessTokenController.php:78)exp,签出的 token 永不过期POST {API}/v2/sentence(Bearer = AI model token)
{
"sentences": [
{
"book_id": 1,
"paragraph": 10,
"word_start": 0,
"word_end": 12,
"channel_uid": "<channel uid>",
"content": "译文",
"content_type": "markdown",
"access_token": "<§2.4 签出的 JWT>"
}
]
}
返回 data: { rows: [SentResource...], count }。
SentenceController::store()(app/Http/Controllers/SentenceController.php:303)UserCanEdit()(SentenceController.php:272):
ShareApi::getResPower(...) >= 20 → 放行;AccessToken.token 重复两次作为密钥验签 access_token,并校验 book 范围。(book_id, paragraph, word_start, word_end, channel_uid) 做 firstOrNew,存在即更新,不存在则新建(天然幂等)sent_history、清 Redis 缓存、Mq::publish('progress', ...)channel / book / access_token 放在顶层,句子数组内不再重复(SentenceController.php:315-326)ai-translate/ai_translate/service.py:429⚠️ book 字段类型陷阱:校验用严格比较
if (isset($jwt->book) && $jwt->book !== 0 && $jwt->book !== $book),而 $book 已被 (int) 转换。
因此签发 access token 时 book 必须是整数(0 表示不限 book);写成 "1" 字符串会导致 "1" !== 1 恒真而被拒绝。
用户 Skill 脚本 API
| | |
|-- 交互式输入口令 --->| |
| |-- POST /v2/sign-in ------->|
| |<-- userToken (365d) -------|
| |-- GET /v2/auth/current --->| 取 realName 作为 studio_name
| | |
| |-- GET /v2/ai-model?view=studio&name=&keyword= -->
| |<-- rows(精确匹配 name)---|
| | 未命中 → POST /v2/ai-model → PUT /v2/ai-model/{uid}
| | |
| |-- GET /v2/ai-model/{uid}/token ★新增 -->
| |<-- modelToken (365d) ------|
|-- 提供 channel_id ->| |
| |-- POST /v2/access-token(Bearer=userToken)-->
| |<-- accessToken ------------|
| | |
| |-- POST /v2/sentence(Bearer=modelToken,句内带 accessToken)-->
| |<-- {rows, count} ----------|
三种 token 的职责必须区分清楚:
| Token | 签发者 | 作用 | 用在哪 |
|---|---|---|---|
| userToken | sign-in |
代表人类操作者 | 查/建 ai-model、签 access token |
| modelToken | 新增端点 | 代表 AI 模型身份 | 写句子时的 Authorization |
| accessToken | access-token |
委托 channel 编辑权给持有者 | 写句子时的 body 字段 |
可行。 五个步骤中四步已有现成 API,剩余一步需新增约 15 行后端代码。
必须做的服务端改动只有 §5.1 一项;其余为质量/安全修补,建议一并处理,因为 Skill 会高频调用这些接口,缺陷会被放大。
用 userToken 直接写句子,跳过 model token。代价:
editor_uid 变成人类用户,丧失 AI 署名与审计能力——这与本设计的核心目的冲突;可作为 Skill 的降级路径(--as-self),但不应是默认行为。
路由(routes/api.php v2 组内):
Route::get('ai-model/{ai_model}/token', [AiModelController::class, 'token']);
控制器(AiModelController):
/**
* 签发 AI 模型的身份 token,供外部客户端以模型身份写入数据。
*/
public function token(Request $request, AiModel $aiModel): JsonResponse
{
$user = AuthService::current($request);
if (! $user) {
return $this->error(__('auth.failed'), 401, 401);
}
if (! StudioApi::userCanManage($user['user_uid'], $aiModel->owner_id)) {
return $this->error(__('auth.failed'), 403, 403);
}
return $this->ok([
'uid' => $aiModel->uid,
'name' => $aiModel->name,
'token' => AuthService::getUserToken($aiModel->uid),
]);
}
要点:
StudioApi::userCanManage() 而非 AiModelController::canEdit(),以支持 group studio;App\Tools\OpsLog);?ttl= 缩短有效期(当前 getUserToken 硬编码 365 天,AuthService.php:21)。| # | 位置 | 问题 | 建议 |
|---|---|---|---|
| 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 枚举等 |
(b) 和 (f) 是真实的安全问题,应优先于 Skill 本身完成。
wikipali-write/
├── SKILL.md # 触发条件 + 流程说明(给模型读)
├── references/
│ └── api.md # 本文 §2 的精简版:端点、字段、陷阱
└── scripts/
├── wp_login.py # 交互式登录,仅此脚本接触密码
└── wp.py # 客户端:ensure-model / grant / write
选 Python 是为了与既有 ai-translate 保持一致(同一套请求格式,便于复用与对照)。
路径:~/.wikipali/credentials.json(不放在用户项目目录内,避免被误提交),权限 0600。
{
"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" }
}
}
原则:
python scripts/wp_login.py(getpass 读取),或在 Claude Code 中用 ! python .../wp_login.py 前缀运行;name 取当前模型标识(如 claude-opus-5),流程:
GET /v2/ai-model?view=studio&name={username}&keyword={modelName};rows 中做 name 精确比对;uid;未命中 → POST 创建,再 PUT 补齐 model / privacy 等字段(在 §5.2d 落地前必须这么做两步);GET /v2/ai-model/{uid}/token 取 modelToken,写入凭据文件缓存。写库属于对外的、不易回滚的操作。Skill 必须在 POST /v2/sentence 之前:
id 与 content 摘要;firstOrNew 语义);count。| 现象 | 含义 | 处置 |
|---|---|---|
| 401 | token 失效/过期 | 引导重新登录,勿自动重试 |
| 403 | 无 channel 编辑权,或非 studio owner | 明确指出缺哪一项权限 |
access-token 返回 count: 0 |
用户对该 channel 无编辑权(被静默跳过) | 当作 403 报错,不可继续写入 |
sentence 返回 count 小于提交条数 |
部分句子鉴权失败被 continue 跳过 |
逐条对比返回的 rows,报告被跳过的句子 id |
no date (200) |
请求缺 sentences 字段 |
客户端 bug |
注意 SentenceController::store() 对逐句失败是静默跳过的(:341),所以「HTTP 200」不等于「全部写入成功」,必须核对 count。
ai_models 上加 token_version 参与 JWT payload,改版即失效)。sent_history(SentenceService::saveHistory),editor_uid 为模型 uid,可追溯。| 阶段 | 内容 | 依赖 |
|---|---|---|
| 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 |
测试要求(api-v13 使用 Pest):
editor_uid == 模型 uid。canEdit 即可),后者更适合团队复用,但需要 §5.1 采用 StudioApi::userCanManage。token_version 设计进去,避免后续 JWT 结构变更。GET /v2/channel(列出可编辑 channel)交互式选择?后者体验更好,需先确认该接口的 view 参数取值。git clone 到 ~/.claude/skills/ 的仓库?