Fáze požadavku: 7. Doménová vrstva & DTO
Modul 7 · Lekce 3

Řešení reálného zadání: Anonymní profil a přenosový kód v StudentProgressService

💡 Shrnutí lekce

Architektura bezregistračního systému postupu: jak vygenerovat unikátní lidsky čitelný přenosový kód, ukládat pole v JSON sloupcích a bezpečně synchronizovat stav mezi zařízeními.

Anonymní identita a synchronizace bez hesel

Jedním ze zásadních architektonických požadavků naší vzdělávací platformy agy-laravel je nulová třecí plocha (zero-friction onboarding):

  • Uživatel nemusí zadávat e-mail, heslo ani procházet procesem registrace.
  • Přesto se jeho postup kurzem a výsledky kvízů musí spolehlivě ukládat.
  • Student musí mít možnost přenést svůj postup na jiné zařízení (např. z mobilu na pracovní počítač).

Tento požadavek řeší doménová služba StudentProgressService v součinnosti s modelem StudentProfile.

Architektura anonymního profilu

  1. UUID Token: Každému návštěvníkovi je při prvním přístupu vygenerováno unikátní UUID v4, které je uloženo v HTTP-only cookie s platností 1 rok (agy_student_token).
  2. Přenosový kód (Transfer Code): Pro snadné opsání člověkem na jiné zařízení generujeme krátký kód formátu AGY-XXXX-XXXX (např. AGY-7K2M-9P4W).
  3. JSONB sloupce v PostgreSQL: Seznam dokončených lekcí (completed_lessons) i skóre z kvízů (quiz_scores) jsou uloženy v JSON formátu přímo v tabulce student_profiles.

Bezkolizní generování přenosového kódu

Přenosový kód musí být krátký, aby se dal snadno přepsat, ale zároveň unikátní v celé databázi. V StudentProgressService k tomu využíváme do-while cyklus s náhodným generováním:

public function generateUniqueTransferCode(): string
{
    do {
        $chunk1 = strtoupper(Str::random(4));
        $chunk2 = strtoupper(Str::random(4));
        $code = "AGY-{$chunk1}-{$chunk2}";
    } while (StudentProfile::where('transfer_code', $code)->exists());

    return $code;
}

Využitím 8 alfanumerických znaků s velkými písmeny a číslicemi vzniká prostor $36^8 \approx 2{,}8 \times 10^{12}$ možných kombinací, což poskytuje zanedbatelnou pravděpodobnost kolize i při desítkách tisíc studentů.

Doménová pravidla v recordQuizScore

Služba StudentProgressService také spravuje byznys pravidla pro dokončování lekcí:

public function recordQuizScore(StudentProfile $profile, int $lessonId, int $scorePercent): StudentProfile
{
    /** @var array<string, int> $scores */
    $scores = (array) ($profile->quiz_scores ?? []);
    $key = (string) $lessonId;

    // Uchováme vždy nejvyšší dosažené skóre studenta
    $scores[$key] = max((int) ($scores[$key] ?? 0), $scorePercent);
    $profile->quiz_scores = $scores;

    /** @var list<int> $completed */
    $completed = array_map('intval', (array) ($profile->completed_lessons ?? []));

    // Byznys pravidlo: Skóre >= 66 % automaticky označí lekci jako splněnou
    if ($scorePercent >= 66 && ! in_array($lessonId, $completed, true)) {
        $completed[] = $lessonId;
        sort($completed);
        $profile->completed_lessons = $completed;
    }

    $profile->last_active_at = now();
    $profile->save();

    return $profile;
}

Všimněte si, jak služba garantuje atomicitu a konzistenci:

  • Skóre se nikdy nesníží (používá se funkce max()).
  • Pokud student dosáhne alespoň 66 % správných odpovědí, lekce se automaticky zařadí do pole completed_lessons.
  • Pole jsou seřazena a normalizována jako celá čísla (intval).
  • Aktualizuje se časové razítko last_active_at.
💻 Klíčová konstrukce z praxe
// app/Services/StudentProgressService.php:84-106, 163-172
public function recordQuizScore(StudentProfile $profile, int $lessonId, int $scorePercent): StudentProfile
{
    /** @var array<string, int> $scores */
    $scores = (array) ($profile->quiz_scores ?? []);
    $key = (string) $lessonId;

    $scores[$key] = max((int) ($scores[$key] ?? 0), $scorePercent);
    $profile->quiz_scores = $scores;

    /** @var list<int> $completed */
    $completed = array_map('intval', (array) ($profile->completed_lessons ?? []));

    if ($scorePercent >= 66 && ! in_array($lessonId, $completed, true)) {
        $completed[] = $lessonId;
        sort($completed);
        $profile->completed_lessons = $completed;
    }

    $profile->last_active_at = now();
    $profile->save();

    return $profile;
}

public function generateUniqueTransferCode(): string
{
    do {
        $chunk1 = strtoupper(Str::random(4));
        $chunk2 = strtoupper(Str::random(4));
        $code = "AGY-{$chunk1}-{$chunk2}";
    } while (StudentProfile::where('transfer_code', $code)->exists());

    return $code;
}

🎯 Ověření znalostí z lekce

Kvíz otestuje pochopení architektury a reálného kódu probíraného v této lekci.

Otázka 1 z 3

Jaký formát má přenosový kód generovaný metodou generateUniqueTransferCode() a jak je zajištěna jeho unikátnost?

Otázka 2 z 3

Jaká podmínka musí být v metodě recordQuizScore() splněna, aby byla lekce automaticky označena za dokončenou?

Otázka 3 z 3

Kde a jak je primárně v prohlížeči studenta uchovávána identita pro návrat k rozpracovanému kurzu?

Zodpovězeno: 0 / 3
🔬 Živá anatomie v této aplikaci
2 soubory
Na co se v kódu zaměřit: Zaměř se na metodu generateUniqueTransferCode() v app/Services/StudentProgressService.php a její do-while cyklus s prefixem AGY-, a dále na metody markLessonCompleted() a recordQuizScore(). Všimni si, jak servisní vrstva spravuje data modelu StudentProfile a zajišťuje atomicitu aktualizací.
cesta: app/Services/StudentProgressService.php
173 řádků · 5.2 KB
Doménová služba pro správu anonymních profilů studentů, postupu lekcemi, výsledků kvízů a přenosových kódů.
1 <?php
2
3 declare(strict_types=1);
4
5 namespace App\Services;
6
7 use App\Models\StudentProfile;
8 use Illuminate\Support\Str;
9
10 final class StudentProgressService
11 {
12 public const string COOKIE_NAME = 'agy_student_token';
13
14 /**
15 * Získá existující profil podle UUID tokenu, nebo vytvoří nový anonymní profil.
16 */
17 public function resolveProfile(?string $token): StudentProfile
18 {
19 if ($token !== null && Str::isUuid($token)) {
20 $existing = StudentProfile::where('token', $token)->first();
21 if ($existing instanceof StudentProfile) {
22 $existing->update(['last_active_at' => now()]);
23
24 return $existing;
25 }
26 }
27
28 return StudentProfile::create([
29 'token' => (string) Str::uuid(),
30 'transfer_code' => $this->generateUniqueTransferCode(),
31 'completed_lessons' => [],
32 'quiz_scores' => [],
33 'last_active_at' => now(),
34 ]);
35 }
36
37 /**
38 * Označí lekci jako dokončenou.
39 */
40 public function markLessonCompleted(StudentProfile $profile, int $lessonId): StudentProfile
41 {
42 /** @var list<int> $completed */
43 $completed = array_map('intval', (array) ($profile->completed_lessons ?? []));
44
45 if (! in_array($lessonId, $completed, true)) {
46 $completed[] = $lessonId;
47 sort($completed);
48 $profile->completed_lessons = $completed;
49 $profile->last_active_at = now();
50 $profile->save();
51 }
52
53 return $profile;
54 }
55
56 /**
57 * Přepne stav dokončení lekce (zapnuto / vypnuto).
58 */
59 public function toggleLessonCompleted(StudentProfile $profile, int $lessonId): StudentProfile
60 {
61 /** @var list<int> $completed */
62 $completed = array_map('intval', (array) ($profile->completed_lessons ?? []));
63
64 $key = array_search($lessonId, $completed, true);
65 if ($key !== false) {
66 unset($completed[$key]);
67 $completed = array_values($completed);
68 } else {
69 $completed[] = $lessonId;
70 sort($completed);
71 }
72
73 $profile->completed_lessons = $completed;
74 $profile->last_active_at = now();
75 $profile->save();
76
77 return $profile;
78 }
79
80 /**
81 * Zaznamená procentuální úspěšnost kvízu k dané lekci.
82 * Při skóre >= 66 % lekci automaticky označí jako dokončenou.
83 */
84 public function recordQuizScore(StudentProfile $profile, int $lessonId, int $scorePercent): StudentProfile
85 {
86 /** @var array<string, int> $scores */
87 $scores = (array) ($profile->quiz_scores ?? []);
88 $key = (string) $lessonId;
89
90 $scores[$key] = max((int) ($scores[$key] ?? 0), $scorePercent);
91 $profile->quiz_scores = $scores;
92
93 /** @var list<int> $completed */
94 $completed = array_map('intval', (array) ($profile->completed_lessons ?? []));
95
96 if ($scorePercent >= 66 && ! in_array($lessonId, $completed, true)) {
97 $completed[] = $lessonId;
98 sort($completed);
99 $profile->completed_lessons = $completed;
100 }
101
102 $profile->last_active_at = now();
103 $profile->save();
104
105 return $profile;
106 }
107
108 /**
109 * Vyhledá profil podle přenosového kódu (normalizovaného na velká písmena bez mezer).
110 */
111 public function findByTransferCode(string $code): ?StudentProfile
112 {
113 $normalized = strtoupper(trim($code));
114
115 if ($normalized === '') {
116 return null;
117 }
118
119 return StudentProfile::where('transfer_code', $normalized)->first();
120 }
121
122 /**
123 * Spočítá souhrnné statistiky postupu studenta.
124 *
125 * @return array{
126 * completedCount: int,
127 * totalCount: int,
128 * percentage: int,
129 * averageQuizScore: int,
130 * quizCount: int
131 * }
132 */
133 public function getProgressStats(StudentProfile $profile, int $totalLessons): array
134 {
135 /** @var list<int> $completed */
136 $completed = (array) ($profile->completed_lessons ?? []);
137 $completedCount = count($completed);
138
139 /** @var array<string, int|string> $scores */
140 $scores = (array) ($profile->quiz_scores ?? []);
141 $numericScores = array_map('intval', array_values($scores));
142 $quizCount = count($numericScores);
143 $averageQuizScore = $quizCount > 0
144 ? (int) round(array_sum($numericScores) / $quizCount)
145 : 0;
146
147 $percentage = $totalLessons > 0
148 ? (int) round(($completedCount / $totalLessons) * 100)
149 : 0;
150
151 return [
152 'completedCount' => $completedCount,
153 'totalCount' => $totalLessons,
154 'percentage' => min(100, $percentage),
155 'averageQuizScore' => $averageQuizScore,
156 'quizCount' => $quizCount,
157 ];
158 }
159
160 /**
161 * Vygeneruje unikátní, snadno čitelný přenosový kód tvaru AGY-XXXX-XXXX.
162 */
163 public function generateUniqueTransferCode(): string
164 {
165 do {
166 $chunk1 = strtoupper(Str::random(4));
167 $chunk2 = strtoupper(Str::random(4));
168 $code = "AGY-{$chunk1}-{$chunk2}";
169 } while (StudentProfile::where('transfer_code', $code)->exists());
170
171 return $code;
172 }
173 }
174
cesta: app/Models/StudentProfile.php
48 řádků · 1.0 KB
Model anonymního studenta s UUID tokenem a přenosovým kódem pro synchronizaci postupu napříč zařízeními.
1 <?php
2
3 declare(strict_types=1);
4
5 namespace App\Models;
6
7 use Illuminate\Database\Eloquent\Factories\HasFactory;
8 use Illuminate\Database\Eloquent\Model;
9
10 class StudentProfile extends Model
11 {
12 use HasFactory;
13
14 /**
15 * @var list<string>
16 */
17 protected $fillable = [
18 'token',
19 'transfer_code',
20 'completed_lessons',
21 'quiz_scores',
22 'last_active_at',
23 ];
24
25 /**
26 * @return array<string, string>
27 */
28 protected function casts(): array
29 {
30 return [
31 'completed_lessons' => 'array',
32 'quiz_scores' => 'array',
33 'last_active_at' => 'datetime',
34 ];
35 }
36
37 public function hasCompletedLesson(int $lessonId): bool
38 {
39 return in_array($lessonId, $this->completed_lessons ?? [], true);
40 }
41
42 public function getQuizScore(int $lessonId): ?int
43 {
44 return isset($this->quiz_scores[(string) $lessonId])
45 ? (int) $this->quiz_scores[(string) $lessonId]
46 : null;
47 }
48 }
49
Zdroj: skutečné soubory této aplikace Plný průzkumník anatomie →