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

fix: 修补 ai-model 与 access-token 的鉴权和数据泄漏问题

- AiModelController::show() 此前完全没有鉴权,任何人可读任意模型,
  补 AuthService::current + canEdit;
- AiModelResource::toArray() 用 parent::toArray() 把第三方 LLM 的 key
  原样返回,改为字段白名单,key / system_prompt 仅 owner 请求时附带。
  不能直接删字段:dashboard 的模型编辑页靠 show 回填这两项,删了会导致
  用户一保存就把 key 清空;
- AccessTokenController::store() 签出的 token 永不过期,payload 注入
  nbf / exp(7 天)。随之 SentenceController::UserCanEdit() 的
  JWT::decode 会抛 ExpiredException,原代码无 try/catch,过期 token
  会变成 500 而非无权,一并补上捕获;
- AiModelController::store() 丢字段、update() 把未提交字段置 null
  (改用 has() 判定增量更新)、两个 FormRequest 的 rules() 为空。

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

+ 11 - 7
api-v13/app/Http/Api/AiAssistantApi.php

@@ -3,19 +3,21 @@
 namespace App\Http\Api;
 
 use App\Models\AiModel;
-use Illuminate\Support\Facades\Storage;
 use Illuminate\Support\Facades\App;
+use Illuminate\Support\Facades\Storage;
 
 class AiAssistantApi
 {
     public static function getByUuid($id)
     {
         $user = AiModel::where('uid', $id)->first();
+
         return self::userInfo($user);
     }
+
     public static function userInfo($user)
     {
-        if (!$user) {
+        if (! $user) {
             return [
                 'id' => 0,
                 'nickName' => 'unknown',
@@ -43,21 +45,23 @@ class AiAssistantApi
         } else {
             $logo = null;
             foreach (config('mint.ai.logo') as $key => $value) {
-                if (strpos($user->model, $key) !== false) {
+                // model / url 均可为 null(新建模型时未必填写),须转字符串避免 strpos 弃用告警
+                if (strpos((string) $user->model, $key) !== false) {
                     $logo = $value;
                     break;
-                } else if (strpos($user->url, $key) !== false) {
+                } elseif (strpos((string) $user->url, $key) !== false) {
                     $logo = $value;
                     break;
                 }
             }
-            $base = config('app.url') . '/assets/images/avatar/';
+            $base = config('app.url').'/assets/images/avatar/';
             if ($logo === null) {
-                $data['avatar'] = $base . 'ai-assistant.png';
+                $data['avatar'] = $base.'ai-assistant.png';
             } else {
-                $data['avatar'] = $base . $logo;
+                $data['avatar'] = $base.$logo;
             }
         }
+
         return $data;
     }
 }

+ 34 - 28
api-v13/app/Http/Controllers/AccessTokenController.php

@@ -2,56 +2,60 @@
 
 namespace App\Http\Controllers;
 
+use App\Http\Api\ChannelApi;
 use App\Models\AccessToken;
-use Illuminate\Http\Request;
-use Illuminate\Support\Str;
+use App\Services\AuthService;
 use Firebase\JWT\JWT;
-use Firebase\JWT\Key;
+use Illuminate\Http\Request;
+use Illuminate\Http\Response;
 use Illuminate\Support\Facades\Log;
-use App\Services\AuthService;
-use App\Http\Api\ChannelApi;
+use Illuminate\Support\Str;
 
 class AccessTokenController extends Controller
 {
+    /**
+     * 签出的 access token 有效期(秒)。7 天足够一次批量写入作业,
+     * 又能把凭据泄漏的窗口限制在可接受范围内。
+     */
+    private const TOKEN_TTL = 60 * 60 * 24 * 7;
+
     /**
      * Display a listing of the resource.
      *
-     * @return \Illuminate\Http\Response
+     * @return Response
      */
     public function index()
     {
         //
     }
 
-
     /**
      * Store a newly created resource in storage.
      *
-     * @param  \Illuminate\Http\Request  $request
-     * @return \Illuminate\Http\Response
+     * @return Response
      */
     public function store(Request $request)
     {
         //
         $user = AuthService::current($request);
-        if (!$user) {
+        if (! $user) {
             return $this->error(__('auth.failed'), [], 401);
         }
         $payload = $request->input('payload');
-        $result = array();
+        $result = [];
         foreach ($payload as $key => $value) {
-            //鉴权
+            // 鉴权
             switch ($value['res_type']) {
                 case 'channel':
-                    if (!isset($value['power']) || !isset($value['res_id'])) {
+                    if (! isset($value['power']) || ! isset($value['res_id'])) {
                         continue 2;
                     }
                     if ($value['power'] === 'edit') {
-                        if (!ChannelApi::userCanEdit($user['user_uid'], $value['res_id'])) {
+                        if (! ChannelApi::userCanEdit($user['user_uid'], $value['res_id'])) {
                             continue 2;
                         }
                     } else {
-                        if (!ChannelApi::userCanRead($user['user_uid'], $value['res_id'])) {
+                        if (! ChannelApi::userCanRead($user['user_uid'], $value['res_id'])) {
                             continue 2;
                         }
                     }
@@ -60,39 +64,44 @@ class AccessTokenController extends Controller
                     continue 2;
                     break;
             }
-            //获取token
+            // 获取token
             $token = AccessToken::firstOrNew(
                 [
                     'res_type' => $value['res_type'],
-                    'res_id' => $value['res_id']
+                    'res_id' => $value['res_id'],
                 ],
                 [
-                    'token' => (string)Str::uuid()
+                    'token' => (string) Str::uuid(),
                 ]
             );
-            if (!$token->exists) {
+            if (! $token->exists) {
                 $token->save();
             }
 
+            // 有效期:payload 里不注入 exp 的话,签出的 token 永久有效,泄漏后无法失效
+            $value['nbf'] = time();
+            $value['exp'] = time() + self::TOKEN_TTL;
+
             try {
-                $jwt = JWT::encode($value, $token->token . $token->token, 'HS512');
+                $jwt = JWT::encode($value, $token->token.$token->token, 'HS512');
             } catch (\Exception $e) {
                 Log::error('jwt', ['error' => $e]);
+
                 continue;
             }
             $result[] = [
                 'payload' => $value,
-                'token' => $jwt
+                'token' => $jwt,
             ];
         }
+
         return $this->ok(['rows' => $result, 'count' => count($result)]);
     }
 
     /**
      * Display the specified resource.
      *
-     * @param  \App\Models\AccessToken  $accessToken
-     * @return \Illuminate\Http\Response
+     * @return Response
      */
     public function show(AccessToken $accessToken)
     {
@@ -102,9 +111,7 @@ class AccessTokenController extends Controller
     /**
      * Update the specified resource in storage.
      *
-     * @param  \Illuminate\Http\Request  $request
-     * @param  \App\Models\AccessToken  $accessToken
-     * @return \Illuminate\Http\Response
+     * @return Response
      */
     public function update(Request $request, AccessToken $accessToken)
     {
@@ -114,8 +121,7 @@ class AccessTokenController extends Controller
     /**
      * Remove the specified resource from storage.
      *
-     * @param  \App\Models\AccessToken  $accessToken
-     * @return \Illuminate\Http\Response
+     * @return Response
      */
     public function destroy(AccessToken $accessToken)
     {

+ 74 - 36
api-v13/app/Http/Controllers/AiModelController.php

@@ -2,35 +2,42 @@
 
 namespace App\Http\Controllers;
 
+use App\Http\Api\StudioApi;
 use App\Http\Requests\StoreAiModelRequest;
 use App\Http\Requests\UpdateAiModelRequest;
+use App\Http\Resources\AiModelResource;
 use App\Models\AiModel;
-use Illuminate\Http\Request;
 use App\Services\AuthService;
+use Illuminate\Http\Request;
+use Illuminate\Http\Response;
 use Illuminate\Support\Str;
-use App\Http\Api\StudioApi;
-use App\Http\Resources\AiModelResource;
-
 
 class AiModelController extends Controller
 {
+    /**
+     * 客户端可写的字段(name / privacy 另行处理:前者参与重名校验,后者建档时有默认值)。
+     */
+    private const EDITABLE_FIELDS = ['description', 'system_prompt', 'url', 'model', 'key'];
+
     /**
      * Display a listing of the resource.
      *
-     * @return \Illuminate\Http\Response
+     * @return Response
      */
     public function index(Request $request)
     {
         //
         $user = AuthService::current($request);
-        if (!$user) {
+        if (! $user) {
             return $this->error(__('auth.failed'), 401, 401);
         }
+
         switch ($request->input('view')) {
             case 'all':
                 $table = AiModel::whereNotNull('owner_id');
                 break;
             case 'studio':
+                // 指定用户名下的记录
                 $studioId = StudioApi::getIdByName($request->input('name'));
                 $table = AiModel::where('owner_id', $studioId);
                 break;
@@ -39,11 +46,11 @@ class AiModelController extends Controller
                     ->orWhere('privacy', 'public');
                 break;
             case 'chat':
-                $table = AiModel::where('owner_id', config("mint.admin.root_uuid"));
+                $table = AiModel::where('owner_id', config('mint.admin.root_uuid'));
                 break;
         }
         if ($request->has('keyword')) {
-            $table = $table->where('name', 'like', '%' . $request->input('keyword') . '%');
+            $table = $table->where('name', 'like', '%'.$request->input('keyword').'%');
         }
         $count = $table->count();
 
@@ -52,15 +59,15 @@ class AiModelController extends Controller
             $request->input('dir', 'asc')
         );
 
-        $table = $table->skip($request->input("offset", 0))
+        $table = $table->skip($request->input('offset', 0))
             ->take($request->input('limit', 1000));
 
         $result = $table->get();
 
         return $this->ok(
             [
-                "rows" => AiModelResource::collection(resource: $result),
-                "count" => $count,
+                'rows' => AiModelResource::collection(resource: $result),
+                'count' => $count,
             ]
         );
     }
@@ -68,88 +75,119 @@ class AiModelController extends Controller
     /**
      * Store a newly created resource in storage.
      *
-     * @param  \App\Http\Requests\StoreAiModelRequest  $request
-     * @return \Illuminate\Http\Response
+     * @return Response
      */
     public function store(StoreAiModelRequest $request)
     {
         //
         $user = AuthService::current($request);
-        if (!$user) {
+        if (! $user) {
             return $this->error(__('auth.failed'), 401, 401);
         }
         $studioId = StudioApi::getIdByName($request->input('studio_name'));
-        if (!self::canEdit($user['user_uid'], $studioId)) {
+        if (! self::canEdit($user['user_uid'], $studioId)) {
             return $this->error(__('auth.failed'), 403, 403);
         }
-        $new = new AiModel();
-        $new->name = $request->input('name');
+        // 同一 studio 内 name 必须唯一:客户端(wikipali-write Skill)靠 name 做幂等匹配,
+        // 重名会让「查不到就创建」的流程反复建出同名记录
+        $duplicated = AiModel::where('owner_id', $studioId)
+            ->where('name', $request->input('name'))
+            ->exists();
+        if ($duplicated) {
+            return $this->error(__('validation.unique', ['attribute' => 'name']), null, 409);
+        }
+
+        $new = new AiModel;
         $new->uid = Str::uuid();
         $new->real_name = Str::uuid();
         $new->owner_id = $studioId;
         $new->editor_id = $user['user_uid'];
+        $new->name = $request->input('name');
+        $new->privacy = $request->input('privacy', 'private');
+        foreach (self::EDITABLE_FIELDS as $field) {
+            if ($request->has($field)) {
+                $new->{$field} = $request->input($field);
+            }
+        }
         $new->save();
+
         return $this->ok(new AiModelResource($new));
     }
 
     /**
      * Display the specified resource.
      *
-     * @param  \App\Models\AiModel  $aiModel
-     * @return \Illuminate\Http\Response
+     * @return Response
      */
-    public function show(AiModel $aiModel)
+    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));
     }
 
     /**
      * Update the specified resource in storage.
      *
-     * @param  \App\Http\Requests\UpdateAiModelRequest  $request
-     * @param  \App\Models\AiModel  $aiModel
-     * @return \Illuminate\Http\Response
+     * @return Response
      */
     public function update(UpdateAiModelRequest $request, AiModel $aiModel)
     {
         //
         $user = AuthService::current($request);
-        if (!$user) {
+        if (! $user) {
             return $this->error(__('auth.failed'), 401, 401);
         }
-        if (!self::canEdit($user['user_uid'], $aiModel->owner_id)) {
+        if (! self::canEdit($user['user_uid'], $aiModel->owner_id)) {
             return $this->error(__('auth.failed'), 403, 403);
         }
-        $aiModel->name = $request->input('name');
-        $aiModel->description = $request->input('description');
-        $aiModel->system_prompt = $request->input('system_prompt');
-        $aiModel->url = $request->input('url');
-        $aiModel->model = $request->input('model');
-        $aiModel->key = $request->input('key');
-        $aiModel->privacy = $request->input('privacy');
+        if ($request->has('name')) {
+            $duplicated = AiModel::where('owner_id', $aiModel->owner_id)
+                ->where('name', $request->input('name'))
+                ->where('uid', '<>', $aiModel->uid)
+                ->exists();
+            if ($duplicated) {
+                return $this->error(__('validation.unique', ['attribute' => 'name']), null, 409);
+            }
+        }
+        // 增量更新:只改请求里出现的字段。
+        // 用 has() 而非 filled(),好让前端能把 description 之类的字段显式清空;
+        // 但未提交的字段必须原样保留——否则客户端的局部 PUT 会把其余字段全置 null。
+        foreach (array_merge(['name', 'privacy'], self::EDITABLE_FIELDS) as $field) {
+            if ($request->has($field)) {
+                $aiModel->{$field} = $request->input($field);
+            }
+        }
         $aiModel->editor_id = $user['user_uid'];
         $aiModel->save();
+
         return $this->ok(new AiModelResource($aiModel));
     }
 
     /**
      * Remove the specified resource from storage.
      *
-     * @param  \App\Models\AiModel  $aiModel
-     * @return \Illuminate\Http\Response
+     * @return Response
      */
     public function destroy(Request $request, AiModel $aiModel)
     {
         //
         $user = AuthService::current($request);
-        if (!$user) {
+        if (! $user) {
             return $this->error(__('auth.failed'), 401, 401);
         }
-        if (!self::canEdit($user['user_uid'], $aiModel->owner_id)) {
+        if (! self::canEdit($user['user_uid'], $aiModel->owner_id)) {
             return $this->error(__('auth.failed'), 403, 403);
         }
         $del = $aiModel->delete();
+
         return $this->ok($del);
     }
 

+ 16 - 7
api-v13/app/Http/Controllers/SentenceController.php

@@ -85,7 +85,7 @@ class SentenceController extends Controller
                     return $this->error('没有关键词');
                 }
                 $table = Sentence::select($indexCol)
-                    ->where('content', 'like', '%' . $key . '%')
+                    ->where('content', 'like', '%'.$key.'%')
                     ->where('editor_uid', $userUid);
 
                 break;
@@ -203,7 +203,7 @@ class SentenceController extends Controller
                 break;
         }
         if (! empty($request->input('key'))) {
-            $table = $table->where('content', 'like', '%' . $request->input('key') . '%');
+            $table = $table->where('content', 'like', '%'.$request->input('key').'%');
         }
 
         $count = $table->count();
@@ -269,7 +269,7 @@ class SentenceController extends Controller
         }
     }
 
-    private function UserCanEdit(string $userId, string $channelId, int  $book, $access_token = null)
+    private function UserCanEdit(string $userId, string $channelId, int $book, $access_token = null)
     {
         $channel = Channel::where('uid', $channelId)->first();
         if (! $channel) {
@@ -284,8 +284,17 @@ class SentenceController extends Controller
                     return false;
                 }
                 $key = AccessToken::where('res_id', $channelId)->value('token');
-                $jwt = JWT::decode($access_token, new Key($key . $key, 'HS512'));
-                if (isset($jwt->book) && $jwt->book !== 0 &&  $jwt->book !== $book) {
+                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;
                 }
             }
@@ -316,7 +325,7 @@ class SentenceController extends Controller
             if ($this->UserCanEdit(
                 $user['user_uid'],
                 $request->input('channel'),
-                (int)$request->input('book', 0),
+                (int) $request->input('book', 0),
                 $request->input('access_token', null)
             )) {
                 $destChannel = Channel::where('uid', $request->input('channel'))->first();
@@ -333,7 +342,7 @@ class SentenceController extends Controller
                 if ($this->UserCanEdit(
                     $user['user_uid'],
                     $sent['channel_uid'],
-                    (int)$sent['book_id'],
+                    (int) $sent['book_id'],
                     isset($sent['access_token']) ? $sent['access_token'] : null
                 )) {
                     $destChannel = Channel::where('uid', $sent['channel_uid'])->first();

+ 11 - 1
api-v13/app/Http/Requests/StoreAiModelRequest.php

@@ -13,18 +13,28 @@ class StoreAiModelRequest extends FormRequest
      */
     public function authorize()
     {
+        // 真正的鉴权在 AiModelController::store():需要先把 studio_name 解成 studio uid
         return true;
     }
 
     /**
      * Get the validation rules that apply to the request.
      *
+     * 长度上限对齐 ai_models 表的列定义。
+     *
      * @return array
      */
     public function rules()
     {
         return [
-            //
+            'name' => ['required', 'string', 'max:64'],
+            'studio_name' => ['required', 'string'],
+            'description' => ['nullable', 'string'],
+            'system_prompt' => ['nullable', 'string'],
+            'url' => ['nullable', 'string', 'max:1024'],
+            'model' => ['nullable', 'string', 'max:1024'],
+            'key' => ['nullable', 'string', 'max:1024'],
+            'privacy' => ['nullable', 'string', 'in:private,public,disable'],
         ];
     }
 }

+ 10 - 1
api-v13/app/Http/Requests/UpdateAiModelRequest.php

@@ -13,18 +13,27 @@ class UpdateAiModelRequest extends FormRequest
      */
     public function authorize()
     {
+        // 真正的鉴权在 AiModelController::update():需要拿到 $aiModel 才能判 owner
         return true;
     }
 
     /**
      * Get the validation rules that apply to the request.
      *
+     * 全部字段用 sometimes:update 是增量的,未提交的字段保持原值。
+     *
      * @return array
      */
     public function rules()
     {
         return [
-            //
+            'name' => ['sometimes', 'required', 'string', 'max:64'],
+            'description' => ['sometimes', 'nullable', 'string'],
+            'system_prompt' => ['sometimes', 'nullable', 'string'],
+            'url' => ['sometimes', 'nullable', 'string', 'max:1024'],
+            'model' => ['sometimes', 'nullable', 'string', 'max:1024'],
+            'key' => ['sometimes', 'nullable', 'string', 'max:1024'],
+            'privacy' => ['sometimes', 'required', 'string', 'in:private,public,disable'],
         ];
     }
 }

+ 47 - 6
api-v13/app/Http/Resources/AiModelResource.php

@@ -2,21 +2,62 @@
 
 namespace App\Http\Resources;
 
-use Illuminate\Http\Resources\Json\JsonResource;
 use App\Http\Api\AiAssistantApi;
+use App\Services\AuthService;
+use Illuminate\Http\Request;
+use Illuminate\Http\Resources\Json\JsonResource;
 
 class AiModelResource extends JsonResource
 {
     /**
-     * Transform the resource into an array.
+     * 把资源转成数组。
+     *
+     * 字段白名单:绝不能回落到 parent::toArray(),那会把 key(第三方 API key)
+     * 和 system_prompt 一并吐出去,而 index() 的 view=all / view=usable 分支
+     * 对任何登录用户可见,等于公开泄漏所有模型的 API key。
+     *
+     * key / system_prompt 仅在请求者是 owner 本人时附带——dashboard 的模型编辑页
+     * (AiModelEdit)需要回填这两个字段。
      *
-     * @param  \Illuminate\Http\Request  $request
-     * @return array|\Illuminate\Contracts\Support\Arrayable|\JsonSerializable
+     * @param  Request  $request
+     * @return array
      */
     public function toArray($request)
     {
-        $data = parent::toArray($request);
-        $data['user'] = AiAssistantApi::userInfo($this);
+        $data = [
+            'uid' => $this->uid,
+            'name' => $this->name,
+            'description' => $this->description,
+            'url' => $this->url,
+            'model' => $this->model,
+            'privacy' => $this->privacy,
+            'owner_id' => $this->owner_id,
+            'editor_id' => $this->editor_id,
+            'created_at' => $this->created_at,
+            'updated_at' => $this->updated_at,
+            'user' => AiAssistantApi::userInfo($this),
+        ];
+
+        if ($this->isRequestedByOwner($request)) {
+            $data['key'] = $this->key;
+            $data['system_prompt'] = $this->system_prompt;
+        }
+
         return $data;
     }
+
+    /**
+     * 请求者是否为本模型的 owner。
+     *
+     * 结果按请求缓存:index() 一次可返回上千行,逐行解一次 JWT 代价过高。
+     */
+    private function isRequestedByOwner($request): bool
+    {
+        if (! $request->attributes->has('auth.current')) {
+            $request->attributes->set('auth.current', AuthService::current($request));
+        }
+        $user = $request->attributes->get('auth.current');
+
+        return $user && $user['user_uid'] === $this->owner_id;
+    }
 }

+ 51 - 0
api-v13/tests/Feature/AccessTokenExpiryTest.php

@@ -0,0 +1,51 @@
+<?php
+
+use App\Models\AccessToken;
+use Firebase\JWT\JWT;
+use Firebase\JWT\Key;
+use Illuminate\Foundation\Testing\RefreshDatabase;
+
+uses(RefreshDatabase::class);
+
+it('signs channel access tokens with an expiry', function () {
+    $owner = makeStudio('tester');
+    $channel = makeChannel($owner);
+
+    $token = $this->postJson('/api/v2/access-token', [
+        'payload' => [[
+            'res_type' => 'channel',
+            'res_id' => $channel,
+            'power' => 'edit',
+            'book' => 0,
+        ]],
+    ], authHeader($owner))
+        ->assertOk()
+        ->assertJsonPath('data.count', 1)
+        ->json('data.rows.0.token');
+
+    $key = AccessToken::where('res_id', $channel)->value('token');
+    $jwt = JWT::decode($token, new Key($key.$key, 'HS512'));
+
+    // 修复前 payload 里没有 exp,签出的 token 永久有效
+    expect($jwt->exp)->toBeGreaterThan(time());
+    expect($jwt->exp)->toBeLessThanOrEqual(time() + 60 * 60 * 24 * 7);
+    expect($jwt->res_id)->toBe($channel);
+});
+
+it('returns an empty row set when the user cannot edit the channel', function () {
+    $owner = makeStudio('owner');
+    $channel = makeChannel($owner);
+    $stranger = makeStudio('stranger');
+
+    // 无权时该条被静默跳过——客户端必须靠 count 判空,不能当成功
+    $this->postJson('/api/v2/access-token', [
+        'payload' => [[
+            'res_type' => 'channel',
+            'res_id' => $channel,
+            'power' => 'edit',
+            'book' => 0,
+        ]],
+    ], authHeader($stranger))
+        ->assertOk()
+        ->assertJsonPath('data.count', 0);
+});

+ 118 - 0
api-v13/tests/Feature/AiModelCrudTest.php

@@ -0,0 +1,118 @@
+<?php
+
+use App\Models\AiModel;
+use Illuminate\Foundation\Testing\RefreshDatabase;
+use Illuminate\Support\Str;
+
+uses(RefreshDatabase::class);
+
+it('stores every field the client sends', function () {
+    $ownerId = makeStudio('tester');
+
+    $this->postJson('/api/v2/ai-model', [
+        'studio_name' => 'tester',
+        'name' => 'claude-opus-5',
+        'model' => 'claude-opus-5-20260101',
+        'url' => 'https://api.anthropic.com',
+        'key' => 'sk-ant-test',
+        'privacy' => 'public',
+        'description' => 'writes sentences',
+    ], authHeader($ownerId))->assertOk();
+
+    $saved = AiModel::where('owner_id', $ownerId)->where('name', 'claude-opus-5')->first();
+
+    // 修复前 store() 只写 name/uid/real_name/owner_id/editor_id,其余全丢
+    expect($saved->model)->toBe('claude-opus-5-20260101');
+    expect($saved->url)->toBe('https://api.anthropic.com');
+    expect($saved->key)->toBe('sk-ant-test');
+    expect($saved->privacy)->toBe('public');
+    expect($saved->description)->toBe('writes sentences');
+});
+
+it('defaults privacy to private', function () {
+    $ownerId = makeStudio('tester');
+
+    $this->postJson('/api/v2/ai-model', [
+        'studio_name' => 'tester',
+        'name' => 'no-privacy-given',
+    ], authHeader($ownerId))->assertOk();
+
+    expect(AiModel::where('owner_id', $ownerId)->first()->privacy)->toBe('private');
+});
+
+it('refuses a duplicate name inside the same studio', function () {
+    $ownerId = makeStudio('tester');
+    AiModel::factory()->ownedBy($ownerId)->create(['name' => 'claude-opus-5']);
+
+    $this->postJson('/api/v2/ai-model', [
+        'studio_name' => 'tester',
+        'name' => 'claude-opus-5',
+    ], authHeader($ownerId))->assertStatus(409);
+
+    expect(AiModel::where('owner_id', $ownerId)->count())->toBe(1);
+});
+
+it('requires a name', function () {
+    $ownerId = makeStudio('tester');
+
+    $this->postJson('/api/v2/ai-model', [
+        'studio_name' => 'tester',
+    ], authHeader($ownerId))->assertStatus(422);
+});
+
+it('updates only the fields present in the request', function () {
+    $owner = (string) Str::uuid();
+    $model = AiModel::factory()->ownedBy($owner)->create([
+        'name' => 'claude-opus-5',
+        'key' => 'sk-keep-me',
+        'system_prompt' => 'keep me too',
+        'url' => 'https://api.anthropic.com',
+    ]);
+
+    // 只改 model 一个字段——这正是 Skill 的 ensure-model 会发的局部 PUT
+    $this->putJson("/api/v2/ai-model/{$model->uid}", [
+        'model' => 'claude-opus-5-20260101',
+    ], authHeader($owner))->assertOk();
+
+    $model->refresh();
+
+    expect($model->model)->toBe('claude-opus-5-20260101');
+    // 修复前这些字段会被 input() 的 null 覆盖掉
+    expect($model->key)->toBe('sk-keep-me');
+    expect($model->system_prompt)->toBe('keep me too');
+    expect($model->url)->toBe('https://api.anthropic.com');
+    expect($model->name)->toBe('claude-opus-5');
+});
+
+it('still allows clearing a field explicitly', function () {
+    $owner = (string) Str::uuid();
+    $model = AiModel::factory()->ownedBy($owner)->create(['description' => 'old text']);
+
+    $this->putJson("/api/v2/ai-model/{$model->uid}", [
+        'description' => null,
+    ], authHeader($owner))->assertOk();
+
+    expect($model->refresh()->description)->toBeNull();
+});
+
+it('refuses to rename onto an existing name', function () {
+    $owner = (string) Str::uuid();
+    AiModel::factory()->ownedBy($owner)->create(['name' => 'taken']);
+    $model = AiModel::factory()->ownedBy($owner)->create(['name' => 'mine']);
+
+    $this->putJson("/api/v2/ai-model/{$model->uid}", [
+        'name' => 'taken',
+    ], authHeader($owner))->assertStatus(409);
+
+    expect($model->refresh()->name)->toBe('mine');
+});
+
+it('rejects an update from a non-owner', function () {
+    $model = AiModel::factory()->create(['name' => 'untouched']);
+
+    $this->putJson("/api/v2/ai-model/{$model->uid}", [
+        'name' => 'hijacked',
+    ], authHeader((string) Str::uuid()))->assertStatus(403);
+
+    expect($model->refresh()->name)->toBe('untouched');
+});

+ 56 - 0
api-v13/tests/Feature/AiModelResourceTest.php

@@ -0,0 +1,56 @@
+<?php
+
+use App\Models\AiModel;
+use Illuminate\Foundation\Testing\RefreshDatabase;
+use Illuminate\Support\Str;
+
+uses(RefreshDatabase::class);
+
+const SECRET_KEY = 'sk-super-secret-api-key';
+const SECRET_PROMPT = 'you are a very secret assistant';
+
+function modelWithSecrets(string $ownerId): AiModel
+{
+    return AiModel::factory()->ownedBy($ownerId)->create([
+        'name' => 'secret-model',
+        'key' => SECRET_KEY,
+        'system_prompt' => SECRET_PROMPT,
+        'privacy' => 'public',
+    ]);
+}
+
+it('never exposes the api key to a stranger listing models', function () {
+    modelWithSecrets((string) Str::uuid());
+
+    $body = $this->getJson('/api/v2/ai-model?view=all', authHeader((string) Str::uuid()))
+        ->assertOk()
+        ->getContent();
+
+    expect($body)->not->toContain(SECRET_KEY);
+    expect($body)->not->toContain(SECRET_PROMPT);
+    // 非敏感字段仍须返回,否则前端列表会空
+    expect($body)->toContain('secret-model');
+});
+
+it('gives the owner back key and system_prompt so the edit form can prefill', function () {
+    $owner = (string) Str::uuid();
+    $model = modelWithSecrets($owner);
+
+    $this->getJson("/api/v2/ai-model/{$model->uid}", authHeader($owner))
+        ->assertOk()
+        ->assertJsonPath('data.key', SECRET_KEY)
+        ->assertJsonPath('data.system_prompt', SECRET_PROMPT);
+});
+
+it('does not leak internal columns', function () {
+    $owner = (string) Str::uuid();
+    $model = modelWithSecrets($owner);
+
+    $data = $this->getJson("/api/v2/ai-model/{$model->uid}", authHeader($owner))
+        ->assertOk()
+        ->json('data');
+
+    // real_name 是模型的登录身份标识,id 是自增主键,都不该外露
+    expect($data)->not->toHaveKey('real_name');
+    expect($data)->not->toHaveKey('id');
+});

+ 32 - 0
api-v13/tests/Feature/ChannelUserEditListTest.php

@@ -0,0 +1,32 @@
+<?php
+
+use Illuminate\Foundation\Testing\RefreshDatabase;
+
+uses(RefreshDatabase::class);
+
+/**
+ * Skill 用 view=user-edit 列出「我能编辑的 channel」供用户交互式选择,
+ * 而不是要求用户手工贴 channel uid(设计决策 3)。
+ */
+it('lists the channels the user can edit', function () {
+    $me = makeStudio('me');
+    $mine = makeChannel($me, 'my channel');
+    makeChannel(makeStudio('someone-else'), 'not mine');
+
+    $rows = $this->getJson('/api/v2/channel?view=user-edit', authHeader($me))
+        ->assertOk()
+        ->json('data.rows');
+
+    $uids = array_column($rows, 'uid');
+
+    expect($uids)->toContain($mine);
+    expect($uids)->toHaveCount(1);
+    // 交互式选择要展示的字段
+    expect($rows[0])->toHaveKeys(['uid', 'name', 'lang']);
+    expect($rows[0]['name'])->toBe('my channel');
+});
+
+it('requires authentication', function () {
+    $this->getJson('/api/v2/channel?view=user-edit')
+        ->assertJsonPath('ok', false);
+});