Merge branch '11-5'

This commit is contained in:
Your Name
2026-11-09 14:51:52 +08:00
20 changed files with 1241 additions and 25 deletions
@@ -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);
}
@@ -0,0 +1,45 @@
<?php
declare(strict_types=1);
namespace app\command;
use app\adminapi\logic\tcm\MobileRecordingMediaLogic;
use think\console\Command;
use think\console\Input;
use think\console\Output;
use think\console\input\Option;
use think\facade\Db;
/** Explicit privileged migration. Never implicitly moves all legacy patient media on deployment. */
final class MobileRecordingCosMigrate extends Command
{
protected function configure()
{
$this->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; }
}
}
@@ -0,0 +1,72 @@
<?php
declare(strict_types=1);
namespace app\command;
use app\adminapi\logic\tcm\MobileRecordingMediaLogic;
use app\common\service\mobilerecording\MobileRecordingObjects;
use app\common\service\mobilerecording\MobileRecordingWork;
use think\console\Command;
use think\console\Input;
use think\console\Output;
use think\console\input\Option;
use think\facade\Db;
/** Schedule once per minute with a non-overlap lock; contains no cloud credentials in arguments. */
final class MobileRecordingCosWork extends Command
{
protected function configure()
{
$this->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; }
}
}
@@ -0,0 +1,57 @@
<?php
declare(strict_types=1);
namespace app\common\service\mobilerecording;
/** Independent authenticated frames permit bounded-memory plaintext Range playback. */
final class MobileRecordingCipher
{
public const FRAME = 1048576;
public const TAG = 16;
public const FORMAT = 'aes256gcm-frame-v1';
public static function frames(int $size): int { return max(1, (int) ceil($size / self::FRAME)); }
public static function cipherSize(int $size): int { return $size + self::frames($size) * self::TAG; }
public static function plainLength(array $object, int $index): int
{
if ($index < 0 || $index >= 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));
}
}
@@ -0,0 +1,37 @@
<?php
declare(strict_types=1);
namespace app\common\service\mobilerecording;
/** One decrypted frame cache, never a public URL or whole-file buffer. */
final class MobileRecordingCloudReader
{
private int $index = -1;
private string $plain = '';
public function __construct(private array $object, private MobileRecordingCos $cloud) {}
public function __invoke(int $offset, int $length): string
{
if ($offset < 0 || $length < 0 || $offset + $length > (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;
}
}
@@ -0,0 +1,124 @@
<?php
declare(strict_types=1);
namespace app\common\service\mobilerecording;
use app\common\service\ConfigService;
use Qcloud\Cos\Client;
/** Dedicated private adapter. Never uses FileService/CDN or the general-purpose upload engine. */
class MobileRecordingCos
{
protected function settings(): array
{
$config = ConfigService::get('storage', 'qcloud', []);
if (!is_array($config) || empty($config['access_key']) || empty($config['secret_key'])) {
throw new \RuntimeException('Private cloud configuration unavailable');
}
return $config;
}
public function location(): array
{
$config = $this->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); }
}
}
@@ -0,0 +1,8 @@
<?php
declare(strict_types=1);
namespace app\common\service\mobilerecording;
/** Only a missing/corrupt legacy local source permits prepare to reset a published local upload. */
final class MobileRecordingLocalUnavailableException extends \DomainException {}
@@ -0,0 +1,193 @@
<?php
declare(strict_types=1);
namespace app\common\service\mobilerecording;
use think\facade\Db;
/** Durable intent, resumable COS parts and a transactional deletion outbox. */
final class MobileRecordingObjects
{
public static function cloud(): MobileRecordingCos { return app(MobileRecordingCos::class); }
/** Caller holds authorized actor/binding/upload locks. Commit this before any remote write. */
public static function ensure(array $upload): array
{
$existing = Db::name('mobile_recording_object')->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;
}
}
@@ -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
{
@@ -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()
@@ -0,0 +1,46 @@
<?php
declare(strict_types=1);
namespace app\common\service\mobilerecording;
use think\facade\Db;
/** Durable scheduling even when ACL/configuration fails before a COS object intent exists. */
final class MobileRecordingWork
{
private static function update(string $id, callable $change): void
{
Db::transaction(static function () use ($id, $change): void {
// Share the API's binding lock before upload/work locks. Unbind and replacement
// remove work before upload; skipping this boundary would invert their lock order.
$peek = Db::name('mobile_recording_upload')->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; });
}
}
+2
View File
@@ -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',
@@ -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;
+7
View File
@@ -0,0 +1,7 @@
<?php
declare(strict_types=1);
// Extends the actual API/ACL/Range suite with COS fault injection. No live service or config is loaded.
define('MOBILE_RECORDING_COS_FAULT_TEST', true);
require __DIR__ . '/MobileRecordingMediaTest.php';
+26 -1
View File
@@ -11,8 +11,13 @@ use app\adminapi\logic\tcm\MobileRecordingMediaLogic as Media;
use app\adminapi\controller\tcm\MobileRecordingMediaController;
use think\Container;
use think\facade\Db;
use app\common\service\mobilerecording\MobileRecordingObjects as Objects;
use app\common\service\mobilerecording\MobileRecordingCos;
$app = new think\App();
require __DIR__ . '/fixtures/FakeMobileRecordingCos.php';
$fakeCloud = new FakeMobileRecordingCos();
Container::getInstance()->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();
+85
View File
@@ -0,0 +1,85 @@
<?php
declare(strict_types=1);
use app\common\service\mobilerecording\MobileRecordingCos;
/** In-memory synthetic COS. No configuration, credentials, SDK or network access. */
final class FakeMobileRecordingCos extends MobileRecordingCos
{
public array $current = ['bucket' => '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'; }
}
+134
View File
@@ -0,0 +1,134 @@
<?php
use app\adminapi\logic\tcm\MobileRecordingLogic as CosBinding;
use app\adminapi\logic\tcm\MobileRecordingMediaLogic as CosMedia;
use app\common\service\mobilerecording\MobileRecordingObjects as CosObjects;
use think\facade\Db as CosDb;
(static function () use ($fakeCloud, $expect, $controller, $input, $identity, $target, $prepare, $wav, $storage, $driver, $capture): void {
$binding = CosBinding::bind($input, 11)['binding'];
$bindingId = $binding['id'];
$send = static function (string $text) use ($target): array {
$p = CosMedia::prepare($target + ['kind' => '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';
})();
@@ -0,0 +1,182 @@
<?php
use app\adminapi\logic\tcm\MobileRecordingLogic as FinalBinding;
use app\adminapi\logic\tcm\MobileRecordingMediaLogic as FinalMedia;
use app\common\service\mobilerecording\MobileRecordingObjects as FinalObjects;
use think\facade\Db as FinalDb;
// Regressions for COS-FINAL-001/002/003. Runs with both SQLite and the isolated real MySQL schema.
(static function () use ($fakeCloud, $expect, $controller, $input, $storage): void {
$send = static function (string $label, string $text, int $actor = 11) use ($input): array {
$identity = ['installation_id' => $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);
})();
+66
View File
@@ -0,0 +1,66 @@
<?php
/** Exercise the actual adapter and installed SDK with a transport that cannot reach the network. */
class ProbeMobileRecordingCos extends \app\common\service\mobilerecording\MobileRecordingCos
{
public array $responses = [];
public array $requests = [];
protected function settings(): array
{
return ['bucket' => '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, [], '<InitiateMultipartUploadResult><UploadId>synthetic-multipart</UploadId></InitiateMultipartUploadResult>');
$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, [], '<Error><Code>NoSuchKey</Code><Message>synthetic-provider-path</Message></Error>');
$expect($probe->head($po) === null, 'actual SDK missing object is a recoverable absent HEAD result');
$probe->responses[] = new \GuzzleHttp\Psr7\Response(403, [], '<Error><Code>AccessDenied</Code><Message>synthetic-provider-path</Message></Error>');
$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, [], '<Error><Code>ServiceUnavailable</Code><Message>synthetic-provider-path</Message></Error>');
$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');
+10
View File
@@ -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)');