UserService.php 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390
  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. * 只有 AI 一条关系时的预加载声明(如协作者 co_editor),用法同 {@see self::eagerLoadActor()}。
  143. */
  144. public static function eagerLoadAssistant(string $relation): string
  145. {
  146. return $relation.':'.implode(',', self::ASSISTANT_COLUMNS);
  147. }
  148. /**
  149. * 批量取摘要,返回**位置数组**:保持传入顺序,解析不到的整条丢弃。
  150. *
  151. * 这是 `UserApi::getListByUuid()` 的既有口径,只为兼容旧调用点而存在。
  152. * 新代码用 {@see self::byUuids()}——位置数组一旦丢项就和输入对不上号了。
  153. *
  154. * @param array<int, string|null> $uuids
  155. * @return array<int, array<string, mixed>>
  156. */
  157. public function listByUuids(array $uuids): array
  158. {
  159. $map = $this->byUuids($uuids);
  160. $output = [];
  161. foreach ($uuids as $uuid) {
  162. if (is_string($uuid) && isset($map[$uuid])) {
  163. $output[] = $map[$uuid];
  164. }
  165. }
  166. return $output;
  167. }
  168. /**
  169. * 按 uuid 取 AI 助手的摘要,跳过人类表;查不到给占位。
  170. *
  171. * @return array<string, mixed>
  172. */
  173. public function assistantByUuid(?string $uuid): array
  174. {
  175. $assistant = $this->query(AiModel::class, 'uid', [$uuid], self::ASSISTANT_COLUMNS)->first();
  176. return $assistant ? $this->assistantProfile($assistant) : self::UNKNOWN;
  177. }
  178. /**
  179. * 按自增主键取人类用户的摘要。
  180. *
  181. * @return array<string, mixed>
  182. */
  183. public function byId(int|string|null $id): array
  184. {
  185. $user = UserInfo::query()->where('id', $id)->first(self::USER_COLUMNS);
  186. return $user ? $this->profile($user) : self::UNKNOWN;
  187. }
  188. /**
  189. * 按用户名取人类用户的摘要。
  190. *
  191. * @return array<string, mixed>
  192. */
  193. public function byName(?string $name): array
  194. {
  195. $user = UserInfo::query()->where('username', $name)->first(self::USER_COLUMNS);
  196. return $user ? $this->profile($user) : self::UNKNOWN;
  197. }
  198. /** 用户名 → uuid(`user_infos.userid`)。 */
  199. public function uuidByName(?string $name): ?string
  200. {
  201. return UserInfo::query()->where('username', $name)->value('userid');
  202. }
  203. /** uuid → 自增主键(`user_infos.id`)。 */
  204. public function intIdByUuid(?string $uuid): ?int
  205. {
  206. return UserInfo::query()->where('userid', $uuid)->value('id');
  207. }
  208. /** 用户名 → 自增主键(`user_infos.id`)。 */
  209. public function intIdByName(?string $name): ?int
  210. {
  211. return UserInfo::query()->where('username', $name)->value('id');
  212. }
  213. /**
  214. * 把一行 `user_infos` 拼成公开摘要。
  215. *
  216. * 参数刻意不加类型:传进来的既有 UserInfo 模型,也有包着模型的 Resource。
  217. *
  218. * @return array<string, mixed>
  219. */
  220. public function profile($user): array
  221. {
  222. if (! $user) {
  223. return self::UNKNOWN;
  224. }
  225. $profile = [
  226. 'id' => $user->userid,
  227. 'nickName' => $user->nickname,
  228. 'userName' => $user->username,
  229. 'realName' => $user->username,
  230. 'sn' => $user->id,
  231. ];
  232. // roles 与 avatar 是可选键:没有就整个键不出现,不是给个空值。
  233. // 这是 v2 的既有口径,前端用 `in` 判断,别改成恒定存在。
  234. if (! empty($user->role)) {
  235. $profile['roles'] = json_decode($user->role);
  236. }
  237. if ($avatar = $this->avatarUrl($user->avatar)) {
  238. $profile['avatar'] = $avatar;
  239. }
  240. return $profile;
  241. }
  242. /**
  243. * 把一行 `ai_models` 拼成公开摘要。
  244. *
  245. * 参数刻意不加类型:`AiModelResource` 传进来的是 Resource 而不是模型。
  246. *
  247. * @return array<string, mixed>
  248. */
  249. public function assistantProfile($assistant): array
  250. {
  251. if (! $assistant) {
  252. return self::UNKNOWN;
  253. }
  254. return [
  255. 'id' => $assistant->uid,
  256. 'nickName' => $assistant->name,
  257. 'userName' => $assistant->real_name,
  258. 'realName' => $assistant->real_name,
  259. 'roles' => ['ai'],
  260. 'sn' => 0,
  261. 'avatar' => $this->avatarUrl($assistant->avatar)
  262. ?? $this->assistantLogo($assistant->model, $assistant->url),
  263. ];
  264. }
  265. /**
  266. * 清空请求内的身份映射。
  267. *
  268. * 只有「同一进程里改过用户资料又要重新读出来」的场合才需要,例如长跑的
  269. * artisan 命令。普通 HTTP 请求不必调用。
  270. */
  271. public function flush(): void
  272. {
  273. $this->resolved = [];
  274. }
  275. /**
  276. * 一批 uuid 的实际装载:先查人类表,剩下的才查 AI 表。
  277. *
  278. * 两次简单查询而不是一次 union/join:两张表各自的 uuid 列上都有唯一索引,
  279. * 分开查各走各的索引,第二次还常常因为没有剩余 uuid 而根本不发生。
  280. *
  281. * @param array<int, string> $uuids
  282. * @return array<string, array<string, mixed>>
  283. */
  284. private function load(array $uuids): array
  285. {
  286. $found = [];
  287. foreach ($this->query(UserInfo::class, 'userid', $uuids, self::USER_COLUMNS)->get() as $user) {
  288. $found[$user->userid] = $this->profile($user);
  289. }
  290. $rest = array_values(array_diff($uuids, array_keys($found)));
  291. if (! $rest) {
  292. return $found;
  293. }
  294. foreach ($this->query(AiModel::class, 'uid', $rest, self::ASSISTANT_COLUMNS)->get() as $assistant) {
  295. $found[$assistant->uid] = $this->assistantProfile($assistant);
  296. }
  297. return $found;
  298. }
  299. /**
  300. * 按 uuid 列表查某张表,只取指定列。
  301. *
  302. * @param class-string<Model> $model
  303. * @param array<int, string|null> $uuids
  304. * @param array<int, string> $columns
  305. * @return Builder<covariant \Illuminate\Database\Eloquent\Model>
  306. */
  307. private function query(string $model, string $column, array $uuids, array $columns)
  308. {
  309. return $model::query()->whereIn($column, $uuids)->select($columns);
  310. }
  311. /**
  312. * 头像缩略图地址;没有头像返回 null。
  313. *
  314. * 生产环境给 6 天有效期的签名 URL,本地与测试环境给普通 URL——签名要连对象
  315. * 存储,测试里会直接炸。
  316. *
  317. * 原先人类路径认 `local` + `testing`、AI 路径只认 `local`,是两份副本漂移的
  318. * 结果,这里统一成前者。差异只在 testing 环境显现,生产行为不变。
  319. */
  320. private function avatarUrl(?string $avatar): ?string
  321. {
  322. if (! $avatar) {
  323. return null;
  324. }
  325. $img = str_replace('.jpg', '_s.jpg', $avatar);
  326. return App::environment(['local', 'testing'])
  327. ? Storage::url($img)
  328. : Storage::temporaryUrl($img, now()->addDays(6));
  329. }
  330. /**
  331. * AI 助手没有自定义头像时的内置 logo:按 model / url 里的关键字挑,都不匹配
  332. * 就用通用图标。
  333. */
  334. private function assistantLogo(?string $model, ?string $url): string
  335. {
  336. $logo = 'ai-assistant.png';
  337. // model 与 url 分别匹配,不要拼成一个串:关键字可能跨越拼接处产生假匹配。
  338. // 两者都可为 null(新建模型时未必填写),转字符串避免 str_contains 报类型错。
  339. foreach (config('mint.ai.logo', []) as $keyword => $file) {
  340. if (str_contains((string) $model, (string) $keyword)
  341. || str_contains((string) $url, (string) $keyword)) {
  342. $logo = $file;
  343. break;
  344. }
  345. }
  346. return config('app.url').'/assets/images/avatar/'.$logo;
  347. }
  348. }