DhammaTermController.php 28 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603
  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\Support\Facades\Cache;
  16. use Illuminate\Support\Facades\DB;
  17. use Illuminate\Support\Facades\Log;
  18. use Illuminate\Support\Str;
  19. class DhammaTermController extends Controller
  20. {
  21. use ChecksChannelEditPower;
  22. public function __construct(
  23. private TermIndexService $termIndexService,
  24. ) {}
  25. /**
  26. * 列出术语(dhamma term)
  27. *
  28. * 按 view 指定的口径返回术语列表。studio、channel、user 三个口径需要登录;
  29. * studio 要求 name 指向的 studio 属于当前用户,否则 403;channel 要求当前用户是
  30. * channel owner 或协作者(ShareApi 权限值非 0),否则 403。
  31. * create-by-channel、show、hot-meaning 三个口径直接返回各自的数据结构,
  32. * 不走后面的搜索/排序/分页;其余口径统一返回 {rows, count}。
  33. * hot-meaning 结果会按 config('mint.cache.expire') 缓存在 term/hot_meaning 键下。
  34. * 未指定 view(或给了无法识别的值)时没有任何基础查询,会直接报错,调用方必须传 view。
  35. *
  36. * @queryParam view string required 查询口径。Enum: create-by-channel,studio,channel,show,user,word,tag,hot-meaning
  37. * @queryParam channel string view=create-by-channel 时必填,目标 channel 的 uid,
  38. * 用于确定语言、同 studio 的 channel 列表与 studio 信息
  39. * @queryParam word string view=create-by-channel 时为要新建的术语词形;
  40. * view=word 时为逗号分隔的词或义项列表,按 word 或 meaning 命中
  41. * @queryParam name string view=studio 时必填,studio 名称,须与当前用户的 studio 一致
  42. * @queryParam id string view=channel 时为 channel id(uid);view=show 时为术语主键 id
  43. * @queryParam tag string view=tag 时必填,逗号分隔的 tag 列表
  44. * @queryParam language string view=hot-meaning 时必填,统计该语言下每个词出现次数最多的义项
  45. * @queryParam search string 关键字,按 word、word_en 前缀匹配或 meaning 模糊匹配
  46. * @queryParam order string 排序字段。Default: updated_at
  47. * @queryParam dir string 排序方向。Enum: asc,desc Default: desc
  48. * @queryParam offset integer 分页偏移量。Default: 0
  49. * @queryParam limit integer 每页条数。Default: 1000
  50. * @queryParam mode string 渲染 note/summary 的模式,透传给 Markdown 渲染器。Default: read
  51. * @queryParam format string note 的渲染输出格式。Enum: react,html,text Default: react
  52. * @queryParam community_summary boolean 传入该参数时,自身没有 note 的术语会回落到社区
  53. * channel 的解释作为 summary
  54. * @queryParam exp boolean 传入该参数时附带编辑者截至本条更新时间的累计经验值(秒)
  55. */
  56. public function index(Request $request)
  57. {
  58. $result = false;
  59. $indexCol = [
  60. 'id',
  61. 'guid',
  62. 'word',
  63. 'meaning',
  64. 'other_meaning',
  65. 'tag',
  66. 'language',
  67. 'channal',
  68. 'owner',
  69. 'editor_id',
  70. 'editor_uid',
  71. 'created_at',
  72. 'updated_at',
  73. ];
  74. switch ($request->input('view')) {
  75. // FIXME: create-by-channel 口径完全不做登录/权限校验,任何人都能枚举任意
  76. // channel 所属 studio 的全部 channel 列表;应补上与 channel 口径一致的鉴权。
  77. case 'create-by-channel':
  78. // 新建术语时。根据术语所在channel 给出新建术语所需数据。如语言,备选意思等。
  79. // 获取channel信息
  80. $currChannel = Channel::where('uid', $request->input('channel'))->first();
  81. if (! $currChannel) {
  82. return $this->error(__('auth.failed'));
  83. }
  84. // TODO 查询studio信息
  85. // 获取同studio的channel列表
  86. $studioChannels = Channel::where('owner_uid', $currChannel->owner_uid)
  87. ->select(['name', 'uid'])
  88. ->get();
  89. // 获取全网意思列表
  90. $meanings = DhammaTerm::where('word', $request->input('word'))
  91. ->where('language', $currChannel->lang)
  92. ->select(['meaning', 'other_meaning'])
  93. ->get();
  94. $meaningList = [];
  95. foreach ($meanings as $key => $value) {
  96. // code...
  97. $meaning1 = [$value->meaning];
  98. if (! empty($value->other_meaning)) {
  99. $meaning2 = \explode(',', $value->other_meaning);
  100. $meaning1 = array_merge($meaning1, $meaning2);
  101. }
  102. foreach ($meaning1 as $key => $value) {
  103. // code...
  104. if (isset($meaningList[$value])) {
  105. $meaningList[$value]++;
  106. } else {
  107. $meaningList[$value] = 1;
  108. }
  109. }
  110. }
  111. $meaningCount = [];
  112. foreach ($meaningList as $key => $value) {
  113. // code...
  114. $meaningCount[] = ['meaning' => $key, 'count' => $value];
  115. }
  116. return $this->ok([
  117. 'word' => $request->input('word'),
  118. 'meaningCount' => $meaningCount,
  119. 'studioChannels' => $studioChannels,
  120. 'language' => $currChannel->lang,
  121. 'studio' => StudioApi::getById($currChannel->owner_uid),
  122. ]);
  123. break;
  124. case 'studio':
  125. // 获取 studio 内所有 term
  126. $user = AuthService::current($request);
  127. if (! $user) {
  128. return $this->error(__('auth.failed'), [], 401);
  129. }
  130. // 判断当前用户是否有指定的studio的权限
  131. if ($user['user_uid'] !== StudioApi::getIdByName($request->input('name'))) {
  132. return $this->error(__('auth.failed'), [], 403);
  133. }
  134. $table = DhammaTerm::select($indexCol)
  135. ->where('owner', $user['user_uid']);
  136. break;
  137. case 'channel':
  138. // 获取 studio 内所有 term
  139. $user = AuthService::current($request);
  140. if (! $user) {
  141. return $this->error(__('auth.failed'));
  142. }
  143. // 判断当前用户是否有指定的 channel 的权限
  144. // FIXME: find() 按主键查(其余地方 channel 都按 uid 查),且结果未判空就访问
  145. // ->owner_uid,id 不存在时直接 500;应改为 where('uid', ...)->first() 并判空返回 404。
  146. $channel = Channel::find($request->input('id'));
  147. if ($user['user_uid'] !== $channel->owner_uid) {
  148. // 看是否为协作
  149. $power = ShareApi::getResPower($user['user_uid'], $request->input('id'));
  150. if ($power === 0) {
  151. return $this->error(__('auth.failed'), [], 403);
  152. }
  153. }
  154. $table = DhammaTerm::select($indexCol)
  155. ->where('channal', $request->input('id'));
  156. break;
  157. case 'show':
  158. // FIXME: 不做登录校验且直接返回原始模型(未过 TermResource),会把 owner、
  159. // editor_id 等非公开字段暴露出去;应改用 TermResource 并补权限判定。
  160. return $this->ok(DhammaTerm::find($request->input('id')));
  161. break;
  162. case 'user':
  163. // code...
  164. $user = AuthService::current($request);
  165. if (! $user) {
  166. return $this->error(__('auth.failed'));
  167. }
  168. $userUid = $user['user_uid'];
  169. // FIXME: 这里的 $search 是死代码,会被方法末尾同名变量重新赋值覆盖;应直接删除。
  170. $search = $request->input('search');
  171. $table = DhammaTerm::select($indexCol)
  172. ->where('owner', $userUid);
  173. break;
  174. case 'word':
  175. // FIXME: whereIn / orWhereIn 未用闭包分组,后面追加的 search 条件会与这个 or
  176. // 错误结合导致越界结果;应包成 ->where(fn ($q) => $q->whereIn(...)->orWhereIn(...))。
  177. $table = DhammaTerm::select($indexCol)
  178. ->whereIn('word', explode(',', $request->input('word')))
  179. ->orWhereIn('meaning', explode(',', $request->input('word')));
  180. break;
  181. case 'tag':
  182. $table = DhammaTerm::select($indexCol)
  183. ->whereIn('tag', explode(',', $request->input('tag')));
  184. break;
  185. case 'hot-meaning':
  186. $key = 'term/hot_meaning';
  187. $value = Cache::remember($key, config('mint.cache.expire'), function () use ($request) {
  188. $hotMeaning = [];
  189. $words = DhammaTerm::select('word')
  190. ->where('language', $request->input('language'))
  191. ->groupby('word')
  192. ->get();
  193. // FIXME: 典型 N+1,每个 word 单独发一次聚合查询;应改成一次
  194. // group by word, meaning 的查询再在 PHP 里取每个词的 top 1。
  195. foreach ($words as $key => $word) {
  196. // code...
  197. $result = DhammaTerm::select(DB::raw('count(*) as word_count, meaning'))
  198. ->where('language', $request->input('language'))
  199. ->where('word', $word['word'])
  200. ->groupby('meaning')
  201. ->orderby('word_count', 'desc')
  202. ->first();
  203. if ($result) {
  204. $hotMeaning[] = [
  205. 'word' => $word['word'],
  206. 'meaning' => $result['meaning'],
  207. 'language' => $request->input('language'),
  208. 'owner' => '',
  209. ];
  210. }
  211. }
  212. // FIXME: 外层 Cache::remember 已负责写缓存,这里重复写入且 TTL(3600)
  213. // 与 config('mint.cache.expire') 不一致;应删除这一行。
  214. Cache::put($key, $hotMeaning, 3600);
  215. return $hotMeaning;
  216. });
  217. return $this->ok(['rows' => $value, 'count' => count($value)]);
  218. break;
  219. // FIXME: 未传 view 或传了无法识别的 view 时 $table 未定义,会一路走到下面的
  220. // $table->count() 直接抛错;应在 default 里返回明确的参数错误。
  221. default:
  222. // code...
  223. break;
  224. }
  225. $search = $request->input('search');
  226. if (! empty($search)) {
  227. $table = $table->where(function ($query) use ($search) {
  228. $query->where('word', 'like', $search.'%')
  229. ->orWhere('word_en', 'like', $search.'%')
  230. ->orWhere('meaning', 'like', '%'.$search.'%');
  231. });
  232. }
  233. $count = $table->count();
  234. $table = $table->orderBy($request->input('order', 'updated_at'), $request->input('dir', 'desc'));
  235. $table = $table->skip($request->input('offset', 0))
  236. ->take($request->input('limit', 1000));
  237. $result = $table->get();
  238. return $this->ok(['rows' => TermResource::collection($result), 'count' => $count]);
  239. }
  240. /**
  241. * 新建术语
  242. *
  243. * 需要登录。传了 channel 则建 channel 级术语,owner 取该 channel 所属 studio、
  244. * language 取该 channel 的语言(请求里的 language 被覆盖),并要求当前身份对该
  245. * channel 有编辑权(owner / 协作者 / channel access token),否则 403。
  246. * 不传 channel 则建 studio 级术语,只允许 studio 本人创建(studioId 必须等于当前
  247. * 用户 uid,否则 403);AI 模型身份一律禁止(403),因为 access token 只能代持
  248. * channel 级权限。
  249. * 唯一性:channel 级按 channel+word+tag 查重,studio 级按 owner+word+tag+language
  250. * 且 channal 为空查重;重复时返回 'word existed',HTTP 状态仍是 200。
  251. * 写入成功后清除该词的 term 缓存并重建 OpenSearch 索引(索引失败只记日志)。
  252. *
  253. * @bodyParam word string required 术语词形,同时用于生成 word_en
  254. * @bodyParam meaning string required 主要义项
  255. * @bodyParam other_meaning string 其他义项,逗号分隔
  256. * @bodyParam note string 术语解释,Markdown
  257. * @bodyParam tag string 标签,参与查重
  258. * @bodyParam channel string channel uid。给出则建 channel 级术语,否则建 studio 级术语
  259. * @bodyParam language string 语言。仅 studio 级术语生效,channel 级会被 channel 语言覆盖
  260. * @bodyParam studioId string studio 的 uuid,不传 channel 时必填(或改用 studioName)
  261. * @bodyParam studioName string studio 名称,未给 studioId 时用它换取 studioId
  262. * @bodyParam access_token string channel access token,供 AI 模型等非 owner 身份验证 channel 编辑权
  263. */
  264. public function store(Request $request)
  265. {
  266. $user = AuthService::current($request);
  267. if (! $user) {
  268. return $this->error(__('auth.failed'));
  269. }
  270. $validated = $request->validate([
  271. 'word' => 'required',
  272. 'meaning' => 'required',
  273. ]);
  274. /**
  275. * 查询重复的
  276. * 一个channel下面word+tag+language 唯一
  277. */
  278. $table = DhammaTerm::where('word', $request->input('word'))
  279. ->where('tag', $request->input('tag'));
  280. if (! empty($request->input('channel'))) {
  281. // channel 内的唯一性只看 channel。此前这里还按 owner 过滤,而
  282. // owner 取的是当前身份——AI 模型的 uid 与落库的 owner(channel
  283. // 所属 studio)永远不等,查重必然落空,同一个词会被反复插入。
  284. $isDoesntExist = $table->where('channal', $request->input('channel'))
  285. ->doesntExist();
  286. } else {
  287. $isDoesntExist = $table->where('owner', $user['user_uid'])
  288. ->whereNull('channal')->where('language', $request->input('language'))
  289. ->doesntExist();
  290. }
  291. if ($isDoesntExist) {
  292. // 没有重复的 插入数据
  293. $term = new DhammaTerm;
  294. $term->id = app('snowflake')->id();
  295. $term->guid = Str::uuid();
  296. $term->word = $request->input('word');
  297. $term->word_en = Tools::getWordEn($request->input('word'));
  298. $term->meaning = $request->input('meaning');
  299. $term->other_meaning = $request->input('other_meaning');
  300. $term->note = $request->input('note');
  301. $term->tag = $request->input('tag');
  302. $term->channal = $request->input('channel');
  303. $term->language = $request->input('language');
  304. if (! empty($request->input('channel'))) {
  305. $channelInfo = ChannelApi::getById($request->input('channel'));
  306. if (! $channelInfo) {
  307. return $this->error('channel id failed');
  308. } else {
  309. // 查看有没有channel权限。术语没有 book 概念,access token 的
  310. // book 一律按 0(不限)判定。
  311. if (! $this->userCanEditChannel(
  312. $user['user_uid'],
  313. $request->input('channel'),
  314. 0,
  315. $request->input('access_token')
  316. )) {
  317. return $this->error(__('auth.failed'), [], 403);
  318. }
  319. $term->owner = $channelInfo['studio_id'];
  320. $term->language = $channelInfo['lang'];
  321. }
  322. } else {
  323. // AI 模型只能在 channel 里建术语。它的权限全部由人类签出的
  324. // channel access token 代持,而 access token 是 channel 级的,
  325. // 代持不了 studio 权限——没有 channel 就没有任何可核验的授权。
  326. if ($this->isAiModel($user['user_uid'])) {
  327. return $this->error('ai model must specify a channel', [], 403);
  328. }
  329. if ($request->has('studioId')) {
  330. $studioId = $request->input('studioId');
  331. } elseif ($request->has('studioName')) {
  332. $studioId = StudioApi::getIdByName($request->input('studioName'));
  333. }
  334. if (! isset($studioId) || ! Str::isUuid($studioId)) {
  335. return $this->error('not valid studioId');
  336. }
  337. // studio 级术语(不属于任何 channel)只能由 studio 本人建。
  338. // 此前这里不校验归属,任何登录用户都能往别人 studio 名下写。
  339. // access token 是 channel 级的,代持不了 studio 权限,所以
  340. // AI 模型建 studio 级术语必然走到这里被拒——这是有意的。
  341. if ($studioId !== $user['user_uid']) {
  342. return $this->error(__('auth.failed'), [], 403);
  343. }
  344. $term->owner = $studioId;
  345. }
  346. $term->editor_id = $this->editorId($user);
  347. $term->editor_uid = $user['user_uid'];
  348. $term->create_time = time() * 1000;
  349. $term->modify_time = time() * 1000;
  350. $term->save();
  351. // 删除cache
  352. $this->deleteCache($term);
  353. // 重建 OpenSearch 索引
  354. $this->reindex($term);
  355. return $this->ok(new TermResource($term));
  356. } else {
  357. return $this->error('word existed', [], 200);
  358. }
  359. }
  360. /**
  361. * editor_id 存的是人类用户的自增 sn,模型没有。模型 token 里的 id 恒为 0,
  362. * 而 0 同时也是「缺省/未知」的值,落库后分不清是模型写的还是数据有问题,
  363. * 故模型一律记 -1;模型的真实身份看 editor_uid。
  364. *
  365. * @param array<string, mixed> $user
  366. */
  367. private function editorId(array $user): int
  368. {
  369. return $this->isAiModel($user['user_uid']) ? -1 : (int) $user['user_id'];
  370. }
  371. /**
  372. * 当前身份是不是 AI 模型。模型 token 的 user_id 恒为 0,但人类的旧 cookie
  373. * 鉴权也可能给出奇怪的值,故直接查表判定,不靠 id。
  374. */
  375. private function isAiModel(string $userUid): bool
  376. {
  377. return AiModel::where('uid', $userUid)->exists();
  378. }
  379. private function deleteCache($term)
  380. {
  381. if (empty($term->channal)) {
  382. // 通用 查询studio所有channel
  383. $channels = Channel::where('owner_uid', $term->owner)->select('uid')->get();
  384. foreach ($channels as $channel) {
  385. // FIXME: $channel 是 Eloquent 模型对象,插值后得到的是 JSON 而非 uid,
  386. // 缓存键与写入时的键对不上,studio 级术语的缓存实际没被清掉;应用 $channel->uid。
  387. Cache::forget("/term/{$channel}/{$term->word}");
  388. }
  389. } else {
  390. Cache::forget("/term/{$term->channal}/{$term->word}");
  391. }
  392. }
  393. /**
  394. * 术语新建/修改后重建 OpenSearch 索引。
  395. *
  396. * 索引是检索/维基展示的副作用数据,重建失败不应阻断本次写入,
  397. * 因此只记录日志、不向上抛异常。
  398. */
  399. private function reindex(DhammaTerm $term): void
  400. {
  401. try {
  402. $this->termIndexService->index($term->guid);
  403. } catch (\Throwable $e) {
  404. Log::error('Failed to index term after write', [
  405. 'guid' => $term->guid,
  406. 'error' => $e->getMessage(),
  407. ]);
  408. }
  409. }
  410. /**
  411. * 查询单条术语
  412. *
  413. * 按 guid(不是自增 id)查找,公开接口,不需要登录。查不到返回错误 '没有查询到数据'。
  414. *
  415. * @urlParam id string required 术语的 guid
  416. *
  417. * @queryParam channel string 渲染 note 时使用的 channel 列表,多个用下划线分隔;
  418. * 不传则按术语自身的 channel 或对应语言的社区 channel 渲染
  419. * @queryParam mode string 渲染模式。Default: read
  420. * @queryParam format string note 的渲染输出格式。Enum: react,html,text Default: react
  421. * @queryParam community_summary boolean 传入该参数时,没有 note 的术语回落到社区解释作为 summary
  422. * @queryParam exp boolean 传入该参数时附带编辑者累计经验值(秒)
  423. */
  424. public function show(Request $request, $id)
  425. {
  426. $result = DhammaTerm::where('guid', $id)->first();
  427. if ($result) {
  428. return $this->ok(new TermResource($result));
  429. } else {
  430. return $this->error('没有查询到数据');
  431. }
  432. }
  433. /**
  434. * 修改术语
  435. *
  436. * 需要登录(未登录 401),按主键 id 查找,不存在返回 '404'。
  437. * 权限:studio 级术语(channal 为空)只有 owner 本人能改,AI 模型一律 403;
  438. * channel 级术语要求对该 channel 有编辑权(owner / 协作者 / access token),否则 403。
  439. * 增量更新——只有请求里出现的字段才会被写入,未提交的字段保持原值;
  440. * 提交 word 时会同步重算 word_en。create_time 不变,只刷新 modify_time。
  441. * 保存后清除该词的 term 缓存并重建 OpenSearch 索引(索引失败只记日志)。
  442. *
  443. * @urlParam id string required 术语主键 id
  444. *
  445. * @bodyParam word string 术语词形,提交时同步重算 word_en
  446. * @bodyParam meaning string 主要义项
  447. * @bodyParam other_meaning string 其他义项,逗号分隔
  448. * @bodyParam note string 术语解释,Markdown
  449. * @bodyParam tag string 标签
  450. * @bodyParam language string 语言
  451. * @bodyParam access_token string channel access token,供非 owner 身份验证 channel 编辑权
  452. */
  453. public function update(Request $request, string $id)
  454. {
  455. $user = AuthService::current($request);
  456. if (! $user) {
  457. return $this->error(__('auth.failed'), [], 401);
  458. }
  459. $dhammaTerm = DhammaTerm::find($id);
  460. if (! $dhammaTerm) {
  461. return $this->error('404');
  462. }
  463. if (empty($dhammaTerm->channal)) {
  464. // studio 级术语(不属于任何 channel)只有 owner 本人能改:
  465. // access token 是 channel 级的,代持不了 studio 权限。
  466. if ($this->isAiModel($user['user_uid'])) {
  467. return $this->error('ai model cannot edit a term outside a channel', [], 403);
  468. }
  469. if ($user['user_uid'] !== $dhammaTerm->owner) {
  470. return $this->error(__('auth.failed'), [], 403);
  471. }
  472. } else {
  473. // 查看有没有channel权限(owner / 协作者 / access token)
  474. if (! $this->userCanEditChannel(
  475. $user['user_uid'],
  476. $dhammaTerm->channal,
  477. 0,
  478. $request->input('access_token')
  479. )) {
  480. return $this->error(__('auth.failed'), [], 403);
  481. }
  482. }
  483. // 增量更新:只改提交上来的字段。此前这里无条件赋值,客户端漏提一个
  484. // 字段就会把库里的 note/tag 等清成 null。
  485. if ($request->has('word')) {
  486. $dhammaTerm->word = $request->input('word');
  487. $dhammaTerm->word_en = Tools::getWordEn($request->input('word'));
  488. }
  489. foreach (['meaning', 'other_meaning', 'note', 'tag', 'language'] as $field) {
  490. if ($request->has($field)) {
  491. $dhammaTerm->$field = $request->input($field);
  492. }
  493. }
  494. $dhammaTerm->editor_id = $this->editorId($user);
  495. $dhammaTerm->editor_uid = $user['user_uid'];
  496. // create_time 是创建时刻,改动时不该被刷新
  497. $dhammaTerm->modify_time = time() * 1000;
  498. $dhammaTerm->save();
  499. // 删除cache
  500. $this->deleteCache($dhammaTerm);
  501. // 重建 OpenSearch 索引
  502. $this->reindex($dhammaTerm);
  503. return $this->ok(new TermResource($dhammaTerm));
  504. }
  505. /**
  506. * 批量删除术语
  507. *
  508. * 需要登录。一次可以删除多条,返回实际删除的条数;无权限的条目被跳过而不报错。
  509. * 传了 uuid 参数时,id 按数组处理:owner 本人可删,非 owner 且术语属于某个 channel 时
  510. * 需要该 channel 的协作权限达到 20(编辑)才可删,studio 级术语非 owner 一律跳过。
  511. * 未传 uuid 时,id 按 JSON 字符串数组处理,且只能删除 owner 是当前用户的术语。
  512. * 每删一条都会清除该词对应的 term 缓存。
  513. *
  514. * @urlParam dhammaTerm string required 路由模型绑定占位的术语 id,实际删除对象由 id 参数决定
  515. *
  516. * @queryParam uuid string 存在该参数即表示 id 以数组形式提交,并启用 channel 协作者删除权限判定
  517. * @queryParam id array required 要删除的术语 id 列表。带 uuid 时为数组,否则为 JSON 数组字符串
  518. */
  519. public function destroy(DhammaTerm $dhammaTerm, Request $request)
  520. {
  521. $user = AuthService::current($request);
  522. if (! $user) {
  523. return $this->error(__('auth.failed'));
  524. }
  525. $count = 0;
  526. // FIXME: 判断用的是 uuid 参数、实际读的却是 id 参数,uuid 纯当开关用,语义混乱
  527. // 极易误用;应改为按 id 的实际类型分支,或统一成一个明确的参数格式。
  528. if ($request->has('uuid')) {
  529. // 查看是否有删除权限
  530. // FIXME: 未校验 input('id') 是数组,客户端传字符串会导致 foreach 直接 fatal;
  531. // 应先 validate(['id' => 'required|array'])。
  532. foreach ($request->input('id') as $key => $uuid) {
  533. $term = DhammaTerm::find($uuid);
  534. if (! $term) {
  535. continue;
  536. }
  537. if ($term->owner !== $user['user_uid']) {
  538. if (! empty($term->channal)) {
  539. // 看是否为协作
  540. $power = ShareApi::getResPower($user['user_uid'], $term->channal);
  541. if ($power < 20) {
  542. continue;
  543. }
  544. } else {
  545. continue;
  546. }
  547. }
  548. $count += $term->delete();
  549. // 删除cache
  550. $this->deleteCache($term);
  551. }
  552. } else {
  553. // FIXME: json_decode 结果未判空,非法 JSON 时得到 null 会让下面的 foreach 报错;
  554. // 应判断解析结果是数组,否则返回参数错误。
  555. $arrId = json_decode($request->input('id'), true);
  556. foreach ($arrId as $key => $id) {
  557. // code...
  558. $term = DhammaTerm::where('id', $id)
  559. ->where('owner', $user['user_uid'])
  560. ->first();
  561. if (! $term) {
  562. continue;
  563. }
  564. // 先取到模型再删:此前这里把 query builder 传给 deleteCache,
  565. // 拿不到 word/channal,缓存根本没被清掉。
  566. $result = $term->delete();
  567. if ($result) {
  568. $this->deleteCache($term);
  569. $count++;
  570. }
  571. }
  572. }
  573. return $this->ok($count);
  574. }
  575. }