Merge pull request 'chore: Remove old Anhang and add some code to the docs' (!317) from docs/code into main
All checks were successful
Build docs / build-docs (push) Successful in 16s

Reviewed-on: #317
Reviewed-by: Constantin Simonis <constantin@simonis.lol>
This commit is contained in:
Jan K9f 2025-06-12 15:33:54 +00:00
commit 7221422d3b
No known key found for this signature in database
GPG key ID: 944223E4D46B7412
10 changed files with 489 additions and 53 deletions

View file

@ -35,6 +35,5 @@
\acro{URL}{Uniform Resource Locator}\acused{URL}
\acro{VM}{Virtual Machine}
\acro{XML}{Extensible Markup Language}
\acro{API}{Application Programming Interface}
\acro{JWT}{JSON Web Token}
\end{acronym}

View file

@ -13,66 +13,35 @@
\clearpage
\subsection{Use Case-Diagramm}
\label{app:UseCase}
\begin{figure}[htb]
\centering
\includegraphicsKeepAspectRatio{UseCase.pdf}{1.3}
\caption{Use Case-Diagramm}
\end{figure}
\subsection{Implementierungsbeispiele}
\label{app:CodeSchichten}
\subsubsection{Frontend-Schicht: Angular Component}
\label{app:FrontendComponent}
\lstinputlisting[language=C, caption={Angular TypeScript Component - Coinflip Game}]{Listings/CoinflipComponent.ts}
\clearpage
\subsection{Amortisation}
\label{app:Amortisation}
Der Zeitpunkt der Amortisation wird als Schnittpunkt der beiden Geraden angegeben.
\begin{figure}[htb]
\centering
\includegraphicsKeepAspectRatio{amortisationgrafik.png}{1}
\caption{Grafische Darstellung der Amortisation}
\end{figure}
\subsubsection{Controller-Schicht: Spring Boot REST Controller}
\label{app:ControllerSchicht}
\lstinputlisting[language=java, caption={Spring Boot REST Controller - Coinflip}]{Listings/CoinflipController.java}
\clearpage
\subsection{composer.json Konfiguration für neusta-m2-intex-client}
\label{app:ComposerJson}
\lstinputlisting[language=json, caption={Konfiguration für neusta-m2-intex-client}]{Listings/composer.json}
\subsubsection{Service-Schicht: Business Logic}
\label{app:ServiceSchicht}
\lstinputlisting[language=java, caption={Service-Klasse mit Geschäftslogik - Coinflip}]{Listings/CoinflipService.java}
\clearpage
\subsection{Deklaration zur Anlage einer SQL Tabelle im Magento 2 Umfeld}
\label{app:InstallData}
\lstinputlisting[language=xml, caption={Deklaration zur Anlage einer SQL Tabelle im Magento 2 Umfeld}]{Listings/InstallData.xml}
\subsubsection{Persistierung-Schicht: JPA Entity}
\label{app:PersistierungSchicht}
\lstinputlisting[language=java, caption={JPA Entity - Benutzer}]{Listings/UserEntity.java}
\clearpage
\subsection{Klasse: Factory}
\label{app:Factory}
\lstinputlisting[language=php, caption={Klasse: Factory}]{Listings/Factory.php}
\clearpage
\subsection{Klasse: CustomerConnection}
\label{app:CustomerConnection}
\lstinputlisting[language=php, caption={Klasse: CustomerConnection}]{Listings/CustomerConnection.php}
\clearpage
\subsection{Klasse: Connection}
\label{app:Connection}
\lstinputlisting[language=php, caption={Abstrakte Klasse: Connection}]{Listings/Connection.php}
\clearpage
\subsection{Klasse: CustomerDataController}
\label{app:CustomerDataController}
\lstinputlisting[language=php, caption={Klasse: CustomerDataController}]{Listings/CustomerDataController.php}
\clearpage
\subsection{UnitTest: FactoryTest}
\label{app:UnitTest}
\lstinputlisting[language=php, caption={Unit Test der Klasse: Factory}]{Listings/UnitTest.php}
\subsubsection{Konfiguration: Application Properties}
\label{app:Konfiguration}
\lstinputlisting[caption={Spring Boot Anwendungskonfiguration}]{Listings/application.properties}
\clearpage

View file

@ -37,3 +37,6 @@ Das Spielergebnis wird strukturiert an das Frontend übermittelt und enthält:
\item Auszahlungsbetrag (bei Gewinn: 2x Einsatz)
\item Geworfene Münzseite
\end{itemize}
\subsubsection{Implementierungsdetails}
Die vollständige Implementierung der Coinflip-Funktionalität umfasst verschiedene Architekturschichten: Das Angular Frontend-Component (siehe \ref{app:FrontendComponent}), den Spring Boot REST Controller (siehe \ref{app:ControllerSchicht}) und die Service-Schicht mit der Geschäftslogik (siehe \ref{app:ServiceSchicht}). Zusätzlich wird die Benutzer-Entity (siehe \ref{app:PersistierungSchicht}) für die Guthaben-Verwaltung verwendet.

View file

@ -2,4 +2,4 @@
\label{sec:Deployment}
Es gibt zwei Server auf denen Instanzen der Applikation laufen.
\subsection{\href{https://casino.simonis.lol/}{Entwicklungsserver}} Auf dem Entwicklungsserver läuft eine Instanz der Applikation, die für die Entwicklung und das Testen von neuen Features genutzt wird. Diese Instanz ist Lokal bei Constantin gehostet und wird durch einen Cloudflare-Tunnel öffentlich zugänglich gemacht.
\subsection{\href{https://trustworthy.casino/}{Produktionsserver}} Auf dem Produktionsserver läuft die finale Version der Applikation, die für die Nutzer zugänglich ist. Diese Instanz ist öffentlich zugänglich und wird von den Nutzern genutzt. Diese Instanz ist auf einem gemieteten Server gehostet. Die Applikation wird durch eine Nginx Reverse-Proxy bereitgestellt, die Anfragen an die \acs{API} und das Frontend weiterleitet und SSL-Zertifikate verwaltet.
\subsection{\href{https://trustworthy.casino/}{Produktionsserver}} Auf dem Produktionsserver läuft die finale Version der Applikation, die für die Nutzer zugänglich ist. Diese Instanz ist öffentlich zugänglich und wird von den Nutzern genutzt. Diese Instanz ist auf einem gemieteten Server gehostet. Die Applikation wird durch eine Nginx Reverse-Proxy bereitgestellt, die Anfragen an die \acs{API} und das Frontend weiterleitet und SSL-Zertifikate verwaltet. Die Konfiguration der Anwendung erfolgt über Umgebungsvariablen und Properties-Dateien (siehe \ref{app:Konfiguration}).

View file

@ -27,7 +27,7 @@ Services übernehmen die Kommunikation mit dem Backend und kapseln die Geschäft
\subsubsection{Backend-Architektur}
Das Backend implementiert eine klassische mehrschichtige Architektur, die eine klare Trennung der Verantwortlichkeiten gewährleistet. Die Controller-Schicht stellt die REST-\acs{API}-Endpunkte bereit und behandelt \acs{HTTP}-Anfragen. Die Service-Schicht enthält die Geschäftslogik und orchestriert verschiedene Use Cases.
Die Repository-Schicht abstrahiert den Datenzugriff und verwendet Spring Data JPA für die Kommunikation mit der Datenbank. Entity-Klassen repräsentieren die Domain-Modelle und bilden die Datenbankstrukturen ab.
Die Repository-Schicht abstrahiert den Datenzugriff und verwendet Spring Data JPA für die Kommunikation mit der Datenbank. Entity-Klassen repräsentieren die Domain-Modelle und bilden die Datenbankstrukturen ab. Eine detaillierte Darstellung der verschiedenen Architekturschichten mit konkreten Code-Beispielen findet sich im Anhang (siehe \ref{app:CodeSchichten}).
\subsection{Datenarchitektur}
Die Datenbank folgt einem relationalen Design mit klar definierten Entitätsbeziehungen. Das Schema gliedert sich in mehrere Hauptbereiche: Der User Management Bereich verwaltet Benutzerkonten und Benutzerprofile. Spielbezogene Daten wie Spielstände, Wetten und Ergebnisse werden in separaten Tabellen gespeichert, um die Integrität der Spiellogik zu gewährleisten.

View file

@ -0,0 +1,247 @@
import { NgClass, NgIf, CurrencyPipe, CommonModule } from '@angular/common';
import { HttpClient } from '@angular/common/http';
import { FormsModule } from '@angular/forms';
import {
ChangeDetectionStrategy,
Component,
ElementRef,
inject,
OnInit,
signal,
ViewChild,
} from '@angular/core';
import { AnimatedNumberComponent } from '@blackjack/components/animated-number/animated-number.component';
import { catchError, finalize } from 'rxjs/operators';
import { of } from 'rxjs';
import { AuthService } from '@service/auth.service';
import { AudioService } from '@shared/services/audio.service';
import { CoinflipGame, CoinflipRequest } from './models/coinflip.model';
@Component({
selector: 'app-coinflip',
standalone: true,
imports: [AnimatedNumberComponent, CurrencyPipe, FormsModule, CommonModule, NgIf, NgClass],
templateUrl: './coinflip.component.html',
styleUrl: './coinflip.component.css',
changeDetection: ChangeDetectionStrategy.OnPush,
})
export default class CoinflipComponent implements OnInit {
currentBet = signal(10);
balance = signal(0);
gameInProgress = signal(false);
isActionInProgress = signal(false);
gameResult = signal<CoinflipGame | null>(null);
betInputValue = signal(10);
errorMessage = signal('');
isInvalidBet = signal(false);
@ViewChild('coinElement') coinElement?: ElementRef;
audioService = inject(AudioService);
authService = inject(AuthService);
private http = inject(HttpClient);
private coinflipSound?: HTMLAudioElement;
ngOnInit(): void {
// Abonniere Benutzerupdates fuer Echtzeitaktualisierungen des Guthabens
this.authService.userSubject.subscribe((user) => {
if (user) {
this.balance.set(user.balance);
}
});
// Initialisiere Muenzwurf-Sound
this.coinflipSound = new Audio('/sounds/coinflip.mp3');
}
setBetAmount(percentage: number) {
const newBet = Math.floor(this.balance() * percentage);
this.betInputValue.set(newBet > 0 ? newBet : 1);
this.currentBet.set(this.betInputValue());
}
updateBet(event: Event) {
const inputElement = event.target as HTMLInputElement;
let value = Number(inputElement.value);
// Setze ungueltigen Einsatz-Status zurueck
this.isInvalidBet.set(false);
// Erzwinge Mindesteinsatz von 1
if (value <= 0) {
value = 1;
}
// Begrenze Einsatz auf verfuegbares Guthaben und zeige Feedback
if (value > this.balance()) {
value = this.balance();
// Visuelles Feedback anzeigen
this.isInvalidBet.set(true);
// Zeige den Fehler kurz an
setTimeout(() => this.isInvalidBet.set(false), 800);
// Aktualisiere das Eingabefeld direkt, um dem Benutzer den maximalen Wert anzuzeigen
inputElement.value = String(value);
}
// Aktualisiere Signale
this.betInputValue.set(value);
this.currentBet.set(value);
}
betHeads() {
this.placeBet('HEAD');
}
betTails() {
this.placeBet('TAILS');
}
private placeBet(side: 'HEAD' | 'TAILS') {
if (this.gameInProgress() || this.isActionInProgress()) return;
// Setze vorheriges Ergebnis zurueck
this.gameResult.set(null);
this.errorMessage.set('');
// Setze Spielstatus
this.gameInProgress.set(true);
this.isActionInProgress.set(true);
// Spiele Einsatz-Sound
this.audioService.playBetSound();
// Erstelle Einsatz-Anfrage
const request: CoinflipRequest = {
betAmount: this.currentBet(),
coinSide: side,
};
// API aufrufen
this.http
.post<CoinflipGame>('/backend/coinflip', request)
.pipe(
catchError((error) => {
console.error('Fehler beim Spielen von Coinflip:', error);
if (error.status === 400 && error.error.message.includes('insufficient')) {
this.errorMessage.set('Unzureichendes Guthaben');
} else {
this.errorMessage.set('Ein Fehler ist aufgetreten. Bitte versuche es erneut.');
}
this.gameInProgress.set(false);
return of(null);
}),
finalize(() => {
this.isActionInProgress.set(false);
})
)
.subscribe((result) => {
if (!result) return;
console.log('API-Antwort:', result);
// Behebe moegliche Inkonsistenzen bei der Eigenschaftenbenennung vom Backend
const fixedResult: CoinflipGame = {
isWin: result.isWin ?? result.win,
payout: result.payout,
coinSide: result.coinSide,
};
console.log('Korrigiertes Ergebnis:', fixedResult);
// Spiele Muenzwurf-Animation und -Sound
this.playCoinFlipAnimation(fixedResult.coinSide);
// Setze Ergebnis nach Abschluss der Animation
setTimeout(() => {
this.gameResult.set(fixedResult);
// Aktualisiere Guthaben mit neuem Wert vom Auth-Service
this.authService.loadCurrentUser();
// Spiele Gewinn-Sound, wenn der Spieler gewonnen hat
if (fixedResult.isWin) {
this.audioService.playWinSound();
}
// Setze Spielstatus nach Anzeigen des Ergebnisses zurueck
setTimeout(() => {
this.gameInProgress.set(false);
}, 1500);
}, 1100); // Kurz nach Ende der Animation
});
}
private playCoinFlipAnimation(result: 'HEAD' | 'TAILS') {
if (!this.coinElement) return;
const coinEl = this.coinElement.nativeElement;
// Setze bestehende Animationen zurueck
coinEl.classList.remove('animate-to-heads', 'animate-to-tails');
// Setze alle Inline-Styles von vorherigen Animationen zurueck
coinEl.style.transform = '';
// Erzwinge Reflow, um Animation neu zu starten
void coinEl.offsetWidth;
// Spiele Muenzwurf-Sound
if (this.coinflipSound) {
this.coinflipSound.currentTime = 0;
this.coinflipSound
.play()
.catch((err) => console.error('Fehler beim Abspielen des Sounds:', err));
}
// Fuege passende Animationsklasse basierend auf dem Ergebnis hinzu
if (result === 'HEAD') {
coinEl.classList.add('animate-to-heads');
} else {
coinEl.classList.add('animate-to-tails');
}
console.log(`Animation angewendet fuer Ergebnis: ${result}`);
}
/**
* Validiert Eingabe waehrend der Benutzer tippt, um ungueltige Werte zu verhindern
*/
validateBetInput(event: KeyboardEvent) {
// Erlaube Navigationstasten (Pfeile, Entf, Ruecktaste, Tab)
const navigationKeys = ['ArrowLeft', 'ArrowRight', 'Delete', 'Backspace', 'Tab'];
if (navigationKeys.includes(event.key)) {
return;
}
// Erlaube nur Zahlen
if (!/^\d$/.test(event.key)) {
event.preventDefault();
return;
}
// Ermittle den Wert, der nach dem Tastendruck entstehen wuerde
const input = event.target as HTMLInputElement;
const currentValue = input.value;
const cursorPosition = input.selectionStart || 0;
const newValue =
currentValue.substring(0, cursorPosition) +
event.key +
currentValue.substring(input.selectionEnd || cursorPosition);
const numValue = Number(newValue);
// Verhindere Werte, die groesser als das Guthaben sind
if (numValue > this.balance()) {
event.preventDefault();
}
}
getResultClass() {
if (!this.gameResult()) return '';
const result = this.gameResult();
const isWinner = result?.isWin || result?.win;
return isWinner ? 'text-emerald-500' : 'text-accent-red';
}
}

View file

@ -0,0 +1,38 @@
package de.szut.casino.coinflip;
import de.szut.casino.exceptionHandling.exceptions.InsufficientFundsException;
import de.szut.casino.exceptionHandling.exceptions.UserNotFoundException;
import de.szut.casino.shared.service.BalanceService;
import de.szut.casino.user.UserEntity;
import de.szut.casino.user.UserService;
import jakarta.validation.Valid;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RestController;
import java.util.Optional;
@RestController
public class CoinflipController {
private final UserService userService;
private final BalanceService balanceService;
private final CoinflipService coinflipService;
public CoinflipController(UserService userService, BalanceService balanceService, CoinflipService coinflipService) {
this.userService = userService;
this.balanceService = balanceService;
this.coinflipService = coinflipService;
}
@PostMapping("/coinflip")
public ResponseEntity<Object> coinFlip(@RequestBody @Valid CoinflipDto coinflipDto) {
UserEntity user = userService.getCurrentUser();
if (!this.balanceService.hasFunds(user, coinflipDto)) {
throw new InsufficientFundsException();
}
return ResponseEntity.ok(coinflipService.play(user, coinflipDto));
}
}

View file

@ -0,0 +1,35 @@
package de.szut.casino.coinflip;
import de.szut.casino.shared.service.BalanceService;
import de.szut.casino.user.UserEntity;
import org.springframework.stereotype.Service;
import java.math.BigDecimal;
import java.util.Random;
@Service
public class CoinflipService {
private final Random random;
private final BalanceService balanceService;
public CoinflipService(BalanceService balanceService, Random random) {
this.balanceService = balanceService;
this.random = random;
}
public CoinflipResult play(UserEntity user, CoinflipDto coinflipDto) {
this.balanceService.subtractFunds(user, coinflipDto.getBetAmount());
CoinSide coinSide = this.random.nextBoolean() ? CoinSide.HEAD : CoinSide.TAILS;
CoinflipResult coinflipResult = new CoinflipResult(false, BigDecimal.ZERO, coinSide);
if (coinSide == coinflipDto.getCoinSide()) {
coinflipResult.setWin(true);
BigDecimal payout = coinflipDto.getBetAmount().multiply(BigDecimal.TWO);
this.balanceService.addFunds(user, payout);
coinflipResult.setPayout(payout);
}
return coinflipResult;
}
}

View file

@ -0,0 +1,92 @@
package de.szut.casino.user;
import jakarta.persistence.*;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.Setter;
import java.math.BigDecimal;
@Setter
@Getter
@Entity
@NoArgsConstructor
public class UserEntity {
@Id
@GeneratedValue
private Long id;
@Version
private Long version;
@Column(unique = true)
private String email;
@Column(unique = true)
private String username;
private String password;
@Column(precision = 19, scale = 2)
private BigDecimal balance;
private Boolean emailVerified = false;
private String verificationToken;
private String passwordResetToken;
@Enumerated(EnumType.STRING)
private AuthProvider provider = AuthProvider.LOCAL;
private String providerId;
public UserEntity(String email, String username, String password, BigDecimal balance, String verificationToken) {
this.email = email;
this.username = username;
this.password = password;
this.balance = balance;
this.verificationToken = verificationToken;
}
public UserEntity(String email, String username, AuthProvider provider, String providerId, BigDecimal balance) {
this.email = email;
this.username = username;
this.provider = provider;
this.providerId = providerId;
this.balance = balance;
this.emailVerified = true; // OAuth providers verify emails
}
public void addBalance(BigDecimal amountToAdd) {
if (amountToAdd == null || amountToAdd.compareTo(BigDecimal.ZERO) <= 0) {
return;
}
if (this.balance == null) {
this.balance = BigDecimal.ZERO;
}
this.balance = this.balance.add(amountToAdd);
}
public void subtractBalance(BigDecimal amountToSubtract) {
if (amountToSubtract == null || amountToSubtract.compareTo(BigDecimal.ZERO) <= 0) {
throw new IllegalArgumentException("Amount to subtract must be positive.");
}
if (this.balance == null) {
this.balance = BigDecimal.ZERO;
}
if (this.balance.compareTo(amountToSubtract) < 0) {
throw new IllegalStateException("Insufficient funds to subtract " + amountToSubtract);
}
this.balance = this.balance.subtract(amountToSubtract);
}
public String getEmailAddress() {
return "${name} <${email}>".replace("${name}", this.username).replace("${email}", this.email);
}
}

View file

@ -0,0 +1,53 @@
spring.datasource.url=jdbc:postgresql://${DB_HOST:localhost}:${DB_PORT:5432}/${DB_NAME:postgresdb}
spring.datasource.username=${DB_USER:postgres_user}
spring.datasource.password=${DB_PASS:postgres_pass}
server.port=${HTTP_PORT:8080}
spring.jpa.hibernate.ddl-auto=update
stripe.secret.key=${STRIPE_SECRET_KEY:sk_test_51QrePYIvCfqz7ANgqam8rEwWcMeKiLOof3j6SCMgu2sl4sESP45DJxca16mWcYo1sQaiBv32CMR6Z4AAAGQPCJo300ubuZKO8I}
stripe.webhook.secret=${STRIPE_WEBHOOK_SECRET:whsec_746b6a488665f6057118bdb4a2b32f4916f16c277109eeaed5e8f8e8b81b8c15}
app.frontend-host=${FE_URL:http://localhost:4200}
app.mail.authentication=${MAIL_AUTHENTICATION:false}
app.mail.host=${MAIL_HOST:localhost}
app.mail.port=${MAIL_PORT:1025}
app.mail.username=${MAIL_USER:null}
app.mail.password=${MAIL_PASS:null}
app.mail.from-address=${MAIL_FROM:casino@localhost}
app.mail.protocol=${MAIL_PROTOCOL:smtp}
spring.application.name=casino
# JWT Configuration
jwt.secret=${JWT_SECRET:5367566B59703373367639792F423F4528482B4D6251655468576D5A71347437}
jwt.expiration.ms=${JWT_EXPIRATION_MS:86400000}
# Logging
logging.level.org.springframework.security=DEBUG
# Swagger
springdoc.swagger-ui.path=swagger
springdoc.swagger-ui.try-it-out-enabled=true
# GitHub OAuth2 Configuration
spring.security.oauth2.client.registration.github.client-id=${GITHUB_CLIENT_ID:Ov23lingzZsPn1wwACoK}
spring.security.oauth2.client.registration.github.client-secret=${GITHUB_CLIENT_SECRET:4b327fb3b1ab67584a03bcb9d53fa6439fbccad7}
spring.security.oauth2.client.registration.github.redirect-uri=${app.frontend-host}/oauth2/callback/github
spring.security.oauth2.client.registration.github.scope=user:email,read:user
spring.security.oauth2.client.provider.github.authorization-uri=https://github.com/login/oauth/authorize
spring.security.oauth2.client.provider.github.token-uri=https://github.com/login/oauth/access_token
spring.security.oauth2.client.provider.github.user-info-uri=https://api.github.com/user
spring.security.oauth2.client.provider.github.user-name-attribute=login
# OAuth Success and Failure URLs
app.oauth2.authorizedRedirectUris=${app.frontend-host}/auth/oauth2/callback
# Google OAuth2 Configuration
spring.security.oauth2.client.registration.google.client-id=${GOOGLE_CLIENT_ID:350791038883-c1r7v4o793itq8a0rh7dut7itm7uneam.apps.googleusercontent.com}
spring.security.oauth2.client.registration.google.client-secret=${GOOGLE_CLIENT_SECRET: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}
spring.security.oauth2.client.registration.google.redirect-uri=${app.frontend-host}/oauth2/callback/google
spring.security.oauth2.client.registration.google.scope=email,profile
spring.security.oauth2.client.provider.google.authorization-uri=https://accounts.google.com/o/oauth2/v2/auth
spring.security.oauth2.client.provider.google.token-uri=https://oauth2.googleapis.com/token
spring.security.oauth2.client.provider.google.user-info-uri=https://www.googleapis.com/oauth2/v3/userinfo
spring.security.oauth2.client.provider.google.user-name-attribute=sub