Просмотр исходного кода

feat: 术语支持 AI 模型带 access token 写入

术语此前只认 AuthService::current:AI 模型既不是 channel owner 也不在
share 表里,建/改术语必然 403;即便放行,editor_id 是 bigint 也装不下
模型的 uuid,署名会丢。现在与 sentences 走同一条链路。

- 把 SentenceController 的 UserCanEdit 原样抽成 ChecksChannelEditPower
  trait(owner → 协作者 → access token),两个控制器共用。术语没有 book
  概念,access token 的 book 一律按 0 判定。
- dhamma_terms 加 editor_uid(uuid),与 sentences.editor_uid 对齐;
  TermResource 优先按 uuid 解析署名,UserApi::getByUuid 会回落到 AI 模型。
  老数据该列为 null,仍按 editor_id 解析,不需要回填。

顺带修三个既有问题:

- store() 的 studio 分支从不校验 studioId 归属,任何登录用户都能往别人
  studio 名下写术语。
- update() 无条件赋值,客户端漏提一个字段就会把库里的 note/tag 清成
  null,还会把 create_time 刷成当前时刻。改为增量更新。
- destroy() 把 query builder 传给 deleteCache,取不到 word/channal,
  缓存实际没被清;find() 返回 null 时还会致命错误。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
visuddhinanda 1 неделя назад
Родитель
Сommit
756a88db92

+ 58 - 0
api-v13/app/Http/Controllers/Concerns/ChecksChannelEditPower.php

@@ -0,0 +1,58 @@
+<?php
+
+namespace App\Http\Controllers\Concerns;
+
+use App\Http\Api\ShareApi;
+use App\Models\AccessToken;
+use App\Models\Channel;
+use Firebase\JWT\JWT;
+use Firebase\JWT\Key;
+
+/**
+ * channel 编辑权的统一判定:owner → 协作者 → access token。
+ *
+ * 第三条分支是外部客户端(如 wikipali write Skill)唯一的入口:AI 模型
+ * 有自己的 uid,既不是 owner 也不在 share 表里,只能靠人类用户为它签出的
+ * access token 代持权限。
+ */
+trait ChecksChannelEditPower
+{
+    /**
+     * @param  string  $userId  当前身份的 uuid(人类用户或 AI 模型)
+     * @param  int  $book  本次写入涉及的 book;无 book 概念的资源传 0
+     * @param  string|null  $accessToken  由 AccessTokenController 签出的 JWT
+     */
+    protected function userCanEditChannel(string $userId, string $channelId, int $book, $accessToken = null): bool
+    {
+        $channel = Channel::where('uid', $channelId)->first();
+        if (! $channel) {
+            return false;
+        }
+        if ($channel->owner_uid !== $userId) {
+            // 判断是否为协作
+            $power = ShareApi::getResPower($userId, $channel->uid, 2);
+            if ($power < 20) {
+                // 判断token
+                if (! $accessToken) {
+                    return false;
+                }
+                $key = AccessToken::where('res_id', $channelId)->value('token');
+                if (! $key) {
+                    return false;
+                }
+                try {
+                    // access token 现在带 exp,过期会抛 ExpiredException;
+                    // 伪造/损坏的 token 同样抛异常。一律当作无权,不要冒泡成 500。
+                    $jwt = JWT::decode($accessToken, new Key($key.$key, 'HS512'));
+                } catch (\Exception $e) {
+                    return false;
+                }
+                if (isset($jwt->book) && $jwt->book !== 0 && $jwt->book !== $book) {
+                    return false;
+                }
+            }
+        }
+
+        return true;
+    }
+}

+ 56 - 21
api-v13/app/Http/Controllers/DhammaTermController.php

@@ -5,6 +5,7 @@ namespace App\Http\Controllers;
 use App\Http\Api\ChannelApi;
 use App\Http\Api\ShareApi;
 use App\Http\Api\StudioApi;
+use App\Http\Controllers\Concerns\ChecksChannelEditPower;
 use App\Http\Resources\TermResource;
 use App\Models\Channel;
 use App\Models\DhammaTerm;
@@ -18,6 +19,8 @@ use Illuminate\Support\Str;
 
 class DhammaTermController extends Controller
 {
+    use ChecksChannelEditPower;
+
     /**
      * Display a listing of the resource.
      *
@@ -38,6 +41,7 @@ class DhammaTermController extends Controller
             'channal',
             'owner',
             'editor_id',
+            'editor_uid',
             'created_at',
             'updated_at',
         ];
@@ -250,10 +254,15 @@ class DhammaTermController extends Controller
                 if (! $channelInfo) {
                     return $this->error('channel id failed');
                 } else {
-                    // 查看有没有channel权限
-                    $power = ShareApi::getResPower($user['user_uid'], $request->input('channel'), 2);
-                    if ($power < 20) {
-                        return $this->error(__('auth.failed'));
+                    // 查看有没有channel权限。术语没有 book 概念,access token 的
+                    // book 一律按 0(不限)判定。
+                    if (! $this->userCanEditChannel(
+                        $user['user_uid'],
+                        $request->input('channel'),
+                        0,
+                        $request->input('access_token')
+                    )) {
+                        return $this->error(__('auth.failed'), [], 403);
                     }
                     $term->owner = $channelInfo['studio_id'];
                     $term->language = $channelInfo['lang'];
@@ -264,13 +273,20 @@ class DhammaTermController extends Controller
                 } elseif ($request->has('studioName')) {
                     $studioId = StudioApi::getIdByName($request->input('studioName'));
                 }
-                if (Str::isUuid($studioId)) {
-                    $term->owner = $studioId;
-                } else {
+                if (! isset($studioId) || ! Str::isUuid($studioId)) {
                     return $this->error('not valid studioId');
                 }
+                // studio 级术语(不属于任何 channel)只能由 studio 本人建。
+                // 此前这里不校验归属,任何登录用户都能往别人 studio 名下写。
+                // access token 是 channel 级的,代持不了 studio 权限,所以
+                // AI 模型建 studio 级术语必然走到这里被拒——这是有意的。
+                if ($studioId !== $user['user_uid']) {
+                    return $this->error(__('auth.failed'), [], 403);
+                }
+                $term->owner = $studioId;
             }
             $term->editor_id = $user['user_id'];
+            $term->editor_uid = $user['user_uid'];
             $term->create_time = time() * 1000;
             $term->modify_time = time() * 1000;
             $term->save();
@@ -332,27 +348,37 @@ class DhammaTermController extends Controller
         }
 
         if (empty($dhammaTerm->channal)) {
-            // 查看有没有studio权限
+            // 查看有没有studio权限。access token 是 channel 级的,代持不了
+            // studio 权限,故 studio 级术语只有 owner 本人能改。
             if ($user['user_uid'] !== $dhammaTerm->owner) {
                 return $this->error(__('auth.failed'), [], 403);
             }
         } else {
-            // 查看有没有channel权限
-            $power = ShareApi::getResPower($user['user_uid'], $dhammaTerm->channal, 2);
-            if ($power < 20) {
+            // 查看有没有channel权限(owner / 协作者 / access token)
+            if (! $this->userCanEditChannel(
+                $user['user_uid'],
+                $dhammaTerm->channal,
+                0,
+                $request->input('access_token')
+            )) {
                 return $this->error(__('auth.failed'), [], 403);
             }
         }
 
-        $dhammaTerm->word = $request->input('word');
-        $dhammaTerm->word_en = Tools::getWordEn($request->input('word'));
-        $dhammaTerm->meaning = $request->input('meaning');
-        $dhammaTerm->other_meaning = $request->input('other_meaning');
-        $dhammaTerm->note = $request->input('note');
-        $dhammaTerm->tag = $request->input('tag');
-        $dhammaTerm->language = $request->input('language');
+        // 增量更新:只改提交上来的字段。此前这里无条件赋值,客户端漏提一个
+        // 字段就会把库里的 note/tag 等清成 null。
+        if ($request->has('word')) {
+            $dhammaTerm->word = $request->input('word');
+            $dhammaTerm->word_en = Tools::getWordEn($request->input('word'));
+        }
+        foreach (['meaning', 'other_meaning', 'note', 'tag', 'language'] as $field) {
+            if ($request->has($field)) {
+                $dhammaTerm->$field = $request->input($field);
+            }
+        }
         $dhammaTerm->editor_id = $user['user_id'];
-        $dhammaTerm->create_time = time() * 1000;
+        $dhammaTerm->editor_uid = $user['user_uid'];
+        // create_time 是创建时刻,改动时不该被刷新
         $dhammaTerm->modify_time = time() * 1000;
         $dhammaTerm->save();
         // 删除cache
@@ -380,6 +406,9 @@ class DhammaTermController extends Controller
             // 查看是否有删除权限
             foreach ($request->input('id') as $key => $uuid) {
                 $term = DhammaTerm::find($uuid);
+                if (! $term) {
+                    continue;
+                }
                 if ($term->owner !== $user['user_uid']) {
                     if (! empty($term->channal)) {
                         // 看是否为协作
@@ -400,10 +429,16 @@ class DhammaTermController extends Controller
             foreach ($arrId as $key => $id) {
                 // code...
                 $term = DhammaTerm::where('id', $id)
-                    ->where('owner', $user['user_uid']);
+                    ->where('owner', $user['user_uid'])
+                    ->first();
+                if (! $term) {
+                    continue;
+                }
+                // 先取到模型再删:此前这里把 query builder 传给 deleteCache,
+                // 拿不到 word/channal,缓存根本没被清掉。
                 $result = $term->delete();
-                $this->deleteCache($term);
                 if ($result) {
+                    $this->deleteCache($term);
                     $count++;
                 }
             }

+ 5 - 36
api-v13/app/Http/Controllers/SentenceController.php

@@ -6,6 +6,7 @@ use App\Http\Api\ChannelApi;
 use App\Http\Api\Mq;
 use App\Http\Api\PaliTextApi;
 use App\Http\Api\ShareApi;
+use App\Http\Controllers\Concerns\ChecksChannelEditPower;
 use App\Http\Resources\SentResource;
 use App\Models\AccessToken;
 use App\Models\Channel;
@@ -24,6 +25,8 @@ use Illuminate\Support\Str;
 
 class SentenceController extends Controller
 {
+    use ChecksChannelEditPower;
+
     public function __construct(protected SentenceService $sentenceService) {}
 
     /**
@@ -269,40 +272,6 @@ class SentenceController extends Controller
         }
     }
 
-    private function UserCanEdit(string $userId, string $channelId, int $book, $access_token = null)
-    {
-        $channel = Channel::where('uid', $channelId)->first();
-        if (! $channel) {
-            return false;
-        }
-        if ($channel->owner_uid !== $userId) {
-            // 判断是否为协作
-            $power = ShareApi::getResPower($userId, $channel->uid, 2);
-            if ($power < 20) {
-                // 判断token
-                if (! $access_token) {
-                    return false;
-                }
-                $key = AccessToken::where('res_id', $channelId)->value('token');
-                if (! $key) {
-                    return false;
-                }
-                try {
-                    // access token 现在带 exp,过期会抛 ExpiredException;
-                    // 伪造/损坏的 token 同样抛异常。一律当作无权,不要冒泡成 500。
-                    $jwt = JWT::decode($access_token, new Key($key.$key, 'HS512'));
-                } catch (\Exception $e) {
-                    return false;
-                }
-                if (isset($jwt->book) && $jwt->book !== 0 && $jwt->book !== $book) {
-                    return false;
-                }
-            }
-        }
-
-        return true;
-    }
-
     /**
      * 新建多个句子
      * 如果句子存在,修改
@@ -322,7 +291,7 @@ class SentenceController extends Controller
         }
         $destChannel = null;
         if ($request->has('channel')) {
-            if ($this->UserCanEdit(
+            if ($this->userCanEditChannel(
                 $user['user_uid'],
                 $request->input('channel'),
                 (int) $request->input('book', 0),
@@ -339,7 +308,7 @@ class SentenceController extends Controller
             // 权限
             if (! $request->has('channel')) {
 
-                if ($this->UserCanEdit(
+                if ($this->userCanEditChannel(
                     $user['user_uid'],
                     $sent['channel_uid'],
                     (int) $sent['book_id'],

+ 5 - 1
api-v13/app/Http/Resources/TermResource.php

@@ -39,7 +39,11 @@ class TermResource extends JsonResource
             'channel_id' => $this->channal,
             'channal' => $this->channal,
             'studio' => StudioApi::getById($this->owner),
-            'editor' => UserApi::getById($this->editor_id),
+            // editor_uid 装得下 AI 模型的 uuid,getByUuid 查不到人类用户时
+            // 会回落到 AI 模型;老数据没有这一列,仍按 editor_id 解析。
+            'editor' => $this->editor_uid
+                ? UserApi::getByUuid($this->editor_uid)
+                : UserApi::getById($this->editor_id),
             'created_at' => $this->created_at,
             'updated_at' => $this->updated_at,
         ];

+ 1 - 1
api-v13/app/Models/DhammaTerm.php

@@ -17,5 +17,5 @@ class DhammaTerm extends Model
 
     public $incrementing = false;
 
-    protected $fillable = ['id', 'guid', 'word', 'word_en', 'meaning', 'channal', 'language', 'owner', 'editor_id', 'create_time', 'modify_time'];
+    protected $fillable = ['id', 'guid', 'word', 'word_en', 'meaning', 'channal', 'language', 'owner', 'editor_id', 'editor_uid', 'create_time', 'modify_time'];
 }

+ 29 - 0
api-v13/database/migrations/2026_08_19_100000_add_editor_uid_to_dhamma_terms.php

@@ -0,0 +1,29 @@
+<?php
+
+use Illuminate\Database\Migrations\Migration;
+use Illuminate\Database\Schema\Blueprint;
+use Illuminate\Support\Facades\Schema;
+
+return new class extends Migration
+{
+    /**
+     * editor_id 是 bigint,只装得下人类用户的自增 sn;AI 模型的身份是 uuid,
+     * 用模型 token 写入时 editor_id 恒为 0,署名会丢失。补一列 uuid,与
+     * sentences.editor_uid 对齐。老数据该列为 null,读取时回落 editor_id。
+     */
+    public function up(): void
+    {
+        Schema::table('dhamma_terms', function (Blueprint $table) {
+            $table->uuid('editor_uid')->nullable()->index()
+                ->comment('编辑者 uuid,人类用户或 AI 模型');
+        });
+    }
+
+    public function down(): void
+    {
+        Schema::table('dhamma_terms', function (Blueprint $table) {
+            $table->dropIndex(['editor_uid']);
+            $table->dropColumn('editor_uid');
+        });
+    }
+};

+ 185 - 0
api-v13/tests/Feature/TermWriteAsAiModelTest.php

@@ -0,0 +1,185 @@
+<?php
+
+use App\Models\AiModel;
+use App\Models\DhammaTerm;
+use Illuminate\Foundation\Testing\RefreshDatabase;
+
+uses(RefreshDatabase::class);
+
+/**
+ * 术语的写入链路与句子一致:人类签出 channel 的 access token,AI 模型带着
+ * 它以自己的身份建/改术语。这里的核心同样是署名——editor_uid 必须是模型。
+ */
+
+/** 人类签出 channel access token,并取出模型身份 token。 */
+function termWriteTokens($test, string $human, string $channel, AiModel $model): array
+{
+    $accessToken = $test->postJson('/api/v2/access-token', [
+        'payload' => [[
+            'res_type' => 'channel',
+            'res_id' => $channel,
+            'power' => 'edit',
+            'book' => 0,
+        ]],
+    ], authHeader($human))
+        ->assertOk()
+        ->assertJsonPath('data.count', 1)
+        ->json('data.rows.0.token');
+
+    $modelToken = $test->getJson("/api/v2/ai-model-token/{$model->uid}", authHeader($human))
+        ->assertOk()
+        ->json('data.token');
+
+    return [$accessToken, $modelToken];
+}
+
+it('creates a channel term attributed to the ai model', function () {
+    $human = makeStudio('tester');
+    $channel = makeChannel($human);
+    $model = AiModel::factory()->ownedBy($human)->create(['name' => 'claude-opus-5']);
+    [$accessToken, $modelToken] = termWriteTokens($this, $human, $channel, $model);
+
+    $this->postJson('/api/v2/terms', [
+        'word' => 'satipaṭṭhāna',
+        'meaning' => '念处',
+        'other_meaning' => '念住',
+        'note' => '四念处之念处。',
+        'tag' => 'abhidhamma',
+        'channel' => $channel,
+        'access_token' => $accessToken,
+    ], ['Authorization' => 'Bearer '.$modelToken])
+        ->assertOk()
+        ->assertJsonPath('data.word', 'satipaṭṭhāna');
+
+    $saved = DhammaTerm::where('word', 'satipaṭṭhāna')->first();
+
+    expect($saved)->not->toBeNull();
+    expect($saved->meaning)->toBe('念处');
+    expect($saved->channal)->toBe($channel);
+    // 核心断言:署名归模型,而不是发起操作的人类
+    expect($saved->editor_uid)->toBe($model->uid);
+    expect($saved->editor_uid)->not->toBe($human);
+    // owner 仍是 channel 所属 studio
+    expect($saved->owner)->toBe($human);
+});
+
+it('refuses to create a term without an access token', function () {
+    $human = makeStudio('tester');
+    $channel = makeChannel($human);
+    $model = AiModel::factory()->ownedBy($human)->create();
+
+    $modelToken = $this->getJson("/api/v2/ai-model-token/{$model->uid}", authHeader($human))
+        ->json('data.token');
+
+    $this->postJson('/api/v2/terms', [
+        'word' => 'satipaṭṭhāna',
+        'meaning' => '念处',
+        'channel' => $channel,
+    ], ['Authorization' => 'Bearer '.$modelToken])
+        ->assertStatus(403);
+
+    expect(DhammaTerm::count())->toBe(0);
+});
+
+it('refuses an access token issued for a different channel', function () {
+    $human = makeStudio('tester');
+    $channel = makeChannel($human, 'mine');
+    $otherChannel = makeChannel(makeStudio('someone-else'), 'not mine');
+    $model = AiModel::factory()->ownedBy($human)->create();
+    [$accessToken, $modelToken] = termWriteTokens($this, $human, $channel, $model);
+
+    $this->postJson('/api/v2/terms', [
+        'word' => 'satipaṭṭhāna',
+        'meaning' => '念处',
+        'channel' => $otherChannel,
+        'access_token' => $accessToken,
+    ], ['Authorization' => 'Bearer '.$modelToken])
+        ->assertStatus(403);
+
+    expect(DhammaTerm::count())->toBe(0);
+});
+
+it('updates a term as the ai model, leaving unsubmitted fields untouched', function () {
+    $human = makeStudio('tester');
+    $channel = makeChannel($human);
+    $model = AiModel::factory()->ownedBy($human)->create();
+    [$accessToken, $modelToken] = termWriteTokens($this, $human, $channel, $model);
+
+    $guid = $this->postJson('/api/v2/terms', [
+        'word' => 'satipaṭṭhāna',
+        'meaning' => '念处',
+        'note' => '原有的注解',
+        'tag' => 'abhidhamma',
+        'channel' => $channel,
+        'access_token' => $accessToken,
+    ], ['Authorization' => 'Bearer '.$modelToken])
+        ->assertOk()
+        ->json('data.guid');
+
+    $createTime = DhammaTerm::find($guid)->create_time;
+
+    // 只提交 meaning:note/tag 必须原样保留
+    $this->putJson("/api/v2/terms/{$guid}", [
+        'meaning' => '念住',
+        'access_token' => $accessToken,
+    ], ['Authorization' => 'Bearer '.$modelToken])
+        ->assertOk()
+        ->assertJsonPath('data.meaning', '念住');
+
+    $saved = DhammaTerm::find($guid);
+
+    expect($saved->meaning)->toBe('念住');
+    expect($saved->word)->toBe('satipaṭṭhāna');
+    expect($saved->note)->toBe('原有的注解');
+    expect($saved->tag)->toBe('abhidhamma');
+    expect($saved->editor_uid)->toBe($model->uid);
+    // create_time 是创建时刻,改动不该刷新它
+    expect($saved->create_time)->toBe($createTime);
+});
+
+it('refuses to update a term with no access token', function () {
+    $human = makeStudio('tester');
+    $channel = makeChannel($human);
+    $model = AiModel::factory()->ownedBy($human)->create();
+    [$accessToken, $modelToken] = termWriteTokens($this, $human, $channel, $model);
+
+    $guid = $this->postJson('/api/v2/terms', [
+        'word' => 'satipaṭṭhāna',
+        'meaning' => '念处',
+        'channel' => $channel,
+        'access_token' => $accessToken,
+    ], ['Authorization' => 'Bearer '.$modelToken])->json('data.guid');
+
+    $this->putJson("/api/v2/terms/{$guid}", [
+        'meaning' => '篡改',
+    ], ['Authorization' => 'Bearer '.$modelToken])
+        ->assertStatus(403);
+
+    expect(DhammaTerm::find($guid)->meaning)->toBe('念处');
+});
+
+it('refuses a studio term written into someone elses studio', function () {
+    $human = makeStudio('tester');
+    $victim = makeStudio('victim');
+
+    // 人类身份,但把 owner 指向别人的 studio
+    $this->postJson('/api/v2/terms', [
+        'word' => 'satipaṭṭhāna',
+        'meaning' => '念处',
+        'studioName' => 'victim',
+        'language' => 'zh-Hans',
+    ], authHeader($human))
+        ->assertStatus(403);
+
+    expect(DhammaTerm::count())->toBe(0);
+
+    // 自己的 studio 则放行
+    $this->postJson('/api/v2/terms', [
+        'word' => 'satipaṭṭhāna',
+        'meaning' => '念处',
+        'studioName' => 'tester',
+        'language' => 'zh-Hans',
+    ], authHeader($human))->assertOk();
+
+    expect(DhammaTerm::where('owner', $human)->count())->toBe(1);
+});