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)');