beginTransaction(); try { $active=Database::selectOne("SELECT uuid FROM watch_sessions WHERE student_id=? AND video_version_id=? AND status='active' FOR UPDATE",[$studentId,$row['version_id']]); if ($active) { $pdo->commit(); return ['http_status'=>200,'status'=>'active','watch_session_id'=>$active['uuid'],'reused'=>true]; } $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 FROM watch_sessions ws 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'=>'انتهت جلسة المشاهدة.']); $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)]); $eligible=0; if($type==='heartbeat' && in_array($last['event_type'],['start','heartbeat','resume'],true)) { $clientDelta=$positionMs-(int)$last['client_position_ms']; $serverDelta=(int)(Database::selectOne('SELECT TIMESTAMPDIFF(SECOND, ?, NOW()) AS s',[$last['server_received_at']])['s'] ?? 0); // A contiguous interval is bounded by elapsed server time and 60s; // seeks, background bursts, and client-only minutes earn nothing. if($clientDelta>=1000 && $clientDelta<=90000 && $serverDelta>=1 && $serverDelta<=90) { $eligible=min((int)floor($clientDelta/1000),(int)$serverDelta,60); if($eligible>0) Database::insert("INSERT INTO eligible_watch_intervals (watch_session_id,start_ms,end_ms,eligible_seconds,reason_code,policy_version,status) VALUES (?,?,?,?,?,'watch-v1','measured')",[$session['id'],(int)$last['client_position_ms'],$positionMs,$eligible,'contiguous_heartbeat']); } } 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,c.grade_level 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 lessons l ON l.id=vv.source_lesson_id AND l.encoding_status='ready' JOIN courses c ON c.id=l.course_id 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_level']??'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;} }