Преглед изворни кода

Merge pull request #2445 from visuddhinanda/development

Development
visuddhinanda пре 1 недеља
родитељ
комит
ad1eddb0ca

+ 117 - 0
api-v13/app/Console/Commands/BackfillTermEditorUid.php

@@ -0,0 +1,117 @@
+<?php
+
+namespace App\Console\Commands;
+
+use App\Models\UserInfo;
+use Illuminate\Console\Attributes\Description;
+use Illuminate\Console\Attributes\Signature;
+use Illuminate\Console\Command;
+use Illuminate\Support\Facades\DB;
+
+/**
+ * editor_id 是人类用户的自增 sn,editor_uid 是 uuid。加 editor_uid 这一列之前
+ * 写入的术语只有前者,这条命令按 user_infos.id → user_infos.userid 补上。
+ *
+ * 负数不碰:那是 AI 模型的哨兵值(-1),模型的身份只有 editor_uid,本来就
+ * 没有 sn。editor_id = 0 是 admin 本人(user_infos 里就有 id=0 这一行),
+ * 照常回填。
+ */
+#[Signature('terms:backfill-editor-uid {--dry-run : 只统计与回显,不写库}')]
+#[Description('把 dhamma_terms 里为空的 editor_uid 按 editor_id 回填成用户 uuid')]
+class BackfillTermEditorUid extends Command
+{
+    /**
+     * Execute the console command.
+     */
+    public function handle(): int
+    {
+        $dryRun = (bool) $this->option('dry-run');
+
+        $pending = DB::table('dhamma_terms')->whereNull('editor_uid');
+        $total = (clone $pending)->count();
+        if ($total === 0) {
+            $this->info('没有 editor_uid 为空的术语,无需回填。');
+
+            return self::SUCCESS;
+        }
+
+        $negative = (clone $pending)->where('editor_id', '<', 0)->count();
+
+        $this->line("editor_uid 为空的术语:{$total} 条");
+        if ($negative > 0) {
+            $this->line("  · editor_id < 0(AI 模型哨兵):{$negative} 条 —— 跳过,它们没有 sn");
+        }
+
+        $target = (clone $pending)->where('editor_id', '>=', 0);
+
+        $ids = (clone $target)->distinct()->pluck('editor_id');
+        if ($ids->isEmpty()) {
+            $this->warn('没有可回填的行。');
+
+            return self::SUCCESS;
+        }
+
+        /** @var array<int, string> $uidById */
+        $uidById = UserInfo::whereIn('id', $ids)->pluck('userid', 'id')->all();
+
+        $missing = $ids->reject(fn ($id) => isset($uidById[$id]))->values();
+        if ($missing->isNotEmpty()) {
+            $this->warn("有 {$missing->count()} 个 editor_id 在 user_infos 里查不到,这些行会原样保留:");
+            foreach ($missing as $id) {
+                $count = (clone $target)->where('editor_id', $id)->count();
+                $this->warn("  editor_id={$id}  {$count} 条");
+            }
+        }
+
+        $this->line(str_repeat('-', 60));
+        $this->line(sprintf('%-12s %-38s %s', 'editor_id', 'userid', '条数'));
+
+        $plan = [];
+        foreach ($ids as $id) {
+            if (! isset($uidById[$id])) {
+                continue;
+            }
+            $count = (clone $target)->where('editor_id', $id)->count();
+            $plan[$id] = ['uid' => $uidById[$id], 'count' => $count];
+            $this->line(sprintf('%-12s %-38s %s', $id, $uidById[$id], $count));
+        }
+
+        $willWrite = array_sum(array_column($plan, 'count'));
+        $this->line(str_repeat('-', 60));
+        $this->line("将回填 {$willWrite} 条,涉及 ".count($plan).' 个用户。');
+
+        if ($dryRun) {
+            $this->info('--dry-run:未写库。');
+
+            return self::SUCCESS;
+        }
+
+        if (! $this->confirm("确认写入这 {$willWrite} 条?", false)) {
+            $this->warn('已取消,未改动任何数据。');
+
+            return self::FAILURE;
+        }
+
+        // 一个 editor_id 一条 UPDATE(本例 77 条),比逐行快得多。
+        // 走 DB::table 而不是 Eloquent:后者会顺手刷 updated_at,
+        // 那是术语的最后修改时间,不该被一次数据补写污染。
+        $written = 0;
+        $bar = $this->output->createProgressBar(count($plan));
+        $bar->start();
+        foreach ($plan as $id => $row) {
+            $written += DB::table('dhamma_terms')
+                ->whereNull('editor_uid')
+                ->where('editor_id', $id)
+                ->update(['editor_uid' => $row['uid']]);
+            $bar->advance();
+        }
+        $bar->finish();
+        $this->newLine(2);
+
+        $left = DB::table('dhamma_terms')->whereNull('editor_uid')->count();
+        $this->info("已回填 {$written} 条。");
+        $this->line("仍为空的还有 {$left} 条".($left > 0 ? '(AI 哨兵,或查不到的用户)' : '').'。');
+
+        return self::SUCCESS;
+    }
+}

+ 79 - 0
api-v13/app/Console/Commands/ExportPaliWordFrequency.php

@@ -0,0 +1,79 @@
+<?php
+
+namespace App\Console\Commands;
+
+use App\Models\Sentence;
+use Illuminate\Console\Attributes\Description;
+use Illuminate\Console\Attributes\Signature;
+use Illuminate\Console\Command;
+
+#[Signature('export:pali-word-frequency {--limit=0 : 只处理前 N 条记录,0 表示全部(测试用)}')]
+#[Description('扫描 channel.type=translation 的句子,统计 [[巴利文]] 单词出现次数并输出 CSV')]
+class ExportPaliWordFrequency extends Command
+{
+    /**
+     * Execute the console command.
+     */
+    public function handle(): int
+    {
+        $limit = (int) $this->option('limit');
+
+        $query = Sentence::type('translation')->select(['uid', 'content'])->orderBy('uid');
+        $total = $query->count();
+        $this->info("符合要求的 sentences 总数: {$total}".($limit > 0 ? "(本次仅处理前 {$limit} 条)" : ''));
+
+        if ($total === 0) {
+            $this->warn('没有找到任何记录,退出。');
+
+            return 0;
+        }
+
+        if ($limit > 0) {
+            $query = $query->limit($limit);
+        }
+
+        $wordCounts = [];
+        $processed = 0;
+        $matchedSentences = 0;
+
+        foreach ($query->cursor() as $sent) {
+            $processed++;
+            if (! empty($sent->content) && preg_match_all('/\[\[([^\[\]]+)\]\]/u', $sent->content, $matches) > 0) {
+                $matchedSentences++;
+                foreach ($matches[1] as $word) {
+                    $word = trim($word);
+                    if ($word === '') {
+                        continue;
+                    }
+                    $wordCounts[$word] = ($wordCounts[$word] ?? 0) + 1;
+                }
+            }
+
+            if ($processed % 1000 === 0) {
+                $this->info("已处理 {$processed}/{$total} 条,其中 {$matchedSentences} 条包含 [[巴利文]],累计发现 ".count($wordCounts).' 个不同单词');
+            }
+        }
+
+        arsort($wordCounts, SORT_NUMERIC);
+
+        $fileName = 'pali-word-frequency-'.date('Ymd-His').($limit > 0 ? "-limit{$limit}" : '').'.csv';
+        $exportDir = storage_path('app/public/export');
+        if (! is_dir($exportDir)) {
+            mkdir($exportDir, 0755, true);
+        }
+        $filePath = $exportDir.'/'.$fileName;
+
+        $file = fopen($filePath, 'w');
+        fwrite($file, "\xEF\xBB\xBF");
+        fputcsv($file, ['word', 'count']);
+        foreach ($wordCounts as $word => $count) {
+            fputcsv($file, [$word, $count]);
+        }
+        fclose($file);
+
+        $this->info("扫描完成:共处理 {$processed} 条记录,{$matchedSentences} 条包含 [[巴利文]],".count($wordCounts).' 个不同单词');
+        $this->info("CSV 已输出: {$filePath}");
+
+        return 0;
+    }
+}

+ 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;
+    }
+}

+ 95 - 26
api-v13/app/Http/Controllers/DhammaTermController.php

@@ -5,7 +5,9 @@ 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\AiModel;
 use App\Models\Channel;
 use App\Models\DhammaTerm;
 use App\Services\AuthService;
@@ -18,6 +20,8 @@ use Illuminate\Support\Str;
 
 class DhammaTermController extends Controller
 {
+    use ChecksChannelEditPower;
+
     /**
      * Display a listing of the resource.
      *
@@ -38,6 +42,7 @@ class DhammaTermController extends Controller
             'channal',
             'owner',
             'editor_id',
+            'editor_uid',
             'created_at',
             'updated_at',
         ];
@@ -221,14 +226,17 @@ class DhammaTermController extends Controller
          * 查询重复的
          * 一个channel下面word+tag+language 唯一
          */
-        $table = DhammaTerm::where('owner', $user['user_uid'])
-            ->where('word', $request->input('word'))
+        $table = DhammaTerm::where('word', $request->input('word'))
             ->where('tag', $request->input('tag'));
         if (! empty($request->input('channel'))) {
+            // channel 内的唯一性只看 channel。此前这里还按 owner 过滤,而
+            // owner 取的是当前身份——AI 模型的 uid 与落库的 owner(channel
+            // 所属 studio)永远不等,查重必然落空,同一个词会被反复插入。
             $isDoesntExist = $table->where('channal', $request->input('channel'))
                 ->doesntExist();
         } else {
-            $isDoesntExist = $table->whereNull('channal')->where('language', $request->input('language'))
+            $isDoesntExist = $table->where('owner', $user['user_uid'])
+                ->whereNull('channal')->where('language', $request->input('language'))
                 ->doesntExist();
         }
 
@@ -250,27 +258,45 @@ 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'];
                 }
             } else {
+                // AI 模型只能在 channel 里建术语。它的权限全部由人类签出的
+                // channel access token 代持,而 access token 是 channel 级的,
+                // 代持不了 studio 权限——没有 channel 就没有任何可核验的授权。
+                if ($this->isAiModel($user['user_uid'])) {
+                    return $this->error('ai model must specify a channel', [], 403);
+                }
                 if ($request->has('studioId')) {
                     $studioId = $request->input('studioId');
                 } 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_id = $this->editorId($user);
+            $term->editor_uid = $user['user_uid'];
             $term->create_time = time() * 1000;
             $term->modify_time = time() * 1000;
             $term->save();
@@ -283,6 +309,27 @@ class DhammaTermController extends Controller
         }
     }
 
+    /**
+     * editor_id 存的是人类用户的自增 sn,模型没有。模型 token 里的 id 恒为 0,
+     * 而 0 同时也是「缺省/未知」的值,落库后分不清是模型写的还是数据有问题,
+     * 故模型一律记 -1;模型的真实身份看 editor_uid。
+     *
+     * @param  array<string, mixed>  $user
+     */
+    private function editorId(array $user): int
+    {
+        return $this->isAiModel($user['user_uid']) ? -1 : (int) $user['user_id'];
+    }
+
+    /**
+     * 当前身份是不是 AI 模型。模型 token 的 user_id 恒为 0,但人类的旧 cookie
+     * 鉴权也可能给出奇怪的值,故直接查表判定,不靠 id。
+     */
+    private function isAiModel(string $userUid): bool
+    {
+        return AiModel::where('uid', $userUid)->exists();
+    }
+
     private function deleteCache($term)
     {
         if (empty($term->channal)) {
@@ -332,27 +379,40 @@ class DhammaTermController extends Controller
         }
 
         if (empty($dhammaTerm->channal)) {
-            // 查看有没有studio权限
+            // studio 级术语(不属于任何 channel)只有 owner 本人能改:
+            // access token 是 channel 级的,代持不了 studio 权限。
+            if ($this->isAiModel($user['user_uid'])) {
+                return $this->error('ai model cannot edit a term outside a channel', [], 403);
+            }
             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');
-        $dhammaTerm->editor_id = $user['user_id'];
-        $dhammaTerm->create_time = time() * 1000;
+        // 增量更新:只改提交上来的字段。此前这里无条件赋值,客户端漏提一个
+        // 字段就会把库里的 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 = $this->editorId($user);
+        $dhammaTerm->editor_uid = $user['user_uid'];
+        // create_time 是创建时刻,改动时不该被刷新
         $dhammaTerm->modify_time = time() * 1000;
         $dhammaTerm->save();
         // 删除cache
@@ -380,6 +440,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 +463,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'],

+ 10 - 4
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,
         ];
@@ -47,9 +51,11 @@ class TermResource extends JsonResource
         if ($request->has('channel') && ! empty($request->input('channel'))) {
             $channels = explode('_', $request->input('channel'));
         } else {
-            if (! empty($this->channel_id) && Str::isUuid($this->channel_id)) {
-                $channelId = $this->channel_id;
-                $data['channel'] = ChannelApi::getById($this->channel_id);
+            // 列名是 channal;channel_id 只存在于本资源的输出里,模型上取不到,
+            // 于是所有 channel 内的术语都拿不到 channel 信息、还被当成社区术语渲染。
+            if (! empty($this->channal) && Str::isUuid($this->channal)) {
+                $channelId = $this->channal;
+                $data['channel'] = ChannelApi::getById($this->channal);
             } else {
                 $channelId = ChannelApi::getSysChannel('_community_translation_'.$this->language.'_');
                 if (empty($channelId)) {

+ 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');
+        });
+    }
+};

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

@@ -0,0 +1,277 @@
+<?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);
+    // editor_id 存人类的自增 sn,模型没有;0 与「缺省/未知」撞车,故记 -1
+    expect($saved->editor_id)->toBe(-1);
+    // 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);
+    expect($saved->editor_id)->toBe(-1);
+    // 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);
+    // 人类写入不受影响:editor_id 仍是 token 里的用户 sn
+    expect(DhammaTerm::where('owner', $human)->first()->editor_id)->toBe(1);
+});
+
+it('refuses to create a studio-level term as an ai model', function () {
+    $human = makeStudio('tester');
+    $model = AiModel::factory()->ownedBy($human)->create();
+
+    $modelToken = $this->getJson("/api/v2/ai-model-token/{$model->uid}", authHeader($human))
+        ->json('data.token');
+
+    // 不给 channel:模型没有任何可核验的授权,必须被拒
+    $this->postJson('/api/v2/terms', [
+        'word' => 'satipaṭṭhāna',
+        'meaning' => '念处',
+        'studioName' => 'tester',
+        'language' => 'zh-Hans',
+    ], ['Authorization' => 'Bearer '.$modelToken])
+        ->assertStatus(403);
+
+    expect(DhammaTerm::count())->toBe(0);
+});
+
+it('refuses to edit a studio-level term as an ai model', function () {
+    $human = makeStudio('tester');
+    $model = AiModel::factory()->ownedBy($human)->create();
+
+    // 人类先建一条 studio 级术语
+    $guid = $this->postJson('/api/v2/terms', [
+        'word' => 'satipaṭṭhāna',
+        'meaning' => '念处',
+        'studioName' => 'tester',
+        'language' => 'zh-Hans',
+    ], authHeader($human))->assertOk()->json('data.guid');
+
+    $modelToken = $this->getJson("/api/v2/ai-model-token/{$model->uid}", authHeader($human))
+        ->json('data.token');
+
+    $this->putJson("/api/v2/terms/{$guid}", [
+        'meaning' => '篡改',
+    ], ['Authorization' => 'Bearer '.$modelToken])
+        ->assertStatus(403);
+
+    expect(DhammaTerm::find($guid)->meaning)->toBe('念处');
+});
+
+it('rejects a duplicate word+tag in the same channel written by an ai model', function () {
+    $human = makeStudio('tester');
+    $channel = makeChannel($human);
+    $model = AiModel::factory()->ownedBy($human)->create();
+    [$accessToken, $modelToken] = termWriteTokens($this, $human, $channel, $model);
+
+    $payload = [
+        'word' => 'satipaṭṭhāna',
+        'meaning' => '念处',
+        'tag' => 'abhidhamma',
+        'channel' => $channel,
+        'access_token' => $accessToken,
+    ];
+
+    $this->postJson('/api/v2/terms', $payload, ['Authorization' => 'Bearer '.$modelToken])
+        ->assertOk();
+
+    // 查重按 channel,不按 owner——模型的 uid 与落库的 owner 不等,
+    // 按 owner 查会永远查不到重复,同一个词被反复插入。
+    $this->postJson('/api/v2/terms', $payload, ['Authorization' => 'Bearer '.$modelToken])
+        ->assertOk()
+        ->assertJsonPath('message', 'word existed');
+
+    expect(DhammaTerm::where('word', 'satipaṭṭhāna')->count())->toBe(1);
+});
+
+it('exposes the channel of a channel term', function () {
+    $human = makeStudio('tester');
+    $channel = makeChannel($human, 'my channel');
+    $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');
+
+    // channel 信息取自 channal 列;此前读的是模型上并不存在的 channel_id
+    $this->getJson("/api/v2/terms/{$guid}", authHeader($human))
+        ->assertOk()
+        ->assertJsonPath('data.channel.name', 'my channel');
+});