max(1, min(524288000, (int) config('followup_audio.max_bytes', 524288000))), 'max_seconds' => max(1, min(3600, (int) config('followup_audio.max_seconds', 3600))), 'chunk_bytes' => max(65536, min(2097152, (int) config('followup_audio.chunk_bytes', 2097152))), ]; } public static function fileName(string $name): array { if ($name === '' || strlen($name) > 240 || preg_match('/[\x00-\x1f\x7f\/\\\\]/', $name)) { throw new DomainException('录音文件名无效'); } $extension = strtolower(pathinfo($name, PATHINFO_EXTENSION)); if (!in_array($extension, self::EXTENSIONS, true)) { throw new DomainException('仅支持 MP3、M4A、WAV、AMR 录音'); } return [$name, $extension]; } public static function createSession(int $diagnosisId, string $name, int $bytes, int $actor, array $info): array { FollowupAudioAccess::diagnosis($diagnosisId, $actor, $info); [$name, $extension] = self::fileName($name); if ($bytes <= 0 || $bytes > self::limits()['max_bytes']) { throw new DomainException('录音大小超出允许范围'); } // Bound abandoned staging space per actor, without querying other patients' data. if (Db::name('followup_audio_upload')->where('actor_id', $actor)->where('status', 'uploading') ->where('expires_at', '>', time())->count() >= 10) { throw new DomainException('未完成上传过多,请先完成已有上传或稍后重试'); } $id = bin2hex(random_bytes(24)); $dir = self::directory($id, true); try { Db::name('followup_audio_upload')->insert([ 'id' => $id, 'diagnosis_id' => $diagnosisId, 'actor_id' => $actor, 'file_name' => $name, 'extension' => $extension, 'total_bytes' => $bytes, 'received_bytes' => 0, 'sha256' => '', 'duration_seconds' => 0, 'status' => 'uploading', 'created_at' => time(), 'expires_at' => time() + 86400, ]); } catch (\Throwable $e) { @rmdir($dir . '/parts'); @rmdir($dir); throw $e; } return ['upload_id' => $id, 'chunk_bytes' => self::limits()['chunk_bytes']]; } public static function session(string $id): array { self::validId($id); $row = Db::name('followup_audio_upload')->where('id', $id)->find(); if (!$row) { throw new DomainException('录音上传不存在或已清理'); } return $row; } public static function owned(string $id, int $actor, array $info): array { $upload = self::session($id); if ((int) $upload['actor_id'] !== $actor) { throw new DomainException('录音上传不存在或无权操作'); } FollowupAudioAccess::diagnosis((int) $upload['diagnosis_id'], $actor, $info); if ((int) $upload['expires_at'] <= time() || $upload['status'] === 'deleted') { throw new DomainException('录音上传已过期,请重新上传'); } return $upload; } public static function putChunk(string $id, int $index, string $source, int $actor, array $info): array { self::owned($id, $actor, $info); return self::locked($id, static function () use ($id, $index, $source, $actor, $info): array { $row = self::owned($id, $actor, $info); if ($row['status'] !== 'uploading' || !is_file($source) || is_link($source)) { throw new DomainException('当前录音不能继续上传'); } $chunk = self::limits()['chunk_bytes']; $count = (int) ceil((int) $row['total_bytes'] / $chunk); if ($index < 0 || $index >= $count) { throw new DomainException('录音分片序号无效'); } $expected = min($chunk, (int) $row['total_bytes'] - $index * $chunk); if (filesize($source) !== $expected) { throw new DomainException('录音分片大小不符,请重新上传'); } $part = self::directory($id) . '/parts/' . $index; if (is_link($part)) { throw new DomainException('录音存储路径无效'); } if (is_file($part)) { if (!hash_equals((string) hash_file('sha256', $part), (string) hash_file('sha256', $source))) { throw new DomainException('同一分片内容不一致,请重新上传'); } return ['index' => $index, 'received' => true]; } $tmp = $part . '.' . bin2hex(random_bytes(8)) . '.tmp'; if (!copy($source, $tmp)) { throw new DomainException('录音分片保存失败'); } chmod($tmp, 0600); if (!rename($tmp, $part)) { @unlink($tmp); throw new DomainException('录音分片保存失败'); } $received = 0; for ($i = 0; $i < $count; $i++) { $p = self::directory($id) . '/parts/' . $i; if (is_file($p) && !is_link($p)) { $received += (int) filesize($p); } } Db::name('followup_audio_upload')->where('id', $id)->where('status', 'uploading') ->update(['received_bytes' => $received]); return ['index' => $index, 'received' => true]; }); } public static function complete(string $id, int $actor, array $info): array { self::owned($id, $actor, $info); return self::locked($id, static function () use ($id, $actor, $info): array { $row = self::owned($id, $actor, $info); if ($row['status'] === 'complete') { self::path($row); return self::completion($row); } if ($row['status'] !== 'uploading') { throw new DomainException('当前录音无法完成上传'); } $dir = self::directory($id); $tmp = $dir . '/assembling.' . bin2hex(random_bytes(8)); $out = fopen($tmp, 'xb'); if ($out === false) { throw new DomainException('录音合并失败'); } chmod($tmp, 0600); try { $size = (int) $row['total_bytes']; $chunk = self::limits()['chunk_bytes']; for ($i = 0; $i < (int) ceil($size / $chunk); $i++) { $part = $dir . '/parts/' . $i; if (!is_file($part) || is_link($part) || filesize($part) !== min($chunk, $size - $i * $chunk)) { throw new DomainException('录音分片不完整,请继续上传'); } $in = fopen($part, 'rb'); if ($in === false) { throw new DomainException('录音分片不可读取'); } try { if (stream_copy_to_stream($in, $out) !== filesize($part)) { throw new DomainException('录音合并失败'); } } finally { fclose($in); } } } catch (\Throwable $e) { fclose($out); @unlink($tmp); throw $e; } fclose($out); try { $media = self::inspect($tmp, (string) $row['extension']); $row['sha256'] = hash_file('sha256', $tmp); $row['duration_seconds'] = $media['duration_seconds']; $row['expires_at'] = (int) $row['created_at'] + max(1, (int) config('followup_audio.retention_days', 90)) * 86400; $row['status'] = 'complete'; if (!rename($tmp, $dir . '/audio.' . $row['extension'])) { throw new DomainException('录音保存失败'); } Db::name('followup_audio_upload')->where('id', $id)->where('status', 'uploading')->update([ 'status' => 'complete', 'sha256' => $row['sha256'], 'duration_seconds' => $row['duration_seconds'], 'received_bytes' => (int) $row['total_bytes'], 'expires_at' => $row['expires_at'], ]); foreach (glob($dir . '/parts/*') ?: [] as $part) { if (is_file($part) && !is_link($part)) { unlink($part); } } return self::completion($row); } finally { if (is_file($tmp)) { unlink($tmp); } } }); } /** Bounded metadata process; never interpolate file names into a shell command. */ public static function inspect(string $path, string $extension): array { if (!is_file($path) || is_link($path) || !in_array($extension, self::EXTENSIONS, true)) { throw new DomainException('录音文件无效'); } $pipes = []; $arguments = [(string) config('followup_audio.ffprobe', 'ffprobe'), '-v', 'error', '-protocol_whitelist', 'file,pipe']; if ($extension === 'amr') { $arguments[] = '-count_packets'; } $process = proc_open(array_merge($arguments, ['-show_entries', 'format=duration,format_name:stream=codec_type,codec_name,nb_read_packets', '-of', 'json', $path]), [0 => ['pipe', 'r'], 1 => ['pipe', 'w'], 2 => ['pipe', 'w']], $pipes); if (!is_resource($process)) { throw new DomainException('录音检测工具不可用,请联系管理员'); } fclose($pipes[0]); stream_set_blocking($pipes[1], false); stream_set_blocking($pipes[2], false); $stdout = ''; $deadline = microtime(true) + 15; $exit = -1; do { $stdout .= stream_get_contents($pipes[1], 65536); stream_get_contents($pipes[2], 65536); // Decoder diagnostics may include filenames; never expose them. $status = proc_get_status($process); if (!$status['running']) { $exit = $status['exitcode']; $stdout .= stream_get_contents($pipes[1], 65536); break; } if (strlen($stdout) > 1048576 || microtime(true) > $deadline) { proc_terminate($process, 9); break; } usleep(10000); } while (true); fclose($pipes[1]); fclose($pipes[2]); proc_close($process); $data = json_decode($stdout, true); $seconds = (float) ($data['format']['duration'] ?? 0); $containers = explode(',', (string) ($data['format']['format_name'] ?? '')); $expected = ['mp3' => 'mp3', 'wav' => 'wav', 'm4a' => 'm4a', 'amr' => 'amr'][$extension]; $streams = $data['streams'] ?? []; if ($extension === 'amr' && count($streams) === 1 && (int) ($streams[0]['nb_read_packets'] ?? 0) > 0) { $seconds = (int) $streams[0]['nb_read_packets'] * 0.02; } $audio = array_filter($streams, static fn ($s): bool => ($s['codec_type'] ?? '') === 'audio'); $video = array_filter($streams, static fn ($s): bool => ($s['codec_type'] ?? '') === 'video'); if ($exit !== 0 || !$audio || $video || !in_array($expected, $containers, true) || !is_finite($seconds) || $seconds <= 0 || $seconds > self::limits()['max_seconds'] + 0.1) { throw new DomainException('录音格式、内容或时长不符合要求(最长一小时)'); } return ['duration_seconds' => round($seconds, 3), 'format' => $extension]; } public static function path(array $upload): string { if (($upload['status'] ?? '') !== 'complete' || (int) ($upload['expires_at'] ?? 0) <= time() || !in_array($upload['extension'] ?? '', self::EXTENSIONS, true)) { throw new DomainException('原始录音已过期或不可用'); } $path = self::directory((string) $upload['id']) . '/audio.' . $upload['extension']; if (!is_file($path) || is_link($path) || filesize($path) !== (int) $upload['total_bytes']) { throw new DomainException('原始录音不可用'); } return $path; } public static function cleanup(string $id): void { $row = self::session($id); if ((int) $row['expires_at'] > time()) { throw new DomainException('录音尚未到清理时间'); } if ($row['status'] === 'deleted') { return; } // A previous filesystem deletion may succeed just before its DB transaction fails. Reconcile idempotently. if (!is_dir(self::directory($id, false, true))) { Db::name('followup_audio_upload')->where('id', $id)->update(['status' => 'deleted']); return; } self::locked($id, static function () use ($id): void { $dir = self::directory($id); $files = new \RecursiveIteratorIterator(new \RecursiveDirectoryIterator($dir, \FilesystemIterator::SKIP_DOTS), \RecursiveIteratorIterator::CHILD_FIRST); foreach ($files as $file) { if ($file->isLink() || $file->isFile()) { unlink($file->getPathname()); } elseif ($file->isDir()) { rmdir($file->getPathname()); } } Db::name('followup_audio_upload')->where('id', $id)->update(['status' => 'deleted']); }, true); } private static function completion(array $row): array { return ['upload_id' => $row['id'], 'duration_seconds' => (float) $row['duration_seconds'], 'sha256' => $row['sha256']]; } private static function validId(string $id): void { if (!preg_match('/^[a-f0-9]{48}$/D', $id)) { throw new DomainException('录音上传标识无效'); } } private static function directory(string $id, bool $create = false, bool $allowMissing = false): string { self::validId($id); $root = rtrim((string) config('followup_audio.private_dir', runtime_path() . 'private/followup_audio'), '/'); if ($root === '' || $root[0] !== '/' || is_link($root)) { throw new DomainException('私有录音存储配置无效'); } if ($create && !is_dir($root) && !mkdir($root, 0700, true) && !is_dir($root)) { throw new DomainException('私有录音存储不可用'); } $realRoot = realpath($root); $public = realpath(dirname(__DIR__, 4) . '/public'); if ($realRoot === false || ($public && ($realRoot === $public || str_starts_with($realRoot, $public . '/')))) { throw new DomainException('录音必须保存在私有目录'); } $dir = $realRoot . '/' . $id; if ($create && !is_dir($dir) && !mkdir($dir . '/parts', 0700, true)) { throw new DomainException('录音上传目录创建失败'); } if (is_link($dir) || (!$allowMissing && !is_dir($dir)) || is_link($dir . '/parts')) { throw new DomainException('录音上传目录不可用'); } return $dir; } private static function locked(string $id, callable $handler, bool $remove = false) { $dir = self::directory($id); if (is_link($dir . '/.lock')) { throw new DomainException('录音存储锁无效'); } $lock = fopen($dir . '/.lock', 'c'); if (!$lock || !flock($lock, LOCK_EX)) { throw new DomainException('录音正在处理中,请稍后重试'); } try { return $handler(); } finally { flock($lock, LOCK_UN); fclose($lock); if ($remove) { @unlink($dir . '/.lock'); @rmdir($dir); } } } }