diff --git a/server/app/adminapi/logic/tcm/MobileRecordingMediaLogic.php b/server/app/adminapi/logic/tcm/MobileRecordingMediaLogic.php index e2318a4a9..76477b712 100644 --- a/server/app/adminapi/logic/tcm/MobileRecordingMediaLogic.php +++ b/server/app/adminapi/logic/tcm/MobileRecordingMediaLogic.php @@ -7,6 +7,8 @@ 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; @@ -45,8 +47,8 @@ final class MobileRecordingMediaLogic self::deleteUploads($obsolete, $garbage); if ($existing) { if ($existing['state'] === 'complete') { - try { $file = Storage::open($existing); fclose($file); } - catch (DomainException $e) { + 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']) @@ -76,6 +78,14 @@ final class MobileRecordingMediaLogic 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('分片超出声明的文件大小'); } @@ -99,14 +109,16 @@ final class MobileRecordingMediaLogic MobileRecordingLogic::only($p, ['upload_id', 'diagnosis_id']); $uploadId = self::uploadId($p['upload_id'] ?? null); $diagnosis = self::diagnosisId($p); - return self::mutate(static function (array &$garbage) use ($uploadId, $diagnosis, $actor): array { - $row = self::synchronize(self::session($uploadId, $diagnosis, $actor)); + // 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) { - // Persist a reset before returning a validation error, so same-hash retry is recoverable. + catch (\DomainException $e) { if (!ftruncate($file, 0) || !fflush($file)) { throw new \RuntimeException('Upload reset failed'); } return $e->getMessage(); } @@ -115,15 +127,61 @@ final class MobileRecordingMediaLogic Db::name('mobile_recording_upload')->where('upload_id', $uploadId)->update(['received_bytes' => 0, 'updated_at' => time()]); return ['validation_error' => $error]; } - Db::name('mobile_recording_upload')->where('upload_id', $uploadId)->update(['state' => 'complete', '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); - return self::dto(array_replace($row, ['state' => 'complete'])); + 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 @@ -149,16 +207,17 @@ final class MobileRecordingMediaLogic 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(Storage::open($row), (int) $row['size_bytes'], $row['media_type'], $range); + 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 { - $file = Storage::open($row); - try { $text = Storage::bytes($file, 0, (int) $row['size_bytes']); } - finally { fclose($file); } + $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']]; }); } @@ -187,6 +246,7 @@ final class MobileRecordingMediaLogic 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()]); @@ -197,6 +257,7 @@ final class MobileRecordingMediaLogic 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; @@ -247,6 +308,7 @@ final class MobileRecordingMediaLogic 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']) { @@ -269,6 +331,7 @@ final class MobileRecordingMediaLogic 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); } diff --git a/server/app/command/MobileRecordingCosMigrate.php b/server/app/command/MobileRecordingCosMigrate.php new file mode 100644 index 000000000..0a8ca07fc --- /dev/null +++ b/server/app/command/MobileRecordingCosMigrate.php @@ -0,0 +1,45 @@ +setName('mobile-recording:cos-migrate')->setDescription('分批迁移历史本地完整录音/文字至COS') + ->addOption('dry-run', null, Option::VALUE_NONE, '只统计,不读取内容、不调用COS') + ->addOption('limit', null, Option::VALUE_REQUIRED, '每批1至100条', '20') + ->addOption('after', null, Option::VALUE_REQUIRED, '上一批输出的32位不透明cursor', ''); + } + protected function execute(Input $input, Output $output): int + { + try { + $after = (string) $input->getOption('after'); + if ($after !== '' && !preg_match('/^[a-f0-9]{32}$/D', $after)) { throw new \InvalidArgumentException('Invalid cursor'); } + $query = Db::name('mobile_recording_upload')->alias('u') + ->leftJoin([Db::name('mobile_recording_object')->getTable() => 'o'], 'o.upload_id=u.upload_id') + ->where('u.state', 'complete')->whereRaw('(o.upload_id IS NULL OR o.state IN (\'pending\',\'finalizing\',\'verifying\'))'); + if ($after !== '') { $query->where('u.upload_id', '>', $after); } + $ids = $query->order('u.upload_id')->limit(max(1, min(100, (int) $input->getOption('limit'))))->column('u.upload_id'); + $counts = ['selected' => count($ids), 'migrated' => 0, 'deferred' => 0, 'next_cursor' => $ids ? end($ids) : '']; + if (!$input->getOption('dry-run')) { + foreach ($ids as $id) { + try { MobileRecordingMediaLogic::migrateLocal($id); $counts['migrated']++; } + catch (\Throwable $e) { $counts['deferred']++; } + } + } + $output->writeln('MOBILE_RECORDING_COS_MIGRATE ' . json_encode($counts)); + return 0; + } catch (\Throwable $e) { $output->writeln('MOBILE_RECORDING_COS_MIGRATE storage_or_configuration_error'); return 1; } + } +} diff --git a/server/app/command/MobileRecordingCosWork.php b/server/app/command/MobileRecordingCosWork.php new file mode 100644 index 000000000..252faa9bd --- /dev/null +++ b/server/app/command/MobileRecordingCosWork.php @@ -0,0 +1,72 @@ +setName('mobile-recording:cos-work')->setDescription('推进手机录音COS提交及私有对象删除重试') + ->addOption('dry-run', null, Option::VALUE_NONE, '只统计,不调用COS') + ->addOption('limit', null, Option::VALUE_REQUIRED, '最多处理的上传数,1至100', '5') + ->addOption('seconds', null, Option::VALUE_REQUIRED, '上传调度时间预算,1至300秒;单个COS请求仍有10秒超时', '20'); + } + protected function execute(Input $input, Output $output): int + { + try { + $limit = max(1, min(100, (int) $input->getOption('limit'))); + $seconds = max(1, min(300, (int) $input->getOption('seconds'))); + $dry = (bool) $input->getOption('dry-run'); + $deadline = microtime(true) + $seconds; + $counts = MobileRecordingObjects::cleanup(1, $dry); + $counts += ['uploads_selected' => 0, 'uploads_completed' => 0, 'uploads_deferred' => 0, + 'uploads_denied' => 0, 'staging_cleaned' => 0]; + $rows = Db::name('mobile_recording_upload')->alias('u') + ->join([Db::name('mobile_recording_binding')->getTable() => 'b'], 'b.id=u.binding_id') + ->leftJoin([Db::name('mobile_recording_object')->getTable() => 'o'], 'o.upload_id=u.upload_id') + ->leftJoin([Db::name('mobile_recording_asset')->getTable() => 'a'], 'a.upload_id=u.upload_id AND a.binding_id=u.binding_id AND a.kind=u.kind') + ->leftJoin([Db::name('mobile_recording_work')->getTable() => 'w'], 'w.upload_id=u.upload_id') + ->whereRaw("((u.state='uploading' AND u.received_bytes=u.size_bytes) OR (u.state='complete' AND o.state IN ('pending','finalizing','verifying')) OR (u.state='complete' AND o.state='ready' AND a.upload_id IS NOT NULL AND COALESCE(w.staging_cleaned,0)=0))") + ->whereRaw('COALESCE(w.next_retry_at,0) <= ' . time()) + ->field('u.upload_id,u.state,o.state AS object_state,b.admin_id,b.diagnosis_id') + ->orderRaw('COALESCE(w.last_attempt_at,0),u.updated_at,u.upload_id')->limit($limit)->select()->toArray(); + $counts['uploads_selected'] = count($rows); + if (!$dry) { + foreach ($rows as $row) { + if (microtime(true) >= $deadline) { break; } + try { + if ($row['state'] === 'complete' && $row['object_state'] === 'ready') { + if (!MobileRecordingObjects::cleanupStaging($row['upload_id'], true)) { throw new \DomainException('Media publication no longer active'); } + $counts['staging_cleaned']++; + } elseif ($row['state'] === 'complete') { MobileRecordingMediaLogic::migrateLocal($row['upload_id']); $counts['uploads_completed']++; } + else { MobileRecordingMediaLogic::complete(['upload_id' => $row['upload_id'], 'diagnosis_id' => (int) $row['diagnosis_id']], (int) $row['admin_id']); } + if ($row['state'] === 'uploading') { + // complete may publish successfully while best-effort unlink failed. Keep that task retryable. + if (!MobileRecordingObjects::cleanupStaging($row['upload_id'], true)) { throw new \DomainException('Media publication no longer active'); } + $counts['uploads_completed']++; + } + MobileRecordingWork::attempted($row['upload_id']); + } catch (\Throwable $e) { + MobileRecordingWork::attempted($row['upload_id'], $e); + $counts['uploads_deferred']++; + if ($e instanceof \DomainException) { $counts['uploads_denied']++; } + } + } + } + $output->writeln('MOBILE_RECORDING_COS_WORK ' . json_encode($counts)); + return $counts['errors'] > 0 || $counts['uploads_deferred'] > 0 ? 1 : 0; + } catch (\Throwable $e) { $output->writeln('MOBILE_RECORDING_COS_WORK storage_or_configuration_error'); return 1; } + } +} diff --git a/server/app/common/service/mobilerecording/MobileRecordingCipher.php b/server/app/common/service/mobilerecording/MobileRecordingCipher.php new file mode 100644 index 000000000..dc95ea390 --- /dev/null +++ b/server/app/common/service/mobilerecording/MobileRecordingCipher.php @@ -0,0 +1,57 @@ += self::frames((int) $object['plain_size'])) { throw new \RuntimeException('Invalid media frame'); } + return min(self::FRAME, max(0, (int) $object['plain_size'] - $index * self::FRAME)); + } + private static function aad(array $object, int $index): string + { + // Diagnosis and owner assignments may change. Only immutable content identity is authenticated. + return implode('|', [self::FORMAT, $object['upload_id'], $object['kind'], $object['plain_size'], $object['plain_sha256'], $index]); + } + private static function key(array $object): string + { + if (!preg_match('/^[0-9a-f]{64}$/D', (string) $object['encryption_key'])) { throw new \RuntimeException('Media key unavailable'); } + return hex2bin($object['encryption_key']); + } + private static function nonce(array $object, int $index): string + { + if (!preg_match('/^[0-9a-f]{16}$/D', (string) $object['nonce_prefix'])) { throw new \RuntimeException('Media nonce unavailable'); } + return hex2bin($object['nonce_prefix']) . pack('N', $index); + } + public static function encrypt(array $object, int $index, string $plain): string + { + if (strlen($plain) !== self::plainLength($object, $index)) { throw new \RuntimeException('Invalid media frame length'); } + $tag = ''; + $cipher = openssl_encrypt($plain, 'aes-256-gcm', self::key($object), OPENSSL_RAW_DATA, + self::nonce($object, $index), $tag, self::aad($object, $index), self::TAG); + if ($cipher === false || strlen($tag) !== self::TAG) { throw new \RuntimeException('Media encryption unavailable'); } + return $cipher . $tag; + } + public static function decrypt(array $object, int $index, string $cipher): string + { + if (strlen($cipher) !== self::plainLength($object, $index) + self::TAG) { throw new \RuntimeException('Media frame length mismatch'); } + $plain = openssl_decrypt(substr($cipher, 0, -self::TAG), 'aes-256-gcm', self::key($object), OPENSSL_RAW_DATA, + self::nonce($object, $index), substr($cipher, -self::TAG), self::aad($object, $index)); + if ($plain === false) { throw new \RuntimeException('Media frame authentication failed'); } + return $plain; + } + public static function context(array $object): string + { + return hash_hmac('sha256', self::aad($object, 0), self::key($object)); + } +} diff --git a/server/app/common/service/mobilerecording/MobileRecordingCloudReader.php b/server/app/common/service/mobilerecording/MobileRecordingCloudReader.php new file mode 100644 index 000000000..af57101a0 --- /dev/null +++ b/server/app/common/service/mobilerecording/MobileRecordingCloudReader.php @@ -0,0 +1,37 @@ + (int) $this->object['plain_size']) { throw new \RuntimeException('Media read range invalid'); } + // Empty text still authenticates its one zero-length GCM frame. + if ((int) $this->object['plain_size'] === 0) { $this->frame(0); return ''; } + $result = ''; + while (strlen($result) < $length) { + $at = $offset + strlen($result); + $index = intdiv($at, MobileRecordingCipher::FRAME); + $this->frame($index); + $part = substr($this->plain, $at % MobileRecordingCipher::FRAME, min($length - strlen($result), MobileRecordingCipher::FRAME)); + if ($part === '') { throw new \RuntimeException('Media frame incomplete'); } + $result .= $part; + } + return $result; + } + private function frame(int $index): void + { + if ($this->index === $index) { return; } + $start = $index * (MobileRecordingCipher::FRAME + MobileRecordingCipher::TAG); + $end = $start + MobileRecordingCipher::plainLength($this->object, $index) + MobileRecordingCipher::TAG - 1; + $this->plain = MobileRecordingCipher::decrypt($this->object, $index, $this->cloud->range($this->object, $start, $end)); + $this->index = $index; + } +} diff --git a/server/app/common/service/mobilerecording/MobileRecordingCos.php b/server/app/common/service/mobilerecording/MobileRecordingCos.php new file mode 100644 index 000000000..1568ed5a1 --- /dev/null +++ b/server/app/common/service/mobilerecording/MobileRecordingCos.php @@ -0,0 +1,124 @@ +settings(); + $location = ['bucket' => (string) ($config['bucket'] ?? ''), 'region' => (string) ($config['region'] ?? '')]; + self::validateLocation($location); + return $location; + } + private static function validateLocation(array $object): void + { + if (!preg_match('/^[a-z0-9][a-z0-9-]{1,62}-[0-9]{5,20}$/D', (string) $object['bucket']) + || !preg_match('/^[a-z]{2}-[a-z0-9-]{2,40}$/D', (string) $object['region'])) { + throw new \RuntimeException('Private cloud location invalid'); + } + } + protected function client(array $object): Client + { + self::validateLocation($object); + $config = $this->settings(); + // Installed SDK 2.6.5 requires "schema", not "scheme". Region is the object's durable snapshot. + return new Client(['schema' => 'https', 'region' => $object['region'], 'retry' => 0, + 'timeout' => 10, 'connect_timeout' => 3, 'allow_redirects' => false, + 'credentials' => ['secretId' => $config['access_key'], 'secretKey' => $config['secret_key']]]); + } + protected function invoke(string $method, array $object, array $params = [], bool $missingIsNull = false) + { + if (!preg_match('#^private/mobile-recording/v1/[a-f0-9]{32}\.enc$#D', (string) $object['object_key'])) { + throw new \RuntimeException('Private cloud object invalid'); + } + $args = ['Bucket' => $object['bucket'], 'Key' => $object['object_key'], + '@http' => ['connect_timeout' => 3, 'timeout' => 10, 'read_timeout' => 10, 'allow_redirects' => false]] + $params; + if ($method === 'getObject') { $args['@http']['stream'] = true; } + try { return $this->client($object)->$method($args); } + catch (\Throwable $e) { + $status = method_exists($e, 'getStatusCode') ? (int) $e->getStatusCode() : 0; + if ($missingIsNull && $status === 404) { return null; } + if ($status === 401 || $status === 403) { + // Permanent cloud authorization/configuration errors must not trigger a phone retry storm. + throw new \DomainException('云端存储授权不可用,请联系管理员检查配置'); + } + // Do not retain the provider exception as previous: URLs, signatures and settings stay private. + throw new \RuntimeException('Private cloud temporarily unavailable'); + } + } + public function initiate(array $object): string + { + $result = $this->invoke('createMultipartUpload', $object, ['ACL' => 'private', + 'ContentType' => 'application/octet-stream', 'CacheControl' => 'private, no-store, max-age=0', + 'ContentDisposition' => 'attachment; filename="encrypted-recording"', + 'Metadata' => ['mr-context' => $object['context_hash'], 'mr-format' => MobileRecordingCipher::FORMAT]]); + $id = (string) ($result['UploadId'] ?? ''); + if ($id === '' || strlen($id) > 1024) { throw new \RuntimeException('Private cloud session unavailable'); } + return $id; + } + public function part(array $object, int $number, string $cipher): string + { + $result = $this->invoke('uploadPart', $object, ['UploadId' => $object['multipart_id'], 'PartNumber' => $number, + 'Body' => $cipher, 'Headers' => ['Content-MD5' => base64_encode(md5($cipher, true))]]); + $etag = trim((string) ($result['ETag'] ?? ''), '"'); + if (!hash_equals(md5($cipher), strtolower($etag))) { throw new \RuntimeException('Private cloud part integrity failed'); } + return $etag; + } + public function finish(array $object, array $parts): void + { + $this->invoke('completeMultipartUpload', $object, ['UploadId' => $object['multipart_id'], 'Parts' => $parts]); + } + public function head(array $object, bool $pinned = false): ?array + { + $version = $pinned && !empty($object['version_id']) ? ['VersionId' => $object['version_id']] : []; + $result = $this->invoke('headObject', $object, $version, true); + return $result === null ? null : ['size' => (int) ($result['ContentLength'] ?? -1), + 'context' => (string) ($result['Metadata']['mr-context'] ?? ''), + 'version_id' => (string) ($result['VersionId'] ?? '')]; + } + public function range(array $object, int $start, int $end): string + { + $params = ['Range' => "bytes={$start}-{$end}"]; + if ($object['version_id'] !== '') { $params['VersionId'] = $object['version_id']; } + $result = $this->invoke('getObject', $object, $params); + $body = $result['Body'] ?? null; + try { + $length = $end - $start + 1; + if ((int) ($result['ContentLength'] ?? -1) !== $length + || ($result['ContentRange'] ?? '') !== "bytes {$start}-{$end}/" . $object['cipher_size'] + || !$body instanceof \Psr\Http\Message\StreamInterface) { throw new \RuntimeException('Private cloud range invalid'); } + $bytes = ''; + while (strlen($bytes) < $length) { + $chunk = $body->read(min(65536, $length - strlen($bytes))); + if ($chunk === '') { throw new \RuntimeException('Private cloud range incomplete'); } + $bytes .= $chunk; + } + return $bytes; + } catch (\Throwable $e) { throw new \RuntimeException('Private cloud read unavailable'); } + finally { if ($body instanceof \Psr\Http\Message\StreamInterface) { $body->close(); } } + } + public function delete(array $object): void + { + $params = $object['version_id'] !== '' ? ['VersionId' => $object['version_id']] : []; + $this->invoke('deleteObject', $object, $params, true); + } + public function abort(array $object): void + { + if ($object['multipart_id'] !== '') { $this->invoke('abortMultipartUpload', $object, ['UploadId' => $object['multipart_id']], true); } + } +} diff --git a/server/app/common/service/mobilerecording/MobileRecordingLocalUnavailableException.php b/server/app/common/service/mobilerecording/MobileRecordingLocalUnavailableException.php new file mode 100644 index 000000000..10fea5881 --- /dev/null +++ b/server/app/common/service/mobilerecording/MobileRecordingLocalUnavailableException.php @@ -0,0 +1,8 @@ +where('upload_id', $upload['upload_id'])->lock(true)->find(); + if ($existing) { return $existing; } + $object = self::cloud()->location() + ['upload_id' => $upload['upload_id'], 'backend' => 'cos', + 'object_key' => 'private/mobile-recording/v1/' . $upload['upload_id'] . '.enc', 'version_id' => '', + 'encryption_key' => bin2hex(random_bytes(32)), 'nonce_prefix' => bin2hex(random_bytes(8)), + 'cipher_format' => MobileRecordingCipher::FORMAT, 'kind' => $upload['kind'], + 'plain_size' => (int) $upload['size_bytes'], 'plain_sha256' => $upload['sha256'], + 'cipher_size' => MobileRecordingCipher::cipherSize((int) $upload['size_bytes']), + 'multipart_id' => '', 'part_etags' => '[]', 'state' => 'pending', 'attempts' => 0, 'next_retry_at' => 0, + 'created_at' => time(), 'updated_at' => time()]; + $object['context_hash'] = MobileRecordingCipher::context($object); + Db::name('mobile_recording_object')->insert($object); + return $object; + } + + private static function confirm(array $object, ?array $head): bool + { + if ($head === null) { return false; } + if ($head['size'] !== (int) $object['cipher_size'] || !hash_equals($object['context_hash'], (string) $head['context'])) { + throw new \RuntimeException('Private cloud object integrity mismatch'); + } + if (strlen($head['version_id']) > 256) { throw new \RuntimeException('Private cloud version invalid'); } + Db::name('mobile_recording_object')->where('upload_id', $object['upload_id'])->update([ + 'state' => 'ready', 'version_id' => $head['version_id'], 'attempts' => 0, 'next_retry_at' => 0, 'updated_at' => time()]); + return true; + } + + /** Each bounded cloud operation has its own committed checkpoint. A retry never restarts all parts. */ + public static function advance(string $id, callable $guard, float $seconds = 20): void + { + $deadline = microtime(true) + max(0.01, min(20, $seconds)); + $probe = true; + do { + $ready = Db::transaction(static function () use ($id, $guard, &$probe): bool { + $upload = $guard(); // Lock current binding and recheck ACL before each remote operation. + $object = Db::name('mobile_recording_object')->where('upload_id', $id)->lock(true)->find(); + if (!$object || !in_array($object['state'], ['pending', 'finalizing', 'verifying', 'ready'], true)) { + throw new \DomainException('上传会话已失效,请重新准备上传'); + } + if ($object['state'] === 'ready') { return true; } + $cloud = self::cloud(); + if ($probe || $object['state'] === 'verifying') { + $probe = false; + if (self::confirm($object, $cloud->head($object))) { return true; } + if ($object['state'] === 'verifying') { throw new \RuntimeException('Private cloud confirmation pending'); } + return false; + } + if ($object['multipart_id'] === '') { + $multipart = $cloud->initiate($object); + Db::name('mobile_recording_object')->where('upload_id', $id)->update(['multipart_id' => $multipart, 'updated_at' => time()]); + return false; + } + $parts = json_decode($object['part_etags'], true, 512, JSON_THROW_ON_ERROR); + $count = count($parts); + $frames = MobileRecordingCipher::frames((int) $object['plain_size']); + if ($count < $frames) { + $cipher = MobileRecordingStorage::locked($id, static function ($file) use ($object, $count): string { + $plain = MobileRecordingStorage::bytes($file, $count * MobileRecordingCipher::FRAME, MobileRecordingCipher::plainLength($object, $count)); + return MobileRecordingCipher::encrypt($object, $count, $plain); + }); + $etag = $cloud->part($object, $count + 1, $cipher); + $parts[] = ['PartNumber' => $count + 1, 'ETag' => $etag]; + Db::name('mobile_recording_object')->where('upload_id', $id)->update(['part_etags' => json_encode($parts), 'updated_at' => time()]); + return false; + } + if ($object['state'] === 'pending') { + // This checkpoint survives a lost CompleteMultipartUpload acknowledgement. + Db::name('mobile_recording_object')->where('upload_id', $id)->update(['state' => 'finalizing', 'updated_at' => time()]); + return false; + } + $cloud->finish($object, $parts); + Db::name('mobile_recording_object')->where('upload_id', $id)->update(['state' => 'verifying', 'updated_at' => time()]); + return false; + }); + if ($ready) { return; } + } while (microtime(true) < $deadline); + // Existing dev26 machine code temporarily_unavailable drives bounded prepare/complete retries. + throw new \RuntimeException('Private cloud upload checkpointed; continue on retry'); + } + + public static function ready(array $upload): bool + { + return Db::name('mobile_recording_object')->where('upload_id', $upload['upload_id'])->where('state', 'ready')->count() > 0; + } + + public static function source(array $upload) + { + $object = Db::name('mobile_recording_object')->where('upload_id', $upload['upload_id'])->find(); + if (!$object || in_array($object['state'], ['pending', 'finalizing', 'verifying'], true)) { + // Only an already-published legacy local upload can take this path. + return MobileRecordingStorage::open($upload); + } + if ($object['state'] !== 'ready' || $object['cipher_format'] !== MobileRecordingCipher::FORMAT + || $object['kind'] !== $upload['kind'] || (int) $object['plain_size'] !== (int) $upload['size_bytes'] + || !hash_equals($object['plain_sha256'], $upload['sha256'])) { throw new \RuntimeException('Private cloud reference invalid'); } + return new MobileRecordingCloudReader($object, self::cloud()); + } + + public static function verifyAvailable(array $upload): void + { + $object = Db::name('mobile_recording_object')->where('upload_id', $upload['upload_id'])->where('state', 'ready')->find(); + if (!$object) { $file = MobileRecordingStorage::open($upload); fclose($file); return; } + $head = self::cloud()->head($object, true); + if (!$head || $head['size'] !== (int) $object['cipher_size'] || !hash_equals($object['context_hash'], $head['context'])) { + throw new \RuntimeException('Private cloud completed object unavailable'); + } + } + + /** Recover the commit-to-unlink window. Never delete legacy or un-published source bytes. */ + public static function cleanupStaging(string $id, bool $onlyPending = false): bool + { + return Db::transaction(static function () use ($id, $onlyPending): bool { + $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; + $upload = Db::name('mobile_recording_upload')->where('upload_id', $id)->where('state', 'complete')->lock(true)->find(); + if (!$binding || !$upload) { return false; } + $object = Db::name('mobile_recording_object')->where('upload_id', $id)->where('state', 'ready')->lock(true)->find(); + if (!$object || !Db::name('mobile_recording_asset')->where('binding_id', $upload['binding_id']) + ->where('kind', $upload['kind'])->where('upload_id', $id)->count()) { return false; } + if ($onlyPending && Db::name('mobile_recording_work')->where('upload_id', $id)->value('staging_cleaned') == 1) { return true; } + self::verifyAvailable($upload); // Pin the recorded version before deleting the fallback source. + MobileRecordingStorage::removeStaging($id); // Missing file is success; unlink failure is retryable. + MobileRecordingWork::stagingCleaned($id); // Commit after unlink; a crash can only cause a safe repeat. + return true; + }); + } + + /** Same transaction as reference removal. Location/crypto ledger survives for reliable cleanup. */ + public static function queueDelete(array $ids): void + { + if (!$ids) { return; } + Db::name('mobile_recording_object')->whereIn('upload_id', $ids)->where('state', '<>', 'deleted') + ->update(['state' => 'deleting', 'next_retry_at' => 0, 'updated_at' => time()]); + Db::name('mobile_recording_work')->whereIn('upload_id', $ids)->delete(); + } + + public static function cleanup(int $limit = 10, bool $dryRun = false): array + { + $ids = Db::name('mobile_recording_object')->where('state', 'deleting')->where('next_retry_at', '<=', time()) + ->order('updated_at')->limit(max(1, min(100, $limit)))->column('upload_id'); + $counts = ['eligible' => count($ids), 'deleted' => 0, 'errors' => 0]; + if ($dryRun) { return $counts; } + foreach ($ids as $id) { + try { + $deleted = Db::transaction(static function () use ($id): bool { + $object = Db::name('mobile_recording_object')->where('upload_id', $id)->lock(true)->find(); + if (!$object || $object['state'] !== 'deleting') { return false; } + if (Db::name('mobile_recording_upload')->where('upload_id', $id)->count() + || Db::name('mobile_recording_asset')->where('upload_id', $id)->count()) { throw new \RuntimeException('Media reference still live'); } + $cloud = self::cloud(); + $cloud->abort($object); + $head = $cloud->head($object, $object['version_id'] !== ''); + if ($head) { + if (!hash_equals($object['context_hash'], $head['context'])) { throw new \RuntimeException('Cleanup context mismatch'); } + $object['version_id'] = $head['version_id']; + $cloud->delete($object); // Explicit VersionId when enabled; no mere delete marker. + if ($cloud->head($object, true) !== null) { throw new \RuntimeException('Cleanup confirmation pending'); } + } + Db::name('mobile_recording_object')->where('upload_id', $id)->update(['state' => 'deleted', + 'version_id' => $object['version_id'], 'encryption_key' => '', 'nonce_prefix' => '', 'plain_sha256' => '', + 'part_etags' => '[]', 'multipart_id' => '', 'next_retry_at' => 0, 'updated_at' => time()]); + return true; + }); + if ($deleted) { $counts['deleted']++; } + } catch (\Throwable $e) { + $counts['errors']++; + $row = Db::name('mobile_recording_object')->where('upload_id', $id)->find(); + if ($row && $row['state'] === 'deleting') { + $attempts = min(10000, (int) $row['attempts'] + 1); + Db::name('mobile_recording_object')->where('upload_id', $id)->update(['attempts' => $attempts, + 'next_retry_at' => time() + min(3600, 30 * (2 ** min(7, $attempts - 1))), 'updated_at' => time()]); + } + } + } + return $counts; + } +} diff --git a/server/app/common/service/mobilerecording/MobileRecordingStorage.php b/server/app/common/service/mobilerecording/MobileRecordingStorage.php index e887acca4..089bb73ab 100644 --- a/server/app/common/service/mobilerecording/MobileRecordingStorage.php +++ b/server/app/common/service/mobilerecording/MobileRecordingStorage.php @@ -194,10 +194,10 @@ final class MobileRecordingStorage public static function open(array $upload) { $file = @fopen(self::path((string) $upload['upload_id']), 'rb'); - if (!$file) { throw new DomainException('已上传文件暂不可读取,请重新上传'); } + if (!$file) { throw new MobileRecordingLocalUnavailableException('已上传文件暂不可读取,请重新上传'); } if ((int) fstat($file)['size'] !== (int) $upload['size_bytes']) { fclose($file); - throw new DomainException('已上传文件暂不可读取,请重新上传'); + throw new MobileRecordingLocalUnavailableException('已上传文件暂不可读取,请重新上传'); } return $file; } @@ -211,6 +211,14 @@ final class MobileRecordingStorage } } + /** Confirmed COS publication only. A failed unlink must leave its durable cleanup task pending. */ + public static function removeStaging(string $id): void + { + $path = self::path($id); + clearstatcache(true, $path); + if (file_exists($path) && !@unlink($path)) { throw new RuntimeException('Private recording cleanup pending'); } + } + /** CLI housekeeping only. Never expires referenced uploads or completed patient assets. */ public static function cleanupOrphans(bool $dryRun = true): array { diff --git a/server/app/common/service/mobilerecording/MobileRecordingStream.php b/server/app/common/service/mobilerecording/MobileRecordingStream.php index 0e0fcbfb5..fb0dff335 100644 --- a/server/app/common/service/mobilerecording/MobileRecordingStream.php +++ b/server/app/common/service/mobilerecording/MobileRecordingStream.php @@ -10,6 +10,7 @@ final class MobileRecordingStream extends \think\Response private $file; private int $start = 0; private int $length = 0; + private string $prefetched = ''; public function __construct($file, int $size, string $mime, string $range = '') { @@ -39,20 +40,29 @@ final class MobileRecordingStream extends \think\Response } $this->header(['Content-Length' => (string) $this->length]); $this->allowCache(false); + // Fetch/authenticate the first cloud frame before HTTP headers are sent, so failure is still JSON. + if (is_callable($this->file) && $this->length > 0) { + $this->prefetched = ($this->file)($this->start, min(65536, $this->length)); + } } protected function sendData(string $data): void { - if (!is_resource($this->file) || fseek($this->file, $this->start) !== 0) { return; } + $remote = is_callable($this->file); + if (!$remote && (!is_resource($this->file) || fseek($this->file, $this->start) !== 0)) { return; } try { $remaining = $this->length; + $offset = $this->start; while ($remaining > 0 && !connection_aborted()) { - $chunk = fread($this->file, min(65536, $remaining)); + if ($this->prefetched !== '') { $chunk = $this->prefetched; $this->prefetched = ''; } + else { $chunk = $remote ? ($this->file)($offset, min(65536, $remaining)) : fread($this->file, min(65536, $remaining)); } if ($chunk === false || $chunk === '') { break; } echo $chunk; - $remaining -= strlen($chunk); + $remaining -= strlen($chunk); $offset += strlen($chunk); } - } finally { fclose($this->file); $this->file = null; } + } catch (\Throwable $e) { + // A short body fails Content-Length at the client; never append provider errors to audio. + } finally { if (is_resource($this->file)) { fclose($this->file); } $this->file = null; } } public function __destruct() diff --git a/server/app/common/service/mobilerecording/MobileRecordingWork.php b/server/app/common/service/mobilerecording/MobileRecordingWork.php new file mode 100644 index 000000000..28d9faccf --- /dev/null +++ b/server/app/common/service/mobilerecording/MobileRecordingWork.php @@ -0,0 +1,46 @@ +where('upload_id', $id)->find(); + if (!$peek || !Db::name('mobile_recording_binding')->where('id', $peek['binding_id'])->lock(true)->find()) { return; } + $upload = Db::name('mobile_recording_upload')->where('upload_id', $id)->lock(true)->find(); + if (!$upload || (int) $upload['binding_id'] !== (int) $peek['binding_id']) { return; } + $current = Db::name('mobile_recording_work')->where('upload_id', $id)->lock(true)->find(); + $row = $current ?: ['upload_id' => $id, 'attempts' => 0, 'next_retry_at' => 0, + 'last_attempt_at' => 0, 'last_result' => '', 'staging_cleaned' => 0]; + $row = $change($row); + if ($current) { Db::name('mobile_recording_work')->where('upload_id', $id)->update($row); } + else { Db::name('mobile_recording_work')->insert($row); } + }); + } + + public static function attempted(string $id, ?\Throwable $error = null): void + { + self::update($id, static function (array $row) use ($error): array { + $row['attempts'] = $error === null ? 0 : min(10000, (int) $row['attempts'] + 1); + $row['next_retry_at'] = $error === null ? 0 : time() + min(3600, 30 * (2 ** min(7, $row['attempts'] - 1))); + $row['last_attempt_at'] = time(); + // Store only a bounded classification, never exception text, URLs or credentials. + $row['last_result'] = $error === null ? 'completed' : ($error instanceof \DomainException ? 'denied' : 'deferred'); + return $row; + }); + } + + public static function stagingCleaned(string $id): void + { + self::update($id, static function (array $row): array { $row['staging_cleaned'] = 1; return $row; }); + } +} diff --git a/server/config/console.php b/server/config/console.php index d919ee558..f4f22ef19 100755 --- a/server/config/console.php +++ b/server/config/console.php @@ -5,6 +5,8 @@ return [ // 指令定义 'commands' => [ + 'mobile-recording:cos-work' => 'app\\command\\MobileRecordingCosWork', + 'mobile-recording:cos-migrate' => 'app\\command\\MobileRecordingCosMigrate', 'mobile-recording:cleanup' => 'app\\command\\MobileRecordingCleanup', 'followup-audio:work' => 'app\\command\\FollowupAudioWork', 'followup-audio:probe' => 'app\\command\\FollowupAudioProbe', diff --git a/server/database/migrations/2026_11_09_mobile_recording_cos.sql b/server/database/migrations/2026_11_09_mobile_recording_cos.sql new file mode 100644 index 000000000..e1fd558b6 --- /dev/null +++ b/server/database/migrations/2026_11_09_mobile_recording_cos.sql @@ -0,0 +1,42 @@ +-- Upgrade after both existing mobile_recording_binding and mobile_recording_media migrations. +-- Existing uploads without a ready object ledger remain readable from private local storage. +-- Encryption keys are server-only secrets: never expose this table via generic admin export/API. +CREATE TABLE IF NOT EXISTS `zyt_mobile_recording_object` ( + `upload_id` char(32) CHARACTER SET ascii COLLATE ascii_bin NOT NULL, + `backend` varchar(16) NOT NULL DEFAULT 'cos', + `bucket` varchar(100) CHARACTER SET ascii COLLATE ascii_bin NOT NULL, + `region` varchar(48) CHARACTER SET ascii COLLATE ascii_bin NOT NULL, + `object_key` varchar(160) CHARACTER SET ascii COLLATE ascii_bin NOT NULL, + `version_id` varchar(256) CHARACTER SET ascii COLLATE ascii_bin NOT NULL DEFAULT '', + `encryption_key` char(64) CHARACTER SET ascii COLLATE ascii_bin NOT NULL, + `nonce_prefix` char(16) CHARACTER SET ascii COLLATE ascii_bin NOT NULL, + `cipher_format` varchar(32) NOT NULL DEFAULT 'aes256gcm-frame-v1', + `context_hash` char(64) CHARACTER SET ascii COLLATE ascii_bin NOT NULL, + `kind` varchar(16) NOT NULL, + `plain_size` bigint unsigned NOT NULL, + `plain_sha256` char(64) CHARACTER SET ascii COLLATE ascii_bin NOT NULL, + `cipher_size` bigint unsigned NOT NULL, + `multipart_id` varchar(1024) CHARACTER SET ascii COLLATE ascii_bin NOT NULL DEFAULT '', + `part_etags` mediumtext NOT NULL, + `state` varchar(16) NOT NULL DEFAULT 'pending', + `attempts` int unsigned NOT NULL DEFAULT 0, + `next_retry_at` int unsigned NOT NULL DEFAULT 0, + `created_at` int unsigned NOT NULL, + `updated_at` int unsigned NOT NULL, + PRIMARY KEY (`upload_id`), + UNIQUE KEY `uq_cos_media_location` (`bucket`, `region`, `object_key`), + KEY `idx_cos_media_work` (`state`, `next_retry_at`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +-- Separate from the object intent: permission/configuration failures can precede COS allocation. +-- Missing work row means staging cleanup is not yet acknowledged; never infer cleanup from publication. +CREATE TABLE IF NOT EXISTS `zyt_mobile_recording_work` ( + `upload_id` char(32) CHARACTER SET ascii COLLATE ascii_bin NOT NULL, + `attempts` int unsigned NOT NULL DEFAULT 0, + `next_retry_at` int unsigned NOT NULL DEFAULT 0, + `last_attempt_at` int unsigned NOT NULL DEFAULT 0, + `last_result` varchar(16) NOT NULL DEFAULT '', + `staging_cleaned` tinyint unsigned NOT NULL DEFAULT 0, + PRIMARY KEY (`upload_id`), + KEY `idx_media_work_retry` (`next_retry_at`, `last_attempt_at`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; diff --git a/server/tests/MobileRecordingCosTest.php b/server/tests/MobileRecordingCosTest.php new file mode 100644 index 000000000..4e2e917a2 --- /dev/null +++ b/server/tests/MobileRecordingCosTest.php @@ -0,0 +1,7 @@ +instance(MobileRecordingCos::class, $fakeCloud); $manager = new think\DbManager(); $driver = 'sqlite'; $pdo = null; @@ -83,6 +88,8 @@ try { $pdo->exec(file_get_contents(dirname(__DIR__) . '/database/migrations/2026_11_06_mobile_recording_binding.sql')); $pdo->exec(file_get_contents(dirname(__DIR__) . '/database/migrations/2026_11_09_mobile_recording_media.sql')); $pdo->exec(file_get_contents(dirname(__DIR__) . '/database/migrations/2026_11_09_mobile_recording_media.sql')); + $pdo->exec(file_get_contents(dirname(__DIR__) . '/database/migrations/2026_11_09_mobile_recording_cos.sql')); + $pdo->exec(file_get_contents(dirname(__DIR__) . '/database/migrations/2026_11_09_mobile_recording_cos.sql')); $expect(true, 'real upgrade migration executes and is idempotent on isolated MySQL'); } else { Db::execute('CREATE TABLE zyt_mobile_recording_binding (id INTEGER PRIMARY KEY AUTOINCREMENT, admin_id INT NOT NULL, @@ -168,7 +175,22 @@ try { $expect(filesize($storage . '/' . $session['upload_id'] . '.bin') === Media::CHUNK_SIZE, 'uncommitted trailing bytes removed'); $last = substr($wav, Media::CHUNK_SIZE); Media::chunk($upload + ['offset' => Media::CHUNK_SIZE, 'chunk_sha256' => hash('sha256', $last)], $last, 11); - $expect(Media::complete($upload, 11)['state'] === 'complete', 'full size hash and WAV signature validated'); + $expect(Media::complete($upload, 11)['state'] === 'complete', 'full size hash and WAV signature validated before confirmed COS publication'); + $cosObject = Db::name('mobile_recording_object')->where('upload_id', $session['upload_id'])->find(); + $expect($cosObject['state'] === 'ready' && $cosObject['version_id'] !== '', 'COS location and version durably recorded'); + $expect(!is_file($storage . '/' . $session['upload_id'] . '.bin'), 'completed COS media clears local plaintext staging'); + $expect(!str_starts_with($fakeCloud->objects[$fakeCloud->key($cosObject)]['bytes'], 'RIFF'), 'COS audio is ciphertext rather than playable plaintext'); + if ($driver === 'mysql') { + $tables = ['mobile_recording_binding', 'mobile_recording_upload', 'mobile_recording_asset', 'mobile_recording_object', 'mobile_recording_work']; + $beforeUpgrade = []; + foreach ($tables as $table) { $beforeUpgrade[$table] = Db::name($table)->select()->toArray(); } + $pdo->exec(file_get_contents(dirname(__DIR__) . '/database/migrations/2026_11_09_mobile_recording_cos.sql')); + $pdo->exec(file_get_contents(dirname(__DIR__) . '/database/migrations/2026_11_09_mobile_recording_cos.sql')); + foreach ($tables as $table) { + $expect(Db::name($table)->select()->toArray() === $beforeUpgrade[$table], 'COS upgrade preserves existing synthetic ' . $table . ' rows'); + } + } + $expect(Media::chunk($chunk, $first, 11)['state'] === 'complete', 'duplicate completed chunk validates against COS without recreating local file'); $expect(Media::complete($upload, 11)['state'] === 'complete', 'repeat complete idempotent'); $expect(Media::prepare($prepare, 11)['state'] === 'complete', 'repeat completed prepare reuses object'); $expect(Binding::patientLists(['diagnosis_id' => 101], 11)['lists'][0]['upload_state'] === 'partial', 'audio alone is partial and available'); @@ -245,6 +267,8 @@ try { $denied(fn () => Media::chunk($chunk, $first, 11), 'unbind immediately revokes pending writes'); $expect(Db::name('mobile_recording_upload')->count() === 0 && Db::name('mobile_recording_asset')->count() === 0, 'unbind removes all media DB references'); $expect((glob($storage . '/*.bin') ?: []) === [], 'unbind removes private files'); + $cloudCleanup = Objects::cleanup(100); + $expect($cloudCleanup['errors'] === 0 && $fakeCloud->objects === [], 'revoked COS assets deleted with explicit version IDs'); $orphan = $storage . '/' . str_repeat('f', 32) . '.bin'; file_put_contents($orphan, 'synthetic orphan'); touch($orphan, time() - 90000); @@ -252,6 +276,7 @@ try { $expect($cleanup['eligible'] === 1 && is_file($orphan), 'orphan cleanup dry run preserves files'); $cleanup = \app\common\service\mobilerecording\MobileRecordingStorage::cleanupOrphans(false); $expect($cleanup['deleted'] === 1 && !is_file($orphan), 'orphan cleanup removes only aged unreferenced objects'); + if (defined('MOBILE_RECORDING_COS_FAULT_TEST')) { require __DIR__ . '/fixtures/mobile_recording_cos_cases.php'; } Binding::bind($input, 11); Db::execute('DROP TABLE zyt_mobile_recording_upload'); $missingSchema = $controller('prepare', $prepare)->getData(); diff --git a/server/tests/fixtures/FakeMobileRecordingCos.php b/server/tests/fixtures/FakeMobileRecordingCos.php new file mode 100644 index 000000000..58a3f15aa --- /dev/null +++ b/server/tests/fixtures/FakeMobileRecordingCos.php @@ -0,0 +1,85 @@ + 'synthetic-1250000000', 'region' => 'ap-guangzhou']; + public array $objects = []; + public array $multipart = []; + public array $calls = []; + public int $losePartAck = 0; + public int $loseFinishAck = 0; + public int $failHead = 0; + public int $denyHead = 0; + public int $failDelete = 0; + public int $loseCreateAck = 0; + public int $partWrites = 0; + public int $finishes = 0; + public array $deletedVersions = []; + public $onPart = null; + public function location(): array { return $this->current; } + public function key(array $o): string { return $o['bucket'] . '|' . $o['region'] . '|' . $o['object_key']; } + public function initiate(array $o): string + { + $id = 'fake-' . (count($this->multipart) + 1); + $this->multipart[$id] = ['object' => $o, 'parts' => []]; + $this->calls[] = 'initiate'; + if ($this->loseCreateAck-- > 0) { throw new RuntimeException('synthetic create acknowledgement lost'); } + return $id; + } + public function part(array $o, int $number, string $cipher): string + { + if (!isset($this->multipart[$o['multipart_id']])) { throw new RuntimeException('synthetic session missing'); } + $this->multipart[$o['multipart_id']]['parts'][$number] = $cipher; + $this->partWrites++; $this->calls[] = 'part'; + if ($this->onPart !== null) { $callback = $this->onPart; $this->onPart = null; $callback(); } + if ($this->losePartAck-- > 0) { throw new RuntimeException('synthetic part acknowledgement lost'); } + return md5($cipher); + } + public function finish(array $o, array $parts): void + { + $session = $this->multipart[$o['multipart_id']] ?? null; + if (!$session) { throw new RuntimeException('synthetic multipart absent'); } + $bytes = ''; + foreach ($parts as $part) { + $body = $session['parts'][$part['PartNumber']] ?? ''; + if (md5($body) !== $part['ETag']) { throw new RuntimeException('synthetic part mismatch'); } + $bytes .= $body; + } + $this->objects[$this->key($o)] = ['bytes' => $bytes, 'context' => $o['context_hash'], 'version_id' => 'version-' . (++$this->finishes)]; + unset($this->multipart[$o['multipart_id']]); + $this->calls[] = 'finish'; + if ($this->loseFinishAck-- > 0) { throw new RuntimeException('synthetic finish acknowledgement lost'); } + } + public function head(array $o, bool $pinned = false): ?array + { + $this->calls[] = 'head'; + if ($this->denyHead-- > 0) { throw new DomainException('云端存储授权不可用,请联系管理员检查配置'); } + if ($this->failHead-- > 0) { throw new RuntimeException('synthetic COS down'); } + $object = $this->objects[$this->key($o)] ?? null; + if (!$object || ($pinned && $o['version_id'] !== '' && $o['version_id'] !== $object['version_id'])) { return null; } + return ['size' => strlen($object['bytes']), 'context' => $object['context'], 'version_id' => $object['version_id']]; + } + public function range(array $o, int $start, int $end): string + { + $object = $this->objects[$this->key($o)] ?? null; + if (!$object || $o['version_id'] !== $object['version_id']) { throw new RuntimeException('synthetic pinned object absent'); } + if ($end - $start + 1 > 1048592) { throw new RuntimeException('unbounded cloud read'); } + $this->calls[] = 'range:' . $start . '-' . $end; + return substr($object['bytes'], $start, $end - $start + 1); + } + public function delete(array $o): void + { + if ($this->failDelete-- > 0) { throw new RuntimeException('synthetic delete failed'); } + $object = $this->objects[$this->key($o)] ?? null; + if ($object && $object['version_id'] !== $o['version_id']) { throw new RuntimeException('unversioned delete forbidden in fixture'); } + $this->deletedVersions[] = $o['version_id']; + unset($this->objects[$this->key($o)]); + $this->calls[] = 'delete'; + } + public function abort(array $o): void { unset($this->multipart[$o['multipart_id']]); $this->calls[] = 'abort'; } +} diff --git a/server/tests/fixtures/mobile_recording_cos_cases.php b/server/tests/fixtures/mobile_recording_cos_cases.php new file mode 100644 index 000000000..28607a721 --- /dev/null +++ b/server/tests/fixtures/mobile_recording_cos_cases.php @@ -0,0 +1,134 @@ + 'transcript', 'size_bytes' => strlen($text), + 'sha256' => hash('sha256', $text), 'media_type' => 'text/plain'], 11); + $request = ['upload_id' => $p['upload_id'], 'diagnosis_id' => 101]; + if ($text !== '') { CosMedia::chunk($request + ['offset' => 0, 'chunk_sha256' => hash('sha256', $text)], $text, 11); } + return $request; + }; + $read = static fn (): array => CosMedia::transcript(['binding_id' => $bindingId, 'diagnosis_id' => 101], 11); + $legacy = $send('synthetic legacy plaintext'); + CosDb::name('mobile_recording_upload')->where('upload_id', $legacy['upload_id'])->update(['state' => 'complete']); + CosDb::name('mobile_recording_asset')->insert(['binding_id' => $bindingId, 'kind' => 'transcript', 'upload_id' => $legacy['upload_id'], 'updated_at' => time()]); + $calls = count($fakeCloud->calls); + $expect($read()['text'] === 'synthetic legacy plaintext' && count($fakeCloud->calls) === $calls, 'legacy local completion reads without COS or config'); + $expect(!CosObjects::ready(CosDb::name('mobile_recording_upload')->where('upload_id', $legacy['upload_id'])->find()), 'legacy remains local until explicit migration'); + $output = new \think\console\Output('buffer'); + $exit = (new \app\command\MobileRecordingCosMigrate())->run(new \think\console\Input(['--dry-run', '--limit=1']), $output); + $expect($exit === 0 && count($fakeCloud->calls) === $calls && is_file($storage . '/' . $legacy['upload_id'] . '.bin'), 'actual migration CLI dry-run reads no media or cloud and writes no object'); + $expect(str_contains($output->fetch(), '"selected":1'), 'migration dry-run reports bounded batch count'); + CosMedia::migrateLocal($legacy['upload_id']); + $expect($read()['text'] === 'synthetic legacy plaintext', 'explicit migration preserves exact text'); + $expect(!is_file($storage . '/' . $legacy['upload_id'] . '.bin'), 'migration removes local plaintext only after ready commit'); + + $replacementText = 'synthetic replacement secret UTF8 中文'; + $replacement = $send($replacementText); + $fakeCloud->failHead = 1; + $failure = $controller('complete', $replacement)->getData(); + $expect($failure['code'] === 0 && $failure['data'] === ['error_code' => 'temporarily_unavailable', 'retryable' => true], 'COS outage uses unchanged dev26 retryable machine envelope'); + $ledger = CosDb::name('mobile_recording_object')->where('upload_id', $replacement['upload_id'])->find(); + $expect(strlen($ledger['encryption_key']) === 64 && $ledger['state'] === 'pending', 'intent and crypto key committed before remote calls'); + $expect(CosMedia::status($target, 11)['transcript']['received_bytes'] === strlen($replacementText), 'COS failure preserves confirmed phone cursor'); + $expect($read()['text'] === 'synthetic legacy plaintext' && is_file($storage . '/' . $replacement['upload_id'] . '.bin'), 'COS failure keeps old publication and new local staging'); + $calls = count($fakeCloud->calls); + $output = new \think\console\Output('buffer'); + $exit = (new \app\command\MobileRecordingCosWork())->run(new \think\console\Input(['--dry-run', '--limit=1', '--seconds=1']), $output); + $expect($exit === 0 && count($fakeCloud->calls) === $calls && str_contains($output->fetch(), '"uploads_selected":1'), 'actual worker dry-run reports pending upload without remote calls'); + $fakeCloud->loseCreateAck = 1; + $failure = $controller('complete', $replacement)->getData(); + $orphanIds = array_keys($fakeCloud->multipart); + $orphan = end($orphanIds); + $afterCreate = CosDb::name('mobile_recording_object')->where('upload_id', $replacement['upload_id'])->find(); + $expect($failure['code'] === 0 && $afterCreate['multipart_id'] === '' && $fakeCloud->multipart[$orphan]['parts'] === [], + 'lost create acknowledgement sends no patient parts before durable multipart checkpoint'); + // COS lifecycle for this dedicated prefix must abort stale incomplete uploads; simulate that cleanup only. + unset($fakeCloud->multipart[$orphan]); + $fakeCloud->losePartAck = 1; + $failure = $controller('complete', $replacement)->getData(); + $ledger = CosDb::name('mobile_recording_object')->where('upload_id', $replacement['upload_id'])->find(); + $expect($failure['code'] === 0 && $ledger['multipart_id'] !== '' && json_decode($ledger['part_etags'], true) === [], 'lost part acknowledgement keeps durable multipart ID without false checkpoint'); + $fakeCloud->loseFinishAck = 1; + $failure = $controller('complete', $replacement)->getData(); + $expect($failure['code'] === 0 && isset($fakeCloud->objects[$fakeCloud->key($ledger)]), 'lost remote completion acknowledgement leaves tracked ciphertext'); + $finishes = $fakeCloud->finishes; + $output = new \think\console\Output('buffer'); + $exit = (new \app\command\MobileRecordingCosWork())->run(new \think\console\Input(['--limit=1', '--seconds=20']), $output); + $expect($exit === 0 && str_contains($output->fetch(), '"uploads_completed":1'), 'actual worker resumes and publishes checkpointed upload without the phone'); + $expect($fakeCloud->finishes === $finishes && $read()['text'] === $replacementText, 'HEAD recovers lost finish acknowledgement without duplicate completion'); + $ledger = CosDb::name('mobile_recording_object')->where('upload_id', $replacement['upload_id'])->find(); + $cipher = $fakeCloud->objects[$fakeCloud->key($ledger)]['bytes']; + $expect(!str_contains($cipher, $replacementText) && strlen($cipher) === strlen($replacementText) + 16, 'COS text contains only authenticated ciphertext'); + $publicDto = json_encode(CosMedia::status($target, 11)); + $expect(!str_contains($publicDto, $ledger['encryption_key']) && !str_contains($publicDto, $ledger['bucket']) + && !str_contains($publicDto, $ledger['object_key']), 'protocol never returns crypto key bucket or object key'); + $fakeCloud->current = ['bucket' => 'new-synthetic-1250000000', 'region' => 'ap-shanghai']; + $expect($read()['text'] === $replacementText, 'configuration change reads original persisted location'); + $next = $send('new config content'); + if ($driver === 'sqlite') { + CosDb::execute("CREATE TRIGGER synthetic_publish_failure BEFORE UPDATE ON zyt_mobile_recording_upload WHEN NEW.state='complete' BEGIN SELECT RAISE(ABORT,'synthetic publication failure'); END"); + $failure = $controller('complete', $next)->getData(); + $ready = CosDb::name('mobile_recording_object')->where('upload_id', $next['upload_id'])->find(); + $expect($failure['code'] === 0 && $ready['state'] === 'ready', 'remote ready survives separate DB publication failure'); + $expect($read()['text'] === $replacementText, 'failed publication preserves previously completed asset'); + $expect(is_file($storage . '/' . $next['upload_id'] . '.bin'), 'failed publication does not clear source staging'); + CosDb::execute('DROP TRIGGER synthetic_publish_failure'); + $finishes = $fakeCloud->finishes; + CosMedia::complete($next, 11); + $expect($fakeCloud->finishes === $finishes, 'publication retry does not reupload already ready COS object'); + } else { CosMedia::complete($next, 11); } + $ready = CosDb::name('mobile_recording_object')->where('upload_id', $next['upload_id'])->find(); + $expect($ready['bucket'] === $fakeCloud->current['bucket'] && $ledger['bucket'] !== $ready['bucket'], 'new uploads use new config while old ledger is immutable'); + $key = $fakeCloud->key($ready); $untampered = $fakeCloud->objects[$key]['bytes']; + $fakeCloud->objects[$key]['bytes'][0] = chr(ord($untampered[0]) ^ 1); + $failure = $controller('transcript', ['binding_id' => $bindingId, 'diagnosis_id' => 101], false)->getData(); + $expect($failure['code'] === 0 && !str_contains(json_encode($failure), 'new config content'), 'GCM tampering fails closed before returning text'); + $fakeCloud->objects[$key]['bytes'] = $untampered; + CosBinding::bind(array_replace($input, ['diagnosis_id' => 102]), 11); + $expect(CosMedia::transcript(['binding_id' => $bindingId, 'diagnosis_id' => 102], 11)['text'] === 'new config content', 'immutable AAD permits authorized rebind'); + CosBinding::bind($input, 11); + $audio = CosMedia::prepare($prepare, 11); + $ap = ['upload_id' => $audio['upload_id'], 'diagnosis_id' => 101]; + for ($offset = 0; $offset < strlen($wav); $offset += CosMedia::CHUNK_SIZE) { + $part = substr($wav, $offset, CosMedia::CHUNK_SIZE); + CosMedia::chunk($ap + ['offset' => $offset, 'chunk_sha256' => hash('sha256', $part)], $part, 11); + } + CosMedia::complete($ap, 11); + $range = 'bytes=' . (CosMedia::CHUNK_SIZE - 5) . '-' . (CosMedia::CHUNK_SIZE + 5); + $expect($capture(CosMedia::audio(['binding_id' => $bindingId], 11, $range)) === substr($wav, CosMedia::CHUNK_SIZE - 5, 11), 'plaintext Range crossing two authenticated frames is exact'); + $empty = $send(''); CosMedia::complete($empty, 11); + $emptyObject = CosDb::name('mobile_recording_object')->where('upload_id', $empty['upload_id'])->find(); + $expect(strlen($fakeCloud->objects[$fakeCloud->key($emptyObject)]['bytes']) === 16 && $read()['text'] === '', 'empty transcript is a verified GCM tag rather than unprotected empty object'); + CosBinding::unbind($identity, 11); + $fakeCloud->failDelete = 1; + $clean = CosObjects::cleanup(100); + $expect($clean['errors'] === 1 && CosDb::name('mobile_recording_object')->where('state', 'deleting')->count() === 1, 'delete failure retains durable location and retry outbox'); + $denial = $controller('transcript', ['binding_id' => $bindingId], false)->getData(); + $expect($denial['code'] === 0 && $denial['data']['retryable'] === false, 'access revoked before remote deletion retry'); + CosDb::name('mobile_recording_object')->where('state', 'deleting')->update(['next_retry_at' => 0]); + $clean = CosObjects::cleanup(100); + $expect($clean['errors'] === 0 && $fakeCloud->objects === [], 'delete retries remove objects from their original buckets'); + $expect(!in_array('', $fakeCloud->deletedVersions, true), 'versioned objects are deleted by explicit version ID'); + $expect(CosDb::name('mobile_recording_object')->where('state', 'deleted')->where('encryption_key', '<>', '')->count() === 0, 'successful cleanup erases per-object decryption keys'); + + // Mutation during a fake remote callback stresses the revalidation boundary (not a claim of concurrent InnoDB testing). + CosBinding::bind($input, 11); + $revoked = $send('revoked while part in flight'); + $fakeCloud->onPart = static function () use ($identity): void { CosBinding::unbind($identity, 11); }; + $failure = $controller('complete', $revoked)->getData(); + $expect($failure['code'] === 0 && CosDb::name('mobile_recording_asset')->count() === 0, 'revoked binding cannot publish after remote callback'); + $expect(CosDb::name('mobile_recording_object')->where('upload_id', $revoked['upload_id'])->value('state') === 'deleting', 'revoked in-flight multipart remains tracked for abort'); + $clean = CosObjects::cleanup(100); + $expect($clean['errors'] === 0 && $fakeCloud->multipart === [], 'queued cleanup aborts in-flight multipart'); + + require __DIR__ . '/mobile_recording_cos_sdk.php'; + require __DIR__ . '/mobile_recording_cos_final_cases.php'; +})(); diff --git a/server/tests/fixtures/mobile_recording_cos_final_cases.php b/server/tests/fixtures/mobile_recording_cos_final_cases.php new file mode 100644 index 000000000..93f1ace00 --- /dev/null +++ b/server/tests/fixtures/mobile_recording_cos_final_cases.php @@ -0,0 +1,182 @@ + $input['installation_id'], 'recording_key' => hash('sha256', 'cos-final-' . $label)]; + $binding = FinalBinding::bind(array_replace($input, $identity), $actor)['binding']; + $prepare = $identity + ['diagnosis_id' => 101, 'kind' => 'transcript', 'size_bytes' => strlen($text), + 'sha256' => hash('sha256', $text), 'media_type' => 'text/plain']; + $upload = FinalMedia::prepare($prepare, $actor); + $request = ['upload_id' => $upload['upload_id'], 'diagnosis_id' => 101]; + if ($text !== '') { FinalMedia::chunk($request + ['offset' => 0, 'chunk_sha256' => hash('sha256', $text)], $text, $actor); } + return ['id' => $upload['upload_id'], 'request' => $request, 'prepare' => $prepare, + 'binding' => $binding['id'], 'identity' => $identity, 'actor' => $actor, 'text' => $text]; + }; + $legacy = static function (array $case): void { + FinalDb::name('mobile_recording_upload')->where('upload_id', $case['id'])->update(['state' => 'complete']); + FinalDb::name('mobile_recording_asset')->insert(['binding_id' => $case['binding'], 'kind' => 'transcript', + 'upload_id' => $case['id'], 'updated_at' => time()]); + }; + $advanceOnly = static function (array $case): void { + $guard = static fn (): array => FinalDb::name('mobile_recording_upload')->where('upload_id', $case['id'])->lock(true)->find(); + FinalDb::transaction(static fn (): array => FinalObjects::ensure($guard())); + FinalObjects::advance($case['id'], $guard); + }; + $worker = static function (bool $dry = false): array { + $output = new \think\console\Output('buffer'); + $options = ['--limit=1', '--seconds=20']; + if ($dry) { $options[] = '--dry-run'; } + $exit = (new \app\command\MobileRecordingCosWork())->run(new \think\console\Input($options), $output); + $text = trim($output->fetch()); + return ['exit' => $exit, 'counts' => json_decode(substr($text, strlen('MOBILE_RECORDING_COS_WORK ')), true, 512, JSON_THROW_ON_ERROR)]; + }; + $forget = static function (array $case): void { FinalBinding::unbind($case['identity'], $case['actor']); }; + + $published = $send('authorization', 'synthetic durable publication'); + FinalMedia::complete($published['request'], 11); + $beforeUpload = FinalDb::name('mobile_recording_upload')->where('upload_id', $published['id'])->find(); + $beforeAsset = FinalDb::name('mobile_recording_asset')->where('upload_id', $published['id'])->find(); + $beforeObject = FinalDb::name('mobile_recording_object')->where('upload_id', $published['id'])->find(); + $fakeCloud->denyHead = 1; + $denial = $controller('prepare', $published['prepare'])->getData(); + $expect($denial['code'] === 0 && $denial['data'] === ['error_code' => 'request_rejected', 'retryable' => false], + 'COS-FINAL-001 cloud authorization failure keeps non-retryable API envelope'); + $expect(FinalDb::name('mobile_recording_upload')->where('upload_id', $published['id'])->find() === $beforeUpload + && FinalDb::name('mobile_recording_asset')->where('upload_id', $published['id'])->find() === $beforeAsset + && FinalDb::name('mobile_recording_object')->where('upload_id', $published['id'])->find() === $beforeObject, + 'COS-FINAL-001 cloud authorization failure preserves published asset upload cursor and ready ledger'); + $fakeCloud->failHead = 1; + $failure = $controller('prepare', $published['prepare'])->getData(); + $expect($failure['code'] === 0 && $failure['data']['retryable'] === true + && FinalDb::name('mobile_recording_asset')->where('upload_id', $published['id'])->find() === $beforeAsset, + 'COS-FINAL-001 transient cloud outage also preserves publication'); + $expect(FinalMedia::transcript(['binding_id' => $published['binding']], 11)['text'] === $published['text'], + 'COS-FINAL-001 published transcript remains readable after cloud authorization recovers'); + $forget($published); + + $missing = $send('missing-local', 'synthetic missing legacy'); + $legacy($missing); + unlink($storage . '/' . $missing['id'] . '.bin'); + $calls = count($fakeCloud->calls); + $reset = FinalMedia::prepare($missing['prepare'], 11); + $expect($reset['state'] === 'uploading' && $reset['received_bytes'] === 0 + && FinalDb::name('mobile_recording_asset')->where('upload_id', $missing['id'])->count() === 0 + && count($fakeCloud->calls) === $calls, 'COS-FINAL-001 explicit missing legacy source still resets for reupload without COS'); + $forget($missing); + + $crash = $send('publication-window', 'synthetic publication window'); + FinalMedia::complete($crash['request'], 11); + $path = $storage . '/' . $crash['id'] . '.bin'; + // Reconstruct exactly the committed publication before its cleanup acknowledgement exists. + file_put_contents($path, $crash['text']); + FinalDb::name('mobile_recording_work')->where('upload_id', $crash['id'])->delete(); + $finishes = $fakeCloud->finishes; + $repeat = FinalMedia::complete($crash['request'], 11); + $expect($repeat['state'] === 'complete' && !is_file($path) && $fakeCloud->finishes === $finishes + && FinalDb::name('mobile_recording_work')->where('upload_id', $crash['id'])->value('staging_cleaned') == 1, + 'COS-FINAL-002 repeat complete recovers publication-to-unlink crash without reupload'); + file_put_contents($path, $crash['text']); + FinalDb::name('mobile_recording_work')->where('upload_id', $crash['id'])->delete(); + $calls = count($fakeCloud->calls); + $preview = $worker(true); + $expect($preview['exit'] === 0 && $preview['counts']['uploads_selected'] === 1 && is_file($path) + && count($fakeCloud->calls) === $calls && FinalDb::name('mobile_recording_work')->where('upload_id', $crash['id'])->count() === 0, + 'COS-FINAL-002 worker dry-run reports staging recovery without remote or local writes'); + $run = $worker(); + $expect($run['exit'] === 0 && $run['counts']['staging_cleaned'] === 1 && !is_file($path) && $fakeCloud->finishes === $finishes, + 'COS-FINAL-002 worker recovers committed publication staging without phone or reupload'); + // The file is already absent, but a process died before recording that fact in the work table. + FinalDb::name('mobile_recording_work')->where('upload_id', $crash['id'])->delete(); + $run = $worker(); + $expect($run['exit'] === 0 && $run['counts']['staging_cleaned'] === 1 + && FinalDb::name('mobile_recording_work')->where('upload_id', $crash['id'])->value('staging_cleaned') == 1, + 'COS-FINAL-002 worker idempotently acknowledges unlink-to-DB crash with missing file'); + file_put_contents($path, $crash['text']); + FinalMedia::complete($crash['request'], 11); + $expect(!is_file($path), 'COS-FINAL-002 repeat complete also removes unexpectedly restored staging'); + $forget($crash); + + $migration = $send('migration-window', 'synthetic migration window'); + $legacy($migration); + $calls = count($fakeCloud->calls); + $expect(!FinalObjects::cleanupStaging($migration['id']) && is_file($storage . '/' . $migration['id'] . '.bin') + && count($fakeCloud->calls) === $calls, 'COS-FINAL-002 cleanup preserves published legacy source without COS ledger'); + $advanceOnly($migration); // The real ready checkpoint is committed; explicit migration has not unlinked yet. + $finishes = $fakeCloud->finishes; + $run = $worker(); + $expect($run['exit'] === 0 && $run['counts']['staging_cleaned'] === 1 + && !is_file($storage . '/' . $migration['id'] . '.bin') && $fakeCloud->finishes === $finishes, + 'COS-FINAL-002 worker recovers legacy migration ready-to-unlink crash'); + FinalMedia::migrateLocal($migration['id']); + $expect(FinalMedia::transcript(['binding_id' => $migration['binding']], 11)['text'] === $migration['text'], + 'COS-FINAL-002 explicit migration remains idempotent after recovered cleanup'); + $forget($migration); + + $unpublished = $send('unpublished-ready', 'synthetic not yet published'); + $advanceOnly($unpublished); + $calls = count($fakeCloud->calls); + $expect(!FinalObjects::cleanupStaging($unpublished['id']) && is_file($storage . '/' . $unpublished['id'] . '.bin') + && count($fakeCloud->calls) === $calls, 'COS-FINAL-002 cleanup never removes ready but unpublished staging'); + $forget($unpublished); + + $unlink = $send('unlink-failure', 'synthetic unlink failure'); + FinalMedia::complete($unlink['request'], 11); + $path = $storage . '/' . $unlink['id'] . '.bin'; + // A directory at the owned synthetic filename deterministically makes unlink fail on both test platforms. + mkdir($path, 0700); + file_put_contents($path . '/synthetic', 'owned fixture'); + FinalDb::name('mobile_recording_work')->where('upload_id', $unlink['id'])->delete(); + try { + $repeat = FinalMedia::complete($unlink['request'], 11); + $run = $worker(); + $work = FinalDb::name('mobile_recording_work')->where('upload_id', $unlink['id'])->find(); + $expect($repeat['state'] === 'complete' && $run['exit'] === 1 && $run['counts']['uploads_deferred'] === 1 + && $work['staging_cleaned'] == 0 && $work['next_retry_at'] > time() + && FinalDb::name('mobile_recording_asset')->where('upload_id', $unlink['id'])->count() === 1, + 'COS-FINAL-002 unlink failure retains publication and durable cleanup retry'); + } finally { unlink($path . '/synthetic'); rmdir($path); } + file_put_contents($path, $unlink['text']); + FinalDb::name('mobile_recording_work')->where('upload_id', $unlink['id'])->update(['next_retry_at' => 0]); + $run = $worker(); + $expect($run['exit'] === 0 && !is_file($path) + && FinalDb::name('mobile_recording_work')->where('upload_id', $unlink['id'])->value('staging_cleaned') == 1, + 'COS-FINAL-002 next due worker removes staging after unlink failure clears'); + $forget($unlink); + + $blocked = $send('blocked-owner', 'synthetic disabled owner'); + $healthy = $send('healthy-owner', 'synthetic healthy owner', 1); + FinalDb::name('mobile_recording_upload')->where('upload_id', $blocked['id'])->update(['updated_at' => 1]); + FinalDb::name('mobile_recording_upload')->where('upload_id', $healthy['id'])->update(['updated_at' => 2]); + FinalDb::name('admin')->where('id', 11)->update(['disable' => 1]); + try { + $first = $worker(); + $work = FinalDb::name('mobile_recording_work')->where('upload_id', $blocked['id'])->find(); + $expect($first['exit'] === 1 && $first['counts']['uploads_denied'] === 1 && $work['attempts'] == 1 + && $work['last_result'] === 'denied' && $work['next_retry_at'] >= time() + 29, + 'COS-FINAL-003 denied oldest upload persists bounded retry classification and time before COS intent'); + $second = $worker(); + $expect($second['exit'] === 0 && $second['counts']['uploads_completed'] === 1 + && FinalDb::name('mobile_recording_upload')->where('upload_id', $healthy['id'])->value('state') === 'complete', + 'COS-FINAL-003 limit one worker reaches healthy upload behind denied owner'); + $third = $worker(); + $expect($third['counts']['uploads_selected'] === 0 + && FinalDb::name('mobile_recording_object')->where('upload_id', $blocked['id'])->count() === 0 + && FinalDb::name('mobile_recording_asset')->where('upload_id', $blocked['id'])->count() === 0, + 'COS-FINAL-003 denied owner stays unpublished and is not immediately retried'); + } finally { FinalDb::name('admin')->where('id', 11)->update(['disable' => 0]); } + FinalDb::name('mobile_recording_work')->where('upload_id', $blocked['id'])->update(['next_retry_at' => 0]); + $run = $worker(); + $work = FinalDb::name('mobile_recording_work')->where('upload_id', $blocked['id'])->find(); + $expect($run['exit'] === 0 && $run['counts']['uploads_completed'] === 1 && $work['attempts'] == 0 + && $work['next_retry_at'] == 0 && $work['last_result'] === 'completed', + 'COS-FINAL-003 due retry rechecks restored ACL and clears failure backoff after success'); + $forget($blocked); $forget($healthy); + $expect(FinalDb::name('mobile_recording_work')->count() === 0, 'COS-FINAL-003 binding revocation removes obsolete maintenance rows'); + FinalObjects::cleanup(100); +})(); diff --git a/server/tests/fixtures/mobile_recording_cos_sdk.php b/server/tests/fixtures/mobile_recording_cos_sdk.php new file mode 100644 index 000000000..d3976ff48 --- /dev/null +++ b/server/tests/fixtures/mobile_recording_cos_sdk.php @@ -0,0 +1,66 @@ + 'synthetic-1250000000', 'region' => 'ap-guangzhou', + 'access_key' => 'SYNTHETIC_NOT_REAL', 'secret_key' => 'SYNTHETIC_NOT_REAL_SECRET', 'domain' => 'https://unused.invalid']; + } + protected function client(array $object): \Qcloud\Cos\Client + { + $client = parent::client($object); + $client->httpClient->getConfig('handler')->setHandler(function ($request, $options) { + $this->requests[] = ['scheme' => $request->getUri()->getScheme(), 'host' => $request->getUri()->getHost(), + 'query' => $request->getUri()->getQuery(), 'acl' => $request->getHeaderLine('x-cos-acl'), + 'range' => $request->getHeaderLine('Range'), 'md5' => $request->getHeaderLine('Content-MD5'), + 'content_type' => $request->getHeaderLine('Content-Type'), 'options' => $options, + 'authorized' => $request->hasHeader('Authorization')]; + if (!$this->responses) { throw new RuntimeException('No synthetic response; network forbidden'); } + return \GuzzleHttp\Promise\Create::promiseFor(array_shift($this->responses)); + }); + return $client; + } +} +$probe = new ProbeMobileRecordingCos(); +$po = ['bucket' => 'synthetic-1250000000', 'region' => 'ap-guangzhou', + 'object_key' => 'private/mobile-recording/v1/' . str_repeat('a', 32) . '.enc', 'context_hash' => str_repeat('b', 64), + 'multipart_id' => 'synthetic-multipart', 'version_id' => 'synthetic-version', 'cipher_size' => 8]; +$probe->responses[] = new \GuzzleHttp\Psr7\Response(200, [], 'synthetic-multipart'); +$expect($probe->initiate($po) === 'synthetic-multipart', 'actual SDK parses persisted multipart ID'); +$r = $probe->requests[0]; +$expect($r['scheme'] === 'https' && $r['acl'] === 'private', 'actual adapter serializes HTTPS schema and explicit private ACL'); +$expect($r['content_type'] === 'application/octet-stream' && !str_contains($r['host'], 'unused.invalid'), 'ciphertext never uses configured public CDN domain'); +$expect($r['options']['connect_timeout'] === 3 && $r['options']['timeout'] === 10 && $r['options']['allow_redirects'] === false, + 'actual SDK receives bounded per-command connection request timeouts and no redirects'); +$probe->responses[] = new \GuzzleHttp\Psr7\Response(200, ['ETag' => '"' . md5('synthetic-cipher') . '"']); +$expect($probe->part($po, 1, 'synthetic-cipher') === md5('synthetic-cipher'), 'part acknowledgement validates expected MD5'); +$expect($probe->requests[1]['md5'] === base64_encode(md5('synthetic-cipher', true)), 'bounded ciphertext part sends Content-MD5'); +$probe->responses[] = new \GuzzleHttp\Psr7\Response(200, ['Content-Length' => '8', 'x-cos-meta-mr-context' => str_repeat('b', 64), 'x-cos-version-id' => 'synthetic-version']); +$head = $probe->head($po, true); +$expect($head === ['size' => 8, 'context' => str_repeat('b', 64), 'version_id' => 'synthetic-version'], 'actual SDK HEAD confirms context size and version'); +$probe->responses[] = new \GuzzleHttp\Psr7\Response(206, ['Content-Length' => '4', 'Content-Range' => 'bytes 2-5/8'], + new \GuzzleHttp\Psr7\NoSeekStream(\GuzzleHttp\Psr7\Utils::streamFor('2345'))); +$expect($probe->range($po, 2, 5) === '2345', 'actual SDK nonseekable body reads from range response position zero'); +$r = $probe->requests[3]; +$expect($r['range'] === 'bytes=2-5' && $r['options']['stream'] === true && str_contains($r['query'], 'versionId=synthetic-version'), + 'actual adapter pins version and enables bounded Range transport streaming'); +$expect($r['authorized'] && !str_contains($r['query'], 'q-sign-'), 'SDK authentication uses headers rather than signed public URLs'); +$probe->responses[] = new \GuzzleHttp\Psr7\Response(204); +$probe->delete($po); +$expect(str_contains($probe->requests[4]['query'], 'versionId=synthetic-version'), 'actual SDK deletion targets recorded version rather than delete marker'); +$probe->responses[] = new \GuzzleHttp\Psr7\Response(404, [], 'NoSuchKeysynthetic-provider-path'); +$expect($probe->head($po) === null, 'actual SDK missing object is a recoverable absent HEAD result'); +$probe->responses[] = new \GuzzleHttp\Psr7\Response(403, [], 'AccessDeniedsynthetic-provider-path'); +$permanent = false; +try { $probe->initiate($po); } +catch (\DomainException $e) { $permanent = $e->getPrevious() === null && !str_contains($e->getMessage(), 'synthetic-provider-path'); } +$expect($permanent, 'actual SDK permanent cloud denial is non-retryable and strips provider details'); +$probe->responses[] = new \GuzzleHttp\Psr7\Response(503, [], 'ServiceUnavailablesynthetic-provider-path'); +$temporary = false; +try { $probe->initiate($po); } +catch (\RuntimeException $e) { $temporary = $e->getPrevious() === null && !str_contains($e->getMessage(), 'synthetic-provider-path'); } +$expect($temporary, 'actual SDK transient cloud failure retains retryable sanitized exception type'); diff --git a/server/tests/fixtures/mobile_recording_media_schema.php b/server/tests/fixtures/mobile_recording_media_schema.php index 6ef18699b..a5de75417 100644 --- a/server/tests/fixtures/mobile_recording_media_schema.php +++ b/server/tests/fixtures/mobile_recording_media_schema.php @@ -8,3 +8,13 @@ \think\facade\Db::execute('CREATE TABLE zyt_mobile_recording_asset ( binding_id INTEGER NOT NULL, kind TEXT NOT NULL, upload_id TEXT NOT NULL UNIQUE, updated_at INTEGER NOT NULL, PRIMARY KEY(binding_id,kind))'); +\think\facade\Db::execute('CREATE TABLE zyt_mobile_recording_object ( + upload_id TEXT PRIMARY KEY, backend TEXT NOT NULL, bucket TEXT NOT NULL, region TEXT NOT NULL, object_key TEXT NOT NULL, + version_id TEXT NOT NULL, encryption_key TEXT NOT NULL, nonce_prefix TEXT NOT NULL, cipher_format TEXT NOT NULL, + context_hash TEXT NOT NULL, kind TEXT NOT NULL, plain_size INTEGER NOT NULL, plain_sha256 TEXT NOT NULL, cipher_size INTEGER NOT NULL, + multipart_id TEXT NOT NULL, part_etags TEXT NOT NULL, state TEXT NOT NULL, attempts INTEGER NOT NULL, + next_retry_at INTEGER NOT NULL, created_at INTEGER NOT NULL, updated_at INTEGER NOT NULL, + UNIQUE(bucket,region,object_key))'); +\think\facade\Db::execute('CREATE TABLE zyt_mobile_recording_work ( + upload_id TEXT PRIMARY KEY, attempts INTEGER NOT NULL DEFAULT 0, next_retry_at INTEGER NOT NULL DEFAULT 0, + last_attempt_at INTEGER NOT NULL DEFAULT 0, last_result TEXT NOT NULL DEFAULT "", staging_cleaned INTEGER NOT NULL DEFAULT 0)');