Files
zyt/server/app/adminapi/logic/tcm/MobileRecordingMediaLogic.php
2026-11-09 14:51:31 +08:00

368 lines
21 KiB
PHP

<?php
declare(strict_types=1);
namespace app\adminapi\logic\tcm;
use app\common\service\mobilerecording\MobileRecordingStorage as Storage;
use app\common\service\mobilerecording\MobileRecordingStream;
use app\common\service\mobilerecording\MobileRecordingResyncException;
use app\common\service\mobilerecording\MobileRecordingLocalUnavailableException;
use app\common\service\mobilerecording\MobileRecordingObjects as Objects;
use DomainException;
use think\facade\Db;
/** The binding is the authorization anchor; upload IDs are not bearer credentials. */
final class MobileRecordingMediaLogic
{
public const CHUNK_SIZE = 1048576;
public const AUDIO_MAX = 536870912;
public const TEXT_MAX = 8388608;
private const MIMES = ['audio/wav', 'audio/flac', 'audio/ogg', 'audio/mpeg', 'audio/mp4', 'audio/amr'];
public static function prepare(array $p, int $actor): array
{
MobileRecordingLogic::only($p, ['installation_id', 'recording_key', 'diagnosis_id', 'kind', 'size_bytes', 'sha256', 'media_type']);
$identity = MobileRecordingLogic::identity($p);
$diagnosis = self::diagnosisId($p);
$kind = $p['kind'] ?? '';
if (!in_array($kind, ['audio', 'transcript'], true)) { throw new DomainException('上传类型无效'); }
$size = MobileRecordingLogic::integer($p['size_bytes'] ?? null, $kind === 'audio' ? 1 : 0,
$kind === 'audio' ? self::AUDIO_MAX : self::TEXT_MAX, '文件大小');
$sha = self::hash($p['sha256'] ?? null);
$mime = $p['media_type'] ?? '';
if (!is_string($mime) || ($kind === 'audio' ? !in_array($mime, self::MIMES, true) : $mime !== 'text/plain')) {
throw new DomainException('文件媒体类型无效');
}
return self::mutate(static function (array &$garbage) use ($identity, $diagnosis, $kind, $size, $sha, $mime, $actor): array {
$binding = self::owned($identity, $diagnosis, $actor);
$id = (int) $binding['id'];
$existing = Db::name('mobile_recording_upload')->where('binding_id', $id)->where('kind', $kind)->where('sha256', $sha)->find();
if ($existing && ((int) $existing['size_bytes'] !== $size || $existing['media_type'] !== $mime)) {
throw new DomainException('相同摘要的文件信息不一致');
}
// Only one unfinished version per kind; the published version stays readable until replacement commits.
$obsolete = Db::name('mobile_recording_upload')->where('binding_id', $id)->where('kind', $kind)
->where('state', 'uploading')->where('sha256', '<>', $sha)->column('upload_id');
self::deleteUploads($obsolete, $garbage);
if ($existing) {
if ($existing['state'] === 'complete') {
try { Objects::verifyAvailable($existing); }
catch (MobileRecordingLocalUnavailableException $e) {
Db::name('mobile_recording_asset')->where('upload_id', $existing['upload_id'])->delete();
$existing['state'] = 'uploading'; $existing['received_bytes'] = 0;
Db::name('mobile_recording_upload')->where('upload_id', $existing['upload_id'])
->update(['state' => 'uploading', 'received_bytes' => 0, 'updated_at' => time()]);
}
}
return self::dto(self::synchronize($existing));
}
$row = ['upload_id' => bin2hex(random_bytes(16)), 'binding_id' => $id, 'diagnosis_id' => $diagnosis,
'kind' => $kind, 'sha256' => $sha, 'media_type' => $mime, 'size_bytes' => $size,
'received_bytes' => 0, 'state' => 'uploading', 'created_at' => time(), 'updated_at' => time()];
Db::name('mobile_recording_upload')->insert($row);
Storage::locked($row['upload_id'], static fn ($file): int => Storage::reconcile($file, 0));
return self::dto($row);
});
}
public static function chunk(array $p, string $bytes, int $actor): array
{
MobileRecordingLogic::only($p, ['upload_id', 'diagnosis_id', 'offset', 'chunk_sha256']);
$uploadId = self::uploadId($p['upload_id'] ?? null);
$diagnosis = self::diagnosisId($p);
$offset = MobileRecordingLogic::integer($p['offset'] ?? null, 0, self::AUDIO_MAX, '分片偏移');
$sha = self::hash($p['chunk_sha256'] ?? null);
$length = strlen($bytes);
if ($length < 1 || $length > self::CHUNK_SIZE) { throw new DomainException('分片大小必须为 1 至 1048576 字节'); }
if (!hash_equals($sha, hash('sha256', $bytes))) { throw new DomainException('分片摘要校验失败'); }
return self::mutate(static function (array &$garbage) use ($uploadId, $diagnosis, $offset, $bytes, $length, $actor): array {
$row = self::session($uploadId, $diagnosis, $actor);
if ($row['state'] === 'complete') {
if ($offset + $length > (int) $row['size_bytes']) { throw new DomainException('分片超出声明的文件大小'); }
$source = Objects::source($row);
try { $existing = is_callable($source) ? $source($offset, $length) : Storage::bytes($source, $offset, $length); }
finally { if (is_resource($source)) { fclose($source); } }
if (!hash_equals($bytes, $existing)) { throw new MobileRecordingResyncException('分片与已接收内容冲突,请查询上传状态'); }
return self::dto($row);
}
return Storage::locked($uploadId, static function ($file) use ($row, $offset, $bytes, $length): array {
$received = Storage::reconcile($file, (int) $row['received_bytes']);
if ($offset + $length > (int) $row['size_bytes']) { throw new DomainException('分片超出声明的文件大小'); }
if ($offset < $received && $offset + $length <= $received) {
if (!hash_equals($bytes, Storage::bytes($file, $offset, $length))) {
throw new MobileRecordingResyncException('分片与已接收内容冲突,请查询上传状态');
}
} elseif ($offset === $received && $row['state'] === 'uploading') {
Storage::write($file, $offset, $bytes);
$received += $length;
} else { throw new MobileRecordingResyncException('分片偏移不连续,请查询上传状态'); }
Db::name('mobile_recording_upload')->where('upload_id', $row['upload_id'])
->update(['received_bytes' => $received, 'updated_at' => time()]);
return self::dto(array_replace($row, ['received_bytes' => $received]));
});
});
}
public static function complete(array $p, int $actor): array
{
MobileRecordingLogic::only($p, ['upload_id', 'diagnosis_id']);
$uploadId = self::uploadId($p['upload_id'] ?? null);
$diagnosis = self::diagnosisId($p);
// Persist the object location and encryption key before the first COS call.
$prepared = self::mutate(static function (array &$garbage) use ($uploadId, $diagnosis, $actor): array {
$row = self::session($uploadId, $diagnosis, $actor);
if ($row['state'] === 'complete') { return self::dto($row); }
if (Objects::ready($row)) { return ['prepared' => true]; }
$row = self::synchronize($row);
if ((int) $row['received_bytes'] !== (int) $row['size_bytes']) { throw new MobileRecordingResyncException('文件尚未上传完整'); }
$error = Storage::locked($uploadId, static function ($file) use ($row): ?string {
try { Storage::validate($file, $row); return null; }
catch (\DomainException $e) {
if (!ftruncate($file, 0) || !fflush($file)) { throw new \RuntimeException('Upload reset failed'); }
return $e->getMessage();
}
});
if ($error !== null) {
Db::name('mobile_recording_upload')->where('upload_id', $uploadId)->update(['received_bytes' => 0, 'updated_at' => time()]);
return ['validation_error' => $error];
}
Objects::ensure($row);
return ['prepared' => true];
});
if (!isset($prepared['prepared'])) {
if (($prepared['state'] ?? '') === 'complete') { self::tryCleanupStaging($uploadId); }
return $prepared;
}
Objects::advance($uploadId, static fn (): array => self::session($uploadId, $diagnosis, $actor));
$result = self::mutate(static function (array &$garbage) use ($uploadId, $diagnosis, $actor): array {
$row = self::session($uploadId, $diagnosis, $actor);
if (!Objects::ready($row)) { throw new \RuntimeException('Private cloud confirmation pending'); }
if ($row['state'] !== 'complete') { self::publish($row, $garbage); }
return self::dto(array_replace($row, ['state' => 'complete', 'received_bytes' => (int) $row['size_bytes']]));
});
self::tryCleanupStaging($uploadId);
return $result;
}
private static function tryCleanupStaging(string $id): void
{
// Publication is already durable; failure must not unpublish it. The worker retries this task.
try { Objects::cleanupStaging($id); } catch (\Throwable $ignored) {}
}
private static function publish(array $row, array &$garbage): void
{
$uploadId = $row['upload_id'];
Db::name('mobile_recording_upload')->where('upload_id', $uploadId)
->update(['state' => 'complete', 'received_bytes' => $row['size_bytes'], 'updated_at' => time()]);
$asset = ['binding_id' => $row['binding_id'], 'kind' => $row['kind'], 'upload_id' => $uploadId, 'updated_at' => time()];
$query = Db::name('mobile_recording_asset')->where('binding_id', $row['binding_id'])->where('kind', $row['kind']);
if ((clone $query)->find()) { $query->update($asset); } else { Db::name('mobile_recording_asset')->insert($asset); }
$oldIds = Db::name('mobile_recording_upload')->where('binding_id', $row['binding_id'])->where('kind', $row['kind'])
->where('upload_id', '<>', $uploadId)->column('upload_id');
self::deleteUploads($oldIds, $garbage);
}
/** Privileged maintenance only, never routed by the HTTP controller. */
public static function migrateLocal(string $id): void
{
$guard = static function () use ($id): array {
$peek = Db::name('mobile_recording_upload')->where('upload_id', $id)->find();
$binding = $peek ? Db::name('mobile_recording_binding')->where('id', $peek['binding_id'])->lock(true)->find() : null;
$row = Db::name('mobile_recording_upload')->where('upload_id', $id)->where('state', 'complete')->lock(true)->find();
if (!$binding || !$row) { throw new \DomainException('Legacy media reference no longer active'); }
return $row;
};
Db::transaction(static function () use ($id, $guard): void {
$row = $guard();
if (Objects::ready($row)) { return; }
Storage::locked($id, static fn ($file) => Storage::validate($file, $row));
Objects::ensure($row);
});
Objects::advance($id, $guard);
Objects::cleanupStaging($id);
}
public static function status(array $p, int $actor): array
{
MobileRecordingLogic::only($p, ['installation_id', 'recording_key', 'diagnosis_id']);
$identity = MobileRecordingLogic::identity($p);
$diagnosis = self::diagnosisId($p);
return self::mutate(static function (array &$garbage) use ($identity, $diagnosis, $actor): array {
$binding = self::owned($identity, $diagnosis, $actor);
$result = [];
foreach (['audio', 'transcript'] as $kind) {
// A pending replacement is the mobile recovery target, even while the old asset remains published.
$rows = Db::name('mobile_recording_upload')->where('binding_id', $binding['id'])->where('kind', $kind)
->order('state', 'desc')->select()->toArray();
$row = $rows ? self::synchronize($rows[0]) : null;
$result[$kind] = $row ? self::assetState($row) : ['state' => 'missing', 'upload_id' => '', 'size_bytes' => 0,
'received_bytes' => 0, 'sha256' => '', 'media_type' => ''];
}
return $result;
});
}
public static function audio(array $p, int $actor, string $range = ''): MobileRecordingStream
{
return self::read($p, $actor, 'audio', static function (array $row) use ($range): MobileRecordingStream {
return new MobileRecordingStream(Objects::source($row), (int) $row['size_bytes'], $row['media_type'], $range);
});
}
public static function transcript(array $p, int $actor): array
{
return self::read($p, $actor, 'transcript', static function (array $row): array {
$source = Objects::source($row);
try { $text = is_callable($source) ? $source(0, (int) $row['size_bytes']) : Storage::bytes($source, 0, (int) $row['size_bytes']); }
finally { if (is_resource($source)) { fclose($source); } }
if (!hash_equals($row['sha256'], hash('sha256', $text))) { throw new \RuntimeException('Transcript integrity mismatch'); }
return ['text' => $text, 'sha256' => $row['sha256'], 'size_bytes' => (int) $row['size_bytes']];
});
}
private static function read(array $p, int $actor, string $kind, callable $read)
{
MobileRecordingLogic::only($p, ['binding_id', 'diagnosis_id']);
$id = MobileRecordingLogic::integer($p['binding_id'] ?? null, 1, PHP_INT_MAX, '关联标识');
$anchor = isset($p['diagnosis_id']) ? self::diagnosisId($p) : null;
return Db::transaction(static function () use ($id, $anchor, $actor, $kind, $read) {
$info = MobileRecordingLogic::actor($actor, true);
$binding = Db::name('mobile_recording_binding')->where('id', $id)->lock(true)->find();
if (!$binding || ($anchor !== null && (int) $binding['diagnosis_id'] !== $anchor)) {
throw new DomainException('录音关联已变更或不可访问');
}
MobileRecordingLogic::diagnosis((int) $binding['diagnosis_id'], $actor, $info, true);
$asset = Db::name('mobile_recording_asset')->where('binding_id', $id)->where('kind', $kind)->find();
$row = $asset ? Db::name('mobile_recording_upload')->where('upload_id', $asset['upload_id'])
->where('binding_id', $id)->where('kind', $kind)->where('state', 'complete')->find() : null;
if (!$row) { throw new DomainException('此录音尚未上传对应内容'); }
return $read($row);
});
}
/** Called only after the binding's actor/diagnoses are locked and authorized. */
public static function bindingMoved(int $id, int $diagnosisId): array
{
$garbage = Db::name('mobile_recording_upload')->where('binding_id', $id)->where('state', 'uploading')->column('upload_id');
Objects::queueDelete($garbage);
if ($garbage) { Db::name('mobile_recording_upload')->whereIn('upload_id', $garbage)->delete(); }
Db::name('mobile_recording_upload')->where('binding_id', $id)->where('state', 'complete')
->update(['diagnosis_id' => $diagnosisId, 'updated_at' => time()]);
return $garbage;
}
/** Remove authorization references in the same transaction as the binding. */
public static function bindingRemoved(int $id): array
{
$garbage = Db::name('mobile_recording_upload')->where('binding_id', $id)->column('upload_id');
Objects::queueDelete($garbage);
Db::name('mobile_recording_asset')->where('binding_id', $id)->delete();
Db::name('mobile_recording_upload')->where('binding_id', $id)->delete();
return $garbage;
}
public static function summaries(array $ids): array
{
if (!$ids) { return []; }
$result = [];
foreach ($ids as $id) { $result[$id] = ['audio_available' => false, 'transcript_available' => false, 'upload_state' => 'not_uploaded']; }
$published = Db::name('mobile_recording_asset')->whereIn('binding_id', $ids)->select()->toArray();
foreach ($published as $asset) { $result[$asset['binding_id']][$asset['kind'] . '_available'] = true; }
$pending = Db::name('mobile_recording_upload')->whereIn('binding_id', $ids)->where('state', 'uploading')->column('binding_id');
foreach ($result as $id => &$summary) {
$complete = (int) $summary['audio_available'] + (int) $summary['transcript_available'];
$summary['upload_state'] = in_array($id, array_map('intval', $pending), true) ? 'uploading'
: ($complete === 2 ? 'complete' : ($complete === 1 ? 'partial' : 'not_uploaded'));
}
unset($summary);
return $result;
}
private static function owned(array $identity, int $diagnosis, int $actor): array
{
$info = MobileRecordingLogic::actor($actor, true);
$binding = Db::name('mobile_recording_binding')->where('admin_id', $actor)
->where('installation_id', $identity['installation_id'])->where('recording_key', $identity['recording_key'])->lock(true)->find();
return self::authorizeBinding($binding, $diagnosis, $actor, $info);
}
private static function session(string $id, int $diagnosis, int $actor): array
{
$info = MobileRecordingLogic::actor($actor, true);
$lookup = Db::name('mobile_recording_upload')->where('upload_id', $id)->find();
$binding = $lookup ? Db::name('mobile_recording_binding')->where('id', $lookup['binding_id'])->where('admin_id', $actor)->lock(true)->find() : null;
self::authorizeBinding($binding, $diagnosis, $actor, $info);
$row = Db::name('mobile_recording_upload')->where('upload_id', $id)->lock(true)->find();
if (!$row || (int) $row['diagnosis_id'] !== $diagnosis) { throw new DomainException('上传会话已失效,请重新准备上传'); }
return $row;
}
private static function authorizeBinding(?array $binding, int $diagnosis, int $actor, array $info): array
{
if (!$binding || (int) $binding['diagnosis_id'] !== $diagnosis) { throw new DomainException('录音关联已变更或不可访问'); }
MobileRecordingLogic::diagnosis($diagnosis, $actor, $info, true);
return $binding;
}
private static function synchronize(array $row): array
{
if ($row['state'] === 'uploading' && Objects::ready($row)) { return $row; }
if ($row['state'] === 'uploading') {
$received = Storage::locked($row['upload_id'], static fn ($file): int => Storage::reconcile($file, (int) $row['received_bytes']));
if ($received !== (int) $row['received_bytes']) {
Db::name('mobile_recording_upload')->where('upload_id', $row['upload_id'])->update(['received_bytes' => $received, 'updated_at' => time()]);
$row['received_bytes'] = $received;
}
}
return $row;
}
private static function mutate(callable $operation): array
{
$garbage = [];
$result = Db::transaction(static function () use ($operation, &$garbage): array { return $operation($garbage); });
Storage::cleanup($garbage);
if (isset($result['validation_error'])) { throw new DomainException($result['validation_error']); }
return $result;
}
private static function deleteUploads(array $ids, array &$garbage): void
{
if (!$ids) { return; }
Objects::queueDelete($ids);
Db::name('mobile_recording_upload')->whereIn('upload_id', $ids)->delete();
$garbage = array_merge($garbage, $ids);
}
private static function diagnosisId(array $p): int
{
return MobileRecordingLogic::integer($p['diagnosis_id'] ?? null, 1, 4294967295, '诊单标识');
}
private static function uploadId($id): string
{
if (!is_string($id) || !preg_match('/^[a-f0-9]{32}$/D', $id)) { throw new DomainException('上传标识无效'); }
return $id;
}
private static function hash($hash): string
{
if (!is_string($hash) || !preg_match('/^[a-f0-9]{64}$/D', $hash)) { throw new DomainException('文件摘要无效'); }
return $hash;
}
private static function dto(array $row): array
{
return ['upload_id' => $row['upload_id'], 'received_bytes' => (int) $row['received_bytes'],
'size_bytes' => (int) $row['size_bytes'], 'state' => $row['state'], 'chunk_size' => self::CHUNK_SIZE];
}
private static function assetState(array $row): array
{
return ['state' => $row['state'], 'upload_id' => $row['upload_id'], 'size_bytes' => (int) $row['size_bytes'],
'received_bytes' => (int) $row['received_bytes'], 'sha256' => $row['sha256'], 'media_type' => $row['media_type']];
}
}