Files
saqel/backend/app/Services/WatchSessionService.php
T

66 lines
7.8 KiB
PHP

<?php
declare(strict_types=1);
namespace App\Services;
use App\Core\Database;
/** Server-owned watch evidence. Client positions are hints, never a balance. */
final class WatchSessionService {
public static function start(int $studentId, string $versionUuid, ?string $nationalId): array {
$row=self::authorisedVersion($studentId,$versionUuid,$nationalId);
if (!$row['allowed']) return $row;
$pdo=Database::getConnection(); $pdo->beginTransaction();
try {
$student=Database::selectOne('SELECT id FROM students WHERE id=? FOR UPDATE',[$studentId]);
if(!$student)return self::finish($pdo,['http_status'=>404,'status'=>'student_not_found']);
$active=Database::selectOne("SELECT uuid,video_version_id FROM watch_sessions WHERE student_id=? AND status='active' ORDER BY id DESC LIMIT 1 FOR UPDATE",[$studentId]);
if ($active) {
if((int)$active['video_version_id']===(int)$row['version_id'])return self::finish($pdo,['http_status'=>200,'status'=>'active','watch_session_id'=>$active['uuid'],'reused'=>true]);
return self::finish($pdo,['http_status'=>409,'status'=>'another_session_active','message'=>'أنهِ جلسة المشاهدة الحالية قبل بدء حصة أخرى.']);
}
$uuid=self::uuid();
Database::insert("INSERT INTO watch_sessions (uuid,student_id,video_version_id,funding_source,status) VALUES (?,?,?,?, 'active')",[$uuid,$studentId,$row['version_id'],$row['funding_source']]);
$id=(int)(Database::selectOne('SELECT id FROM watch_sessions WHERE uuid=?',[$uuid])['id'] ?? 0);
Database::insert("INSERT INTO watch_events (watch_session_id,sequence_no,event_type,client_position_ms,payload_json) VALUES (?,1,'start',0,?)",[$id,json_encode(['server_contract'=>'watch-v1'])]);
$pdo->commit(); return ['http_status'=>201,'status'=>'started','watch_session_id'=>$uuid];
} catch(\Throwable $e){if($pdo->inTransaction())$pdo->rollBack();throw $e;}
}
public static function event(int $studentId,string $sessionUuid,int $sequence,string $type,int $positionMs,array $payload=[]):array {
if(!self::isUuid($sessionUuid)||$sequence<2||!in_array($type,['heartbeat','pause','seek','resume','end','buffer'],true)||$positionMs<0) return ['http_status'=>400,'status'=>'error','message'=>'حدث المشاهدة غير صالح.'];
$pdo=Database::getConnection();$pdo->beginTransaction();try {
$session=Database::selectOne("SELECT ws.id,ws.status,ws.video_version_id,ws.funding_source,l.duration_seconds FROM watch_sessions ws JOIN video_versions vv ON vv.id=ws.video_version_id JOIN lessons l ON l.id=vv.source_lesson_id WHERE ws.uuid=? AND ws.student_id=? FOR UPDATE",[$sessionUuid,$studentId]);
if(!$session)return self::finish($pdo,['http_status'=>404,'status'=>'error','message'=>'جلسة المشاهدة غير موجودة.']);
if($session['status']!=='active')return self::finish($pdo,['http_status'=>409,'status'=>'ended','message'=>'انتهت جلسة المشاهدة.']);
$version = Database::selectOne('SELECT uuid FROM video_versions WHERE id=?', [(int)$session['video_version_id']]);
$authorised = $version ? self::authorisedVersion($studentId, (string)$version['uuid'], null) : ['allowed'=>false];
if (empty($authorised['allowed'])) {
Database::query("UPDATE watch_sessions SET status='ended',server_ended_at=NOW() WHERE id=?",[$session['id']]);
return self::finish($pdo,['http_status'=>409,'status'=>'revoked','message'=>'انتهت صلاحية هذه المشاهدة.']);
}
$last=Database::selectOne('SELECT sequence_no,event_type,client_position_ms,server_received_at FROM watch_events WHERE watch_session_id=? ORDER BY sequence_no DESC LIMIT 1 FOR UPDATE',[$session['id']]);
if((int)$last['sequence_no'] >= $sequence) return self::finish($pdo,['http_status'=>200,'status'=>'replayed']);
if((int)$last['sequence_no']+1 !== $sequence) return self::finish($pdo,['http_status'=>409,'status'=>'sequence_gap','message'=>'تسلسل أحداث المشاهدة غير متصل.']);
Database::insert('INSERT INTO watch_events (watch_session_id,sequence_no,event_type,client_position_ms,payload_json) VALUES (?,?,?,?,?)',[$session['id'],$sequence,$type,$positionMs,json_encode($payload,JSON_UNESCAPED_UNICODE)]);
$serverDelta=(int)(Database::selectOne('SELECT TIMESTAMPDIFF(SECOND, ?, NOW()) AS s',[$last['server_received_at']])['s'] ?? 0);
$decision=WatchIntervalPolicy::evaluate((string)$last['event_type'],$type,(int)$last['client_position_ms'],$positionMs,$serverDelta,max(0,(int)$session['duration_seconds'])*1000,(string)$session['funding_source']);
$eligible=(int)$decision['seconds'];
if($type==='heartbeat') Database::insert(
"INSERT INTO eligible_watch_intervals (watch_session_id,event_sequence_no,start_ms,end_ms,eligible_seconds,reason_code,policy_version,status) VALUES (?,?,?,?,?,?,'watch-v1',?)",
[$session['id'],$sequence,(int)$last['client_position_ms'],$positionMs,$eligible,$decision['reason'],$decision['status']]
);
if($type==='end'){Database::query("UPDATE watch_sessions SET status='ended',server_ended_at=NOW() WHERE id=?",[$session['id']]);}
return self::finish($pdo,['http_status'=>200,'status'=>$type==='end'?'ended':'recorded','eligible_seconds_added'=>$eligible]);
}catch(\Throwable $e){if($pdo->inTransaction())$pdo->rollBack();throw $e;}
}
private static function authorisedVersion(int $studentId,string $uuid,?string $national):array {
if(!self::isUuid($uuid))return ['http_status'=>400,'status'=>'error','message'=>'هوية نسخة الفيديو غير صالحة.'];
$row=Database::selectOne("SELECT vv.id,l.id lesson_id,l.course_id,cl.grade_key FROM video_versions vv JOIN teacher_submissions ts ON ts.id=vv.teacher_submission_id AND ts.current_published_video_version_id=vv.id AND ts.status='published' JOIN curriculum_lessons cl ON cl.id=ts.curriculum_lesson_id AND cl.source_status='approved' JOIN video_review_jobs j ON j.video_version_id=vv.id AND j.status='approved' JOIN publication_bundles pb ON pb.curriculum_lesson_id=cl.id AND pb.status='published' JOIN publication_bundle_assets pba ON pba.publication_bundle_id=pb.id AND pba.role='primary_lesson' JOIN content_assets a ON a.id=pba.content_asset_id AND a.sha256=j.markdown_sha256 AND a.review_status='approved' AND a.rights_status='cleared' JOIN lessons l ON l.id=vv.source_lesson_id AND l.encoding_status='ready' WHERE vv.uuid=? AND vv.status='published' LIMIT 1",[$uuid]);
if(!$row)return ['http_status'=>404,'status'=>'error','message'=>'نسخة الفيديو غير منشورة.'];
$access=StudentAccessControlService::validateLessonAccess($studentId,$national,StudentAccessControlService::normalizeGrade($row['grade_key']??'grade_10'),(int)$row['course_id'],(int)$row['lesson_id'],false);
if(empty($access['allowed']))return ['http_status'=>403,'status'=>'forbidden','message'=>$access['message']??'غير مصرح بالمشاهدة.'];
return ['allowed'=>true,'version_id'=>(int)$row['id'],'funding_source'=>!empty($access['is_sponsored'])?'school':(($access['reason']??'')==='paid_pass_active'?'marketplace':'none')];
}
private static function isUuid(string $v):bool{return(bool)preg_match('/^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i',$v);}
private static function uuid():string{$b=random_bytes(16);$b[6]=chr((ord($b[6])&15)|64);$b[8]=chr((ord($b[8])&63)|128);return vsprintf('%s%s-%s-%s-%s-%s%s%s',str_split(bin2hex($b),4));}
private static function finish(\PDO $pdo,array $r):array{$pdo->commit();return $r;}
}