Преглед на файлове

文生文和文生图兼容解密提示词

lh преди 1 месец
родител
ревизия
be6e626f4a
променени са 3 файла, в които са добавени 188 реда и са изтрити 0 реда
  1. 49 0
      app/Http/Controllers/Anime/AnimeController.php
  2. 4 0
      app/Http/Middleware/TrimStrings.php
  3. 135 0
      app/Libs/GenerationContext.php

+ 49 - 0
app/Http/Controllers/Anime/AnimeController.php

@@ -7,6 +7,7 @@ use App\Facade\Site;
 use App\Consts\ErrorConst;
 use App\Exceptions\ApiException;
 use App\Libs\ApiResponse;
+use App\Libs\GenerationContext;
 use App\Libs\Utils;
 use App\Models\MpGenerateVideoTask;
 use App\Services\AIGeneration\AIImageGenerationService;
@@ -1373,6 +1374,49 @@ class AnimeController extends BaseController
         }
     }
 
+    /**
+     * 校验生图/视频请求的 context_data 解密结果是否与 prompt 逐字节一致
+     *
+     * 未携带 context_data 时兼容旧客户端放行;携带但解密失败或与 prompt 不一致时中断请求,
+     * 解密失败与解密后不一致分别记录日志,便于联调定位。
+     *
+     * @param array  $data  请求参数
+     * @param string $scene 场景标识(用于日志区分接口)
+     */
+    protected function assertGenerationContext($data, $scene)
+    {
+        $prompt      = is_array($data) ? getProp($data, 'prompt', null) : null;
+        $contextData = is_array($data) ? getProp($data, 'context_data', null) : null;
+
+        $result = GenerationContext::verify($prompt, $contextData);
+
+        $logData = [
+            'scene'         => $scene,
+            'status'        => $result['status'],
+            'prompt_bytes'  => $result['prompt_bytes'],
+            'decoded_bytes' => $result['decoded_bytes'],
+        ];
+
+        if ($result['status'] === GenerationContext::STATUS_OK) {
+            return;
+        }
+
+        // 未升级的旧客户端暂不携带 context_data,本阶段保持原逻辑放行
+        if ($result['status'] === GenerationContext::STATUS_MISSING) {
+            dLog('anime')->warning('生成接口未携带 context_data', $logData);
+            return;
+        }
+
+        $logData['detail'] = $result['message'];
+        if ($result['status'] === GenerationContext::STATUS_MISMATCH) {
+            $logData['prompt_sha256']  = is_string($prompt) ? substr(hash('sha256', $prompt), 0, 16) : '';
+            $logData['decoded_sha256'] = $result['decoded_sha256'];
+        }
+        dLog('anime')->error('生成接口 context_data 校验失败', $logData);
+
+        Utils::throwError('20003:' . $result['message']);
+    }
+
     // 文生图(通用)
     public function generateImg(Request $request) {
         // 忽略所有超时限制
@@ -1386,6 +1430,7 @@ class AnimeController extends BaseController
 
         if (!$useStream) {
             // 原有逻辑:同步等待结果,普通 JSON 响应(格式保持不变)
+            $this->assertGenerationContext($data, 'generateImg');
             $result = $this->AnimeService->generateImg($data);
 
             // 多张图片:data为首图URL,image_urls为全部图片URL数组,task_center_id为任务中心ID
@@ -1425,6 +1470,8 @@ class AnimeController extends BaseController
             }
 
             try {
+                // 校验 context_data 解密结果与 prompt 是否逐字节一致,失败通过 SSE type=error 返回
+                $this->assertGenerationContext($data, 'generateImg');
                 // 创建图片生成任务,任务创建后立即回调发送init消息(包含任务中心ID和任务信息)
                 $result = $this->AnimeService->generateImg($data, function ($taskCenterId, $taskInfo) {
                     echo "data: " . json_encode([
@@ -2771,6 +2818,8 @@ class AnimeController extends BaseController
         ];
 
         try {
+            // 校验 context_data 解密结果与 prompt 是否逐字节一致,失败统一走 SSE type=error
+            $this->assertGenerationContext($data, 'generateVideo');
             // 创建视频生成任务
             $result = $this->AnimeService->generateVideo($data);
             $taskId = $result['task_id'];

+ 4 - 0
app/Http/Middleware/TrimStrings.php

@@ -15,5 +15,9 @@ class TrimStrings extends Middleware
         'current_password',
         'password',
         'password_confirmation',
+        // 生图/视频提示词需保留原始字节(首尾空格、换行、制表符等),
+        // 用于与 context_data 解密结果逐字节比对
+        'prompt',
+        'context_data',
     ];
 }

+ 135 - 0
app/Libs/GenerationContext.php

@@ -0,0 +1,135 @@
+<?php
+
+
+namespace App\Libs;
+
+
+/**
+ * 生图/视频提示词上下文(context_data)解密与校验
+ *
+ * 协议第一版:AES-256-GCM
+ * 数据包:0x01 || IV(12字节) || 密文 || Tag(16字节),整体使用标准 Base64 编码
+ * AAD:sound-ai:context_data:v1
+ */
+class GenerationContext
+{
+    /** 协议版本 */
+    const VERSION = 1;
+
+    /** 数据包最小长度:版本 1 字节 + IV 12 字节 + Tag 16 字节 */
+    const MIN_PACKET_LENGTH = 29;
+
+    /** 联调共享密钥(64 位十六进制字符串,解码后为 32 字节) */
+    const KEY_HEX = 'f1b20a84c45fe3dbb270e867f9b1a181e9e5ffb3f06fce80341f8a35808f9b6a';
+
+    /** 附加认证数据 */
+    const AAD = 'sound-ai:context_data:v1';
+
+    /** 校验通过 */
+    const STATUS_OK = 'ok';
+    /** 未携带 context_data */
+    const STATUS_MISSING = 'missing';
+    /** 编码/版本错误或认证/解密失败 */
+    const STATUS_INVALID = 'invalid';
+    /** 解密成功但与 prompt 不一致 */
+    const STATUS_MISMATCH = 'mismatch';
+
+    /**
+     * 解密 context_data
+     *
+     * @param mixed $value 前端传入的 context_data
+     * @return string prompt 原文
+     * @throws \RuntimeException 编码/版本错误、密钥错误或认证/解密失败
+     */
+    public static function decode($value)
+    {
+        if (!is_string($value) || $value === '') {
+            throw new \RuntimeException('context_data 编码或版本错误');
+        }
+
+        $packet = base64_decode($value, true);
+        // 回写比对用于排除空白字符、URL-safe 变体等非标准 Base64 编码
+        if ($packet === false
+            || base64_encode($packet) !== $value
+            || strlen($packet) < self::MIN_PACKET_LENGTH
+            || ord($packet[0]) !== self::VERSION) {
+            throw new \RuntimeException('context_data 编码或版本错误');
+        }
+
+        $key = hex2bin(self::KEY_HEX);
+        if ($key === false || strlen($key) !== 32) {
+            throw new \RuntimeException('context_data 密钥配置错误');
+        }
+
+        $iv         = substr($packet, 1, 12);
+        $tag        = substr($packet, -16);
+        $ciphertext = substr($packet, 13, -16);
+
+        $plain = openssl_decrypt(
+            $ciphertext,
+            'aes-256-gcm',
+            $key,
+            OPENSSL_RAW_DATA,
+            $iv,
+            $tag,
+            self::AAD
+        );
+
+        if ($plain === false) {
+            throw new \RuntimeException('context_data 认证或解密失败');
+        }
+
+        return $plain;
+    }
+
+    /**
+     * 校验 prompt 与 context_data 是否逐字节一致
+     *
+     * 不对 prompt 做 trim、换行转换、Unicode 归一化、模板替换等任何处理。
+     *
+     * @param mixed $prompt
+     * @param mixed $contextData
+     * @return array
+     */
+    public static function verify($prompt, $contextData)
+    {
+        $result = [
+            'status'         => self::STATUS_MISSING,
+            'message'        => '',
+            'prompt_bytes'   => is_string($prompt) ? strlen($prompt) : 0,
+            'decoded_bytes'  => 0,
+            'decoded_sha256' => '',
+        ];
+
+        if ($contextData === null || $contextData === '') {
+            return $result;
+        }
+
+        if (!is_string($contextData)) {
+            $result['status']  = self::STATUS_INVALID;
+            $result['message'] = 'context_data 编码或版本错误';
+            return $result;
+        }
+
+        try {
+            $decoded = self::decode($contextData);
+        } catch (\RuntimeException $e) {
+            $result['status']  = self::STATUS_INVALID;
+            $result['message'] = $e->getMessage();
+            return $result;
+        }
+
+        $result['decoded_bytes']  = strlen($decoded);
+        $result['decoded_sha256'] = substr(hash('sha256', $decoded), 0, 16);
+
+        if (!is_string($prompt) || !hash_equals($prompt, $decoded)) {
+            $result['status']  = self::STATUS_MISMATCH;
+            $result['message'] = 'context_data 与 prompt 不一致';
+            return $result;
+        }
+
+        $result['status'] = self::STATUS_OK;
+
+        return $result;
+    }
+}