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