Files
zyt/server/app/common/service/followupaudio/FollowupAudioUpload.php
T

364 lines
16 KiB
PHP

<?php
declare(strict_types=1);
namespace app\common\service\followupaudio;
use DomainException;
use think\facade\Db;
/** Private, actor-bound, bounded chunk uploads. Never creates a simulated doctor call. */
final class FollowupAudioUpload
{
private const EXTENSIONS = ['mp3', 'm4a', 'wav', 'amr'];
public static function limits(): array
{
return [
'max_bytes' => 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);
}
}
}
}