2
0

ArticleController.php 33 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819
  1. <?php
  2. namespace App\Http\Controllers;
  3. use App\Http\Api\ChannelApi;
  4. use App\Http\Api\SentenceApi;
  5. use App\Http\Api\ShareApi;
  6. use App\Http\Api\StudioApi;
  7. use App\Http\Resources\ArticleResource;
  8. use App\Models\Article;
  9. use App\Models\ArticleCollection;
  10. use App\Models\Collection;
  11. use App\Models\CustomBook;
  12. use App\Models\CustomBookId;
  13. use App\Models\Sentence;
  14. use App\Services\AuthService;
  15. use App\Tools\OpsLog;
  16. use Illuminate\Http\Request;
  17. use Illuminate\Support\Facades\DB;
  18. use Illuminate\Support\Facades\Log;
  19. use Illuminate\Support\Str;
  20. class ArticleController extends Controller
  21. {
  22. public static function userCanRead($user_uid, Article $article)
  23. {
  24. if ($article->status === 30) {
  25. return true;
  26. }
  27. if (empty($user_uid)) {
  28. return false;
  29. }
  30. // 私有文章,判断是否为所有者
  31. if ($user_uid === $article->owner) {
  32. return true;
  33. }
  34. // 非所有者
  35. // 判断是否为文章协作者
  36. $power = ShareApi::getResPower($user_uid, $article->uid);
  37. if ($power >= 10) {
  38. return true;
  39. }
  40. // 无读取权限
  41. // 判断文集是否有读取权限
  42. $inCollection = ArticleCollection::where('article_id', $article->uid)
  43. ->select('collect_id')
  44. ->groupBy('collect_id')->get();
  45. if (! $inCollection) {
  46. return false;
  47. }
  48. // 查找与文章同主人的文集
  49. $collections = Collection::whereIn('uid', $inCollection)
  50. ->where('owner', $article->owner)
  51. ->select('uid')
  52. ->get();
  53. if (! $collections) {
  54. return false;
  55. }
  56. // 查找与文章同主人的文集是否是共享的
  57. $power = 0;
  58. foreach ($collections as $collection) {
  59. // code...
  60. $currPower = ShareApi::getResPower($user_uid, $collection->uid);
  61. if ($currPower >= 10) {
  62. return true;
  63. }
  64. }
  65. return false;
  66. }
  67. public static function userCanEditId($user_uid, $articleId)
  68. {
  69. $article = Article::find($articleId);
  70. if ($article) {
  71. return ArticleController::userCanEdit($user_uid, $article);
  72. } else {
  73. return false;
  74. }
  75. }
  76. public static function userCanEdit($user_uid, $article)
  77. {
  78. if (empty($user_uid)) {
  79. return false;
  80. }
  81. // 私有文章,判断是否为所有者
  82. if ($user_uid === $article->owner) {
  83. return true;
  84. }
  85. // 非所有者
  86. // 判断是否为文章协作者
  87. $power = ShareApi::getResPower($user_uid, $article->uid);
  88. if ($power >= 20) {
  89. return true;
  90. }
  91. // 无读取权限
  92. // 判断文集是否有读取权限
  93. $inCollection = ArticleCollection::where('article_id', $article->uid)
  94. ->select('collect_id')
  95. ->groupBy('collect_id')->get();
  96. if (! $inCollection) {
  97. return false;
  98. }
  99. // 查找与文章同主人的文集
  100. $collections = Collection::whereIn('uid', $inCollection)
  101. ->where('owner', $article->owner)
  102. ->select('uid')
  103. ->get();
  104. if (! $collections) {
  105. return false;
  106. }
  107. // 查找与文章同主人的文集是否是共享的
  108. $power = 0;
  109. foreach ($collections as $collection) {
  110. // code...
  111. $currPower = ShareApi::getResPower($user_uid, $collection->uid);
  112. if ($currPower >= 20) {
  113. return true;
  114. }
  115. }
  116. return false;
  117. }
  118. public static function userCanManage($user_uid, $studioName)
  119. {
  120. if (empty($user_uid)) {
  121. return false;
  122. }
  123. // 判断是否为所有者
  124. if ($user_uid === StudioApi::getIdByName($studioName)) {
  125. return true;
  126. } else {
  127. return false;
  128. }
  129. }
  130. /**
  131. * 列出 article(文章)
  132. *
  133. * 按 view 指定的口径返回文章列表,支持关键字搜索、分页、排序。
  134. * 默认只返回列表字段(uid、title、subtitle、summary、owner、lang、status、editor_id、时间),
  135. * content=true 时才附带正文与正文类型。
  136. * view=studio 需要登录,且当前登录用户必须就是 name 指定的 studio 的拥有者,否则返回 401/403。
  137. *
  138. * @queryParam view string required 查询口径。template=按 studio_name 取该 studio 的全部文章(模版库);studio=取某 studio 内的文章,需要登录;public=只取已公开(status=30)的文章。Enum: template,studio,public
  139. * @queryParam content string 是否返回文章正文。传 true 时结果附带 content 与 content_type。Example: true
  140. * @queryParam studio_name string view=template 时使用,指定 studio 的名称
  141. * @queryParam name string view=studio 时使用,指定 studio 的名称,必须与当前登录用户一致
  142. * @queryParam view2 string view=studio 时的二级口径:my=我创建的(owner 为该 studio),其余值=协作的(别人共享给我的,res_type=3)。Default: my
  143. * @queryParam anthology string view=studio 时按文集过滤。all=不过滤;none=不属于我任何文集的文章;其余值按文集 uid 过滤。Enum: all,none
  144. * @queryParam search string 按标题模糊搜索(title like %search%)
  145. * @queryParam subtitle string 按副标题过滤(subtitle like,需自行带通配符)
  146. * @queryParam order string 排序字段。Default: updated_at
  147. * @queryParam dir string 排序方向。Enum: asc,desc Default: desc
  148. * @queryParam offset integer 分页起始位置。Default: 0
  149. * @queryParam limit integer 每页条数。Default: 1000
  150. */
  151. public function index(Request $request)
  152. {
  153. $field = [
  154. 'uid',
  155. 'title',
  156. 'subtitle',
  157. 'summary',
  158. 'owner',
  159. 'lang',
  160. 'status',
  161. 'editor_id',
  162. 'updated_at',
  163. 'created_at',
  164. ];
  165. if ($request->input('content') === 'true') {
  166. $field[] = 'content';
  167. $field[] = 'content_type';
  168. }
  169. $table = Article::select($field);
  170. switch ($request->input('view')) {
  171. case 'template':
  172. $studioId = StudioApi::getIdByName($request->input('studio_name'));
  173. $table = $table->where('owner', $studioId);
  174. break;
  175. case 'studio':
  176. // 获取studio内所有 article
  177. $user = AuthService::current($request);
  178. if (! $user) {
  179. return $this->error(__('auth.failed'), [], 401);
  180. }
  181. // 判断当前用户是否有指定的studio的权限
  182. $studioId = StudioApi::getIdByName($request->input('name'));
  183. if ($user['user_uid'] !== $studioId) {
  184. return $this->error(__('auth.failed'), [], 403);
  185. }
  186. if ($request->input('view2', 'my') === 'my') {
  187. $table = $table->where('owner', $studioId);
  188. } else {
  189. // 协作
  190. $resList = ShareApi::getResList($studioId, 3);
  191. $resId = [];
  192. foreach ($resList as $res) {
  193. $resId[] = $res['res_id'];
  194. }
  195. $table = $table->whereIn('uid', $resId)->where('owner', '<>', $studioId);
  196. }
  197. // 根据anthology过滤
  198. if ($request->has('anthology')) {
  199. switch ($request->input('anthology')) {
  200. case 'all':
  201. break;
  202. case 'none':
  203. // 我的文集
  204. $myCollection = Collection::where('owner', $studioId)->select('uid')->get();
  205. // 收录在我的文集里面的文章
  206. $articles = ArticleCollection::whereIn('collect_id', $myCollection)
  207. ->select('article_id')->groupBy('article_id')->get();
  208. // 不在这些范围之内的文章
  209. $table = $table->whereNotIn('uid', $articles);
  210. break;
  211. default:
  212. $articles = ArticleCollection::where('collect_id', $request->input('anthology'))
  213. ->select('article_id')->get();
  214. $table = $table->whereIn('uid', $articles);
  215. break;
  216. }
  217. }
  218. break;
  219. case 'public':
  220. $table = $table->where('status', 30);
  221. break;
  222. default:
  223. // FIXME: 这里只调用 error() 没有 return,view 非法时会继续往下执行并返回全表数据(最多 limit 条);
  224. // 建议改成 return $this->error('view error', [], 400)。
  225. $this->error('view error');
  226. break;
  227. }
  228. // 处理搜索
  229. if ($request->has('search') && ! empty($request->input('search'))) {
  230. $table = $table->where('title', 'like', '%'.$request->input('search').'%');
  231. }
  232. if ($request->has('subtitle') && ! empty($request->input('subtitle'))) {
  233. $table = $table->where('subtitle', 'like', $request->input('subtitle'));
  234. }
  235. // 获取记录总条数
  236. $count = $table->count();
  237. // 处理排序
  238. $table = $table->orderBy(
  239. $request->input('order', 'updated_at'),
  240. $request->input('dir', 'desc')
  241. );
  242. // 处理分页
  243. $table = $table->skip($request->input('offset', 0))
  244. ->take($request->input('limit', 1000));
  245. // 获取数据
  246. $result = $table->get();
  247. return $this->ok(['rows' => ArticleResource::collection($result), 'count' => $count]);
  248. }
  249. /**
  250. * 统计我的文章数量
  251. *
  252. * 返回指定 studio 下「我的」和「协作的」两类文章条数。
  253. * 需要登录,且当前登录用户必须就是 studio 的拥有者,否则返回鉴权失败。
  254. *
  255. * @queryParam studio string required studio 名称,必须与当前登录用户一致
  256. */
  257. public function showMyNumber(Request $request)
  258. {
  259. $user = AuthService::current($request);
  260. if (! $user) {
  261. return $this->error(__('auth.failed'));
  262. }
  263. // 判断当前用户是否有指定的studio的权限
  264. $studioId = StudioApi::getIdByName($request->input('studio'));
  265. if ($user['user_uid'] !== $studioId) {
  266. return $this->error(__('auth.failed'));
  267. }
  268. // 我的
  269. $my = Article::where('owner', $studioId)->count();
  270. // 协作
  271. $resList = ShareApi::getResList($studioId, 3);
  272. $resId = [];
  273. foreach ($resList as $res) {
  274. $resId[] = $res['res_id'];
  275. }
  276. $collaboration = Article::whereIn('uid', $resId)->where('owner', '<>', $studioId)->count();
  277. return $this->ok(['my' => $my, 'collaboration' => $collaboration]);
  278. }
  279. /**
  280. * 新建 article(文章)
  281. *
  282. * 需要登录。权限:当前用户必须是 studio 的拥有者;若不是,则必须对 anthologyId 指定的文集
  283. * 拥有大于只读的权限(power>10),否则返回 403。
  284. * 副作用:整个新建过程在事务中执行——写入 articles 表,若给了 anthologyId 则同时把文章挂进文集;
  285. * 若再给了 parentNode,会按目录树重建该文集的 article_collections 记录(删除后整表重插)并刷新文集缓存。
  286. * 标题超过 128 字会被截断。
  287. *
  288. * @bodyParam studio string required studio 名称,文章归属的 studio
  289. * @bodyParam title string required 文章标题,最多 128 字,超出截断
  290. * @bodyParam lang string required 文章语言代码
  291. * @bodyParam status integer 公开状态,30 表示公开。不传则用数据库默认值
  292. * @bodyParam parentId string 父文章 uid,写入 articles.parent
  293. * @bodyParam anthologyId string 文集 uid,传入则把新文章加入该文集
  294. * @bodyParam parentNode string 文集目录中的挂接点文章 uid,新文章作为它的子节点插入;
  295. * 不传则作为一级节点(level=1)追加到文集末尾
  296. */
  297. public function store(Request $request)
  298. {
  299. // 判断权限
  300. $user = AuthService::current($request);
  301. if (! $user) {
  302. return $this->error(__('auth.failed'), [], 401);
  303. } else {
  304. $user_uid = $user['user_uid'];
  305. }
  306. $canManage = ArticleController::userCanManage($user_uid, $request->input('studio'));
  307. if (! $canManage) {
  308. // 判断是否有文集权限
  309. if ($request->has('anthologyId')) {
  310. $currPower = ShareApi::getResPower($user_uid, $request->input('anthologyId'));
  311. if ($currPower <= 10) {
  312. return $this->error(__('auth.failed'), [], 403);
  313. }
  314. } else {
  315. return $this->error(__('auth.failed'), [], 403);
  316. }
  317. }
  318. // 权限判断结束
  319. // 查询标题是否重复
  320. /*
  321. if(Article::where('title',$request->input('title'))->where('owner',$studioUuid)->exists()){
  322. return $this->error(__('validation.exists'));
  323. }*/
  324. $newArticle = new Article;
  325. DB::transaction(function () use ($user, $request, $newArticle) {
  326. $studioUuid = StudioApi::getIdByName($request->input('studio'));
  327. // 新建文章,加入文集必须都成功。否则回滚
  328. $newArticle->id = app('snowflake')->id();
  329. $newArticle->uid = Str::uuid();
  330. $newArticle->title = mb_substr($request->input('title'), 0, 128, 'UTF-8');
  331. $newArticle->lang = $request->input('lang');
  332. if (! empty($request->input('status'))) {
  333. $newArticle->status = $request->input('status');
  334. }
  335. $newArticle->owner = $studioUuid;
  336. $newArticle->owner_id = $user['user_id'];
  337. $newArticle->editor_id = $user['user_id'];
  338. $newArticle->parent = $request->input('parentId');
  339. $newArticle->create_time = time() * 1000;
  340. $newArticle->modify_time = time() * 1000;
  341. $newArticle->save();
  342. OpsLog::debug($user['user_uid'], $newArticle);
  343. $anthologyId = $request->input('anthologyId');
  344. if (Str::isUuid($anthologyId)) {
  345. $parentNode = $request->input('parentNode');
  346. if (Str::isUuid($parentNode)) {
  347. $map = ArticleCollection::where('collect_id', $anthologyId)
  348. ->orderBy('id')->get();
  349. $newMap = [];
  350. $parentNodeLevel = -1;
  351. $appended = false;
  352. foreach ($map as $key => $row) {
  353. $orgNode = $row;
  354. if (! $appended) {
  355. if ($parentNodeLevel > 0) {
  356. if ($row->level <= $parentNodeLevel) {
  357. // parent node 末尾
  358. $newNode = [];
  359. $newNode['collect_id'] = $anthologyId;
  360. $newNode['article_id'] = $newArticle->uid;
  361. $newNode['level'] = $parentNodeLevel + 1;
  362. $newNode['title'] = $newArticle->title;
  363. $newNode['children'] = 0;
  364. $newMap[] = $newNode;
  365. $appended = true;
  366. }
  367. } else {
  368. if ($row->article_id === $parentNode) {
  369. $parentNodeLevel = $row->level;
  370. $orgNode['children'] = $orgNode['children'] + 1;
  371. }
  372. }
  373. }
  374. $newMap[] = $orgNode;
  375. }
  376. if ($parentNodeLevel > 0) {
  377. if ($appended === false) {
  378. $newNode = [];
  379. $newNode['collect_id'] = $anthologyId;
  380. $newNode['article_id'] = $newArticle->uid;
  381. $newNode['level'] = $parentNodeLevel + 1;
  382. $newNode['title'] = $newArticle->title;
  383. $newNode['children'] = 0;
  384. $newMap[] = $newNode;
  385. }
  386. } else {
  387. // FIXME: parentNode 找不到挂接点时只写日志,不把文章加入文集,结果文章建成了却不在目录里、前端看不到;
  388. // 建议改为回退成一级节点追加,或直接抛异常让事务回滚并向前端报错。
  389. Log::warning('没找到挂接点'.$parentNode);
  390. }
  391. $delete = ArticleCollection::where('collect_id', $anthologyId)->delete();
  392. $count = 0;
  393. foreach ($newMap as $key => $row) {
  394. $new = new ArticleCollection;
  395. $new->id = app('snowflake')->id();
  396. $new->article_id = $row['article_id'];
  397. $new->collect_id = $row['collect_id'];
  398. $new->title = $row['title'];
  399. $new->level = $row['level'];
  400. $new->children = $row['children'];
  401. $new->editor_id = $user['user_id'];
  402. if (isset($row['deleted_at'])) {
  403. $new->deleted_at = $row['deleted_at'];
  404. }
  405. $new->save();
  406. $count++;
  407. }
  408. ArticleMapController::updateCollection($anthologyId);
  409. } else {
  410. $articleMap = new ArticleCollection;
  411. $articleMap->id = app('snowflake')->id();
  412. $articleMap->article_id = $newArticle->uid;
  413. $articleMap->collect_id = $request->input('anthologyId');
  414. $articleMap->title = Article::find($newArticle->uid)->title;
  415. $articleMap->level = 1;
  416. $articleMap->save();
  417. }
  418. }
  419. });
  420. if (Str::isUuid($newArticle->uid)) {
  421. return $this->ok(new ArticleResource($newArticle));
  422. } else {
  423. return $this->error('fail');
  424. }
  425. }
  426. /**
  427. * 查看单篇 article(文章)
  428. *
  429. * 前端阅读页最常用的接口。允许匿名访问:status=30(公开)的文章任何人可读;
  430. * 非公开文章需要登录,且满足以下之一——是文章 owner、对该文章有只读以上共享权限(power>=10)、
  431. * 或对该文章所在的(同一 owner 的)文集有只读以上共享权限,否则返回 403。
  432. *
  433. * @urlParam article string required 文章 uid(路由模型绑定到 Article,主键即 uid)
  434. *
  435. * @queryParam anthology string 所在文集 uid,用于在返回结果里渲染文集路径(path)
  436. * @queryParam channel string 渲染标题所用的 channel uid,多个用下划线分隔;不传则回退到文集的默认 channel
  437. */
  438. public function show(Request $request, Article $article)
  439. {
  440. if (! $article) {
  441. return $this->error('no recorder');
  442. }
  443. // 判断权限
  444. $user = AuthService::current($request);
  445. if (! $user) {
  446. $user_uid = '';
  447. } else {
  448. $user_uid = $user['user_uid'];
  449. }
  450. $canRead = ArticleController::userCanRead($user_uid, $article);
  451. if (! $canRead) {
  452. return $this->error(__('auth.failed'), 403, 403);
  453. }
  454. return $this->ok(new ArticleResource($article));
  455. }
  456. /**
  457. * 预览文章正文
  458. *
  459. * PUT /api/v2/article-preview/{id}。用请求体里的 content 覆盖文章正文后走一遍渲染再返回,
  460. * 不写数据库,用于编辑器里的即时预览。
  461. * 权限与查看文章一致:公开文章匿名可预览,否则需要读权限,无权限返回 401。
  462. * 未传 content 时返回 error('no content'),但 HTTP 状态码仍是 200。
  463. *
  464. * @urlParam id string required 文章 uid
  465. *
  466. * @bodyParam content string required 待预览的正文内容(markdown)
  467. *
  468. * @queryParam anthology string 所在文集 uid,用于渲染文集路径
  469. * @queryParam channel string 渲染所用 channel uid,多个用下划线分隔
  470. */
  471. public function preview(Request $request, string $articleId)
  472. {
  473. $article = Article::find($articleId);
  474. if (! $article) {
  475. return $this->error('no recorder');
  476. }
  477. // 判断权限
  478. $user = AuthService::current($request);
  479. if (! $user) {
  480. $user_uid = '';
  481. } else {
  482. $user_uid = $user['user_uid'];
  483. }
  484. $canRead = ArticleController::userCanRead($user_uid, $article);
  485. if (! $canRead) {
  486. return $this->error(__('auth.failed'), [], 401);
  487. }
  488. if ($request->has('content')) {
  489. $article->content = $request->input('content');
  490. return $this->ok(new ArticleResource($article));
  491. } else {
  492. return $this->error('no content', [], 200);
  493. }
  494. }
  495. /**
  496. * 更新 article(文章)
  497. *
  498. * 需要登录。权限:必须是文章 owner,或对文章有编辑以上共享权限(power>=20),
  499. * 或对文章所在的同 owner 文集有编辑以上权限,否则返回 401。
  500. * 字段会整体覆盖:title/subtitle 截断到 128 字,summary 截断到 1024 字,
  501. * status 未传时会被重置为 10(非公开),即部分更新也必须带全字段。
  502. * 副作用:to_tpl=true 时把正文转成模版——按行切句,正文中的句子被替换成 {{book-para-start-end}} 占位,
  503. * 同时把原文逐句写入 sentences 表(必要时为文集新建 CustomBook 与 channel),该过程需要对应 channel 的写权限。
  504. *
  505. * @urlParam article string required 文章 uid(路由模型绑定)
  506. *
  507. * @bodyParam title string required 标题,最多 128 字
  508. * @bodyParam subtitle string 副标题,最多 128 字
  509. * @bodyParam summary string 摘要,最多 1024 字
  510. * @bodyParam content string required 正文(markdown)
  511. * @bodyParam lang string required 语言代码
  512. * @bodyParam status integer 公开状态,30 表示公开。Default: 10
  513. * @bodyParam to_tpl boolean 是否把正文转换为模版并把原文切句入库,必须是布尔 true 才生效
  514. * @bodyParam anthology_id string to_tpl=true 时必填,文集 uid,用于定位/创建书号与 channel
  515. */
  516. public function update(Request $request, Article $article)
  517. {
  518. if (! $article) {
  519. return $this->error('no recorder');
  520. }
  521. // 鉴权
  522. $user = AuthService::current($request);
  523. if (! $user) {
  524. return $this->error(__('auth.failed'), 401, 401);
  525. } else {
  526. $user_uid = $user['user_uid'];
  527. }
  528. $canEdit = ArticleController::userCanEdit($user_uid, $article);
  529. if (! $canEdit) {
  530. return $this->error(__('auth.failed'), 401, 401);
  531. }
  532. /*
  533. //查询标题是否重复
  534. if(Article::where('title',$request->input('title'))
  535. ->where('owner',$article->owner)
  536. ->where('uid',"<>",$article->uid)
  537. ->exists()){
  538. return $this->error(__('validation.exists'));
  539. }*/
  540. $content = $request->input('content');
  541. if ($request->input('to_tpl') === true) {
  542. /**
  543. * 转化为模版
  544. */
  545. $tplContent = $this->toTpl(
  546. $content,
  547. $request->input('anthology_id'),
  548. $user
  549. );
  550. $content = $tplContent;
  551. }
  552. $article->title = mb_substr($request->input('title'), 0, 128, 'UTF-8');
  553. $article->subtitle = mb_substr($request->input('subtitle'), 0, 128, 'UTF-8');
  554. $article->summary = mb_substr($request->input('summary'), 0, 1024, 'UTF-8');
  555. $article->content = $content;
  556. $article->lang = $request->input('lang');
  557. // FIXME: status 未传时会被静默重置为 10(非公开),已公开的文章做部分更新会被降级;
  558. // 建议改为只有请求体明确带 status 时才赋值,否则保留原值。
  559. $article->status = $request->input('status', 10);
  560. $article->editor_id = $user['user_id'];
  561. $article->modify_time = time() * 1000;
  562. $article->save();
  563. OpsLog::debug($user_uid, $article);
  564. return $this->ok(new ArticleResource($article));
  565. }
  566. /**
  567. * 删除 article(文章)
  568. *
  569. * 需要登录,且只有文章 owner 本人可以删除(协作者不行),否则返回鉴权失败。
  570. * 副作用:在事务中删除文章记录,并同步把该文章从所有文集目录(article_collections)中摘除。
  571. *
  572. * @urlParam article string required 文章 uid(路由模型绑定)
  573. */
  574. public function destroy(Request $request, Article $article)
  575. {
  576. $user = AuthService::current($request);
  577. if (! $user) {
  578. return $this->error(__('auth.failed'));
  579. }
  580. // 判断当前用户是否有指定的studio的权限
  581. if ($user['user_uid'] !== $article->owner) {
  582. return $this->error(__('auth.failed'));
  583. }
  584. $delete = 0;
  585. // FIXME: $delete 按值捕获,闭包内的赋值传不出来,返回值恒为 0,前端拿不到真实删除结果;
  586. // 建议改为 use (&$delete) 引用捕获,或直接用 DB::transaction 的返回值。
  587. DB::transaction(function () use ($article, $delete) {
  588. // TODO 删除文集中的文章
  589. $delete = $article->delete();
  590. ArticleMapController::deleteArticle($article->uid);
  591. });
  592. return $this->ok($delete);
  593. }
  594. public function toTpl($content, $anthologyId, $user)
  595. {
  596. // 查询书号
  597. if (! Str::isUuid($anthologyId)) {
  598. throw new \Exception('anthology Id not uuid');
  599. }
  600. $bookId = $this->getBookId($anthologyId, $user);
  601. $tpl = $this->convertToTpl($content, $bookId['book'], $bookId['paragraph']);
  602. // 保存原文到句子表
  603. $customBook = $this->getCustomBookByBookId($bookId['book']);
  604. $sentenceSave = new SentenceApi;
  605. $auth = $sentenceSave->auth($customBook->channel_id, $user['user_uid']);
  606. if (! $auth) {
  607. throw new \Exception('auth fail');
  608. }
  609. foreach ($tpl['sentences'] as $key => $sentence) {
  610. $sentenceSave->store($sentence, $user);
  611. }
  612. return $tpl['content'];
  613. }
  614. private function getCustomBookByBookId($bookId)
  615. {
  616. return CustomBook::where('book_id', $bookId)->first();
  617. }
  618. private function getBookId($anthologyId, $user)
  619. {
  620. $anthology = Collection::where('uid', $anthologyId)->first();
  621. if (! $anthology) {
  622. throw new \Exception('anthology not exists id='.$anthologyId);
  623. }
  624. $bookId = $anthology->book_id;
  625. if (empty($bookId)) {
  626. // 生成 book id
  627. $newBookId = CustomBook::max('book_id') + 1;
  628. $newBook = new CustomBook;
  629. $newBook->id = app('snowflake')->id();
  630. $newBook->book_id = $newBookId;
  631. $newBook->title = $anthology->title;
  632. $newBook->owner = $anthology->owner;
  633. $newBook->editor_id = $user['user_id'];
  634. $newBook->lang = $anthology->lang;
  635. $newBook->status = $anthology->status;
  636. // 查询anthology所在的studio有没有符合要求的channel 没有的话,建立
  637. $channelId = ChannelApi::userBookGetOrCreate($anthology->owner, $anthology->lang, $anthology->status);
  638. if ($channelId === false) {
  639. throw new \Exception('user book get fail studio='.$anthology->owner.' language='.$anthology->lang);
  640. }
  641. $newBook->channel_id = $channelId;
  642. $ok = $newBook->save();
  643. if (! $ok) {
  644. throw new \Exception('user book create fail studio='.$anthology->owner.' language='.$anthology->lang);
  645. }
  646. CustomBookId::where('key', 'max_book_number')->update(['value' => $newBookId]);
  647. $bookId = $newBookId;
  648. $anthology->book_id = $newBookId;
  649. $anthology->save();
  650. } else {
  651. $channelId = CustomBook::where('book_id', $bookId)->value('channel_id');
  652. }
  653. $maxPara = Sentence::where('channel_uid', $channelId)
  654. ->where('book_id', $bookId)->max('paragraph');
  655. if (! $maxPara) {
  656. $maxPara = 0;
  657. }
  658. return ['book' => $bookId, 'paragraph' => $maxPara + 1];
  659. }
  660. public function convertToTpl($content, $bookId, $paraStart)
  661. {
  662. $newSentence = [];
  663. $para = $paraStart;
  664. $sentNum = 1;
  665. $newText = '';
  666. $isTable = false;
  667. $isList = false;
  668. $newSent = '';
  669. $sentences = explode("\n", $content);
  670. foreach ($sentences as $row) {
  671. // $data 为一行文本
  672. $listHead = '';
  673. $isList = false;
  674. $heading = false;
  675. $title = false;
  676. $trimData = trim($row);
  677. // 判断是否为list
  678. $listLeft = strstr($row, '- ', true);
  679. if ($listLeft !== false) {
  680. if (ctype_space($listLeft) || empty($listLeft)) {
  681. // - 左侧是空,判定为list
  682. $isList = true;
  683. $iListPos = mb_strpos($row, '- ', 0, 'UTF-8');
  684. $listHead = mb_substr($row, 0, $iListPos + 2, 'UTF-8');
  685. $listBody = mb_substr($row, $iListPos + 2, mb_strlen($row, 'UTF-8') - $iListPos + 2, 'UTF-8');
  686. }
  687. }
  688. // TODO 判断是否为标题
  689. $headingStart = mb_strpos($row, '# ', 0, 'UTF-8');
  690. if ($headingStart !== false) {
  691. $headingLeft = mb_substr($row, 0, $headingStart + 2, 'UTF-8');
  692. $title = mb_substr($row, $headingStart + 2, null, 'UTF-8');
  693. if (str_replace('#', '', trim($headingLeft)) === '') {
  694. // 除了#没有其他东西,那么是标题
  695. $heading = $headingLeft;
  696. $newText .= $headingLeft;
  697. $newText .= '{{'."{$bookId}-{$para}-{$sentNum}-{$sentNum}"."}}\n";
  698. $newSentence[] = $this->newSent($bookId, $para, $sentNum, $sentNum, $title);
  699. $newSent = '';
  700. $para++;
  701. $sentNum = 1;
  702. continue;
  703. }
  704. }
  705. // 判断是否为表格开始
  706. if (mb_substr($trimData, 0, 1, 'UTF-8') == '|') {
  707. $isTable = true;
  708. }
  709. if ($trimData != '' && $isTable == true) {
  710. // 如果是表格 不新增句子
  711. $newSent .= "{$row}\n";
  712. continue;
  713. }
  714. if ($isList == true) {
  715. $newSent .= $listBody;
  716. } else {
  717. $newSent .= $trimData;
  718. }
  719. // 生成句子编号
  720. if ($trimData == '') {
  721. // 空行
  722. if (strlen($newSent) > 0) {
  723. // 之前有内容
  724. $newText .= '{{'."{$bookId}-{$para}-{$sentNum}-{$sentNum}"."}}\n";
  725. $newSentence[] = $this->newSent($bookId, $para, $sentNum, $sentNum, $newSent);
  726. $newSent = '';
  727. }
  728. // 新的段落 不插入数据库
  729. $para++;
  730. $sentNum = 1;
  731. $newText .= "\n";
  732. $isTable = false; // 表格开始标记
  733. $isList = false;
  734. continue;
  735. } else {
  736. $sentNum = $sentNum + 10;
  737. }
  738. if (mb_substr($trimData, 0, 2, 'UTF-8') == '{{') {
  739. // 已经有的句子链接不处理
  740. $newText .= $trimData."\n";
  741. } else {
  742. $newText .= $listHead;
  743. $newText .= '{{'."{$bookId}-{$para}-{$sentNum}-{$sentNum}"."}}\n";
  744. $newSentence[] = $this->newSent($bookId, $para, $sentNum, $sentNum, $newSent);
  745. $newSent = '';
  746. }
  747. }
  748. return [
  749. 'content' => $newText,
  750. 'sentences' => $newSentence,
  751. ];
  752. }
  753. private function newSent($book, $para, $start, $end, $content)
  754. {
  755. return [
  756. 'book_id' => $book,
  757. 'paragraph' => $para,
  758. 'word_start' => $start,
  759. 'word_end' => $end,
  760. 'content' => $content,
  761. ];
  762. }
  763. }