lh 1 месяц назад
Родитель
Сommit
de01201255

+ 114 - 0
app/Http/Controllers/Canvas/CanvasController.php

@@ -0,0 +1,114 @@
+<?php
+
+namespace App\Http\Controllers\Canvas;
+
+use App\Libs\ApiResponse;
+use App\Services\Canvas\CanvasService;
+use Illuminate\Http\Request;
+use Illuminate\Routing\Controller as BaseController;
+
+/**
+ * 画布模式控制器
+ *
+ * 画布:独立工作台,节点可复用全局资产库(mp_products)或动漫分集
+ * (主体/场景/道具列表),节点之间无方向多对多关联。
+ */
+class CanvasController extends BaseController
+{
+    use ApiResponse;
+
+    protected $CanvasService;
+
+    public function __construct(CanvasService $CanvasService)
+    {
+        $this->CanvasService = $CanvasService;
+    }
+
+    // 画布列表
+    public function canvasList(Request $request)
+    {
+        $data   = $request->all();
+        $result = $this->CanvasService->canvasList($data);
+        return $this->success($result);
+    }
+
+    // 创建画布
+    public function createCanvas(Request $request)
+    {
+        $data   = $request->all();
+        $result = $this->CanvasService->createCanvas($data);
+        return $this->success($result);
+    }
+
+    // 编辑画布
+    public function editCanvas(Request $request)
+    {
+        $data   = $request->all();
+        $result = $this->CanvasService->editCanvas($data);
+        return $this->success($result);
+    }
+
+    // 删除画布
+    public function delCanvas(Request $request)
+    {
+        $data   = $request->all();
+        $result = $this->CanvasService->delCanvas($data);
+        return $this->success($result);
+    }
+
+    // 画布详情(画布信息 + 全部节点 + 全部关联)
+    public function canvasDetail(Request $request)
+    {
+        $data   = $request->all();
+        $result = $this->CanvasService->canvasDetail($data);
+        return $this->success($result);
+    }
+
+    // 保存节点(创建/更新,分镜节点同步分镜数据)
+    public function saveNode(Request $request)
+    {
+        $data   = $request->all();
+        $result = $this->CanvasService->saveNode($data);
+        return $this->success($result);
+    }
+
+    // 节点详情
+    public function nodeInfo(Request $request)
+    {
+        $data   = $request->all();
+        $result = $this->CanvasService->nodeInfo($data);
+        return $this->success($result);
+    }
+
+    // 删除节点
+    public function delNode(Request $request)
+    {
+        $data   = $request->all();
+        $result = $this->CanvasService->delNode($data);
+        return $this->success($result);
+    }
+
+    // 获取节点关联
+    public function nodeRels(Request $request)
+    {
+        $data   = $request->all();
+        $result = $this->CanvasService->nodeRels($data);
+        return $this->success($result);
+    }
+
+    // 保存节点关联(整体替换)
+    public function saveRels(Request $request)
+    {
+        $data   = $request->all();
+        $result = $this->CanvasService->saveRels($data);
+        return $this->success($result);
+    }
+
+    // 画布关系图(全部节点 + 全部关联)
+    public function graph(Request $request)
+    {
+        $data   = $request->all();
+        $result = $this->CanvasService->graph($data);
+        return $this->success($result);
+    }
+}

+ 836 - 0
app/Services/Canvas/CanvasService.php

@@ -0,0 +1,836 @@
+<?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'];
+
+    /**
+     * 画布列表(分页)
+     *
+     * @param array $data keyword-关键字搜索 page-页码 page_size-每页数量
+     * @return array
+     */
+    public function canvasList($data)
+    {
+        $uid       = Site::getUid();
+        $cpid      = Site::getCpid();
+        $keyword   = trim((string)getProp($data, 'name', ''));
+        $canvas_id = getProp($data, 'canvas_id');
+        $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('id as canvas_id', 'name', 'description', 'cover_url', 'created_at', 
+            DB::raw('(select count(id) from mp_canvas_nodes where canvas_id = mp_canvases.id and is_deleted = 0) as node_count'), 
+            DB::raw('(select count(id) from mp_canvas_segments where canvas_id = mp_canvases.id and is_deleted = 0) as segment_count'));
+
+        if ($canvas_id) {
+            $query->where('id', $canvas_id);
+        }
+
+
+        if ($keyword !== '') {
+            $query->where('name', 'like', "%{$keyword}%");
+        }
+
+        $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');
+            return $item;
+        })->toArray();
+
+        return [
+            'meta' => getMeta($list),
+            'list' => $items
+        ];
+    }
+
+    /**
+     * 创建画布
+     *
+     * @param array $data name-画布名称 description-描述 cover_url-封面
+     * @return array
+     */
+    public function createCanvas($data)
+    {
+        $name = trim((string)getProp($data, 'name', ''));
+        if ($name === '') {
+            Utils::throwError('20003:请提供画布名称');
+        }
+
+        $uid  = Site::getUid();
+        $cpid = Site::getCpid();
+        $now  = date('Y-m-d H:i:s');
+
+        $canvas_id = DB::table('mp_canvases')->insertGetId([
+            'uid'         => $uid,
+            'cpid'        => $cpid,
+            'name'        => $name,
+            'description' => (string)getProp($data, 'description', ''),
+            'cover_url'   => (string)getProp($data, 'cover_url', ''),
+            'is_deleted'  => 0,
+            'created_at'  => $now,
+            'updated_at'  => $now
+        ]);
+
+        if (!$canvas_id) {
+            Utils::throwError('20003:创建画布失败');
+        }
+
+        return ['canvas_id' => $canvas_id];
+    }
+
+    /**
+     * 编辑画布(只更新传入的字段)
+     *
+     * @param array $data canvas_id-画布ID 其余可选字段
+     * @return array
+     */
+    public function editCanvas($data)
+    {
+        $canvas_id = (int)getProp($data, 'canvas_id');
+        $this->checkCanvas($canvas_id);
+
+        $update = ['updated_at' => date('Y-m-d H:i:s')];
+
+        $editable = ['name', 'description', 'cover_url'];
+        foreach ($editable as $field) {
+            if (array_key_exists($field, $data)) {
+                $update[$field] = (string)$data[$field];
+            }
+        }
+
+        DB::table('mp_canvases')->where('id', $canvas_id)->update($update);
+
+        return ['success' => 1];
+    }
+
+    /**
+     * 删除画布(软删除画布/节点/分镜数据,物理删除关联)
+     *
+     * @param array $data canvas_id-画布ID
+     * @return array
+     */
+    public function delCanvas($data)
+    {
+        $canvas_id = (int)getProp($data, 'canvas_id');
+        $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
+     * @return array
+     */
+    public function canvasDetail($data)
+    {
+        $canvas_id = (int)getProp($data, 'canvas_id');
+        $canvas    = $this->checkCanvas($canvas_id);
+        $canvas    = (array)$canvas;
+        $canvas['created_at'] = transDate($canvas['created_at'], 'Y-m-d H:i:s');
+        $canvas['updated_at'] = transDate($canvas['updated_at'], 'Y-m-d H:i:s');
+
+        return array_merge($canvas, $this->getGraph($canvas_id));
+    }
+
+    /**
+     * 保存节点(创建/更新)
+     *
+     * 通用字段直接写 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 int $canvas_id
+     * @return object
+     */
+    private function checkCanvas($canvas_id)
+    {
+        if (!$canvas_id) {
+            Utils::throwError('20003:请提供画布ID');
+        }
+        $canvas = DB::table('mp_canvases')
+            ->where('id', $canvas_id)
+            ->where('uid', Site::getUid())
+            ->where('cpid', Site::getCpid())
+            ->where('is_deleted', 0)
+            ->first();
+        if (!$canvas) {
+            Utils::throwError('20003:画布不存在或无权访问');
+        }
+        return $canvas;
+    }
+
+    /**
+     * 校验节点归属并返回节点
+     *
+     * @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);
+    }
+}

+ 253 - 0
docs/canvas_api.md

@@ -0,0 +1,253 @@
+# 画布模式接口说明(保存节点 / 保存节点关联)
+
+画布模式:无限画布(无固定宽高),节点无限扩展、无固定根节点,仅记录两两之间的无方向多对多关联。
+
+通用说明:
+
+- 所有接口在 `api` 路由组下,需要登录态(`bindToken / bindExportToken / checkLogin / checkSign / checkCompany` 中间件)。
+- 返回格式统一为 `{ "msg": "", "code": 0, "data": {...} }`;出错时 `code` 为错误码,`msg` 为错误信息。
+- 表前缀统一为 `mp_`,节点类型为字符串小写。
+
+---
+
+## 1. 保存节点:POST api/canvas/saveNode
+
+创建或更新画布节点。**不传 `node_id` 表示创建**,**传 `node_id` 表示更新**(更新时节点类型以库中为准,不允许变更)。
+
+### 1.1 入参说明
+
+| 参数                  | 类型          | 必填     | 说明                                                                                                                                                             |
+| --------------------- | ------------- | -------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------- |
+| `canvas_id`         | int           | 是       | 画布 ID                                                                                                                                                          |
+| `node_id`           | int           | 否       | 节点 ID,更新时必传,创建时不传                                                                                                                                  |
+| `node_type`         | string        | 创建时是 | 节点类型,枚举:`theme`-主题、`subject`-主体、`scene`-场景、`prop`-道具、`segment`-分镜、`text`-文字、`image`-图片、`video`-视频、`audio`-音频 |
+| `name`              | string        | 否       | 节点名称                                                                                                                                                         |
+| `content`           | string        | 否       | 节点内容(主要用于`text` 文字节点)                                                                                                                            |
+| `text_prompt`       | string        | 否       | 文字提示词                                                                                                                                                       |
+| `pic_prompt`        | string        | 否       | 图片提示词                                                                                                                                                       |
+| `image_url`         | string        | 否       | 图片 URL                                                                                                                                                         |
+| `video_url`         | string        | 否       | 视频 URL                                                                                                                                                         |
+| `audio_url`         | string        | 否       | 音频 URL                                                                                                                                                         |
+| `thumbnail_url`     | string        | 否       | 缩略图 URL                                                                                                                                                       |
+| `duration`          | int           | 否       | 时长(秒),视频/音频/分镜使用                                                                                                                                   |
+| `pos_x`             | number        | 否       | 节点在画布上的 X 坐标,默认`0`                                                                                                                                 |
+| `pos_y`             | number        | 否       | 节点在画布上的 Y 坐标,默认`0`                                                                                                                                 |
+| `z_index`           | int           | 否       | 节点层级,默认`0`                                                                                                                                              |
+| `size_w`            | number        | 否       | 节点显示宽度(可空)                                                                                                                                             |
+| `size_h`            | number        | 否       | 节点显示高度(可空)                                                                                                                                             |
+| `source_type`       | string        | 否       | 关联来源,枚举:`none`-无、`product`-全局资产库、`episode`-动漫分集,默认 `none`                                                                         |
+| `source_product_id` | int           | 否       | 全局资产 ID(`mp_products.id`),`source_type=product` 时使用                                                                                                |
+| `source_anime_id`   | int           | 否       | 动漫 ID(`mp_animes.id`),`source_type=episode` 时使用                                                                                                      |
+| `source_episode_id` | int           | 否       | 剧集 ID(`mp_anime_episodes.id`),`source_type=episode` 时使用                                                                                              |
+| `source_ref`        | string        | 否       | 来源明细引用,如剧集 JSON 列表中的 key,用于定位具体列表项                                                                                                       |
+| `ext`               | object/string | 否       | 扩展信息,传对象或 JSON 字符串均可,库内以 JSON 存储                                                                                                             |
+| `segment`           | object        | 否       | 分镜数据子对象,仅`node_type=segment` 时生效(见 1.2)                                                                                                         |
+
+### 1.2 segment 子对象字段
+
+| 参数               | 类型          | 必填 | 说明                                                                                                     |
+| ------------------ | ------------- | ---- | -------------------------------------------------------------------------------------------------------- |
+| `segment_number` | int           | 否   | 分镜序号,默认`1`                                                                                      |
+| `name`           | string        | 否   | 分镜名称,缺省时取节点级`name`                                                                         |
+| `text_prompt`    | string        | 否   | 文字提示词,缺省时取节点级`text_prompt`                                                                |
+| `pic_prompt`     | string        | 否   | 图片提示词,缺省时取节点级`pic_prompt`                                                                 |
+| `image_url`      | string        | 否   | 图片 URL,缺省时取节点级`image_url`                                                                    |
+| `video_url`      | string        | 否   | 视频 URL,缺省时取节点级`video_url`                                                                    |
+| `audio_url`      | string        | 否   | 音频 URL,缺省时取节点级`audio_url`                                                                    |
+| `duration`       | int           | 否   | 时长(秒)                                                                                               |
+| `status`         | string        | 否   | 状态,枚举:`draft`-草稿、`generating`-生成中、`completed`-已完成、`failed`-失败,默认 `draft` |
+| `ext`            | object/string | 否   | 分镜扩展信息 JSON                                                                                        |
+
+> 保存分镜节点时,接口会同时写入节点表与画布分镜表(`mp_canvas_segments`);查询节点时返回的数据以分镜表字段为准。
+
+### 1.3 示例请求体
+
+创建普通节点(图片):
+
+```json
+{
+  "canvas_id": 1,
+  "node_type": "image",
+  "name": "图片-林默立绘",
+  "image_url": "https://example.com/linmo.png",
+  "thumbnail_url": "https://example.com/linmo_thumb.png",
+  "pos_x": 280,
+  "pos_y": 380
+}
+```
+
+创建分镜节点:
+
+```json
+{
+  "canvas_id": 1,
+  "node_type": "segment",
+  "name": "分镜1-雨夜相遇",
+  "pos_x": 480,
+  "pos_y": 220,
+  "segment": {
+    "segment_number": 1,
+    "text_prompt": "林默在雨夜街头偶遇苏晴",
+    "pic_prompt": "写实。全景,雨夜街头,两人对视",
+    "image_url": "https://example.com/seg1.png",
+    "video_url": "",
+    "audio_url": "",
+    "duration": 8,
+    "status": "completed"
+  }
+}
+```
+
+更新节点(复用动漫分集中的主体列表项):
+
+```json
+{
+  "canvas_id": 1,
+  "node_id": 2,
+  "name": "主体-林默",
+  "source_type": "episode",
+  "source_anime_id": 10,
+  "source_episode_id": 3,
+  "source_ref": "roles.0",
+  "pos_x": 300,
+  "pos_y": 100
+}
+```
+
+### 1.4 返回示例
+
+```json
+{
+  "msg": "",
+  "code": 0,
+  "data": {
+    "node_id": 21
+  }
+}
+```
+
+---
+
+## 2. 保存节点关联:POST api/canvas/saveRels
+
+保存某个节点的关联列表。**整体替换语义**:先清空该节点的全部关联,再写入新关联;返回实际写入的关联条数。
+
+### 2.1 入参说明
+
+| 参数            | 类型  | 必填 | 说明                                                                                                     |
+| --------------- | ----- | ---- | -------------------------------------------------------------------------------------------------------- |
+| `canvas_id`   | int   | 是   | 画布 ID                                                                                                  |
+| `node_id`     | int   | 是   | 中心节点 ID                                                                                              |
+| `related_ids` | array | 是   | 要关联的节点 ID 数组;也可传逗号分隔的字符串(如`"3,7,9"`)。传空数组/空字符串表示清空该节点的所有关联 |
+
+### 2.2 规则说明
+
+- 关联**无方向**:`A 关联 B` 与 `B 关联 A` 等价,内部统一按(小 id,大 id)存储,同一对节点只保留一条。
+- 支持**多对多**:一个节点可以关联任意多个节点,1:1、1:N、N:1、N:N 均由该表表达。
+- 自动过滤:与自身关联、跨画布的节点、不存在的节点会被忽略,不影响其他关联写入。
+- `related_ids` 会整体替换该节点原有全部关联,不需要前端先查再删。
+
+### 2.3 示例请求体
+
+```json
+{
+  "canvas_id": 1,
+  "node_id": 2,
+  "related_ids": [6, 8, 12, 16]
+}
+```
+
+清空节点 2 的全部关联:
+
+```json
+{
+  "canvas_id": 1,
+  "node_id": 2,
+  "related_ids": []
+}
+```
+
+### 2.4 返回示例
+
+```json
+{
+  "msg": "",
+  "code": 0,
+  "data": {
+    "success": 1,
+    "rel_count": 4
+  }
+}
+```
+
+---
+
+## 3. 关联查询接口(配套)
+
+### 3.1 获取节点关联:GET api/canvas/nodeRels
+
+入参:
+
+| 参数          | 类型 | 必填 | 说明    |
+| ------------- | ---- | ---- | ------- |
+| `canvas_id` | int  | 是   | 画布 ID |
+| `node_id`   | int  | 是   | 节点 ID |
+
+返回:`rel_ids`(关联节点 ID 数组)+ `related_nodes`(关联节点完整信息数组)。
+
+### 3.2 画布关系图:GET api/canvas/graph
+
+入参:
+
+| 参数          | 类型 | 必填 | 说明                                                                      |
+| ------------- | ---- | ---- | ------------------------------------------------------------------------- |
+| `canvas_id` | int  | 是   | 画布 ID                                                                   |
+| `node_id`   | int  | 否   | 中心节点 ID;不传返回画布全部节点与关联,传了返回以该节点为中心的局部子图 |
+| `depth`     | int  | 否   | 局部子图深度(1-5),默认`1`,仅 `node_id` 传入时生效                 |
+
+返回:`nodes`(节点数组)+ `rels`(关联数组,每项含 `node_id_a` / `node_id_b`),局部子图模式额外返回 `center_node_id`、`depth`、`levels`。
+
+局部子图模式下(传了 `node_id`),节点与关联都附带 `level` 字段,并提供 `levels` 层级摘要,方便前端逐层加载与动画:
+
+- `nodes[].level`:节点距中心节点的跳数,中心节点为 `0`,直接邻居为 `1`,以此类推;
+- `rels[].level`:关联首次被 BFS 触及的轮次(`depth=1` 时全部为 `1`,即中心节点的直接关联);
+- `levels`:数组,每项 `{ level, node_ids, rel_ids }`,`node_ids` / `rel_ids` 分别为该层包含的节点 ID 与关联 ID 列表(`level=0` 为中心节点)。
+
+前端渲染建议:直接用平铺的 `nodes` / `rels` 画节点和连线;需要"逐层展开/动画"时,按 `level` 过滤即可(如 `nodes.filter(n => n.level <= 1)`),或直接遍历 `levels` 摘要。
+
+> 局部子图的 `rels` 只包含从中心节点展开过程中触及的关联:`depth=1` 时仅返回中心节点的直接关联,不会返回"邻居之间的边"(例如节点 9 的邻居 5 与 18 之间若存在关联,不会在 `depth=1` 的结果中出现,需 `depth=2` 才会包含)。
+
+> 以演示数据中心节点 9、`depth=2` 为例的返回结果图示见 [canvas_subgraph_depth2.png](images/canvas_subgraph_depth2.png):返回 9 个节点、9 条边;节点 1 与 2 之间的边虽然两者都在子图内,但 BFS 展开过程中未触及,因此不返回。
+
+### 3.3 节点详情:GET api/canvas/nodeInfo
+
+入参:`canvas_id`、`node_id`。返回节点完整信息(分镜节点含分镜数据)+ `rel_ids` + `related_nodes`。
+
+---
+
+## 4. 接口测试演示数据
+
+仓库内已提供演示数据生成器 `database/seeders/CanvasDemoSeeder.php`,会生成一个名为「接口测试画布-示例」的画布,包含:
+
+- 20 个节点:主题×2、主体×2、场景×2、道具×2、分镜×3、文字×2、图片×3、视频×2、音频×2
+- 27 条无方向关联,节点间构成连通网络
+- 3 条画布分镜数据(含提示词、图片/视频 URL、状态)
+
+运行前确认画布数据表已创建(`php artisan migrate`),然后执行:
+
+```powershell
+# 指定测试用户的 uid / cpid(不指定默认 uid=1, cpid=1)
+$env:CANVAS_DEMO_UID = 123
+$env:CANVAS_DEMO_CPID = 456
+php artisan db:seed --class=CanvasDemoSeeder
+```
+
+重复执行会先清理同名演示画布再重新生成。执行成功后控制台会输出 `canvas_id` 与 20 个节点的编号→ID 映射,便于直接拼接测试请求。
+
+测试建议顺序:
+
+1. `GET api/canvas/list` 查看画布;
+2. `GET api/canvas/graph?canvas_id=1` 查看全部节点与关联;
+3. `GET api/canvas/nodeRels?canvas_id=1&node_id=8` 查看分镜节点的关联;
+4. `POST api/canvas/saveRels` 修改某节点关联后,再调用 `graph` 或 `nodeRels` 验证;
+5. `POST api/canvas/saveNode` 新建/更新节点后,用 `nodeInfo` 验证。

+ 17 - 1
routes/api.php

@@ -7,6 +7,7 @@ use App\Http\Controllers\Timbre\TimbreController;
 use App\Http\Controllers\AIGeneration\ImageGenerationController;
 use App\Http\Controllers\AIGeneration\VideoGenerationController;
 use App\Http\Controllers\Anime\AnimeController;
+use App\Http\Controllers\Canvas\CanvasController;
 use App\Http\Controllers\PromptTemplate\PromptTemplateController;
 use Illuminate\Support\Facades\Route;
 
@@ -248,6 +249,21 @@ Route::group(['middleware' => ['bindToken', 'bindExportToken', 'checkLogin']], f
             Route::post('editPromptTemplate', [PromptTemplateController::class, 'editTemplate']);         // 编辑提示词模板
             Route::get('deletePromptTemplate', [PromptTemplateController::class, 'deleteTemplate']);      // 删除提示词模板
         });
+
+        // 画布模式
+        Route::group(['prefix' => 'canvas'], function () {
+            Route::get('list', [CanvasController::class, 'canvasList']);          // 画布列表
+            Route::post('create', [CanvasController::class, 'createCanvas']);     // 创建画布
+            Route::post('edit', [CanvasController::class, 'editCanvas']);         // 编辑画布
+            Route::get('detail', [CanvasController::class, 'canvasDetail']);      // 画布详情(节点+关系)
+            Route::get('del', [CanvasController::class, 'delCanvas']);            // 删除画布
+            Route::post('saveNode', [CanvasController::class, 'saveNode']);       // 保存节点(含分镜数据)
+            Route::get('nodeInfo', [CanvasController::class, 'nodeInfo']);        // 节点详情
+            Route::post('delNode', [CanvasController::class, 'delNode']);         // 删除节点
+            Route::get('nodeRels', [CanvasController::class, 'nodeRels']);        // 获取节点关联
+            Route::post('saveRels', [CanvasController::class, 'saveRels']);       // 保存节点关联
+            Route::get('graph', [CanvasController::class, 'graph']);              // 画布关系图
+        });
     });
     
 });
@@ -263,4 +279,4 @@ Route::get('testLink', function () {
 
 // seedance回调
 Route::any('video/seedanceCallback', [VideoGenerationController::class, 'seedanceCallback']);
-Route::any('{slug}',[AccountController::class, 'index'])->where('slug', '(.*)?');
+Route::any('{slug}',[AccountController::class, 'index'])->where('slug', '(.*)?');