UserService.php 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382
  1. <?php
  2. namespace App\Services;
  3. use App\Http\Resources\Concerns\ResolvesActor;
  4. use App\Models\AiModel;
  5. use App\Models\UserInfo;
  6. use Illuminate\Database\Eloquent\Builder;
  7. use Illuminate\Database\Eloquent\Model;
  8. use Illuminate\Support\Facades\App;
  9. use Illuminate\Support\Facades\Storage;
  10. /**
  11. * 操作者摘要(昵称、头像、角色)的唯一实现。
  12. *
  13. * 取代 `App\Http\Api\UserApi` 与 `App\Http\Api\AiAssistantApi`——那两个类已经改成
  14. * 零逻辑的转发壳,v2 的六十多个调用点一个都不用动。等调用点全部换成注入本类,
  15. * `app/Http/Api/` 才可以删。**不要再往那两个壳里写逻辑。**
  16. *
  17. * 「操作者」有两种:人类存 `user_infos`,AI 助手存 `ai_models`。两者共用一个 uuid
  18. * 空间,且没有类型判别列,所以解析顺序恒定为「先人、后 AI、都没有就给占位」。
  19. *
  20. * 相对原先两个静态类的三点改动:
  21. *
  22. * 1. **单点取值是批量取值的特例。** 原先单条走一条路、列表走另一条路,两处各自
  23. * 拼形状。现在只有 {@see self::load()} 一个出口。
  24. * 2. **请求内身份映射。** 同一个 uuid 在一次请求里往往要解析很多次(列表里同一个
  25. * 编辑者、反复出现的当前用户)。本类注册为 singleton,解析过的记在 `$resolved`
  26. * 里,含「确认查不到」的否定结果,所以重复查询和重复的头像签名都只发生一次。
  27. * 3. **只取用得到的列。** 原先是 `->first()`(`SELECT *`),每行把 `password`、
  28. * `reset_password_token`、AI 的 `key` 一起捞进内存。
  29. */
  30. class UserService
  31. {
  32. /**
  33. * 查不到人时的占位摘要。
  34. *
  35. * 键集合比真实摘要少(没有 `sn` / `roles`),这是 v2 既有口径,前端在依赖,
  36. * 不要「顺手补齐」。
  37. */
  38. private const UNKNOWN = [
  39. 'id' => 0,
  40. 'nickName' => 'unknown',
  41. 'userName' => 'unknown',
  42. 'realName' => 'unknown',
  43. 'avatar' => '',
  44. ];
  45. /** 拼人类摘要用得到的列;`userid` 是匹配列,不能省。 */
  46. private const USER_COLUMNS = ['userid', 'id', 'nickname', 'username', 'role', 'avatar'];
  47. /** 拼 AI 摘要用得到的列;`uid` 是匹配列,不能省。 */
  48. private const ASSISTANT_COLUMNS = ['uid', 'name', 'real_name', 'avatar', 'model', 'url'];
  49. /**
  50. * 请求内的身份映射:uuid => 摘要,或 null 表示「已确认查不到」。
  51. *
  52. * 记否定结果是为了让查不到的 uuid 也只查一次。进程常驻(octane / 长跑命令)
  53. * 时它是陈旧数据的来源,改过用户资料又要重读的场合调 {@see self::flush()}。
  54. *
  55. * @var array<string, array<string, mixed>|null>
  56. */
  57. private array $resolved = [];
  58. /**
  59. * 按 uuid 取一个操作者的摘要;查不到给占位。
  60. *
  61. * @param string|null $uuid 允许为 null:调用方常直接把可空的 editor_id 传进来
  62. * @return array<string, mixed>
  63. */
  64. public function byUuid(?string $uuid): array
  65. {
  66. return $this->byUuids([$uuid])[$uuid] ?? self::UNKNOWN;
  67. }
  68. /**
  69. * 批量取摘要,返回 **uuid => 摘要** 的映射。
  70. *
  71. * 最多两次查询,与 uuid 个数无关;`ai_models` 只在人类表还没解析完时才查。
  72. * 已经在身份映射里的 uuid 不参与查询。
  73. *
  74. * 解析不到的 uuid **不出现在结果里**——由调用方决定省略还是补占位,比塞一个
  75. * unknown 进去更诚实。列表渲染请用这个方法,不要循环调 {@see self::byUuid()}。
  76. *
  77. * @param array<int, string|null> $uuids
  78. * @return array<string, array<string, mixed>>
  79. */
  80. public function byUuids(array $uuids): array
  81. {
  82. $wanted = array_values(array_unique(array_filter(
  83. $uuids,
  84. static fn ($uuid): bool => is_string($uuid) && $uuid !== '',
  85. )));
  86. if (! $wanted) {
  87. return [];
  88. }
  89. $unseen = array_values(array_diff($wanted, array_keys($this->resolved)));
  90. if ($unseen) {
  91. $loaded = $this->load($unseen);
  92. foreach ($unseen as $uuid) {
  93. $this->resolved[$uuid] = $loaded[$uuid] ?? null;
  94. }
  95. }
  96. $output = [];
  97. foreach ($wanted as $uuid) {
  98. if ($this->resolved[$uuid] !== null) {
  99. $output[$uuid] = $this->resolved[$uuid];
  100. }
  101. }
  102. return $output;
  103. }
  104. /**
  105. * 从「人类行」与「AI 行」两个候选里定出操作者摘要。
  106. *
  107. * 这是解析顺序(先人、后 AI、都没有给占位)的唯一实现。传进来的应当是**已经
  108. * 预加载好的模型**,本方法不查库——它只负责拼形状。列表渲染时每行调用它是安全的。
  109. *
  110. * Resource 里直接用 {@see ResolvesActor::actor()},
  111. * 不必自己解析本服务。
  112. *
  113. * @param mixed $human 一行 `user_infos`,没有传 null
  114. * @param mixed $assistant 一行 `ai_models`,没有传 null
  115. * @return array<string, mixed>
  116. */
  117. public function actor($human, $assistant = null): array
  118. {
  119. return $human ? $this->profile($human) : $this->assistantProfile($assistant);
  120. }
  121. /**
  122. * 操作者两条关系的预加载声明,直接喂给 `->with()`。
  123. *
  124. * 把列白名单收在这里,而不是让每个控制器各抄一份长字符串——那串东西一旦漏掉
  125. * 匹配列(`userid` / `uid`),关系会静默对不上、整列变 null,不报错。
  126. *
  127. * Reaction::query()->with(UserService::eagerLoadActor())
  128. * Sentence::query()->with(UserService::eagerLoadActor('editor', 'editorAssistant'))
  129. *
  130. * @param string $human 人类那条关系的名字
  131. * @param string $assistant AI 那条关系的名字
  132. * @return array<int, string>
  133. */
  134. public static function eagerLoadActor(string $human = 'user', string $assistant = 'aiModel'): array
  135. {
  136. return [
  137. $human.':'.implode(',', self::USER_COLUMNS),
  138. $assistant.':'.implode(',', self::ASSISTANT_COLUMNS),
  139. ];
  140. }
  141. /**
  142. * 批量取摘要,返回**位置数组**:保持传入顺序,解析不到的整条丢弃。
  143. *
  144. * 这是 `UserApi::getListByUuid()` 的既有口径,只为兼容旧调用点而存在。
  145. * 新代码用 {@see self::byUuids()}——位置数组一旦丢项就和输入对不上号了。
  146. *
  147. * @param array<int, string|null> $uuids
  148. * @return array<int, array<string, mixed>>
  149. */
  150. public function listByUuids(array $uuids): array
  151. {
  152. $map = $this->byUuids($uuids);
  153. $output = [];
  154. foreach ($uuids as $uuid) {
  155. if (is_string($uuid) && isset($map[$uuid])) {
  156. $output[] = $map[$uuid];
  157. }
  158. }
  159. return $output;
  160. }
  161. /**
  162. * 按 uuid 取 AI 助手的摘要,跳过人类表;查不到给占位。
  163. *
  164. * @return array<string, mixed>
  165. */
  166. public function assistantByUuid(?string $uuid): array
  167. {
  168. $assistant = $this->query(AiModel::class, 'uid', [$uuid], self::ASSISTANT_COLUMNS)->first();
  169. return $assistant ? $this->assistantProfile($assistant) : self::UNKNOWN;
  170. }
  171. /**
  172. * 按自增主键取人类用户的摘要。
  173. *
  174. * @return array<string, mixed>
  175. */
  176. public function byId(int|string|null $id): array
  177. {
  178. $user = UserInfo::query()->where('id', $id)->first(self::USER_COLUMNS);
  179. return $user ? $this->profile($user) : self::UNKNOWN;
  180. }
  181. /**
  182. * 按用户名取人类用户的摘要。
  183. *
  184. * @return array<string, mixed>
  185. */
  186. public function byName(?string $name): array
  187. {
  188. $user = UserInfo::query()->where('username', $name)->first(self::USER_COLUMNS);
  189. return $user ? $this->profile($user) : self::UNKNOWN;
  190. }
  191. /** 用户名 → uuid(`user_infos.userid`)。 */
  192. public function uuidByName(?string $name): ?string
  193. {
  194. return UserInfo::query()->where('username', $name)->value('userid');
  195. }
  196. /** uuid → 自增主键(`user_infos.id`)。 */
  197. public function intIdByUuid(?string $uuid): ?int
  198. {
  199. return UserInfo::query()->where('userid', $uuid)->value('id');
  200. }
  201. /** 用户名 → 自增主键(`user_infos.id`)。 */
  202. public function intIdByName(?string $name): ?int
  203. {
  204. return UserInfo::query()->where('username', $name)->value('id');
  205. }
  206. /**
  207. * 把一行 `user_infos` 拼成公开摘要。
  208. *
  209. * 参数刻意不加类型:传进来的既有 UserInfo 模型,也有包着模型的 Resource。
  210. *
  211. * @return array<string, mixed>
  212. */
  213. public function profile($user): array
  214. {
  215. if (! $user) {
  216. return self::UNKNOWN;
  217. }
  218. $profile = [
  219. 'id' => $user->userid,
  220. 'nickName' => $user->nickname,
  221. 'userName' => $user->username,
  222. 'realName' => $user->username,
  223. 'sn' => $user->id,
  224. ];
  225. // roles 与 avatar 是可选键:没有就整个键不出现,不是给个空值。
  226. // 这是 v2 的既有口径,前端用 `in` 判断,别改成恒定存在。
  227. if (! empty($user->role)) {
  228. $profile['roles'] = json_decode($user->role);
  229. }
  230. if ($avatar = $this->avatarUrl($user->avatar)) {
  231. $profile['avatar'] = $avatar;
  232. }
  233. return $profile;
  234. }
  235. /**
  236. * 把一行 `ai_models` 拼成公开摘要。
  237. *
  238. * 参数刻意不加类型:`AiModelResource` 传进来的是 Resource 而不是模型。
  239. *
  240. * @return array<string, mixed>
  241. */
  242. public function assistantProfile($assistant): array
  243. {
  244. if (! $assistant) {
  245. return self::UNKNOWN;
  246. }
  247. return [
  248. 'id' => $assistant->uid,
  249. 'nickName' => $assistant->name,
  250. 'userName' => $assistant->real_name,
  251. 'realName' => $assistant->real_name,
  252. 'roles' => ['ai'],
  253. 'sn' => 0,
  254. 'avatar' => $this->avatarUrl($assistant->avatar)
  255. ?? $this->assistantLogo($assistant->model, $assistant->url),
  256. ];
  257. }
  258. /**
  259. * 清空请求内的身份映射。
  260. *
  261. * 只有「同一进程里改过用户资料又要重新读出来」的场合才需要,例如长跑的
  262. * artisan 命令。普通 HTTP 请求不必调用。
  263. */
  264. public function flush(): void
  265. {
  266. $this->resolved = [];
  267. }
  268. /**
  269. * 一批 uuid 的实际装载:先查人类表,剩下的才查 AI 表。
  270. *
  271. * 两次简单查询而不是一次 union/join:两张表各自的 uuid 列上都有唯一索引,
  272. * 分开查各走各的索引,第二次还常常因为没有剩余 uuid 而根本不发生。
  273. *
  274. * @param array<int, string> $uuids
  275. * @return array<string, array<string, mixed>>
  276. */
  277. private function load(array $uuids): array
  278. {
  279. $found = [];
  280. foreach ($this->query(UserInfo::class, 'userid', $uuids, self::USER_COLUMNS)->get() as $user) {
  281. $found[$user->userid] = $this->profile($user);
  282. }
  283. $rest = array_values(array_diff($uuids, array_keys($found)));
  284. if (! $rest) {
  285. return $found;
  286. }
  287. foreach ($this->query(AiModel::class, 'uid', $rest, self::ASSISTANT_COLUMNS)->get() as $assistant) {
  288. $found[$assistant->uid] = $this->assistantProfile($assistant);
  289. }
  290. return $found;
  291. }
  292. /**
  293. * 按 uuid 列表查某张表,只取指定列。
  294. *
  295. * @param class-string<Model> $model
  296. * @param array<int, string|null> $uuids
  297. * @param array<int, string> $columns
  298. * @return Builder<covariant \Illuminate\Database\Eloquent\Model>
  299. */
  300. private function query(string $model, string $column, array $uuids, array $columns)
  301. {
  302. return $model::query()->whereIn($column, $uuids)->select($columns);
  303. }
  304. /**
  305. * 头像缩略图地址;没有头像返回 null。
  306. *
  307. * 生产环境给 6 天有效期的签名 URL,本地与测试环境给普通 URL——签名要连对象
  308. * 存储,测试里会直接炸。
  309. *
  310. * 原先人类路径认 `local` + `testing`、AI 路径只认 `local`,是两份副本漂移的
  311. * 结果,这里统一成前者。差异只在 testing 环境显现,生产行为不变。
  312. */
  313. private function avatarUrl(?string $avatar): ?string
  314. {
  315. if (! $avatar) {
  316. return null;
  317. }
  318. $img = str_replace('.jpg', '_s.jpg', $avatar);
  319. return App::environment(['local', 'testing'])
  320. ? Storage::url($img)
  321. : Storage::temporaryUrl($img, now()->addDays(6));
  322. }
  323. /**
  324. * AI 助手没有自定义头像时的内置 logo:按 model / url 里的关键字挑,都不匹配
  325. * 就用通用图标。
  326. */
  327. private function assistantLogo(?string $model, ?string $url): string
  328. {
  329. $logo = 'ai-assistant.png';
  330. // model 与 url 分别匹配,不要拼成一个串:关键字可能跨越拼接处产生假匹配。
  331. // 两者都可为 null(新建模型时未必填写),转字符串避免 str_contains 报类型错。
  332. foreach (config('mint.ai.logo', []) as $keyword => $file) {
  333. if (str_contains((string) $model, (string) $keyword)
  334. || str_contains((string) $url, (string) $keyword)) {
  335. $logo = $file;
  336. break;
  337. }
  338. }
  339. return config('app.url').'/assets/images/avatar/'.$logo;
  340. }
  341. }