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']]; } }