| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065106610671068106910701071107210731074107510761077107810791080108110821083108410851086108710881089109010911092109310941095109610971098109911001101110211031104110511061107110811091110111111121113111411151116111711181119112011211122112311241125112611271128 |
- <?php
- namespace App\Services\Canvas;
- use App\Facade\Site;
- use App\Libs\Utils;
- use Illuminate\Support\Facades\DB;
- /**
- * 画布模式服务
- *
- * 核心模型:
- * 画布(mp_canvases) -> 节点(mp_canvas_nodes) + 关联(mp_canvas_node_rels) + 分镜数据(mp_canvas_segments)
- *
- * 画布为无限扩展画布,无固定宽高;节点无限扩展、无固定根节点,仅记录两两关联。
- * 关联查询一律按节点ID查邻接关系(a=node OR b=node),不依赖树形结构,
- * 因此无根节点不影响关联数据的获取;数据量大时可使用 graph 的局部子图查询。
- *
- * 节点类型说明:
- * - theme/subject/scene/prop/segment 属于"列表型"节点,可复用全局资产库或动漫分集中的数据
- * - text/image/video/audio 属于"内容型"节点
- * - 关联无方向、多对多,通过 (node_id_a, node_id_b) 且 a < b 归一化存储
- */
- class CanvasService
- {
- /** 节点类型白名单 */
- const NODE_TYPES = ['theme', 'subject', 'scene', 'prop', 'segment', 'text', 'image', 'video', 'audio'];
- /** 关联来源类型白名单 */
- const SOURCE_TYPES = ['none', 'product', 'episode'];
- /** 画布文档大小与图规模限制 */
- const MAX_DOCUMENT_BYTES = 2097152; // 2MB
- const MAX_NODES = 2000;
- const MAX_EDGES = 5000;
- const MAX_TEXT_LENGTH = 50000;
- const MAX_URL_LENGTH = 2048;
- const MAX_TITLE_LENGTH = 100;
- /**
- * 画布摘要列表(分页,不返回完整图)
- *
- * @param array $data anime_id-动漫ID(可选) episode_id-剧集ID(可选) page-页码 page_size-每页数量
- * @return array
- */
- public function canvasList($data)
- {
- $uid = Site::getUid();
- $cpid = Site::getCpid();
- $anime_id = (int)getProp($data, 'anime_id', 0);
- $episode_id = (int)getProp($data, 'episode_id', 0);
- $title = getProp($data, 'title');
- $page = max(1, (int)getProp($data, 'page', 1));
- $page_size = min(100, max(1, (int)getProp($data, 'page_size', 20)));
- $query = DB::table('mp_canvases')
- ->where('uid', $uid)
- ->where('cpid', $cpid)
- ->where('is_deleted', 0)
- ->select('canvas_uuid as canvas_id', 'name as title', 'description', 'cover_url',
- 'anime_id', 'episode_id', 'episode_number', 'created_at', 'updated_at');
- if ($anime_id) {
- $query->where('anime_id', $anime_id);
- }
- if ($episode_id) {
- $query->where('episode_id', $episode_id);
- }
- if ($title) {
- $query->where('name', 'like', "%$title%");
- }
- $list = $query->orderByDesc('id')->paginate($page_size, ['*'], 'page', $page);
- $items = $list->map(function ($item) {
- $item = (array)$item;
- $item['created_at'] = transDate($item['created_at'], 'Y-m-d H:i:s');
- $item['updated_at'] = transDate($item['updated_at'], 'Y-m-d H:i:s');
- $item['scope'] = [
- 'anime_id' => (int)$item['anime_id'],
- 'episode_id' => (int)$item['episode_id'],
- 'episode_number' => (int)$item['episode_number']
- ];
- unset($item['anime_id'], $item['episode_id'], $item['episode_number']);
- return $item;
- })->toArray();
- return [
- 'meta' => getMeta($list),
- 'list' => $items
- ];
- }
- /**
- * 创建画布
- *
- * 必填:name、document;canvas_id 可选(缺省自动生成);可选:anime_id、episode_id、episode_number
- *
- * @param array $data
- * @return array
- */
- public function createCanvas($data)
- {
- $uid = Site::getUid();
- $cpid = Site::getCpid();
- $now = date('Y-m-d H:i:s');
- $canvas_uuid = trim((string)getProp($data, 'canvas_id', ''));
- if ($canvas_uuid === '') {
- $canvas_uuid = $this->generateCanvasId();
- }
- if (mb_strlen($canvas_uuid) > 64) {
- Utils::throwError('20003:canvas_id长度超出限制');
- }
- if (DB::table('mp_canvases')->where('canvas_uuid', $canvas_uuid)->exists()) {
- Utils::throwError('20003:画布ID已存在');
- }
- $name = trim((string)$this->docProp($data, 'name', ''));
- if ($name === '') {
- Utils::throwError('20003:请提供画布名称');
- }
- $document = $this->documentForStorage($this->extractDocument($data));
- if ($document === null) {
- Utils::throwError('20003:请提供document');
- }
- $this->checkDocumentSize($document);
- $canvas_id = DB::table('mp_canvases')->insertGetId([
- 'uid' => $uid,
- 'cpid' => $cpid,
- 'canvas_uuid' => $canvas_uuid,
- 'name' => $name,
- 'description' => (string)$this->docProp($data, 'description', ''),
- 'cover_url' => (string)$this->docProp($data, 'cover_url', ''),
- 'anime_id' => $this->scopeProp($data, 'anime_id'),
- 'episode_id' => $this->scopeProp($data, 'episode_id'),
- 'episode_number' => $this->scopeProp($data, 'episode_number'),
- 'document' => $document,
- 'is_deleted' => 0,
- 'created_at' => $now,
- 'updated_at' => $now
- ]);
- if (!$canvas_id) {
- Utils::throwError('20003:创建画布失败');
- }
- return $this->documentResult($canvas_uuid, $name, $document);
- }
- /**
- * 保存画布完整文档
- *
- * 必填:canvas_id、name、document;可选:anime_id、episode_id、episode_number(不传保留原值)
- *
- * @param array $data
- * @return array
- */
- public function editCanvas($data)
- {
- $canvas_id = (string)getProp($data, 'canvas_id', '');
- if ($canvas_id === '') {
- Utils::throwError('20003:请提供画布ID');
- }
- $canvas = $this->checkCanvas($canvas_id);
- $name = trim((string)$this->docProp($data, 'name', ''));
- if ($name === '') {
- Utils::throwError('20003:请提供画布名称');
- }
- $document = $this->documentForStorage($this->extractDocument($data));
- if ($document === null) {
- Utils::throwError('20003:请提供document');
- }
- $this->checkDocumentSize($document);
- $update = [
- 'name' => $name,
- 'document' => $document,
- 'updated_at' => date('Y-m-d H:i:s')
- ];
- foreach (['anime_id', 'episode_id', 'episode_number'] as $field) {
- $value = $this->scopeProp($data, $field, null);
- if ($value !== null) {
- $update[$field] = $value;
- }
- }
- DB::table('mp_canvases')->where('id', $canvas->id)->update($update);
- return $this->documentResult($this->canvasKey($canvas), $name, $document);
- }
- /**
- * 删除画布(软删除画布/节点/分镜数据,物理删除关联)
- *
- * @param array $data canvas_id-画布ID
- * @return array
- */
- public function delCanvas($data)
- {
- $canvas_id = (string)getProp($data, 'canvas_id', '');
- $canvas = $this->checkCanvas($canvas_id);
- $now = date('Y-m-d H:i:s');
- DB::beginTransaction();
- try {
- DB::table('mp_canvases')->where('id', $canvas->id)->update(['is_deleted' => 1, 'updated_at' => $now]);
- DB::table('mp_canvas_nodes')->where('canvas_id', $canvas->id)->update(['is_deleted' => 1, 'updated_at' => $now]);
- DB::table('mp_canvas_segments')->where('canvas_id', $canvas->id)->update(['is_deleted' => 1, 'updated_at' => $now]);
- DB::table('mp_canvas_node_rels')->where('canvas_id', $canvas->id)->delete();
- DB::commit();
- } catch (\Throwable $e) {
- DB::rollBack();
- Utils::throwError('20003:删除画布失败');
- }
- return ['success' => 1];
- }
- /**
- * 打开指定画布,返回完整文档
- *
- * @param array $data canvas_id-画布ID(字符串 canvas_uuid 或旧数字ID)
- * @return array
- */
- public function canvasDetail($data)
- {
- $canvas_id = (string)getProp($data, 'canvas_id', '');
- $canvas = $this->checkCanvas($canvas_id);
- $raw = (string)getProp($canvas, 'document', '');
- if ($raw !== '') {
- return $this->documentResult($this->canvasKey($canvas), (string)$canvas->name, $raw);
- }
- // 旧画布无 document 时由节点/关联表兜底构建
- return $this->documentResult($this->canvasKey($canvas), (string)$canvas->name, $this->buildLegacyDocument($canvas));
- }
- /**
- * 保存节点(创建/更新)
- *
- * 通用字段直接写 mp_canvas_nodes;node_type=segment 时同步写 mp_canvas_segments。
- * 分镜关键数据优先取 data.segment 子对象,缺省时回退到节点级字段。
- *
- * @param array $data canvas_id-画布ID node_id-节点ID(更新时) node_type-节点类型
- * name/content/text_prompt/pic_prompt/image_url/video_url/audio_url/
- * thumbnail_url/duration/pos_x/pos_y/z_index/size_w/size_h
- * source_type/source_product_id/source_anime_id/source_episode_id/source_ref/ext
- * segment-分镜数据子对象(可选)
- * @return array
- */
- public function saveNode($data)
- {
- $canvas_id = (int)getProp($data, 'canvas_id');
- $node_id = (int)getProp($data, 'node_id', 0);
- $node_type = (string)getProp($data, 'node_type', '');
- if (!in_array($node_type, self::NODE_TYPES, true)) {
- Utils::throwError('20003:无效的节点类型');
- }
- $this->checkCanvas($canvas_id);
- $uid = Site::getUid();
- $cpid = Site::getCpid();
- $now = date('Y-m-d H:i:s');
- $fields = [
- 'name' => (string)getProp($data, 'name', ''),
- 'content' => (string)getProp($data, 'content', ''),
- 'text_prompt' => (string)getProp($data, 'text_prompt', ''),
- 'pic_prompt' => (string)getProp($data, 'pic_prompt', ''),
- 'image_url' => (string)getProp($data, 'image_url', ''),
- 'video_url' => (string)getProp($data, 'video_url', ''),
- 'audio_url' => (string)getProp($data, 'audio_url', ''),
- 'thumbnail_url' => (string)getProp($data, 'thumbnail_url', ''),
- 'duration' => $this->nullableInt(getProp($data, 'duration', null)),
- 'pos_x' => (float)getProp($data, 'pos_x', 0),
- 'pos_y' => (float)getProp($data, 'pos_y', 0),
- 'z_index' => (int)getProp($data, 'z_index', 0),
- 'size_w' => $this->nullableFloat(getProp($data, 'size_w', null)),
- 'size_h' => $this->nullableFloat(getProp($data, 'size_h', null)),
- 'source_type' => (string)getProp($data, 'source_type', 'none'),
- 'source_product_id' => $this->nullableInt(getProp($data, 'source_product_id', null)),
- 'source_anime_id' => $this->nullableInt(getProp($data, 'source_anime_id', null)),
- 'source_episode_id' => $this->nullableInt(getProp($data, 'source_episode_id', null)),
- 'source_ref' => (string)getProp($data, 'source_ref', ''),
- 'ext' => $this->jsonEncode(getProp($data, 'ext', null))
- ];
- if (!in_array($fields['source_type'], self::SOURCE_TYPES, true)) {
- $fields['source_type'] = 'none';
- }
- DB::beginTransaction();
- try {
- $real_type = $node_type;
- if ($node_id) {
- $exists = DB::table('mp_canvas_nodes')
- ->where('id', $node_id)
- ->where('canvas_id', $canvas_id)
- ->where('is_deleted', 0)
- ->first();
- if (!$exists) {
- Utils::throwError('20003:节点不存在');
- }
- // 更新时节点类型以库中为准,不允许变更
- $real_type = $exists->node_type;
- $fields['updated_at'] = $now;
- DB::table('mp_canvas_nodes')->where('id', $node_id)->update($fields);
- } else {
- $fields['canvas_id'] = $canvas_id;
- $fields['uid'] = $uid;
- $fields['cpid'] = $cpid;
- $fields['node_type'] = $node_type;
- $fields['is_deleted'] = 0;
- $fields['created_at'] = $now;
- $fields['updated_at'] = $now;
- $node_id = DB::table('mp_canvas_nodes')->insertGetId($fields);
- if (!$node_id) {
- Utils::throwError('20003:保存节点失败');
- }
- }
- // 分镜节点同步分镜数据表
- if ($real_type === 'segment') {
- $this->syncSegment($canvas_id, $node_id, $data, $now);
- }
- DB::commit();
- } catch (\Throwable $e) {
- DB::rollBack();
- throw $e;
- }
- return ['node_id' => $node_id];
- }
- /**
- * 节点详情(含分镜数据与当前关联)
- *
- * @param array $data canvas_id-画布ID node_id-节点ID
- * @return array
- */
- public function nodeInfo($data)
- {
- $canvas_id = (int)getProp($data, 'canvas_id');
- $node_id = (int)getProp($data, 'node_id');
- $this->checkCanvas($canvas_id);
- $node = $this->getNode($canvas_id, $node_id);
- $node = $this->formatNode($node, $canvas_id);
- $node['rel_ids'] = $this->getNodeRelIds($canvas_id, $node_id);
- $node['related_nodes'] = $this->getRelatedNodes($canvas_id, $node_id);
- return $node;
- }
- /**
- * 获取节点的所有关联节点
- *
- * @param array $data canvas_id-画布ID node_id-节点ID
- * @return array
- */
- public function nodeRels($data)
- {
- $canvas_id = (int)getProp($data, 'canvas_id');
- $node_id = (int)getProp($data, 'node_id');
- $this->checkCanvas($canvas_id);
- $this->getNode($canvas_id, $node_id);
- return [
- 'node_id' => $node_id,
- 'rel_ids' => $this->getNodeRelIds($canvas_id, $node_id),
- 'related_nodes' => $this->getRelatedNodes($canvas_id, $node_id)
- ];
- }
- /**
- * 保存节点关联(整体替换:先清空该节点的所有关联,再写入新关联)
- *
- * 关联无方向、多对多;内部统一归一化为 (小id, 大id) 存储,配合唯一索引防止重复。
- *
- * @param array $data canvas_id-画布ID node_id-节点ID related_ids-关联节点ID数组(可传逗号分隔字符串)
- * @return array
- */
- public function saveRels($data)
- {
- $canvas_id = (int)getProp($data, 'canvas_id');
- $node_id = (int)getProp($data, 'node_id');
- $this->checkCanvas($canvas_id);
- $this->getNode($canvas_id, $node_id);
- $related_ids = getProp($data, 'related_ids', []);
- if (!is_array($related_ids)) {
- $related_ids = $related_ids === '' ? [] : explode(',', (string)$related_ids);
- }
- $related_ids = array_values(array_unique(array_filter(array_map('intval', $related_ids))));
- // 去除自身关联
- $related_ids = array_values(array_diff($related_ids, [$node_id]));
- $pairs = [];
- if ($related_ids) {
- // 只允许关联同一画布下未删除的节点
- $valid_ids = DB::table('mp_canvas_nodes')
- ->where('canvas_id', $canvas_id)
- ->whereIn('id', $related_ids)
- ->where('is_deleted', 0)
- ->pluck('id')
- ->all();
- foreach ($valid_ids as $rid) {
- $a = (int)min($node_id, $rid);
- $b = (int)max($node_id, $rid);
- $pairs[$a . '_' . $b] = ['a' => $a, 'b' => $b];
- }
- }
- $now = date('Y-m-d H:i:s');
- DB::beginTransaction();
- try {
- DB::table('mp_canvas_node_rels')
- ->where('canvas_id', $canvas_id)
- ->where(function ($query) use ($node_id) {
- $query->where('node_id_a', $node_id)
- ->orWhere('node_id_b', $node_id);
- })
- ->delete();
- if ($pairs) {
- $rows = [];
- foreach ($pairs as $pair) {
- $rows[] = [
- 'canvas_id' => $canvas_id,
- 'node_id_a' => $pair['a'],
- 'node_id_b' => $pair['b'],
- 'created_at' => $now,
- 'updated_at' => $now
- ];
- }
- DB::table('mp_canvas_node_rels')->insert($rows);
- }
- DB::commit();
- } catch (\Throwable $e) {
- DB::rollBack();
- Utils::throwError('20003:保存关联失败');
- }
- return ['success' => 1, 'rel_count' => count($pairs)];
- }
- /**
- * 删除节点(软删除节点/分镜数据,物理删除关联)
- *
- * @param array $data canvas_id-画布ID node_id-节点ID
- * @return array
- */
- public function delNode($data)
- {
- $canvas_id = (int)getProp($data, 'canvas_id');
- $node_id = (int)getProp($data, 'node_id');
- $this->checkCanvas($canvas_id);
- $node = $this->getNode($canvas_id, $node_id);
- $now = date('Y-m-d H:i:s');
- DB::beginTransaction();
- try {
- DB::table('mp_canvas_nodes')->where('id', $node_id)->update(['is_deleted' => 1, 'updated_at' => $now]);
- DB::table('mp_canvas_node_rels')
- ->where('canvas_id', $canvas_id)
- ->where(function ($query) use ($node_id) {
- $query->where('node_id_a', $node_id)
- ->orWhere('node_id_b', $node_id);
- })
- ->delete();
- if ($node->node_type === 'segment') {
- DB::table('mp_canvas_segments')
- ->where('canvas_id', $canvas_id)
- ->where('node_id', $node_id)
- ->update(['is_deleted' => 1, 'updated_at' => $now]);
- }
- DB::commit();
- } catch (\Throwable $e) {
- DB::rollBack();
- Utils::throwError('20003:删除节点失败');
- }
- return ['success' => 1];
- }
- /**
- * 画布关系图(供前端渲染)
- *
- * 不传 node_id 时返回画布全部节点与关联;传 node_id 时返回以该节点为中心、
- * 深度为 depth 的局部子图(按广度优先收集邻居;rels 只包含 BFS 展开过程中触及的边,
- * 即 depth=1 时仅返回中心节点的直接关联),适合大画布局部加载。
- * 局部子图模式下,每个节点/每条关联附带 level 字段(节点为距中心的跳数,
- * 关联为 BFS 展开时首次被触及的轮次),另附 levels 摘要便于前端逐层加载与动画。
- *
- * @param array $data canvas_id-画布ID node_id-中心节点ID(可选) depth-子图深度(可选,默认1)
- * @return array
- */
- public function graph($data)
- {
- $canvas_id = (int)getProp($data, 'canvas_id');
- $node_id = (int)getProp($data, 'node_id', 0);
- $depth = min(5, max(1, (int)getProp($data, 'depth', 1)));
- $this->checkCanvas($canvas_id);
- if ($node_id) {
- return $this->getSubGraph($canvas_id, $node_id, $depth);
- }
- return $this->getGraph($canvas_id);
- }
- /* ------------------------------------------------------------------ */
- /* 私有方法 */
- /* ------------------------------------------------------------------ */
- /**
- * 校验画布归属并返回画布
- *
- * @param string|int $canvas_id
- * @return object
- */
- private function checkCanvas($canvas_id)
- {
- if ($canvas_id === '' || $canvas_id === null) {
- Utils::throwError('20003:请提供画布ID');
- }
- $query = DB::table('mp_canvases')
- ->where('uid', Site::getUid())
- ->where('cpid', Site::getCpid())
- ->where('is_deleted', 0);
- if (is_numeric($canvas_id)) {
- $query->where('id', (int)$canvas_id);
- } else {
- $query->where('canvas_uuid', (string)$canvas_id);
- }
- $canvas = $query->first();
- if (!$canvas) {
- Utils::throwError('20003:画布不存在或无权访问');
- }
- return $canvas;
- }
- /**
- * 画布对外资源 ID:优先字符串 canvas_uuid,旧数据兜底数字 id
- *
- * @param object $canvas
- * @return string
- */
- private function canvasKey($canvas)
- {
- $uuid = (string)getProp($canvas, 'canvas_uuid', '');
- return $uuid !== '' ? $uuid : (string)(int)getProp($canvas, 'id', 0);
- }
- /**
- * 生成前端可用的稳定画布 ID
- *
- * @return string
- */
- private function generateCanvasId()
- {
- return 'canvas-' . \Illuminate\Support\Str::uuid()->toString();
- }
- /**
- * 提取前端传入的 document(必填字段)
- *
- * @param array $data
- * @return mixed
- */
- private function extractDocument($data)
- {
- if (!is_array($data)) {
- Utils::throwError('20003:请求体格式不正确');
- }
- if (!array_key_exists('document', $data)) {
- Utils::throwError('20003:请提供document');
- }
- return $data['document'];
- }
- /**
- * 规范化 document 用于数据库存储:JSON 对象转字符串;JSON 字符串原样保存;空值存 null
- *
- * @param mixed $document
- * @return string|null
- */
- private function documentForStorage($document)
- {
- if ($document === null || $document === '') {
- return null;
- }
- if (is_array($document) || is_object($document)) {
- $json = json_encode($document, JSON_UNESCAPED_UNICODE);
- if ($json === false) {
- Utils::throwError('20003:document格式不正确');
- }
- return $json;
- }
- if (is_string($document)) {
- return $document;
- }
- Utils::throwError('20003:document格式不正确');
- }
- /**
- * 存储的 document 返回给前端:JSON 字符串解码为对象;非 JSON 字符串原样返回
- *
- * @param mixed $document
- * @return mixed
- */
- private function documentForResponse($document)
- {
- if ($document === null || $document === '') {
- return null;
- }
- if (is_array($document) || is_object($document)) {
- return $document;
- }
- $raw = (string)$document;
- $doc = json_decode($raw, true);
- if ($doc === null && trim($raw) !== 'null') {
- return $raw;
- }
- return $doc;
- }
- /**
- * 组装画布文档相关返回结构:document 原样返回,canvas_id 由服务端维护
- *
- * @param string $canvas_id
- * @param mixed $document
- * @return array
- */
- private function documentResult($canvas_id, $name, $document)
- {
- return [
- 'canvas_id' => (string)$canvas_id,
- 'name' => (string)$name,
- 'document' => $this->documentForResponse($document)
- ];
- }
- /**
- * 校验 document 存储大小(仅整体大小限制,不解析内部结构)
- *
- * @param string|null $document
- * @return void
- */
- private function checkDocumentSize($document)
- {
- if ($document !== null && strlen($document) > self::MAX_DOCUMENT_BYTES) {
- Utils::throwError('20003:画布文档大小超出限制');
- }
- }
- /**
- * 读取请求中 document 相关字段(兼容 document 放在顶层或 document 字段内)
- *
- * @param array $data
- * @param string $key
- * @param mixed $default
- * @return mixed
- */
- private function docProp($data, $key, $default = '')
- {
- if (array_key_exists($key, $data)) {
- return $data[$key];
- }
- $doc = getProp($data, 'document', null);
- if (is_array($doc) && array_key_exists($key, $doc)) {
- return $doc[$key];
- }
- return $default;
- }
- /**
- * 读取画布元数据列(anime_id/episode_id/episode_number),
- * 兼容顶层字段、document 顶层字段、document.scope 三种位置;不影响 document 原样保存
- *
- * @param array $data
- * @param string $key
- * @param mixed $default
- * @return int|null
- */
- private function scopeProp($data, $key, $default = 0)
- {
- if (array_key_exists($key, $data)) {
- return (int)$data[$key];
- }
- $doc = getProp($data, 'document', null);
- if (is_array($doc)) {
- if (array_key_exists($key, $doc)) {
- return (int)$doc[$key];
- }
- $scope = getProp($doc, 'scope', []);
- if (is_array($scope) && array_key_exists($key, $scope)) {
- return (int)$scope[$key];
- }
- }
- $scope = getProp($data, 'scope', null);
- if (is_array($scope) && array_key_exists($key, $scope)) {
- return (int)$scope[$key];
- }
- return $default;
- }
- /**
- * 从画布行组装 scope
- *
- * @param object $canvas
- * @return array
- */
- private function scopeFromRow($canvas)
- {
- return [
- 'anime_id' => (int)getProp($canvas, 'anime_id', 0),
- 'episode_id' => (int)getProp($canvas, 'episode_id', 0),
- 'episode_number' => (int)getProp($canvas, 'episode_number', 0)
- ];
- }
- /**
- * 旧画布文档兜底:由节点/关联表生成 graph
- *
- * @param object $canvas
- * @return array
- */
- private function buildLegacyDocument($canvas)
- {
- $graph = $this->getGraph((int)getProp($canvas, 'id', 0));
- $nodes = [];
- foreach ($graph['nodes'] as $node) {
- $node = (array)$node;
- if (isset($node['id'])) {
- $node['id'] = (string)$node['id'];
- }
- $nodes[] = $node;
- }
- $edges = [];
- foreach ($graph['rels'] as $rel) {
- $edges[] = [
- 'id' => (string)$rel['id'],
- 'source' => (string)$rel['node_id_a'],
- 'target' => (string)$rel['node_id_b']
- ];
- }
- return [
- 'canvas_id' => $this->canvasKey($canvas),
- 'scope' => $this->scopeFromRow($canvas),
- 'title' => (string)getProp($canvas, 'name', '未命名画布'),
- 'graph' => [
- 'viewport' => ['x' => 0, 'y' => 0, 'zoom' => 1],
- 'nodes' => $nodes,
- 'edges' => $edges
- ],
- 'preferences' => [
- 'minimap_visible' => true,
- 'snap_to_grid_enabled' => false
- ]
- ];
- }
- /**
- * 校验节点归属并返回节点
- *
- * @param int $canvas_id
- * @param int $node_id
- * @return object
- */
- private function getNode($canvas_id, $node_id)
- {
- if (!$node_id) {
- Utils::throwError('20003:请提供节点ID');
- }
- $node = DB::table('mp_canvas_nodes')
- ->where('id', $node_id)
- ->where('canvas_id', $canvas_id)
- ->where('is_deleted', 0)
- ->first();
- if (!$node) {
- Utils::throwError('20003:节点不存在');
- }
- return $node;
- }
- /**
- * 同步分镜数据表(创建或更新)
- *
- * @param int $canvas_id
- * @param int $node_id
- * @param array $data
- * @param string $now
- * @return void
- */
- private function syncSegment($canvas_id, $node_id, $data, $now)
- {
- $seg = getProp($data, 'segment');
- $seg = is_array($seg) ? $seg : [];
- $seg_fields = [
- 'segment_number' => (int)getProp($seg, 'segment_number', 1),
- 'name' => (string)getProp($seg, 'name', getProp($data, 'name', '')),
- 'text_prompt' => (string)getProp($seg, 'text_prompt', getProp($data, 'text_prompt', '')),
- 'pic_prompt' => (string)getProp($seg, 'pic_prompt', getProp($data, 'pic_prompt', '')),
- 'image_url' => (string)getProp($seg, 'image_url', getProp($data, 'image_url', '')),
- 'video_url' => (string)getProp($seg, 'video_url', getProp($data, 'video_url', '')),
- 'audio_url' => (string)getProp($seg, 'audio_url', getProp($data, 'audio_url', '')),
- 'duration' => $this->nullableInt(getProp($seg, 'duration', getProp($data, 'duration', null))),
- 'status' => (string)getProp($seg, 'status', 'draft'),
- 'ext' => $this->jsonEncode(getProp($seg, 'ext', null))
- ];
- $exists = DB::table('mp_canvas_segments')
- ->where('canvas_id', $canvas_id)
- ->where('node_id', $node_id)
- ->where('is_deleted', 0)
- ->first();
- if ($exists) {
- $seg_fields['updated_at'] = $now;
- DB::table('mp_canvas_segments')->where('id', $exists->id)->update($seg_fields);
- } else {
- $seg_fields['canvas_id'] = $canvas_id;
- $seg_fields['node_id'] = $node_id;
- $seg_fields['uid'] = Site::getUid();
- $seg_fields['cpid'] = Site::getCpid();
- $seg_fields['is_deleted'] = 0;
- $seg_fields['created_at'] = $now;
- $seg_fields['updated_at'] = $now;
- DB::table('mp_canvas_segments')->insert($seg_fields);
- }
- }
- /**
- * 获取以指定节点为中心的局部子图(广度优先,最多 depth 层)
- *
- * 节点集合为距中心不超过 depth 跳的所有节点;
- * 关系集合为 BFS 展开过程中触及的边(至少一端距中心不超过 depth-1 跳),
- * 因此 depth=1 时只返回中心节点的直接关联,不会返回"邻居之间的边"。
- * 返回的节点带 level(距中心跳数,中心为 0),关联带 level(首次被触及的轮次),
- * 并提供 levels 摘要 [{level, node_ids, rel_ids}] 供前端逐层渲染。
- *
- * @param int $canvas_id
- * @param int $node_id
- * @param int $depth
- * @return array
- */
- private function getSubGraph($canvas_id, $node_id, $depth)
- {
- $this->getNode($canvas_id, $node_id);
- // 广度优先收集节点(节点只入队一次,避免环导致死循环)
- $collectedMap = [$node_id => 0];
- $collected = [$node_id];
- $frontier = [$node_id];
- $edgeMap = [];
- for ($i = 0; $i < $depth; $i++) {
- $level = $i + 1;
- $rels = DB::table('mp_canvas_node_rels')
- ->where('canvas_id', $canvas_id)
- ->where(function ($query) use ($frontier) {
- $query->whereIn('node_id_a', $frontier)
- ->orWhereIn('node_id_b', $frontier);
- })
- ->get();
- $next = [];
- foreach ($rels as $rel) {
- $a = (int)$rel->node_id_a;
- $b = (int)$rel->node_id_b;
- // 只记录 BFS 展开过程中触及的边(去重),不返回"邻居之间的边"
- if (!isset($edgeMap[$a . '_' . $b])) {
- $edgeMap[$a . '_' . $b] = [
- 'id' => (int)$rel->id,
- 'node_id_a' => $a,
- 'node_id_b' => $b,
- 'level' => $level
- ];
- }
- foreach ([$a, $b] as $nid) {
- if (!isset($collectedMap[$nid])) {
- $collectedMap[$nid] = $level;
- $collected[] = $nid;
- $next[] = $nid;
- }
- }
- }
- if (!$next) {
- break;
- }
- $frontier = $next;
- }
- $nodes = DB::table('mp_canvas_nodes')
- ->where('canvas_id', $canvas_id)
- ->whereIn('id', $collected)
- ->where('is_deleted', 0)
- ->orderBy('z_index')
- ->orderBy('id')
- ->get()
- ->map(function ($node) use ($canvas_id, $collectedMap) {
- $node = $this->formatNode($node, $canvas_id);
- $node['level'] = $collectedMap[$node['id']];
- return $node;
- })
- ->toArray();
- $rels = array_values($edgeMap);
- usort($rels, function ($x, $y) {
- return $x['id'] - $y['id'];
- });
- // 层级摘要:每层包含的节点ID与关联ID
- $levelNodes = [];
- $levelRels = [];
- foreach ($collected as $nid) {
- $levelNodes[$collectedMap[$nid]][] = $nid;
- }
- foreach ($rels as $rel) {
- $levelRels[$rel['level']][] = $rel['id'];
- }
- $levels = [];
- for ($lv = 0; $lv <= $depth; $lv++) {
- if (isset($levelNodes[$lv]) || isset($levelRels[$lv])) {
- $levels[] = [
- 'level' => $lv,
- 'node_ids' => isset($levelNodes[$lv]) ? $levelNodes[$lv] : [],
- 'rel_ids' => isset($levelRels[$lv]) ? $levelRels[$lv] : []
- ];
- }
- }
- return [
- 'center_node_id' => $node_id,
- 'depth' => $depth,
- 'levels' => $levels,
- 'nodes' => $nodes,
- 'rels' => $rels
- ];
- }
- /**
- * 获取画布全量节点与关联
- *
- * @param int $canvas_id
- * @return array
- */
- private function getGraph($canvas_id)
- {
- $nodes = DB::table('mp_canvas_nodes')
- ->where('canvas_id', $canvas_id)
- ->where('is_deleted', 0)
- ->orderBy('z_index')
- ->orderBy('id')
- ->get()
- ->map(function ($node) use ($canvas_id) {
- return $this->formatNode($node, $canvas_id);
- })
- ->toArray();
- $rels = DB::table('mp_canvas_node_rels')
- ->where('canvas_id', $canvas_id)
- ->orderBy('id')
- ->get()
- ->map(function ($rel) {
- return [
- 'id' => (int)$rel->id,
- 'node_id_a' => (int)$rel->node_id_a,
- 'node_id_b' => (int)$rel->node_id_b
- ];
- })
- ->toArray();
- return ['nodes' => $nodes, 'rels' => $rels];
- }
- /**
- * 格式化节点输出(分镜节点合并分镜数据表字段)
- *
- * @param object $node
- * @param int $canvas_id
- * @return array
- */
- private function formatNode($node, $canvas_id)
- {
- $node = (array)$node;
- $node['ext'] = $node['ext'] ? json_decode($node['ext'], true) : null;
- if ($node['node_type'] === 'segment') {
- $segment = DB::table('mp_canvas_segments')
- ->where('canvas_id', $canvas_id)
- ->where('node_id', $node['id'])
- ->where('is_deleted', 0)
- ->first();
- if ($segment) {
- $segment = (array)$segment;
- // 分镜数据表为准,覆盖节点级同名关键字段
- foreach (['name', 'text_prompt', 'pic_prompt', 'image_url', 'video_url', 'audio_url', 'duration', 'ext'] as $field) {
- $node[$field] = $segment[$field];
- }
- $node['segment_number'] = $segment['segment_number'];
- $node['status'] = $segment['status'];
- } else {
- $node['segment_number'] = null;
- $node['status'] = null;
- }
- }
- $node['id'] = (int)$node['id'];
- $node['canvas_id'] = (int)$node['canvas_id'];
- $node['pos_x'] = (float)$node['pos_x'];
- $node['pos_y'] = (float)$node['pos_y'];
- $node['z_index'] = (int)$node['z_index'];
- $node['duration'] = $node['duration'] !== null ? (int)$node['duration'] : null;
- $node['source_product_id'] = $node['source_product_id'] !== null ? (int)$node['source_product_id'] : null;
- $node['source_anime_id'] = $node['source_anime_id'] !== null ? (int)$node['source_anime_id'] : null;
- $node['source_episode_id'] = $node['source_episode_id'] !== null ? (int)$node['source_episode_id'] : null;
- return $node;
- }
- /**
- * 获取节点所有关联节点ID
- *
- * @param int $canvas_id
- * @param int $node_id
- * @return array
- */
- private function getNodeRelIds($canvas_id, $node_id)
- {
- $rels = DB::table('mp_canvas_node_rels')
- ->where('canvas_id', $canvas_id)
- ->where(function ($query) use ($node_id) {
- $query->where('node_id_a', $node_id)
- ->orWhere('node_id_b', $node_id);
- })
- ->get();
- $ids = [];
- foreach ($rels as $rel) {
- $ids[] = (int)($rel->node_id_a == $node_id ? $rel->node_id_b : $rel->node_id_a);
- }
- return $ids;
- }
- /**
- * 获取节点的关联节点完整信息
- *
- * @param int $canvas_id
- * @param int $node_id
- * @return array
- */
- private function getRelatedNodes($canvas_id, $node_id)
- {
- $ids = $this->getNodeRelIds($canvas_id, $node_id);
- if (!$ids) {
- return [];
- }
- return DB::table('mp_canvas_nodes')
- ->where('canvas_id', $canvas_id)
- ->whereIn('id', $ids)
- ->where('is_deleted', 0)
- ->orderBy('id')
- ->get()
- ->map(function ($node) use ($canvas_id) {
- return $this->formatNode($node, $canvas_id);
- })
- ->toArray();
- }
- /**
- * 可空整型
- *
- * @param mixed $value
- * @return int|null
- */
- private function nullableInt($value)
- {
- return ($value === null || $value === '') ? null : (int)$value;
- }
- /**
- * 可空浮点
- *
- * @param mixed $value
- * @return float|null
- */
- private function nullableFloat($value)
- {
- return ($value === null || $value === '') ? null : (float)$value;
- }
- /**
- * JSON 编码(已是 JSON 字符串则原样保留)
- *
- * @param mixed $value
- * @return string|null
- */
- private function jsonEncode($value)
- {
- if ($value === null || $value === '') {
- return null;
- }
- if (is_string($value)) {
- return json_decode($value, true) !== null ? $value : json_encode($value, JSON_UNESCAPED_UNICODE);
- }
- return json_encode($value, JSON_UNESCAPED_UNICODE);
- }
- }
|