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

refactor(api-v13): UserApi / AiAssistantApi 改为转发壳,逻辑收进 UserService

app/Http/Api/ 这个目录名是错的:既不是 HTTP 层也不是对外 API,是一堆领域帮手
被塞在了 Http/ 下。21 个类、7569 行、约 414 处调用,逐步并进 app/Services/。

关键是旧类**不保留实现,改成零逻辑转发壳**。若 UserApi 留一份给 v2、
UserService 另写一份给 v3,就是同一段逻辑两个副本,必然漂移——而烂掉的那份
恰好是线上 v4 在用的。转发壳让逻辑只有一份,v2 的 65 个调用点一个不用改,
将来删目录退化成批量替换类名。

UserService 是重写不是搬运,拿到三样静态 helper 拿不到的东西:

- 单点取值成了批量取值的特例,拼形状只有一个出口
- 请求内身份映射(注册为 singleton),同一 uuid 在一次请求里只查一次库、
  只签一次头像 URL,含「确认查不到」的否定结果
- 只取用得到的列。原来 ->first() 是 SELECT *,每行把 user_infos.password、
  ai_models.key(API 密钥)一起捞进内存

byUuids() 返回 uuid => 摘要的映射;旧的 getListByUuid() 那种位置数组会丢项、
与输入对不上号,只作为兼容口径保留。byUuid(null) 现在零查询(旧实现会白查两表)。

顺带修一个 AiModel 的潜在 bug:它声明了 $primaryKey = 'uid' 和 casts uid=>string,
却漏了 $keyType。Eloquent 按默认 int 主键处理,关系预加载会走 whereIntegerInRaw
把 uuid 全部强转成 0(where "uid" in (0))。补上 $keyType = 'string',只影响关系
预加载的取值方式,$incrementing 未动、写入行为不变。

统一了一处已经漂开的差异:头像签名的环境判断,人类路径认 local+testing、AI 路径
只认 local。合并成前者,差异只在 testing 环境显现。

测试 16 条,一半钉住新行为(批量两次查询、全人类时一次、身份映射命中、否定结果
也记、flush 后重读、null 零查询),一半钉住转发壳与 Service 返回同一结果——
那是 v2 那 65 个调用点的兜底。

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

+ 13 - 52
api-v13/app/Http/Api/AiAssistantApi.php

@@ -2,66 +2,27 @@
 
 namespace App\Http\Api;
 
-use App\Models\AiModel;
-use Illuminate\Support\Facades\App;
-use Illuminate\Support\Facades\Storage;
+use App\Services\UserService;
 
+/**
+ * **冻结的转发壳,不要往里写逻辑。**
+ *
+ * 实现在 {@see UserService}——AI 助手与人类用户拼的是同一种摘要,原先分成两个类
+ * 只是历史结果,两份代码已经漂出了差异(头像在 testing 环境的处理不一致)。
+ *
+ * @deprecated 用 App\Services\UserService
+ */
 class AiAssistantApi
 {
+    /** @deprecated 用 UserService::assistantByUuid() */
     public static function getByUuid($id)
     {
-        $user = AiModel::where('uid', $id)->first();
-
-        return self::userInfo($user);
+        return app(UserService::class)->assistantByUuid($id);
     }
 
+    /** @deprecated 用 UserService::assistantProfile() */
     public static function userInfo($user)
     {
-        if (! $user) {
-            return [
-                'id' => 0,
-                'nickName' => 'unknown',
-                'userName' => 'unknown',
-                'realName' => 'unknown',
-                'avatar' => '',
-            ];
-        }
-        $data = [
-            'id' => $user->uid,
-            'nickName' => $user->name,
-            'userName' => $user->real_name,
-            'realName' => $user->real_name,
-            'roles' => ['ai'],
-            'sn' => 0,
-        ];
-
-        if ($user->avatar) {
-            $img = str_replace('.jpg', '_s.jpg', $user->avatar);
-            if (App::environment('local')) {
-                $data['avatar'] = Storage::url($img);
-            } else {
-                $data['avatar'] = Storage::temporaryUrl($img, now()->addDays(6));
-            }
-        } else {
-            $logo = null;
-            foreach (config('mint.ai.logo') as $key => $value) {
-                // model / url 均可为 null(新建模型时未必填写),须转字符串避免 strpos 弃用告警
-                if (strpos((string) $user->model, $key) !== false) {
-                    $logo = $value;
-                    break;
-                } elseif (strpos((string) $user->url, $key) !== false) {
-                    $logo = $value;
-                    break;
-                }
-            }
-            $base = config('app.url').'/assets/images/avatar/';
-            if ($logo === null) {
-                $data['avatar'] = $base.'ai-assistant.png';
-            } else {
-                $data['avatar'] = $base.$logo;
-            }
-        }
-
-        return $data;
+        return app(UserService::class)->assistantProfile($user);
     }
 }

+ 36 - 68
api-v13/app/Http/Api/UserApi.php

@@ -2,110 +2,78 @@
 
 namespace App\Http\Api;
 
-use App\Models\AiModel;
-use App\Models\UserInfo;
-use Illuminate\Support\Facades\App;
-use Illuminate\Support\Facades\Storage;
-
+use App\Services\UserService;
+
+/**
+ * **冻结的转发壳,不要往里写逻辑。**
+ *
+ * 实现全部在 {@see UserService}。保留这个类只为一件事:让 v2 的六十多个调用点
+ * 不必改动——改它们要配快照测试,而 dashboard-v4 在线上跑着且无人维护。
+ *
+ * 新代码一律注入 `UserService`。等本类的调用点清零,连同 `app/Http/Api/` 一起删。
+ *
+ * 方法名沿用 v2 的旧名,其中几个名不副实(`getIdByName` 返回的是 uuid,
+ * `getIdByUuid` 返回的是自增主键),`UserService` 那边已改成诚实的名字。
+ *
+ * @deprecated 用 App\Services\UserService
+ */
 class UserApi
 {
+    /** @deprecated 用 UserService::uuidByName() */
     public static function getIdByName($name)
     {
-        return UserInfo::where('username', $name)->value('userid');
+        return self::service()->uuidByName($name);
     }
 
+    /** @deprecated 用 UserService::intIdByUuid() */
     public static function getIdByUuid($uuid)
     {
-        return UserInfo::where('userid', $uuid)->value('id');
+        return self::service()->intIdByUuid($uuid);
     }
 
+    /** @deprecated 用 UserService::intIdByName() */
     public static function getIntIdByName($name)
     {
-        return UserInfo::where('username', $name)->value('id');
+        return self::service()->intIdByName($name);
     }
 
+    /** @deprecated 用 UserService::byId() */
     public static function getById($id)
     {
-        $user = UserInfo::where('id', $id)->first();
-
-        return UserApi::userInfo($user);
+        return self::service()->byId($id);
     }
 
+    /** @deprecated 用 UserService::byName() */
     public static function getByName($name)
     {
-        $user = UserInfo::where('username', $name)->first();
-
-        return UserApi::userInfo($user);
+        return self::service()->byName($name);
     }
 
+    /** @deprecated 用 UserService::byUuid();列表场景用 byUuids(),别循环调这个 */
     public static function getByUuid($id)
     {
-        $user = UserInfo::where('userid', $id)->first();
-        if (! $user) {
-            return AiAssistantApi::getByUuid($id);
-        }
-
-        return UserApi::userInfo($user);
+        return self::service()->byUuid($id);
     }
 
+    /** @deprecated 用 UserService::byUuids(),它返回 uuid => 摘要的映射,不会丢项 */
     public static function getListByUuid($uuid)
     {
+        // 非数组返回 null 是 v2 的既有口径,留在适配层,不要带进 Service
         if (! $uuid || ! is_array($uuid)) {
             return null;
         }
-        $users = UserInfo::whereIn('userid', $uuid)->get();
-        $assistants = AiModel::whereIn('uid', $uuid)->get();
-        $output = [];
-        foreach ($uuid as $key => $id) {
-            foreach ($users as $user) {
-                if ($user->userid === $id) {
-                    $output[] = UserApi::userInfo($user);
-
-                    continue;
-                }
-            }
-            foreach ($assistants as $assistant) {
-                if ($assistant->uid === $id) {
-                    $output[] = AiAssistantApi::userInfo($assistant);
 
-                    continue;
-                }
-            }
-        }
-
-        return $output;
+        return self::service()->listByUuids($uuid);
     }
 
+    /** @deprecated 用 UserService::profile() */
     public static function userInfo($user)
     {
-        if (! $user) {
-            return [
-                'id' => 0,
-                'nickName' => 'unknown',
-                'userName' => 'unknown',
-                'realName' => 'unknown',
-                'avatar' => '',
-            ];
-        }
-        $data = [
-            'id' => $user->userid,
-            'nickName' => $user->nickname,
-            'userName' => $user->username,
-            'realName' => $user->username,
-            'sn' => $user->id,
-        ];
-        if (! empty($user->role)) {
-            $data['roles'] = json_decode($user->role);
-        }
-        if ($user->avatar) {
-            $img = str_replace('.jpg', '_s.jpg', $user->avatar);
-            if (App::environment(['local', 'testing'])) {
-                $data['avatar'] = Storage::url($img);
-            } else {
-                $data['avatar'] = Storage::temporaryUrl($img, now()->addDays(6));
-            }
-        }
+        return self::service()->profile($user);
+    }
 
-        return $data;
+    private static function service(): UserService
+    {
+        return app(UserService::class);
     }
 }

+ 11 - 0
api-v13/app/Models/AiModel.php

@@ -11,6 +11,17 @@ class AiModel extends Model
 
     protected $primaryKey = 'uid';
 
+    /**
+     * 主键 `uid` 是 uuid 字符串。
+     *
+     * 不声明这一行,Eloquent 按默认的 int 主键处理,关系预加载会走
+     * `whereIntegerInRaw`,把 uuid 全部强转成 0(`where "uid" in (0)`)。
+     * 下面的 casts 本来就写着 string,这里只是把两处声明对齐。
+     *
+     * 只影响关系预加载的取值方式;`$incrementing` 未动,写入行为不变。
+     */
+    protected $keyType = 'string';
+
     protected $casts = [
         'uid' => 'string',
     ];

+ 13 - 0
api-v13/app/Providers/AppServiceProvider.php

@@ -4,6 +4,7 @@ namespace App\Providers;
 
 use App\Services\PaliSeriesesService;
 use App\Services\RomanizeService;
+use App\Services\UserService;
 use App\Tools\QueryBuilderMacro;
 use App\View\Composers\BlogViewComposer;
 use Carbon\CarbonImmutable;
@@ -56,6 +57,18 @@ class AppServiceProvider extends ServiceProvider
         |--------------------------------------------------------------------------
         */
         $this->app->singleton(PaliSeriesesService::class);
+
+        /*
+        |--------------------------------------------------------------------------
+        | User Service
+        |--------------------------------------------------------------------------
+        |
+        | 必须是 singleton:它在请求内维护一张 uuid => 摘要的身份映射,同一个用户
+        | 在一次请求里被解析多次时只查一次库、只签一次头像 URL。每次 make 新实例
+        | 那张映射就永远是空的。
+        |
+        */
+        $this->app->singleton(UserService::class);
     }
 
     /**

+ 382 - 0
api-v13/app/Services/UserService.php

@@ -0,0 +1,382 @@
+<?php
+
+namespace App\Services;
+
+use App\Http\Resources\Concerns\ResolvesActor;
+use App\Models\AiModel;
+use App\Models\UserInfo;
+use Illuminate\Database\Eloquent\Builder;
+use Illuminate\Database\Eloquent\Model;
+use Illuminate\Support\Facades\App;
+use Illuminate\Support\Facades\Storage;
+
+/**
+ * 操作者摘要(昵称、头像、角色)的唯一实现。
+ *
+ * 取代 `App\Http\Api\UserApi` 与 `App\Http\Api\AiAssistantApi`——那两个类已经改成
+ * 零逻辑的转发壳,v2 的六十多个调用点一个都不用动。等调用点全部换成注入本类,
+ * `app/Http/Api/` 才可以删。**不要再往那两个壳里写逻辑。**
+ *
+ * 「操作者」有两种:人类存 `user_infos`,AI 助手存 `ai_models`。两者共用一个 uuid
+ * 空间,且没有类型判别列,所以解析顺序恒定为「先人、后 AI、都没有就给占位」。
+ *
+ * 相对原先两个静态类的三点改动:
+ *
+ * 1. **单点取值是批量取值的特例。** 原先单条走一条路、列表走另一条路,两处各自
+ *    拼形状。现在只有 {@see self::load()} 一个出口。
+ * 2. **请求内身份映射。** 同一个 uuid 在一次请求里往往要解析很多次(列表里同一个
+ *    编辑者、反复出现的当前用户)。本类注册为 singleton,解析过的记在 `$resolved`
+ *    里,含「确认查不到」的否定结果,所以重复查询和重复的头像签名都只发生一次。
+ * 3. **只取用得到的列。** 原先是 `->first()`(`SELECT *`),每行把 `password`、
+ *    `reset_password_token`、AI 的 `key` 一起捞进内存。
+ */
+class UserService
+{
+    /**
+     * 查不到人时的占位摘要。
+     *
+     * 键集合比真实摘要少(没有 `sn` / `roles`),这是 v2 既有口径,前端在依赖,
+     * 不要「顺手补齐」。
+     */
+    private const UNKNOWN = [
+        'id' => 0,
+        'nickName' => 'unknown',
+        'userName' => 'unknown',
+        'realName' => 'unknown',
+        'avatar' => '',
+    ];
+
+    /** 拼人类摘要用得到的列;`userid` 是匹配列,不能省。 */
+    private const USER_COLUMNS = ['userid', 'id', 'nickname', 'username', 'role', 'avatar'];
+
+    /** 拼 AI 摘要用得到的列;`uid` 是匹配列,不能省。 */
+    private const ASSISTANT_COLUMNS = ['uid', 'name', 'real_name', 'avatar', 'model', 'url'];
+
+    /**
+     * 请求内的身份映射:uuid => 摘要,或 null 表示「已确认查不到」。
+     *
+     * 记否定结果是为了让查不到的 uuid 也只查一次。进程常驻(octane / 长跑命令)
+     * 时它是陈旧数据的来源,改过用户资料又要重读的场合调 {@see self::flush()}。
+     *
+     * @var array<string, array<string, mixed>|null>
+     */
+    private array $resolved = [];
+
+    /**
+     * 按 uuid 取一个操作者的摘要;查不到给占位。
+     *
+     * @param  string|null  $uuid  允许为 null:调用方常直接把可空的 editor_id 传进来
+     * @return array<string, mixed>
+     */
+    public function byUuid(?string $uuid): array
+    {
+        return $this->byUuids([$uuid])[$uuid] ?? self::UNKNOWN;
+    }
+
+    /**
+     * 批量取摘要,返回 **uuid => 摘要** 的映射。
+     *
+     * 最多两次查询,与 uuid 个数无关;`ai_models` 只在人类表还没解析完时才查。
+     * 已经在身份映射里的 uuid 不参与查询。
+     *
+     * 解析不到的 uuid **不出现在结果里**——由调用方决定省略还是补占位,比塞一个
+     * unknown 进去更诚实。列表渲染请用这个方法,不要循环调 {@see self::byUuid()}。
+     *
+     * @param  array<int, string|null>  $uuids
+     * @return array<string, array<string, mixed>>
+     */
+    public function byUuids(array $uuids): array
+    {
+        $wanted = array_values(array_unique(array_filter(
+            $uuids,
+            static fn ($uuid): bool => is_string($uuid) && $uuid !== '',
+        )));
+        if (! $wanted) {
+            return [];
+        }
+
+        $unseen = array_values(array_diff($wanted, array_keys($this->resolved)));
+        if ($unseen) {
+            $loaded = $this->load($unseen);
+            foreach ($unseen as $uuid) {
+                $this->resolved[$uuid] = $loaded[$uuid] ?? null;
+            }
+        }
+
+        $output = [];
+        foreach ($wanted as $uuid) {
+            if ($this->resolved[$uuid] !== null) {
+                $output[$uuid] = $this->resolved[$uuid];
+            }
+        }
+
+        return $output;
+    }
+
+    /**
+     * 从「人类行」与「AI 行」两个候选里定出操作者摘要。
+     *
+     * 这是解析顺序(先人、后 AI、都没有给占位)的唯一实现。传进来的应当是**已经
+     * 预加载好的模型**,本方法不查库——它只负责拼形状。列表渲染时每行调用它是安全的。
+     *
+     * Resource 里直接用 {@see ResolvesActor::actor()},
+     * 不必自己解析本服务。
+     *
+     * @param  mixed  $human  一行 `user_infos`,没有传 null
+     * @param  mixed  $assistant  一行 `ai_models`,没有传 null
+     * @return array<string, mixed>
+     */
+    public function actor($human, $assistant = null): array
+    {
+        return $human ? $this->profile($human) : $this->assistantProfile($assistant);
+    }
+
+    /**
+     * 操作者两条关系的预加载声明,直接喂给 `->with()`。
+     *
+     * 把列白名单收在这里,而不是让每个控制器各抄一份长字符串——那串东西一旦漏掉
+     * 匹配列(`userid` / `uid`),关系会静默对不上、整列变 null,不报错。
+     *
+     *     Reaction::query()->with(UserService::eagerLoadActor())
+     *     Sentence::query()->with(UserService::eagerLoadActor('editor', 'editorAssistant'))
+     *
+     * @param  string  $human  人类那条关系的名字
+     * @param  string  $assistant  AI 那条关系的名字
+     * @return array<int, string>
+     */
+    public static function eagerLoadActor(string $human = 'user', string $assistant = 'aiModel'): array
+    {
+        return [
+            $human.':'.implode(',', self::USER_COLUMNS),
+            $assistant.':'.implode(',', self::ASSISTANT_COLUMNS),
+        ];
+    }
+
+    /**
+     * 批量取摘要,返回**位置数组**:保持传入顺序,解析不到的整条丢弃。
+     *
+     * 这是 `UserApi::getListByUuid()` 的既有口径,只为兼容旧调用点而存在。
+     * 新代码用 {@see self::byUuids()}——位置数组一旦丢项就和输入对不上号了。
+     *
+     * @param  array<int, string|null>  $uuids
+     * @return array<int, array<string, mixed>>
+     */
+    public function listByUuids(array $uuids): array
+    {
+        $map = $this->byUuids($uuids);
+
+        $output = [];
+        foreach ($uuids as $uuid) {
+            if (is_string($uuid) && isset($map[$uuid])) {
+                $output[] = $map[$uuid];
+            }
+        }
+
+        return $output;
+    }
+
+    /**
+     * 按 uuid 取 AI 助手的摘要,跳过人类表;查不到给占位。
+     *
+     * @return array<string, mixed>
+     */
+    public function assistantByUuid(?string $uuid): array
+    {
+        $assistant = $this->query(AiModel::class, 'uid', [$uuid], self::ASSISTANT_COLUMNS)->first();
+
+        return $assistant ? $this->assistantProfile($assistant) : self::UNKNOWN;
+    }
+
+    /**
+     * 按自增主键取人类用户的摘要。
+     *
+     * @return array<string, mixed>
+     */
+    public function byId(int|string|null $id): array
+    {
+        $user = UserInfo::query()->where('id', $id)->first(self::USER_COLUMNS);
+
+        return $user ? $this->profile($user) : self::UNKNOWN;
+    }
+
+    /**
+     * 按用户名取人类用户的摘要。
+     *
+     * @return array<string, mixed>
+     */
+    public function byName(?string $name): array
+    {
+        $user = UserInfo::query()->where('username', $name)->first(self::USER_COLUMNS);
+
+        return $user ? $this->profile($user) : self::UNKNOWN;
+    }
+
+    /** 用户名 → uuid(`user_infos.userid`)。 */
+    public function uuidByName(?string $name): ?string
+    {
+        return UserInfo::query()->where('username', $name)->value('userid');
+    }
+
+    /** uuid → 自增主键(`user_infos.id`)。 */
+    public function intIdByUuid(?string $uuid): ?int
+    {
+        return UserInfo::query()->where('userid', $uuid)->value('id');
+    }
+
+    /** 用户名 → 自增主键(`user_infos.id`)。 */
+    public function intIdByName(?string $name): ?int
+    {
+        return UserInfo::query()->where('username', $name)->value('id');
+    }
+
+    /**
+     * 把一行 `user_infos` 拼成公开摘要。
+     *
+     * 参数刻意不加类型:传进来的既有 UserInfo 模型,也有包着模型的 Resource。
+     *
+     * @return array<string, mixed>
+     */
+    public function profile($user): array
+    {
+        if (! $user) {
+            return self::UNKNOWN;
+        }
+
+        $profile = [
+            'id' => $user->userid,
+            'nickName' => $user->nickname,
+            'userName' => $user->username,
+            'realName' => $user->username,
+            'sn' => $user->id,
+        ];
+
+        // roles 与 avatar 是可选键:没有就整个键不出现,不是给个空值。
+        // 这是 v2 的既有口径,前端用 `in` 判断,别改成恒定存在。
+        if (! empty($user->role)) {
+            $profile['roles'] = json_decode($user->role);
+        }
+        if ($avatar = $this->avatarUrl($user->avatar)) {
+            $profile['avatar'] = $avatar;
+        }
+
+        return $profile;
+    }
+
+    /**
+     * 把一行 `ai_models` 拼成公开摘要。
+     *
+     * 参数刻意不加类型:`AiModelResource` 传进来的是 Resource 而不是模型。
+     *
+     * @return array<string, mixed>
+     */
+    public function assistantProfile($assistant): array
+    {
+        if (! $assistant) {
+            return self::UNKNOWN;
+        }
+
+        return [
+            'id' => $assistant->uid,
+            'nickName' => $assistant->name,
+            'userName' => $assistant->real_name,
+            'realName' => $assistant->real_name,
+            'roles' => ['ai'],
+            'sn' => 0,
+            'avatar' => $this->avatarUrl($assistant->avatar)
+                ?? $this->assistantLogo($assistant->model, $assistant->url),
+        ];
+    }
+
+    /**
+     * 清空请求内的身份映射。
+     *
+     * 只有「同一进程里改过用户资料又要重新读出来」的场合才需要,例如长跑的
+     * artisan 命令。普通 HTTP 请求不必调用。
+     */
+    public function flush(): void
+    {
+        $this->resolved = [];
+    }
+
+    /**
+     * 一批 uuid 的实际装载:先查人类表,剩下的才查 AI 表。
+     *
+     * 两次简单查询而不是一次 union/join:两张表各自的 uuid 列上都有唯一索引,
+     * 分开查各走各的索引,第二次还常常因为没有剩余 uuid 而根本不发生。
+     *
+     * @param  array<int, string>  $uuids
+     * @return array<string, array<string, mixed>>
+     */
+    private function load(array $uuids): array
+    {
+        $found = [];
+        foreach ($this->query(UserInfo::class, 'userid', $uuids, self::USER_COLUMNS)->get() as $user) {
+            $found[$user->userid] = $this->profile($user);
+        }
+
+        $rest = array_values(array_diff($uuids, array_keys($found)));
+        if (! $rest) {
+            return $found;
+        }
+
+        foreach ($this->query(AiModel::class, 'uid', $rest, self::ASSISTANT_COLUMNS)->get() as $assistant) {
+            $found[$assistant->uid] = $this->assistantProfile($assistant);
+        }
+
+        return $found;
+    }
+
+    /**
+     * 按 uuid 列表查某张表,只取指定列。
+     *
+     * @param  class-string<Model>  $model
+     * @param  array<int, string|null>  $uuids
+     * @param  array<int, string>  $columns
+     * @return Builder<covariant \Illuminate\Database\Eloquent\Model>
+     */
+    private function query(string $model, string $column, array $uuids, array $columns)
+    {
+        return $model::query()->whereIn($column, $uuids)->select($columns);
+    }
+
+    /**
+     * 头像缩略图地址;没有头像返回 null。
+     *
+     * 生产环境给 6 天有效期的签名 URL,本地与测试环境给普通 URL——签名要连对象
+     * 存储,测试里会直接炸。
+     *
+     * 原先人类路径认 `local` + `testing`、AI 路径只认 `local`,是两份副本漂移的
+     * 结果,这里统一成前者。差异只在 testing 环境显现,生产行为不变。
+     */
+    private function avatarUrl(?string $avatar): ?string
+    {
+        if (! $avatar) {
+            return null;
+        }
+        $img = str_replace('.jpg', '_s.jpg', $avatar);
+
+        return App::environment(['local', 'testing'])
+            ? Storage::url($img)
+            : Storage::temporaryUrl($img, now()->addDays(6));
+    }
+
+    /**
+     * AI 助手没有自定义头像时的内置 logo:按 model / url 里的关键字挑,都不匹配
+     * 就用通用图标。
+     */
+    private function assistantLogo(?string $model, ?string $url): string
+    {
+        $logo = 'ai-assistant.png';
+        // model 与 url 分别匹配,不要拼成一个串:关键字可能跨越拼接处产生假匹配。
+        // 两者都可为 null(新建模型时未必填写),转字符串避免 str_contains 报类型错。
+        foreach (config('mint.ai.logo', []) as $keyword => $file) {
+            if (str_contains((string) $model, (string) $keyword)
+                || str_contains((string) $url, (string) $keyword)) {
+                $logo = $file;
+                break;
+            }
+        }
+
+        return config('app.url').'/assets/images/avatar/'.$logo;
+    }
+}

+ 234 - 0
api-v13/tests/Feature/UserServiceTest.php

@@ -0,0 +1,234 @@
+<?php
+
+use App\Http\Api\AiAssistantApi;
+use App\Http\Api\UserApi;
+use App\Models\AiModel;
+use App\Models\UserInfo;
+use App\Services\UserService;
+use Illuminate\Foundation\Testing\RefreshDatabase;
+use Illuminate\Support\Facades\DB;
+use Illuminate\Support\Str;
+
+uses(RefreshDatabase::class);
+
+/**
+ * UserService 取代 UserApi / AiAssistantApi 两个静态类。
+ *
+ * 这里断言两件事:
+ *   1. 新实现的行为(批量、身份映射、占位口径)
+ *   2. 转发壳与 Service 返回同一结果——v2 的六十多个调用点靠这条兜底
+ */
+function makeAssistant(string $name, array $attrs = []): AiModel
+{
+    $assistant = new AiModel;
+    $assistant->forceFill(array_merge([
+        'id' => random_int(1, PHP_INT_MAX),
+        'uid' => (string) Str::uuid(),
+        'name' => $name,
+        'real_name' => $name.'-real',
+        'model' => 'gpt-4',
+        'url' => null,
+        'avatar' => null,
+        // ai_models 的 owner_id / editor_id 是 NOT NULL 且无默认值
+        'owner_id' => (string) Str::uuid(),
+        'editor_id' => (string) Str::uuid(),
+    ], $attrs))->save();
+
+    return $assistant;
+}
+
+it('resolves a human user', function () {
+    $uid = makeStudio('alice');
+
+    $profile = app(UserService::class)->byUuid($uid);
+
+    expect($profile['id'])->toBe($uid)
+        ->and($profile['nickName'])->toBe('alice')
+        ->and($profile['userName'])->toBe('alice')
+        ->and($profile['sn'])->toBeGreaterThan(0);
+});
+
+it('falls back to an ai assistant when the uuid is not a human', function () {
+    $assistant = makeAssistant('claude');
+
+    $profile = app(UserService::class)->byUuid($assistant->uid);
+
+    expect($profile['id'])->toBe($assistant->uid)
+        ->and($profile['nickName'])->toBe('claude')
+        ->and($profile['roles'])->toBe(['ai'])
+        ->and($profile['sn'])->toBe(0);
+});
+
+it('returns the unknown placeholder for an unresolvable uuid', function () {
+    $profile = app(UserService::class)->byUuid((string) Str::uuid());
+
+    expect($profile)->toBe([
+        'id' => 0,
+        'nickName' => 'unknown',
+        'userName' => 'unknown',
+        'realName' => 'unknown',
+        'avatar' => '',
+    ]);
+});
+
+it('costs no query for a null uuid', function () {
+    DB::enableQueryLog();
+    DB::flushQueryLog();
+    $profile = app(UserService::class)->byUuid(null);
+    $queries = count(DB::getQueryLog());
+    DB::disableQueryLog();
+
+    // 旧实现会为 null 白查两张表
+    expect($queries)->toBe(0)
+        ->and($profile['nickName'])->toBe('unknown');
+});
+
+it('resolves a mixed batch in two queries regardless of size', function () {
+    $humans = collect(range(1, 6))->map(fn ($i) => makeStudio("batch-user-{$i}"))->all();
+    $assistants = collect(range(1, 3))->map(fn ($i) => makeAssistant("batch-ai-{$i}")->uid)->all();
+    $orphan = (string) Str::uuid();
+
+    DB::enableQueryLog();
+    DB::flushQueryLog();
+    $map = app(UserService::class)->byUuids([...$humans, ...$assistants, $orphan]);
+    $queries = count(DB::getQueryLog());
+    DB::disableQueryLog();
+
+    // user_infos 一次 + ai_models 一次,与 uuid 个数无关
+    expect($queries)->toBe(2)
+        ->and($map)->toHaveCount(9)          // 解析不到的 orphan 不出现在结果里
+        ->and($map)->not->toHaveKey($orphan)
+        ->and($map[$humans[0]]['nickName'])->toBe('batch-user-1')
+        ->and($map[$assistants[0]]['roles'])->toBe(['ai']);
+});
+
+it('skips ai_models entirely when every uuid is a human', function () {
+    $humans = collect(range(1, 4))->map(fn ($i) => makeStudio("human-only-{$i}"))->all();
+
+    DB::enableQueryLog();
+    DB::flushQueryLog();
+    app(UserService::class)->byUuids($humans);
+    $queries = count(DB::getQueryLog());
+    DB::disableQueryLog();
+
+    expect($queries)->toBe(1);
+});
+
+it('remembers resolved identities within the request', function () {
+    $uid = makeStudio('memoized');
+    $service = app(UserService::class);
+    $service->byUuid($uid);
+
+    DB::enableQueryLog();
+    DB::flushQueryLog();
+    $again = $service->byUuid($uid);
+    $queries = count(DB::getQueryLog());
+    DB::disableQueryLog();
+
+    expect($queries)->toBe(0)
+        ->and($again['nickName'])->toBe('memoized');
+});
+
+it('remembers negative results too', function () {
+    $orphan = (string) Str::uuid();
+    $service = app(UserService::class);
+    $service->byUuid($orphan);
+
+    DB::enableQueryLog();
+    DB::flushQueryLog();
+    $service->byUuid($orphan);
+    $queries = count(DB::getQueryLog());
+    DB::disableQueryLog();
+
+    expect($queries)->toBe(0);
+});
+
+it('re-reads after flush', function () {
+    $uid = makeStudio('flushed');
+    $service = app(UserService::class);
+    $service->byUuid($uid);
+    $service->flush();
+
+    DB::enableQueryLog();
+    DB::flushQueryLog();
+    $service->byUuid($uid);
+    $queries = count(DB::getQueryLog());
+    DB::disableQueryLog();
+
+    expect($queries)->toBeGreaterThan(0);
+});
+
+it('keeps input order and drops misses in the positional list', function () {
+    $a = makeStudio('list-a');
+    $b = makeStudio('list-b');
+    $orphan = (string) Str::uuid();
+
+    $list = app(UserService::class)->listByUuids([$b, $orphan, $a]);
+
+    expect($list)->toHaveCount(2)
+        ->and($list[0]['nickName'])->toBe('list-b')
+        ->and($list[1]['nickName'])->toBe('list-a');
+});
+
+it('resolves either candidate through actor()', function () {
+    $service = app(UserService::class);
+    $uid = makeStudio('either-human');
+    $human = UserInfo::where('userid', $uid)->first();
+    $assistant = makeAssistant('either-ai');
+
+    // 人类优先,AI 兜底,都没有给占位
+    expect($service->actor($human, $assistant)['nickName'])->toBe('either-human')
+        ->and($service->actor(null, $assistant)['nickName'])->toBe('either-ai')
+        ->and($service->actor(null, null)['nickName'])->toBe('unknown');
+});
+
+it('always includes the matching column in the eager load spec', function () {
+    [$human, $assistant] = UserService::eagerLoadActor();
+
+    // 漏掉匹配列不会报错,只会让整列静默变 null——这条守的就是那个
+    expect($human)->toStartWith('user:')
+        ->and(explode(',', explode(':', $human)[1]))->toContain('userid')
+        ->and($assistant)->toStartWith('aiModel:')
+        ->and(explode(',', explode(':', $assistant)[1]))->toContain('uid');
+
+    // 关系名可换,列白名单不跟着走样
+    [$editor] = UserService::eagerLoadActor('editor', 'editorAssistant');
+    expect($editor)->toStartWith('editor:')
+        ->and(explode(',', explode(':', $editor)[1]))->toContain('userid');
+});
+
+it('forwards UserApi to the service unchanged', function () {
+    $uid = makeStudio('forwarded');
+    $service = app(UserService::class);
+
+    expect(UserApi::getByUuid($uid))->toBe($service->byUuid($uid))
+        ->and(UserApi::getIdByName('forwarded'))->toBe($uid)
+        ->and(UserApi::getIdByUuid($uid))->toBe($service->intIdByUuid($uid))
+        ->and(UserApi::getIntIdByName('forwarded'))->toBe($service->intIdByName('forwarded'))
+        ->and(UserApi::getByName('forwarded'))->toBe($service->byName('forwarded'))
+        ->and(UserApi::userInfo(null))->toBe($service->profile(null));
+});
+
+it('keeps the v2 quirk of returning null for a non-array list', function () {
+    expect(UserApi::getListByUuid(null))->toBeNull()
+        ->and(UserApi::getListByUuid('not-an-array'))->toBeNull()
+        ->and(UserApi::getListByUuid([]))->toBeNull();
+});
+
+it('forwards AiAssistantApi to the service unchanged', function () {
+    $assistant = makeAssistant('forwarded-ai');
+    $service = app(UserService::class);
+
+    expect(AiAssistantApi::getByUuid($assistant->uid))->toBe($service->assistantByUuid($assistant->uid))
+        ->and(AiAssistantApi::userInfo($assistant))->toBe($service->assistantProfile($assistant))
+        ->and(AiAssistantApi::userInfo(null))->toBe($service->assistantProfile(null));
+});
+
+it('picks a built-in logo for an assistant without an avatar', function () {
+    $assistant = makeAssistant('logo-test', ['model' => 'gpt-4', 'url' => null]);
+
+    $avatar = app(UserService::class)->assistantProfile($assistant)['avatar'];
+
+    expect($avatar)->toStartWith(config('app.url').'/assets/images/avatar/')
+        ->and($avatar)->toEndWith('.png');
+});