DhammaTermController.php 20 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510
  1. <?php
  2. namespace App\Http\Controllers;
  3. use App\Http\Api\ChannelApi;
  4. use App\Http\Api\ShareApi;
  5. use App\Http\Api\StudioApi;
  6. use App\Http\Controllers\Concerns\ChecksChannelEditPower;
  7. use App\Http\Resources\TermResource;
  8. use App\Models\AiModel;
  9. use App\Models\Channel;
  10. use App\Models\DhammaTerm;
  11. use App\Services\AuthService;
  12. use App\Services\TermIndexService;
  13. use App\Tools\Tools;
  14. use Illuminate\Http\Request;
  15. use Illuminate\Http\Response;
  16. use Illuminate\Support\Facades\Cache;
  17. use Illuminate\Support\Facades\DB;
  18. use Illuminate\Support\Facades\Log;
  19. use Illuminate\Support\Str;
  20. class DhammaTermController extends Controller
  21. {
  22. use ChecksChannelEditPower;
  23. public function __construct(
  24. private TermIndexService $termIndexService,
  25. ) {}
  26. /**
  27. * Display a listing of the resource.
  28. *
  29. * @return Response
  30. */
  31. public function index(Request $request)
  32. {
  33. $result = false;
  34. $indexCol = [
  35. 'id',
  36. 'guid',
  37. 'word',
  38. 'meaning',
  39. 'other_meaning',
  40. 'tag',
  41. 'language',
  42. 'channal',
  43. 'owner',
  44. 'editor_id',
  45. 'editor_uid',
  46. 'created_at',
  47. 'updated_at',
  48. ];
  49. switch ($request->input('view')) {
  50. case 'create-by-channel':
  51. // 新建术语时。根据术语所在channel 给出新建术语所需数据。如语言,备选意思等。
  52. // 获取channel信息
  53. $currChannel = Channel::where('uid', $request->input('channel'))->first();
  54. if (! $currChannel) {
  55. return $this->error(__('auth.failed'));
  56. }
  57. // TODO 查询studio信息
  58. // 获取同studio的channel列表
  59. $studioChannels = Channel::where('owner_uid', $currChannel->owner_uid)
  60. ->select(['name', 'uid'])
  61. ->get();
  62. // 获取全网意思列表
  63. $meanings = DhammaTerm::where('word', $request->input('word'))
  64. ->where('language', $currChannel->lang)
  65. ->select(['meaning', 'other_meaning'])
  66. ->get();
  67. $meaningList = [];
  68. foreach ($meanings as $key => $value) {
  69. // code...
  70. $meaning1 = [$value->meaning];
  71. if (! empty($value->other_meaning)) {
  72. $meaning2 = \explode(',', $value->other_meaning);
  73. $meaning1 = array_merge($meaning1, $meaning2);
  74. }
  75. foreach ($meaning1 as $key => $value) {
  76. // code...
  77. if (isset($meaningList[$value])) {
  78. $meaningList[$value]++;
  79. } else {
  80. $meaningList[$value] = 1;
  81. }
  82. }
  83. }
  84. $meaningCount = [];
  85. foreach ($meaningList as $key => $value) {
  86. // code...
  87. $meaningCount[] = ['meaning' => $key, 'count' => $value];
  88. }
  89. return $this->ok([
  90. 'word' => $request->input('word'),
  91. 'meaningCount' => $meaningCount,
  92. 'studioChannels' => $studioChannels,
  93. 'language' => $currChannel->lang,
  94. 'studio' => StudioApi::getById($currChannel->owner_uid),
  95. ]);
  96. break;
  97. case 'studio':
  98. // 获取 studio 内所有 term
  99. $user = AuthService::current($request);
  100. if (! $user) {
  101. return $this->error(__('auth.failed'), [], 401);
  102. }
  103. // 判断当前用户是否有指定的studio的权限
  104. if ($user['user_uid'] !== StudioApi::getIdByName($request->input('name'))) {
  105. return $this->error(__('auth.failed'), [], 403);
  106. }
  107. $table = DhammaTerm::select($indexCol)
  108. ->where('owner', $user['user_uid']);
  109. break;
  110. case 'channel':
  111. // 获取 studio 内所有 term
  112. $user = AuthService::current($request);
  113. if (! $user) {
  114. return $this->error(__('auth.failed'));
  115. }
  116. // 判断当前用户是否有指定的 channel 的权限
  117. $channel = Channel::find($request->input('id'));
  118. if ($user['user_uid'] !== $channel->owner_uid) {
  119. // 看是否为协作
  120. $power = ShareApi::getResPower($user['user_uid'], $request->input('id'));
  121. if ($power === 0) {
  122. return $this->error(__('auth.failed'), [], 403);
  123. }
  124. }
  125. $table = DhammaTerm::select($indexCol)
  126. ->where('channal', $request->input('id'));
  127. break;
  128. case 'show':
  129. return $this->ok(DhammaTerm::find($request->input('id')));
  130. break;
  131. case 'user':
  132. // code...
  133. $user = AuthService::current($request);
  134. if (! $user) {
  135. return $this->error(__('auth.failed'));
  136. }
  137. $userUid = $user['user_uid'];
  138. $search = $request->input('search');
  139. $table = DhammaTerm::select($indexCol)
  140. ->where('owner', $userUid);
  141. break;
  142. case 'word':
  143. $table = DhammaTerm::select($indexCol)
  144. ->whereIn('word', explode(',', $request->input('word')))
  145. ->orWhereIn('meaning', explode(',', $request->input('word')));
  146. break;
  147. case 'tag':
  148. $table = DhammaTerm::select($indexCol)
  149. ->whereIn('tag', explode(',', $request->input('tag')));
  150. break;
  151. case 'hot-meaning':
  152. $key = 'term/hot_meaning';
  153. $value = Cache::remember($key, config('mint.cache.expire'), function () use ($request) {
  154. $hotMeaning = [];
  155. $words = DhammaTerm::select('word')
  156. ->where('language', $request->input('language'))
  157. ->groupby('word')
  158. ->get();
  159. foreach ($words as $key => $word) {
  160. // code...
  161. $result = DhammaTerm::select(DB::raw('count(*) as word_count, meaning'))
  162. ->where('language', $request->input('language'))
  163. ->where('word', $word['word'])
  164. ->groupby('meaning')
  165. ->orderby('word_count', 'desc')
  166. ->first();
  167. if ($result) {
  168. $hotMeaning[] = [
  169. 'word' => $word['word'],
  170. 'meaning' => $result['meaning'],
  171. 'language' => $request->input('language'),
  172. 'owner' => '',
  173. ];
  174. }
  175. }
  176. Cache::put($key, $hotMeaning, 3600);
  177. return $hotMeaning;
  178. });
  179. return $this->ok(['rows' => $value, 'count' => count($value)]);
  180. break;
  181. default:
  182. // code...
  183. break;
  184. }
  185. $search = $request->input('search');
  186. if (! empty($search)) {
  187. $table = $table->where(function ($query) use ($search) {
  188. $query->where('word', 'like', $search . '%')
  189. ->orWhere('word_en', 'like', $search . '%')
  190. ->orWhere('meaning', 'like', '%' . $search . '%');
  191. });
  192. }
  193. $count = $table->count();
  194. $table = $table->orderBy($request->input('order', 'updated_at'), $request->input('dir', 'desc'));
  195. $table = $table->skip($request->input('offset', 0))
  196. ->take($request->input('limit', 1000));
  197. $result = $table->get();
  198. return $this->ok(['rows' => TermResource::collection($result), 'count' => $count]);
  199. }
  200. /**
  201. * Store a newly created resource in storage.
  202. *
  203. * @return Response
  204. */
  205. public function store(Request $request)
  206. {
  207. $user = AuthService::current($request);
  208. if (! $user) {
  209. return $this->error(__('auth.failed'));
  210. }
  211. $validated = $request->validate([
  212. 'word' => 'required',
  213. 'meaning' => 'required',
  214. ]);
  215. /**
  216. * 查询重复的
  217. * 一个channel下面word+tag+language 唯一
  218. */
  219. $table = DhammaTerm::where('word', $request->input('word'))
  220. ->where('tag', $request->input('tag'));
  221. if (! empty($request->input('channel'))) {
  222. // channel 内的唯一性只看 channel。此前这里还按 owner 过滤,而
  223. // owner 取的是当前身份——AI 模型的 uid 与落库的 owner(channel
  224. // 所属 studio)永远不等,查重必然落空,同一个词会被反复插入。
  225. $isDoesntExist = $table->where('channal', $request->input('channel'))
  226. ->doesntExist();
  227. } else {
  228. $isDoesntExist = $table->where('owner', $user['user_uid'])
  229. ->whereNull('channal')->where('language', $request->input('language'))
  230. ->doesntExist();
  231. }
  232. if ($isDoesntExist) {
  233. // 没有重复的 插入数据
  234. $term = new DhammaTerm;
  235. $term->id = app('snowflake')->id();
  236. $term->guid = Str::uuid();
  237. $term->word = $request->input('word');
  238. $term->word_en = Tools::getWordEn($request->input('word'));
  239. $term->meaning = $request->input('meaning');
  240. $term->other_meaning = $request->input('other_meaning');
  241. $term->note = $request->input('note');
  242. $term->tag = $request->input('tag');
  243. $term->channal = $request->input('channel');
  244. $term->language = $request->input('language');
  245. if (! empty($request->input('channel'))) {
  246. $channelInfo = ChannelApi::getById($request->input('channel'));
  247. if (! $channelInfo) {
  248. return $this->error('channel id failed');
  249. } else {
  250. // 查看有没有channel权限。术语没有 book 概念,access token 的
  251. // book 一律按 0(不限)判定。
  252. if (! $this->userCanEditChannel(
  253. $user['user_uid'],
  254. $request->input('channel'),
  255. 0,
  256. $request->input('access_token')
  257. )) {
  258. return $this->error(__('auth.failed'), [], 403);
  259. }
  260. $term->owner = $channelInfo['studio_id'];
  261. $term->language = $channelInfo['lang'];
  262. }
  263. } else {
  264. // AI 模型只能在 channel 里建术语。它的权限全部由人类签出的
  265. // channel access token 代持,而 access token 是 channel 级的,
  266. // 代持不了 studio 权限——没有 channel 就没有任何可核验的授权。
  267. if ($this->isAiModel($user['user_uid'])) {
  268. return $this->error('ai model must specify a channel', [], 403);
  269. }
  270. if ($request->has('studioId')) {
  271. $studioId = $request->input('studioId');
  272. } elseif ($request->has('studioName')) {
  273. $studioId = StudioApi::getIdByName($request->input('studioName'));
  274. }
  275. if (! isset($studioId) || ! Str::isUuid($studioId)) {
  276. return $this->error('not valid studioId');
  277. }
  278. // studio 级术语(不属于任何 channel)只能由 studio 本人建。
  279. // 此前这里不校验归属,任何登录用户都能往别人 studio 名下写。
  280. // access token 是 channel 级的,代持不了 studio 权限,所以
  281. // AI 模型建 studio 级术语必然走到这里被拒——这是有意的。
  282. if ($studioId !== $user['user_uid']) {
  283. return $this->error(__('auth.failed'), [], 403);
  284. }
  285. $term->owner = $studioId;
  286. }
  287. $term->editor_id = $this->editorId($user);
  288. $term->editor_uid = $user['user_uid'];
  289. $term->create_time = time() * 1000;
  290. $term->modify_time = time() * 1000;
  291. $term->save();
  292. // 删除cache
  293. $this->deleteCache($term);
  294. // 重建 OpenSearch 索引
  295. $this->reindex($term);
  296. return $this->ok(new TermResource($term));
  297. } else {
  298. return $this->error('word existed', [], 200);
  299. }
  300. }
  301. /**
  302. * editor_id 存的是人类用户的自增 sn,模型没有。模型 token 里的 id 恒为 0,
  303. * 而 0 同时也是「缺省/未知」的值,落库后分不清是模型写的还是数据有问题,
  304. * 故模型一律记 -1;模型的真实身份看 editor_uid。
  305. *
  306. * @param array<string, mixed> $user
  307. */
  308. private function editorId(array $user): int
  309. {
  310. return $this->isAiModel($user['user_uid']) ? -1 : (int) $user['user_id'];
  311. }
  312. /**
  313. * 当前身份是不是 AI 模型。模型 token 的 user_id 恒为 0,但人类的旧 cookie
  314. * 鉴权也可能给出奇怪的值,故直接查表判定,不靠 id。
  315. */
  316. private function isAiModel(string $userUid): bool
  317. {
  318. return AiModel::where('uid', $userUid)->exists();
  319. }
  320. private function deleteCache($term)
  321. {
  322. if (empty($term->channal)) {
  323. // 通用 查询studio所有channel
  324. $channels = Channel::where('owner_uid', $term->owner)->select('uid')->get();
  325. foreach ($channels as $channel) {
  326. Cache::forget("/term/{$channel}/{$term->word}");
  327. }
  328. } else {
  329. Cache::forget("/term/{$term->channal}/{$term->word}");
  330. }
  331. }
  332. /**
  333. * 术语新建/修改后重建 OpenSearch 索引。
  334. *
  335. * 索引是检索/维基展示的副作用数据,重建失败不应阻断本次写入,
  336. * 因此只记录日志、不向上抛异常。
  337. */
  338. private function reindex(DhammaTerm $term): void
  339. {
  340. try {
  341. $this->termIndexService->index($term->guid);
  342. } catch (\Throwable $e) {
  343. Log::error('Failed to index term after write', [
  344. 'guid' => $term->guid,
  345. 'error' => $e->getMessage(),
  346. ]);
  347. }
  348. }
  349. /**
  350. * Display the specified resource.
  351. *
  352. * @param string $id
  353. * @return Response
  354. */
  355. public function show(Request $request, $id)
  356. {
  357. //
  358. $result = DhammaTerm::where('guid', $id)->first();
  359. if ($result) {
  360. return $this->ok(new TermResource($result));
  361. } else {
  362. return $this->error('没有查询到数据');
  363. }
  364. }
  365. /**
  366. * Update the specified resource in storage.
  367. *
  368. * @param DhammaTerm $dhammaTerm
  369. * @return Response
  370. */
  371. public function update(Request $request, string $id)
  372. {
  373. //
  374. $user = AuthService::current($request);
  375. if (! $user) {
  376. return $this->error(__('auth.failed'), [], 401);
  377. }
  378. $dhammaTerm = DhammaTerm::find($id);
  379. if (! $dhammaTerm) {
  380. return $this->error('404');
  381. }
  382. if (empty($dhammaTerm->channal)) {
  383. // studio 级术语(不属于任何 channel)只有 owner 本人能改:
  384. // access token 是 channel 级的,代持不了 studio 权限。
  385. if ($this->isAiModel($user['user_uid'])) {
  386. return $this->error('ai model cannot edit a term outside a channel', [], 403);
  387. }
  388. if ($user['user_uid'] !== $dhammaTerm->owner) {
  389. return $this->error(__('auth.failed'), [], 403);
  390. }
  391. } else {
  392. // 查看有没有channel权限(owner / 协作者 / access token)
  393. if (! $this->userCanEditChannel(
  394. $user['user_uid'],
  395. $dhammaTerm->channal,
  396. 0,
  397. $request->input('access_token')
  398. )) {
  399. return $this->error(__('auth.failed'), [], 403);
  400. }
  401. }
  402. // 增量更新:只改提交上来的字段。此前这里无条件赋值,客户端漏提一个
  403. // 字段就会把库里的 note/tag 等清成 null。
  404. if ($request->has('word')) {
  405. $dhammaTerm->word = $request->input('word');
  406. $dhammaTerm->word_en = Tools::getWordEn($request->input('word'));
  407. }
  408. foreach (['meaning', 'other_meaning', 'note', 'tag', 'language'] as $field) {
  409. if ($request->has($field)) {
  410. $dhammaTerm->$field = $request->input($field);
  411. }
  412. }
  413. $dhammaTerm->editor_id = $this->editorId($user);
  414. $dhammaTerm->editor_uid = $user['user_uid'];
  415. // create_time 是创建时刻,改动时不该被刷新
  416. $dhammaTerm->modify_time = time() * 1000;
  417. $dhammaTerm->save();
  418. // 删除cache
  419. $this->deleteCache($dhammaTerm);
  420. // 重建 OpenSearch 索引
  421. $this->reindex($dhammaTerm);
  422. return $this->ok(new TermResource($dhammaTerm));
  423. }
  424. /**
  425. * Remove the specified resource from storage.
  426. *
  427. * @return Response
  428. */
  429. public function destroy(DhammaTerm $dhammaTerm, Request $request)
  430. {
  431. /**
  432. * 一次删除多个单词
  433. */
  434. $user = AuthService::current($request);
  435. if (! $user) {
  436. return $this->error(__('auth.failed'));
  437. }
  438. $count = 0;
  439. if ($request->has('uuid')) {
  440. // 查看是否有删除权限
  441. foreach ($request->input('id') as $key => $uuid) {
  442. $term = DhammaTerm::find($uuid);
  443. if (! $term) {
  444. continue;
  445. }
  446. if ($term->owner !== $user['user_uid']) {
  447. if (! empty($term->channal)) {
  448. // 看是否为协作
  449. $power = ShareApi::getResPower($user['user_uid'], $term->channal);
  450. if ($power < 20) {
  451. continue;
  452. }
  453. } else {
  454. continue;
  455. }
  456. }
  457. $count += $term->delete();
  458. // 删除cache
  459. $this->deleteCache($term);
  460. }
  461. } else {
  462. $arrId = json_decode($request->input('id'), true);
  463. foreach ($arrId as $key => $id) {
  464. // code...
  465. $term = DhammaTerm::where('id', $id)
  466. ->where('owner', $user['user_uid'])
  467. ->first();
  468. if (! $term) {
  469. continue;
  470. }
  471. // 先取到模型再删:此前这里把 query builder 传给 deleteCache,
  472. // 拿不到 word/channal,缓存根本没被清掉。
  473. $result = $term->delete();
  474. if ($result) {
  475. $this->deleteCache($term);
  476. $count++;
  477. }
  478. }
  479. }
  480. return $this->ok($count);
  481. }
  482. }