CanvasService.php 44 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065106610671068106910701071107210731074107510761077107810791080108110821083108410851086108710881089109010911092109310941095109610971098109911001101110211031104110511061107110811091110111111121113111411151116111711181119112011211122112311241125112611271128112911301131113211331134113511361137113811391140114111421143114411451146114711481149115011511152115311541155115611571158115911601161116211631164116511661167116811691170117111721173117411751176117711781179118011811182118311841185118611871188118911901191119211931194119511961197119811991200120112021203120412051206120712081209121012111212121312141215121612171218121912201221122212231224122512261227122812291230123112321233123412351236123712381239124012411242124312441245124612471248124912501251125212531254125512561257
  1. <?php
  2. namespace App\Services\Canvas;
  3. use App\Facade\Site;
  4. use App\Libs\Utils;
  5. use Illuminate\Support\Facades\DB;
  6. /**
  7. * 画布模式服务
  8. *
  9. * 核心模型:
  10. * 画布(mp_canvases) -> 节点(mp_canvas_nodes) + 关联(mp_canvas_node_rels) + 分镜数据(mp_canvas_segments)
  11. *
  12. * 画布为无限扩展画布,无固定宽高;节点无限扩展、无固定根节点,仅记录两两关联。
  13. * 关联查询一律按节点ID查邻接关系(a=node OR b=node),不依赖树形结构,
  14. * 因此无根节点不影响关联数据的获取;数据量大时可使用 graph 的局部子图查询。
  15. *
  16. * 节点类型说明:
  17. * - theme/subject/scene/prop/segment 属于"列表型"节点,可复用全局资产库或动漫分集中的数据
  18. * - text/image/video/audio 属于"内容型"节点
  19. * - 关联无方向、多对多,通过 (node_id_a, node_id_b) 且 a < b 归一化存储
  20. */
  21. class CanvasService
  22. {
  23. /** 节点类型白名单 */
  24. const NODE_TYPES = ['theme', 'subject', 'scene', 'prop', 'segment', 'text', 'image', 'video', 'audio'];
  25. /** 关联来源类型白名单 */
  26. const SOURCE_TYPES = ['none', 'product', 'episode'];
  27. /** 当前支持的画布文档 schema 版本 */
  28. const SUPPORTED_SCHEMA_VERSION = 1;
  29. /** 画布文档大小与图规模限制 */
  30. const MAX_DOCUMENT_BYTES = 2097152; // 2MB
  31. const MAX_NODES = 2000;
  32. const MAX_EDGES = 5000;
  33. const MAX_TEXT_LENGTH = 50000;
  34. const MAX_URL_LENGTH = 2048;
  35. const MAX_TITLE_LENGTH = 100;
  36. /**
  37. * 画布摘要列表(分页,不返回完整图)
  38. *
  39. * @param array $data anime_id-动漫ID(可选) episode_id-剧集ID(可选) page-页码 page_size-每页数量
  40. * @return array
  41. */
  42. public function canvasList($data)
  43. {
  44. $uid = Site::getUid();
  45. $cpid = Site::getCpid();
  46. $anime_id = (int)getProp($data, 'anime_id', 0);
  47. $episode_id = (int)getProp($data, 'episode_id', 0);
  48. $page = max(1, (int)getProp($data, 'page', 1));
  49. $page_size = min(100, max(1, (int)getProp($data, 'page_size', 20)));
  50. $query = DB::table('mp_canvases')
  51. ->where('uid', $uid)
  52. ->where('cpid', $cpid)
  53. ->where('is_deleted', 0)
  54. ->select('canvas_uuid as canvas_id', 'name as title', 'description', 'cover_url',
  55. 'anime_id', 'episode_id', 'episode_number', 'schema_version', 'revision',
  56. 'created_at', 'updated_at');
  57. if ($anime_id) {
  58. $query->where('anime_id', $anime_id);
  59. }
  60. if ($episode_id) {
  61. $query->where('episode_id', $episode_id);
  62. }
  63. $list = $query->orderByDesc('id')->paginate($page_size, ['*'], 'page', $page);
  64. $items = $list->map(function ($item) {
  65. $item = (array)$item;
  66. $item['created_at'] = transDate($item['created_at'], 'Y-m-d H:i:s');
  67. $item['updated_at'] = transDate($item['updated_at'], 'Y-m-d H:i:s');
  68. $item['scope'] = [
  69. 'anime_id' => (int)$item['anime_id'],
  70. 'episode_id' => (int)$item['episode_id'],
  71. 'episode_number' => (int)$item['episode_number']
  72. ];
  73. unset($item['anime_id'], $item['episode_id'], $item['episode_number']);
  74. return $item;
  75. })->toArray();
  76. return [
  77. 'meta' => getMeta($list),
  78. 'list' => $items
  79. ];
  80. }
  81. /**
  82. * 创建画布(完整文档模式,兼容旧的 name-only 创建)
  83. *
  84. * @param array $data 完整画布文档或旧版 name/description/cover_url
  85. * @return array
  86. */
  87. public function createCanvas($data)
  88. {
  89. $uid = Site::getUid();
  90. $cpid = Site::getCpid();
  91. $now = date('Y-m-d H:i:s');
  92. if ($this->isDocumentRequest($data)) {
  93. $doc = $this->validateDocument($data, true);
  94. $canvas_uuid = $doc['canvas_id'];
  95. if (DB::table('mp_canvases')->where('canvas_uuid', $canvas_uuid)->exists()) {
  96. Utils::throwError('20003:画布ID已存在');
  97. }
  98. $row = [
  99. 'uid' => $uid,
  100. 'cpid' => $cpid,
  101. 'canvas_uuid' => $canvas_uuid,
  102. 'name' => $doc['title'],
  103. 'description' => (string)getProp($data, 'description', ''),
  104. 'cover_url' => (string)getProp($data, 'cover_url', ''),
  105. 'anime_id' => $doc['scope']['anime_id'],
  106. 'episode_id' => $doc['scope']['episode_id'],
  107. 'episode_number' => $doc['scope']['episode_number'],
  108. 'schema_version' => self::SUPPORTED_SCHEMA_VERSION,
  109. 'revision' => 1,
  110. 'document' => json_encode($doc, JSON_UNESCAPED_UNICODE),
  111. 'is_deleted' => 0,
  112. 'created_at' => $now,
  113. 'updated_at' => $now
  114. ];
  115. $canvas_id = DB::table('mp_canvases')->insertGetId($row);
  116. if (!$canvas_id) {
  117. Utils::throwError('20003:创建画布失败');
  118. }
  119. $row['id'] = $canvas_id;
  120. return $this->buildDocument((object)$row);
  121. }
  122. // 旧版 name-only 创建,保留兼容
  123. $name = trim((string)getProp($data, 'name', ''));
  124. if ($name === '') {
  125. Utils::throwError('20003:请提供画布名称');
  126. }
  127. $canvas_uuid = $this->generateCanvasId();
  128. $canvas_id = DB::table('mp_canvases')->insertGetId([
  129. 'uid' => $uid,
  130. 'cpid' => $cpid,
  131. 'canvas_uuid' => $canvas_uuid,
  132. 'name' => $name,
  133. 'description' => (string)getProp($data, 'description', ''),
  134. 'cover_url' => (string)getProp($data, 'cover_url', ''),
  135. 'schema_version' => self::SUPPORTED_SCHEMA_VERSION,
  136. 'revision' => 1,
  137. 'document' => null,
  138. 'is_deleted' => 0,
  139. 'created_at' => $now,
  140. 'updated_at' => $now
  141. ]);
  142. if (!$canvas_id) {
  143. Utils::throwError('20003:创建画布失败');
  144. }
  145. return $this->canvasDetail(['canvas_id' => $canvas_uuid]);
  146. }
  147. /**
  148. * 保存画布完整文档;revision 不匹配时返回冲突标记
  149. *
  150. * @param array $data 完整画布文档
  151. * @return array
  152. */
  153. public function editCanvas($data)
  154. {
  155. $canvas_id = (string)getProp($data, 'canvas_id', '');
  156. if ($canvas_id === '') {
  157. Utils::throwError('20003:请提供画布ID');
  158. }
  159. $canvas = $this->checkCanvas($canvas_id);
  160. $doc = $this->validateDocument($data, false, $canvas);
  161. if ((int)$doc['revision'] !== (int)$canvas->revision) {
  162. return [
  163. 'revision_conflict' => true,
  164. 'canvas_id' => $this->canvasKey($canvas),
  165. 'revision' => (int)$canvas->revision
  166. ];
  167. }
  168. $next_revision = (int)$canvas->revision + 1;
  169. $doc['revision'] = $next_revision;
  170. $doc['canvas_id'] = $this->canvasKey($canvas);
  171. DB::table('mp_canvases')->where('id', $canvas->id)->update([
  172. 'name' => $doc['title'],
  173. 'anime_id' => $doc['scope']['anime_id'],
  174. 'episode_id' => $doc['scope']['episode_id'],
  175. 'episode_number' => $doc['scope']['episode_number'],
  176. 'schema_version' => self::SUPPORTED_SCHEMA_VERSION,
  177. 'revision' => $next_revision,
  178. 'document' => json_encode($doc, JSON_UNESCAPED_UNICODE),
  179. 'updated_at' => date('Y-m-d H:i:s')
  180. ]);
  181. return $this->canvasDetail(['canvas_id' => $doc['canvas_id']]);
  182. }
  183. /**
  184. * 删除画布(软删除画布/节点/分镜数据,物理删除关联)
  185. *
  186. * @param array $data canvas_id-画布ID
  187. * @return array
  188. */
  189. public function delCanvas($data)
  190. {
  191. $canvas_id = (string)getProp($data, 'canvas_id', '');
  192. $canvas = $this->checkCanvas($canvas_id);
  193. $now = date('Y-m-d H:i:s');
  194. DB::beginTransaction();
  195. try {
  196. DB::table('mp_canvases')->where('id', $canvas->id)->update(['is_deleted' => 1, 'updated_at' => $now]);
  197. DB::table('mp_canvas_nodes')->where('canvas_id', $canvas->id)->update(['is_deleted' => 1, 'updated_at' => $now]);
  198. DB::table('mp_canvas_segments')->where('canvas_id', $canvas->id)->update(['is_deleted' => 1, 'updated_at' => $now]);
  199. DB::table('mp_canvas_node_rels')->where('canvas_id', $canvas->id)->delete();
  200. DB::commit();
  201. } catch (\Throwable $e) {
  202. DB::rollBack();
  203. Utils::throwError('20003:删除画布失败');
  204. }
  205. return ['success' => 1];
  206. }
  207. /**
  208. * 打开指定画布,返回完整文档
  209. *
  210. * @param array $data canvas_id-画布ID(字符串 canvas_uuid 或旧数字ID)
  211. * @return array
  212. */
  213. public function canvasDetail($data)
  214. {
  215. $canvas_id = (string)getProp($data, 'canvas_id', '');
  216. $canvas = $this->checkCanvas($canvas_id);
  217. return $this->buildDocument($canvas);
  218. }
  219. /**
  220. * 保存节点(创建/更新)
  221. *
  222. * 通用字段直接写 mp_canvas_nodes;node_type=segment 时同步写 mp_canvas_segments。
  223. * 分镜关键数据优先取 data.segment 子对象,缺省时回退到节点级字段。
  224. *
  225. * @param array $data canvas_id-画布ID node_id-节点ID(更新时) node_type-节点类型
  226. * name/content/text_prompt/pic_prompt/image_url/video_url/audio_url/
  227. * thumbnail_url/duration/pos_x/pos_y/z_index/size_w/size_h
  228. * source_type/source_product_id/source_anime_id/source_episode_id/source_ref/ext
  229. * segment-分镜数据子对象(可选)
  230. * @return array
  231. */
  232. public function saveNode($data)
  233. {
  234. $canvas_id = (int)getProp($data, 'canvas_id');
  235. $node_id = (int)getProp($data, 'node_id', 0);
  236. $node_type = (string)getProp($data, 'node_type', '');
  237. if (!in_array($node_type, self::NODE_TYPES, true)) {
  238. Utils::throwError('20003:无效的节点类型');
  239. }
  240. $this->checkCanvas($canvas_id);
  241. $uid = Site::getUid();
  242. $cpid = Site::getCpid();
  243. $now = date('Y-m-d H:i:s');
  244. $fields = [
  245. 'name' => (string)getProp($data, 'name', ''),
  246. 'content' => (string)getProp($data, 'content', ''),
  247. 'text_prompt' => (string)getProp($data, 'text_prompt', ''),
  248. 'pic_prompt' => (string)getProp($data, 'pic_prompt', ''),
  249. 'image_url' => (string)getProp($data, 'image_url', ''),
  250. 'video_url' => (string)getProp($data, 'video_url', ''),
  251. 'audio_url' => (string)getProp($data, 'audio_url', ''),
  252. 'thumbnail_url' => (string)getProp($data, 'thumbnail_url', ''),
  253. 'duration' => $this->nullableInt(getProp($data, 'duration', null)),
  254. 'pos_x' => (float)getProp($data, 'pos_x', 0),
  255. 'pos_y' => (float)getProp($data, 'pos_y', 0),
  256. 'z_index' => (int)getProp($data, 'z_index', 0),
  257. 'size_w' => $this->nullableFloat(getProp($data, 'size_w', null)),
  258. 'size_h' => $this->nullableFloat(getProp($data, 'size_h', null)),
  259. 'source_type' => (string)getProp($data, 'source_type', 'none'),
  260. 'source_product_id' => $this->nullableInt(getProp($data, 'source_product_id', null)),
  261. 'source_anime_id' => $this->nullableInt(getProp($data, 'source_anime_id', null)),
  262. 'source_episode_id' => $this->nullableInt(getProp($data, 'source_episode_id', null)),
  263. 'source_ref' => (string)getProp($data, 'source_ref', ''),
  264. 'ext' => $this->jsonEncode(getProp($data, 'ext', null))
  265. ];
  266. if (!in_array($fields['source_type'], self::SOURCE_TYPES, true)) {
  267. $fields['source_type'] = 'none';
  268. }
  269. DB::beginTransaction();
  270. try {
  271. $real_type = $node_type;
  272. if ($node_id) {
  273. $exists = DB::table('mp_canvas_nodes')
  274. ->where('id', $node_id)
  275. ->where('canvas_id', $canvas_id)
  276. ->where('is_deleted', 0)
  277. ->first();
  278. if (!$exists) {
  279. Utils::throwError('20003:节点不存在');
  280. }
  281. // 更新时节点类型以库中为准,不允许变更
  282. $real_type = $exists->node_type;
  283. $fields['updated_at'] = $now;
  284. DB::table('mp_canvas_nodes')->where('id', $node_id)->update($fields);
  285. } else {
  286. $fields['canvas_id'] = $canvas_id;
  287. $fields['uid'] = $uid;
  288. $fields['cpid'] = $cpid;
  289. $fields['node_type'] = $node_type;
  290. $fields['is_deleted'] = 0;
  291. $fields['created_at'] = $now;
  292. $fields['updated_at'] = $now;
  293. $node_id = DB::table('mp_canvas_nodes')->insertGetId($fields);
  294. if (!$node_id) {
  295. Utils::throwError('20003:保存节点失败');
  296. }
  297. }
  298. // 分镜节点同步分镜数据表
  299. if ($real_type === 'segment') {
  300. $this->syncSegment($canvas_id, $node_id, $data, $now);
  301. }
  302. DB::commit();
  303. } catch (\Throwable $e) {
  304. DB::rollBack();
  305. throw $e;
  306. }
  307. return ['node_id' => $node_id];
  308. }
  309. /**
  310. * 节点详情(含分镜数据与当前关联)
  311. *
  312. * @param array $data canvas_id-画布ID node_id-节点ID
  313. * @return array
  314. */
  315. public function nodeInfo($data)
  316. {
  317. $canvas_id = (int)getProp($data, 'canvas_id');
  318. $node_id = (int)getProp($data, 'node_id');
  319. $this->checkCanvas($canvas_id);
  320. $node = $this->getNode($canvas_id, $node_id);
  321. $node = $this->formatNode($node, $canvas_id);
  322. $node['rel_ids'] = $this->getNodeRelIds($canvas_id, $node_id);
  323. $node['related_nodes'] = $this->getRelatedNodes($canvas_id, $node_id);
  324. return $node;
  325. }
  326. /**
  327. * 获取节点的所有关联节点
  328. *
  329. * @param array $data canvas_id-画布ID node_id-节点ID
  330. * @return array
  331. */
  332. public function nodeRels($data)
  333. {
  334. $canvas_id = (int)getProp($data, 'canvas_id');
  335. $node_id = (int)getProp($data, 'node_id');
  336. $this->checkCanvas($canvas_id);
  337. $this->getNode($canvas_id, $node_id);
  338. return [
  339. 'node_id' => $node_id,
  340. 'rel_ids' => $this->getNodeRelIds($canvas_id, $node_id),
  341. 'related_nodes' => $this->getRelatedNodes($canvas_id, $node_id)
  342. ];
  343. }
  344. /**
  345. * 保存节点关联(整体替换:先清空该节点的所有关联,再写入新关联)
  346. *
  347. * 关联无方向、多对多;内部统一归一化为 (小id, 大id) 存储,配合唯一索引防止重复。
  348. *
  349. * @param array $data canvas_id-画布ID node_id-节点ID related_ids-关联节点ID数组(可传逗号分隔字符串)
  350. * @return array
  351. */
  352. public function saveRels($data)
  353. {
  354. $canvas_id = (int)getProp($data, 'canvas_id');
  355. $node_id = (int)getProp($data, 'node_id');
  356. $this->checkCanvas($canvas_id);
  357. $this->getNode($canvas_id, $node_id);
  358. $related_ids = getProp($data, 'related_ids', []);
  359. if (!is_array($related_ids)) {
  360. $related_ids = $related_ids === '' ? [] : explode(',', (string)$related_ids);
  361. }
  362. $related_ids = array_values(array_unique(array_filter(array_map('intval', $related_ids))));
  363. // 去除自身关联
  364. $related_ids = array_values(array_diff($related_ids, [$node_id]));
  365. $pairs = [];
  366. if ($related_ids) {
  367. // 只允许关联同一画布下未删除的节点
  368. $valid_ids = DB::table('mp_canvas_nodes')
  369. ->where('canvas_id', $canvas_id)
  370. ->whereIn('id', $related_ids)
  371. ->where('is_deleted', 0)
  372. ->pluck('id')
  373. ->all();
  374. foreach ($valid_ids as $rid) {
  375. $a = (int)min($node_id, $rid);
  376. $b = (int)max($node_id, $rid);
  377. $pairs[$a . '_' . $b] = ['a' => $a, 'b' => $b];
  378. }
  379. }
  380. $now = date('Y-m-d H:i:s');
  381. DB::beginTransaction();
  382. try {
  383. DB::table('mp_canvas_node_rels')
  384. ->where('canvas_id', $canvas_id)
  385. ->where(function ($query) use ($node_id) {
  386. $query->where('node_id_a', $node_id)
  387. ->orWhere('node_id_b', $node_id);
  388. })
  389. ->delete();
  390. if ($pairs) {
  391. $rows = [];
  392. foreach ($pairs as $pair) {
  393. $rows[] = [
  394. 'canvas_id' => $canvas_id,
  395. 'node_id_a' => $pair['a'],
  396. 'node_id_b' => $pair['b'],
  397. 'created_at' => $now,
  398. 'updated_at' => $now
  399. ];
  400. }
  401. DB::table('mp_canvas_node_rels')->insert($rows);
  402. }
  403. DB::commit();
  404. } catch (\Throwable $e) {
  405. DB::rollBack();
  406. Utils::throwError('20003:保存关联失败');
  407. }
  408. return ['success' => 1, 'rel_count' => count($pairs)];
  409. }
  410. /**
  411. * 删除节点(软删除节点/分镜数据,物理删除关联)
  412. *
  413. * @param array $data canvas_id-画布ID node_id-节点ID
  414. * @return array
  415. */
  416. public function delNode($data)
  417. {
  418. $canvas_id = (int)getProp($data, 'canvas_id');
  419. $node_id = (int)getProp($data, 'node_id');
  420. $this->checkCanvas($canvas_id);
  421. $node = $this->getNode($canvas_id, $node_id);
  422. $now = date('Y-m-d H:i:s');
  423. DB::beginTransaction();
  424. try {
  425. DB::table('mp_canvas_nodes')->where('id', $node_id)->update(['is_deleted' => 1, 'updated_at' => $now]);
  426. DB::table('mp_canvas_node_rels')
  427. ->where('canvas_id', $canvas_id)
  428. ->where(function ($query) use ($node_id) {
  429. $query->where('node_id_a', $node_id)
  430. ->orWhere('node_id_b', $node_id);
  431. })
  432. ->delete();
  433. if ($node->node_type === 'segment') {
  434. DB::table('mp_canvas_segments')
  435. ->where('canvas_id', $canvas_id)
  436. ->where('node_id', $node_id)
  437. ->update(['is_deleted' => 1, 'updated_at' => $now]);
  438. }
  439. DB::commit();
  440. } catch (\Throwable $e) {
  441. DB::rollBack();
  442. Utils::throwError('20003:删除节点失败');
  443. }
  444. return ['success' => 1];
  445. }
  446. /**
  447. * 画布关系图(供前端渲染)
  448. *
  449. * 不传 node_id 时返回画布全部节点与关联;传 node_id 时返回以该节点为中心、
  450. * 深度为 depth 的局部子图(按广度优先收集邻居;rels 只包含 BFS 展开过程中触及的边,
  451. * 即 depth=1 时仅返回中心节点的直接关联),适合大画布局部加载。
  452. * 局部子图模式下,每个节点/每条关联附带 level 字段(节点为距中心的跳数,
  453. * 关联为 BFS 展开时首次被触及的轮次),另附 levels 摘要便于前端逐层加载与动画。
  454. *
  455. * @param array $data canvas_id-画布ID node_id-中心节点ID(可选) depth-子图深度(可选,默认1)
  456. * @return array
  457. */
  458. public function graph($data)
  459. {
  460. $canvas_id = (int)getProp($data, 'canvas_id');
  461. $node_id = (int)getProp($data, 'node_id', 0);
  462. $depth = min(5, max(1, (int)getProp($data, 'depth', 1)));
  463. $this->checkCanvas($canvas_id);
  464. if ($node_id) {
  465. return $this->getSubGraph($canvas_id, $node_id, $depth);
  466. }
  467. return $this->getGraph($canvas_id);
  468. }
  469. /* ------------------------------------------------------------------ */
  470. /* 私有方法 */
  471. /* ------------------------------------------------------------------ */
  472. /**
  473. * 校验画布归属并返回画布
  474. *
  475. * @param string|int $canvas_id
  476. * @return object
  477. */
  478. private function checkCanvas($canvas_id)
  479. {
  480. if ($canvas_id === '' || $canvas_id === null) {
  481. Utils::throwError('20003:请提供画布ID');
  482. }
  483. $query = DB::table('mp_canvases')
  484. ->where('uid', Site::getUid())
  485. ->where('cpid', Site::getCpid())
  486. ->where('is_deleted', 0);
  487. if (is_numeric($canvas_id)) {
  488. $query->where('id', (int)$canvas_id);
  489. } else {
  490. $query->where('canvas_uuid', (string)$canvas_id);
  491. }
  492. $canvas = $query->first();
  493. if (!$canvas) {
  494. Utils::throwError('20003:画布不存在或无权访问');
  495. }
  496. return $canvas;
  497. }
  498. /**
  499. * 画布对外资源 ID:优先字符串 canvas_uuid,旧数据兜底数字 id
  500. *
  501. * @param object $canvas
  502. * @return string
  503. */
  504. private function canvasKey($canvas)
  505. {
  506. $uuid = (string)getProp($canvas, 'canvas_uuid', '');
  507. return $uuid !== '' ? $uuid : (string)(int)getProp($canvas, 'id', 0);
  508. }
  509. /**
  510. * 生成前端可用的稳定画布 ID
  511. *
  512. * @return string
  513. */
  514. private function generateCanvasId()
  515. {
  516. return 'canvas-' . \Illuminate\Support\Str::uuid()->toString();
  517. }
  518. /**
  519. * 判断请求是否为完整文档创建
  520. *
  521. * @param mixed $data
  522. * @return bool
  523. */
  524. private function isDocumentRequest($data)
  525. {
  526. return is_array($data) && (
  527. array_key_exists('graph', $data) ||
  528. array_key_exists('schema_version', $data) ||
  529. array_key_exists('title', $data) ||
  530. array_key_exists('scope', $data) ||
  531. array_key_exists('canvas_id', $data)
  532. );
  533. }
  534. /**
  535. * 校验并规范化完整画布文档
  536. *
  537. * @param array $data
  538. * @param bool $isCreate
  539. * @param object|null $canvas
  540. * @return array
  541. */
  542. private function validateDocument($data, $isCreate, $canvas = null)
  543. {
  544. if (!is_array($data)) {
  545. Utils::throwError('20003:请求体格式不正确');
  546. }
  547. $schema_version = (int)getProp($data, 'schema_version', self::SUPPORTED_SCHEMA_VERSION);
  548. if ($schema_version !== self::SUPPORTED_SCHEMA_VERSION) {
  549. Utils::throwError('20003:不支持的schema_version');
  550. }
  551. $canvas_id = (string)getProp($data, 'canvas_id', '');
  552. if ($isCreate) {
  553. if ($canvas_id === '') {
  554. $canvas_id = $this->generateCanvasId();
  555. }
  556. if (mb_strlen($canvas_id) > 64) {
  557. Utils::throwError('20003:canvas_id长度超出限制');
  558. }
  559. } else {
  560. $key = $canvas ? $this->canvasKey($canvas) : '';
  561. if ($canvas_id !== '' && $canvas_id !== $key && $canvas_id !== (string)getProp($canvas, 'id', 0)) {
  562. Utils::throwError('20003:canvas_id与画布不一致');
  563. }
  564. $canvas_id = $key;
  565. }
  566. $revision = $isCreate ? 1 : (int)getProp($data, 'revision', -1);
  567. if (!$isCreate && $revision < 0) {
  568. Utils::throwError('20003:缺少revision');
  569. }
  570. $title = trim((string)getProp($data, 'title', getProp($data, 'name', '')));
  571. if ($title === '') {
  572. $title = '未命名画布';
  573. }
  574. if (mb_strlen($title) > self::MAX_TITLE_LENGTH) {
  575. Utils::throwError('20003:画布标题长度超出限制');
  576. }
  577. $scope = getProp($data, 'scope', []);
  578. if (!is_array($scope)) {
  579. Utils::throwError('20003:scope格式不正确');
  580. }
  581. $scope = $this->checkScope($scope);
  582. $graph = getProp($data, 'graph', []);
  583. if (!is_array($graph)) {
  584. Utils::throwError('20003:graph格式不正确');
  585. }
  586. $graph = $this->normalizeGraph($graph);
  587. $preferences = getProp($data, 'preferences', []);
  588. if (!is_array($preferences)) {
  589. $preferences = [];
  590. }
  591. $doc = [
  592. 'schema_version' => self::SUPPORTED_SCHEMA_VERSION,
  593. 'canvas_id' => $canvas_id,
  594. 'revision' => $revision,
  595. 'scope' => $scope,
  596. 'title' => $title,
  597. 'graph' => $graph,
  598. 'preferences' => $preferences
  599. ];
  600. if (strlen(json_encode($doc, JSON_UNESCAPED_UNICODE)) > self::MAX_DOCUMENT_BYTES) {
  601. Utils::throwError('20003:画布文档大小超出限制');
  602. }
  603. $this->checkTextAndUrlLimits($doc);
  604. return $doc;
  605. }
  606. /**
  607. * 校验 scope 归属:动漫属于当前用户,剧集属于该动漫
  608. *
  609. * @param array $scope
  610. * @return array
  611. */
  612. private function checkScope($scope)
  613. {
  614. $anime_id = (int)getProp($scope, 'anime_id', 0);
  615. $episode_id = (int)getProp($scope, 'episode_id', 0);
  616. if (!$anime_id || !$episode_id) {
  617. Utils::throwError('20003:scope.anime_id和scope.episode_id不能为空');
  618. }
  619. $anime = DB::table('mp_animes')
  620. ->where('id', $anime_id)
  621. ->where('user_id', Site::getUid())
  622. ->where('is_deleted', 0)
  623. ->first();
  624. if (!$anime) {
  625. Utils::throwError('20003:动漫不存在或无权访问');
  626. }
  627. $episode = DB::table('mp_anime_episodes')
  628. ->where('id', $episode_id)
  629. ->where('anime_id', $anime_id)
  630. ->first();
  631. if (!$episode) {
  632. Utils::throwError('20003:剧集不存在或不属于该动漫');
  633. }
  634. return [
  635. 'anime_id' => $anime_id,
  636. 'episode_id' => $episode_id,
  637. 'episode_number' => (int)$episode->episode_number
  638. ];
  639. }
  640. /**
  641. * 图结构最小校验:节点/边数量、ID 唯一、边端点存在
  642. *
  643. * @param array $graph
  644. * @return array
  645. */
  646. private function normalizeGraph($graph)
  647. {
  648. $viewport = getProp($graph, 'viewport', ['x' => 0, 'y' => 0, 'zoom' => 1]);
  649. if (!is_array($viewport)) {
  650. $viewport = ['x' => 0, 'y' => 0, 'zoom' => 1];
  651. }
  652. $nodes = getProp($graph, 'nodes', []);
  653. $edges = getProp($graph, 'edges', []);
  654. if (!is_array($nodes)) {
  655. $nodes = [];
  656. }
  657. if (!is_array($edges)) {
  658. $edges = [];
  659. }
  660. if (count($nodes) > self::MAX_NODES) {
  661. Utils::throwError('20003:节点数超出限制');
  662. }
  663. if (count($edges) > self::MAX_EDGES) {
  664. Utils::throwError('20003:边数超出限制');
  665. }
  666. $nodeIds = [];
  667. foreach ($nodes as $node) {
  668. if (!is_array($node)) {
  669. Utils::throwError('20003:节点格式不正确');
  670. }
  671. $id = getProp($node, 'id', '');
  672. if ($id === '' || $id === null || !is_scalar($id)) {
  673. Utils::throwError('20003:节点ID不能为空');
  674. }
  675. $idKey = (string)$id;
  676. if (isset($nodeIds[$idKey])) {
  677. Utils::throwError('20003:节点ID重复:' . $idKey);
  678. }
  679. $nodeIds[$idKey] = true;
  680. }
  681. $edgeIds = [];
  682. foreach ($edges as $edge) {
  683. if (!is_array($edge)) {
  684. Utils::throwError('20003:边格式不正确');
  685. }
  686. $source = (string)getProp($edge, 'source', '');
  687. $target = (string)getProp($edge, 'target', '');
  688. if (!isset($nodeIds[$source]) || !isset($nodeIds[$target])) {
  689. Utils::throwError('20003:边端点不存在');
  690. }
  691. $edgeId = getProp($edge, 'id', '');
  692. if ($edgeId !== '' && $edgeId !== null) {
  693. if (!is_scalar($edgeId)) {
  694. Utils::throwError('20003:边ID格式不正确');
  695. }
  696. $edgeKey = (string)$edgeId;
  697. if (isset($edgeIds[$edgeKey])) {
  698. Utils::throwError('20003:边ID重复:' . $edgeKey);
  699. }
  700. $edgeIds[$edgeKey] = true;
  701. }
  702. }
  703. return [
  704. 'viewport' => $viewport,
  705. 'nodes' => $nodes,
  706. 'edges' => $edges
  707. ];
  708. }
  709. /**
  710. * 递归限制文档内文本与 URL 长度
  711. *
  712. * @param mixed $value
  713. * @return void
  714. */
  715. private function checkTextAndUrlLimits($value)
  716. {
  717. if (is_string($value)) {
  718. if (strlen($value) > self::MAX_TEXT_LENGTH) {
  719. Utils::throwError('20003:文本内容长度超出限制');
  720. }
  721. if (preg_match('#^https?://#i', $value) && strlen($value) > self::MAX_URL_LENGTH) {
  722. Utils::throwError('20003:URL长度超出限制');
  723. }
  724. return;
  725. }
  726. if (!is_array($value)) {
  727. return;
  728. }
  729. foreach ($value as $item) {
  730. $this->checkTextAndUrlLimits($item);
  731. }
  732. }
  733. /**
  734. * 组装完整文档;旧画布无 document 时从节点/关联表构建
  735. *
  736. * @param object $canvas
  737. * @return array
  738. */
  739. private function buildDocument($canvas)
  740. {
  741. $raw = (string)getProp($canvas, 'document', '');
  742. if ($raw !== '') {
  743. $doc = json_decode($raw, true);
  744. if (is_array($doc)) {
  745. $doc['canvas_id'] = $this->canvasKey($canvas);
  746. $doc['revision'] = (int)getProp($canvas, 'revision', 1);
  747. $doc['schema_version'] = (int)getProp($canvas, 'schema_version', self::SUPPORTED_SCHEMA_VERSION);
  748. $doc['scope'] = $this->scopeFromRow($canvas);
  749. $doc['title'] = (string)getProp($canvas, 'name', getProp($doc, 'title', '未命名画布'));
  750. if (!isset($doc['graph']) || !is_array($doc['graph'])) {
  751. $doc['graph'] = ['viewport' => ['x' => 0, 'y' => 0, 'zoom' => 1], 'nodes' => [], 'edges' => []];
  752. }
  753. if (!isset($doc['preferences']) || !is_array($doc['preferences'])) {
  754. $doc['preferences'] = ['minimap_visible' => true, 'snap_to_grid_enabled' => false];
  755. }
  756. return $doc;
  757. }
  758. }
  759. return $this->buildLegacyDocument($canvas);
  760. }
  761. /**
  762. * 从画布行组装 scope
  763. *
  764. * @param object $canvas
  765. * @return array
  766. */
  767. private function scopeFromRow($canvas)
  768. {
  769. return [
  770. 'anime_id' => (int)getProp($canvas, 'anime_id', 0),
  771. 'episode_id' => (int)getProp($canvas, 'episode_id', 0),
  772. 'episode_number' => (int)getProp($canvas, 'episode_number', 0)
  773. ];
  774. }
  775. /**
  776. * 旧画布文档兜底:由节点/关联表生成 graph
  777. *
  778. * @param object $canvas
  779. * @return array
  780. */
  781. private function buildLegacyDocument($canvas)
  782. {
  783. $graph = $this->getGraph((int)getProp($canvas, 'id', 0));
  784. $nodes = [];
  785. foreach ($graph['nodes'] as $node) {
  786. $node = (array)$node;
  787. if (isset($node['id'])) {
  788. $node['id'] = (string)$node['id'];
  789. }
  790. $nodes[] = $node;
  791. }
  792. $edges = [];
  793. foreach ($graph['rels'] as $rel) {
  794. $edges[] = [
  795. 'id' => (string)$rel['id'],
  796. 'source' => (string)$rel['node_id_a'],
  797. 'target' => (string)$rel['node_id_b']
  798. ];
  799. }
  800. return [
  801. 'schema_version' => self::SUPPORTED_SCHEMA_VERSION,
  802. 'canvas_id' => $this->canvasKey($canvas),
  803. 'revision' => (int)getProp($canvas, 'revision', 1),
  804. 'scope' => $this->scopeFromRow($canvas),
  805. 'title' => (string)getProp($canvas, 'name', '未命名画布'),
  806. 'graph' => [
  807. 'viewport' => ['x' => 0, 'y' => 0, 'zoom' => 1],
  808. 'nodes' => $nodes,
  809. 'edges' => $edges
  810. ],
  811. 'preferences' => [
  812. 'minimap_visible' => true,
  813. 'snap_to_grid_enabled' => false
  814. ]
  815. ];
  816. }
  817. /**
  818. * 校验节点归属并返回节点
  819. *
  820. * @param int $canvas_id
  821. * @param int $node_id
  822. * @return object
  823. */
  824. private function getNode($canvas_id, $node_id)
  825. {
  826. if (!$node_id) {
  827. Utils::throwError('20003:请提供节点ID');
  828. }
  829. $node = DB::table('mp_canvas_nodes')
  830. ->where('id', $node_id)
  831. ->where('canvas_id', $canvas_id)
  832. ->where('is_deleted', 0)
  833. ->first();
  834. if (!$node) {
  835. Utils::throwError('20003:节点不存在');
  836. }
  837. return $node;
  838. }
  839. /**
  840. * 同步分镜数据表(创建或更新)
  841. *
  842. * @param int $canvas_id
  843. * @param int $node_id
  844. * @param array $data
  845. * @param string $now
  846. * @return void
  847. */
  848. private function syncSegment($canvas_id, $node_id, $data, $now)
  849. {
  850. $seg = getProp($data, 'segment');
  851. $seg = is_array($seg) ? $seg : [];
  852. $seg_fields = [
  853. 'segment_number' => (int)getProp($seg, 'segment_number', 1),
  854. 'name' => (string)getProp($seg, 'name', getProp($data, 'name', '')),
  855. 'text_prompt' => (string)getProp($seg, 'text_prompt', getProp($data, 'text_prompt', '')),
  856. 'pic_prompt' => (string)getProp($seg, 'pic_prompt', getProp($data, 'pic_prompt', '')),
  857. 'image_url' => (string)getProp($seg, 'image_url', getProp($data, 'image_url', '')),
  858. 'video_url' => (string)getProp($seg, 'video_url', getProp($data, 'video_url', '')),
  859. 'audio_url' => (string)getProp($seg, 'audio_url', getProp($data, 'audio_url', '')),
  860. 'duration' => $this->nullableInt(getProp($seg, 'duration', getProp($data, 'duration', null))),
  861. 'status' => (string)getProp($seg, 'status', 'draft'),
  862. 'ext' => $this->jsonEncode(getProp($seg, 'ext', null))
  863. ];
  864. $exists = DB::table('mp_canvas_segments')
  865. ->where('canvas_id', $canvas_id)
  866. ->where('node_id', $node_id)
  867. ->where('is_deleted', 0)
  868. ->first();
  869. if ($exists) {
  870. $seg_fields['updated_at'] = $now;
  871. DB::table('mp_canvas_segments')->where('id', $exists->id)->update($seg_fields);
  872. } else {
  873. $seg_fields['canvas_id'] = $canvas_id;
  874. $seg_fields['node_id'] = $node_id;
  875. $seg_fields['uid'] = Site::getUid();
  876. $seg_fields['cpid'] = Site::getCpid();
  877. $seg_fields['is_deleted'] = 0;
  878. $seg_fields['created_at'] = $now;
  879. $seg_fields['updated_at'] = $now;
  880. DB::table('mp_canvas_segments')->insert($seg_fields);
  881. }
  882. }
  883. /**
  884. * 获取以指定节点为中心的局部子图(广度优先,最多 depth 层)
  885. *
  886. * 节点集合为距中心不超过 depth 跳的所有节点;
  887. * 关系集合为 BFS 展开过程中触及的边(至少一端距中心不超过 depth-1 跳),
  888. * 因此 depth=1 时只返回中心节点的直接关联,不会返回"邻居之间的边"。
  889. * 返回的节点带 level(距中心跳数,中心为 0),关联带 level(首次被触及的轮次),
  890. * 并提供 levels 摘要 [{level, node_ids, rel_ids}] 供前端逐层渲染。
  891. *
  892. * @param int $canvas_id
  893. * @param int $node_id
  894. * @param int $depth
  895. * @return array
  896. */
  897. private function getSubGraph($canvas_id, $node_id, $depth)
  898. {
  899. $this->getNode($canvas_id, $node_id);
  900. // 广度优先收集节点(节点只入队一次,避免环导致死循环)
  901. $collectedMap = [$node_id => 0];
  902. $collected = [$node_id];
  903. $frontier = [$node_id];
  904. $edgeMap = [];
  905. for ($i = 0; $i < $depth; $i++) {
  906. $level = $i + 1;
  907. $rels = DB::table('mp_canvas_node_rels')
  908. ->where('canvas_id', $canvas_id)
  909. ->where(function ($query) use ($frontier) {
  910. $query->whereIn('node_id_a', $frontier)
  911. ->orWhereIn('node_id_b', $frontier);
  912. })
  913. ->get();
  914. $next = [];
  915. foreach ($rels as $rel) {
  916. $a = (int)$rel->node_id_a;
  917. $b = (int)$rel->node_id_b;
  918. // 只记录 BFS 展开过程中触及的边(去重),不返回"邻居之间的边"
  919. if (!isset($edgeMap[$a . '_' . $b])) {
  920. $edgeMap[$a . '_' . $b] = [
  921. 'id' => (int)$rel->id,
  922. 'node_id_a' => $a,
  923. 'node_id_b' => $b,
  924. 'level' => $level
  925. ];
  926. }
  927. foreach ([$a, $b] as $nid) {
  928. if (!isset($collectedMap[$nid])) {
  929. $collectedMap[$nid] = $level;
  930. $collected[] = $nid;
  931. $next[] = $nid;
  932. }
  933. }
  934. }
  935. if (!$next) {
  936. break;
  937. }
  938. $frontier = $next;
  939. }
  940. $nodes = DB::table('mp_canvas_nodes')
  941. ->where('canvas_id', $canvas_id)
  942. ->whereIn('id', $collected)
  943. ->where('is_deleted', 0)
  944. ->orderBy('z_index')
  945. ->orderBy('id')
  946. ->get()
  947. ->map(function ($node) use ($canvas_id, $collectedMap) {
  948. $node = $this->formatNode($node, $canvas_id);
  949. $node['level'] = $collectedMap[$node['id']];
  950. return $node;
  951. })
  952. ->toArray();
  953. $rels = array_values($edgeMap);
  954. usort($rels, function ($x, $y) {
  955. return $x['id'] - $y['id'];
  956. });
  957. // 层级摘要:每层包含的节点ID与关联ID
  958. $levelNodes = [];
  959. $levelRels = [];
  960. foreach ($collected as $nid) {
  961. $levelNodes[$collectedMap[$nid]][] = $nid;
  962. }
  963. foreach ($rels as $rel) {
  964. $levelRels[$rel['level']][] = $rel['id'];
  965. }
  966. $levels = [];
  967. for ($lv = 0; $lv <= $depth; $lv++) {
  968. if (isset($levelNodes[$lv]) || isset($levelRels[$lv])) {
  969. $levels[] = [
  970. 'level' => $lv,
  971. 'node_ids' => isset($levelNodes[$lv]) ? $levelNodes[$lv] : [],
  972. 'rel_ids' => isset($levelRels[$lv]) ? $levelRels[$lv] : []
  973. ];
  974. }
  975. }
  976. return [
  977. 'center_node_id' => $node_id,
  978. 'depth' => $depth,
  979. 'levels' => $levels,
  980. 'nodes' => $nodes,
  981. 'rels' => $rels
  982. ];
  983. }
  984. /**
  985. * 获取画布全量节点与关联
  986. *
  987. * @param int $canvas_id
  988. * @return array
  989. */
  990. private function getGraph($canvas_id)
  991. {
  992. $nodes = DB::table('mp_canvas_nodes')
  993. ->where('canvas_id', $canvas_id)
  994. ->where('is_deleted', 0)
  995. ->orderBy('z_index')
  996. ->orderBy('id')
  997. ->get()
  998. ->map(function ($node) use ($canvas_id) {
  999. return $this->formatNode($node, $canvas_id);
  1000. })
  1001. ->toArray();
  1002. $rels = DB::table('mp_canvas_node_rels')
  1003. ->where('canvas_id', $canvas_id)
  1004. ->orderBy('id')
  1005. ->get()
  1006. ->map(function ($rel) {
  1007. return [
  1008. 'id' => (int)$rel->id,
  1009. 'node_id_a' => (int)$rel->node_id_a,
  1010. 'node_id_b' => (int)$rel->node_id_b
  1011. ];
  1012. })
  1013. ->toArray();
  1014. return ['nodes' => $nodes, 'rels' => $rels];
  1015. }
  1016. /**
  1017. * 格式化节点输出(分镜节点合并分镜数据表字段)
  1018. *
  1019. * @param object $node
  1020. * @param int $canvas_id
  1021. * @return array
  1022. */
  1023. private function formatNode($node, $canvas_id)
  1024. {
  1025. $node = (array)$node;
  1026. $node['ext'] = $node['ext'] ? json_decode($node['ext'], true) : null;
  1027. if ($node['node_type'] === 'segment') {
  1028. $segment = DB::table('mp_canvas_segments')
  1029. ->where('canvas_id', $canvas_id)
  1030. ->where('node_id', $node['id'])
  1031. ->where('is_deleted', 0)
  1032. ->first();
  1033. if ($segment) {
  1034. $segment = (array)$segment;
  1035. // 分镜数据表为准,覆盖节点级同名关键字段
  1036. foreach (['name', 'text_prompt', 'pic_prompt', 'image_url', 'video_url', 'audio_url', 'duration', 'ext'] as $field) {
  1037. $node[$field] = $segment[$field];
  1038. }
  1039. $node['segment_number'] = $segment['segment_number'];
  1040. $node['status'] = $segment['status'];
  1041. } else {
  1042. $node['segment_number'] = null;
  1043. $node['status'] = null;
  1044. }
  1045. }
  1046. $node['id'] = (int)$node['id'];
  1047. $node['canvas_id'] = (int)$node['canvas_id'];
  1048. $node['pos_x'] = (float)$node['pos_x'];
  1049. $node['pos_y'] = (float)$node['pos_y'];
  1050. $node['z_index'] = (int)$node['z_index'];
  1051. $node['duration'] = $node['duration'] !== null ? (int)$node['duration'] : null;
  1052. $node['source_product_id'] = $node['source_product_id'] !== null ? (int)$node['source_product_id'] : null;
  1053. $node['source_anime_id'] = $node['source_anime_id'] !== null ? (int)$node['source_anime_id'] : null;
  1054. $node['source_episode_id'] = $node['source_episode_id'] !== null ? (int)$node['source_episode_id'] : null;
  1055. return $node;
  1056. }
  1057. /**
  1058. * 获取节点所有关联节点ID
  1059. *
  1060. * @param int $canvas_id
  1061. * @param int $node_id
  1062. * @return array
  1063. */
  1064. private function getNodeRelIds($canvas_id, $node_id)
  1065. {
  1066. $rels = DB::table('mp_canvas_node_rels')
  1067. ->where('canvas_id', $canvas_id)
  1068. ->where(function ($query) use ($node_id) {
  1069. $query->where('node_id_a', $node_id)
  1070. ->orWhere('node_id_b', $node_id);
  1071. })
  1072. ->get();
  1073. $ids = [];
  1074. foreach ($rels as $rel) {
  1075. $ids[] = (int)($rel->node_id_a == $node_id ? $rel->node_id_b : $rel->node_id_a);
  1076. }
  1077. return $ids;
  1078. }
  1079. /**
  1080. * 获取节点的关联节点完整信息
  1081. *
  1082. * @param int $canvas_id
  1083. * @param int $node_id
  1084. * @return array
  1085. */
  1086. private function getRelatedNodes($canvas_id, $node_id)
  1087. {
  1088. $ids = $this->getNodeRelIds($canvas_id, $node_id);
  1089. if (!$ids) {
  1090. return [];
  1091. }
  1092. return DB::table('mp_canvas_nodes')
  1093. ->where('canvas_id', $canvas_id)
  1094. ->whereIn('id', $ids)
  1095. ->where('is_deleted', 0)
  1096. ->orderBy('id')
  1097. ->get()
  1098. ->map(function ($node) use ($canvas_id) {
  1099. return $this->formatNode($node, $canvas_id);
  1100. })
  1101. ->toArray();
  1102. }
  1103. /**
  1104. * 可空整型
  1105. *
  1106. * @param mixed $value
  1107. * @return int|null
  1108. */
  1109. private function nullableInt($value)
  1110. {
  1111. return ($value === null || $value === '') ? null : (int)$value;
  1112. }
  1113. /**
  1114. * 可空浮点
  1115. *
  1116. * @param mixed $value
  1117. * @return float|null
  1118. */
  1119. private function nullableFloat($value)
  1120. {
  1121. return ($value === null || $value === '') ? null : (float)$value;
  1122. }
  1123. /**
  1124. * JSON 编码(已是 JSON 字符串则原样保留)
  1125. *
  1126. * @param mixed $value
  1127. * @return string|null
  1128. */
  1129. private function jsonEncode($value)
  1130. {
  1131. if ($value === null || $value === '') {
  1132. return null;
  1133. }
  1134. if (is_string($value)) {
  1135. return json_decode($value, true) !== null ? $value : json_encode($value, JSON_UNESCAPED_UNICODE);
  1136. }
  1137. return json_encode($value, JSON_UNESCAPED_UNICODE);
  1138. }
  1139. }