2
0

DhammaTermController.php 20 KB

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