Просмотр исходного кода

新增公司级别隔离中间件(支持白名单和总开关)

lh 15 часов назад
Родитель
Сommit
763bdd2b9e
2 измененных файлов с 337 добавлено и 21 удалено
  1. 178 21
      app/Http/Middleware/CheckCompany.php
  2. 159 0
      config/param_ownership.php

+ 178 - 21
app/Http/Middleware/CheckCompany.php

@@ -3,19 +3,27 @@
 
 namespace App\Http\Middleware;
 
-use App\Cache\UserCache;
 use App\Consts\ErrorConst;
 use App\Libs\Utils;
 use App\Facade\Site;
-use App\Models\Channel\Channel;
 use Closure;
 use App\Exceptions\ApiException;
-use Illuminate\Support\Facades\Log;
 use Illuminate\Support\Facades\DB;
 
 class CheckCompany
 {
     /**
+     * 请求参数资源归属校验(公司维度)
+     *
+     * 规则:
+     * - 参数带资源 ID 时,校验该资源是否属于当前登录用户所在公司(cpid);
+     * - 同一公司内不同用户默认视为共享,不校验 user_id;
+     * - superadmin 等平台角色跳过校验(需要跨公司操作);
+     * - 参数未传时跳过,必填校验由业务层负责。
+     *
+     * 校验项由 config/param_ownership.php 配置,支持参数名映射、
+     * 批量参数(数组/逗号分隔/JSON)以及按路由覆盖。
+     *
      * @param         $request
      * @param Closure $next
      * @return mixed
@@ -30,31 +38,180 @@ class CheckCompany
             if (!$token) Utils::throwError(ErrorConst::NOT_LOGIN);
         }
 
-        if (env('CHECK_COMPANY')) {
-            $cpid = Site::getCpid();
-            
-            $script_id = getProp($params, 'script_id');
-            if ($script_id) {
-                if (!DB::table('mp_scripts')->where('id', $script_id)->where('cpid', $cpid)->value('id')) {
-                    Utils::throwError(ErrorConst::NOT_ACCESS);
-                }
+        $config = config('param_ownership', []);
+        if (empty($config['enabled'])) {
+            return $next($request);
+        }
+
+        // 平台角色需要跨公司操作,跳过归属校验
+        if (in_array(Site::getRole(), getProp($config, 'skip_roles', []), true)) {
+            return $next($request);
+        }
+
+        $cpid = Site::getCpid();
+        if (!$cpid) {
+            Utils::throwError(ErrorConst::NOT_ACCESS);
+        }
+
+        // 内部公司白名单:灰度期间先放过自己人,避免误拦
+        if (in_array($cpid, getProp($config, 'skip_cpids', []), true)) {
+            return $next($request);
+        }
+
+        $ownerColumn = getProp($config, 'owner_column', 'cpid');
+        $path        = $request->path();
+        $routeMap    = getProp($config, 'routes', []);
+
+        // 汇总全部校验项:普通 / 批量 / 链式 / 链式批量 / 按路由覆盖
+        $paramMap = getProp($config, 'params', []);
+
+        foreach (getProp($config, 'array_params', []) as $key => $define) {
+            $define['batch'] = true;
+            $paramMap[$key]  = $define;
+        }
+
+        foreach (getProp($config, 'linked_params', []) as $key => $define) {
+            $paramMap[$key] = $define;
+        }
+
+        foreach (getProp($config, 'linked_array_params', []) as $key => $define) {
+            $define['batch'] = true;
+            $paramMap[$key]  = $define;
+        }
+
+        if (isset($routeMap[$path]) && is_array($routeMap[$path])) {
+            foreach ($routeMap[$path] as $key => $define) {
+                $paramMap[$key] = $define;
             }
+        }
 
-            $anime_id = getProp($params, 'anime_id');
-            if ($anime_id) {
-                if (!DB::table('mp_animes')->where('id', $anime_id)->where('cpid', $cpid)->value('id')) {
-                    Utils::throwError(ErrorConst::NOT_ACCESS);
-                }
+        foreach ($paramMap as $key => $define) {
+            $this->checkOwnership($params, $key, $define, $cpid, $ownerColumn);
+        }
+
+        return $next($request);
+    }
+
+    /**
+     * 校验参数所指向资源的归属公司
+     *
+     * 支持两种模式:
+     * - 直接校验:目标表自带归属列(cpid);
+     * - 链式校验:目标表无归属列,先取记录再用外键到父表校验。
+     *
+     * @param array  $params      请求参数
+     * @param string $key         参数名
+     * @param array  $define      配置(table / key / type / batch / via)
+     * @param int    $cpid        当前公司ID
+     * @param string $ownerColumn 归属列名
+     * @throws ApiException
+     */
+    private function checkOwnership(array $params, string $key, array $define, int $cpid, string $ownerColumn): void
+    {
+        $table = getProp($define, 'table');
+        if (!$table || !array_key_exists($key, $params)) {
+            return;
+        }
+
+        $keyColumn = getProp($define, 'key', 'id');
+        $type      = getProp($define, 'type', 'int');
+        $batch     = (bool)getProp($define, 'batch', false);
+        $via       = getProp($define, 'via', null);
+
+        foreach ($this->toIdList($params[$key], $batch, $type) as $id) {
+            $owned = $via
+                ? $this->ownedVia($table, $keyColumn, $id, $via, $cpid, $ownerColumn)
+                : DB::table($table)->where($keyColumn, $id)->where($ownerColumn, $cpid)->exists();
+
+            if (!$owned) {
+                dLog('checkCompany')->info('资源归属校验未通过', [
+                    'path'  => request()->path(),
+                    'param' => $key,
+                    'id'    => $id,
+                    'table' => $table,
+                    'cpid'  => $cpid,
+                    'uid'   => Site::getUid(),
+                ]);
+                Utils::throwError(ErrorConst::NOT_ACCESS);
             }
+        }
+    }
+
+    /**
+     * 链式归属校验:按主键取记录,再用记录中的外键去父表校验归属
+     *
+     * @param string $table       目标表
+     * @param string $keyColumn   目标表主键列
+     * @param mixed  $id          主键值
+     * @param array  $via         ['local' => 外键列, 'table' => 父表, 'key' => 父表主键列]
+     * @param int    $cpid        当前公司ID
+     * @param string $ownerColumn 归属列名
+     * @return bool
+     */
+    private function ownedVia(string $table, string $keyColumn, $id, array $via, int $cpid, string $ownerColumn): bool
+    {
+        $local    = getProp($via, 'local');
+        $refTable = getProp($via, 'table');
+        $refKey   = getProp($via, 'key', 'id');
+        if (!$local || !$refTable) {
+            return false;
+        }
 
-            $episode_id = getProp($params, 'episode_id');
-            if ($episode_id) {
-                if (!DB::table('mp_anime_episodes')->where('id', $episode_id)->where('cpid', $cpid)->value('id')) {
-                    Utils::throwError(ErrorConst::NOT_ACCESS);
+        // 资源不存在时按无权限处理(fail-closed)
+        $row = DB::table($table)->where($keyColumn, $id)->first([$local]);
+        if (!$row) {
+            return false;
+        }
+
+        $refValue = getProp($row, $local);
+        if ($refValue === null || $refValue === '') {
+            return false;
+        }
+
+        return DB::table($refTable)->where($refKey, $refValue)->where($ownerColumn, $cpid)->exists();
+    }
+
+    /**
+     * 统一转换为 ID 列表(兼容单值、数组、逗号分隔、JSON 数组)
+     *
+     * @param mixed  $value
+     * @param bool   $batch 是否允许逗号分隔 / JSON 数组
+     * @param string $type  主键类型:int(默认)/ string
+     * @return array
+     */
+    private function toIdList($value, bool $batch, string $type = 'int'): array
+    {
+        if (is_array($value)) {
+            $items = $value;
+        } elseif ($batch && is_string($value)) {
+            $decoded = json_decode($value, true);
+            $items   = is_array($decoded) ? $decoded : explode(',', $value);
+        } else {
+            $items = [$value];
+        }
+
+        $ids = [];
+        foreach ($items as $item) {
+            if (is_array($item)) {
+                $ids = array_merge($ids, $this->toIdList($item, $batch, $type));
+                continue;
+            }
+
+            if ($type === 'string') {
+                // 字符串主键(如 mp_episode_segments.segment_id)需保留原值
+                $val = trim((string)$item);
+                if ($val !== '') {
+                    $ids[] = $val;
                 }
+                continue;
+            }
+
+            $id = (int)$item;
+            if ($id > 0) {
+                $ids[] = $id;
             }
         }
 
-        return $next($request);
+        return array_values(array_unique($ids));
     }
 }

+ 159 - 0
config/param_ownership.php

@@ -0,0 +1,159 @@
+<?php
+
+/**
+ * 请求参数资源归属校验配置
+ *
+ * 配合 App\Http\Middleware\CheckCompany 使用:
+ * 校验请求参数中的资源 ID 是否属于当前登录用户所在公司(cpid),防止跨公司访问。
+ * 同一公司内不同用户默认视为共享,因此这里只校验公司维度(cpid),不校验 user_id。
+ *
+ * 说明:在 config 文件中使用 env() 是安全的(config:cache 时会被固化);
+ *      业务代码中请勿直接使用 env()。
+ */
+
+// 分镜归属:mp_episode_segments 无 cpid,靠 anime_id 推导;
+// 注意 segment_id 是字符串业务键(形如 202601011200001234001),不能按整数解析。
+$segmentDefine = [
+    'table' => 'mp_episode_segments',
+    'key'   => 'segment_id',
+    'type'  => 'string',
+    'via'   => ['local' => 'anime_id', 'table' => 'mp_animes', 'key' => 'id'],
+];
+
+// 片段归属:片段同样存放在 mp_episode_segments,但以自增主键 id 定位
+$actDefine = [
+    'table' => 'mp_episode_segments',
+    'key'   => 'id',
+    'via'   => ['local' => 'anime_id', 'table' => 'mp_animes', 'key' => 'id'],
+];
+
+return [
+
+    // 总开关(对应 .env 中的 CHECK_COMPANY)
+    'enabled' => (bool)env('CHECK_COMPANY', false),
+
+    // 跳过校验的角色:平台运营角色本身需要跨公司操作
+    'skip_roles' => ['superadmin'],
+
+    /*
+    | 跳过校验的公司ID(内部公司白名单)
+    | 用于灰度上线或线上异常时先放过内部公司,避免误拦影响自己人。
+    | 多个用逗号分隔,例如 .env 中 CHECK_COMPANY_SKIP_CPIDS=1,2;
+    | 留空表示不跳过任何公司(全部校验)。
+    */
+    'skip_cpids' => array_values(array_filter(array_map('intval', explode(',', (string)env('CHECK_COMPANY_SKIP_CPIDS', '1'))))),
+
+    // 资源归属列(公司维度)
+    'owner_column' => 'cpid',
+
+    /*
+    |--------------------------------------------------------------------------
+    | 全局参数映射
+    |--------------------------------------------------------------------------
+    | 格式:参数名 => ['table' => 表名, 'key' => 主键列(可选,默认 id)]
+    | 仅当该参数名在全项目范围内语义唯一时才放在这里;
+    | 参数名有歧义(例如 id、task_id)时请放到下方 routes 中按路由指定。
+    */
+    'params' => [
+        // 原有校验项,保持行为不变
+        'script_id'         => ['table' => 'mp_scripts'],
+        'anime_id'          => ['table' => 'mp_animes'],
+        'episode_id'        => ['table' => 'mp_anime_episodes'],
+
+        // 画布
+        'canvas_id'         => ['table' => 'mp_canvases'],
+
+        // 资产库(个人库 user_id=自身,公共库 user_id=0,归属列均为 cpid)
+        'product_id'        => ['table' => 'mp_products'],
+        'parent_id'         => ['table' => 'mp_products'],
+        'target_parent_id'  => ['table' => 'mp_products'],
+        'source_product_id' => ['table' => 'mp_products'],
+        'source_anime_id'   => ['table' => 'mp_animes'],
+        'source_episode_id' => ['table' => 'mp_anime_episodes'],
+
+        // 提示词模板
+        'template_id'       => ['table' => 'mp_prompt_templates'],
+    ],
+
+    /*
+    |--------------------------------------------------------------------------
+    | 批量参数
+    |--------------------------------------------------------------------------
+    | 值可能是数组、逗号分隔字符串或 JSON 数组,会逐项校验。
+    */
+    'array_params' => [
+        'script_ids' => ['table' => 'mp_scripts'],
+    ],
+
+    /*
+    |--------------------------------------------------------------------------
+    | 链式校验参数(目标表没有 cpid 列,需要沿父级推导归属)
+    |--------------------------------------------------------------------------
+    | 格式:参数名 => [
+    |     'table' => 目标表,
+    |     'key'   => 目标表主键列(默认 id),
+    |     'type'  => 主键类型 int|string(默认 int),
+    |     'via'   => ['local' => 目标表中的外键列, 'table' => 父表, 'key' => 父表主键列],
+    | ]
+    |
+    | 校验方式:按主键取出记录 → 用记录中的外键查父表 → 校验父表记录的 cpid。
+    | 记录不存在时按无权限处理(fail-closed)。
+    */
+    'linked_params' => [
+        // 剧本分集组:mp_script_episode_group.script_id -> mp_scripts
+        'group_id' => [
+            'table' => 'mp_script_episode_group',
+            'via'   => ['local' => 'script_id', 'table' => 'mp_scripts', 'key' => 'id'],
+        ],
+
+        // 剧本对话记录:mp_script_records.script_id -> mp_scripts
+        'rid' => [
+            'table' => 'mp_script_records',
+            'via'   => ['local' => 'script_id', 'table' => 'mp_scripts', 'key' => 'id'],
+        ],
+
+        // 分镜 / 相邻分镜:mp_episode_segments.anime_id -> mp_animes
+        'segment_id'        => $segmentDefine,
+        'prev_segment_id'   => $segmentDefine,
+        'target_segment_id' => $segmentDefine,
+
+        // 片段 / 相邻片段:mp_episode_segments.anime_id -> mp_animes
+        'act_id'            => $actDefine,
+        'prev_act_id'       => $actDefine,
+        'target_act_id'     => $actDefine,
+
+        // 画布节点:mp_canvas_nodes.canvas_id -> mp_canvases
+        'node_id' => [
+            'table' => 'mp_canvas_nodes',
+            'via'   => ['local' => 'canvas_id', 'table' => 'mp_canvases', 'key' => 'id'],
+        ],
+    ],
+
+    // 链式校验的批量参数(值可能是数组 / 逗号分隔 / JSON 数组)
+    'linked_array_params' => [
+        // 节点关联:mp_canvas_nodes.canvas_id -> mp_canvases
+        'related_ids' => [
+            'table' => 'mp_canvas_nodes',
+            'via'   => ['local' => 'canvas_id', 'table' => 'mp_canvases', 'key' => 'id'],
+        ],
+    ],
+
+    /*
+    |--------------------------------------------------------------------------
+    | 按路由覆盖(参数名有歧义时使用)
+    |--------------------------------------------------------------------------
+    | 键为 $request->path() 的返回值(不含前导斜杠),例如 api/anime/deleteProduct
+    */
+    'routes' => [
+        'api/anime/deleteProduct'     => ['id' => ['table' => 'mp_products']],
+        'api/anime/editProduct'       => ['id' => ['table' => 'mp_products']],
+        'api/anime/getFolderPath'     => ['id' => ['table' => 'mp_products']],
+        'api/anime/renameFolder'      => ['id' => ['table' => 'mp_products']],
+        'api/anime/moveRoleOrFolder'  => ['id' => ['table' => 'mp_products']],
+        'api/anime/generateThreeView' => ['id' => ['table' => 'mp_products']],
+        'api/anime/globalProducts'    => ['id' => ['table' => 'mp_products']],
+        'api/anime/taskCenter/detail' => ['task_id' => ['table' => 'mp_task_center']],
+        'api/anime/taskCenter/list'   => ['task_id' => ['table' => 'mp_task_center']],
+    ],
+
+];