CanvasService.php 39 KB

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