ソースを参照

解析剧本资产新增json格式化安全检查及修复机制

lh 2 週間 前
コミット
b1793db7f8

+ 280 - 0
app/Libs/JsonSafeRepair.php

@@ -0,0 +1,280 @@
+<?php
+
+namespace App\Libs;
+
+/**
+ * 剧本资产生成结果的 JSON 安全修复器
+ *
+ * 仅处理一种已验证可安全修复的语法错误:在数组(content 二维数组)中,
+ * 一个元素已经结束后又额外出现了一个 "}"(多写的对象闭合符)。
+ *
+ * 修复采用逐字符解析(字符串内容完整跳过,不做任何修改),定位多余
+ * 的 "}" 并删除;每次删除后都必须通过完整的 json_decode 校验才算成功,
+ * 校验不通过则放弃修复并原样返回,绝不猜测补全其它符号。
+ */
+class JsonSafeRepair
+{
+    /**
+     * 尝试安全修复 JSON。
+     *
+     * @param string $json 原始内容
+     * @return array{ok:bool, json:?string, removed_positions:int[], reason:string}
+     */
+    public static function repair(string $json): array
+    {
+        $base = ['ok' => false, 'json' => null, 'removed_positions' => [], 'reason' => ''];
+
+        if ($json === '') {
+            $base['reason'] = 'empty';
+            return $base;
+        }
+
+        // 本身就是合法 JSON,无需修复
+        if (self::isValidJson($json)) {
+            $base['ok'] = true;
+            $base['json'] = $json;
+            return $base;
+        }
+
+        $current = $json;
+        $removed = [];
+
+        for ($attempt = 0; $attempt < 30; $attempt++) {
+            $index = self::findExtraClosingBrace($current);
+            if ($index === null) {
+                $base['reason'] = 'unsupported-syntax';
+                $base['removed_positions'] = $removed;
+                return $base;
+            }
+
+            $current = substr($current, 0, $index) . substr($current, $index + 1);
+            $removed[] = $index;
+
+            if (self::isValidJson($current)) {
+                $base['ok'] = true;
+                $base['json'] = $current;
+                $base['removed_positions'] = $removed;
+                return $base;
+            }
+        }
+
+        $base['reason'] = 'too-many-attempts';
+        $base['removed_positions'] = $removed;
+        return $base;
+    }
+
+    private static function isValidJson(string $json): bool
+    {
+        json_decode($json, true);
+        return json_last_error() === JSON_ERROR_NONE;
+    }
+
+    /**
+     * 定位"数组内一个元素结束后多写了一个 }"的字符位置;无匹配返回 null。
+     *
+     * 实现说明:逐字符模拟 JSON 的括号/状态结构,字符串内容(含转义)完整跳过,
+     * 不修改任何字符;仅当栈顶是数组、且该数组刚完成一个元素(期待 , 或 ])时
+     * 遇到 "}",并且其后能自然续接(]、}、, 或结尾)才认为这是多余的 "}"。
+     */
+    private static function findExtraClosingBrace(string $json): ?int
+    {
+        $len = strlen($json);
+        // 栈元素: ['t' => '['|'{', 'p' => 'value'|'key'|'colon'|'comma']
+        $stack = [];
+        $i = 0;
+
+        while ($i < $len) {
+            $ch = $json[$i];
+
+            if ($ch === ' ' || $ch === "\t" || $ch === "\n" || $ch === "\r") {
+                $i++;
+                continue;
+            }
+
+            if ($ch === '"') {
+                // 字符串:可能是对象的 key,也可能是 value
+                if (!self::isStringStartAllowed($stack)) {
+                    return null;
+                }
+                $i = self::skipString($json, $i, $len);
+                if ($i >= $len) {
+                    return null; // 字符串未闭合,不在安全修复范围
+                }
+                self::markValueDone($stack, true);
+                $i++;
+                continue;
+            }
+
+            if ($ch === '{' || $ch === '[') {
+                if (!self::isValueStartAllowed($stack)) {
+                    return null;
+                }
+                $stack[] = ['t' => $ch, 'p' => $ch === '[' ? 'value' : 'key'];
+                $i++;
+                continue;
+            }
+
+            if ($ch === '}') {
+                if (empty($stack)) {
+                    return null;
+                }
+                $topIndex = count($stack) - 1;
+                $top = $stack[$topIndex];
+
+                if ($top['t'] === '[') {
+                    if ($top['p'] === 'comma') {
+                        // 数组元素结束后出现多余的 }:仅当后面能自然续接时才删除
+                        $j = $i + 1;
+                        while ($j < $len && ($json[$j] === ' ' || $json[$j] === "\t" || $json[$j] === "\n" || $json[$j] === "\r")) {
+                            $j++;
+                        }
+                        if ($j >= $len || $json[$j] === ']' || $json[$j] === '}' || $json[$j] === ',') {
+                            return $i;
+                        }
+                    }
+                    return null;
+                }
+
+                // 对象闭合:空对象 {} 或 key:value 之后
+                if ($top['p'] === 'key' || $top['p'] === 'comma') {
+                    array_pop($stack);
+                    self::markValueDone($stack, false);
+                    $i++;
+                    continue;
+                }
+                return null; // 冒号后缺值等,不在安全修复范围
+            }
+
+            if ($ch === ']') {
+                if (empty($stack)) {
+                    return null;
+                }
+                $topIndex = count($stack) - 1;
+                $top = $stack[$topIndex];
+                if ($top['t'] === '[' && ($top['p'] === 'value' || $top['p'] === 'comma')) {
+                    array_pop($stack);
+                    self::markValueDone($stack, false);
+                    $i++;
+                    continue;
+                }
+                return null;
+            }
+
+            if ($ch === ',') {
+                if (empty($stack)) {
+                    return null;
+                }
+                $topIndex = count($stack) - 1;
+                if ($stack[$topIndex]['p'] === 'comma') {
+                    $stack[$topIndex]['p'] = $stack[$topIndex]['t'] === '[' ? 'value' : 'key';
+                    $i++;
+                    continue;
+                }
+                return null;
+            }
+
+            if ($ch === ':') {
+                if (empty($stack)) {
+                    return null;
+                }
+                $topIndex = count($stack) - 1;
+                if ($stack[$topIndex]['t'] === '{' && $stack[$topIndex]['p'] === 'colon') {
+                    $stack[$topIndex]['p'] = 'value';
+                    $i++;
+                    continue;
+                }
+                return null;
+            }
+
+            // 字面量 token(数字 / true / false / null)
+            if (!self::isValueStartAllowed($stack)) {
+                return null;
+            }
+            $i++;
+            while ($i < $len) {
+                $c = $json[$i];
+                if ($c === ' ' || $c === "\t" || $c === "\n" || $c === "\r"
+                    || $c === ',' || $c === ']' || $c === '}' || $c === ':'
+                    || $c === '"' || $c === '{' || $c === '[') {
+                    break;
+                }
+                $i++;
+            }
+            self::markValueDone($stack, false);
+        }
+
+        // 完整扫描未发现可安全修复的多余 }
+        return null;
+    }
+
+    /**
+     * 跳过一段 JSON 字符串(含转义),返回闭合引号的位置;未闭合返回 $len。
+     */
+    private static function skipString(string $json, int $i, int $len): int
+    {
+        $i++;
+        while ($i < $len) {
+            $c = $json[$i];
+            if ($c === '\\') {
+                $i += 2;
+                continue;
+            }
+            if ($c === '"') {
+                return $i;
+            }
+            $i++;
+        }
+        return $len;
+    }
+
+    /**
+     * 值(或对象的 key 字符串)是否允许在此位置开始。
+     */
+    private static function isStringStartAllowed(array $stack): bool
+    {
+        if (empty($stack)) {
+            return true;
+        }
+        $top = end($stack);
+        if ($top['t'] === '[') {
+            return $top['p'] === 'value';
+        }
+        // 对象内:期待 key(字符串)或冒号后的 value
+        return $top['p'] === 'key' || $top['p'] === 'value';
+    }
+
+    /**
+     * 非字符串值(嵌套容器、数字、true/false/null)是否允许开始。
+     */
+    private static function isValueStartAllowed(array $stack): bool
+    {
+        if (empty($stack)) {
+            return true;
+        }
+        $top = end($stack);
+        if ($top['t'] === '[') {
+            return $top['p'] === 'value';
+        }
+        // 对象内非字符串值只能是冒号后的 value
+        return $top['p'] === 'value';
+    }
+
+    /**
+     * 一个值(字符串/字面量/容器结束)之后,标记所在层进入"期待分隔符或闭合"状态。
+     *
+     * @param bool $stringToken 是否为字符串 token(字符串在对象 key 位置时是 key)
+     */
+    private static function markValueDone(array &$stack, bool $stringToken): void
+    {
+        if (empty($stack)) {
+            return;
+        }
+        $topIndex = count($stack) - 1;
+        if ($stringToken && $stack[$topIndex]['t'] === '{' && $stack[$topIndex]['p'] === 'key') {
+            // key 字符串结束,期待冒号
+            $stack[$topIndex]['p'] = 'colon';
+            return;
+        }
+        $stack[$topIndex]['p'] = 'comma';
+    }
+}

+ 1 - 0
app/Models/MpScriptGenerateTask.php

@@ -21,6 +21,7 @@ class MpScriptGenerateTask extends Model
         'status',
         'prompt',
         'result',
+        'json_result',
         'error_message',
         'started_at',
         'completed_at',

+ 88 - 0
app/Services/DeepSeek/DeepSeekService.php

@@ -5,6 +5,7 @@ namespace App\Services\DeepSeek;
 use App\Consts\ErrorConst;
 use App\Consts\BaseConst;
 use App\Facade\Site;
+use App\Libs\JsonSafeRepair;
 use App\Libs\Utils;
 use GuzzleHttp\Client;
 use DateTime;
@@ -1090,6 +1091,15 @@ class DeepSeekService
                         'completed_at' => date('Y-m-d H:i:s'),
                         'updated_at'   => date('Y-m-d H:i:s'),
                     ]);
+
+                    // JSON 输出结果的安全校验/修复与观察(不改变 result 与 SSE 推送内容)
+                    $this->guardScriptGenerateJsonResult(
+                        (int)$ctx['generate_task_id'],
+                        (int)($ctx['script_id'] ?? 0),
+                        (int)($ctx['uid'] ?? 0),
+                        (string)$fullContent,
+                        (string)($ctx['response_format'] ?? '')
+                    );
                 }
                 if ($ctx['has_valid_script'] && $fullContent !== '') {
                     $this->chargeChatSuccess($ctx['uid'], $this->pointsService->getTokensFromUsage($usage), [
@@ -1151,6 +1161,75 @@ class DeepSeekService
     }
 
     /**
+     * 剧本资产生成结果的 JSON 安全校验/修复与观察
+     *
+     * 规则:
+     * - result 永远保留模型原文,本方法不修改 result;
+     * - 仅当原文非法 JSON 且 JsonSafeRepair 安全修复成功时,才把修复后的标准
+     *   JSON 写入 mp_script_generate_tasks.json_result;
+     * - 每次生成结果都写一条 system_logs(channel=script_generate_json),
+     *   用于观察非法 JSON 的发生率与修复率。
+     *
+     * @param int    $taskId        剧本资产生成任务 ID
+     * @param int    $scriptId      剧本 ID
+     * @param int    $uid           用户 ID
+     * @param string $raw           已落库的 result 原文
+     * @param string $responseFormat 请求的 response_format
+     * @return void
+     */
+    private function guardScriptGenerateJsonResult(int $taskId, int $scriptId, int $uid, string $raw, string $responseFormat): void
+    {
+        if ($taskId <= 0 || $raw === '') {
+            return;
+        }
+
+        // 仅对"要求 JSON 输出"或"内容明显是 JSON"的结果做校验,避免误伤普通文本结果
+        $trimmed = ltrim($raw);
+        $looksLikeJson = $responseFormat === 'json'
+            || (isset($trimmed[0]) && ($trimmed[0] === '{' || $trimmed[0] === '['));
+        if (!$looksLikeJson) {
+            return;
+        }
+
+        $repair = JsonSafeRepair::repair($raw);
+        $common = [
+            'task_id'    => $taskId,
+            'script_id'  => $scriptId,
+            'uid'        => $uid,
+            'raw_length' => strlen($raw),
+        ];
+
+        if ($repair['ok'] && $repair['json'] !== $raw) {
+            // 原文非法但安全修复成功:修复版另存 json_result,result 仍保留原文
+            DB::table('mp_script_generate_tasks')->where('id', $taskId)->update([
+                'json_result' => $repair['json'],
+                'updated_at'  => date('Y-m-d H:i:s'),
+            ]);
+            logDB('script_generate_json', 'warning', '生成JSON修复成功', $common + [
+                'json_state'        => 'repaired',
+                'json_length'       => strlen((string)$repair['json']),
+                'removed_count'     => count($repair['removed_positions']),
+                'removed_positions' => $repair['removed_positions'],
+            ]);
+            return;
+        }
+
+        if ($repair['ok']) {
+            // 原文本身就是合法 JSON
+            logDB('script_generate_json', 'info', '生成JSON校验通过', $common + [
+                'json_state' => 'valid',
+            ]);
+            return;
+        }
+
+        // 非法 JSON 且不在安全修复范围内:保留原文,只记录观察日志
+        logDB('script_generate_json', 'error', '生成JSON修复失败', $common + [
+            'json_state' => 'repair_failed',
+            'reason'     => $repair['reason'],
+        ]);
+    }
+
+    /**
      * 通用文生文方法(非流式版本)- 支持多模型、图片输入、JSON输出和模板提示词
      * 
      * @param array $data 请求参数
@@ -1522,6 +1601,15 @@ class DeepSeekService
                 'updated_at'   => date('Y-m-d H:i:s')
             ]);
             $aiResult['generate_task_id'] = $generateTaskId;
+
+            // JSON 输出结果的安全校验/修复与观察(不改变 result)
+            $this->guardScriptGenerateJsonResult(
+                (int)$generateTaskId,
+                (int)$script_id,
+                (int)$uid,
+                (string)getProp($aiResult, 'content', ''),
+                (string)$responseFormat
+            );
         }
 
         // 调用成功且存在有效剧本时,返回结果前扣除积分并记录明细(chat类型10积分)