Compare commits
17 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 7c4a42327d | |||
| 52e389db24 | |||
| efaf5a3794 | |||
| 4fe93b5ff3 | |||
| 0f2d1b1efe | |||
| 5ff05242a8 | |||
| b06c77a1eb | |||
| 03bc669efe | |||
| c3873ddd84 | |||
| d7ceeac1b0 | |||
| cdbd3cd9b4 | |||
| a708c74425 | |||
| 9ad7651c44 | |||
| 389392fd1d | |||
| aaebeaa547 | |||
| 03ee3855f5 | |||
| 94a39cf3b4 |
@@ -6,8 +6,10 @@ on:
|
|||||||
- 'v*'
|
- 'v*'
|
||||||
|
|
||||||
env:
|
env:
|
||||||
REGISTRY: git.igmlcreation.fr
|
GITEA_REGISTRY: git.igmlcreation.fr
|
||||||
REGISTRY_USER: ietm64
|
GITEA_REGISTRY_USER: ietm64
|
||||||
|
GHCR_REGISTRY: ghcr.io
|
||||||
|
GHCR_NAMESPACE: igmlcreation
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
build:
|
build:
|
||||||
@@ -26,19 +28,39 @@ jobs:
|
|||||||
- name: Login to Gitea Registry
|
- name: Login to Gitea Registry
|
||||||
uses: docker/login-action@v3
|
uses: docker/login-action@v3
|
||||||
with:
|
with:
|
||||||
registry: ${{ env.REGISTRY }}
|
registry: ${{ env.GITEA_REGISTRY }}
|
||||||
username: ${{ env.REGISTRY_USER }}
|
username: ${{ env.GITEA_REGISTRY_USER }}
|
||||||
password: ${{ secrets.DOCKER_PAT }}
|
password: ${{ secrets.DOCKER_PAT }}
|
||||||
|
|
||||||
|
# Login to GHCR (GitHub Container Registry) pour distribuer les images
|
||||||
|
# publiquement aux utilisateurs finaux. Reputation domaine plus elevee
|
||||||
|
# que git.igmlcreation.fr (mieux pour les antivirus / SmartScreen).
|
||||||
|
- name: Login to GHCR
|
||||||
|
uses: docker/login-action@v3
|
||||||
|
with:
|
||||||
|
registry: ${{ env.GHCR_REGISTRY }}
|
||||||
|
username: ${{ env.GHCR_NAMESPACE }}
|
||||||
|
password: ${{ secrets.GHCR_TOKEN }}
|
||||||
|
|
||||||
- name: Extract version
|
- name: Extract version
|
||||||
id: meta
|
id: meta
|
||||||
run: echo "version=${GITHUB_REF_NAME#v}" >> $GITHUB_OUTPUT
|
run: echo "version=${GITHUB_REF_NAME#v}" >> $GITHUB_OUTPUT
|
||||||
|
|
||||||
|
# Push vers les deux registries en un seul build (build-push-action
|
||||||
|
# accepte une liste de tags ; aucun build supplementaire necessaire).
|
||||||
|
# Naming :
|
||||||
|
# - Gitea : conserve l'ancien pattern ietm64/<component> pour ne pas
|
||||||
|
# casser les installs existantes qui ont REGISTRY=git.igmlcreation.fr
|
||||||
|
# dans leur .env.
|
||||||
|
# - GHCR : nouveau pattern igmlcreation/loremind-<component> qui evite
|
||||||
|
# la collision avec d'autres projets de l'org.
|
||||||
- name: Build & push ${{ matrix.component }}
|
- name: Build & push ${{ matrix.component }}
|
||||||
uses: docker/build-push-action@v5
|
uses: docker/build-push-action@v5
|
||||||
with:
|
with:
|
||||||
context: ./${{ matrix.component }}
|
context: ./${{ matrix.component }}
|
||||||
push: true
|
push: true
|
||||||
tags: |
|
tags: |
|
||||||
${{ env.REGISTRY }}/${{ env.REGISTRY_USER }}/${{ matrix.component }}:latest
|
${{ env.GITEA_REGISTRY }}/${{ env.GITEA_REGISTRY_USER }}/${{ matrix.component }}:latest
|
||||||
${{ env.REGISTRY }}/${{ env.REGISTRY_USER }}/${{ matrix.component }}:${{ steps.meta.outputs.version }}
|
${{ env.GITEA_REGISTRY }}/${{ env.GITEA_REGISTRY_USER }}/${{ matrix.component }}:${{ steps.meta.outputs.version }}
|
||||||
|
${{ env.GHCR_REGISTRY }}/${{ env.GHCR_NAMESPACE }}/loremind-${{ matrix.component }}:latest
|
||||||
|
${{ env.GHCR_REGISTRY }}/${{ env.GHCR_NAMESPACE }}/loremind-${{ matrix.component }}:${{ steps.meta.outputs.version }}
|
||||||
|
|||||||
11
.gitignore
vendored
11
.gitignore
vendored
@@ -7,6 +7,11 @@
|
|||||||
brain/data/settings.json
|
brain/data/settings.json
|
||||||
*.key
|
*.key
|
||||||
*.pem
|
*.pem
|
||||||
|
# Exception : la cle PUBLIQUE JWT du relais Patreon est destinee a etre
|
||||||
|
# embarquee dans le binaire. Pas de risque a la committer (c'est une cle
|
||||||
|
# publique par construction). Sans cette exception, le module licensing
|
||||||
|
# est silencieusement desactive dans les builds CI.
|
||||||
|
!core/src/main/resources/licensing/jwt-public-key.pem
|
||||||
|
|
||||||
# ============================================================================
|
# ============================================================================
|
||||||
# Java / Spring Boot / Maven
|
# Java / Spring Boot / Maven
|
||||||
@@ -91,8 +96,14 @@ Thumbs.db
|
|||||||
# Documentation hors-code (conservee hors du repo)
|
# Documentation hors-code (conservee hors du repo)
|
||||||
# ============================================================================
|
# ============================================================================
|
||||||
docs/
|
docs/
|
||||||
|
loremind-docs/
|
||||||
|
|
||||||
# ============================================================================
|
# ============================================================================
|
||||||
# Docker Compose override (dev uniquement, non-distribue aux end users)
|
# Docker Compose override (dev uniquement, non-distribue aux end users)
|
||||||
# ============================================================================
|
# ============================================================================
|
||||||
docker-compose.override.yml
|
docker-compose.override.yml
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
# Relais OAuth Patreon (repo Gitea separe, clone localement pour facilite)
|
||||||
|
# ============================================================================
|
||||||
|
relay/
|
||||||
|
|||||||
311
INSTALL.md
311
INSTALL.md
@@ -1,311 +0,0 @@
|
|||||||
# Installation de LoreMindMJ
|
|
||||||
|
|
||||||
Ce document decrit la procedure d'installation de LoreMindMJ. Temps estime :
|
|
||||||
5 a 10 minutes selon la qualite de la connexion reseau.
|
|
||||||
|
|
||||||
## 1. Prerequis
|
|
||||||
|
|
||||||
- **Docker Desktop** ([Windows](https://www.docker.com/products/docker-desktop/) /
|
|
||||||
[Mac](https://www.docker.com/products/docker-desktop/)) ou
|
|
||||||
**Docker Engine + Compose v2** (Linux). Verification :
|
|
||||||
```
|
|
||||||
docker --version
|
|
||||||
docker compose version
|
|
||||||
```
|
|
||||||
Compose v2 est requis : la commande est `docker compose`, non `docker-compose`.
|
|
||||||
|
|
||||||
- **Un fournisseur LLM**, au choix :
|
|
||||||
- **[Ollama](https://ollama.com/)** installe sur la machine hote (gratuit,
|
|
||||||
local, necessite environ 6 Go de RAM libre pour les modeles recommandes).
|
|
||||||
- **Une cle API [1min.ai](https://1min.ai)** (hebergement cloud, facturation
|
|
||||||
a l'usage, aucune installation supplementaire requise).
|
|
||||||
|
|
||||||
- Environ **2 Go d'espace disque** pour les images Docker, auxquels s'ajoute
|
|
||||||
la taille des modeles Ollama si l'option locale est retenue.
|
|
||||||
|
|
||||||
## 2. Recuperation des fichiers
|
|
||||||
|
|
||||||
Telecharger les deux fichiers suivants depuis la
|
|
||||||
[derniere release](https://git.igmlcreation.fr/ietm64/LoreMindMJ/releases) et
|
|
||||||
les placer dans un dossier dedie (par exemple `~/loremind/` ou
|
|
||||||
`C:\Programs\loremind\`) :
|
|
||||||
|
|
||||||
- `docker-compose.yml`
|
|
||||||
- `.env.example`
|
|
||||||
|
|
||||||
Le code source n'est pas necessaire : les images sont pre-construites et
|
|
||||||
publiees sur le registry Gitea `git.igmlcreation.fr` (non Docker Hub). Le
|
|
||||||
premier `docker compose pull` les telechargera automatiquement.
|
|
||||||
|
|
||||||
## 3. Configuration du fichier `.env`
|
|
||||||
|
|
||||||
Renommer `.env.example` en `.env` et l'ouvrir dans un editeur de texte. **Trois
|
|
||||||
variables sont obligatoires** ; sans elles, `docker compose up` refusera de
|
|
||||||
demarrer. Ce comportement est volontaire afin d'eviter tout deploiement
|
|
||||||
non-securise par defaut.
|
|
||||||
|
|
||||||
### `POSTGRES_PASSWORD`
|
|
||||||
|
|
||||||
Mot de passe de la base de donnees PostgreSQL. Choisir une valeur robuste.
|
|
||||||
Seuls les conteneurs utilisent cette valeur : il n'est pas necessaire de la
|
|
||||||
memoriser au-dela du fichier `.env`.
|
|
||||||
|
|
||||||
### `ADMIN_PASSWORD`
|
|
||||||
|
|
||||||
Protege l'ecran **Parametres** de l'application via HTTP Basic. Cette valeur
|
|
||||||
sera demandee par le navigateur lors de toute modification de la configuration
|
|
||||||
(changement de modele LLM, saisie de cle API, etc.). Le nom d'utilisateur par
|
|
||||||
defaut est `admin`, modifiable via la variable `ADMIN_USERNAME`.
|
|
||||||
|
|
||||||
### `BRAIN_INTERNAL_SECRET`
|
|
||||||
|
|
||||||
Secret partage entre le service Java (`core`) et le service Python (`brain`).
|
|
||||||
Empeche toute requete externe d'atteindre directement le service Brain.
|
|
||||||
Generer une valeur aleatoire de 64 caracteres hexadecimaux :
|
|
||||||
|
|
||||||
```
|
|
||||||
openssl rand -hex 32
|
|
||||||
```
|
|
||||||
|
|
||||||
Sous Windows sans `openssl`, utiliser PowerShell :
|
|
||||||
|
|
||||||
```powershell
|
|
||||||
-join ((48..57) + (97..102) | Get-Random -Count 64 | % {[char]$_})
|
|
||||||
```
|
|
||||||
|
|
||||||
### Variables optionnelles
|
|
||||||
|
|
||||||
- `WEB_PORT` (defaut `8081`) : port d'ecoute de l'interface web.
|
|
||||||
- `ADMIN_USERNAME` (defaut `admin`) : identifiant de la popup Parametres.
|
|
||||||
- `LLM_PROVIDER` (defaut `ollama`) : choix du fournisseur LLM (voir
|
|
||||||
section 5).
|
|
||||||
|
|
||||||
Les autres variables (`MINIO_USER`/`MINIO_PASSWORD`, `POSTGRES_DB`,
|
|
||||||
`POSTGRES_USER`) disposent de valeurs par defaut adaptees a un deploiement
|
|
||||||
personnel et peuvent etre conservees en l'etat.
|
|
||||||
|
|
||||||
## 4. Lancement de la stack
|
|
||||||
|
|
||||||
Depuis le dossier contenant `docker-compose.yml` et `.env` :
|
|
||||||
|
|
||||||
```
|
|
||||||
docker compose up -d
|
|
||||||
```
|
|
||||||
|
|
||||||
Le premier demarrage telecharge les images (environ 1 a 2 Go au total) et
|
|
||||||
initialise la base. Compter 2 a 5 minutes selon la qualite de la connexion.
|
|
||||||
La progression peut etre suivie via :
|
|
||||||
|
|
||||||
```
|
|
||||||
docker compose logs -f
|
|
||||||
```
|
|
||||||
|
|
||||||
(`Ctrl+C` pour quitter l'affichage ; les services continuent de fonctionner
|
|
||||||
en arriere-plan.)
|
|
||||||
|
|
||||||
Une fois les services en etat `healthy`, ouvrir **http://localhost:8081**
|
|
||||||
dans un navigateur.
|
|
||||||
|
|
||||||
### Verification du fonctionnement
|
|
||||||
|
|
||||||
```
|
|
||||||
docker compose ps
|
|
||||||
```
|
|
||||||
|
|
||||||
Cinq conteneurs doivent apparaitre en etat `Up` ou `healthy` :
|
|
||||||
`loremind-postgres`, `loremind-minio`, `loremind-core`, `loremind-brain`,
|
|
||||||
`loremind-web`. Le conteneur `loremind-minio-init` s'arrete automatiquement
|
|
||||||
apres creation du bucket d'images : ce comportement est normal.
|
|
||||||
|
|
||||||
## 5. Configuration du fournisseur LLM
|
|
||||||
|
|
||||||
### Ollama (local, gratuit)
|
|
||||||
|
|
||||||
Installer Ollama sur la machine hote (pas dans Docker), puis telecharger un
|
|
||||||
modele :
|
|
||||||
|
|
||||||
```
|
|
||||||
ollama pull gemma4:26b
|
|
||||||
```
|
|
||||||
|
|
||||||
Dans `.env` :
|
|
||||||
|
|
||||||
```
|
|
||||||
LLM_PROVIDER=ollama
|
|
||||||
LLM_MODEL=gemma4:26b
|
|
||||||
OLLAMA_BASE_URL=http://host.docker.internal:11434
|
|
||||||
```
|
|
||||||
|
|
||||||
L'adresse `host.docker.internal` permet au conteneur `brain` d'atteindre
|
|
||||||
Ollama sur la machine hote. Cette resolution est native sous Docker Desktop
|
|
||||||
(Mac / Windows). Sous Linux, le fichier `docker-compose.yml` declare un
|
|
||||||
`extra_hosts` equivalent.
|
|
||||||
|
|
||||||
### 1min.ai (cloud, paye)
|
|
||||||
|
|
||||||
Dans `.env` :
|
|
||||||
|
|
||||||
```
|
|
||||||
LLM_PROVIDER=onemin
|
|
||||||
ONEMIN_API_KEY=sk-...
|
|
||||||
ONEMIN_MODEL=gpt-4o-mini
|
|
||||||
```
|
|
||||||
|
|
||||||
### Modification a chaud
|
|
||||||
|
|
||||||
Le fournisseur, le modele et la cle API peuvent etre modifies a chaud depuis
|
|
||||||
l'ecran **Parametres** de l'application. Les modifications sont persistees
|
|
||||||
dans un volume Docker et survivent aux redemarrages. Les variables d'env du
|
|
||||||
fichier `.env` sont uniquement utilisees comme valeurs initiales au premier
|
|
||||||
demarrage.
|
|
||||||
|
|
||||||
## 6. Mise a jour
|
|
||||||
|
|
||||||
```
|
|
||||||
docker compose pull
|
|
||||||
docker compose up -d
|
|
||||||
```
|
|
||||||
|
|
||||||
Les donnees (base PostgreSQL, images MinIO, configuration Brain) sont
|
|
||||||
stockees dans des volumes Docker et survivent aux mises a jour.
|
|
||||||
|
|
||||||
## 7. Sauvegarde
|
|
||||||
|
|
||||||
Les donnees sont reparties dans trois volumes Docker :
|
|
||||||
|
|
||||||
- `loremindmj_postgres-data` — ensemble des donnees applicatives (lores,
|
|
||||||
campagnes, pages, templates, branches narratives, etc.).
|
|
||||||
- `loremindmj_minio-data` — images uploadees.
|
|
||||||
- `loremindmj_brain-data` — parametres IA (fournisseur courant, cle API
|
|
||||||
1min.ai).
|
|
||||||
|
|
||||||
### Export SQL de la base
|
|
||||||
|
|
||||||
```
|
|
||||||
docker compose exec postgres pg_dump -U loremind loremind > backup.sql
|
|
||||||
```
|
|
||||||
|
|
||||||
### Sauvegarde complete des volumes
|
|
||||||
|
|
||||||
Arreter la stack au prealable afin de garantir la coherence des donnees :
|
|
||||||
|
|
||||||
```
|
|
||||||
docker compose stop
|
|
||||||
docker run --rm -v loremindmj_postgres-data:/data -v $(pwd):/backup alpine tar czf /backup/postgres-data.tar.gz -C /data .
|
|
||||||
docker run --rm -v loremindmj_minio-data:/data -v $(pwd):/backup alpine tar czf /backup/minio-data.tar.gz -C /data .
|
|
||||||
docker compose start
|
|
||||||
```
|
|
||||||
|
|
||||||
Sous Windows PowerShell, remplacer `$(pwd)` par `${PWD}`.
|
|
||||||
|
|
||||||
## 8. Resolution des problemes
|
|
||||||
|
|
||||||
### Port 8081 deja utilise
|
|
||||||
|
|
||||||
Modifier `WEB_PORT=8082` (ou toute autre valeur libre) dans `.env`, puis
|
|
||||||
relancer :
|
|
||||||
|
|
||||||
```
|
|
||||||
docker compose up -d
|
|
||||||
```
|
|
||||||
|
|
||||||
### Erreur "set POSTGRES_PASSWORD in .env" (ou variable equivalente) au lancement
|
|
||||||
|
|
||||||
Une des trois variables obligatoires de l'etape 3 est manquante. Verifier le
|
|
||||||
contenu du fichier `.env`.
|
|
||||||
|
|
||||||
### Popup "Ce site vous demande de vous connecter" sur l'ecran Parametres
|
|
||||||
|
|
||||||
Comportement attendu : il s'agit de l'authentification HTTP Basic. Utiliser
|
|
||||||
la valeur de `ADMIN_USERNAME` (par defaut `admin`) et celle de
|
|
||||||
`ADMIN_PASSWORD`.
|
|
||||||
|
|
||||||
### Erreurs `password authentication failed` en boucle dans les logs Postgres
|
|
||||||
|
|
||||||
Si la variable `POSTGRES_PASSWORD` a ete modifiee apres un premier lancement,
|
|
||||||
le volume Postgres conserve l'ancien mot de passe (initialise une seule fois).
|
|
||||||
Deux options :
|
|
||||||
|
|
||||||
- **Redemarrer avec un volume vierge** (entraine la perte des donnees) :
|
|
||||||
```
|
|
||||||
docker compose down -v
|
|
||||||
docker compose up -d
|
|
||||||
```
|
|
||||||
- **Modifier le mot de passe en base** sans toucher au volume :
|
|
||||||
```
|
|
||||||
docker compose exec postgres psql -U postgres
|
|
||||||
```
|
|
||||||
Puis dans le prompt `psql` :
|
|
||||||
```sql
|
|
||||||
ALTER USER loremind WITH PASSWORD 'valeur_exacte_du_env';
|
|
||||||
\q
|
|
||||||
```
|
|
||||||
Redemarrer ensuite le Core : `docker compose restart core`.
|
|
||||||
|
|
||||||
### Erreur "502 Bad Gateway" ou message d'erreur IA dans l'interface
|
|
||||||
|
|
||||||
Le service Brain ne parvient pas a contacter le fournisseur LLM. Verifier :
|
|
||||||
|
|
||||||
- **Ollama** : `ollama serve` est-il actif ? Le modele est-il telecharge
|
|
||||||
(`ollama list`) ? La valeur de `LLM_MODEL` correspond-elle exactement au
|
|
||||||
nom d'un modele liste ?
|
|
||||||
- **1min.ai** : la cle API est-elle valide ? Le modele existe-t-il ?
|
|
||||||
- Consulter les logs du Brain :
|
|
||||||
```
|
|
||||||
docker compose logs brain
|
|
||||||
```
|
|
||||||
|
|
||||||
### Un service ne demarre pas ou reste en etat `unhealthy`
|
|
||||||
|
|
||||||
Consulter les logs du service concerne :
|
|
||||||
|
|
||||||
```
|
|
||||||
docker compose logs <service>
|
|
||||||
```
|
|
||||||
|
|
||||||
Services disponibles : `postgres`, `minio`, `core`, `brain`, `web`.
|
|
||||||
|
|
||||||
### Redemarrage d'un service apres modification du `.env`
|
|
||||||
|
|
||||||
```
|
|
||||||
docker compose up -d <service>
|
|
||||||
```
|
|
||||||
|
|
||||||
Redemarrage complet : `docker compose restart`.
|
|
||||||
|
|
||||||
### Remise a zero complete (PERTE DES DONNEES)
|
|
||||||
|
|
||||||
```
|
|
||||||
docker compose down -v
|
|
||||||
```
|
|
||||||
|
|
||||||
L'option `-v` supprime les volumes. L'ensemble des lores, campagnes, images
|
|
||||||
et parametres est perdu de maniere definitive.
|
|
||||||
|
|
||||||
### "No such image" ou "pull access denied" au premier lancement
|
|
||||||
|
|
||||||
Le registry Gitea peut necessiter une authentification selon la visibilite
|
|
||||||
configuree pour les images. Contacter l'editeur du projet.
|
|
||||||
|
|
||||||
## 9. Exposition reseau des services
|
|
||||||
|
|
||||||
- **Interface web** : http://localhost:8081 (port configurable via
|
|
||||||
`WEB_PORT`).
|
|
||||||
- **PostgreSQL** : accessible uniquement via le reseau Docker interne, non
|
|
||||||
expose vers l'hote.
|
|
||||||
- **MinIO** : accessible uniquement via le reseau Docker interne. Les images
|
|
||||||
transitent par le reverse-proxy Java sur `/api/images/{id}/content`. Le
|
|
||||||
binding `127.0.0.1:9000/9001` defini dans `docker-compose.override.yml`
|
|
||||||
n'est actif qu'en developpement.
|
|
||||||
- **Brain Python** : accessible uniquement via le reseau Docker interne.
|
|
||||||
Toute requete doit porter l'en-tete `X-Internal-Secret`, injectee
|
|
||||||
automatiquement par le Core Java et jamais exposee au navigateur.
|
|
||||||
|
|
||||||
## 10. Desinstallation
|
|
||||||
|
|
||||||
```
|
|
||||||
docker compose down -v
|
|
||||||
docker image rm git.igmlcreation.fr/ietm64/core git.igmlcreation.fr/ietm64/brain git.igmlcreation.fr/ietm64/web
|
|
||||||
```
|
|
||||||
|
|
||||||
Supprimer ensuite le dossier contenant `docker-compose.yml` et `.env`.
|
|
||||||
70
README.md
70
README.md
@@ -1,69 +1,31 @@
|
|||||||
# LoreMind
|
# LoreMind
|
||||||
|
|
||||||
Application web d'aide aux Maîtres de Jeu (JDR) pour centraliser la gestion de l'univers (Lore) et le suivi des campagnes, avec un moteur IA intégré pour générer du contenu structuré.
|

|
||||||
|
|
||||||
|
Loremind est une application web angular auto-hébergable afin de venir en aide aux Maîtres de jeu qui souhaitent centraliser leur univers et leurs campagnes.
|
||||||
|
Cette dernière intègre un moteur IA qui va ingérer le contenu du lore et de la campagne afin de pouvoir répondre à des questions précises sur l'univers ou la campagne, mais également proposer des idées de création dans le contexte de la campagne et du lore.
|
||||||
|
Pour le moment seul Ollama est supporté pour la partie locale, il y-a également une intégration pour 1min.ai. Plus tard, d'autres moteurs seront supportés.
|
||||||
|
|
||||||
|
## Documentation
|
||||||
|
|
||||||
|
La documentation complète est accessible sur le site [loremind-docs](https://loremind-docs.igmlcreation.fr/)
|
||||||
|
|
||||||
|
Pour l'installation, consultez le guide dans cette dernière .
|
||||||
|
|
||||||
## Fonctionnalités
|
## Fonctionnalités
|
||||||
|
|
||||||
- Gestion centralisée du Lore : Lieux, Factions, PNJ, et tous les éléments de votre univers
|
- Gestion centralisée du Lore : Lieux, Factions, PNJ, et tous les éléments de votre univers
|
||||||
- Suivi de campagnes : Sessions, actions des joueurs, chronologie
|
- Suivi de campagnes : Sessions, actions des joueurs, chronologie
|
||||||
- Moteur IA intégré : Génération automatique de contenu (PNJ, Villes, Quêtes) à partir de templates
|
- Moteur IA intégré : Génération automatique de contenu (PNJ, Villes, Quêtes) à partir de templates
|
||||||
- Export vers FoundryVTT : Transfert structuré des données vers votre VTT préféré (en développement)
|
|
||||||
|
|
||||||
## Captures d'écran
|
## Démo
|
||||||
|
|
||||||
### Page d'accueil
|
Une démo est disponible sur le site [loremind-demo](https://loremind-demo.igmlcreation.fr/)
|
||||||

|
|
||||||
|
|
||||||
### Recherche
|
!! Attention, la démo est uniquement accessible à 10 personnes à la fois (instances personnalisées). Cette limite est mise en place pour éviter l'overhead sur les ressources serveur.
|
||||||

|
|
||||||
|
|
||||||
## Stack Technologique
|
Cette dernière est utilisable 20 minutes maximum par session avant d'être réinitialiser.
|
||||||
|
Vous comprendrez également qu'elle ne contient pas de démo pour la partie IA, pour laquelle il faut configurer un serveur Ollama (et qui ferait donc exploser le serveur) ou utiliser 1min.ai.
|
||||||
LoreMind utilise une architecture distribuée pour séparer les responsabilités :
|
|
||||||
|
|
||||||
- **Frontend** : Angular (Interface utilisateur, affichage du lore, formulaires de templates)
|
|
||||||
- **Backend Core** : Java (Spring Boot) - Orchestration, persistance, export VTT
|
|
||||||
- **Backend IA** : Python - Traitement des LLM et génération de contenu
|
|
||||||
- **Base de données** : PostgreSQL avec JSONB pour les templates flexibles
|
|
||||||
|
|
||||||
## Architecture
|
|
||||||
|
|
||||||
### Backend Java (Domain-Driven Design & Hexagonal)
|
|
||||||
|
|
||||||
Le Backend Core respecte strictement :
|
|
||||||
- **Domain-Driven Design (DDD)** : Séparation en Bounded Contexts autonomes
|
|
||||||
- **Architecture Hexagonale (Ports et Adaptateurs)** : Domaine pur sans dépendances techniques
|
|
||||||
|
|
||||||
#### Bounded Contexts
|
|
||||||
- **LoreContext** : Gestion de l'encyclopédie de l'univers
|
|
||||||
- **CampaignContext** : Suivi des sessions et chronologie
|
|
||||||
- **GenerationContext** : Gestion des requêtes IA et templates
|
|
||||||
|
|
||||||
#### Couches
|
|
||||||
- **Domaine (Core)** : Entités métier pures et interfaces (Ports)
|
|
||||||
- **Application** : Orchestration des flux (Use Cases)
|
|
||||||
- **Infrastructure** : Implémentation technique (Adapters)
|
|
||||||
|
|
||||||
## Installation
|
|
||||||
|
|
||||||
Pour installer LoreMind chez vous (Docker requis), suivez le guide **[INSTALL.md](INSTALL.md)** — 3 étapes, 5 minutes chrono :
|
|
||||||
|
|
||||||
1. Télécharger `docker-compose.yml` + `.env.example` depuis la [dernière release](https://git.igmlcreation.fr/ietm64/LoreMindMJ/releases)
|
|
||||||
2. Renommer `.env.example` → `.env` et changer `POSTGRES_PASSWORD`
|
|
||||||
3. `docker compose up -d` → ouvrir http://localhost:8081
|
|
||||||
|
|
||||||
Mise à jour : `docker compose pull && docker compose up -d`.
|
|
||||||
|
|
||||||
## Développement (contributeurs)
|
|
||||||
|
|
||||||
Pour builder les images localement depuis les sources :
|
|
||||||
|
|
||||||
```bash
|
|
||||||
git clone https://git.igmlcreation.fr/ietm64/LoreMindMJ.git
|
|
||||||
cd LoreMindMJ
|
|
||||||
# Créer un docker-compose.override.yml local (voir docs de contrib)
|
|
||||||
docker compose up -d --build
|
|
||||||
```
|
|
||||||
|
|
||||||
## License
|
## License
|
||||||
|
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ from app.domain.models import (
|
|||||||
ChatMessage,
|
ChatMessage,
|
||||||
ChapterSummary,
|
ChapterSummary,
|
||||||
CharacterSummary,
|
CharacterSummary,
|
||||||
|
NpcSummary,
|
||||||
GameSystemContext,
|
GameSystemContext,
|
||||||
LoreStructuralContext,
|
LoreStructuralContext,
|
||||||
NarrativeEntityContext,
|
NarrativeEntityContext,
|
||||||
@@ -198,10 +199,12 @@ class ChatUseCase:
|
|||||||
else "\n(Cette campagne n'est associée à aucun univers — tu peux proposer des éléments d'ambiance libres.)"
|
else "\n(Cette campagne n'est associée à aucun univers — tu peux proposer des éléments d'ambiance libres.)"
|
||||||
)
|
)
|
||||||
characters_block = ChatUseCase._format_characters(ctx.characters)
|
characters_block = ChatUseCase._format_characters(ctx.characters)
|
||||||
|
npcs_block = ChatUseCase._format_npcs(ctx.npcs)
|
||||||
return (
|
return (
|
||||||
"--- CAMPAGNE COURANTE ---\n"
|
"--- CAMPAGNE COURANTE ---\n"
|
||||||
f"Nom : {ctx.campaign_name}{desc}{lore_note}\n"
|
f"Nom : {ctx.campaign_name}{desc}{lore_note}\n"
|
||||||
f"{characters_block}\n"
|
f"{characters_block}"
|
||||||
|
f"{npcs_block}\n"
|
||||||
"Structure narrative (les flèches → indiquent des transitions de scène "
|
"Structure narrative (les flèches → indiquent des transitions de scène "
|
||||||
"déclenchées par un choix des joueurs) :\n"
|
"déclenchées par un choix des joueurs) :\n"
|
||||||
f"{arcs_block}"
|
f"{arcs_block}"
|
||||||
@@ -231,6 +234,33 @@ class ChatUseCase:
|
|||||||
)
|
)
|
||||||
return "\n".join(lines) + "\n"
|
return "\n".join(lines) + "\n"
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _format_npcs(npcs: list[NpcSummary]) -> str:
|
||||||
|
"""Bloc PNJ — symétrique aux PJ avec sa propre instruction anti-halluci.
|
||||||
|
|
||||||
|
Distinction importante : pour les PNJ, l'IA est ENCOURAGÉE à proposer de
|
||||||
|
nouveaux PNJ (création créative = OK). En revanche, elle ne doit pas
|
||||||
|
référencer comme existant un PNJ qui n'est pas dans la liste ci-dessous.
|
||||||
|
"""
|
||||||
|
if not npcs:
|
||||||
|
return (
|
||||||
|
"\nPersonnages non-joueurs (PNJ) : aucun défini pour l'instant. "
|
||||||
|
"Tu peux librement proposer de nouveaux PNJ au MJ, mais ne "
|
||||||
|
"fais pas comme s'ils existaient déjà dans la campagne.\n"
|
||||||
|
)
|
||||||
|
lines = ["\nPersonnages non-joueurs (PNJ) connus :"]
|
||||||
|
for n in npcs:
|
||||||
|
if n.snippet:
|
||||||
|
lines.append(f"- **{n.name}** — {n.snippet}")
|
||||||
|
else:
|
||||||
|
lines.append(f"- **{n.name}** (fiche vide)")
|
||||||
|
lines.append(
|
||||||
|
"Pour une fiche complète d'un PNJ existant (apparence, motivations), "
|
||||||
|
"n'invente rien : demande au MJ d'ouvrir l'éditeur du PNJ. Tu peux "
|
||||||
|
"en revanche proposer librement de NOUVEAUX PNJ."
|
||||||
|
)
|
||||||
|
return "\n".join(lines) + "\n"
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _format_arcs(arcs: list[ArcSummary]) -> str:
|
def _format_arcs(arcs: list[ArcSummary]) -> str:
|
||||||
if not arcs:
|
if not arcs:
|
||||||
@@ -319,7 +349,8 @@ class ChatUseCase:
|
|||||||
"arc": "ARC",
|
"arc": "ARC",
|
||||||
"chapter": "CHAPITRE",
|
"chapter": "CHAPITRE",
|
||||||
"scene": "SCÈNE",
|
"scene": "SCÈNE",
|
||||||
"character": "FICHE DE PERSONNAGE",
|
"character": "FICHE DE PERSONNAGE (PJ)",
|
||||||
|
"npc": "FICHE DE PNJ",
|
||||||
}.get(ne.entity_type.lower(), ne.entity_type.upper())
|
}.get(ne.entity_type.lower(), ne.entity_type.upper())
|
||||||
if ne.fields:
|
if ne.fields:
|
||||||
fields_block = "\n".join(
|
fields_block = "\n".join(
|
||||||
|
|||||||
@@ -170,6 +170,7 @@ class CampaignStructuralContext:
|
|||||||
campaign_description: str | None
|
campaign_description: str | None
|
||||||
arcs: list[ArcSummary]
|
arcs: list[ArcSummary]
|
||||||
characters: list["CharacterSummary"] = field(default_factory=list)
|
characters: list["CharacterSummary"] = field(default_factory=list)
|
||||||
|
npcs: list["NpcSummary"] = field(default_factory=list)
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
@dataclass(frozen=True)
|
||||||
@@ -185,6 +186,19 @@ class CharacterSummary:
|
|||||||
snippet: str
|
snippet: str
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class NpcSummary:
|
||||||
|
"""Résumé d'un PNJ : symétrique à CharacterSummary.
|
||||||
|
|
||||||
|
Permet à l'IA de connaître les PNJ d'une campagne (nom + snippet) sans
|
||||||
|
injecter leurs fiches complètes. Évolution prévue : entity_type="npc"
|
||||||
|
pour focus sur la fiche complète.
|
||||||
|
"""
|
||||||
|
|
||||||
|
name: str
|
||||||
|
snippet: str
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
@dataclass(frozen=True)
|
||||||
class NarrativeEntityContext:
|
class NarrativeEntityContext:
|
||||||
"""Contexte d'une entité narrative précise en cours d'édition.
|
"""Contexte d'une entité narrative précise en cours d'édition.
|
||||||
|
|||||||
@@ -61,7 +61,16 @@ class OllamaLLMProvider:
|
|||||||
async with httpx.AsyncClient(timeout=self._timeout) as client:
|
async with httpx.AsyncClient(timeout=self._timeout) as client:
|
||||||
try:
|
try:
|
||||||
response = await client.post(url, json=payload)
|
response = await client.post(url, json=payload)
|
||||||
response.raise_for_status()
|
if response.status_code >= 400:
|
||||||
|
body = response.text
|
||||||
|
try:
|
||||||
|
err_obj = json.loads(body)
|
||||||
|
err_msg = err_obj.get("error") or body
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
err_msg = body
|
||||||
|
raise LLMProviderError(
|
||||||
|
f"Ollama HTTP {response.status_code} : {err_msg.strip()[:500]}"
|
||||||
|
)
|
||||||
except httpx.HTTPError as exc:
|
except httpx.HTTPError as exc:
|
||||||
raise LLMProviderError(
|
raise LLMProviderError(
|
||||||
f"Erreur lors de l'appel à Ollama : {exc}"
|
f"Erreur lors de l'appel à Ollama : {exc}"
|
||||||
@@ -105,7 +114,20 @@ class OllamaLLMProvider:
|
|||||||
async with httpx.AsyncClient(timeout=self._timeout) as client:
|
async with httpx.AsyncClient(timeout=self._timeout) as client:
|
||||||
try:
|
try:
|
||||||
async with client.stream("POST", url, json=payload) as response:
|
async with client.stream("POST", url, json=payload) as response:
|
||||||
response.raise_for_status()
|
if response.status_code >= 400:
|
||||||
|
# On lit le body d'erreur pour le remonter a l'utilisateur,
|
||||||
|
# sinon on ne voit que "500 Internal Server Error" sans
|
||||||
|
# savoir POURQUOI Ollama refuse (modele introuvable, OOM,
|
||||||
|
# num_ctx trop grand pour la VRAM, etc.).
|
||||||
|
body = (await response.aread()).decode("utf-8", errors="replace")
|
||||||
|
try:
|
||||||
|
err_obj = json.loads(body)
|
||||||
|
err_msg = err_obj.get("error") or body
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
err_msg = body
|
||||||
|
raise LLMProviderError(
|
||||||
|
f"Ollama HTTP {response.status_code} : {err_msg.strip()[:500]}"
|
||||||
|
)
|
||||||
async for line in response.aiter_lines():
|
async for line in response.aiter_lines():
|
||||||
if not line.strip():
|
if not line.strip():
|
||||||
continue
|
continue
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ from app.domain.models import (
|
|||||||
CampaignStructuralContext,
|
CampaignStructuralContext,
|
||||||
ChapterSummary,
|
ChapterSummary,
|
||||||
CharacterSummary,
|
CharacterSummary,
|
||||||
|
NpcSummary,
|
||||||
ChatMessage,
|
ChatMessage,
|
||||||
GameSystemContext,
|
GameSystemContext,
|
||||||
LoreStructuralContext,
|
LoreStructuralContext,
|
||||||
@@ -205,6 +206,13 @@ class CharacterSummaryDTO(BaseModel):
|
|||||||
snippet: str = ""
|
snippet: str = ""
|
||||||
|
|
||||||
|
|
||||||
|
class NpcSummaryDTO(BaseModel):
|
||||||
|
"""Résumé d'un PNJ : symétrique à CharacterSummaryDTO."""
|
||||||
|
|
||||||
|
name: str
|
||||||
|
snippet: str = ""
|
||||||
|
|
||||||
|
|
||||||
class CampaignContextDTO(BaseModel):
|
class CampaignContextDTO(BaseModel):
|
||||||
"""Carte narrative enrichie : arcs → chapitres → scènes avec synopsis."""
|
"""Carte narrative enrichie : arcs → chapitres → scènes avec synopsis."""
|
||||||
|
|
||||||
@@ -212,12 +220,13 @@ class CampaignContextDTO(BaseModel):
|
|||||||
campaign_description: str | None = None
|
campaign_description: str | None = None
|
||||||
arcs: list[ArcSummaryDTO] = Field(default_factory=list)
|
arcs: list[ArcSummaryDTO] = Field(default_factory=list)
|
||||||
characters: list[CharacterSummaryDTO] = Field(default_factory=list)
|
characters: list[CharacterSummaryDTO] = Field(default_factory=list)
|
||||||
|
npcs: list[NpcSummaryDTO] = Field(default_factory=list)
|
||||||
|
|
||||||
|
|
||||||
class NarrativeEntityDTO(BaseModel):
|
class NarrativeEntityDTO(BaseModel):
|
||||||
"""Entité narrative (arc/chapter/scene/character) en cours d'édition — focus optionnel."""
|
"""Entité narrative (arc/chapter/scene/character) en cours d'édition — focus optionnel."""
|
||||||
|
|
||||||
entity_type: str = Field(pattern="^(arc|chapter|scene|character)$")
|
entity_type: str = Field(pattern="^(arc|chapter|scene|character|npc)$")
|
||||||
title: str
|
title: str
|
||||||
fields: dict[str, str] = Field(default_factory=dict)
|
fields: dict[str, str] = Field(default_factory=dict)
|
||||||
|
|
||||||
@@ -553,11 +562,16 @@ def _to_campaign_context(dto: CampaignContextDTO | None) -> CampaignStructuralCo
|
|||||||
CharacterSummary(name=c.name, snippet=c.snippet)
|
CharacterSummary(name=c.name, snippet=c.snippet)
|
||||||
for c in dto.characters
|
for c in dto.characters
|
||||||
]
|
]
|
||||||
|
npcs = [
|
||||||
|
NpcSummary(name=n.name, snippet=n.snippet)
|
||||||
|
for n in dto.npcs
|
||||||
|
]
|
||||||
return CampaignStructuralContext(
|
return CampaignStructuralContext(
|
||||||
campaign_name=dto.campaign_name,
|
campaign_name=dto.campaign_name,
|
||||||
campaign_description=dto.campaign_description,
|
campaign_description=dto.campaign_description,
|
||||||
arcs=arcs,
|
arcs=arcs,
|
||||||
characters=characters,
|
characters=characters,
|
||||||
|
npcs=npcs,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
25
core/pom.xml
25
core/pom.xml
@@ -14,7 +14,7 @@
|
|||||||
|
|
||||||
<groupId>com.loremind</groupId>
|
<groupId>com.loremind</groupId>
|
||||||
<artifactId>loremind-core</artifactId>
|
<artifactId>loremind-core</artifactId>
|
||||||
<version>0.6.12</version>
|
<version>0.8.1</version>
|
||||||
<name>LoreMind Core</name>
|
<name>LoreMind Core</name>
|
||||||
<description>Backend Core - Architecture Hexagonale</description>
|
<description>Backend Core - Architecture Hexagonale</description>
|
||||||
|
|
||||||
@@ -83,6 +83,19 @@
|
|||||||
<artifactId>minio</artifactId>
|
<artifactId>minio</artifactId>
|
||||||
<version>8.5.11</version>
|
<version>8.5.11</version>
|
||||||
</dependency>
|
</dependency>
|
||||||
|
|
||||||
|
<!-- Nimbus JOSE+JWT — verification des JWT Ed25519 (EdDSA) emis par le relais
|
||||||
|
Patreon. Supporte nativement les cles Ed25519 via BouncyCastle. -->
|
||||||
|
<dependency>
|
||||||
|
<groupId>com.nimbusds</groupId>
|
||||||
|
<artifactId>nimbus-jose-jwt</artifactId>
|
||||||
|
<version>9.40</version>
|
||||||
|
</dependency>
|
||||||
|
<dependency>
|
||||||
|
<groupId>org.bouncycastle</groupId>
|
||||||
|
<artifactId>bcprov-jdk18on</artifactId>
|
||||||
|
<version>1.78.1</version>
|
||||||
|
</dependency>
|
||||||
</dependencies>
|
</dependencies>
|
||||||
|
|
||||||
<build>
|
<build>
|
||||||
@@ -98,6 +111,16 @@
|
|||||||
</exclude>
|
</exclude>
|
||||||
</excludes>
|
</excludes>
|
||||||
</configuration>
|
</configuration>
|
||||||
|
<executions>
|
||||||
|
<!-- Genere META-INF/build-info.properties (project.version)
|
||||||
|
consomme par Spring BuildProperties pour exposer la
|
||||||
|
version courante a l'application (UpdateCheckService). -->
|
||||||
|
<execution>
|
||||||
|
<goals>
|
||||||
|
<goal>build-info</goal>
|
||||||
|
</goals>
|
||||||
|
</execution>
|
||||||
|
</executions>
|
||||||
</plugin>
|
</plugin>
|
||||||
|
|
||||||
<!-- JaCoCo : rapport de couverture des tests unitaires.
|
<!-- JaCoCo : rapport de couverture des tests unitaires.
|
||||||
|
|||||||
@@ -2,12 +2,14 @@ package com.loremind;
|
|||||||
|
|
||||||
import org.springframework.boot.SpringApplication;
|
import org.springframework.boot.SpringApplication;
|
||||||
import org.springframework.boot.autoconfigure.SpringBootApplication;
|
import org.springframework.boot.autoconfigure.SpringBootApplication;
|
||||||
|
import org.springframework.scheduling.annotation.EnableScheduling;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Classe principale de l'application LoreMind.
|
* Classe principale de l'application LoreMind.
|
||||||
* Point d'entrée Spring Boot qui démarre l'application.
|
* Point d'entrée Spring Boot qui démarre l'application.
|
||||||
*/
|
*/
|
||||||
@SpringBootApplication
|
@SpringBootApplication
|
||||||
|
@EnableScheduling
|
||||||
public class LoreMindApplication {
|
public class LoreMindApplication {
|
||||||
|
|
||||||
public static void main(String[] args) {
|
public static void main(String[] args) {
|
||||||
|
|||||||
@@ -4,7 +4,9 @@ import com.loremind.domain.campaigncontext.Character;
|
|||||||
import com.loremind.domain.campaigncontext.ports.CharacterRepository;
|
import com.loremind.domain.campaigncontext.ports.CharacterRepository;
|
||||||
import org.springframework.stereotype.Service;
|
import org.springframework.stereotype.Service;
|
||||||
|
|
||||||
|
import java.util.HashMap;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
import java.util.Optional;
|
import java.util.Optional;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -22,8 +24,17 @@ public class CharacterService {
|
|||||||
/**
|
/**
|
||||||
* Parameter Object pour la création / mise à jour d'un Character.
|
* Parameter Object pour la création / mise à jour d'un Character.
|
||||||
* `order` est fourni par le controller ; si absent, le service le calcule.
|
* `order` est fourni par le controller ; si absent, le service le calcule.
|
||||||
|
* Les maps {@code values}/{@code imageValues} peuvent etre null (interpretees vides).
|
||||||
*/
|
*/
|
||||||
public record CharacterData(String name, String markdownContent, String campaignId, Integer order) {}
|
public record CharacterData(
|
||||||
|
String name,
|
||||||
|
String portraitImageId,
|
||||||
|
String headerImageId,
|
||||||
|
Map<String, String> values,
|
||||||
|
Map<String, List<String>> imageValues,
|
||||||
|
String campaignId,
|
||||||
|
Integer order
|
||||||
|
) {}
|
||||||
|
|
||||||
public Character createCharacter(CharacterData data) {
|
public Character createCharacter(CharacterData data) {
|
||||||
int order = data.order() != null
|
int order = data.order() != null
|
||||||
@@ -31,7 +42,10 @@ public class CharacterService {
|
|||||||
: nextOrderFor(data.campaignId());
|
: nextOrderFor(data.campaignId());
|
||||||
Character character = Character.builder()
|
Character character = Character.builder()
|
||||||
.name(data.name())
|
.name(data.name())
|
||||||
.markdownContent(data.markdownContent())
|
.portraitImageId(data.portraitImageId())
|
||||||
|
.headerImageId(data.headerImageId())
|
||||||
|
.values(data.values() != null ? new HashMap<>(data.values()) : new HashMap<>())
|
||||||
|
.imageValues(data.imageValues() != null ? new HashMap<>(data.imageValues()) : new HashMap<>())
|
||||||
.campaignId(data.campaignId())
|
.campaignId(data.campaignId())
|
||||||
.order(order)
|
.order(order)
|
||||||
.build();
|
.build();
|
||||||
@@ -50,7 +64,10 @@ public class CharacterService {
|
|||||||
Character existing = characterRepository.findById(id)
|
Character existing = characterRepository.findById(id)
|
||||||
.orElseThrow(() -> new IllegalArgumentException("Character non trouvé avec l'ID: " + id));
|
.orElseThrow(() -> new IllegalArgumentException("Character non trouvé avec l'ID: " + id));
|
||||||
existing.setName(data.name());
|
existing.setName(data.name());
|
||||||
existing.setMarkdownContent(data.markdownContent());
|
existing.setPortraitImageId(data.portraitImageId());
|
||||||
|
existing.setHeaderImageId(data.headerImageId());
|
||||||
|
existing.setValues(data.values() != null ? new HashMap<>(data.values()) : new HashMap<>());
|
||||||
|
existing.setImageValues(data.imageValues() != null ? new HashMap<>(data.imageValues()) : new HashMap<>());
|
||||||
if (data.order() != null) {
|
if (data.order() != null) {
|
||||||
existing.setOrder(data.order());
|
existing.setOrder(data.order());
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,82 @@
|
|||||||
|
package com.loremind.application.campaigncontext;
|
||||||
|
|
||||||
|
import com.loremind.domain.campaigncontext.Npc;
|
||||||
|
import com.loremind.domain.campaigncontext.ports.NpcRepository;
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
|
||||||
|
import java.util.HashMap;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
import java.util.Optional;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Service d'application pour les fiches de PNJ (campagne).
|
||||||
|
*/
|
||||||
|
@Service
|
||||||
|
public class NpcService {
|
||||||
|
|
||||||
|
private final NpcRepository npcRepository;
|
||||||
|
|
||||||
|
public NpcService(NpcRepository npcRepository) {
|
||||||
|
this.npcRepository = npcRepository;
|
||||||
|
}
|
||||||
|
|
||||||
|
public record NpcData(
|
||||||
|
String name,
|
||||||
|
String portraitImageId,
|
||||||
|
String headerImageId,
|
||||||
|
Map<String, String> values,
|
||||||
|
Map<String, List<String>> imageValues,
|
||||||
|
String campaignId,
|
||||||
|
Integer order
|
||||||
|
) {}
|
||||||
|
|
||||||
|
public Npc createNpc(NpcData data) {
|
||||||
|
int order = data.order() != null
|
||||||
|
? data.order()
|
||||||
|
: nextOrderFor(data.campaignId());
|
||||||
|
Npc npc = Npc.builder()
|
||||||
|
.name(data.name())
|
||||||
|
.portraitImageId(data.portraitImageId())
|
||||||
|
.headerImageId(data.headerImageId())
|
||||||
|
.values(data.values() != null ? new HashMap<>(data.values()) : new HashMap<>())
|
||||||
|
.imageValues(data.imageValues() != null ? new HashMap<>(data.imageValues()) : new HashMap<>())
|
||||||
|
.campaignId(data.campaignId())
|
||||||
|
.order(order)
|
||||||
|
.build();
|
||||||
|
return npcRepository.save(npc);
|
||||||
|
}
|
||||||
|
|
||||||
|
public Optional<Npc> getNpcById(String id) {
|
||||||
|
return npcRepository.findById(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
public List<Npc> getNpcsByCampaignId(String campaignId) {
|
||||||
|
return npcRepository.findByCampaignId(campaignId);
|
||||||
|
}
|
||||||
|
|
||||||
|
public Npc updateNpc(String id, NpcData data) {
|
||||||
|
Npc existing = npcRepository.findById(id)
|
||||||
|
.orElseThrow(() -> new IllegalArgumentException("Npc non trouvé avec l'ID: " + id));
|
||||||
|
existing.setName(data.name());
|
||||||
|
existing.setPortraitImageId(data.portraitImageId());
|
||||||
|
existing.setHeaderImageId(data.headerImageId());
|
||||||
|
existing.setValues(data.values() != null ? new HashMap<>(data.values()) : new HashMap<>());
|
||||||
|
existing.setImageValues(data.imageValues() != null ? new HashMap<>(data.imageValues()) : new HashMap<>());
|
||||||
|
if (data.order() != null) {
|
||||||
|
existing.setOrder(data.order());
|
||||||
|
}
|
||||||
|
return npcRepository.save(existing);
|
||||||
|
}
|
||||||
|
|
||||||
|
public void deleteNpc(String id) {
|
||||||
|
npcRepository.deleteById(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
private int nextOrderFor(String campaignId) {
|
||||||
|
return npcRepository.findByCampaignId(campaignId).stream()
|
||||||
|
.mapToInt(Npc::getOrder)
|
||||||
|
.max()
|
||||||
|
.orElse(-1) + 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,6 +2,7 @@ package com.loremind.application.gamesystemcontext;
|
|||||||
|
|
||||||
import com.loremind.domain.gamesystemcontext.GameSystem;
|
import com.loremind.domain.gamesystemcontext.GameSystem;
|
||||||
import com.loremind.domain.gamesystemcontext.ports.GameSystemRepository;
|
import com.loremind.domain.gamesystemcontext.ports.GameSystemRepository;
|
||||||
|
import com.loremind.domain.shared.template.TemplateField;
|
||||||
import org.springframework.stereotype.Service;
|
import org.springframework.stereotype.Service;
|
||||||
|
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
@@ -18,11 +19,14 @@ public class GameSystemService {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Parameter Object pour la création / mise à jour d'un GameSystem.
|
* Parameter Object pour la création / mise à jour d'un GameSystem.
|
||||||
|
* Les templates peuvent etre null (interpretes comme listes vides).
|
||||||
*/
|
*/
|
||||||
public record GameSystemData(
|
public record GameSystemData(
|
||||||
String name,
|
String name,
|
||||||
String description,
|
String description,
|
||||||
String rulesMarkdown,
|
String rulesMarkdown,
|
||||||
|
List<TemplateField> characterTemplate,
|
||||||
|
List<TemplateField> npcTemplate,
|
||||||
String author,
|
String author,
|
||||||
boolean isPublic
|
boolean isPublic
|
||||||
) {}
|
) {}
|
||||||
@@ -35,6 +39,8 @@ public class GameSystemService {
|
|||||||
.author(normalize(data.author()))
|
.author(normalize(data.author()))
|
||||||
.isPublic(data.isPublic())
|
.isPublic(data.isPublic())
|
||||||
.build();
|
.build();
|
||||||
|
gameSystem.replaceCharacterTemplate(data.characterTemplate());
|
||||||
|
gameSystem.replaceNpcTemplate(data.npcTemplate());
|
||||||
return gameSystemRepository.save(gameSystem);
|
return gameSystemRepository.save(gameSystem);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -52,6 +58,8 @@ public class GameSystemService {
|
|||||||
existing.setName(data.name());
|
existing.setName(data.name());
|
||||||
existing.setDescription(data.description());
|
existing.setDescription(data.description());
|
||||||
existing.setRulesMarkdown(data.rulesMarkdown());
|
existing.setRulesMarkdown(data.rulesMarkdown());
|
||||||
|
existing.replaceCharacterTemplate(data.characterTemplate());
|
||||||
|
existing.replaceNpcTemplate(data.npcTemplate());
|
||||||
existing.setAuthor(normalize(data.author()));
|
existing.setAuthor(normalize(data.author()));
|
||||||
existing.setPublic(data.isPublic());
|
existing.setPublic(data.isPublic());
|
||||||
return gameSystemRepository.save(existing);
|
return gameSystemRepository.save(existing);
|
||||||
|
|||||||
@@ -4,17 +4,20 @@ import com.loremind.domain.campaigncontext.Arc;
|
|||||||
import com.loremind.domain.campaigncontext.Campaign;
|
import com.loremind.domain.campaigncontext.Campaign;
|
||||||
import com.loremind.domain.campaigncontext.Chapter;
|
import com.loremind.domain.campaigncontext.Chapter;
|
||||||
import com.loremind.domain.campaigncontext.Character;
|
import com.loremind.domain.campaigncontext.Character;
|
||||||
|
import com.loremind.domain.campaigncontext.Npc;
|
||||||
import com.loremind.domain.campaigncontext.Scene;
|
import com.loremind.domain.campaigncontext.Scene;
|
||||||
import com.loremind.domain.campaigncontext.ports.ArcRepository;
|
import com.loremind.domain.campaigncontext.ports.ArcRepository;
|
||||||
import com.loremind.domain.campaigncontext.ports.CampaignRepository;
|
import com.loremind.domain.campaigncontext.ports.CampaignRepository;
|
||||||
import com.loremind.domain.campaigncontext.ports.ChapterRepository;
|
import com.loremind.domain.campaigncontext.ports.ChapterRepository;
|
||||||
import com.loremind.domain.campaigncontext.ports.CharacterRepository;
|
import com.loremind.domain.campaigncontext.ports.CharacterRepository;
|
||||||
|
import com.loremind.domain.campaigncontext.ports.NpcRepository;
|
||||||
import com.loremind.domain.campaigncontext.ports.SceneRepository;
|
import com.loremind.domain.campaigncontext.ports.SceneRepository;
|
||||||
import com.loremind.domain.generationcontext.CampaignStructuralContext;
|
import com.loremind.domain.generationcontext.CampaignStructuralContext;
|
||||||
import com.loremind.domain.generationcontext.CampaignStructuralContext.ArcSummary;
|
import com.loremind.domain.generationcontext.CampaignStructuralContext.ArcSummary;
|
||||||
import com.loremind.domain.generationcontext.CampaignStructuralContext.BranchHint;
|
import com.loremind.domain.generationcontext.CampaignStructuralContext.BranchHint;
|
||||||
import com.loremind.domain.generationcontext.CampaignStructuralContext.ChapterSummary;
|
import com.loremind.domain.generationcontext.CampaignStructuralContext.ChapterSummary;
|
||||||
import com.loremind.domain.generationcontext.CampaignStructuralContext.CharacterSummary;
|
import com.loremind.domain.generationcontext.CampaignStructuralContext.CharacterSummary;
|
||||||
|
import com.loremind.domain.generationcontext.CampaignStructuralContext.NpcSummary;
|
||||||
import com.loremind.domain.generationcontext.CampaignStructuralContext.SceneSummary;
|
import com.loremind.domain.generationcontext.CampaignStructuralContext.SceneSummary;
|
||||||
import org.springframework.stereotype.Component;
|
import org.springframework.stereotype.Component;
|
||||||
|
|
||||||
@@ -42,21 +45,24 @@ public class CampaignStructuralContextBuilder {
|
|||||||
private final ChapterRepository chapterRepository;
|
private final ChapterRepository chapterRepository;
|
||||||
private final SceneRepository sceneRepository;
|
private final SceneRepository sceneRepository;
|
||||||
private final CharacterRepository characterRepository;
|
private final CharacterRepository characterRepository;
|
||||||
|
private final NpcRepository npcRepository;
|
||||||
|
|
||||||
public CampaignStructuralContextBuilder(
|
public CampaignStructuralContextBuilder(
|
||||||
CampaignRepository campaignRepository,
|
CampaignRepository campaignRepository,
|
||||||
ArcRepository arcRepository,
|
ArcRepository arcRepository,
|
||||||
ChapterRepository chapterRepository,
|
ChapterRepository chapterRepository,
|
||||||
SceneRepository sceneRepository,
|
SceneRepository sceneRepository,
|
||||||
CharacterRepository characterRepository) {
|
CharacterRepository characterRepository,
|
||||||
|
NpcRepository npcRepository) {
|
||||||
this.campaignRepository = campaignRepository;
|
this.campaignRepository = campaignRepository;
|
||||||
this.arcRepository = arcRepository;
|
this.arcRepository = arcRepository;
|
||||||
this.chapterRepository = chapterRepository;
|
this.chapterRepository = chapterRepository;
|
||||||
this.sceneRepository = sceneRepository;
|
this.sceneRepository = sceneRepository;
|
||||||
this.characterRepository = characterRepository;
|
this.characterRepository = characterRepository;
|
||||||
|
this.npcRepository = npcRepository;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Longueur max du snippet de PJ injecté dans le contexte (coût tokens maîtrisé). */
|
/** Longueur max du snippet de PJ/PNJ injecté dans le contexte (coût tokens maîtrisé). */
|
||||||
private static final int CHARACTER_SNIPPET_MAX_LEN = 160;
|
private static final int CHARACTER_SNIPPET_MAX_LEN = 160;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -79,11 +85,17 @@ public class CampaignStructuralContextBuilder {
|
|||||||
.map(this::toCharacterSummary)
|
.map(this::toCharacterSummary)
|
||||||
.collect(Collectors.toList());
|
.collect(Collectors.toList());
|
||||||
|
|
||||||
|
List<NpcSummary> npcs = npcRepository.findByCampaignId(campaignId).stream()
|
||||||
|
.sorted(Comparator.comparingInt(Npc::getOrder))
|
||||||
|
.map(this::toNpcSummary)
|
||||||
|
.collect(Collectors.toList());
|
||||||
|
|
||||||
return new CampaignStructuralContext(
|
return new CampaignStructuralContext(
|
||||||
campaign.getName(),
|
campaign.getName(),
|
||||||
campaign.getDescription(),
|
campaign.getDescription(),
|
||||||
arcs,
|
arcs,
|
||||||
characters);
|
characters,
|
||||||
|
npcs);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -92,19 +104,33 @@ public class CampaignStructuralContextBuilder {
|
|||||||
* sans injecter toute sa fiche.
|
* sans injecter toute sa fiche.
|
||||||
*/
|
*/
|
||||||
private CharacterSummary toCharacterSummary(Character c) {
|
private CharacterSummary toCharacterSummary(Character c) {
|
||||||
return new CharacterSummary(c.getName(), extractSnippet(c.getMarkdownContent()));
|
return new CharacterSummary(c.getName(), extractSnippet(c.getValues()));
|
||||||
}
|
}
|
||||||
|
|
||||||
private static String extractSnippet(String markdown) {
|
/** Symétrique à {@link #toCharacterSummary} pour les PNJ. */
|
||||||
if (markdown == null || markdown.isBlank()) return "";
|
private NpcSummary toNpcSummary(Npc n) {
|
||||||
String firstLine = markdown.lines()
|
return new NpcSummary(n.getName(), extractSnippet(n.getValues()));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Snippet pour le resume IA : 1re ligne signifiante de la 1re valeur non vide
|
||||||
|
* du template (refonte 2026-04-30 — remplace l'ancien parsing markdown).
|
||||||
|
*/
|
||||||
|
private static String extractSnippet(java.util.Map<String, String> values) {
|
||||||
|
if (values == null || values.isEmpty()) return "";
|
||||||
|
for (String value : values.values()) {
|
||||||
|
if (value == null || value.isBlank()) continue;
|
||||||
|
String firstLine = value.lines()
|
||||||
.map(String::strip)
|
.map(String::strip)
|
||||||
.filter(l -> !l.isEmpty() && !l.startsWith("#"))
|
.filter(l -> !l.isEmpty() && !l.startsWith("#"))
|
||||||
.findFirst()
|
.findFirst()
|
||||||
.orElse("");
|
.orElse("");
|
||||||
|
if (firstLine.isEmpty()) continue;
|
||||||
if (firstLine.length() <= CHARACTER_SNIPPET_MAX_LEN) return firstLine;
|
if (firstLine.length() <= CHARACTER_SNIPPET_MAX_LEN) return firstLine;
|
||||||
return firstLine.substring(0, CHARACTER_SNIPPET_MAX_LEN - 1).stripTrailing() + "…";
|
return firstLine.substring(0, CHARACTER_SNIPPET_MAX_LEN - 1).stripTrailing() + "…";
|
||||||
}
|
}
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
|
||||||
private ArcSummary toArcSummary(Arc arc) {
|
private ArcSummary toArcSummary(Arc arc) {
|
||||||
List<ChapterSummary> chapters = chapterRepository.findByArcId(arc.getId()).stream()
|
List<ChapterSummary> chapters = chapterRepository.findByArcId(arc.getId()).stream()
|
||||||
|
|||||||
@@ -3,10 +3,12 @@ package com.loremind.application.generationcontext;
|
|||||||
import com.loremind.domain.campaigncontext.Arc;
|
import com.loremind.domain.campaigncontext.Arc;
|
||||||
import com.loremind.domain.campaigncontext.Chapter;
|
import com.loremind.domain.campaigncontext.Chapter;
|
||||||
import com.loremind.domain.campaigncontext.Character;
|
import com.loremind.domain.campaigncontext.Character;
|
||||||
|
import com.loremind.domain.campaigncontext.Npc;
|
||||||
import com.loremind.domain.campaigncontext.Scene;
|
import com.loremind.domain.campaigncontext.Scene;
|
||||||
import com.loremind.domain.campaigncontext.ports.ArcRepository;
|
import com.loremind.domain.campaigncontext.ports.ArcRepository;
|
||||||
import com.loremind.domain.campaigncontext.ports.ChapterRepository;
|
import com.loremind.domain.campaigncontext.ports.ChapterRepository;
|
||||||
import com.loremind.domain.campaigncontext.ports.CharacterRepository;
|
import com.loremind.domain.campaigncontext.ports.CharacterRepository;
|
||||||
|
import com.loremind.domain.campaigncontext.ports.NpcRepository;
|
||||||
import com.loremind.domain.campaigncontext.ports.SceneRepository;
|
import com.loremind.domain.campaigncontext.ports.SceneRepository;
|
||||||
import com.loremind.domain.generationcontext.NarrativeEntityContext;
|
import com.loremind.domain.generationcontext.NarrativeEntityContext;
|
||||||
import org.springframework.stereotype.Component;
|
import org.springframework.stereotype.Component;
|
||||||
@@ -29,22 +31,25 @@ public class NarrativeEntityContextBuilder {
|
|||||||
private final ChapterRepository chapterRepository;
|
private final ChapterRepository chapterRepository;
|
||||||
private final SceneRepository sceneRepository;
|
private final SceneRepository sceneRepository;
|
||||||
private final CharacterRepository characterRepository;
|
private final CharacterRepository characterRepository;
|
||||||
|
private final NpcRepository npcRepository;
|
||||||
|
|
||||||
public NarrativeEntityContextBuilder(
|
public NarrativeEntityContextBuilder(
|
||||||
ArcRepository arcRepository,
|
ArcRepository arcRepository,
|
||||||
ChapterRepository chapterRepository,
|
ChapterRepository chapterRepository,
|
||||||
SceneRepository sceneRepository,
|
SceneRepository sceneRepository,
|
||||||
CharacterRepository characterRepository) {
|
CharacterRepository characterRepository,
|
||||||
|
NpcRepository npcRepository) {
|
||||||
this.arcRepository = arcRepository;
|
this.arcRepository = arcRepository;
|
||||||
this.chapterRepository = chapterRepository;
|
this.chapterRepository = chapterRepository;
|
||||||
this.sceneRepository = sceneRepository;
|
this.sceneRepository = sceneRepository;
|
||||||
this.characterRepository = characterRepository;
|
this.characterRepository = characterRepository;
|
||||||
|
this.npcRepository = npcRepository;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Charge l'entité narrative ciblée et la projette vers un VO du GenerationContext.
|
* Charge l'entité narrative ciblée et la projette vers un VO du GenerationContext.
|
||||||
*
|
*
|
||||||
* @param entityType "arc", "chapter", "scene" ou "character" (insensible à la casse)
|
* @param entityType "arc", "chapter", "scene", "character" ou "npc" (insensible à la casse)
|
||||||
* @param entityId l'ID de l'entité
|
* @param entityId l'ID de l'entité
|
||||||
* @throws IllegalArgumentException si le type est inconnu ou l'entité introuvable
|
* @throws IllegalArgumentException si le type est inconnu ou l'entité introuvable
|
||||||
*/
|
*/
|
||||||
@@ -55,6 +60,7 @@ public class NarrativeEntityContextBuilder {
|
|||||||
case "chapter" -> fromChapter(loadChapter(entityId));
|
case "chapter" -> fromChapter(loadChapter(entityId));
|
||||||
case "scene" -> fromScene(loadScene(entityId));
|
case "scene" -> fromScene(loadScene(entityId));
|
||||||
case "character" -> fromCharacter(loadCharacter(entityId));
|
case "character" -> fromCharacter(loadCharacter(entityId));
|
||||||
|
case "npc" -> fromNpc(loadNpc(entityId));
|
||||||
default -> throw new IllegalArgumentException("Type d'entité narrative inconnu: " + entityType);
|
default -> throw new IllegalArgumentException("Type d'entité narrative inconnu: " + entityType);
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -81,6 +87,11 @@ public class NarrativeEntityContextBuilder {
|
|||||||
.orElseThrow(() -> new IllegalArgumentException("Personnage non trouvé: " + id));
|
.orElseThrow(() -> new IllegalArgumentException("Personnage non trouvé: " + id));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private Npc loadNpc(String id) {
|
||||||
|
return npcRepository.findById(id)
|
||||||
|
.orElseThrow(() -> new IllegalArgumentException("PNJ non trouvé: " + id));
|
||||||
|
}
|
||||||
|
|
||||||
// --- Mapping entité → VO ------------------------------------------------
|
// --- Mapping entité → VO ------------------------------------------------
|
||||||
|
|
||||||
private NarrativeEntityContext fromArc(Arc a) {
|
private NarrativeEntityContext fromArc(Arc a) {
|
||||||
@@ -119,10 +130,22 @@ public class NarrativeEntityContextBuilder {
|
|||||||
|
|
||||||
private NarrativeEntityContext fromCharacter(Character c) {
|
private NarrativeEntityContext fromCharacter(Character c) {
|
||||||
Map<String, String> fields = new LinkedHashMap<>();
|
Map<String, String> fields = new LinkedHashMap<>();
|
||||||
putField(fields, "fiche complète (markdown)", c.getMarkdownContent());
|
if (c.getValues() != null) {
|
||||||
|
// Champs templates exposes individuellement — meilleur pour le LLM que
|
||||||
|
// l'ancien blob markdown monolithique.
|
||||||
|
c.getValues().forEach((k, v) -> putField(fields, k, v));
|
||||||
|
}
|
||||||
return new NarrativeEntityContext("character", c.getName(), fields);
|
return new NarrativeEntityContext("character", c.getName(), fields);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private NarrativeEntityContext fromNpc(Npc n) {
|
||||||
|
Map<String, String> fields = new LinkedHashMap<>();
|
||||||
|
if (n.getValues() != null) {
|
||||||
|
n.getValues().forEach((k, v) -> putField(fields, k, v));
|
||||||
|
}
|
||||||
|
return new NarrativeEntityContext("npc", n.getName(), fields);
|
||||||
|
}
|
||||||
|
|
||||||
/** Null/blank devient chaîne vide — uniforme côté prompt, pas de NPE côté LLM. */
|
/** Null/blank devient chaîne vide — uniforme côté prompt, pas de NPE côté LLM. */
|
||||||
private static void putField(Map<String, String> target, String key, String value) {
|
private static void putField(Map<String, String> target, String key, String value) {
|
||||||
target.put(key, value == null ? "" : value);
|
target.put(key, value == null ? "" : value);
|
||||||
|
|||||||
@@ -0,0 +1,261 @@
|
|||||||
|
package com.loremind.application.licensing;
|
||||||
|
|
||||||
|
import com.loremind.domain.licensing.License;
|
||||||
|
import com.loremind.domain.licensing.LicenseClaims;
|
||||||
|
import com.loremind.domain.licensing.LicenseSnapshot;
|
||||||
|
import com.loremind.domain.licensing.LicenseStatus;
|
||||||
|
import com.loremind.domain.licensing.RegistryCredentials;
|
||||||
|
import com.loremind.domain.licensing.ports.JwtVerifier;
|
||||||
|
import com.loremind.domain.licensing.ports.LicenseRelay;
|
||||||
|
import com.loremind.domain.licensing.ports.LicenseRepository;
|
||||||
|
import org.slf4j.Logger;
|
||||||
|
import org.slf4j.LoggerFactory;
|
||||||
|
import org.springframework.beans.factory.annotation.Value;
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
|
||||||
|
import java.time.Duration;
|
||||||
|
import java.time.Instant;
|
||||||
|
import java.util.Optional;
|
||||||
|
import java.util.UUID;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Service application pour la gestion de la licence Patreon.
|
||||||
|
* <p>
|
||||||
|
* Responsabilites :
|
||||||
|
* <ul>
|
||||||
|
* <li>Installer un nouveau JWT recu du relais (apres OAuth utilisateur)</li>
|
||||||
|
* <li>Calculer le {@link LicenseStatus} courant en respectant la grace period</li>
|
||||||
|
* <li>Renouveler le JWT avant expiration en appelant le relais</li>
|
||||||
|
* <li>Activer/desactiver le toggle "canal beta" cote utilisateur</li>
|
||||||
|
* <li>Distribuer les credentials registry pour le pull beta</li>
|
||||||
|
* </ul>
|
||||||
|
*/
|
||||||
|
@Service
|
||||||
|
public class LicenseService {
|
||||||
|
|
||||||
|
private static final Logger log = LoggerFactory.getLogger(LicenseService.class);
|
||||||
|
|
||||||
|
private final LicenseRepository repository;
|
||||||
|
private final JwtVerifier jwtVerifier;
|
||||||
|
private final LicenseRelay relay;
|
||||||
|
private final long gracePeriodSeconds;
|
||||||
|
private final long refreshBeforeExpirySeconds;
|
||||||
|
|
||||||
|
public LicenseService(
|
||||||
|
LicenseRepository repository,
|
||||||
|
JwtVerifier jwtVerifier,
|
||||||
|
LicenseRelay relay,
|
||||||
|
@Value("${licensing.grace-period-days:14}") int gracePeriodDays,
|
||||||
|
@Value("${licensing.refresh-before-expiry-days:2}") int refreshBeforeExpiryDays) {
|
||||||
|
this.repository = repository;
|
||||||
|
this.jwtVerifier = jwtVerifier;
|
||||||
|
this.relay = relay;
|
||||||
|
this.gracePeriodSeconds = (long) gracePeriodDays * 86_400L;
|
||||||
|
this.refreshBeforeExpirySeconds = (long) refreshBeforeExpiryDays * 86_400L;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return true si le verifier est configure (cle publique presente).
|
||||||
|
* L'UI peut masquer toute la section Patreon si false.
|
||||||
|
*/
|
||||||
|
public boolean isLicensingEnabled() {
|
||||||
|
return jwtVerifier.isConfigured();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Genere ou retourne l'instance_id stable de cette installation.
|
||||||
|
* Stocke dans la licence elle-meme. Si pas de licence, en cree un volatil
|
||||||
|
* (sera persiste a la prochaine connexion).
|
||||||
|
*/
|
||||||
|
public String getOrCreateInstanceId() {
|
||||||
|
return repository.findCurrent()
|
||||||
|
.map(License::getInstanceId)
|
||||||
|
.orElseGet(() -> "li-" + UUID.randomUUID());
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Construit l'URL OAuth pour ouvrir dans le navigateur de l'utilisateur.
|
||||||
|
*/
|
||||||
|
public String buildConnectUrl() {
|
||||||
|
return relay.buildConnectUrl(getOrCreateInstanceId());
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Installe un JWT recu du relais (l'utilisateur l'a colle dans l'UI ou
|
||||||
|
* recu via deep-link). Verifie la signature, extrait les claims, persiste.
|
||||||
|
*/
|
||||||
|
public LicenseSnapshot installToken(String rawJwt) throws InstallException {
|
||||||
|
if (!jwtVerifier.isConfigured()) {
|
||||||
|
throw new InstallException("Licensing feature not enabled (no public key configured)");
|
||||||
|
}
|
||||||
|
LicenseClaims claims;
|
||||||
|
try {
|
||||||
|
claims = jwtVerifier.verify(rawJwt);
|
||||||
|
} catch (JwtVerifier.JwtVerificationException e) {
|
||||||
|
throw new InstallException("Invalid JWT: " + e.getMessage());
|
||||||
|
}
|
||||||
|
|
||||||
|
Instant now = Instant.now();
|
||||||
|
if (claims.expiresAt().isBefore(now)) {
|
||||||
|
throw new InstallException("JWT already expired");
|
||||||
|
}
|
||||||
|
|
||||||
|
Optional<License> existing = repository.findCurrent();
|
||||||
|
License toSave = License.builder()
|
||||||
|
.id("current")
|
||||||
|
.rawJwt(rawJwt)
|
||||||
|
.patreonUserId(claims.subject())
|
||||||
|
.tierId(claims.tierId())
|
||||||
|
.instanceId(claims.instanceId())
|
||||||
|
.issuedAt(claims.issuedAt())
|
||||||
|
.expiresAt(claims.expiresAt())
|
||||||
|
.lastRefreshAttemptAt(now)
|
||||||
|
.lastRefreshSucceeded(true)
|
||||||
|
// Au premier install, on active le canal beta par defaut.
|
||||||
|
// Sur reinstall apres deconnexion, on respecte la valeur precedente.
|
||||||
|
.betaChannelEnabled(existing.map(License::isBetaChannelEnabled).orElse(true))
|
||||||
|
.createdAt(existing.map(License::getCreatedAt).orElse(now))
|
||||||
|
.build();
|
||||||
|
|
||||||
|
License saved = repository.save(toSave);
|
||||||
|
log.info("Patreon license installed for user={} tier={} expires={}",
|
||||||
|
saved.getPatreonUserId(), saved.getTierId(), saved.getExpiresAt());
|
||||||
|
return snapshotOf(saved, now);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Etat courant de la licence pour exposition UI / decision technique.
|
||||||
|
*/
|
||||||
|
public LicenseSnapshot getCurrentSnapshot() {
|
||||||
|
Optional<License> opt = repository.findCurrent();
|
||||||
|
if (opt.isEmpty()) return LicenseSnapshot.none();
|
||||||
|
return snapshotOf(opt.get(), Instant.now());
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Supprime la licence (deconnexion volontaire de Patreon par l'utilisateur).
|
||||||
|
*/
|
||||||
|
public void disconnect() {
|
||||||
|
repository.deleteCurrent();
|
||||||
|
log.info("Patreon license removed (user disconnect)");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Active ou desactive le canal beta. Necessite une licence valide ou en grace.
|
||||||
|
*/
|
||||||
|
public LicenseSnapshot setBetaChannelEnabled(boolean enabled) {
|
||||||
|
License current = repository.findCurrent()
|
||||||
|
.orElseThrow(() -> new IllegalStateException("No license installed"));
|
||||||
|
current.setBetaChannelEnabled(enabled);
|
||||||
|
License saved = repository.save(current);
|
||||||
|
return snapshotOf(saved, Instant.now());
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tente un refresh si la licence est proche de l'expiration. Idempotent.
|
||||||
|
* Appele par le daemon planifie + manuellement via l'UI ("Reessayer").
|
||||||
|
*
|
||||||
|
* @return true si un refresh a ete tente (avec ou sans succes)
|
||||||
|
*/
|
||||||
|
public boolean refreshIfNeeded() {
|
||||||
|
Optional<License> opt = repository.findCurrent();
|
||||||
|
if (opt.isEmpty()) return false;
|
||||||
|
License current = opt.get();
|
||||||
|
Instant now = Instant.now();
|
||||||
|
long secondsUntilExpiry = Duration.between(now, current.getExpiresAt()).getSeconds();
|
||||||
|
if (secondsUntilExpiry > refreshBeforeExpirySeconds) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return doRefresh(current, now);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Force un refresh immediat (bouton UI "Reessayer maintenant").
|
||||||
|
*/
|
||||||
|
public boolean forceRefresh() {
|
||||||
|
return repository.findCurrent()
|
||||||
|
.map(license -> doRefresh(license, Instant.now()))
|
||||||
|
.orElse(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
private boolean doRefresh(License current, Instant now) {
|
||||||
|
log.info("Refreshing Patreon license (current expires {})", current.getExpiresAt());
|
||||||
|
try {
|
||||||
|
String newJwt = relay.refreshToken(current.getRawJwt());
|
||||||
|
LicenseClaims claims = jwtVerifier.verify(newJwt);
|
||||||
|
|
||||||
|
current.setRawJwt(newJwt);
|
||||||
|
current.setIssuedAt(claims.issuedAt());
|
||||||
|
current.setExpiresAt(claims.expiresAt());
|
||||||
|
current.setTierId(claims.tierId());
|
||||||
|
current.setLastRefreshAttemptAt(now);
|
||||||
|
current.setLastRefreshSucceeded(true);
|
||||||
|
repository.save(current);
|
||||||
|
log.info("License refreshed successfully (new expiry {})", claims.expiresAt());
|
||||||
|
return true;
|
||||||
|
} catch (LicenseRelay.RelayException e) {
|
||||||
|
current.setLastRefreshAttemptAt(now);
|
||||||
|
current.setLastRefreshSucceeded(false);
|
||||||
|
repository.save(current);
|
||||||
|
if (e.getKind() == LicenseRelay.RelayErrorKind.REJECTED) {
|
||||||
|
log.warn("Relay rejected refresh ({}): tier may have been cancelled", e.getMessage());
|
||||||
|
} else {
|
||||||
|
log.warn("Relay refresh transient failure ({}): {}", e.getKind(), e.getMessage());
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
} catch (JwtVerifier.JwtVerificationException e) {
|
||||||
|
current.setLastRefreshAttemptAt(now);
|
||||||
|
current.setLastRefreshSucceeded(false);
|
||||||
|
repository.save(current);
|
||||||
|
log.error("Relay returned a JWT that fails verification: {}", e.getMessage());
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Recupere les credentials registry pour pull du canal beta.
|
||||||
|
* @return empty si pas de licence valide ou relais en echec
|
||||||
|
*/
|
||||||
|
public Optional<RegistryCredentials> fetchRegistryCredentials() {
|
||||||
|
LicenseSnapshot snap = getCurrentSnapshot();
|
||||||
|
if (snap.status() != LicenseStatus.VALID && snap.status() != LicenseStatus.GRACE) {
|
||||||
|
return Optional.empty();
|
||||||
|
}
|
||||||
|
License current = repository.findCurrent().orElse(null);
|
||||||
|
if (current == null) return Optional.empty();
|
||||||
|
try {
|
||||||
|
return Optional.of(relay.fetchRegistryCredentials(current.getRawJwt()));
|
||||||
|
} catch (LicenseRelay.RelayException e) {
|
||||||
|
log.warn("Cannot fetch registry credentials ({}): {}", e.getKind(), e.getMessage());
|
||||||
|
return Optional.empty();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private LicenseSnapshot snapshotOf(License l, Instant now) {
|
||||||
|
LicenseStatus status = computeStatus(l, now);
|
||||||
|
return new LicenseSnapshot(
|
||||||
|
status,
|
||||||
|
l.getPatreonUserId(),
|
||||||
|
l.getTierId(),
|
||||||
|
l.getInstanceId(),
|
||||||
|
l.getExpiresAt(),
|
||||||
|
l.getLastRefreshAttemptAt(),
|
||||||
|
l.isLastRefreshSucceeded(),
|
||||||
|
l.isBetaChannelEnabled()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private LicenseStatus computeStatus(License l, Instant now) {
|
||||||
|
if (l.getExpiresAt() == null) return LicenseStatus.NONE;
|
||||||
|
if (now.isBefore(l.getExpiresAt())) return LicenseStatus.VALID;
|
||||||
|
long secondsPastExpiry = Duration.between(l.getExpiresAt(), now).getSeconds();
|
||||||
|
if (secondsPastExpiry <= gracePeriodSeconds) return LicenseStatus.GRACE;
|
||||||
|
return LicenseStatus.EXPIRED;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static class InstallException extends Exception {
|
||||||
|
public InstallException(String message) {
|
||||||
|
super(message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
package com.loremind.application.lorecontext;
|
package com.loremind.application.lorecontext;
|
||||||
|
|
||||||
import com.loremind.domain.lorecontext.Template;
|
import com.loremind.domain.lorecontext.Template;
|
||||||
import com.loremind.domain.lorecontext.TemplateField;
|
import com.loremind.domain.shared.template.TemplateField;
|
||||||
import com.loremind.domain.lorecontext.ports.TemplateRepository;
|
import com.loremind.domain.lorecontext.ports.TemplateRepository;
|
||||||
import org.springframework.stereotype.Service;
|
import org.springframework.stereotype.Service;
|
||||||
|
|
||||||
|
|||||||
@@ -4,17 +4,26 @@ import lombok.Builder;
|
|||||||
import lombok.Data;
|
import lombok.Data;
|
||||||
|
|
||||||
import java.time.LocalDateTime;
|
import java.time.LocalDateTime;
|
||||||
|
import java.util.HashMap;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Fiche de personnage joueur (PJ) d'une campagne.
|
* Fiche de personnage joueur (PJ) d'une campagne.
|
||||||
* <p>
|
* <p>
|
||||||
* MVP : contenu markdown libre, l'utilisateur met ce qu'il veut (stats,
|
* Champs universels hard-codes : {@code name}, {@code portraitImageId},
|
||||||
* backstory, équipement). Évolution prévue vers un système templaté par
|
* {@code headerImageId}. Tout le reste est piloté par le template PJ du
|
||||||
* GameSystem (la fiche Nimble n'a pas les mêmes champs qu'une fiche D&D).
|
* GameSystem associé à la campagne (cf. {@link com.loremind.domain.gamesystemcontext.GameSystem#getCharacterTemplate}).
|
||||||
* <p>
|
* <p>
|
||||||
* Scope strict PJ : les PNJ restent dans le Lore (pages templatées) ou
|
* Les valeurs des champs templates sont stockées dans deux maps :
|
||||||
* dans les scènes elles-mêmes. Si le besoin de PNJ spécifiques à une
|
* - {@code values} : champs TEXT et NUMBER (numérique sérialisé en string,
|
||||||
* campagne remonte, on étendra l'entité (ex: type enum PJ/PNJ).
|
* parsé à l'usage cote presentation)
|
||||||
|
* - {@code imageValues} : champs IMAGE (liste ordonnée d'IDs d'images par champ)
|
||||||
|
* <p>
|
||||||
|
* Le champ historique {@code markdownContent} a été supprimé (refonte 2026-04-30).
|
||||||
|
* Le contenu pre-existant est migré dans {@code values["Notes"]} par défaut.
|
||||||
|
* <p>
|
||||||
|
* Scope strict PJ : les PNJ sont gérés par l'entité {@link Npc} (invariants divergents).
|
||||||
*/
|
*/
|
||||||
@Data
|
@Data
|
||||||
@Builder
|
@Builder
|
||||||
@@ -23,11 +32,24 @@ public class Character {
|
|||||||
private String id;
|
private String id;
|
||||||
private String name;
|
private String name;
|
||||||
|
|
||||||
|
/** ID de l'image portrait (champ universel hard-codé). Nullable. */
|
||||||
|
private String portraitImageId;
|
||||||
|
|
||||||
|
/** ID de l'image header/banniere (champ universel hard-codé). Nullable. */
|
||||||
|
private String headerImageId;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Contenu libre en markdown — stats + backstory + notes. Nullable à la création,
|
* Valeurs des champs TEXT et NUMBER du template PJ. Cle = nom du champ
|
||||||
* renseigné progressivement par le MJ.
|
* (sensible a la casse cote stockage mais comparaison case-insensitive
|
||||||
|
* dans le domaine GameSystem). Jamais null apres construction.
|
||||||
*/
|
*/
|
||||||
private String markdownContent;
|
private Map<String, String> values;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Valeurs des champs IMAGE du template PJ. Cle = nom du champ, valeur =
|
||||||
|
* liste ordonnee d'IDs d'images. Jamais null apres construction.
|
||||||
|
*/
|
||||||
|
private Map<String, List<String>> imageValues;
|
||||||
|
|
||||||
/** Référence vers la Campaign parente. */
|
/** Référence vers la Campaign parente. */
|
||||||
private String campaignId;
|
private String campaignId;
|
||||||
@@ -37,4 +59,15 @@ public class Character {
|
|||||||
|
|
||||||
private LocalDateTime createdAt;
|
private LocalDateTime createdAt;
|
||||||
private LocalDateTime updatedAt;
|
private LocalDateTime updatedAt;
|
||||||
|
|
||||||
|
/** Garantit que les maps ne sont jamais null cote consommateur. */
|
||||||
|
public Map<String, String> getValues() {
|
||||||
|
if (values == null) values = new HashMap<>();
|
||||||
|
return values;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Map<String, List<String>> getImageValues() {
|
||||||
|
if (imageValues == null) imageValues = new HashMap<>();
|
||||||
|
return imageValues;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,61 @@
|
|||||||
|
package com.loremind.domain.campaigncontext;
|
||||||
|
|
||||||
|
import lombok.Builder;
|
||||||
|
import lombok.Data;
|
||||||
|
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
import java.util.HashMap;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fiche de personnage non-joueur (PNJ) d'une campagne.
|
||||||
|
* <p>
|
||||||
|
* Entité dédiée distincte de {@link Character} (DDD assumé : invariants divergents
|
||||||
|
* à terme — faction, statut vivant/mort, visibilité côté joueurs, etc.).
|
||||||
|
* <p>
|
||||||
|
* Mêmes champs universels hard-codés et meme structure de templating que Character,
|
||||||
|
* pilotée par le template PNJ du GameSystem
|
||||||
|
* ({@link com.loremind.domain.gamesystemcontext.GameSystem#getNpcTemplate}).
|
||||||
|
* <p>
|
||||||
|
* Scope campagne : les PNJ "univers" (worldboss, figures du Lore) restent gérés
|
||||||
|
* via le système Page/Template du LoreContext.
|
||||||
|
*/
|
||||||
|
@Data
|
||||||
|
@Builder
|
||||||
|
public class Npc {
|
||||||
|
|
||||||
|
private String id;
|
||||||
|
private String name;
|
||||||
|
|
||||||
|
/** ID de l'image portrait (champ universel hard-code). Nullable. */
|
||||||
|
private String portraitImageId;
|
||||||
|
|
||||||
|
/** ID de l'image header/banniere (champ universel hard-code). Nullable. */
|
||||||
|
private String headerImageId;
|
||||||
|
|
||||||
|
/** Valeurs TEXT/NUMBER du template PNJ. Jamais null apres construction. */
|
||||||
|
private Map<String, String> values;
|
||||||
|
|
||||||
|
/** Valeurs IMAGE du template PNJ (listes d'IDs ordonnees par champ). Jamais null. */
|
||||||
|
private Map<String, List<String>> imageValues;
|
||||||
|
|
||||||
|
/** Référence vers la Campaign parente (cross-aggregate via ID). */
|
||||||
|
private String campaignId;
|
||||||
|
|
||||||
|
/** Ordre d'affichage dans la liste des PNJ de la campagne. */
|
||||||
|
private int order;
|
||||||
|
|
||||||
|
private LocalDateTime createdAt;
|
||||||
|
private LocalDateTime updatedAt;
|
||||||
|
|
||||||
|
public Map<String, String> getValues() {
|
||||||
|
if (values == null) values = new HashMap<>();
|
||||||
|
return values;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Map<String, List<String>> getImageValues() {
|
||||||
|
if (imageValues == null) imageValues = new HashMap<>();
|
||||||
|
return imageValues;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
package com.loremind.domain.campaigncontext.ports;
|
||||||
|
|
||||||
|
import com.loremind.domain.campaigncontext.Npc;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Optional;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Port de sortie pour la persistance des fiches de PNJ (campagne).
|
||||||
|
*/
|
||||||
|
public interface NpcRepository {
|
||||||
|
|
||||||
|
Npc save(Npc npc);
|
||||||
|
|
||||||
|
Optional<Npc> findById(String id);
|
||||||
|
|
||||||
|
List<Npc> findByCampaignId(String campaignId);
|
||||||
|
|
||||||
|
void deleteById(String id);
|
||||||
|
|
||||||
|
boolean existsById(String id);
|
||||||
|
}
|
||||||
@@ -1,9 +1,13 @@
|
|||||||
package com.loremind.domain.gamesystemcontext;
|
package com.loremind.domain.gamesystemcontext;
|
||||||
|
|
||||||
|
import com.loremind.domain.shared.template.TemplateField;
|
||||||
import lombok.Builder;
|
import lombok.Builder;
|
||||||
import lombok.Data;
|
import lombok.Data;
|
||||||
|
|
||||||
import java.time.LocalDateTime;
|
import java.time.LocalDateTime;
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Locale;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Entité de domaine représentant un GameSystem (système de JDR).
|
* Entité de domaine représentant un GameSystem (système de JDR).
|
||||||
@@ -12,6 +16,10 @@ import java.time.LocalDateTime;
|
|||||||
* d'un markdown monolithique structuré par titres H2. Les sections sont extraites
|
* d'un markdown monolithique structuré par titres H2. Les sections sont extraites
|
||||||
* à la volée lors de l'injection dans les prompts IA (cf. GameSystemContextSelector).
|
* à la volée lors de l'injection dans les prompts IA (cf. GameSystemContextSelector).
|
||||||
* <p>
|
* <p>
|
||||||
|
* Porte aussi deux templates piloтant la structure des fiches PJ et PNJ d'une
|
||||||
|
* campagne adossée à ce système. Les fiches markdown libres ont laissé place à
|
||||||
|
* un système de champs typés (TEXT/IMAGE/NUMBER) défini ici.
|
||||||
|
* <p>
|
||||||
* {@code author} et {@code isPublic} sont des champs pensés pour un futur marketplace
|
* {@code author} et {@code isPublic} sont des champs pensés pour un futur marketplace
|
||||||
* de rulesets partagés — non exploités au MVP mais persistés dès maintenant pour
|
* de rulesets partagés — non exploités au MVP mais persistés dès maintenant pour
|
||||||
* éviter une migration ultérieure.
|
* éviter une migration ultérieure.
|
||||||
@@ -27,6 +35,21 @@ public class GameSystem {
|
|||||||
/** Markdown monolithique. Sections découpées par titres H2 (## Combat, ## Classes, etc.). */
|
/** Markdown monolithique. Sections découpées par titres H2 (## Combat, ## Classes, etc.). */
|
||||||
private String rulesMarkdown;
|
private String rulesMarkdown;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Template de fiche PJ : champs typés affichés pour chaque personnage joueur.
|
||||||
|
* Hors champs universels hard-codés (nom, portrait, header). Jamais null après
|
||||||
|
* persistance — un template vide est représenté par une liste vide.
|
||||||
|
*/
|
||||||
|
private List<TemplateField> characterTemplate;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Template de fiche PNJ. Mêmes règles que {@link #characterTemplate}.
|
||||||
|
* Distinct du template PJ car les invariants métier divergent (un PNJ peut
|
||||||
|
* n'avoir qu'un nom + une motivation, un PJ porte généralement une feuille
|
||||||
|
* de stats complète).
|
||||||
|
*/
|
||||||
|
private List<TemplateField> npcTemplate;
|
||||||
|
|
||||||
/** Auteur déclaré — futur marketplace. Nullable. */
|
/** Auteur déclaré — futur marketplace. Nullable. */
|
||||||
private String author;
|
private String author;
|
||||||
|
|
||||||
@@ -35,4 +58,88 @@ public class GameSystem {
|
|||||||
|
|
||||||
private LocalDateTime createdAt;
|
private LocalDateTime createdAt;
|
||||||
private LocalDateTime updatedAt;
|
private LocalDateTime updatedAt;
|
||||||
|
|
||||||
|
// --- Méthodes métier : templates PJ/PNJ --------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ajoute un champ au template PJ. Refuse les doublons de nom (insensible à la casse)
|
||||||
|
* pour éviter les collisions de clés dans {@code Character.values}.
|
||||||
|
*/
|
||||||
|
public void addCharacterField(TemplateField field) {
|
||||||
|
characterTemplate = appendField(characterTemplate, field);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Pendant PNJ de {@link #addCharacterField}. */
|
||||||
|
public void addNpcField(TemplateField field) {
|
||||||
|
npcTemplate = appendField(npcTemplate, field);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Retire un champ du template PJ par nom (insensible à la casse).
|
||||||
|
* No-op silencieux si le champ n'existe pas — appelant n'a pas à pré-vérifier.
|
||||||
|
*/
|
||||||
|
public void removeCharacterField(String fieldName) {
|
||||||
|
characterTemplate = removeFieldByName(characterTemplate, fieldName);
|
||||||
|
}
|
||||||
|
|
||||||
|
public void removeNpcField(String fieldName) {
|
||||||
|
npcTemplate = removeFieldByName(npcTemplate, fieldName);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Remplace intégralement le template PJ. Utilisé pour le réordonnancement
|
||||||
|
* et l'édition en bloc côté UI. Valide l'unicité des noms.
|
||||||
|
*/
|
||||||
|
public void replaceCharacterTemplate(List<TemplateField> fields) {
|
||||||
|
characterTemplate = validateAndCopy(fields);
|
||||||
|
}
|
||||||
|
|
||||||
|
public void replaceNpcTemplate(List<TemplateField> fields) {
|
||||||
|
npcTemplate = validateAndCopy(fields);
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Helpers privés ----------------------------------------------------
|
||||||
|
|
||||||
|
private static List<TemplateField> appendField(List<TemplateField> current, TemplateField field) {
|
||||||
|
if (field == null || field.getName() == null || field.getName().isBlank()) {
|
||||||
|
throw new IllegalArgumentException("Field name is required");
|
||||||
|
}
|
||||||
|
List<TemplateField> next = current == null ? new ArrayList<>() : new ArrayList<>(current);
|
||||||
|
if (containsName(next, field.getName())) {
|
||||||
|
throw new IllegalArgumentException("Duplicate field name: " + field.getName());
|
||||||
|
}
|
||||||
|
next.add(field);
|
||||||
|
return next;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static List<TemplateField> removeFieldByName(List<TemplateField> current, String fieldName) {
|
||||||
|
if (current == null || fieldName == null) return current;
|
||||||
|
List<TemplateField> next = new ArrayList<>(current);
|
||||||
|
next.removeIf(f -> equalsIgnoreCase(f.getName(), fieldName));
|
||||||
|
return next;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static List<TemplateField> validateAndCopy(List<TemplateField> fields) {
|
||||||
|
if (fields == null) return new ArrayList<>();
|
||||||
|
List<TemplateField> copy = new ArrayList<>(fields.size());
|
||||||
|
for (TemplateField f : fields) {
|
||||||
|
if (f == null || f.getName() == null || f.getName().isBlank()) {
|
||||||
|
throw new IllegalArgumentException("Field name is required");
|
||||||
|
}
|
||||||
|
if (containsName(copy, f.getName())) {
|
||||||
|
throw new IllegalArgumentException("Duplicate field name: " + f.getName());
|
||||||
|
}
|
||||||
|
copy.add(f);
|
||||||
|
}
|
||||||
|
return copy;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static boolean containsName(List<TemplateField> fields, String name) {
|
||||||
|
return fields.stream().anyMatch(f -> equalsIgnoreCase(f.getName(), name));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static boolean equalsIgnoreCase(String a, String b) {
|
||||||
|
if (a == null || b == null) return a == b;
|
||||||
|
return a.toLowerCase(Locale.ROOT).equals(b.toLowerCase(Locale.ROOT));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -22,12 +22,14 @@ import java.util.List;
|
|||||||
* Record Java : pur domaine, aucune dépendance technique.
|
* Record Java : pur domaine, aucune dépendance technique.
|
||||||
*
|
*
|
||||||
* @param characters Personnages joueurs (PJ) de la campagne. Vide si aucun.
|
* @param characters Personnages joueurs (PJ) de la campagne. Vide si aucun.
|
||||||
|
* @param npcs Personnages non-joueurs (PNJ) de la campagne. Vide si aucun.
|
||||||
*/
|
*/
|
||||||
public record CampaignStructuralContext(
|
public record CampaignStructuralContext(
|
||||||
String campaignName,
|
String campaignName,
|
||||||
String campaignDescription,
|
String campaignDescription,
|
||||||
List<ArcSummary> arcs,
|
List<ArcSummary> arcs,
|
||||||
List<CharacterSummary> characters) {
|
List<CharacterSummary> characters,
|
||||||
|
List<NpcSummary> npcs) {
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Résumé d'un PJ : nom + snippet court du markdown.
|
* Résumé d'un PJ : nom + snippet court du markdown.
|
||||||
@@ -39,6 +41,14 @@ public record CampaignStructuralContext(
|
|||||||
public record CharacterSummary(String name, String snippet) {
|
public record CharacterSummary(String name, String snippet) {
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Résumé d'un PNJ : symétrique à {@link CharacterSummary}.
|
||||||
|
* Snippet court extrait du markdown — la fiche complète est réservée
|
||||||
|
* à un usage focus (à venir, entity_type="npc").
|
||||||
|
*/
|
||||||
|
public record NpcSummary(String name, String snippet) {
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Résumé d'un arc : nom + description courte + ses chapitres.
|
* Résumé d'un arc : nom + description courte + ses chapitres.
|
||||||
*
|
*
|
||||||
|
|||||||
@@ -0,0 +1,48 @@
|
|||||||
|
package com.loremind.domain.licensing;
|
||||||
|
|
||||||
|
import lombok.Builder;
|
||||||
|
import lombok.Data;
|
||||||
|
|
||||||
|
import java.time.Instant;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Licence Patreon installee dans cette instance LoreMind.
|
||||||
|
* <p>
|
||||||
|
* Singleton (une seule licence par instance, identifiee logiquement par
|
||||||
|
* {@code id = "current"}). Contient le JWT brut emis par le relais OAuth
|
||||||
|
* + les claims extraits a la verification, plus l'etat operationnel
|
||||||
|
* (derniere tentative de refresh, succes/echec).
|
||||||
|
* <p>
|
||||||
|
* <b>Note securite :</b> {@link #rawJwt} est stocke tel quel ; sa signature
|
||||||
|
* Ed25519 est verifiee a chaque lecture. Pas besoin de chiffrement au repos
|
||||||
|
* supplementaire — un attaquant qui a acces a la base a deja l'instance,
|
||||||
|
* et le JWT ne donne aucun pouvoir au-dela du canal beta de cette instance.
|
||||||
|
*/
|
||||||
|
@Data
|
||||||
|
@Builder
|
||||||
|
public class License {
|
||||||
|
|
||||||
|
private String id;
|
||||||
|
|
||||||
|
private String rawJwt;
|
||||||
|
|
||||||
|
private String patreonUserId;
|
||||||
|
|
||||||
|
private String tierId;
|
||||||
|
|
||||||
|
private String instanceId;
|
||||||
|
|
||||||
|
private Instant issuedAt;
|
||||||
|
|
||||||
|
private Instant expiresAt;
|
||||||
|
|
||||||
|
private Instant lastRefreshAttemptAt;
|
||||||
|
|
||||||
|
private boolean lastRefreshSucceeded;
|
||||||
|
|
||||||
|
private boolean betaChannelEnabled;
|
||||||
|
|
||||||
|
private Instant createdAt;
|
||||||
|
|
||||||
|
private Instant updatedAt;
|
||||||
|
}
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
package com.loremind.domain.licensing;
|
||||||
|
|
||||||
|
import java.time.Instant;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Claims extraits d'un JWT licence apres verification de signature.
|
||||||
|
* Immuable.
|
||||||
|
*/
|
||||||
|
public record LicenseClaims(
|
||||||
|
String subject,
|
||||||
|
String tierId,
|
||||||
|
String instanceId,
|
||||||
|
Instant issuedAt,
|
||||||
|
Instant expiresAt
|
||||||
|
) {}
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
package com.loremind.domain.licensing;
|
||||||
|
|
||||||
|
import java.time.Instant;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Vue immuable de la licence pour exposition vers les couches superieures.
|
||||||
|
* Decouple le domaine du DTO web et permet de calculer le {@link LicenseStatus}
|
||||||
|
* a un instant donne sans muter l'entite.
|
||||||
|
*/
|
||||||
|
public record LicenseSnapshot(
|
||||||
|
LicenseStatus status,
|
||||||
|
String patreonUserId,
|
||||||
|
String tierId,
|
||||||
|
String instanceId,
|
||||||
|
Instant expiresAt,
|
||||||
|
Instant lastRefreshAttemptAt,
|
||||||
|
boolean lastRefreshSucceeded,
|
||||||
|
boolean betaChannelEnabled
|
||||||
|
) {
|
||||||
|
public static LicenseSnapshot none() {
|
||||||
|
return new LicenseSnapshot(LicenseStatus.NONE, null, null, null, null, null, false, false);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
package com.loremind.domain.licensing;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Etat operationnel de la licence vis-a-vis de l'acces beta.
|
||||||
|
* <p>
|
||||||
|
* Calcule a partir de la presence de licence + son JWT exp + grace period.
|
||||||
|
* <ul>
|
||||||
|
* <li>{@link #NONE} : aucune licence installee</li>
|
||||||
|
* <li>{@link #VALID} : JWT non expire, acces beta autorise</li>
|
||||||
|
* <li>{@link #GRACE} : JWT expire mais dans la periode de tolerance ;
|
||||||
|
* acces beta toujours autorise, l'UI doit avertir</li>
|
||||||
|
* <li>{@link #EXPIRED} : au-dela de la grace period, acces beta refuse</li>
|
||||||
|
* <li>{@link #UNVERIFIABLE} : JWT impossible a verifier (cle publique manquante,
|
||||||
|
* signature invalide, claims malformes) — traite comme NONE pour la securite</li>
|
||||||
|
* </ul>
|
||||||
|
*/
|
||||||
|
public enum LicenseStatus {
|
||||||
|
NONE,
|
||||||
|
VALID,
|
||||||
|
GRACE,
|
||||||
|
EXPIRED,
|
||||||
|
UNVERIFIABLE
|
||||||
|
}
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
package com.loremind.domain.licensing;
|
||||||
|
|
||||||
|
import java.time.Instant;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Credentials de pull pour un registry Docker, distribues par le relais
|
||||||
|
* apres verification d'un JWT licence valide.
|
||||||
|
* <p>
|
||||||
|
* {@code expiresAt} peut etre {@code null} si le credential est statique
|
||||||
|
* (cas du PAT GHCR partage en MVP) ; sinon, l'instance doit re-demander
|
||||||
|
* de nouveaux credentials avant cette date.
|
||||||
|
*/
|
||||||
|
public record RegistryCredentials(
|
||||||
|
String registry,
|
||||||
|
String username,
|
||||||
|
String password,
|
||||||
|
Instant expiresAt
|
||||||
|
) {}
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
package com.loremind.domain.licensing.ports;
|
||||||
|
|
||||||
|
import com.loremind.domain.licensing.RegistryCredentials;
|
||||||
|
|
||||||
|
import java.io.IOException;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Port de sortie : ecriture du docker config.json partage avec Watchtower.
|
||||||
|
* <p>
|
||||||
|
* Le fichier sert a Watchtower pour s'authentifier au registry prive (GHCR)
|
||||||
|
* lors du pull des images du canal beta. Volume Docker {@code docker-config}
|
||||||
|
* monte sur Core (en ecriture) et sur Watchtower (en lecture, via la variable
|
||||||
|
* {@code DOCKER_CONFIG}).
|
||||||
|
*/
|
||||||
|
public interface DockerConfigWriter {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ecrit ou met a jour les credentials pour le registry indique.
|
||||||
|
* Cree le fichier s'il n'existe pas, conserve les autres registries deja
|
||||||
|
* presents (en theorie : aucun, mais defensif).
|
||||||
|
*/
|
||||||
|
void writeCredentials(RegistryCredentials credentials) throws IOException;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Supprime le fichier de credentials. Appele quand la licence est invalidee
|
||||||
|
* ou que le toggle beta passe a OFF.
|
||||||
|
*/
|
||||||
|
void clear() throws IOException;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return true si le fichier de creds existe actuellement.
|
||||||
|
*/
|
||||||
|
boolean isPresent();
|
||||||
|
}
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
package com.loremind.domain.licensing.ports;
|
||||||
|
|
||||||
|
import com.loremind.domain.licensing.LicenseClaims;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Port de sortie : verification de signature et extraction des claims
|
||||||
|
* d'un JWT emis par le relais.
|
||||||
|
* <p>
|
||||||
|
* Implemente cote infrastructure avec la cle publique Ed25519 embarquee
|
||||||
|
* (SPKI PEM via configuration {@code licensing.jwt.public-key}).
|
||||||
|
*/
|
||||||
|
public interface JwtVerifier {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Verifie la signature, l'issuer, l'audience et l'expiration du JWT.
|
||||||
|
* @throws JwtVerificationException si la signature est invalide ou les claims malformes
|
||||||
|
*/
|
||||||
|
LicenseClaims verify(String rawJwt) throws JwtVerificationException;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return true si la cle publique est configuree et utilisable.
|
||||||
|
* Permet a l'application de masquer la feature licensing si pas configuree.
|
||||||
|
*/
|
||||||
|
boolean isConfigured();
|
||||||
|
|
||||||
|
class JwtVerificationException extends Exception {
|
||||||
|
public JwtVerificationException(String message) {
|
||||||
|
super(message);
|
||||||
|
}
|
||||||
|
public JwtVerificationException(String message, Throwable cause) {
|
||||||
|
super(message, cause);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
package com.loremind.domain.licensing.ports;
|
||||||
|
|
||||||
|
import com.loremind.domain.licensing.RegistryCredentials;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Port de sortie vers le service relais OAuth Patreon.
|
||||||
|
* Encapsule les appels HTTP : refresh JWT et fetch registry credentials.
|
||||||
|
*/
|
||||||
|
public interface LicenseRelay {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Demande au relais l'URL OAuth a ouvrir pour connecter le compte Patreon.
|
||||||
|
*/
|
||||||
|
String buildConnectUrl(String instanceId);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Demande au relais de renouveler un JWT existant. Le relais re-verifie
|
||||||
|
* le tier Patreon de l'utilisateur ; renvoie un nouveau JWT si toujours
|
||||||
|
* actif, ou leve {@link RelayException} sinon.
|
||||||
|
*/
|
||||||
|
String refreshToken(String currentJwt) throws RelayException;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Demande au relais les credentials de pull du registry beta.
|
||||||
|
*/
|
||||||
|
RegistryCredentials fetchRegistryCredentials(String currentJwt) throws RelayException;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Erreurs distinctes emises par le relais. Permet au service application
|
||||||
|
* de differencier "tier expire" (action utilisateur) de "relais down"
|
||||||
|
* (action transitoire, garde la grace period).
|
||||||
|
*/
|
||||||
|
class RelayException extends Exception {
|
||||||
|
private final RelayErrorKind kind;
|
||||||
|
|
||||||
|
public RelayException(RelayErrorKind kind, String message) {
|
||||||
|
super(message);
|
||||||
|
this.kind = kind;
|
||||||
|
}
|
||||||
|
|
||||||
|
public RelayException(RelayErrorKind kind, String message, Throwable cause) {
|
||||||
|
super(message, cause);
|
||||||
|
this.kind = kind;
|
||||||
|
}
|
||||||
|
|
||||||
|
public RelayErrorKind getKind() {
|
||||||
|
return kind;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
enum RelayErrorKind {
|
||||||
|
/** Le relais est joignable mais refuse : tier non actif, JWT trop ancien, etc. */
|
||||||
|
REJECTED,
|
||||||
|
/** Le relais a renvoye un JWT mais il est invalide / non parsable. */
|
||||||
|
BAD_RESPONSE,
|
||||||
|
/** Le relais est injoignable / 5xx / timeout. */
|
||||||
|
TRANSIENT
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
package com.loremind.domain.licensing.ports;
|
||||||
|
|
||||||
|
import com.loremind.domain.licensing.License;
|
||||||
|
|
||||||
|
import java.util.Optional;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Port de sortie pour la persistance de la licence installee.
|
||||||
|
* <p>
|
||||||
|
* Une seule licence par instance ({@code id = "current"} par convention).
|
||||||
|
*/
|
||||||
|
public interface LicenseRepository {
|
||||||
|
|
||||||
|
Optional<License> findCurrent();
|
||||||
|
|
||||||
|
License save(License license);
|
||||||
|
|
||||||
|
void deleteCurrent();
|
||||||
|
}
|
||||||
@@ -1,15 +0,0 @@
|
|||||||
package com.loremind.domain.lorecontext;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Type d'un champ dynamique d'un Template.
|
|
||||||
* <p>
|
|
||||||
* - TEXT : valeur textuelle libre (stockee dans Page.values : Map<String, String>)
|
|
||||||
* - IMAGE : galerie d'images, represente comme une liste d'IDs d'images
|
|
||||||
* (stockee dans Page.imageValues : Map<String, List<String>>)
|
|
||||||
* <p>
|
|
||||||
* Extension future possible : RICH_TEXT, NUMBER, DATE, BOOLEAN, LORE_LINK...
|
|
||||||
*/
|
|
||||||
public enum FieldType {
|
|
||||||
TEXT,
|
|
||||||
IMAGE
|
|
||||||
}
|
|
||||||
@@ -1,5 +1,7 @@
|
|||||||
package com.loremind.domain.lorecontext;
|
package com.loremind.domain.lorecontext;
|
||||||
|
|
||||||
|
import com.loremind.domain.shared.template.FieldType;
|
||||||
|
import com.loremind.domain.shared.template.TemplateField;
|
||||||
import lombok.Builder;
|
import lombok.Builder;
|
||||||
import lombok.Data;
|
import lombok.Data;
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,16 @@
|
|||||||
|
package com.loremind.domain.shared.template;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Type d'un champ dynamique de template (kernel partage).
|
||||||
|
* <p>
|
||||||
|
* - TEXT : valeur textuelle libre (Map<String, String>)
|
||||||
|
* - IMAGE : galerie d'images, liste d'IDs (Map<String, List<String>>)
|
||||||
|
* - NUMBER : valeur numerique stockee en texte (parsee a l'usage)
|
||||||
|
* <p>
|
||||||
|
* Extension future possible : RICH_TEXT, DATE, BOOLEAN, KEY_VALUE_LIST, REFERENCE...
|
||||||
|
*/
|
||||||
|
public enum FieldType {
|
||||||
|
TEXT,
|
||||||
|
IMAGE,
|
||||||
|
NUMBER
|
||||||
|
}
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
package com.loremind.domain.lorecontext;
|
package com.loremind.domain.shared.template;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Variante de rendu pour un champ de type IMAGE.
|
* Variante de rendu pour un champ de type IMAGE.
|
||||||
@@ -8,7 +8,7 @@ package com.loremind.domain.lorecontext;
|
|||||||
* - MASONRY : mosaique hauteurs variables facon Pinterest
|
* - MASONRY : mosaique hauteurs variables facon Pinterest
|
||||||
* - CAROUSEL : defilement horizontal
|
* - CAROUSEL : defilement horizontal
|
||||||
* <p>
|
* <p>
|
||||||
* Uniquement significatif quand {@link FieldType} = IMAGE. Ignore pour TEXT.
|
* Uniquement significatif quand {@link FieldType} = IMAGE. Ignore sinon.
|
||||||
*/
|
*/
|
||||||
public enum ImageLayout {
|
public enum ImageLayout {
|
||||||
GALLERY,
|
GALLERY,
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
package com.loremind.domain.lorecontext;
|
package com.loremind.domain.shared.template;
|
||||||
|
|
||||||
import lombok.AllArgsConstructor;
|
import lombok.AllArgsConstructor;
|
||||||
import lombok.Builder;
|
import lombok.Builder;
|
||||||
@@ -6,15 +6,15 @@ import lombok.Data;
|
|||||||
import lombok.NoArgsConstructor;
|
import lombok.NoArgsConstructor;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Value Object d'un champ de Template.
|
* Value Object d'un champ de Template (kernel partage).
|
||||||
* <p>
|
* <p>
|
||||||
* Un champ a un nom (affiche dans l'UI) et un type (TEXT ou IMAGE, extensible).
|
* Un champ a un nom (affiche dans l'UI) et un type. Le type pilote
|
||||||
* Le type pilote le rendu cote front (textarea vs galerie d'images) ET
|
* le rendu cote front et la logique metier (seuls les champs TEXT sont
|
||||||
* la logique metier (seuls les champs TEXT sont envoyes a l'IA pour generation).
|
* envoyes a l'IA pour generation).
|
||||||
* <p>
|
* <p>
|
||||||
* Pour les champs IMAGE, {@link #layout} precise la variante de rendu
|
* Pour les champs IMAGE, {@link #layout} precise la variante de rendu
|
||||||
* (gallery/hero/masonry/carousel). Nullable : l'absence equivaut a GALLERY.
|
* (gallery/hero/masonry/carousel). Nullable : l'absence equivaut a GALLERY.
|
||||||
* Ignore pour les champs TEXT.
|
* Ignore pour les autres types.
|
||||||
*/
|
*/
|
||||||
@Data
|
@Data
|
||||||
@Builder
|
@Builder
|
||||||
@@ -47,4 +47,9 @@ public class TemplateField {
|
|||||||
public static TemplateField image(String name, ImageLayout layout) {
|
public static TemplateField image(String name, ImageLayout layout) {
|
||||||
return new TemplateField(name, FieldType.IMAGE, layout);
|
return new TemplateField(name, FieldType.IMAGE, layout);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Raccourci : construit un champ de type NUMBER. */
|
||||||
|
public static TemplateField number(String name) {
|
||||||
|
return new TemplateField(name, FieldType.NUMBER, null);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -5,6 +5,7 @@ import com.loremind.domain.generationcontext.CampaignStructuralContext.ArcSummar
|
|||||||
import com.loremind.domain.generationcontext.CampaignStructuralContext.BranchHint;
|
import com.loremind.domain.generationcontext.CampaignStructuralContext.BranchHint;
|
||||||
import com.loremind.domain.generationcontext.CampaignStructuralContext.ChapterSummary;
|
import com.loremind.domain.generationcontext.CampaignStructuralContext.ChapterSummary;
|
||||||
import com.loremind.domain.generationcontext.CampaignStructuralContext.CharacterSummary;
|
import com.loremind.domain.generationcontext.CampaignStructuralContext.CharacterSummary;
|
||||||
|
import com.loremind.domain.generationcontext.CampaignStructuralContext.NpcSummary;
|
||||||
import com.loremind.domain.generationcontext.CampaignStructuralContext.SceneSummary;
|
import com.loremind.domain.generationcontext.CampaignStructuralContext.SceneSummary;
|
||||||
import com.loremind.domain.generationcontext.ChatMessage;
|
import com.loremind.domain.generationcontext.ChatMessage;
|
||||||
import com.loremind.domain.generationcontext.ChatRequest;
|
import com.loremind.domain.generationcontext.ChatRequest;
|
||||||
@@ -132,6 +133,12 @@ public class BrainChatPayloadBuilder {
|
|||||||
.map(this::characterSummaryToMap)
|
.map(this::characterSummaryToMap)
|
||||||
.collect(Collectors.toList()));
|
.collect(Collectors.toList()));
|
||||||
}
|
}
|
||||||
|
// Liste des PNJ : symétrique aux PJ, omise si vide pour alléger le payload.
|
||||||
|
if (ctx.npcs() != null && !ctx.npcs().isEmpty()) {
|
||||||
|
map.put("npcs", ctx.npcs().stream()
|
||||||
|
.map(this::npcSummaryToMap)
|
||||||
|
.collect(Collectors.toList()));
|
||||||
|
}
|
||||||
return map;
|
return map;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -144,6 +151,15 @@ public class BrainChatPayloadBuilder {
|
|||||||
return map;
|
return map;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private Map<String, Object> npcSummaryToMap(NpcSummary n) {
|
||||||
|
Map<String, Object> map = new LinkedHashMap<>();
|
||||||
|
map.put("name", n.name());
|
||||||
|
if (n.snippet() != null && !n.snippet().isBlank()) {
|
||||||
|
map.put("snippet", n.snippet());
|
||||||
|
}
|
||||||
|
return map;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Helper générique pour sérialiser les entités structurelles (Arc/Chapter/Scene)
|
* Helper générique pour sérialiser les entités structurelles (Arc/Chapter/Scene)
|
||||||
* avec name, description et illustration_count conditionnel.
|
* avec name, description et illustration_count conditionnel.
|
||||||
|
|||||||
@@ -0,0 +1,111 @@
|
|||||||
|
package com.loremind.infrastructure.licensing;
|
||||||
|
|
||||||
|
import com.fasterxml.jackson.databind.JsonNode;
|
||||||
|
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||||
|
import com.fasterxml.jackson.databind.node.ObjectNode;
|
||||||
|
import com.loremind.domain.licensing.RegistryCredentials;
|
||||||
|
import com.loremind.domain.licensing.ports.DockerConfigWriter;
|
||||||
|
import org.slf4j.Logger;
|
||||||
|
import org.slf4j.LoggerFactory;
|
||||||
|
import org.springframework.beans.factory.annotation.Value;
|
||||||
|
import org.springframework.stereotype.Component;
|
||||||
|
|
||||||
|
import java.io.IOException;
|
||||||
|
import java.nio.charset.StandardCharsets;
|
||||||
|
import java.nio.file.Files;
|
||||||
|
import java.nio.file.Path;
|
||||||
|
import java.nio.file.attribute.PosixFilePermissions;
|
||||||
|
import java.util.Base64;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Implementation : ecriture du fichier {@code config.json} au format Docker
|
||||||
|
* standard, dans un volume partage avec Watchtower.
|
||||||
|
* <p>
|
||||||
|
* Format produit :
|
||||||
|
* <pre>{@code
|
||||||
|
* {
|
||||||
|
* "auths": {
|
||||||
|
* "ghcr.io": {
|
||||||
|
* "auth": "<base64(username:password)>"
|
||||||
|
* }
|
||||||
|
* }
|
||||||
|
* }
|
||||||
|
* }</pre>
|
||||||
|
*/
|
||||||
|
@Component
|
||||||
|
public class FileDockerConfigWriter implements DockerConfigWriter {
|
||||||
|
|
||||||
|
private static final Logger log = LoggerFactory.getLogger(FileDockerConfigWriter.class);
|
||||||
|
|
||||||
|
private final Path configPath;
|
||||||
|
private final ObjectMapper mapper = new ObjectMapper();
|
||||||
|
|
||||||
|
public FileDockerConfigWriter(
|
||||||
|
@Value("${licensing.docker-config-path:/shared/docker/config.json}") String pathStr) {
|
||||||
|
this.configPath = Path.of(pathStr);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void writeCredentials(RegistryCredentials credentials) throws IOException {
|
||||||
|
ensureParentDirectory();
|
||||||
|
|
||||||
|
ObjectNode root;
|
||||||
|
if (Files.exists(configPath)) {
|
||||||
|
try {
|
||||||
|
JsonNode existing = mapper.readTree(configPath.toFile());
|
||||||
|
root = existing.isObject() ? (ObjectNode) existing : mapper.createObjectNode();
|
||||||
|
} catch (IOException e) {
|
||||||
|
log.warn("Existing docker config unreadable, overwriting: {}", e.getMessage());
|
||||||
|
root = mapper.createObjectNode();
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
root = mapper.createObjectNode();
|
||||||
|
}
|
||||||
|
|
||||||
|
ObjectNode auths = root.has("auths") && root.get("auths").isObject()
|
||||||
|
? (ObjectNode) root.get("auths")
|
||||||
|
: root.putObject("auths");
|
||||||
|
|
||||||
|
String b64 = Base64.getEncoder().encodeToString(
|
||||||
|
(credentials.username() + ":" + credentials.password()).getBytes(StandardCharsets.UTF_8));
|
||||||
|
|
||||||
|
ObjectNode entry = mapper.createObjectNode();
|
||||||
|
entry.put("auth", b64);
|
||||||
|
auths.set(credentials.registry(), entry);
|
||||||
|
|
||||||
|
Files.writeString(configPath, mapper.writerWithDefaultPrettyPrinter().writeValueAsString(root),
|
||||||
|
StandardCharsets.UTF_8);
|
||||||
|
applyRestrictivePermissions();
|
||||||
|
log.info("Docker config written at {} for registry {}", configPath, credentials.registry());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void clear() throws IOException {
|
||||||
|
if (Files.exists(configPath)) {
|
||||||
|
Files.delete(configPath);
|
||||||
|
log.info("Docker config cleared at {}", configPath);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public boolean isPresent() {
|
||||||
|
return Files.exists(configPath);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void ensureParentDirectory() throws IOException {
|
||||||
|
Path parent = configPath.getParent();
|
||||||
|
if (parent != null && !Files.exists(parent)) {
|
||||||
|
Files.createDirectories(parent);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 0600 sur POSIX. Sur Windows (dev), no-op silencieux. */
|
||||||
|
private void applyRestrictivePermissions() {
|
||||||
|
try {
|
||||||
|
Files.setPosixFilePermissions(configPath, PosixFilePermissions.fromString("rw-------"));
|
||||||
|
} catch (UnsupportedOperationException | IOException e) {
|
||||||
|
// Windows / FS qui ne supporte pas POSIX => ignore (le conteneur tourne sous Linux en prod)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
@@ -0,0 +1,146 @@
|
|||||||
|
package com.loremind.infrastructure.licensing;
|
||||||
|
|
||||||
|
import com.fasterxml.jackson.databind.JsonNode;
|
||||||
|
import com.loremind.domain.licensing.RegistryCredentials;
|
||||||
|
import com.loremind.domain.licensing.ports.LicenseRelay;
|
||||||
|
import org.slf4j.Logger;
|
||||||
|
import org.slf4j.LoggerFactory;
|
||||||
|
import org.springframework.beans.factory.annotation.Value;
|
||||||
|
import org.springframework.boot.web.client.RestTemplateBuilder;
|
||||||
|
import org.springframework.http.HttpEntity;
|
||||||
|
import org.springframework.http.HttpHeaders;
|
||||||
|
import org.springframework.http.HttpMethod;
|
||||||
|
import org.springframework.http.MediaType;
|
||||||
|
import org.springframework.http.ResponseEntity;
|
||||||
|
import org.springframework.stereotype.Component;
|
||||||
|
import org.springframework.web.client.HttpClientErrorException;
|
||||||
|
import org.springframework.web.client.HttpServerErrorException;
|
||||||
|
import org.springframework.web.client.RestClientException;
|
||||||
|
import org.springframework.web.client.RestTemplate;
|
||||||
|
|
||||||
|
import java.net.URLEncoder;
|
||||||
|
import java.nio.charset.StandardCharsets;
|
||||||
|
import java.time.Duration;
|
||||||
|
import java.time.Instant;
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Client HTTP du relais OAuth Patreon (deploye sur Cloudflare Workers).
|
||||||
|
* Voir {@code relay/} pour le code du relais.
|
||||||
|
*/
|
||||||
|
@Component
|
||||||
|
public class HttpLicenseRelay implements LicenseRelay {
|
||||||
|
|
||||||
|
private static final Logger log = LoggerFactory.getLogger(HttpLicenseRelay.class);
|
||||||
|
|
||||||
|
private final RestTemplate http;
|
||||||
|
private final String baseUrl;
|
||||||
|
|
||||||
|
public HttpLicenseRelay(
|
||||||
|
RestTemplateBuilder builder,
|
||||||
|
@Value("${licensing.relay.base-url:}") String baseUrl) {
|
||||||
|
this.http = builder
|
||||||
|
.setConnectTimeout(Duration.ofSeconds(5))
|
||||||
|
.setReadTimeout(Duration.ofSeconds(15))
|
||||||
|
.build();
|
||||||
|
this.baseUrl = stripTrailingSlash(baseUrl);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public String buildConnectUrl(String instanceId) {
|
||||||
|
if (baseUrl.isBlank()) {
|
||||||
|
throw new IllegalStateException("Licensing relay base URL not configured");
|
||||||
|
}
|
||||||
|
String encoded = URLEncoder.encode(instanceId, StandardCharsets.UTF_8);
|
||||||
|
return baseUrl + "/oauth/start?instance_id=" + encoded;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public String refreshToken(String currentJwt) throws RelayException {
|
||||||
|
if (baseUrl.isBlank()) {
|
||||||
|
throw new RelayException(RelayErrorKind.TRANSIENT, "relay not configured");
|
||||||
|
}
|
||||||
|
HttpHeaders headers = new HttpHeaders();
|
||||||
|
headers.setContentType(MediaType.APPLICATION_JSON);
|
||||||
|
Map<String, String> body = Map.of("jwt", currentJwt);
|
||||||
|
|
||||||
|
ResponseEntity<JsonNode> resp;
|
||||||
|
try {
|
||||||
|
resp = http.exchange(
|
||||||
|
baseUrl + "/token/refresh",
|
||||||
|
HttpMethod.POST,
|
||||||
|
new HttpEntity<>(body, headers),
|
||||||
|
JsonNode.class);
|
||||||
|
} catch (HttpClientErrorException e) {
|
||||||
|
throw new RelayException(RelayErrorKind.REJECTED,
|
||||||
|
"relay rejected refresh: " + e.getStatusCode() + " " + e.getStatusText());
|
||||||
|
} catch (HttpServerErrorException e) {
|
||||||
|
throw new RelayException(RelayErrorKind.TRANSIENT,
|
||||||
|
"relay 5xx: " + e.getStatusCode());
|
||||||
|
} catch (RestClientException e) {
|
||||||
|
throw new RelayException(RelayErrorKind.TRANSIENT, "relay unreachable: " + e.getMessage(), e);
|
||||||
|
}
|
||||||
|
|
||||||
|
JsonNode payload = resp.getBody();
|
||||||
|
if (payload == null || !payload.hasNonNull("jwt")) {
|
||||||
|
throw new RelayException(RelayErrorKind.BAD_RESPONSE, "missing jwt in refresh response");
|
||||||
|
}
|
||||||
|
return payload.get("jwt").asText();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public RegistryCredentials fetchRegistryCredentials(String currentJwt) throws RelayException {
|
||||||
|
if (baseUrl.isBlank()) {
|
||||||
|
throw new RelayException(RelayErrorKind.TRANSIENT, "relay not configured");
|
||||||
|
}
|
||||||
|
HttpHeaders headers = new HttpHeaders();
|
||||||
|
headers.setContentType(MediaType.APPLICATION_JSON);
|
||||||
|
Map<String, String> body = Map.of("jwt", currentJwt);
|
||||||
|
|
||||||
|
ResponseEntity<JsonNode> resp;
|
||||||
|
try {
|
||||||
|
resp = http.exchange(
|
||||||
|
baseUrl + "/registry/credentials",
|
||||||
|
HttpMethod.POST,
|
||||||
|
new HttpEntity<>(body, headers),
|
||||||
|
JsonNode.class);
|
||||||
|
} catch (HttpClientErrorException e) {
|
||||||
|
throw new RelayException(RelayErrorKind.REJECTED,
|
||||||
|
"relay rejected creds: " + e.getStatusCode() + " " + e.getStatusText());
|
||||||
|
} catch (HttpServerErrorException e) {
|
||||||
|
throw new RelayException(RelayErrorKind.TRANSIENT,
|
||||||
|
"relay 5xx: " + e.getStatusCode());
|
||||||
|
} catch (RestClientException e) {
|
||||||
|
throw new RelayException(RelayErrorKind.TRANSIENT, "relay unreachable: " + e.getMessage(), e);
|
||||||
|
}
|
||||||
|
|
||||||
|
JsonNode payload = resp.getBody();
|
||||||
|
if (payload == null
|
||||||
|
|| !payload.hasNonNull("registry")
|
||||||
|
|| !payload.hasNonNull("username")
|
||||||
|
|| !payload.hasNonNull("password")) {
|
||||||
|
throw new RelayException(RelayErrorKind.BAD_RESPONSE, "incomplete credentials response");
|
||||||
|
}
|
||||||
|
Instant expiresAt = null;
|
||||||
|
if (payload.hasNonNull("expires_at")) {
|
||||||
|
try {
|
||||||
|
expiresAt = Instant.parse(payload.get("expires_at").asText());
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.warn("Cannot parse expires_at from relay creds response: {}", e.getMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return new RegistryCredentials(
|
||||||
|
payload.get("registry").asText(),
|
||||||
|
payload.get("username").asText(),
|
||||||
|
payload.get("password").asText(),
|
||||||
|
expiresAt
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String stripTrailingSlash(String s) {
|
||||||
|
if (s == null) return "";
|
||||||
|
String v = s.trim();
|
||||||
|
if (v.endsWith("/")) v = v.substring(0, v.length() - 1);
|
||||||
|
return v;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,93 @@
|
|||||||
|
package com.loremind.infrastructure.licensing;
|
||||||
|
|
||||||
|
import com.loremind.application.licensing.LicenseService;
|
||||||
|
import com.loremind.domain.licensing.LicenseSnapshot;
|
||||||
|
import com.loremind.domain.licensing.LicenseStatus;
|
||||||
|
import com.loremind.domain.licensing.RegistryCredentials;
|
||||||
|
import com.loremind.domain.licensing.ports.DockerConfigWriter;
|
||||||
|
import org.slf4j.Logger;
|
||||||
|
import org.slf4j.LoggerFactory;
|
||||||
|
import org.springframework.scheduling.annotation.Scheduled;
|
||||||
|
import org.springframework.stereotype.Component;
|
||||||
|
|
||||||
|
import java.io.IOException;
|
||||||
|
import java.util.Optional;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Daemon planifie qui :
|
||||||
|
* <ul>
|
||||||
|
* <li>renouvelle le JWT licence via le relais avant expiration (J-2)</li>
|
||||||
|
* <li>met a jour les credentials registry GHCR pour Watchtower
|
||||||
|
* (volume partage docker-config) tant que le canal beta est ON</li>
|
||||||
|
* <li>nettoie les credentials si la licence est invalidee ou le toggle OFF</li>
|
||||||
|
* </ul>
|
||||||
|
* Idempotent : peut tourner toutes les 6h sans risque, fait du no-op
|
||||||
|
* la plupart du temps.
|
||||||
|
*/
|
||||||
|
@Component
|
||||||
|
public class LicenseRefreshDaemon {
|
||||||
|
|
||||||
|
private static final Logger log = LoggerFactory.getLogger(LicenseRefreshDaemon.class);
|
||||||
|
|
||||||
|
/** 6 heures entre chaque cycle. Suffisant pour rattraper un J-2 sans surcharger. */
|
||||||
|
private static final long FIXED_DELAY_MS = 6L * 60L * 60L * 1000L;
|
||||||
|
/** Premier run apres 30s pour laisser le contexte Spring se stabiliser. */
|
||||||
|
private static final long INITIAL_DELAY_MS = 30_000L;
|
||||||
|
|
||||||
|
private final LicenseService licenseService;
|
||||||
|
private final DockerConfigWriter dockerConfigWriter;
|
||||||
|
|
||||||
|
public LicenseRefreshDaemon(LicenseService licenseService,
|
||||||
|
DockerConfigWriter dockerConfigWriter) {
|
||||||
|
this.licenseService = licenseService;
|
||||||
|
this.dockerConfigWriter = dockerConfigWriter;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Scheduled(initialDelay = INITIAL_DELAY_MS, fixedDelay = FIXED_DELAY_MS)
|
||||||
|
public void tick() {
|
||||||
|
if (!licenseService.isLicensingEnabled()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
licenseService.refreshIfNeeded();
|
||||||
|
syncDockerConfig();
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.error("LicenseRefreshDaemon tick failed: {}", e.getMessage(), e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Aligne le fichier docker config avec l'etat de la licence et le toggle :
|
||||||
|
* <ul>
|
||||||
|
* <li>VALID/GRACE + beta ON -> ecrit/refresh les creds</li>
|
||||||
|
* <li>tout autre cas -> efface le fichier</li>
|
||||||
|
* </ul>
|
||||||
|
*/
|
||||||
|
private void syncDockerConfig() {
|
||||||
|
LicenseSnapshot snap = licenseService.getCurrentSnapshot();
|
||||||
|
boolean shouldHaveCreds = snap.betaChannelEnabled()
|
||||||
|
&& (snap.status() == LicenseStatus.VALID || snap.status() == LicenseStatus.GRACE);
|
||||||
|
|
||||||
|
if (!shouldHaveCreds) {
|
||||||
|
try {
|
||||||
|
if (dockerConfigWriter.isPresent()) {
|
||||||
|
dockerConfigWriter.clear();
|
||||||
|
}
|
||||||
|
} catch (IOException e) {
|
||||||
|
log.warn("Cannot clear docker config: {}", e.getMessage());
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
Optional<RegistryCredentials> creds = licenseService.fetchRegistryCredentials();
|
||||||
|
if (creds.isEmpty()) {
|
||||||
|
log.warn("Beta enabled but cannot fetch registry credentials (relay down or rejected)");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
dockerConfigWriter.writeCredentials(creds.get());
|
||||||
|
} catch (IOException e) {
|
||||||
|
log.error("Cannot write docker config: {}", e.getMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,188 @@
|
|||||||
|
package com.loremind.infrastructure.licensing;
|
||||||
|
|
||||||
|
import com.loremind.domain.licensing.LicenseClaims;
|
||||||
|
import com.loremind.domain.licensing.ports.JwtVerifier;
|
||||||
|
import com.nimbusds.jose.JWSAlgorithm;
|
||||||
|
import com.nimbusds.jose.JWSVerifier;
|
||||||
|
import com.nimbusds.jose.crypto.Ed25519Verifier;
|
||||||
|
import com.nimbusds.jose.jwk.OctetKeyPair;
|
||||||
|
import com.nimbusds.jwt.JWTClaimsSet;
|
||||||
|
import com.nimbusds.jwt.SignedJWT;
|
||||||
|
import org.bouncycastle.asn1.ASN1Sequence;
|
||||||
|
import org.bouncycastle.asn1.x509.SubjectPublicKeyInfo;
|
||||||
|
import org.slf4j.Logger;
|
||||||
|
import org.slf4j.LoggerFactory;
|
||||||
|
import org.springframework.beans.factory.annotation.Value;
|
||||||
|
import org.springframework.stereotype.Component;
|
||||||
|
|
||||||
|
import org.springframework.core.io.ClassPathResource;
|
||||||
|
|
||||||
|
import java.io.IOException;
|
||||||
|
import java.io.InputStream;
|
||||||
|
import java.nio.charset.StandardCharsets;
|
||||||
|
import java.text.ParseException;
|
||||||
|
import java.util.Base64;
|
||||||
|
import java.util.Date;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Verifie les JWT EdDSA/Ed25519 emis par le relais Patreon.
|
||||||
|
* <p>
|
||||||
|
* La cle publique est fournie en PEM SPKI via la propriete
|
||||||
|
* {@code licensing.jwt.public-key} (env {@code LICENSING_JWT_PUBLIC_KEY}).
|
||||||
|
* Si la cle est absente ou invalide, {@link #isConfigured()} retourne false
|
||||||
|
* et {@link #verify} echoue systematiquement — la feature licensing est
|
||||||
|
* desactivee silencieusement.
|
||||||
|
*/
|
||||||
|
@Component
|
||||||
|
public class NimbusJwtVerifier implements JwtVerifier {
|
||||||
|
|
||||||
|
private static final Logger log = LoggerFactory.getLogger(NimbusJwtVerifier.class);
|
||||||
|
|
||||||
|
private final String expectedIssuer;
|
||||||
|
private final String expectedAudience;
|
||||||
|
private final OctetKeyPair publicKey;
|
||||||
|
|
||||||
|
public NimbusJwtVerifier(
|
||||||
|
@Value("${licensing.jwt.public-key:}") String publicKeyPemFromEnv,
|
||||||
|
@Value("${licensing.jwt.expected-issuer:loremind-auth}") String expectedIssuer,
|
||||||
|
@Value("${licensing.jwt.expected-audience:loremind-instance}") String expectedAudience) {
|
||||||
|
this.expectedIssuer = expectedIssuer;
|
||||||
|
this.expectedAudience = expectedAudience;
|
||||||
|
// Strategie : env var en priorite (rotation possible sans rebuild),
|
||||||
|
// sinon ressource classpath embarquee dans le binaire.
|
||||||
|
String pem = (publicKeyPemFromEnv != null && !publicKeyPemFromEnv.isBlank())
|
||||||
|
? publicKeyPemFromEnv
|
||||||
|
: loadEmbeddedKey();
|
||||||
|
this.publicKey = parsePemSpki(pem);
|
||||||
|
if (publicKey == null) {
|
||||||
|
log.info("Licensing JWT verifier disabled (no public key found)");
|
||||||
|
} else {
|
||||||
|
String source = (publicKeyPemFromEnv != null && !publicKeyPemFromEnv.isBlank()) ? "env" : "embedded";
|
||||||
|
log.info("Licensing JWT verifier enabled (issuer={}, audience={}, key source={})",
|
||||||
|
expectedIssuer, expectedAudience, source);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Charge la cle publique embarquee dans le binaire (resource classpath).
|
||||||
|
* Le fichier est un PEM SPKI standard, fourni a la build pour chaque
|
||||||
|
* release. Si absent, la feature licensing est desactivee.
|
||||||
|
*/
|
||||||
|
private static String loadEmbeddedKey() {
|
||||||
|
ClassPathResource resource = new ClassPathResource("licensing/jwt-public-key.pem");
|
||||||
|
if (!resource.exists()) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
try (InputStream in = resource.getInputStream()) {
|
||||||
|
return new String(in.readAllBytes(), StandardCharsets.UTF_8);
|
||||||
|
} catch (IOException e) {
|
||||||
|
log.warn("Cannot read embedded JWT public key: {}", e.getMessage());
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public boolean isConfigured() {
|
||||||
|
return publicKey != null;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public LicenseClaims verify(String rawJwt) throws JwtVerificationException {
|
||||||
|
if (publicKey == null) {
|
||||||
|
throw new JwtVerificationException("JWT verifier not configured");
|
||||||
|
}
|
||||||
|
if (rawJwt == null || rawJwt.isBlank()) {
|
||||||
|
throw new JwtVerificationException("JWT is empty");
|
||||||
|
}
|
||||||
|
|
||||||
|
SignedJWT signed;
|
||||||
|
try {
|
||||||
|
signed = SignedJWT.parse(rawJwt);
|
||||||
|
} catch (ParseException e) {
|
||||||
|
throw new JwtVerificationException("JWT parse error: " + e.getMessage(), e);
|
||||||
|
}
|
||||||
|
|
||||||
|
JWSAlgorithm alg = signed.getHeader().getAlgorithm();
|
||||||
|
if (!JWSAlgorithm.EdDSA.equals(alg)) {
|
||||||
|
throw new JwtVerificationException("Unexpected JWT algorithm: " + alg);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
JWSVerifier verifier = new Ed25519Verifier(publicKey);
|
||||||
|
if (!signed.verify(verifier)) {
|
||||||
|
throw new JwtVerificationException("JWT signature invalid");
|
||||||
|
}
|
||||||
|
} catch (Exception e) {
|
||||||
|
throw new JwtVerificationException("JWT signature verification failed: " + e.getMessage(), e);
|
||||||
|
}
|
||||||
|
|
||||||
|
JWTClaimsSet claims;
|
||||||
|
try {
|
||||||
|
claims = signed.getJWTClaimsSet();
|
||||||
|
} catch (ParseException e) {
|
||||||
|
throw new JwtVerificationException("JWT claims parse error", e);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!expectedIssuer.equals(claims.getIssuer())) {
|
||||||
|
throw new JwtVerificationException("JWT issuer mismatch: " + claims.getIssuer());
|
||||||
|
}
|
||||||
|
if (claims.getAudience() == null || !claims.getAudience().contains(expectedAudience)) {
|
||||||
|
throw new JwtVerificationException("JWT audience mismatch");
|
||||||
|
}
|
||||||
|
|
||||||
|
Date exp = claims.getExpirationTime();
|
||||||
|
Date iat = claims.getIssueTime();
|
||||||
|
String sub = claims.getSubject();
|
||||||
|
if (exp == null || iat == null || sub == null) {
|
||||||
|
throw new JwtVerificationException("JWT missing required claims");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Note : on ne refuse pas un JWT expire ici. C'est au LicenseService
|
||||||
|
// de decider ce qu'il fait d'un JWT expire (grace period, refresh, etc.).
|
||||||
|
// La verification de signature reste valide tant que la cle existe.
|
||||||
|
|
||||||
|
String tierId;
|
||||||
|
String instanceId;
|
||||||
|
try {
|
||||||
|
tierId = claims.getStringClaim("tier_id");
|
||||||
|
instanceId = claims.getStringClaim("instance_id");
|
||||||
|
} catch (ParseException e) {
|
||||||
|
throw new JwtVerificationException("JWT custom claim parse error", e);
|
||||||
|
}
|
||||||
|
if (tierId == null || tierId.isBlank() || instanceId == null || instanceId.isBlank()) {
|
||||||
|
throw new JwtVerificationException("JWT missing tier_id or instance_id");
|
||||||
|
}
|
||||||
|
|
||||||
|
return new LicenseClaims(
|
||||||
|
sub,
|
||||||
|
tierId,
|
||||||
|
instanceId,
|
||||||
|
iat.toInstant(),
|
||||||
|
exp.toInstant()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parse une cle publique Ed25519 au format PEM SPKI vers un Nimbus
|
||||||
|
* {@link OctetKeyPair} (forme JWK utilisee pour la verification).
|
||||||
|
*/
|
||||||
|
private static OctetKeyPair parsePemSpki(String pem) {
|
||||||
|
if (pem == null || pem.isBlank()) return null;
|
||||||
|
try {
|
||||||
|
String base64 = pem
|
||||||
|
.replace("-----BEGIN PUBLIC KEY-----", "")
|
||||||
|
.replace("-----END PUBLIC KEY-----", "")
|
||||||
|
.replaceAll("\\s+", "");
|
||||||
|
byte[] der = Base64.getDecoder().decode(base64);
|
||||||
|
SubjectPublicKeyInfo spki = SubjectPublicKeyInfo.getInstance(ASN1Sequence.fromByteArray(der));
|
||||||
|
byte[] keyBytes = spki.getPublicKeyData().getOctets();
|
||||||
|
String x = Base64.getUrlEncoder().withoutPadding().encodeToString(keyBytes);
|
||||||
|
return new OctetKeyPair.Builder(com.nimbusds.jose.jwk.Curve.Ed25519, com.nimbusds.jose.util.Base64URL.from(x))
|
||||||
|
.build();
|
||||||
|
} catch (IOException | IllegalArgumentException e) {
|
||||||
|
log.warn("Cannot parse licensing JWT public key: {}", e.getMessage());
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
@@ -0,0 +1,93 @@
|
|||||||
|
package com.loremind.infrastructure.persistence;
|
||||||
|
|
||||||
|
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||||
|
import org.slf4j.Logger;
|
||||||
|
import org.slf4j.LoggerFactory;
|
||||||
|
import org.springframework.boot.context.event.ApplicationReadyEvent;
|
||||||
|
import org.springframework.context.event.EventListener;
|
||||||
|
import org.springframework.jdbc.core.JdbcTemplate;
|
||||||
|
import org.springframework.stereotype.Component;
|
||||||
|
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Backfill one-shot des fiches Character / Npc post-refonte 2026-04-30.
|
||||||
|
* <p>
|
||||||
|
* Avant la refonte, les fiches stockaient leur contenu dans la colonne
|
||||||
|
* {@code markdown_content}. Apres la refonte, le contenu est dans
|
||||||
|
* {@code field_values} (JSON Map<String,String>). La colonne
|
||||||
|
* {@code markdown_content} subsiste car Hibernate ddl-auto=update ne drop pas.
|
||||||
|
* <p>
|
||||||
|
* Ce backfill copie {@code markdown_content} dans {@code field_values["Notes"]}
|
||||||
|
* pour toutes les fiches qui ont un markdown non vide ET un field_values vide.
|
||||||
|
* Idempotent : si field_values contient deja des donnees, on ne touche pas.
|
||||||
|
* <p>
|
||||||
|
* La colonne {@code markdown_content} n'est PAS supprimee apres backfill —
|
||||||
|
* permet un rollback applicatif au cas ou. Suppression definitive a faire dans
|
||||||
|
* une release ulterieure quand la confiance est etablie.
|
||||||
|
*/
|
||||||
|
@Component
|
||||||
|
public class CharacterNpcMarkdownBackfill {
|
||||||
|
|
||||||
|
private static final Logger log = LoggerFactory.getLogger(CharacterNpcMarkdownBackfill.class);
|
||||||
|
|
||||||
|
private final JdbcTemplate jdbc;
|
||||||
|
private final ObjectMapper mapper = new ObjectMapper();
|
||||||
|
|
||||||
|
public CharacterNpcMarkdownBackfill(JdbcTemplate jdbc) {
|
||||||
|
this.jdbc = jdbc;
|
||||||
|
}
|
||||||
|
|
||||||
|
@EventListener(ApplicationReadyEvent.class)
|
||||||
|
public void backfillIfNeeded() {
|
||||||
|
if (!hasMarkdownContentColumn("characters")) {
|
||||||
|
log.debug("Backfill skip : colonne markdown_content absente (deja migre ou install propre).");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
int chars = backfillTable("characters");
|
||||||
|
int npcs = backfillTable("npcs");
|
||||||
|
if (chars + npcs > 0) {
|
||||||
|
log.info("Backfill markdown -> field_values : {} character(s), {} npc(s) migre(s).", chars, npcs);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private boolean hasMarkdownContentColumn(String table) {
|
||||||
|
try {
|
||||||
|
Integer count = jdbc.queryForObject(
|
||||||
|
"SELECT COUNT(*) FROM information_schema.columns "
|
||||||
|
+ "WHERE table_name = ? AND column_name = 'markdown_content'",
|
||||||
|
Integer.class, table);
|
||||||
|
return count != null && count > 0;
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.warn("Backfill : impossible de verifier la colonne markdown_content sur {}: {}",
|
||||||
|
table, e.getMessage());
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private int backfillTable(String table) {
|
||||||
|
// Selection : fiches avec markdown non vide ET field_values vide ou absent.
|
||||||
|
// field_values peut etre NULL (legacy avant refonte) ou "{}" (refonte appliquee mais sans data).
|
||||||
|
String selectSql = "SELECT id, markdown_content FROM " + table
|
||||||
|
+ " WHERE markdown_content IS NOT NULL "
|
||||||
|
+ " AND markdown_content <> '' "
|
||||||
|
+ " AND (field_values IS NULL OR field_values = '' OR field_values = '{}')";
|
||||||
|
|
||||||
|
var rows = jdbc.queryForList(selectSql);
|
||||||
|
int migrated = 0;
|
||||||
|
for (var row : rows) {
|
||||||
|
Long id = ((Number) row.get("id")).longValue();
|
||||||
|
String markdown = (String) row.get("markdown_content");
|
||||||
|
String json;
|
||||||
|
try {
|
||||||
|
json = mapper.writeValueAsString(Map.of("Notes", markdown));
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.error("Backfill {} id={} : echec serialisation JSON, ignore. {}", table, id, e.getMessage());
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
jdbc.update("UPDATE " + table + " SET field_values = ? WHERE id = ?", json, id);
|
||||||
|
migrated++;
|
||||||
|
}
|
||||||
|
return migrated;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,6 +2,8 @@ package com.loremind.infrastructure.persistence;
|
|||||||
|
|
||||||
import com.loremind.domain.gamesystemcontext.GameSystem;
|
import com.loremind.domain.gamesystemcontext.GameSystem;
|
||||||
import com.loremind.domain.gamesystemcontext.ports.GameSystemRepository;
|
import com.loremind.domain.gamesystemcontext.ports.GameSystemRepository;
|
||||||
|
import com.loremind.domain.shared.template.ImageLayout;
|
||||||
|
import com.loremind.domain.shared.template.TemplateField;
|
||||||
import org.slf4j.Logger;
|
import org.slf4j.Logger;
|
||||||
import org.slf4j.LoggerFactory;
|
import org.slf4j.LoggerFactory;
|
||||||
import org.springframework.boot.context.event.ApplicationReadyEvent;
|
import org.springframework.boot.context.event.ApplicationReadyEvent;
|
||||||
@@ -23,6 +25,10 @@ import java.util.List;
|
|||||||
* <p>
|
* <p>
|
||||||
* Idempotence : ne seed qu'une fois. Si l'utilisateur supprime un ruleset seedé,
|
* Idempotence : ne seed qu'une fois. Si l'utilisateur supprime un ruleset seedé,
|
||||||
* il ne revient pas au redémarrage — c'est voulu (respect du choix utilisateur).
|
* il ne revient pas au redémarrage — c'est voulu (respect du choix utilisateur).
|
||||||
|
* <p>
|
||||||
|
* Backfill 2026-04-30 : pour les GameSystems existants (avant la refonte
|
||||||
|
* template-based), on remplit aussi les templates PJ/PNJ par defaut s'ils
|
||||||
|
* sont vides — sinon les fiches restent inutilisables.
|
||||||
*/
|
*/
|
||||||
@Component
|
@Component
|
||||||
public class GameSystemSeeder {
|
public class GameSystemSeeder {
|
||||||
@@ -37,15 +43,37 @@ public class GameSystemSeeder {
|
|||||||
|
|
||||||
@EventListener(ApplicationReadyEvent.class)
|
@EventListener(ApplicationReadyEvent.class)
|
||||||
public void seedIfEmpty() {
|
public void seedIfEmpty() {
|
||||||
if (!gameSystemRepository.findAll().isEmpty()) {
|
List<GameSystem> existing = gameSystemRepository.findAll();
|
||||||
log.debug("GameSystem seed skipped — table non vide.");
|
if (existing.isEmpty()) {
|
||||||
return;
|
|
||||||
}
|
|
||||||
log.info("Seed initial des GameSystems (table vide)...");
|
log.info("Seed initial des GameSystems (table vide)...");
|
||||||
for (GameSystem gs : defaultSystems()) {
|
for (GameSystem gs : defaultSystems()) {
|
||||||
gameSystemRepository.save(gs);
|
gameSystemRepository.save(gs);
|
||||||
}
|
}
|
||||||
log.info("GameSystems seedés : {}", defaultSystems().size());
|
log.info("GameSystems seedés : {}", defaultSystems().size());
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
log.debug("GameSystem seed skipped — table non vide. Backfill templates si necessaire...");
|
||||||
|
backfillEmptyTemplates(existing);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Backfill idempotent : pour chaque GameSystem existant ou les deux templates
|
||||||
|
* sont vides (PJ ET PNJ), injecte le template generique. Si l'utilisateur a
|
||||||
|
* deja personnalise au moins un des deux, on ne touche a rien.
|
||||||
|
*/
|
||||||
|
private void backfillEmptyTemplates(List<GameSystem> systems) {
|
||||||
|
int patched = 0;
|
||||||
|
for (GameSystem gs : systems) {
|
||||||
|
boolean charEmpty = gs.getCharacterTemplate() == null || gs.getCharacterTemplate().isEmpty();
|
||||||
|
boolean npcEmpty = gs.getNpcTemplate() == null || gs.getNpcTemplate().isEmpty();
|
||||||
|
if (charEmpty && npcEmpty) {
|
||||||
|
gs.replaceCharacterTemplate(genericCharacterTemplate());
|
||||||
|
gs.replaceNpcTemplate(genericNpcTemplate());
|
||||||
|
gameSystemRepository.save(gs);
|
||||||
|
patched++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (patched > 0) log.info("Backfill templates GameSystem : {} systeme(s) patche(s).", patched);
|
||||||
}
|
}
|
||||||
|
|
||||||
private List<GameSystem> defaultSystems() {
|
private List<GameSystem> defaultSystems() {
|
||||||
@@ -56,6 +84,8 @@ public class GameSystemSeeder {
|
|||||||
.author("LoreMind seed")
|
.author("LoreMind seed")
|
||||||
.isPublic(false)
|
.isPublic(false)
|
||||||
.rulesMarkdown(NIMBLE_RULES)
|
.rulesMarkdown(NIMBLE_RULES)
|
||||||
|
.characterTemplate(nimbleCharacterTemplate())
|
||||||
|
.npcTemplate(genericNpcTemplate())
|
||||||
.build(),
|
.build(),
|
||||||
GameSystem.builder()
|
GameSystem.builder()
|
||||||
.name("D&D 5e SRD (extrait)")
|
.name("D&D 5e SRD (extrait)")
|
||||||
@@ -63,6 +93,8 @@ public class GameSystemSeeder {
|
|||||||
.author("LoreMind seed")
|
.author("LoreMind seed")
|
||||||
.isPublic(false)
|
.isPublic(false)
|
||||||
.rulesMarkdown(DND_SRD_RULES)
|
.rulesMarkdown(DND_SRD_RULES)
|
||||||
|
.characterTemplate(dndCharacterTemplate())
|
||||||
|
.npcTemplate(genericNpcTemplate())
|
||||||
.build(),
|
.build(),
|
||||||
GameSystem.builder()
|
GameSystem.builder()
|
||||||
.name("Homebrew Exemple")
|
.name("Homebrew Exemple")
|
||||||
@@ -70,10 +102,70 @@ public class GameSystemSeeder {
|
|||||||
.author("LoreMind seed")
|
.author("LoreMind seed")
|
||||||
.isPublic(false)
|
.isPublic(false)
|
||||||
.rulesMarkdown(HOMEBREW_EXAMPLE)
|
.rulesMarkdown(HOMEBREW_EXAMPLE)
|
||||||
|
.characterTemplate(genericCharacterTemplate())
|
||||||
|
.npcTemplate(genericNpcTemplate())
|
||||||
.build()
|
.build()
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- Templates par defaut ---------------------------------------------
|
||||||
|
|
||||||
|
/** Template generique PJ — utilise pour Homebrew, backfill, et fallback. */
|
||||||
|
private static List<TemplateField> genericCharacterTemplate() {
|
||||||
|
return List.of(
|
||||||
|
TemplateField.text("Histoire"),
|
||||||
|
TemplateField.text("Personnalite"),
|
||||||
|
TemplateField.text("Apparence"),
|
||||||
|
TemplateField.image("Galerie", ImageLayout.GALLERY),
|
||||||
|
TemplateField.text("Notes")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Template generique PNJ — focus besoins MJ. */
|
||||||
|
private static List<TemplateField> genericNpcTemplate() {
|
||||||
|
return List.of(
|
||||||
|
TemplateField.text("Apparence"),
|
||||||
|
TemplateField.text("Motivation"),
|
||||||
|
TemplateField.text("Faction"),
|
||||||
|
TemplateField.text("Notes MJ")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static List<TemplateField> nimbleCharacterTemplate() {
|
||||||
|
return List.of(
|
||||||
|
TemplateField.text("Classe"),
|
||||||
|
TemplateField.number("Blessures graves max"),
|
||||||
|
TemplateField.text("Capacites de classe"),
|
||||||
|
TemplateField.text("Equipement"),
|
||||||
|
TemplateField.text("Histoire"),
|
||||||
|
TemplateField.text("Objectifs personnels"),
|
||||||
|
TemplateField.image("Galerie", ImageLayout.GALLERY)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static List<TemplateField> dndCharacterTemplate() {
|
||||||
|
return List.of(
|
||||||
|
TemplateField.text("Classe"),
|
||||||
|
TemplateField.text("Race"),
|
||||||
|
TemplateField.text("Historique"),
|
||||||
|
TemplateField.text("Alignement"),
|
||||||
|
TemplateField.number("Niveau"),
|
||||||
|
TemplateField.number("PV max"),
|
||||||
|
TemplateField.number("CA"),
|
||||||
|
TemplateField.number("FOR"),
|
||||||
|
TemplateField.number("DEX"),
|
||||||
|
TemplateField.number("CON"),
|
||||||
|
TemplateField.number("INT"),
|
||||||
|
TemplateField.number("SAG"),
|
||||||
|
TemplateField.number("CHA"),
|
||||||
|
TemplateField.text("Competences"),
|
||||||
|
TemplateField.text("Equipement"),
|
||||||
|
TemplateField.text("Sorts"),
|
||||||
|
TemplateField.text("Histoire"),
|
||||||
|
TemplateField.image("Galerie", ImageLayout.GALLERY)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
private static final String NIMBLE_RULES = """
|
private static final String NIMBLE_RULES = """
|
||||||
Système Nimble — résolution rapide, narration fluide, peu de tableaux. Agnostique (aucun univers imposé).
|
Système Nimble — résolution rapide, narration fluide, peu de tableaux. Agnostique (aucun univers imposé).
|
||||||
|
|
||||||
|
|||||||
@@ -3,9 +3,9 @@ package com.loremind.infrastructure.persistence.converter;
|
|||||||
import com.fasterxml.jackson.core.type.TypeReference;
|
import com.fasterxml.jackson.core.type.TypeReference;
|
||||||
import com.fasterxml.jackson.databind.JsonNode;
|
import com.fasterxml.jackson.databind.JsonNode;
|
||||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||||
import com.loremind.domain.lorecontext.FieldType;
|
import com.loremind.domain.shared.template.FieldType;
|
||||||
import com.loremind.domain.lorecontext.ImageLayout;
|
import com.loremind.domain.shared.template.ImageLayout;
|
||||||
import com.loremind.domain.lorecontext.TemplateField;
|
import com.loremind.domain.shared.template.TemplateField;
|
||||||
import jakarta.persistence.AttributeConverter;
|
import jakarta.persistence.AttributeConverter;
|
||||||
import jakarta.persistence.Converter;
|
import jakarta.persistence.Converter;
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
package com.loremind.infrastructure.persistence.entity;
|
package com.loremind.infrastructure.persistence.entity;
|
||||||
|
|
||||||
|
import com.loremind.infrastructure.persistence.converter.StringListMapJsonConverter;
|
||||||
|
import com.loremind.infrastructure.persistence.converter.StringMapJsonConverter;
|
||||||
import jakarta.persistence.*;
|
import jakarta.persistence.*;
|
||||||
import lombok.AllArgsConstructor;
|
import lombok.AllArgsConstructor;
|
||||||
import lombok.Builder;
|
import lombok.Builder;
|
||||||
@@ -7,11 +9,18 @@ import lombok.Data;
|
|||||||
import lombok.NoArgsConstructor;
|
import lombok.NoArgsConstructor;
|
||||||
|
|
||||||
import java.time.LocalDateTime;
|
import java.time.LocalDateTime;
|
||||||
|
import java.util.HashMap;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Entité JPA pour les fiches de personnages (PJ) d'une campagne.
|
* Entité JPA pour les fiches de personnages (PJ).
|
||||||
* Pas de FK physique vers campaigns (weak reference cross-agrégat intra-contexte :
|
* <p>
|
||||||
* on reste dans le Campaign Context, mais l'agrégat Character est autonome).
|
* Refonte 2026-04-30 : ancien champ markdownContent migré vers {@code values["Notes"]}
|
||||||
|
* via Hibernate au demarrage. Hibernate ddl-auto=update ajoute les nouvelles colonnes
|
||||||
|
* sans dropper {@code markdown_content} — les donnees existantes sont conservees mais
|
||||||
|
* plus mappees au domaine. Migration manuelle prevue (script SQL one-shot) si le
|
||||||
|
* deploiement passe en bluegreen.
|
||||||
*/
|
*/
|
||||||
@Entity
|
@Entity
|
||||||
@Table(name = "characters")
|
@Table(name = "characters")
|
||||||
@@ -28,8 +37,21 @@ public class CharacterJpaEntity {
|
|||||||
@Column(nullable = false)
|
@Column(nullable = false)
|
||||||
private String name;
|
private String name;
|
||||||
|
|
||||||
@Column(name = "markdown_content", columnDefinition = "TEXT")
|
@Column(name = "portrait_image_id")
|
||||||
private String markdownContent;
|
private String portraitImageId;
|
||||||
|
|
||||||
|
@Column(name = "header_image_id")
|
||||||
|
private String headerImageId;
|
||||||
|
|
||||||
|
/** Valeurs TEXT/NUMBER serialisees JSON. */
|
||||||
|
@Convert(converter = StringMapJsonConverter.class)
|
||||||
|
@Column(name = "field_values", columnDefinition = "TEXT")
|
||||||
|
private Map<String, String> values;
|
||||||
|
|
||||||
|
/** Valeurs IMAGE serialisees JSON. */
|
||||||
|
@Convert(converter = StringListMapJsonConverter.class)
|
||||||
|
@Column(name = "image_values", columnDefinition = "TEXT")
|
||||||
|
private Map<String, List<String>> imageValues;
|
||||||
|
|
||||||
@Column(name = "campaign_id", nullable = false)
|
@Column(name = "campaign_id", nullable = false)
|
||||||
private Long campaignId;
|
private Long campaignId;
|
||||||
@@ -47,6 +69,8 @@ public class CharacterJpaEntity {
|
|||||||
protected void onCreate() {
|
protected void onCreate() {
|
||||||
createdAt = LocalDateTime.now();
|
createdAt = LocalDateTime.now();
|
||||||
updatedAt = LocalDateTime.now();
|
updatedAt = LocalDateTime.now();
|
||||||
|
if (values == null) values = new HashMap<>();
|
||||||
|
if (imageValues == null) imageValues = new HashMap<>();
|
||||||
}
|
}
|
||||||
|
|
||||||
@PreUpdate
|
@PreUpdate
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
package com.loremind.infrastructure.persistence.entity;
|
package com.loremind.infrastructure.persistence.entity;
|
||||||
|
|
||||||
|
import com.loremind.domain.shared.template.TemplateField;
|
||||||
|
import com.loremind.infrastructure.persistence.converter.TemplateFieldListJsonConverter;
|
||||||
import jakarta.persistence.*;
|
import jakarta.persistence.*;
|
||||||
import lombok.AllArgsConstructor;
|
import lombok.AllArgsConstructor;
|
||||||
import lombok.Builder;
|
import lombok.Builder;
|
||||||
@@ -7,6 +9,8 @@ import lombok.Data;
|
|||||||
import lombok.NoArgsConstructor;
|
import lombok.NoArgsConstructor;
|
||||||
|
|
||||||
import java.time.LocalDateTime;
|
import java.time.LocalDateTime;
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Entité JPA pour la persistance des GameSystems (systèmes de JDR).
|
* Entité JPA pour la persistance des GameSystems (systèmes de JDR).
|
||||||
@@ -32,6 +36,16 @@ public class GameSystemJpaEntity {
|
|||||||
@Column(name = "rules_markdown", columnDefinition = "TEXT")
|
@Column(name = "rules_markdown", columnDefinition = "TEXT")
|
||||||
private String rulesMarkdown;
|
private String rulesMarkdown;
|
||||||
|
|
||||||
|
/** Template PJ serialise en JSON via {@link TemplateFieldListJsonConverter}. */
|
||||||
|
@Convert(converter = TemplateFieldListJsonConverter.class)
|
||||||
|
@Column(name = "character_template", columnDefinition = "TEXT")
|
||||||
|
private List<TemplateField> characterTemplate;
|
||||||
|
|
||||||
|
/** Template PNJ serialise en JSON. */
|
||||||
|
@Convert(converter = TemplateFieldListJsonConverter.class)
|
||||||
|
@Column(name = "npc_template", columnDefinition = "TEXT")
|
||||||
|
private List<TemplateField> npcTemplate;
|
||||||
|
|
||||||
@Column
|
@Column
|
||||||
private String author;
|
private String author;
|
||||||
|
|
||||||
@@ -48,6 +62,8 @@ public class GameSystemJpaEntity {
|
|||||||
protected void onCreate() {
|
protected void onCreate() {
|
||||||
createdAt = LocalDateTime.now();
|
createdAt = LocalDateTime.now();
|
||||||
updatedAt = LocalDateTime.now();
|
updatedAt = LocalDateTime.now();
|
||||||
|
if (characterTemplate == null) characterTemplate = new ArrayList<>();
|
||||||
|
if (npcTemplate == null) npcTemplate = new ArrayList<>();
|
||||||
}
|
}
|
||||||
|
|
||||||
@PreUpdate
|
@PreUpdate
|
||||||
|
|||||||
@@ -0,0 +1,72 @@
|
|||||||
|
package com.loremind.infrastructure.persistence.entity;
|
||||||
|
|
||||||
|
import jakarta.persistence.*;
|
||||||
|
import lombok.AllArgsConstructor;
|
||||||
|
import lombok.Builder;
|
||||||
|
import lombok.Data;
|
||||||
|
import lombok.NoArgsConstructor;
|
||||||
|
|
||||||
|
import java.time.Instant;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Entite JPA pour la licence Patreon installee.
|
||||||
|
* <p>
|
||||||
|
* Singleton : une seule ligne par instance (id = "current"). Ce design permet
|
||||||
|
* de ne jamais avoir de licence "fantome" en base et de simplifier les queries.
|
||||||
|
*/
|
||||||
|
@Entity
|
||||||
|
@Table(name = "licenses")
|
||||||
|
@Data
|
||||||
|
@Builder
|
||||||
|
@NoArgsConstructor
|
||||||
|
@AllArgsConstructor
|
||||||
|
public class LicenseJpaEntity {
|
||||||
|
|
||||||
|
@Id
|
||||||
|
private String id;
|
||||||
|
|
||||||
|
@Column(name = "raw_jwt", columnDefinition = "TEXT", nullable = false)
|
||||||
|
private String rawJwt;
|
||||||
|
|
||||||
|
@Column(name = "patreon_user_id", nullable = false)
|
||||||
|
private String patreonUserId;
|
||||||
|
|
||||||
|
@Column(name = "tier_id", nullable = false)
|
||||||
|
private String tierId;
|
||||||
|
|
||||||
|
@Column(name = "instance_id", nullable = false)
|
||||||
|
private String instanceId;
|
||||||
|
|
||||||
|
@Column(name = "issued_at", nullable = false)
|
||||||
|
private Instant issuedAt;
|
||||||
|
|
||||||
|
@Column(name = "expires_at", nullable = false)
|
||||||
|
private Instant expiresAt;
|
||||||
|
|
||||||
|
@Column(name = "last_refresh_attempt_at")
|
||||||
|
private Instant lastRefreshAttemptAt;
|
||||||
|
|
||||||
|
@Column(name = "last_refresh_succeeded", nullable = false)
|
||||||
|
private boolean lastRefreshSucceeded;
|
||||||
|
|
||||||
|
@Column(name = "beta_channel_enabled", nullable = false)
|
||||||
|
private boolean betaChannelEnabled;
|
||||||
|
|
||||||
|
@Column(name = "created_at", nullable = false, updatable = false)
|
||||||
|
private Instant createdAt;
|
||||||
|
|
||||||
|
@Column(name = "updated_at", nullable = false)
|
||||||
|
private Instant updatedAt;
|
||||||
|
|
||||||
|
@PrePersist
|
||||||
|
protected void onCreate() {
|
||||||
|
Instant now = Instant.now();
|
||||||
|
if (createdAt == null) createdAt = now;
|
||||||
|
updatedAt = now;
|
||||||
|
}
|
||||||
|
|
||||||
|
@PreUpdate
|
||||||
|
protected void onUpdate() {
|
||||||
|
updatedAt = Instant.now();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,73 @@
|
|||||||
|
package com.loremind.infrastructure.persistence.entity;
|
||||||
|
|
||||||
|
import com.loremind.infrastructure.persistence.converter.StringListMapJsonConverter;
|
||||||
|
import com.loremind.infrastructure.persistence.converter.StringMapJsonConverter;
|
||||||
|
import jakarta.persistence.*;
|
||||||
|
import lombok.AllArgsConstructor;
|
||||||
|
import lombok.Builder;
|
||||||
|
import lombok.Data;
|
||||||
|
import lombok.NoArgsConstructor;
|
||||||
|
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
import java.util.HashMap;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Entité JPA pour les fiches de PNJ. Memes regles que CharacterJpaEntity
|
||||||
|
* (cf. note de refonte 2026-04-30 sur la migration markdownContent).
|
||||||
|
*/
|
||||||
|
@Entity
|
||||||
|
@Table(name = "npcs")
|
||||||
|
@Data
|
||||||
|
@Builder
|
||||||
|
@NoArgsConstructor
|
||||||
|
@AllArgsConstructor
|
||||||
|
public class NpcJpaEntity {
|
||||||
|
|
||||||
|
@Id
|
||||||
|
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
||||||
|
private Long id;
|
||||||
|
|
||||||
|
@Column(nullable = false)
|
||||||
|
private String name;
|
||||||
|
|
||||||
|
@Column(name = "portrait_image_id")
|
||||||
|
private String portraitImageId;
|
||||||
|
|
||||||
|
@Column(name = "header_image_id")
|
||||||
|
private String headerImageId;
|
||||||
|
|
||||||
|
@Convert(converter = StringMapJsonConverter.class)
|
||||||
|
@Column(name = "field_values", columnDefinition = "TEXT")
|
||||||
|
private Map<String, String> values;
|
||||||
|
|
||||||
|
@Convert(converter = StringListMapJsonConverter.class)
|
||||||
|
@Column(name = "image_values", columnDefinition = "TEXT")
|
||||||
|
private Map<String, List<String>> imageValues;
|
||||||
|
|
||||||
|
@Column(name = "campaign_id", nullable = false)
|
||||||
|
private Long campaignId;
|
||||||
|
|
||||||
|
@Column(name = "\"order\"", nullable = false)
|
||||||
|
private int order;
|
||||||
|
|
||||||
|
@Column(name = "created_at", nullable = false, updatable = false)
|
||||||
|
private LocalDateTime createdAt;
|
||||||
|
|
||||||
|
@Column(name = "updated_at", nullable = false)
|
||||||
|
private LocalDateTime updatedAt;
|
||||||
|
|
||||||
|
@PrePersist
|
||||||
|
protected void onCreate() {
|
||||||
|
createdAt = LocalDateTime.now();
|
||||||
|
updatedAt = LocalDateTime.now();
|
||||||
|
if (values == null) values = new HashMap<>();
|
||||||
|
if (imageValues == null) imageValues = new HashMap<>();
|
||||||
|
}
|
||||||
|
|
||||||
|
@PreUpdate
|
||||||
|
protected void onUpdate() {
|
||||||
|
updatedAt = LocalDateTime.now();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
package com.loremind.infrastructure.persistence.entity;
|
package com.loremind.infrastructure.persistence.entity;
|
||||||
|
|
||||||
import com.loremind.domain.lorecontext.TemplateField;
|
import com.loremind.domain.shared.template.TemplateField;
|
||||||
import com.loremind.infrastructure.persistence.converter.TemplateFieldListJsonConverter;
|
import com.loremind.infrastructure.persistence.converter.TemplateFieldListJsonConverter;
|
||||||
import jakarta.persistence.*;
|
import jakarta.persistence.*;
|
||||||
import lombok.AllArgsConstructor;
|
import lombok.AllArgsConstructor;
|
||||||
|
|||||||
@@ -0,0 +1,9 @@
|
|||||||
|
package com.loremind.infrastructure.persistence.jpa;
|
||||||
|
|
||||||
|
import com.loremind.infrastructure.persistence.entity.LicenseJpaEntity;
|
||||||
|
import org.springframework.data.jpa.repository.JpaRepository;
|
||||||
|
import org.springframework.stereotype.Repository;
|
||||||
|
|
||||||
|
@Repository
|
||||||
|
public interface LicenseJpaRepository extends JpaRepository<LicenseJpaEntity, String> {
|
||||||
|
}
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
package com.loremind.infrastructure.persistence.jpa;
|
||||||
|
|
||||||
|
import com.loremind.infrastructure.persistence.entity.NpcJpaEntity;
|
||||||
|
import org.springframework.data.jpa.repository.JpaRepository;
|
||||||
|
import org.springframework.stereotype.Repository;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
@Repository
|
||||||
|
public interface NpcJpaRepository extends JpaRepository<NpcJpaEntity, Long> {
|
||||||
|
|
||||||
|
List<NpcJpaEntity> findByCampaignIdOrderByOrderAsc(Long campaignId);
|
||||||
|
}
|
||||||
@@ -6,6 +6,7 @@ import com.loremind.infrastructure.persistence.entity.CharacterJpaEntity;
|
|||||||
import com.loremind.infrastructure.persistence.jpa.CharacterJpaRepository;
|
import com.loremind.infrastructure.persistence.jpa.CharacterJpaRepository;
|
||||||
import org.springframework.stereotype.Repository;
|
import org.springframework.stereotype.Repository;
|
||||||
|
|
||||||
|
import java.util.HashMap;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Optional;
|
import java.util.Optional;
|
||||||
import java.util.stream.Collectors;
|
import java.util.stream.Collectors;
|
||||||
@@ -52,7 +53,10 @@ public class PostgresCharacterRepository implements CharacterRepository {
|
|||||||
return Character.builder()
|
return Character.builder()
|
||||||
.id(e.getId().toString())
|
.id(e.getId().toString())
|
||||||
.name(e.getName())
|
.name(e.getName())
|
||||||
.markdownContent(e.getMarkdownContent())
|
.portraitImageId(e.getPortraitImageId())
|
||||||
|
.headerImageId(e.getHeaderImageId())
|
||||||
|
.values(e.getValues() != null ? new HashMap<>(e.getValues()) : new HashMap<>())
|
||||||
|
.imageValues(e.getImageValues() != null ? new HashMap<>(e.getImageValues()) : new HashMap<>())
|
||||||
.campaignId(e.getCampaignId().toString())
|
.campaignId(e.getCampaignId().toString())
|
||||||
.order(e.getOrder())
|
.order(e.getOrder())
|
||||||
.createdAt(e.getCreatedAt())
|
.createdAt(e.getCreatedAt())
|
||||||
@@ -65,7 +69,10 @@ public class PostgresCharacterRepository implements CharacterRepository {
|
|||||||
return CharacterJpaEntity.builder()
|
return CharacterJpaEntity.builder()
|
||||||
.id(id)
|
.id(id)
|
||||||
.name(c.getName())
|
.name(c.getName())
|
||||||
.markdownContent(c.getMarkdownContent())
|
.portraitImageId(c.getPortraitImageId())
|
||||||
|
.headerImageId(c.getHeaderImageId())
|
||||||
|
.values(c.getValues() != null ? new HashMap<>(c.getValues()) : new HashMap<>())
|
||||||
|
.imageValues(c.getImageValues() != null ? new HashMap<>(c.getImageValues()) : new HashMap<>())
|
||||||
.campaignId(Long.parseLong(c.getCampaignId()))
|
.campaignId(Long.parseLong(c.getCampaignId()))
|
||||||
.order(c.getOrder())
|
.order(c.getOrder())
|
||||||
.createdAt(c.getCreatedAt())
|
.createdAt(c.getCreatedAt())
|
||||||
|
|||||||
@@ -61,6 +61,12 @@ public class PostgresGameSystemRepository implements GameSystemRepository {
|
|||||||
.name(e.getName())
|
.name(e.getName())
|
||||||
.description(e.getDescription())
|
.description(e.getDescription())
|
||||||
.rulesMarkdown(e.getRulesMarkdown())
|
.rulesMarkdown(e.getRulesMarkdown())
|
||||||
|
.characterTemplate(e.getCharacterTemplate() != null
|
||||||
|
? new java.util.ArrayList<>(e.getCharacterTemplate())
|
||||||
|
: new java.util.ArrayList<>())
|
||||||
|
.npcTemplate(e.getNpcTemplate() != null
|
||||||
|
? new java.util.ArrayList<>(e.getNpcTemplate())
|
||||||
|
: new java.util.ArrayList<>())
|
||||||
.author(e.getAuthor())
|
.author(e.getAuthor())
|
||||||
.isPublic(e.isPublic())
|
.isPublic(e.isPublic())
|
||||||
.createdAt(e.getCreatedAt())
|
.createdAt(e.getCreatedAt())
|
||||||
@@ -75,6 +81,12 @@ public class PostgresGameSystemRepository implements GameSystemRepository {
|
|||||||
.name(g.getName())
|
.name(g.getName())
|
||||||
.description(g.getDescription())
|
.description(g.getDescription())
|
||||||
.rulesMarkdown(g.getRulesMarkdown())
|
.rulesMarkdown(g.getRulesMarkdown())
|
||||||
|
.characterTemplate(g.getCharacterTemplate() != null
|
||||||
|
? new java.util.ArrayList<>(g.getCharacterTemplate())
|
||||||
|
: new java.util.ArrayList<>())
|
||||||
|
.npcTemplate(g.getNpcTemplate() != null
|
||||||
|
? new java.util.ArrayList<>(g.getNpcTemplate())
|
||||||
|
: new java.util.ArrayList<>())
|
||||||
.author(g.getAuthor())
|
.author(g.getAuthor())
|
||||||
.isPublic(g.isPublic())
|
.isPublic(g.isPublic())
|
||||||
.createdAt(g.getCreatedAt())
|
.createdAt(g.getCreatedAt())
|
||||||
|
|||||||
@@ -0,0 +1,76 @@
|
|||||||
|
package com.loremind.infrastructure.persistence.postgres;
|
||||||
|
|
||||||
|
import com.loremind.domain.licensing.License;
|
||||||
|
import com.loremind.domain.licensing.ports.LicenseRepository;
|
||||||
|
import com.loremind.infrastructure.persistence.entity.LicenseJpaEntity;
|
||||||
|
import com.loremind.infrastructure.persistence.jpa.LicenseJpaRepository;
|
||||||
|
import org.springframework.stereotype.Repository;
|
||||||
|
|
||||||
|
import java.time.Instant;
|
||||||
|
import java.util.Optional;
|
||||||
|
|
||||||
|
@Repository
|
||||||
|
public class PostgresLicenseRepository implements LicenseRepository {
|
||||||
|
|
||||||
|
static final String CURRENT_ID = "current";
|
||||||
|
|
||||||
|
private final LicenseJpaRepository jpa;
|
||||||
|
|
||||||
|
public PostgresLicenseRepository(LicenseJpaRepository jpa) {
|
||||||
|
this.jpa = jpa;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public Optional<License> findCurrent() {
|
||||||
|
return jpa.findById(CURRENT_ID).map(this::toDomain);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public License save(License license) {
|
||||||
|
LicenseJpaEntity entity = toEntity(license);
|
||||||
|
if (entity.getCreatedAt() == null) {
|
||||||
|
entity.setCreatedAt(Instant.now());
|
||||||
|
}
|
||||||
|
LicenseJpaEntity saved = jpa.save(entity);
|
||||||
|
return toDomain(saved);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void deleteCurrent() {
|
||||||
|
jpa.deleteById(CURRENT_ID);
|
||||||
|
}
|
||||||
|
|
||||||
|
private License toDomain(LicenseJpaEntity e) {
|
||||||
|
return License.builder()
|
||||||
|
.id(e.getId())
|
||||||
|
.rawJwt(e.getRawJwt())
|
||||||
|
.patreonUserId(e.getPatreonUserId())
|
||||||
|
.tierId(e.getTierId())
|
||||||
|
.instanceId(e.getInstanceId())
|
||||||
|
.issuedAt(e.getIssuedAt())
|
||||||
|
.expiresAt(e.getExpiresAt())
|
||||||
|
.lastRefreshAttemptAt(e.getLastRefreshAttemptAt())
|
||||||
|
.lastRefreshSucceeded(e.isLastRefreshSucceeded())
|
||||||
|
.betaChannelEnabled(e.isBetaChannelEnabled())
|
||||||
|
.createdAt(e.getCreatedAt())
|
||||||
|
.updatedAt(e.getUpdatedAt())
|
||||||
|
.build();
|
||||||
|
}
|
||||||
|
|
||||||
|
private LicenseJpaEntity toEntity(License l) {
|
||||||
|
return LicenseJpaEntity.builder()
|
||||||
|
.id(CURRENT_ID)
|
||||||
|
.rawJwt(l.getRawJwt())
|
||||||
|
.patreonUserId(l.getPatreonUserId())
|
||||||
|
.tierId(l.getTierId())
|
||||||
|
.instanceId(l.getInstanceId())
|
||||||
|
.issuedAt(l.getIssuedAt())
|
||||||
|
.expiresAt(l.getExpiresAt())
|
||||||
|
.lastRefreshAttemptAt(l.getLastRefreshAttemptAt())
|
||||||
|
.lastRefreshSucceeded(l.isLastRefreshSucceeded())
|
||||||
|
.betaChannelEnabled(l.isBetaChannelEnabled())
|
||||||
|
.createdAt(l.getCreatedAt())
|
||||||
|
.updatedAt(l.getUpdatedAt())
|
||||||
|
.build();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,82 @@
|
|||||||
|
package com.loremind.infrastructure.persistence.postgres;
|
||||||
|
|
||||||
|
import com.loremind.domain.campaigncontext.Npc;
|
||||||
|
import com.loremind.domain.campaigncontext.ports.NpcRepository;
|
||||||
|
import com.loremind.infrastructure.persistence.entity.NpcJpaEntity;
|
||||||
|
import com.loremind.infrastructure.persistence.jpa.NpcJpaRepository;
|
||||||
|
import org.springframework.stereotype.Repository;
|
||||||
|
|
||||||
|
import java.util.HashMap;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Optional;
|
||||||
|
import java.util.stream.Collectors;
|
||||||
|
|
||||||
|
@Repository
|
||||||
|
public class PostgresNpcRepository implements NpcRepository {
|
||||||
|
|
||||||
|
private final NpcJpaRepository jpaRepository;
|
||||||
|
|
||||||
|
public PostgresNpcRepository(NpcJpaRepository jpaRepository) {
|
||||||
|
this.jpaRepository = jpaRepository;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public Npc save(Npc npc) {
|
||||||
|
NpcJpaEntity entity = toJpaEntity(npc);
|
||||||
|
NpcJpaEntity saved = jpaRepository.save(entity);
|
||||||
|
return toDomainEntity(saved);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public Optional<Npc> findById(String id) {
|
||||||
|
return jpaRepository.findById(Long.parseLong(id)).map(this::toDomainEntity);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public List<Npc> findByCampaignId(String campaignId) {
|
||||||
|
return jpaRepository.findByCampaignIdOrderByOrderAsc(Long.parseLong(campaignId)).stream()
|
||||||
|
.map(this::toDomainEntity)
|
||||||
|
.collect(Collectors.toList());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void deleteById(String id) {
|
||||||
|
jpaRepository.deleteById(Long.parseLong(id));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public boolean existsById(String id) {
|
||||||
|
return jpaRepository.existsById(Long.parseLong(id));
|
||||||
|
}
|
||||||
|
|
||||||
|
private Npc toDomainEntity(NpcJpaEntity e) {
|
||||||
|
return Npc.builder()
|
||||||
|
.id(e.getId().toString())
|
||||||
|
.name(e.getName())
|
||||||
|
.portraitImageId(e.getPortraitImageId())
|
||||||
|
.headerImageId(e.getHeaderImageId())
|
||||||
|
.values(e.getValues() != null ? new HashMap<>(e.getValues()) : new HashMap<>())
|
||||||
|
.imageValues(e.getImageValues() != null ? new HashMap<>(e.getImageValues()) : new HashMap<>())
|
||||||
|
.campaignId(e.getCampaignId().toString())
|
||||||
|
.order(e.getOrder())
|
||||||
|
.createdAt(e.getCreatedAt())
|
||||||
|
.updatedAt(e.getUpdatedAt())
|
||||||
|
.build();
|
||||||
|
}
|
||||||
|
|
||||||
|
private NpcJpaEntity toJpaEntity(Npc n) {
|
||||||
|
Long id = n.getId() != null ? Long.parseLong(n.getId()) : null;
|
||||||
|
return NpcJpaEntity.builder()
|
||||||
|
.id(id)
|
||||||
|
.name(n.getName())
|
||||||
|
.portraitImageId(n.getPortraitImageId())
|
||||||
|
.headerImageId(n.getHeaderImageId())
|
||||||
|
.values(n.getValues() != null ? new HashMap<>(n.getValues()) : new HashMap<>())
|
||||||
|
.imageValues(n.getImageValues() != null ? new HashMap<>(n.getImageValues()) : new HashMap<>())
|
||||||
|
.campaignId(Long.parseLong(n.getCampaignId()))
|
||||||
|
.order(n.getOrder())
|
||||||
|
.createdAt(n.getCreatedAt())
|
||||||
|
.updatedAt(n.getUpdatedAt())
|
||||||
|
.build();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,15 +1,20 @@
|
|||||||
package com.loremind.infrastructure.updates;
|
package com.loremind.infrastructure.updates;
|
||||||
|
|
||||||
import jakarta.annotation.PostConstruct;
|
import com.loremind.application.licensing.LicenseService;
|
||||||
|
import com.loremind.domain.licensing.LicenseSnapshot;
|
||||||
|
import com.loremind.domain.licensing.LicenseStatus;
|
||||||
|
import com.loremind.domain.licensing.RegistryCredentials;
|
||||||
import org.slf4j.Logger;
|
import org.slf4j.Logger;
|
||||||
import org.slf4j.LoggerFactory;
|
import org.slf4j.LoggerFactory;
|
||||||
import org.springframework.beans.factory.annotation.Value;
|
import org.springframework.beans.factory.annotation.Value;
|
||||||
|
import org.springframework.boot.info.BuildProperties;
|
||||||
import org.springframework.boot.web.client.RestTemplateBuilder;
|
import org.springframework.boot.web.client.RestTemplateBuilder;
|
||||||
import org.springframework.http.HttpEntity;
|
import org.springframework.http.HttpEntity;
|
||||||
import org.springframework.http.HttpHeaders;
|
import org.springframework.http.HttpHeaders;
|
||||||
import org.springframework.http.HttpMethod;
|
import org.springframework.http.HttpMethod;
|
||||||
import org.springframework.http.MediaType;
|
import org.springframework.http.MediaType;
|
||||||
import org.springframework.http.ResponseEntity;
|
import org.springframework.http.ResponseEntity;
|
||||||
|
import org.springframework.lang.Nullable;
|
||||||
import org.springframework.stereotype.Service;
|
import org.springframework.stereotype.Service;
|
||||||
import org.springframework.web.client.HttpClientErrorException;
|
import org.springframework.web.client.HttpClientErrorException;
|
||||||
import org.springframework.web.client.RestTemplate;
|
import org.springframework.web.client.RestTemplate;
|
||||||
@@ -19,120 +24,174 @@ import java.nio.charset.StandardCharsets;
|
|||||||
import java.time.Duration;
|
import java.time.Duration;
|
||||||
import java.time.Instant;
|
import java.time.Instant;
|
||||||
import java.util.ArrayList;
|
import java.util.ArrayList;
|
||||||
|
import java.util.Base64;
|
||||||
import java.util.HashMap;
|
import java.util.HashMap;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
import java.util.concurrent.ConcurrentHashMap;
|
import java.util.Optional;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Detection des mises a jour disponibles + declenchement via Watchtower.
|
* Detection des mises a jour disponibles + declenchement via Watchtower.
|
||||||
|
* <p>
|
||||||
|
* <b>Strategie</b> : comparaison de versions semver, pas de digests.
|
||||||
|
* <ul>
|
||||||
|
* <li>La version courante de l'app est lue depuis {@link BuildProperties}
|
||||||
|
* (genere par spring-boot-maven-plugin dans META-INF/build-info.properties).</li>
|
||||||
|
* <li>Pour chaque image suivie, on interroge le registry sur
|
||||||
|
* {@code /v2/<image>/tags/list}, on extrait les tags semver, on prend le max.</li>
|
||||||
|
* <li>Si max > version courante => UPDATE_AVAILABLE.</li>
|
||||||
|
* <li>Si max == version courante => UP_TO_DATE.</li>
|
||||||
|
* <li>Si registry injoignable ou aucun tag valide => UNKNOWN.</li>
|
||||||
|
* </ul>
|
||||||
*
|
*
|
||||||
* Strategie :
|
* <b>Pourquoi pas les digests ?</b> Le bug historique etait : le baseline-digest
|
||||||
* - Au demarrage, on interroge le registry pour le digest courant de chaque
|
* pose au @PostConstruct supposait que le pull venait d'avoir lieu (vrai apres
|
||||||
* image suivie ({@code update-check.images}). On stocke ces digests comme
|
* `docker compose pull && up -d`, faux apres un simple restart de daemon ou un
|
||||||
* "baseline" (= ce que le conteneur en cours d'execution est cense faire
|
* OOM). La version semver lue depuis le binaire est <b>fiable par construction</b> :
|
||||||
* tourner, puisque le `docker compose pull` precede toujours `up -d`).
|
* c'est ce que le code source declare faire tourner.
|
||||||
* - {@link #check()} re-interroge le registry et compare. Si un digest a
|
|
||||||
* change, une mise a jour est disponible.
|
|
||||||
* - {@link #apply()} POST sur /v1/update de Watchtower (qui doit etre lance
|
|
||||||
* avec WATCHTOWER_HTTP_API_UPDATE=true et le meme token).
|
|
||||||
*
|
|
||||||
* Apres un apply reussi, Watchtower redemarre core => ce service est
|
|
||||||
* re-instancie => baseline re-aligne sur le registry => check renvoie
|
|
||||||
* "pas de MAJ" (etat coherent).
|
|
||||||
*
|
|
||||||
* La feature est <b>desactivee silencieusement</b> si {@code WATCHTOWER_TOKEN}
|
|
||||||
* n'est pas defini : check/apply renvoient des reponses neutres et l'UI
|
|
||||||
* masque le badge / bouton.
|
|
||||||
*/
|
*/
|
||||||
@Service
|
@Service
|
||||||
public class UpdateCheckService {
|
public class UpdateCheckService {
|
||||||
|
|
||||||
private static final Logger log = LoggerFactory.getLogger(UpdateCheckService.class);
|
private static final Logger log = LoggerFactory.getLogger(UpdateCheckService.class);
|
||||||
|
|
||||||
private static final List<MediaType> MANIFEST_ACCEPT = List.of(
|
|
||||||
MediaType.parseMediaType("application/vnd.docker.distribution.manifest.v2+json"),
|
|
||||||
MediaType.parseMediaType("application/vnd.docker.distribution.manifest.list.v2+json"),
|
|
||||||
MediaType.parseMediaType("application/vnd.oci.image.manifest.v1+json"),
|
|
||||||
MediaType.parseMediaType("application/vnd.oci.image.index.v1+json")
|
|
||||||
);
|
|
||||||
|
|
||||||
private final RestTemplate http;
|
private final RestTemplate http;
|
||||||
private final String registry;
|
private final String registry;
|
||||||
private final List<String> images;
|
private final List<String> images;
|
||||||
private final String tag;
|
|
||||||
private final String watchtowerUrl;
|
private final String watchtowerUrl;
|
||||||
private final String watchtowerToken;
|
private final String watchtowerToken;
|
||||||
|
private final List<String> betaImages;
|
||||||
private final Map<String, String> baselineDigests = new ConcurrentHashMap<>();
|
private final LicenseService licenseService;
|
||||||
|
/** Version semver courante du binaire (ex: "0.8.0"). Source de verite. */
|
||||||
|
private final String currentVersion;
|
||||||
|
|
||||||
public UpdateCheckService(
|
public UpdateCheckService(
|
||||||
RestTemplateBuilder builder,
|
RestTemplateBuilder builder,
|
||||||
@Value("${update-check.registry:}") String registry,
|
@Value("${update-check.registry:}") String registry,
|
||||||
@Value("${update-check.images:}") String imagesCsv,
|
@Value("${update-check.images:}") String imagesCsv,
|
||||||
@Value("${update-check.tag:latest}") String tag,
|
|
||||||
@Value("${update-check.watchtower-url:http://watchtower:8080}") String watchtowerUrl,
|
@Value("${update-check.watchtower-url:http://watchtower:8080}") String watchtowerUrl,
|
||||||
@Value("${update-check.watchtower-token:}") String watchtowerToken) {
|
@Value("${update-check.watchtower-token:}") String watchtowerToken,
|
||||||
|
@Value("${licensing.beta.images:}") String betaImagesCsv,
|
||||||
|
LicenseService licenseService,
|
||||||
|
@Nullable BuildProperties buildProperties) {
|
||||||
this.http = builder
|
this.http = builder
|
||||||
.setConnectTimeout(Duration.ofSeconds(5))
|
.setConnectTimeout(Duration.ofSeconds(5))
|
||||||
.setReadTimeout(Duration.ofSeconds(15))
|
.setReadTimeout(Duration.ofSeconds(15))
|
||||||
.build();
|
.build();
|
||||||
this.registry = normalizeRegistry(registry);
|
this.registry = normalizeRegistry(registry);
|
||||||
this.images = parseImages(imagesCsv);
|
this.images = parseImages(imagesCsv);
|
||||||
this.tag = tag;
|
|
||||||
this.watchtowerUrl = watchtowerUrl;
|
this.watchtowerUrl = watchtowerUrl;
|
||||||
this.watchtowerToken = watchtowerToken;
|
this.watchtowerToken = watchtowerToken;
|
||||||
}
|
this.betaImages = parseImages(betaImagesCsv);
|
||||||
|
this.licenseService = licenseService;
|
||||||
@PostConstruct
|
this.currentVersion = buildProperties != null ? buildProperties.getVersion() : null;
|
||||||
void initBaseline() {
|
log.info("Update check init - registry={} images={} currentVersion={}",
|
||||||
if (!isEnabled()) {
|
this.registry, this.images, this.currentVersion);
|
||||||
log.info("Update check disabled (WATCHTOWER_TOKEN not set)");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
log.info("Update check enabled - registry={} images={} tag={}", registry, images, tag);
|
|
||||||
for (String image : images) {
|
|
||||||
try {
|
|
||||||
String digest = fetchRemoteDigest(image);
|
|
||||||
if (digest != null) {
|
|
||||||
baselineDigests.put(image, digest);
|
|
||||||
log.debug("Baseline digest for {} = {}", image, digest);
|
|
||||||
}
|
|
||||||
} catch (Exception e) {
|
|
||||||
log.warn("Cannot baseline digest for {}: {}", image, e.getMessage());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public boolean isEnabled() {
|
public boolean isEnabled() {
|
||||||
return watchtowerToken != null && !watchtowerToken.isBlank() && !images.isEmpty();
|
return watchtowerToken != null && !watchtowerToken.isBlank() && !images.isEmpty();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return version courante exposee aux endpoints (ex: pour affichage UI).
|
||||||
|
* {@code null} si build-info.properties absent (dev en IDE sans build Maven).
|
||||||
|
*/
|
||||||
|
public String getCurrentVersion() {
|
||||||
|
return currentVersion;
|
||||||
|
}
|
||||||
|
|
||||||
public UpdateStatus check() {
|
public UpdateStatus check() {
|
||||||
if (!isEnabled()) {
|
if (!isEnabled()) {
|
||||||
return new UpdateStatus(false, false, List.of(), Instant.now());
|
return new UpdateStatus(false, false, false, null, List.of(), Instant.now());
|
||||||
}
|
}
|
||||||
|
if (currentVersion == null) {
|
||||||
|
log.warn("Update check : currentVersion absente (build-info manquant). Tous UNKNOWN.");
|
||||||
|
List<ImageStatus> statuses = new ArrayList<>();
|
||||||
|
for (String image : images) {
|
||||||
|
statuses.add(new ImageStatus(image, null, null, ImageStatusKind.UNKNOWN));
|
||||||
|
}
|
||||||
|
return new UpdateStatus(true, false, true, null, statuses, Instant.now());
|
||||||
|
}
|
||||||
|
|
||||||
List<ImageStatus> statuses = new ArrayList<>();
|
List<ImageStatus> statuses = new ArrayList<>();
|
||||||
boolean anyUpdate = false;
|
boolean anyUpdate = false;
|
||||||
|
boolean anyUnknown = false;
|
||||||
for (String image : images) {
|
for (String image : images) {
|
||||||
String baseline = baselineDigests.get(image);
|
String latest = null;
|
||||||
String remote = null;
|
|
||||||
try {
|
try {
|
||||||
remote = fetchRemoteDigest(image);
|
latest = fetchLatestSemverTag(registry, image, null);
|
||||||
} catch (Exception e) {
|
} catch (Exception e) {
|
||||||
log.warn("Check failed for {}: {}", image, e.getMessage());
|
log.warn("Tags fetch failed for {}: {}", image, e.getMessage());
|
||||||
}
|
}
|
||||||
// Si on n'a pas de baseline (echec au boot), on l'aligne maintenant
|
ImageStatusKind kind;
|
||||||
// pour eviter un faux positif "MAJ dispo".
|
if (latest == null) {
|
||||||
if (baseline == null && remote != null) {
|
kind = ImageStatusKind.UNKNOWN;
|
||||||
baselineDigests.put(image, remote);
|
anyUnknown = true;
|
||||||
baseline = remote;
|
} else {
|
||||||
|
int cmp = compareSemver(currentVersion, latest);
|
||||||
|
if (cmp >= 0) {
|
||||||
|
kind = ImageStatusKind.UP_TO_DATE;
|
||||||
|
} else {
|
||||||
|
kind = ImageStatusKind.UPDATE_AVAILABLE;
|
||||||
|
anyUpdate = true;
|
||||||
}
|
}
|
||||||
boolean updateAvailable = baseline != null && remote != null && !baseline.equals(remote);
|
|
||||||
if (updateAvailable) anyUpdate = true;
|
|
||||||
statuses.add(new ImageStatus(image, baseline, remote, updateAvailable));
|
|
||||||
}
|
}
|
||||||
return new UpdateStatus(true, anyUpdate, statuses, Instant.now());
|
statuses.add(new ImageStatus(image, currentVersion, latest, kind));
|
||||||
|
}
|
||||||
|
return new UpdateStatus(true, anyUpdate, anyUnknown, currentVersion, statuses, Instant.now());
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Verifie l'etat du canal beta (images privees GHCR) avec auth basique.
|
||||||
|
*/
|
||||||
|
public BetaStatus checkBeta() {
|
||||||
|
if (!licenseService.isLicensingEnabled()) {
|
||||||
|
return BetaStatus.disabled("licensing-not-configured");
|
||||||
|
}
|
||||||
|
LicenseSnapshot snap = licenseService.getCurrentSnapshot();
|
||||||
|
if (snap.status() != LicenseStatus.VALID && snap.status() != LicenseStatus.GRACE) {
|
||||||
|
return BetaStatus.disabled("license-" + snap.status().name().toLowerCase());
|
||||||
|
}
|
||||||
|
if (!snap.betaChannelEnabled()) {
|
||||||
|
return BetaStatus.disabled("beta-toggle-off");
|
||||||
|
}
|
||||||
|
if (betaImages.isEmpty()) {
|
||||||
|
return BetaStatus.disabled("no-beta-images-configured");
|
||||||
|
}
|
||||||
|
|
||||||
|
Optional<RegistryCredentials> creds = licenseService.fetchRegistryCredentials();
|
||||||
|
if (creds.isEmpty()) {
|
||||||
|
return new BetaStatus(true, false, true, List.of(), Instant.now(), "relay-unavailable");
|
||||||
|
}
|
||||||
|
|
||||||
|
String basicAuth = "Basic " + Base64.getEncoder().encodeToString(
|
||||||
|
(creds.get().username() + ":" + creds.get().password()).getBytes(StandardCharsets.UTF_8));
|
||||||
|
String betaRegistry = normalizeRegistry(creds.get().registry());
|
||||||
|
|
||||||
|
List<ImageStatus> statuses = new ArrayList<>();
|
||||||
|
boolean anyUpdate = false;
|
||||||
|
boolean anyUnknown = false;
|
||||||
|
for (String image : betaImages) {
|
||||||
|
String latest = null;
|
||||||
|
try {
|
||||||
|
latest = fetchLatestSemverTag(betaRegistry, image, basicAuth);
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.warn("Beta tags fetch failed for {}: {}", image, e.getMessage());
|
||||||
|
}
|
||||||
|
ImageStatusKind kind;
|
||||||
|
if (latest == null) {
|
||||||
|
kind = ImageStatusKind.UNKNOWN;
|
||||||
|
anyUnknown = true;
|
||||||
|
} else if (currentVersion != null && compareSemver(currentVersion, latest) >= 0) {
|
||||||
|
kind = ImageStatusKind.UP_TO_DATE;
|
||||||
|
} else {
|
||||||
|
kind = ImageStatusKind.UPDATE_AVAILABLE;
|
||||||
|
anyUpdate = true;
|
||||||
|
}
|
||||||
|
statuses.add(new ImageStatus(image, currentVersion, latest, kind));
|
||||||
|
}
|
||||||
|
return new BetaStatus(true, anyUpdate, anyUnknown, statuses, Instant.now(), null);
|
||||||
}
|
}
|
||||||
|
|
||||||
public void apply() {
|
public void apply() {
|
||||||
@@ -141,10 +200,6 @@ public class UpdateCheckService {
|
|||||||
}
|
}
|
||||||
HttpHeaders headers = new HttpHeaders();
|
HttpHeaders headers = new HttpHeaders();
|
||||||
headers.setBearerAuth(watchtowerToken);
|
headers.setBearerAuth(watchtowerToken);
|
||||||
// Watchtower /v1/update declenche un scan+update immediat de tous les
|
|
||||||
// conteneurs labellises. La reponse est synchrone et peut prendre
|
|
||||||
// plusieurs secondes; en cas de redemarrage de core, le client
|
|
||||||
// recevra une connexion coupee — c'est attendu, l'UI le gere.
|
|
||||||
http.exchange(
|
http.exchange(
|
||||||
watchtowerUrl + "/v1/update",
|
watchtowerUrl + "/v1/update",
|
||||||
HttpMethod.POST,
|
HttpMethod.POST,
|
||||||
@@ -153,40 +208,121 @@ public class UpdateCheckService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// -----------------------------------------------------------------------
|
// -----------------------------------------------------------------------
|
||||||
// Registry HTTP API v2
|
// Registry HTTP API v2 - tags listing + auth bearer
|
||||||
// -----------------------------------------------------------------------
|
// -----------------------------------------------------------------------
|
||||||
|
|
||||||
private String fetchRemoteDigest(String image) {
|
/**
|
||||||
String url = registry + "/v2/" + image + "/manifests/" + tag;
|
* Interroge le registry pour la liste des tags d'une image, parse les
|
||||||
|
* versions semver et retourne la plus elevee. {@code null} si echec
|
||||||
|
* ou aucun tag valide.
|
||||||
|
*
|
||||||
|
* @param registryUrl URL normalisee (ex: "https://ghcr.io")
|
||||||
|
* @param image nom de l'image (ex: "igmlcreation/loremind-core")
|
||||||
|
* @param authHeader optionnel - "Basic ..." pour les registries prives
|
||||||
|
*/
|
||||||
|
private String fetchLatestSemverTag(String registryUrl, String image, @Nullable String authHeader) {
|
||||||
|
String url = registryUrl + "/v2/" + image + "/tags/list";
|
||||||
HttpHeaders headers = new HttpHeaders();
|
HttpHeaders headers = new HttpHeaders();
|
||||||
headers.setAccept(MANIFEST_ACCEPT);
|
headers.setAccept(List.of(MediaType.APPLICATION_JSON));
|
||||||
|
if (authHeader != null) {
|
||||||
|
headers.set(HttpHeaders.AUTHORIZATION, authHeader);
|
||||||
|
}
|
||||||
|
TagsListResponse body;
|
||||||
try {
|
try {
|
||||||
return digestCall(url, headers);
|
body = tagsCall(url, headers);
|
||||||
} catch (HttpClientErrorException.Unauthorized e) {
|
} catch (HttpClientErrorException.Unauthorized e) {
|
||||||
String www = e.getResponseHeaders() == null ? null
|
String www = e.getResponseHeaders() == null ? null
|
||||||
: e.getResponseHeaders().getFirst(HttpHeaders.WWW_AUTHENTICATE);
|
: e.getResponseHeaders().getFirst(HttpHeaders.WWW_AUTHENTICATE);
|
||||||
String token = obtainBearerToken(www);
|
String token = obtainBearerToken(www, authHeader);
|
||||||
if (token == null) {
|
if (token == null) {
|
||||||
log.warn("Cannot obtain bearer token for {} (registry response: {})", image, www);
|
log.warn("Cannot obtain bearer token for {} (registry response: {})", image, www);
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
headers.setBearerAuth(token);
|
HttpHeaders bearerHeaders = new HttpHeaders();
|
||||||
return digestCall(url, headers);
|
bearerHeaders.setAccept(List.of(MediaType.APPLICATION_JSON));
|
||||||
|
bearerHeaders.setBearerAuth(token);
|
||||||
|
body = tagsCall(url, bearerHeaders);
|
||||||
}
|
}
|
||||||
|
if (body == null || body.tags == null || body.tags.isEmpty()) return null;
|
||||||
|
return findMaxSemver(body.tags);
|
||||||
}
|
}
|
||||||
|
|
||||||
private String digestCall(String url, HttpHeaders headers) {
|
private TagsListResponse tagsCall(String url, HttpHeaders headers) {
|
||||||
ResponseEntity<Void> resp = http.exchange(
|
ResponseEntity<TagsListResponse> resp = http.exchange(
|
||||||
url, HttpMethod.HEAD, new HttpEntity<>(headers), Void.class);
|
url, HttpMethod.GET, new HttpEntity<>(headers), TagsListResponse.class);
|
||||||
return resp.getHeaders().getFirst("Docker-Content-Digest");
|
return resp.getBody();
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Suit le challenge {@code WWW-Authenticate: Bearer realm="...",service="...",scope="..."}
|
* Parcourt la liste des tags, garde uniquement ceux qui parsent en semver
|
||||||
* pour obtenir un jeton (anonyme — suffisant pour les images publiques).
|
* (1 a 3 chiffres separes par des points, optionnel prefix "v"), retourne le max.
|
||||||
|
* Pre-release / build metadata sont strippes pour la comparaison.
|
||||||
|
*/
|
||||||
|
@Nullable
|
||||||
|
static String findMaxSemver(List<String> tags) {
|
||||||
|
String maxTag = null;
|
||||||
|
int[] maxParts = null;
|
||||||
|
for (String t : tags) {
|
||||||
|
if (t == null || t.isBlank()) continue;
|
||||||
|
int[] parts = parseSemver(t);
|
||||||
|
if (parts == null) continue;
|
||||||
|
if (maxParts == null || compareParts(parts, maxParts) > 0) {
|
||||||
|
maxParts = parts;
|
||||||
|
maxTag = t;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return maxTag;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @return [major, minor, patch] ou null si non parsable. */
|
||||||
|
@Nullable
|
||||||
|
static int[] parseSemver(String tag) {
|
||||||
|
if (tag == null) return null;
|
||||||
|
String s = tag.trim();
|
||||||
|
if (s.isEmpty()) return null;
|
||||||
|
if (s.startsWith("v") || s.startsWith("V")) s = s.substring(1);
|
||||||
|
int dashIdx = s.indexOf('-');
|
||||||
|
if (dashIdx > 0) s = s.substring(0, dashIdx);
|
||||||
|
int plusIdx = s.indexOf('+');
|
||||||
|
if (plusIdx > 0) s = s.substring(0, plusIdx);
|
||||||
|
String[] parts = s.split("\\.");
|
||||||
|
if (parts.length < 1 || parts.length > 3) return null;
|
||||||
|
int[] result = new int[]{0, 0, 0};
|
||||||
|
for (int i = 0; i < parts.length; i++) {
|
||||||
|
try {
|
||||||
|
int v = Integer.parseInt(parts[i]);
|
||||||
|
if (v < 0) return null;
|
||||||
|
result[i] = v;
|
||||||
|
} catch (NumberFormatException e) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Compare deux versions semver brutes (sans prefix). Negatif si a < b. */
|
||||||
|
static int compareSemver(String a, String b) {
|
||||||
|
int[] aParts = parseSemver(a);
|
||||||
|
int[] bParts = parseSemver(b);
|
||||||
|
if (aParts == null || bParts == null) return 0;
|
||||||
|
return compareParts(aParts, bParts);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static int compareParts(int[] a, int[] b) {
|
||||||
|
for (int i = 0; i < 3; i++) {
|
||||||
|
int diff = Integer.compare(a[i], b[i]);
|
||||||
|
if (diff != 0) return diff;
|
||||||
|
}
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Suit le challenge {@code WWW-Authenticate: Bearer realm="..."} pour obtenir
|
||||||
|
* un token. Si {@code basicAuth} est fourni, l'utilise pour l'echange (cas
|
||||||
|
* registry prive). Sinon anonyme (cas registry public).
|
||||||
*/
|
*/
|
||||||
@SuppressWarnings("rawtypes")
|
@SuppressWarnings("rawtypes")
|
||||||
private String obtainBearerToken(String wwwAuth) {
|
private String obtainBearerToken(@Nullable String wwwAuth, @Nullable String basicAuth) {
|
||||||
if (wwwAuth == null) return null;
|
if (wwwAuth == null) return null;
|
||||||
String prefix = "Bearer ";
|
String prefix = "Bearer ";
|
||||||
if (!wwwAuth.regionMatches(true, 0, prefix, 0, prefix.length())) return null;
|
if (!wwwAuth.regionMatches(true, 0, prefix, 0, prefix.length())) return null;
|
||||||
@@ -198,14 +334,20 @@ public class UpdateCheckService {
|
|||||||
for (String key : new String[]{"service", "scope"}) {
|
for (String key : new String[]{"service", "scope"}) {
|
||||||
String v = params.get(key);
|
String v = params.get(key);
|
||||||
if (v != null) {
|
if (v != null) {
|
||||||
url.append(hasQuery ? '&' : '?')
|
String encoded = URLEncoder.encode(v, StandardCharsets.UTF_8)
|
||||||
.append(key).append('=')
|
.replace("%3A", ":")
|
||||||
.append(URLEncoder.encode(v, StandardCharsets.UTF_8));
|
.replace("%2F", "/");
|
||||||
|
url.append(hasQuery ? '&' : '?').append(key).append('=').append(encoded);
|
||||||
hasQuery = true;
|
hasQuery = true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
ResponseEntity<Map> resp = http.getForEntity(url.toString(), Map.class);
|
HttpHeaders headers = new HttpHeaders();
|
||||||
|
if (basicAuth != null) {
|
||||||
|
headers.set(HttpHeaders.AUTHORIZATION, basicAuth);
|
||||||
|
}
|
||||||
|
ResponseEntity<Map> resp = http.exchange(url.toString(), HttpMethod.GET,
|
||||||
|
new HttpEntity<>(headers), Map.class);
|
||||||
Map<?, ?> body = resp.getBody();
|
Map<?, ?> body = resp.getBody();
|
||||||
if (body == null) return null;
|
if (body == null) return null;
|
||||||
Object t = body.get("token");
|
Object t = body.get("token");
|
||||||
@@ -266,18 +408,52 @@ public class UpdateCheckService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// -----------------------------------------------------------------------
|
// -----------------------------------------------------------------------
|
||||||
// Records de retour (sortis sous forme JSON par Jackson)
|
// Records / DTO
|
||||||
// -----------------------------------------------------------------------
|
// -----------------------------------------------------------------------
|
||||||
|
|
||||||
|
public enum ImageStatusKind { UP_TO_DATE, UPDATE_AVAILABLE, UNKNOWN }
|
||||||
|
|
||||||
public record UpdateStatus(
|
public record UpdateStatus(
|
||||||
boolean enabled,
|
boolean enabled,
|
||||||
boolean updateAvailable,
|
boolean updateAvailable,
|
||||||
|
boolean anyUnknown,
|
||||||
|
String currentVersion,
|
||||||
List<ImageStatus> images,
|
List<ImageStatus> images,
|
||||||
Instant checkedAt) {}
|
Instant checkedAt) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Statut par image. {@code localVersion} = version embarquee dans le binaire ;
|
||||||
|
* {@code remoteVersion} = plus haute version semver trouvee dans le registry.
|
||||||
|
* {@code updateAvailable} est derive de {@code status} (back-compat front).
|
||||||
|
*/
|
||||||
public record ImageStatus(
|
public record ImageStatus(
|
||||||
String image,
|
String image,
|
||||||
String localDigest,
|
String localVersion,
|
||||||
String remoteDigest,
|
String remoteVersion,
|
||||||
boolean updateAvailable) {}
|
ImageStatusKind status,
|
||||||
|
boolean updateAvailable) {
|
||||||
|
|
||||||
|
public ImageStatus(String image, String localVersion, String remoteVersion, ImageStatusKind status) {
|
||||||
|
this(image, localVersion, remoteVersion, status, status == ImageStatusKind.UPDATE_AVAILABLE);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public record BetaStatus(
|
||||||
|
boolean enabled,
|
||||||
|
boolean updateAvailable,
|
||||||
|
boolean anyUnknown,
|
||||||
|
List<ImageStatus> images,
|
||||||
|
Instant checkedAt,
|
||||||
|
String disabledReason) {
|
||||||
|
|
||||||
|
public static BetaStatus disabled(String reason) {
|
||||||
|
return new BetaStatus(false, false, false, List.of(), Instant.now(), reason);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** DTO pour deserialisation Jackson de /v2/.../tags/list. */
|
||||||
|
static class TagsListResponse {
|
||||||
|
public String name;
|
||||||
|
public List<String> tags;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -67,6 +67,7 @@ public class SecurityConfig {
|
|||||||
.requestMatchers(HttpMethod.OPTIONS, "/**").permitAll()
|
.requestMatchers(HttpMethod.OPTIONS, "/**").permitAll()
|
||||||
.requestMatchers("/api/settings/**").hasRole("ADMIN")
|
.requestMatchers("/api/settings/**").hasRole("ADMIN")
|
||||||
.requestMatchers("/api/admin/**").hasRole("ADMIN")
|
.requestMatchers("/api/admin/**").hasRole("ADMIN")
|
||||||
|
.requestMatchers("/api/license/**").hasRole("ADMIN")
|
||||||
.anyRequest().permitAll()
|
.anyRequest().permitAll()
|
||||||
)
|
)
|
||||||
.httpBasic(basic -> {});
|
.httpBasic(basic -> {});
|
||||||
|
|||||||
@@ -24,9 +24,7 @@ public class CharacterController {
|
|||||||
|
|
||||||
@PostMapping
|
@PostMapping
|
||||||
public ResponseEntity<CharacterDTO> createCharacter(@RequestBody CharacterDTO dto) {
|
public ResponseEntity<CharacterDTO> createCharacter(@RequestBody CharacterDTO dto) {
|
||||||
Character created = characterService.createCharacter(
|
Character created = characterService.createCharacter(toData(dto, null));
|
||||||
new CharacterService.CharacterData(dto.getName(), dto.getMarkdownContent(), dto.getCampaignId(), null)
|
|
||||||
);
|
|
||||||
return ResponseEntity.ok(characterMapper.toDTO(created));
|
return ResponseEntity.ok(characterMapper.toDTO(created));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -47,10 +45,7 @@ public class CharacterController {
|
|||||||
|
|
||||||
@PutMapping("/{id}")
|
@PutMapping("/{id}")
|
||||||
public ResponseEntity<CharacterDTO> updateCharacter(@PathVariable String id, @RequestBody CharacterDTO dto) {
|
public ResponseEntity<CharacterDTO> updateCharacter(@PathVariable String id, @RequestBody CharacterDTO dto) {
|
||||||
Character updated = characterService.updateCharacter(
|
Character updated = characterService.updateCharacter(id, toData(dto, dto.getOrder()));
|
||||||
id,
|
|
||||||
new CharacterService.CharacterData(dto.getName(), dto.getMarkdownContent(), dto.getCampaignId(), dto.getOrder())
|
|
||||||
);
|
|
||||||
return ResponseEntity.ok(characterMapper.toDTO(updated));
|
return ResponseEntity.ok(characterMapper.toDTO(updated));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -59,4 +54,16 @@ public class CharacterController {
|
|||||||
characterService.deleteCharacter(id);
|
characterService.deleteCharacter(id);
|
||||||
return ResponseEntity.noContent().build();
|
return ResponseEntity.noContent().build();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private CharacterService.CharacterData toData(CharacterDTO dto, Integer order) {
|
||||||
|
return new CharacterService.CharacterData(
|
||||||
|
dto.getName(),
|
||||||
|
dto.getPortraitImageId(),
|
||||||
|
dto.getHeaderImageId(),
|
||||||
|
dto.getValues(),
|
||||||
|
dto.getImageValues(),
|
||||||
|
dto.getCampaignId(),
|
||||||
|
order
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,11 +2,16 @@ package com.loremind.infrastructure.web.controller;
|
|||||||
|
|
||||||
import com.loremind.application.gamesystemcontext.GameSystemService;
|
import com.loremind.application.gamesystemcontext.GameSystemService;
|
||||||
import com.loremind.domain.gamesystemcontext.GameSystem;
|
import com.loremind.domain.gamesystemcontext.GameSystem;
|
||||||
|
import com.loremind.domain.shared.template.TemplateField;
|
||||||
import com.loremind.infrastructure.web.dto.gamesystemcontext.GameSystemDTO;
|
import com.loremind.infrastructure.web.dto.gamesystemcontext.GameSystemDTO;
|
||||||
|
import com.loremind.infrastructure.web.dto.shared.TemplateFieldDTO;
|
||||||
import com.loremind.infrastructure.web.mapper.GameSystemMapper;
|
import com.loremind.infrastructure.web.mapper.GameSystemMapper;
|
||||||
|
import com.loremind.infrastructure.web.mapper.TemplateFieldMapper;
|
||||||
|
import org.springframework.http.HttpStatus;
|
||||||
import org.springframework.http.ResponseEntity;
|
import org.springframework.http.ResponseEntity;
|
||||||
import org.springframework.web.bind.annotation.*;
|
import org.springframework.web.bind.annotation.*;
|
||||||
|
|
||||||
|
import java.util.ArrayList;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.stream.Collectors;
|
import java.util.stream.Collectors;
|
||||||
|
|
||||||
@@ -16,10 +21,14 @@ public class GameSystemController {
|
|||||||
|
|
||||||
private final GameSystemService gameSystemService;
|
private final GameSystemService gameSystemService;
|
||||||
private final GameSystemMapper gameSystemMapper;
|
private final GameSystemMapper gameSystemMapper;
|
||||||
|
private final TemplateFieldMapper templateFieldMapper;
|
||||||
|
|
||||||
public GameSystemController(GameSystemService gameSystemService, GameSystemMapper gameSystemMapper) {
|
public GameSystemController(GameSystemService gameSystemService,
|
||||||
|
GameSystemMapper gameSystemMapper,
|
||||||
|
TemplateFieldMapper templateFieldMapper) {
|
||||||
this.gameSystemService = gameSystemService;
|
this.gameSystemService = gameSystemService;
|
||||||
this.gameSystemMapper = gameSystemMapper;
|
this.gameSystemMapper = gameSystemMapper;
|
||||||
|
this.templateFieldMapper = templateFieldMapper;
|
||||||
}
|
}
|
||||||
|
|
||||||
@PostMapping
|
@PostMapping
|
||||||
@@ -63,13 +72,28 @@ public class GameSystemController {
|
|||||||
return ResponseEntity.noContent().build();
|
return ResponseEntity.noContent().build();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Mappe les violations d'invariants domaine (doublons de champs, etc.) en 400. */
|
||||||
|
@ExceptionHandler(IllegalArgumentException.class)
|
||||||
|
public ResponseEntity<String> onIllegalArgument(IllegalArgumentException ex) {
|
||||||
|
return ResponseEntity.status(HttpStatus.BAD_REQUEST).body(ex.getMessage());
|
||||||
|
}
|
||||||
|
|
||||||
private GameSystemService.GameSystemData toData(GameSystemDTO dto) {
|
private GameSystemService.GameSystemData toData(GameSystemDTO dto) {
|
||||||
return new GameSystemService.GameSystemData(
|
return new GameSystemService.GameSystemData(
|
||||||
dto.getName(),
|
dto.getName(),
|
||||||
dto.getDescription(),
|
dto.getDescription(),
|
||||||
dto.getRulesMarkdown(),
|
dto.getRulesMarkdown(),
|
||||||
|
toDomainFields(dto.getCharacterTemplate()),
|
||||||
|
toDomainFields(dto.getNpcTemplate()),
|
||||||
dto.getAuthor(),
|
dto.getAuthor(),
|
||||||
dto.isPublic()
|
dto.isPublic()
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private List<TemplateField> toDomainFields(List<TemplateFieldDTO> dtos) {
|
||||||
|
if (dtos == null) return new ArrayList<>();
|
||||||
|
List<TemplateField> out = new ArrayList<>(dtos.size());
|
||||||
|
for (TemplateFieldDTO d : dtos) out.add(templateFieldMapper.toDomain(d));
|
||||||
|
return out;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,87 @@
|
|||||||
|
package com.loremind.infrastructure.web.controller;
|
||||||
|
|
||||||
|
import com.loremind.application.licensing.LicenseService;
|
||||||
|
import com.loremind.application.licensing.LicenseService.InstallException;
|
||||||
|
import com.loremind.domain.licensing.LicenseSnapshot;
|
||||||
|
import com.loremind.infrastructure.web.dto.licensing.LicenseStatusDTO;
|
||||||
|
import org.springframework.http.ResponseEntity;
|
||||||
|
import org.springframework.web.bind.annotation.*;
|
||||||
|
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Endpoints de gestion de la licence Patreon.
|
||||||
|
*
|
||||||
|
* <ul>
|
||||||
|
* <li>{@code GET /api/license} : etat courant (status, tier, expiration...)</li>
|
||||||
|
* <li>{@code GET /api/license/connect-url} : URL OAuth a ouvrir dans le navigateur</li>
|
||||||
|
* <li>{@code POST /api/license/install} : colle un JWT recu du relais</li>
|
||||||
|
* <li>{@code DELETE /api/license} : deconnecte Patreon (efface la licence)</li>
|
||||||
|
* <li>{@code POST /api/license/refresh} : force un refresh manuel</li>
|
||||||
|
* <li>{@code PUT /api/license/beta-channel} : active/desactive le canal beta</li>
|
||||||
|
* </ul>
|
||||||
|
*/
|
||||||
|
@RestController
|
||||||
|
@RequestMapping("/api/license")
|
||||||
|
public class LicenseController {
|
||||||
|
|
||||||
|
private final LicenseService licenseService;
|
||||||
|
|
||||||
|
public LicenseController(LicenseService licenseService) {
|
||||||
|
this.licenseService = licenseService;
|
||||||
|
}
|
||||||
|
|
||||||
|
@GetMapping
|
||||||
|
public LicenseStatusDTO getStatus() {
|
||||||
|
boolean enabled = licenseService.isLicensingEnabled();
|
||||||
|
LicenseSnapshot snap = licenseService.getCurrentSnapshot();
|
||||||
|
return LicenseStatusDTO.from(enabled, snap);
|
||||||
|
}
|
||||||
|
|
||||||
|
@GetMapping("/connect-url")
|
||||||
|
public Map<String, String> getConnectUrl() {
|
||||||
|
return Map.of("url", licenseService.buildConnectUrl());
|
||||||
|
}
|
||||||
|
|
||||||
|
@PostMapping("/install")
|
||||||
|
public ResponseEntity<?> install(@RequestBody InstallRequest request) {
|
||||||
|
if (request == null || request.jwt() == null || request.jwt().isBlank()) {
|
||||||
|
return ResponseEntity.badRequest().body(Map.of("error", "missing jwt"));
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
LicenseSnapshot snap = licenseService.installToken(request.jwt());
|
||||||
|
return ResponseEntity.ok(LicenseStatusDTO.from(true, snap));
|
||||||
|
} catch (InstallException e) {
|
||||||
|
return ResponseEntity.badRequest().body(Map.of("error", e.getMessage()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@DeleteMapping
|
||||||
|
public ResponseEntity<Void> disconnect() {
|
||||||
|
licenseService.disconnect();
|
||||||
|
return ResponseEntity.noContent().build();
|
||||||
|
}
|
||||||
|
|
||||||
|
@PostMapping("/refresh")
|
||||||
|
public ResponseEntity<LicenseStatusDTO> refresh() {
|
||||||
|
licenseService.forceRefresh();
|
||||||
|
boolean enabled = licenseService.isLicensingEnabled();
|
||||||
|
return ResponseEntity.ok(LicenseStatusDTO.from(enabled, licenseService.getCurrentSnapshot()));
|
||||||
|
}
|
||||||
|
|
||||||
|
@PutMapping("/beta-channel")
|
||||||
|
public ResponseEntity<?> setBetaChannel(@RequestBody BetaChannelRequest request) {
|
||||||
|
if (request == null) {
|
||||||
|
return ResponseEntity.badRequest().body(Map.of("error", "missing body"));
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
LicenseSnapshot snap = licenseService.setBetaChannelEnabled(request.enabled());
|
||||||
|
return ResponseEntity.ok(LicenseStatusDTO.from(true, snap));
|
||||||
|
} catch (IllegalStateException e) {
|
||||||
|
return ResponseEntity.status(409).body(Map.of("error", e.getMessage()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public record InstallRequest(String jwt) {}
|
||||||
|
public record BetaChannelRequest(boolean enabled) {}
|
||||||
|
}
|
||||||
@@ -0,0 +1,69 @@
|
|||||||
|
package com.loremind.infrastructure.web.controller;
|
||||||
|
|
||||||
|
import com.loremind.application.campaigncontext.NpcService;
|
||||||
|
import com.loremind.domain.campaigncontext.Npc;
|
||||||
|
import com.loremind.infrastructure.web.dto.campaigncontext.NpcDTO;
|
||||||
|
import com.loremind.infrastructure.web.mapper.NpcMapper;
|
||||||
|
import org.springframework.http.ResponseEntity;
|
||||||
|
import org.springframework.web.bind.annotation.*;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.stream.Collectors;
|
||||||
|
|
||||||
|
@RestController
|
||||||
|
@RequestMapping("/api/npcs")
|
||||||
|
public class NpcController {
|
||||||
|
|
||||||
|
private final NpcService npcService;
|
||||||
|
private final NpcMapper npcMapper;
|
||||||
|
|
||||||
|
public NpcController(NpcService npcService, NpcMapper npcMapper) {
|
||||||
|
this.npcService = npcService;
|
||||||
|
this.npcMapper = npcMapper;
|
||||||
|
}
|
||||||
|
|
||||||
|
@PostMapping
|
||||||
|
public ResponseEntity<NpcDTO> createNpc(@RequestBody NpcDTO dto) {
|
||||||
|
Npc created = npcService.createNpc(toData(dto, null));
|
||||||
|
return ResponseEntity.ok(npcMapper.toDTO(created));
|
||||||
|
}
|
||||||
|
|
||||||
|
@GetMapping("/{id}")
|
||||||
|
public ResponseEntity<NpcDTO> getNpcById(@PathVariable String id) {
|
||||||
|
return npcService.getNpcById(id)
|
||||||
|
.map(n -> ResponseEntity.ok(npcMapper.toDTO(n)))
|
||||||
|
.orElse(ResponseEntity.notFound().build());
|
||||||
|
}
|
||||||
|
|
||||||
|
@GetMapping("/campaign/{campaignId}")
|
||||||
|
public ResponseEntity<List<NpcDTO>> getNpcsByCampaign(@PathVariable String campaignId) {
|
||||||
|
List<NpcDTO> dtos = npcService.getNpcsByCampaignId(campaignId).stream()
|
||||||
|
.map(npcMapper::toDTO)
|
||||||
|
.collect(Collectors.toList());
|
||||||
|
return ResponseEntity.ok(dtos);
|
||||||
|
}
|
||||||
|
|
||||||
|
@PutMapping("/{id}")
|
||||||
|
public ResponseEntity<NpcDTO> updateNpc(@PathVariable String id, @RequestBody NpcDTO dto) {
|
||||||
|
Npc updated = npcService.updateNpc(id, toData(dto, dto.getOrder()));
|
||||||
|
return ResponseEntity.ok(npcMapper.toDTO(updated));
|
||||||
|
}
|
||||||
|
|
||||||
|
@DeleteMapping("/{id}")
|
||||||
|
public ResponseEntity<Void> deleteNpc(@PathVariable String id) {
|
||||||
|
npcService.deleteNpc(id);
|
||||||
|
return ResponseEntity.noContent().build();
|
||||||
|
}
|
||||||
|
|
||||||
|
private NpcService.NpcData toData(NpcDTO dto, Integer order) {
|
||||||
|
return new NpcService.NpcData(
|
||||||
|
dto.getName(),
|
||||||
|
dto.getPortraitImageId(),
|
||||||
|
dto.getHeaderImageId(),
|
||||||
|
dto.getValues(),
|
||||||
|
dto.getImageValues(),
|
||||||
|
dto.getCampaignId(),
|
||||||
|
order
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,7 +2,7 @@ package com.loremind.infrastructure.web.controller;
|
|||||||
|
|
||||||
import com.loremind.application.lorecontext.TemplateService;
|
import com.loremind.application.lorecontext.TemplateService;
|
||||||
import com.loremind.domain.lorecontext.Template;
|
import com.loremind.domain.lorecontext.Template;
|
||||||
import com.loremind.domain.lorecontext.TemplateField;
|
import com.loremind.domain.shared.template.TemplateField;
|
||||||
import com.loremind.infrastructure.web.dto.lorecontext.TemplateDTO;
|
import com.loremind.infrastructure.web.dto.lorecontext.TemplateDTO;
|
||||||
import com.loremind.infrastructure.web.mapper.TemplateFieldMapper;
|
import com.loremind.infrastructure.web.mapper.TemplateFieldMapper;
|
||||||
import com.loremind.infrastructure.web.mapper.TemplateMapper;
|
import com.loremind.infrastructure.web.mapper.TemplateMapper;
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
package com.loremind.infrastructure.web.controller;
|
package com.loremind.infrastructure.web.controller;
|
||||||
|
|
||||||
import com.loremind.infrastructure.updates.UpdateCheckService;
|
import com.loremind.infrastructure.updates.UpdateCheckService;
|
||||||
|
import com.loremind.infrastructure.updates.UpdateCheckService.BetaStatus;
|
||||||
import com.loremind.infrastructure.updates.UpdateCheckService.UpdateStatus;
|
import com.loremind.infrastructure.updates.UpdateCheckService.UpdateStatus;
|
||||||
import org.slf4j.Logger;
|
import org.slf4j.Logger;
|
||||||
import org.slf4j.LoggerFactory;
|
import org.slf4j.LoggerFactory;
|
||||||
@@ -44,6 +45,12 @@ public class UpdatesController {
|
|||||||
return updates.check();
|
return updates.check();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@GetMapping("/check-beta")
|
||||||
|
public BetaStatus checkBeta() {
|
||||||
|
guardDemoMode();
|
||||||
|
return updates.checkBeta();
|
||||||
|
}
|
||||||
|
|
||||||
@PostMapping("/apply")
|
@PostMapping("/apply")
|
||||||
public ResponseEntity<Map<String, Object>> apply() {
|
public ResponseEntity<Map<String, Object>> apply() {
|
||||||
guardDemoMode();
|
guardDemoMode();
|
||||||
|
|||||||
@@ -0,0 +1,35 @@
|
|||||||
|
package com.loremind.infrastructure.web.controller;
|
||||||
|
|
||||||
|
import org.springframework.boot.info.BuildProperties;
|
||||||
|
import org.springframework.lang.Nullable;
|
||||||
|
import org.springframework.web.bind.annotation.GetMapping;
|
||||||
|
import org.springframework.web.bind.annotation.RequestMapping;
|
||||||
|
import org.springframework.web.bind.annotation.RestController;
|
||||||
|
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Endpoint public exposant la version courante du binaire.
|
||||||
|
* <p>
|
||||||
|
* Consomme par le frontend pour detecter qu'une mise a jour a ete deployee
|
||||||
|
* pendant qu'un onglet utilisateur etait deja ouvert : si la version polled
|
||||||
|
* differe de celle observee au boot, l'UI affiche un bandeau "rechargez".
|
||||||
|
* <p>
|
||||||
|
* Volontairement public (pas d'auth) : la version est deja exposee dans le
|
||||||
|
* JAR / l'image Docker, aucun risque de leak.
|
||||||
|
*/
|
||||||
|
@RestController
|
||||||
|
@RequestMapping("/api/version")
|
||||||
|
public class VersionController {
|
||||||
|
|
||||||
|
private final String version;
|
||||||
|
|
||||||
|
public VersionController(@Nullable BuildProperties buildProperties) {
|
||||||
|
this.version = buildProperties != null ? buildProperties.getVersion() : "dev";
|
||||||
|
}
|
||||||
|
|
||||||
|
@GetMapping
|
||||||
|
public Map<String, String> getVersion() {
|
||||||
|
return Map.of("version", version);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,15 +2,25 @@ package com.loremind.infrastructure.web.dto.campaigncontext;
|
|||||||
|
|
||||||
import lombok.Data;
|
import lombok.Data;
|
||||||
|
|
||||||
|
import java.util.HashMap;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* DTO pour les fiches de personnages (PJ) d'une campagne.
|
* DTO pour les fiches de personnages (PJ) d'une campagne.
|
||||||
|
* Reflete la refonte template-based : champs universels hard-codes (name,
|
||||||
|
* portrait, header) + maps {@code values}/{@code imageValues} pour les
|
||||||
|
* champs templates pilotes par le GameSystem.
|
||||||
*/
|
*/
|
||||||
@Data
|
@Data
|
||||||
public class CharacterDTO {
|
public class CharacterDTO {
|
||||||
|
|
||||||
private String id;
|
private String id;
|
||||||
private String name;
|
private String name;
|
||||||
private String markdownContent;
|
private String portraitImageId;
|
||||||
|
private String headerImageId;
|
||||||
|
private Map<String, String> values = new HashMap<>();
|
||||||
|
private Map<String, List<String>> imageValues = new HashMap<>();
|
||||||
private String campaignId;
|
private String campaignId;
|
||||||
private int order;
|
private int order;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,23 @@
|
|||||||
|
package com.loremind.infrastructure.web.dto.campaigncontext;
|
||||||
|
|
||||||
|
import lombok.Data;
|
||||||
|
|
||||||
|
import java.util.HashMap;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* DTO pour les fiches de PNJ d'une campagne. Meme structure que CharacterDTO.
|
||||||
|
*/
|
||||||
|
@Data
|
||||||
|
public class NpcDTO {
|
||||||
|
|
||||||
|
private String id;
|
||||||
|
private String name;
|
||||||
|
private String portraitImageId;
|
||||||
|
private String headerImageId;
|
||||||
|
private Map<String, String> values = new HashMap<>();
|
||||||
|
private Map<String, List<String>> imageValues = new HashMap<>();
|
||||||
|
private String campaignId;
|
||||||
|
private int order;
|
||||||
|
}
|
||||||
@@ -1,9 +1,14 @@
|
|||||||
package com.loremind.infrastructure.web.dto.gamesystemcontext;
|
package com.loremind.infrastructure.web.dto.gamesystemcontext;
|
||||||
|
|
||||||
|
import com.loremind.infrastructure.web.dto.shared.TemplateFieldDTO;
|
||||||
import lombok.Data;
|
import lombok.Data;
|
||||||
|
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* DTO pour l'entité GameSystem (système de JDR).
|
* DTO pour l'entité GameSystem (système de JDR).
|
||||||
|
* Expose les templates PJ/PNJ comme listes de TemplateFieldDTO pour le wire.
|
||||||
*/
|
*/
|
||||||
@Data
|
@Data
|
||||||
public class GameSystemDTO {
|
public class GameSystemDTO {
|
||||||
@@ -12,6 +17,8 @@ public class GameSystemDTO {
|
|||||||
private String name;
|
private String name;
|
||||||
private String description;
|
private String description;
|
||||||
private String rulesMarkdown;
|
private String rulesMarkdown;
|
||||||
|
private List<TemplateFieldDTO> characterTemplate = new ArrayList<>();
|
||||||
|
private List<TemplateFieldDTO> npcTemplate = new ArrayList<>();
|
||||||
private String author;
|
private String author;
|
||||||
private boolean isPublic;
|
private boolean isPublic;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,35 @@
|
|||||||
|
package com.loremind.infrastructure.web.dto.licensing;
|
||||||
|
|
||||||
|
import com.loremind.domain.licensing.LicenseSnapshot;
|
||||||
|
|
||||||
|
import java.time.Instant;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Vue serialisee de l'etat de la licence pour le frontend.
|
||||||
|
* Le {@code rawJwt} n'est volontairement JAMAIS expose.
|
||||||
|
*/
|
||||||
|
public record LicenseStatusDTO(
|
||||||
|
boolean enabled,
|
||||||
|
String status,
|
||||||
|
String patreonUserId,
|
||||||
|
String tierId,
|
||||||
|
String instanceId,
|
||||||
|
Instant expiresAt,
|
||||||
|
Instant lastRefreshAttemptAt,
|
||||||
|
Boolean lastRefreshSucceeded,
|
||||||
|
boolean betaChannelEnabled
|
||||||
|
) {
|
||||||
|
public static LicenseStatusDTO from(boolean enabled, LicenseSnapshot snap) {
|
||||||
|
return new LicenseStatusDTO(
|
||||||
|
enabled,
|
||||||
|
snap.status().name(),
|
||||||
|
snap.patreonUserId(),
|
||||||
|
snap.tierId(),
|
||||||
|
snap.instanceId(),
|
||||||
|
snap.expiresAt(),
|
||||||
|
snap.lastRefreshAttemptAt(),
|
||||||
|
snap.lastRefreshAttemptAt() != null ? snap.lastRefreshSucceeded() : null,
|
||||||
|
snap.betaChannelEnabled()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
package com.loremind.infrastructure.web.dto.lorecontext;
|
package com.loremind.infrastructure.web.dto.lorecontext;
|
||||||
|
|
||||||
|
import com.loremind.infrastructure.web.dto.shared.TemplateFieldDTO;
|
||||||
import lombok.Data;
|
import lombok.Data;
|
||||||
|
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
package com.loremind.infrastructure.web.dto.lorecontext;
|
package com.loremind.infrastructure.web.dto.shared;
|
||||||
|
|
||||||
import lombok.AllArgsConstructor;
|
import lombok.AllArgsConstructor;
|
||||||
import lombok.Data;
|
import lombok.Data;
|
||||||
@@ -7,7 +7,7 @@ import lombok.NoArgsConstructor;
|
|||||||
/**
|
/**
|
||||||
* DTO pour un champ de Template.
|
* DTO pour un champ de Template.
|
||||||
* <p>
|
* <p>
|
||||||
* Miroir wire-friendly de {@link com.loremind.domain.lorecontext.TemplateField}.
|
* Miroir wire-friendly de {@link com.loremind.domain.shared.template.TemplateField}.
|
||||||
* Le type est serialise en string (TEXT/IMAGE) pour interop facile avec Angular.
|
* Le type est serialise en string (TEXT/IMAGE) pour interop facile avec Angular.
|
||||||
* Le layout (null pour TEXT, ou GALLERY/HERO/MASONRY/CAROUSEL pour IMAGE) pilote
|
* Le layout (null pour TEXT, ou GALLERY/HERO/MASONRY/CAROUSEL pour IMAGE) pilote
|
||||||
* le rendu visuel des champs image cote front.
|
* le rendu visuel des champs image cote front.
|
||||||
@@ -4,6 +4,8 @@ import com.loremind.domain.campaigncontext.Character;
|
|||||||
import com.loremind.infrastructure.web.dto.campaigncontext.CharacterDTO;
|
import com.loremind.infrastructure.web.dto.campaigncontext.CharacterDTO;
|
||||||
import org.springframework.stereotype.Component;
|
import org.springframework.stereotype.Component;
|
||||||
|
|
||||||
|
import java.util.HashMap;
|
||||||
|
|
||||||
@Component
|
@Component
|
||||||
public class CharacterMapper {
|
public class CharacterMapper {
|
||||||
|
|
||||||
@@ -12,7 +14,10 @@ public class CharacterMapper {
|
|||||||
CharacterDTO dto = new CharacterDTO();
|
CharacterDTO dto = new CharacterDTO();
|
||||||
dto.setId(c.getId());
|
dto.setId(c.getId());
|
||||||
dto.setName(c.getName());
|
dto.setName(c.getName());
|
||||||
dto.setMarkdownContent(c.getMarkdownContent());
|
dto.setPortraitImageId(c.getPortraitImageId());
|
||||||
|
dto.setHeaderImageId(c.getHeaderImageId());
|
||||||
|
dto.setValues(c.getValues() != null ? new HashMap<>(c.getValues()) : new HashMap<>());
|
||||||
|
dto.setImageValues(c.getImageValues() != null ? new HashMap<>(c.getImageValues()) : new HashMap<>());
|
||||||
dto.setCampaignId(c.getCampaignId());
|
dto.setCampaignId(c.getCampaignId());
|
||||||
dto.setOrder(c.getOrder());
|
dto.setOrder(c.getOrder());
|
||||||
return dto;
|
return dto;
|
||||||
@@ -23,7 +28,10 @@ public class CharacterMapper {
|
|||||||
return Character.builder()
|
return Character.builder()
|
||||||
.id(dto.getId())
|
.id(dto.getId())
|
||||||
.name(dto.getName())
|
.name(dto.getName())
|
||||||
.markdownContent(dto.getMarkdownContent())
|
.portraitImageId(dto.getPortraitImageId())
|
||||||
|
.headerImageId(dto.getHeaderImageId())
|
||||||
|
.values(dto.getValues() != null ? new HashMap<>(dto.getValues()) : new HashMap<>())
|
||||||
|
.imageValues(dto.getImageValues() != null ? new HashMap<>(dto.getImageValues()) : new HashMap<>())
|
||||||
.campaignId(dto.getCampaignId())
|
.campaignId(dto.getCampaignId())
|
||||||
.order(dto.getOrder())
|
.order(dto.getOrder())
|
||||||
.build();
|
.build();
|
||||||
|
|||||||
@@ -1,12 +1,23 @@
|
|||||||
package com.loremind.infrastructure.web.mapper;
|
package com.loremind.infrastructure.web.mapper;
|
||||||
|
|
||||||
import com.loremind.domain.gamesystemcontext.GameSystem;
|
import com.loremind.domain.gamesystemcontext.GameSystem;
|
||||||
|
import com.loremind.domain.shared.template.TemplateField;
|
||||||
import com.loremind.infrastructure.web.dto.gamesystemcontext.GameSystemDTO;
|
import com.loremind.infrastructure.web.dto.gamesystemcontext.GameSystemDTO;
|
||||||
|
import com.loremind.infrastructure.web.dto.shared.TemplateFieldDTO;
|
||||||
import org.springframework.stereotype.Component;
|
import org.springframework.stereotype.Component;
|
||||||
|
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
@Component
|
@Component
|
||||||
public class GameSystemMapper {
|
public class GameSystemMapper {
|
||||||
|
|
||||||
|
private final TemplateFieldMapper fieldMapper;
|
||||||
|
|
||||||
|
public GameSystemMapper(TemplateFieldMapper fieldMapper) {
|
||||||
|
this.fieldMapper = fieldMapper;
|
||||||
|
}
|
||||||
|
|
||||||
public GameSystemDTO toDTO(GameSystem g) {
|
public GameSystemDTO toDTO(GameSystem g) {
|
||||||
if (g == null) return null;
|
if (g == null) return null;
|
||||||
GameSystemDTO dto = new GameSystemDTO();
|
GameSystemDTO dto = new GameSystemDTO();
|
||||||
@@ -14,6 +25,8 @@ public class GameSystemMapper {
|
|||||||
dto.setName(g.getName());
|
dto.setName(g.getName());
|
||||||
dto.setDescription(g.getDescription());
|
dto.setDescription(g.getDescription());
|
||||||
dto.setRulesMarkdown(g.getRulesMarkdown());
|
dto.setRulesMarkdown(g.getRulesMarkdown());
|
||||||
|
dto.setCharacterTemplate(toDTOList(g.getCharacterTemplate()));
|
||||||
|
dto.setNpcTemplate(toDTOList(g.getNpcTemplate()));
|
||||||
dto.setAuthor(g.getAuthor());
|
dto.setAuthor(g.getAuthor());
|
||||||
dto.setPublic(g.isPublic());
|
dto.setPublic(g.isPublic());
|
||||||
return dto;
|
return dto;
|
||||||
@@ -26,8 +39,24 @@ public class GameSystemMapper {
|
|||||||
.name(dto.getName())
|
.name(dto.getName())
|
||||||
.description(dto.getDescription())
|
.description(dto.getDescription())
|
||||||
.rulesMarkdown(dto.getRulesMarkdown())
|
.rulesMarkdown(dto.getRulesMarkdown())
|
||||||
|
.characterTemplate(toDomainList(dto.getCharacterTemplate()))
|
||||||
|
.npcTemplate(toDomainList(dto.getNpcTemplate()))
|
||||||
.author(dto.getAuthor())
|
.author(dto.getAuthor())
|
||||||
.isPublic(dto.isPublic())
|
.isPublic(dto.isPublic())
|
||||||
.build();
|
.build();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private List<TemplateFieldDTO> toDTOList(List<TemplateField> fields) {
|
||||||
|
if (fields == null) return new ArrayList<>();
|
||||||
|
List<TemplateFieldDTO> out = new ArrayList<>(fields.size());
|
||||||
|
for (TemplateField f : fields) out.add(fieldMapper.toDTO(f));
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
private List<TemplateField> toDomainList(List<TemplateFieldDTO> dtos) {
|
||||||
|
if (dtos == null) return new ArrayList<>();
|
||||||
|
List<TemplateField> out = new ArrayList<>(dtos.size());
|
||||||
|
for (TemplateFieldDTO d : dtos) out.add(fieldMapper.toDomain(d));
|
||||||
|
return out;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,39 @@
|
|||||||
|
package com.loremind.infrastructure.web.mapper;
|
||||||
|
|
||||||
|
import com.loremind.domain.campaigncontext.Npc;
|
||||||
|
import com.loremind.infrastructure.web.dto.campaigncontext.NpcDTO;
|
||||||
|
import org.springframework.stereotype.Component;
|
||||||
|
|
||||||
|
import java.util.HashMap;
|
||||||
|
|
||||||
|
@Component
|
||||||
|
public class NpcMapper {
|
||||||
|
|
||||||
|
public NpcDTO toDTO(Npc n) {
|
||||||
|
if (n == null) return null;
|
||||||
|
NpcDTO dto = new NpcDTO();
|
||||||
|
dto.setId(n.getId());
|
||||||
|
dto.setName(n.getName());
|
||||||
|
dto.setPortraitImageId(n.getPortraitImageId());
|
||||||
|
dto.setHeaderImageId(n.getHeaderImageId());
|
||||||
|
dto.setValues(n.getValues() != null ? new HashMap<>(n.getValues()) : new HashMap<>());
|
||||||
|
dto.setImageValues(n.getImageValues() != null ? new HashMap<>(n.getImageValues()) : new HashMap<>());
|
||||||
|
dto.setCampaignId(n.getCampaignId());
|
||||||
|
dto.setOrder(n.getOrder());
|
||||||
|
return dto;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Npc toDomain(NpcDTO dto) {
|
||||||
|
if (dto == null) return null;
|
||||||
|
return Npc.builder()
|
||||||
|
.id(dto.getId())
|
||||||
|
.name(dto.getName())
|
||||||
|
.portraitImageId(dto.getPortraitImageId())
|
||||||
|
.headerImageId(dto.getHeaderImageId())
|
||||||
|
.values(dto.getValues() != null ? new HashMap<>(dto.getValues()) : new HashMap<>())
|
||||||
|
.imageValues(dto.getImageValues() != null ? new HashMap<>(dto.getImageValues()) : new HashMap<>())
|
||||||
|
.campaignId(dto.getCampaignId())
|
||||||
|
.order(dto.getOrder())
|
||||||
|
.build();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,9 +1,9 @@
|
|||||||
package com.loremind.infrastructure.web.mapper;
|
package com.loremind.infrastructure.web.mapper;
|
||||||
|
|
||||||
import com.loremind.domain.lorecontext.FieldType;
|
import com.loremind.domain.shared.template.FieldType;
|
||||||
import com.loremind.domain.lorecontext.ImageLayout;
|
import com.loremind.domain.shared.template.ImageLayout;
|
||||||
import com.loremind.domain.lorecontext.TemplateField;
|
import com.loremind.domain.shared.template.TemplateField;
|
||||||
import com.loremind.infrastructure.web.dto.lorecontext.TemplateFieldDTO;
|
import com.loremind.infrastructure.web.dto.shared.TemplateFieldDTO;
|
||||||
import org.springframework.stereotype.Component;
|
import org.springframework.stereotype.Component;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
package com.loremind.infrastructure.web.mapper;
|
package com.loremind.infrastructure.web.mapper;
|
||||||
|
|
||||||
import com.loremind.domain.lorecontext.Template;
|
import com.loremind.domain.lorecontext.Template;
|
||||||
import com.loremind.domain.lorecontext.TemplateField;
|
import com.loremind.domain.shared.template.TemplateField;
|
||||||
import com.loremind.infrastructure.web.dto.lorecontext.TemplateDTO;
|
import com.loremind.infrastructure.web.dto.lorecontext.TemplateDTO;
|
||||||
import com.loremind.infrastructure.web.dto.lorecontext.TemplateFieldDTO;
|
import com.loremind.infrastructure.web.dto.shared.TemplateFieldDTO;
|
||||||
import org.springframework.stereotype.Component;
|
import org.springframework.stereotype.Component;
|
||||||
|
|
||||||
import java.util.ArrayList;
|
import java.util.ArrayList;
|
||||||
|
|||||||
@@ -65,6 +65,39 @@ app.demo-mode=${DEMO_MODE:false}
|
|||||||
# Feature desactivee si UPDATE_CHECK_WATCHTOWER_TOKEN est vide.
|
# Feature desactivee si UPDATE_CHECK_WATCHTOWER_TOKEN est vide.
|
||||||
update-check.registry=${UPDATE_CHECK_REGISTRY:}
|
update-check.registry=${UPDATE_CHECK_REGISTRY:}
|
||||||
update-check.images=${UPDATE_CHECK_IMAGES:}
|
update-check.images=${UPDATE_CHECK_IMAGES:}
|
||||||
update-check.tag=${UPDATE_CHECK_TAG:latest}
|
|
||||||
update-check.watchtower-url=${WATCHTOWER_URL:http://watchtower:8080}
|
update-check.watchtower-url=${WATCHTOWER_URL:http://watchtower:8080}
|
||||||
update-check.watchtower-token=${WATCHTOWER_TOKEN:}
|
update-check.watchtower-token=${WATCHTOWER_TOKEN:}
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
# Licensing (canal beta gate par Patreon)
|
||||||
|
# ============================================================================
|
||||||
|
# URL du relais OAuth Patreon (Cloudflare Workers). En prod : valeur par defaut.
|
||||||
|
licensing.relay.base-url=${LICENSING_RELAY_BASE_URL:https://loremind-auth.igmlcreation.fr}
|
||||||
|
|
||||||
|
# Cle publique Ed25519 (PEM SPKI) qui verifie les JWT emis par le relais.
|
||||||
|
# En prod : chargee automatiquement depuis classpath:licensing/jwt-public-key.pem
|
||||||
|
# (embarquee dans le binaire). Cette propriete sert UNIQUEMENT a la rotation
|
||||||
|
# de cle ou aux tests : si LICENSING_JWT_PUBLIC_KEY est defini, il prevaut
|
||||||
|
# sur le fichier embarque.
|
||||||
|
licensing.jwt.public-key=${LICENSING_JWT_PUBLIC_KEY:}
|
||||||
|
licensing.jwt.expected-issuer=loremind-auth
|
||||||
|
licensing.jwt.expected-audience=loremind-instance
|
||||||
|
|
||||||
|
# Periode de tolerance apres expiration du JWT pendant laquelle l'instance
|
||||||
|
# garde l'acces beta meme si le relais est indisponible pour le refresh.
|
||||||
|
licensing.grace-period-days=14
|
||||||
|
# Avant J-N de l'expiration, le daemon tente un refresh.
|
||||||
|
licensing.refresh-before-expiry-days=2
|
||||||
|
|
||||||
|
# Identifiant stable de l'instance (UUID genere a la premiere connexion Patreon
|
||||||
|
# et conserve en base). Utilise dans le state OAuth + dans le JWT.
|
||||||
|
licensing.instance-id-file=${LICENSING_INSTANCE_ID_FILE:}
|
||||||
|
|
||||||
|
# Image beta : si la licence est valide ET le toggle canal beta active,
|
||||||
|
# UpdateCheckService check ces images en plus du canal stable.
|
||||||
|
licensing.beta.images=${LICENSING_BETA_IMAGES:igmlcreation/loremind-beta-core,igmlcreation/loremind-beta-brain,igmlcreation/loremind-beta-web}
|
||||||
|
|
||||||
|
# Chemin de sortie pour le docker config.json partage avec Watchtower.
|
||||||
|
# Volume Docker `docker-config` monte sur ce chemin dans Core, et sur
|
||||||
|
# `/shared/docker` dans Watchtower (DOCKER_CONFIG=/shared/docker).
|
||||||
|
licensing.docker-config-path=${LICENSING_DOCKER_CONFIG_PATH:/shared/docker/config.json}
|
||||||
|
|||||||
29
core/src/main/resources/licensing/README.md
Normal file
29
core/src/main/resources/licensing/README.md
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
# Cle publique JWT du relais OAuth Patreon
|
||||||
|
|
||||||
|
Le fichier `jwt-public-key.pem` contient la **cle publique Ed25519** qui sert
|
||||||
|
a verifier la signature des JWT licence emis par le relais
|
||||||
|
(`loremind-auth.igmlcreation.fr`).
|
||||||
|
|
||||||
|
## Pourquoi ici ?
|
||||||
|
|
||||||
|
- C'est une **cle publique** : par nature non-secrete, elle peut etre committee
|
||||||
|
dans le repo public et embarquee dans le binaire distribue.
|
||||||
|
- Cela evite a chaque utilisateur final de devoir renseigner manuellement la
|
||||||
|
cle dans son `.env` au moment de l'installation.
|
||||||
|
- L'env `LICENSING_JWT_PUBLIC_KEY` peut surcharger cette valeur (utile pour
|
||||||
|
la rotation de cle sans rebuild ou pour les tests).
|
||||||
|
|
||||||
|
## Si le fichier est absent
|
||||||
|
|
||||||
|
La feature licensing est **desactivee silencieusement** : `LicenseService.isLicensingEnabled()`
|
||||||
|
renvoie `false`, et l'UI masque toute la section Patreon.
|
||||||
|
|
||||||
|
## Rotation de cle
|
||||||
|
|
||||||
|
1. Generer une nouvelle paire dans le relais : `npm run keys:generate`
|
||||||
|
2. Pousser la nouvelle cle privee : `wrangler secret put JWT_PRIVATE_KEY`
|
||||||
|
3. Remplacer `jwt-public-key.pem` ici avec la nouvelle cle publique
|
||||||
|
4. Rebuild + redeployer LoreMind (les anciens JWT seront refuses au prochain
|
||||||
|
refresh, l'utilisateur sera invite a reconnecter Patreon)
|
||||||
|
5. Optionnel : pendant la transition, supporter les deux cles en parallele
|
||||||
|
(pas implemente en MVP, peut etre ajoute si besoin operationnel)
|
||||||
3
core/src/main/resources/licensing/jwt-public-key.pem
Normal file
3
core/src/main/resources/licensing/jwt-public-key.pem
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
-----BEGIN PUBLIC KEY-----
|
||||||
|
MCowBQYDK2VwAyEArbfaqBq54HJR1pKqliTShKrNIab32gpBwSTDw90I4wg=
|
||||||
|
-----END PUBLIC KEY-----
|
||||||
@@ -0,0 +1,163 @@
|
|||||||
|
package com.loremind.application.campaigncontext;
|
||||||
|
|
||||||
|
import com.loremind.domain.campaigncontext.Npc;
|
||||||
|
import com.loremind.domain.campaigncontext.ports.NpcRepository;
|
||||||
|
import org.junit.jupiter.api.BeforeEach;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
import org.junit.jupiter.api.extension.ExtendWith;
|
||||||
|
import org.mockito.ArgumentCaptor;
|
||||||
|
import org.mockito.InjectMocks;
|
||||||
|
import org.mockito.Mock;
|
||||||
|
import org.mockito.junit.jupiter.MockitoExtension;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
import java.util.Optional;
|
||||||
|
|
||||||
|
import static org.junit.jupiter.api.Assertions.*;
|
||||||
|
import static org.mockito.ArgumentMatchers.any;
|
||||||
|
import static org.mockito.Mockito.*;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Test unitaire pour NpcService.
|
||||||
|
* Couvre la création (avec auto-calcul de l'order), la lecture, la mise à jour
|
||||||
|
* (incl. cas non trouvé), la suppression, et le calcul d'order.
|
||||||
|
*/
|
||||||
|
@ExtendWith(MockitoExtension.class)
|
||||||
|
public class NpcServiceTest {
|
||||||
|
|
||||||
|
@Mock
|
||||||
|
private NpcRepository npcRepository;
|
||||||
|
|
||||||
|
@InjectMocks
|
||||||
|
private NpcService npcService;
|
||||||
|
|
||||||
|
private Npc testNpc;
|
||||||
|
|
||||||
|
@BeforeEach
|
||||||
|
void setUp() {
|
||||||
|
testNpc = Npc.builder()
|
||||||
|
.id("npc-1")
|
||||||
|
.name("Borin le forgeron")
|
||||||
|
.values(new java.util.HashMap<>(Map.of("Notes", "Forgeron nain")))
|
||||||
|
.campaignId("camp-1")
|
||||||
|
.order(1)
|
||||||
|
.build();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void testCreateNpc_WithExplicitOrder() {
|
||||||
|
when(npcRepository.save(any(Npc.class))).thenReturn(testNpc);
|
||||||
|
|
||||||
|
Npc result = npcService.createNpc(
|
||||||
|
new NpcService.NpcData("Borin le forgeron", null, null,
|
||||||
|
Map.of("Notes", "Borin"), null, "camp-1", 5));
|
||||||
|
|
||||||
|
assertNotNull(result);
|
||||||
|
ArgumentCaptor<Npc> captor = ArgumentCaptor.forClass(Npc.class);
|
||||||
|
verify(npcRepository).save(captor.capture());
|
||||||
|
assertEquals(5, captor.getValue().getOrder());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void testCreateNpc_AutoComputesNextOrder_WhenNullProvided() {
|
||||||
|
// Existant : 2 PNJ avec orders 0 et 3 → next = 4
|
||||||
|
Npc a = Npc.builder().id("a").campaignId("camp-1").order(0).build();
|
||||||
|
Npc b = Npc.builder().id("b").campaignId("camp-1").order(3).build();
|
||||||
|
when(npcRepository.findByCampaignId("camp-1")).thenReturn(List.of(a, b));
|
||||||
|
when(npcRepository.save(any(Npc.class))).thenReturn(testNpc);
|
||||||
|
|
||||||
|
npcService.createNpc(new NpcService.NpcData("Nouveau", null, null, null, null, "camp-1", null));
|
||||||
|
|
||||||
|
ArgumentCaptor<Npc> captor = ArgumentCaptor.forClass(Npc.class);
|
||||||
|
verify(npcRepository).save(captor.capture());
|
||||||
|
assertEquals(4, captor.getValue().getOrder());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void testCreateNpc_FirstNpcGetsOrderZero() {
|
||||||
|
when(npcRepository.findByCampaignId("camp-1")).thenReturn(List.of());
|
||||||
|
when(npcRepository.save(any(Npc.class))).thenReturn(testNpc);
|
||||||
|
|
||||||
|
npcService.createNpc(new NpcService.NpcData("Premier", null, null, null, null, "camp-1", null));
|
||||||
|
|
||||||
|
ArgumentCaptor<Npc> captor = ArgumentCaptor.forClass(Npc.class);
|
||||||
|
verify(npcRepository).save(captor.capture());
|
||||||
|
assertEquals(0, captor.getValue().getOrder());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void testGetNpcById_Found() {
|
||||||
|
when(npcRepository.findById("npc-1")).thenReturn(Optional.of(testNpc));
|
||||||
|
|
||||||
|
Optional<Npc> result = npcService.getNpcById("npc-1");
|
||||||
|
|
||||||
|
assertTrue(result.isPresent());
|
||||||
|
assertEquals("Borin le forgeron", result.get().getName());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void testGetNpcById_NotFound() {
|
||||||
|
when(npcRepository.findById("missing")).thenReturn(Optional.empty());
|
||||||
|
|
||||||
|
Optional<Npc> result = npcService.getNpcById("missing");
|
||||||
|
|
||||||
|
assertFalse(result.isPresent());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void testGetNpcsByCampaignId_DelegatesToRepository() {
|
||||||
|
Npc a = Npc.builder().id("a").campaignId("camp-1").order(1).build();
|
||||||
|
Npc b = Npc.builder().id("b").campaignId("camp-1").order(2).build();
|
||||||
|
when(npcRepository.findByCampaignId("camp-1")).thenReturn(List.of(a, b));
|
||||||
|
|
||||||
|
List<Npc> result = npcService.getNpcsByCampaignId("camp-1");
|
||||||
|
|
||||||
|
assertEquals(2, result.size());
|
||||||
|
verify(npcRepository).findByCampaignId("camp-1");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void testUpdateNpc_Success() {
|
||||||
|
when(npcRepository.findById("npc-1")).thenReturn(Optional.of(testNpc));
|
||||||
|
when(npcRepository.save(any(Npc.class))).thenAnswer(inv -> inv.getArgument(0));
|
||||||
|
|
||||||
|
Npc result = npcService.updateNpc("npc-1",
|
||||||
|
new NpcService.NpcData("Borin renommé", null, null,
|
||||||
|
Map.of("Notes", "v2"), null, "camp-1", 7));
|
||||||
|
|
||||||
|
assertEquals("Borin renommé", result.getName());
|
||||||
|
assertEquals("v2", result.getValues().get("Notes"));
|
||||||
|
assertEquals(7, result.getOrder());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void testUpdateNpc_OrderNullPreservesExistingOrder() {
|
||||||
|
when(npcRepository.findById("npc-1")).thenReturn(Optional.of(testNpc));
|
||||||
|
when(npcRepository.save(any(Npc.class))).thenAnswer(inv -> inv.getArgument(0));
|
||||||
|
|
||||||
|
Npc result = npcService.updateNpc("npc-1",
|
||||||
|
new NpcService.NpcData("Borin", null, null,
|
||||||
|
Map.of("Notes", "txt"), null, "camp-1", null));
|
||||||
|
|
||||||
|
// testNpc avait order=1 → préservé
|
||||||
|
assertEquals(1, result.getOrder());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void testUpdateNpc_NotFoundThrows() {
|
||||||
|
when(npcRepository.findById("missing")).thenReturn(Optional.empty());
|
||||||
|
|
||||||
|
IllegalArgumentException ex = assertThrows(IllegalArgumentException.class,
|
||||||
|
() -> npcService.updateNpc("missing",
|
||||||
|
new NpcService.NpcData("x", null, null, null, null, "camp-1", null)));
|
||||||
|
assertTrue(ex.getMessage().contains("missing"));
|
||||||
|
verify(npcRepository, never()).save(any());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void testDeleteNpc_DelegatesToRepository() {
|
||||||
|
npcService.deleteNpc("npc-1");
|
||||||
|
verify(npcRepository).deleteById("npc-1");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -3,12 +3,15 @@ package com.loremind.application.generationcontext;
|
|||||||
import com.loremind.domain.campaigncontext.Arc;
|
import com.loremind.domain.campaigncontext.Arc;
|
||||||
import com.loremind.domain.campaigncontext.Campaign;
|
import com.loremind.domain.campaigncontext.Campaign;
|
||||||
import com.loremind.domain.campaigncontext.Chapter;
|
import com.loremind.domain.campaigncontext.Chapter;
|
||||||
|
import com.loremind.domain.campaigncontext.Character;
|
||||||
|
import com.loremind.domain.campaigncontext.Npc;
|
||||||
import com.loremind.domain.campaigncontext.Scene;
|
import com.loremind.domain.campaigncontext.Scene;
|
||||||
import com.loremind.domain.campaigncontext.SceneBranch;
|
import com.loremind.domain.campaigncontext.SceneBranch;
|
||||||
import com.loremind.domain.campaigncontext.ports.ArcRepository;
|
import com.loremind.domain.campaigncontext.ports.ArcRepository;
|
||||||
import com.loremind.domain.campaigncontext.ports.CampaignRepository;
|
import com.loremind.domain.campaigncontext.ports.CampaignRepository;
|
||||||
import com.loremind.domain.campaigncontext.ports.ChapterRepository;
|
import com.loremind.domain.campaigncontext.ports.ChapterRepository;
|
||||||
import com.loremind.domain.campaigncontext.ports.CharacterRepository;
|
import com.loremind.domain.campaigncontext.ports.CharacterRepository;
|
||||||
|
import com.loremind.domain.campaigncontext.ports.NpcRepository;
|
||||||
import com.loremind.domain.campaigncontext.ports.SceneRepository;
|
import com.loremind.domain.campaigncontext.ports.SceneRepository;
|
||||||
import com.loremind.domain.generationcontext.CampaignStructuralContext;
|
import com.loremind.domain.generationcontext.CampaignStructuralContext;
|
||||||
import org.junit.jupiter.api.BeforeEach;
|
import org.junit.jupiter.api.BeforeEach;
|
||||||
@@ -43,6 +46,8 @@ public class CampaignStructuralContextBuilderTest {
|
|||||||
private SceneRepository sceneRepository;
|
private SceneRepository sceneRepository;
|
||||||
@Mock
|
@Mock
|
||||||
private CharacterRepository characterRepository;
|
private CharacterRepository characterRepository;
|
||||||
|
@Mock
|
||||||
|
private NpcRepository npcRepository;
|
||||||
|
|
||||||
@InjectMocks
|
@InjectMocks
|
||||||
private CampaignStructuralContextBuilder builder;
|
private CampaignStructuralContextBuilder builder;
|
||||||
@@ -144,6 +149,66 @@ public class CampaignStructuralContextBuilderTest {
|
|||||||
assertEquals("(scène inconnue)", scene1Summary.branches().get(1).targetSceneName());
|
assertEquals("(scène inconnue)", scene1Summary.branches().get(1).targetSceneName());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void testBuild_ProjectsCharactersAndNpcsWithSnippets() {
|
||||||
|
Character pj1 = Character.builder().id("c-1").campaignId("camp-1").order(1)
|
||||||
|
.name("Aragorn")
|
||||||
|
.values(new java.util.HashMap<>(java.util.Map.of("Histoire", "# Aragorn\n\nRôdeur du Nord, héritier d'Isildur.")))
|
||||||
|
.build();
|
||||||
|
Character pj2 = Character.builder().id("c-2").campaignId("camp-1").order(2)
|
||||||
|
.name("Legolas")
|
||||||
|
.values(null) // pas de snippet → string vide
|
||||||
|
.build();
|
||||||
|
Npc npc1 = Npc.builder().id("n-1").campaignId("camp-1").order(2)
|
||||||
|
.name("Borin le forgeron")
|
||||||
|
.values(new java.util.HashMap<>(java.util.Map.of("Histoire", "# Borin\n\nNain barbu au regard perçant, ancien clan Feuillefer.")))
|
||||||
|
.build();
|
||||||
|
Npc npc2 = Npc.builder().id("n-2").campaignId("camp-1").order(1)
|
||||||
|
.name("Dame Elara")
|
||||||
|
.values(new java.util.HashMap<>(java.util.Map.of("Histoire", "")))
|
||||||
|
.build();
|
||||||
|
|
||||||
|
when(campaignRepository.findById("camp-1")).thenReturn(Optional.of(campaign));
|
||||||
|
when(arcRepository.findByCampaignId("camp-1")).thenReturn(List.of());
|
||||||
|
when(characterRepository.findByCampaignId("camp-1")).thenReturn(List.of(pj2, pj1));
|
||||||
|
when(npcRepository.findByCampaignId("camp-1")).thenReturn(List.of(npc1, npc2));
|
||||||
|
|
||||||
|
CampaignStructuralContext ctx = builder.build("camp-1");
|
||||||
|
|
||||||
|
// PJ triés par order croissant
|
||||||
|
assertEquals(2, ctx.characters().size());
|
||||||
|
assertEquals("Aragorn", ctx.characters().get(0).name());
|
||||||
|
assertEquals("Rôdeur du Nord, héritier d'Isildur.", ctx.characters().get(0).snippet());
|
||||||
|
assertEquals("Legolas", ctx.characters().get(1).name());
|
||||||
|
assertEquals("", ctx.characters().get(1).snippet());
|
||||||
|
|
||||||
|
// PNJ triés par order croissant : Elara (1) avant Borin (2)
|
||||||
|
assertEquals(2, ctx.npcs().size());
|
||||||
|
assertEquals("Dame Elara", ctx.npcs().get(0).name());
|
||||||
|
assertEquals("", ctx.npcs().get(0).snippet());
|
||||||
|
assertEquals("Borin le forgeron", ctx.npcs().get(1).name());
|
||||||
|
assertEquals("Nain barbu au regard perçant, ancien clan Feuillefer.",
|
||||||
|
ctx.npcs().get(1).snippet());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void testBuild_TruncatesLongSnippet() {
|
||||||
|
// Snippet > 160 chars : doit être tronqué à 159 + "…"
|
||||||
|
String longLine = "x".repeat(200);
|
||||||
|
Npc longNpc = Npc.builder().id("n-1").campaignId("camp-1").order(1)
|
||||||
|
.name("Verbeux").values(new java.util.HashMap<>(java.util.Map.of("Histoire", longLine))).build();
|
||||||
|
|
||||||
|
when(campaignRepository.findById("camp-1")).thenReturn(Optional.of(campaign));
|
||||||
|
when(arcRepository.findByCampaignId("camp-1")).thenReturn(List.of());
|
||||||
|
when(npcRepository.findByCampaignId("camp-1")).thenReturn(List.of(longNpc));
|
||||||
|
|
||||||
|
CampaignStructuralContext ctx = builder.build("camp-1");
|
||||||
|
|
||||||
|
String snippet = ctx.npcs().get(0).snippet();
|
||||||
|
assertEquals(160, snippet.length());
|
||||||
|
assertTrue(snippet.endsWith("…"));
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void testBuild_CountsIllustrationsNullSafe() {
|
void testBuild_CountsIllustrationsNullSafe() {
|
||||||
Arc arc = Arc.builder().id("arc-1").name("Arc").description("").order(1)
|
Arc arc = Arc.builder().id("arc-1").name("Arc").description("").order(1)
|
||||||
|
|||||||
@@ -3,12 +3,12 @@ package com.loremind.application.generationcontext;
|
|||||||
import com.loremind.domain.generationcontext.GenerationContext;
|
import com.loremind.domain.generationcontext.GenerationContext;
|
||||||
import com.loremind.domain.generationcontext.GenerationResult;
|
import com.loremind.domain.generationcontext.GenerationResult;
|
||||||
import com.loremind.domain.generationcontext.ports.AiProvider;
|
import com.loremind.domain.generationcontext.ports.AiProvider;
|
||||||
import com.loremind.domain.lorecontext.FieldType;
|
import com.loremind.domain.shared.template.FieldType;
|
||||||
import com.loremind.domain.lorecontext.Lore;
|
import com.loremind.domain.lorecontext.Lore;
|
||||||
import com.loremind.domain.lorecontext.LoreNode;
|
import com.loremind.domain.lorecontext.LoreNode;
|
||||||
import com.loremind.domain.lorecontext.Page;
|
import com.loremind.domain.lorecontext.Page;
|
||||||
import com.loremind.domain.lorecontext.Template;
|
import com.loremind.domain.lorecontext.Template;
|
||||||
import com.loremind.domain.lorecontext.TemplateField;
|
import com.loremind.domain.shared.template.TemplateField;
|
||||||
import com.loremind.domain.lorecontext.ports.LoreNodeRepository;
|
import com.loremind.domain.lorecontext.ports.LoreNodeRepository;
|
||||||
import com.loremind.domain.lorecontext.ports.LoreRepository;
|
import com.loremind.domain.lorecontext.ports.LoreRepository;
|
||||||
import com.loremind.domain.lorecontext.ports.PageRepository;
|
import com.loremind.domain.lorecontext.ports.PageRepository;
|
||||||
|
|||||||
@@ -2,9 +2,13 @@ package com.loremind.application.generationcontext;
|
|||||||
|
|
||||||
import com.loremind.domain.campaigncontext.Arc;
|
import com.loremind.domain.campaigncontext.Arc;
|
||||||
import com.loremind.domain.campaigncontext.Chapter;
|
import com.loremind.domain.campaigncontext.Chapter;
|
||||||
|
import com.loremind.domain.campaigncontext.Character;
|
||||||
|
import com.loremind.domain.campaigncontext.Npc;
|
||||||
import com.loremind.domain.campaigncontext.Scene;
|
import com.loremind.domain.campaigncontext.Scene;
|
||||||
import com.loremind.domain.campaigncontext.ports.ArcRepository;
|
import com.loremind.domain.campaigncontext.ports.ArcRepository;
|
||||||
import com.loremind.domain.campaigncontext.ports.ChapterRepository;
|
import com.loremind.domain.campaigncontext.ports.ChapterRepository;
|
||||||
|
import com.loremind.domain.campaigncontext.ports.CharacterRepository;
|
||||||
|
import com.loremind.domain.campaigncontext.ports.NpcRepository;
|
||||||
import com.loremind.domain.campaigncontext.ports.SceneRepository;
|
import com.loremind.domain.campaigncontext.ports.SceneRepository;
|
||||||
import com.loremind.domain.generationcontext.NarrativeEntityContext;
|
import com.loremind.domain.generationcontext.NarrativeEntityContext;
|
||||||
import org.junit.jupiter.api.Test;
|
import org.junit.jupiter.api.Test;
|
||||||
@@ -30,6 +34,8 @@ public class NarrativeEntityContextBuilderTest {
|
|||||||
@Mock private ArcRepository arcRepository;
|
@Mock private ArcRepository arcRepository;
|
||||||
@Mock private ChapterRepository chapterRepository;
|
@Mock private ChapterRepository chapterRepository;
|
||||||
@Mock private SceneRepository sceneRepository;
|
@Mock private SceneRepository sceneRepository;
|
||||||
|
@Mock private CharacterRepository characterRepository;
|
||||||
|
@Mock private NpcRepository npcRepository;
|
||||||
|
|
||||||
@InjectMocks private NarrativeEntityContextBuilder builder;
|
@InjectMocks private NarrativeEntityContextBuilder builder;
|
||||||
|
|
||||||
@@ -107,11 +113,68 @@ public class NarrativeEntityContextBuilderTest {
|
|||||||
assertEquals("arc", ctx.entityType());
|
assertEquals("arc", ctx.entityType());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void testBuild_Character_MarkdownProjected() {
|
||||||
|
// Refonte 2026-04-30 : les valeurs templates sont projetees individuellement
|
||||||
|
// dans la map fields (cle = nom du champ template).
|
||||||
|
Character c = Character.builder()
|
||||||
|
.id("c-1").name("Aragorn")
|
||||||
|
.values(new java.util.HashMap<>(java.util.Map.of(
|
||||||
|
"Histoire", "# Aragorn\nRôdeur",
|
||||||
|
"Race", "Humain")))
|
||||||
|
.build();
|
||||||
|
when(characterRepository.findById("c-1")).thenReturn(Optional.of(c));
|
||||||
|
|
||||||
|
NarrativeEntityContext ctx = builder.build("character", "c-1");
|
||||||
|
|
||||||
|
assertEquals("character", ctx.entityType());
|
||||||
|
assertEquals("Aragorn", ctx.title());
|
||||||
|
assertEquals("# Aragorn\nRôdeur", ctx.fields().get("Histoire"));
|
||||||
|
assertEquals("Humain", ctx.fields().get("Race"));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void testBuild_Npc_MarkdownProjected() {
|
||||||
|
Npc n = Npc.builder()
|
||||||
|
.id("n-1").name("Borin le forgeron")
|
||||||
|
.values(new java.util.HashMap<>(java.util.Map.of(
|
||||||
|
"Faction", "Clan Feuillefer",
|
||||||
|
"Histoire", "# Borin")))
|
||||||
|
.build();
|
||||||
|
when(npcRepository.findById("n-1")).thenReturn(Optional.of(n));
|
||||||
|
|
||||||
|
NarrativeEntityContext ctx = builder.build("npc", "n-1");
|
||||||
|
|
||||||
|
assertEquals("npc", ctx.entityType());
|
||||||
|
assertEquals("Borin le forgeron", ctx.title());
|
||||||
|
assertEquals("Clan Feuillefer", ctx.fields().get("Faction"));
|
||||||
|
assertEquals("# Borin", ctx.fields().get("Histoire"));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void testBuild_Npc_NormalizesCase() {
|
||||||
|
Npc n = Npc.builder().id("n-1").name("Elara")
|
||||||
|
.values(new java.util.HashMap<>(java.util.Map.of("Notes", "desc"))).build();
|
||||||
|
when(npcRepository.findById("n-1")).thenReturn(Optional.of(n));
|
||||||
|
|
||||||
|
NarrativeEntityContext ctx = builder.build(" NPC ", "n-1");
|
||||||
|
assertEquals("npc", ctx.entityType());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void testBuild_NpcNotFoundThrows() {
|
||||||
|
when(npcRepository.findById("missing")).thenReturn(Optional.empty());
|
||||||
|
|
||||||
|
IllegalArgumentException ex = assertThrows(IllegalArgumentException.class,
|
||||||
|
() -> builder.build("npc", "missing"));
|
||||||
|
assertTrue(ex.getMessage().contains("missing"));
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void testBuild_UnknownTypeThrows() {
|
void testBuild_UnknownTypeThrows() {
|
||||||
IllegalArgumentException ex = assertThrows(IllegalArgumentException.class,
|
IllegalArgumentException ex = assertThrows(IllegalArgumentException.class,
|
||||||
() -> builder.build("npc", "id"));
|
() -> builder.build("alien", "id"));
|
||||||
assertTrue(ex.getMessage().contains("npc"));
|
assertTrue(ex.getMessage().contains("alien"));
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
|
|||||||
@@ -55,7 +55,7 @@ public class StreamChatForCampaignUseCaseTest {
|
|||||||
@SuppressWarnings("unchecked")
|
@SuppressWarnings("unchecked")
|
||||||
@BeforeEach
|
@BeforeEach
|
||||||
void setUp() {
|
void setUp() {
|
||||||
campaignCtx = new CampaignStructuralContext("X", "d", List.of(), List.of());
|
campaignCtx = new CampaignStructuralContext("X", "d", List.of(), List.of(), List.of());
|
||||||
messages = List.of();
|
messages = List.of();
|
||||||
onUsage = mock(Consumer.class);
|
onUsage = mock(Consumer.class);
|
||||||
onToken = mock(Consumer.class);
|
onToken = mock(Consumer.class);
|
||||||
|
|||||||
@@ -5,10 +5,10 @@ import com.loremind.domain.generationcontext.ChatRequest;
|
|||||||
import com.loremind.domain.generationcontext.ChatUsage;
|
import com.loremind.domain.generationcontext.ChatUsage;
|
||||||
import com.loremind.domain.generationcontext.LoreStructuralContext;
|
import com.loremind.domain.generationcontext.LoreStructuralContext;
|
||||||
import com.loremind.domain.generationcontext.ports.AiChatProvider;
|
import com.loremind.domain.generationcontext.ports.AiChatProvider;
|
||||||
import com.loremind.domain.lorecontext.FieldType;
|
import com.loremind.domain.shared.template.FieldType;
|
||||||
import com.loremind.domain.lorecontext.Page;
|
import com.loremind.domain.lorecontext.Page;
|
||||||
import com.loremind.domain.lorecontext.Template;
|
import com.loremind.domain.lorecontext.Template;
|
||||||
import com.loremind.domain.lorecontext.TemplateField;
|
import com.loremind.domain.shared.template.TemplateField;
|
||||||
import com.loremind.domain.lorecontext.ports.PageRepository;
|
import com.loremind.domain.lorecontext.ports.PageRepository;
|
||||||
import com.loremind.domain.lorecontext.ports.TemplateRepository;
|
import com.loremind.domain.lorecontext.ports.TemplateRepository;
|
||||||
import org.junit.jupiter.api.BeforeEach;
|
import org.junit.jupiter.api.BeforeEach;
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
package com.loremind.application.lorecontext;
|
package com.loremind.application.lorecontext;
|
||||||
|
|
||||||
import com.loremind.domain.lorecontext.Template;
|
import com.loremind.domain.lorecontext.Template;
|
||||||
import com.loremind.domain.lorecontext.TemplateField;
|
import com.loremind.domain.shared.template.TemplateField;
|
||||||
import com.loremind.domain.lorecontext.ports.TemplateRepository;
|
import com.loremind.domain.lorecontext.ports.TemplateRepository;
|
||||||
import org.junit.jupiter.api.BeforeEach;
|
import org.junit.jupiter.api.BeforeEach;
|
||||||
import org.junit.jupiter.api.Test;
|
import org.junit.jupiter.api.Test;
|
||||||
|
|||||||
@@ -0,0 +1,139 @@
|
|||||||
|
package com.loremind.domain.gamesystemcontext;
|
||||||
|
|
||||||
|
import com.loremind.domain.shared.template.FieldType;
|
||||||
|
import com.loremind.domain.shared.template.ImageLayout;
|
||||||
|
import com.loremind.domain.shared.template.TemplateField;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertThrows;
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tests unitaires du domaine GameSystem ciblant la gestion des templates PJ/PNJ.
|
||||||
|
* Le ruleset markdown est testé ailleurs via GameSystemContextSelector.
|
||||||
|
*/
|
||||||
|
class GameSystemTest {
|
||||||
|
|
||||||
|
// --- addCharacterField --------------------------------------------------
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void addCharacterField_appendsField() {
|
||||||
|
GameSystem gs = GameSystem.builder().build();
|
||||||
|
|
||||||
|
gs.addCharacterField(TemplateField.text("Histoire"));
|
||||||
|
gs.addCharacterField(TemplateField.image("Portrait", ImageLayout.HERO));
|
||||||
|
|
||||||
|
assertEquals(2, gs.getCharacterTemplate().size());
|
||||||
|
assertEquals("Histoire", gs.getCharacterTemplate().get(0).getName());
|
||||||
|
assertEquals(FieldType.IMAGE, gs.getCharacterTemplate().get(1).getType());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void addCharacterField_rejectsDuplicateNameCaseInsensitive() {
|
||||||
|
GameSystem gs = GameSystem.builder().build();
|
||||||
|
gs.addCharacterField(TemplateField.text("Histoire"));
|
||||||
|
|
||||||
|
// Doublon de cle dans Character.values garanti casse-insensible :
|
||||||
|
// "Histoire" et "histoire" produiraient la meme cle JSON.
|
||||||
|
assertThrows(IllegalArgumentException.class,
|
||||||
|
() -> gs.addCharacterField(TemplateField.number("HISTOIRE")));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void addCharacterField_rejectsBlankName() {
|
||||||
|
GameSystem gs = GameSystem.builder().build();
|
||||||
|
assertThrows(IllegalArgumentException.class,
|
||||||
|
() -> gs.addCharacterField(new TemplateField(" ", FieldType.TEXT)));
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- removeCharacterField ----------------------------------------------
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void removeCharacterField_removesByNameCaseInsensitive() {
|
||||||
|
GameSystem gs = GameSystem.builder()
|
||||||
|
.characterTemplate(new ArrayList<>(List.of(
|
||||||
|
TemplateField.text("Histoire"),
|
||||||
|
TemplateField.text("Notes")
|
||||||
|
)))
|
||||||
|
.build();
|
||||||
|
|
||||||
|
gs.removeCharacterField("HISTOIRE");
|
||||||
|
|
||||||
|
assertEquals(1, gs.getCharacterTemplate().size());
|
||||||
|
assertEquals("Notes", gs.getCharacterTemplate().get(0).getName());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void removeCharacterField_silentNoOpWhenMissing() {
|
||||||
|
GameSystem gs = GameSystem.builder()
|
||||||
|
.characterTemplate(new ArrayList<>(List.of(TemplateField.text("Histoire"))))
|
||||||
|
.build();
|
||||||
|
|
||||||
|
gs.removeCharacterField("absent");
|
||||||
|
|
||||||
|
assertEquals(1, gs.getCharacterTemplate().size());
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- replaceCharacterTemplate ------------------------------------------
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void replaceCharacterTemplate_overwritesEntireList() {
|
||||||
|
GameSystem gs = GameSystem.builder()
|
||||||
|
.characterTemplate(new ArrayList<>(List.of(TemplateField.text("Old"))))
|
||||||
|
.build();
|
||||||
|
|
||||||
|
gs.replaceCharacterTemplate(List.of(
|
||||||
|
TemplateField.text("A"),
|
||||||
|
TemplateField.number("B")));
|
||||||
|
|
||||||
|
assertEquals(2, gs.getCharacterTemplate().size());
|
||||||
|
assertEquals("A", gs.getCharacterTemplate().get(0).getName());
|
||||||
|
assertEquals("B", gs.getCharacterTemplate().get(1).getName());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void replaceCharacterTemplate_rejectsDuplicates() {
|
||||||
|
GameSystem gs = GameSystem.builder().build();
|
||||||
|
assertThrows(IllegalArgumentException.class,
|
||||||
|
() -> gs.replaceCharacterTemplate(List.of(
|
||||||
|
TemplateField.text("a"),
|
||||||
|
TemplateField.text("A"))));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void replaceCharacterTemplate_nullBecomesEmptyList() {
|
||||||
|
GameSystem gs = GameSystem.builder().build();
|
||||||
|
gs.replaceCharacterTemplate(null);
|
||||||
|
assertTrue(gs.getCharacterTemplate().isEmpty());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void replaceCharacterTemplate_isolatesInternalListFromCallerMutations() {
|
||||||
|
// Garantie d'encapsulation : muter la liste passee ne doit pas affecter le GameSystem.
|
||||||
|
List<TemplateField> external = new ArrayList<>(List.of(TemplateField.text("A")));
|
||||||
|
GameSystem gs = GameSystem.builder().build();
|
||||||
|
|
||||||
|
gs.replaceCharacterTemplate(external);
|
||||||
|
external.add(TemplateField.text("B"));
|
||||||
|
|
||||||
|
assertEquals(1, gs.getCharacterTemplate().size());
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Templates NPC : meme logique, sanity check minimal ----------------
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void npcTemplate_followsSameRulesAsCharacterTemplate() {
|
||||||
|
GameSystem gs = GameSystem.builder().build();
|
||||||
|
|
||||||
|
gs.addNpcField(TemplateField.text("Motivation"));
|
||||||
|
assertThrows(IllegalArgumentException.class,
|
||||||
|
() -> gs.addNpcField(TemplateField.text("motivation")));
|
||||||
|
|
||||||
|
gs.removeNpcField("Motivation");
|
||||||
|
assertTrue(gs.getNpcTemplate().isEmpty());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -43,6 +43,7 @@ class CampaignStructuralContextTest {
|
|||||||
"Les Ombres",
|
"Les Ombres",
|
||||||
"Une campagne dark fantasy",
|
"Une campagne dark fantasy",
|
||||||
List.of(arc),
|
List.of(arc),
|
||||||
|
List.of(),
|
||||||
List.of());
|
List.of());
|
||||||
|
|
||||||
assertEquals("Les Ombres", ctx.campaignName());
|
assertEquals("Les Ombres", ctx.campaignName());
|
||||||
|
|||||||
@@ -56,7 +56,7 @@ class ChatRequestTest {
|
|||||||
ChatRequest request = ChatRequest.builder()
|
ChatRequest request = ChatRequest.builder()
|
||||||
.messages(sampleMessages)
|
.messages(sampleMessages)
|
||||||
.campaignContext(new CampaignStructuralContext(
|
.campaignContext(new CampaignStructuralContext(
|
||||||
"Les Ombres", "...", List.of(), List.of()))
|
"Les Ombres", "...", List.of(), List.of(), List.of()))
|
||||||
.narrativeEntity(new NarrativeEntityContext(
|
.narrativeEntity(new NarrativeEntityContext(
|
||||||
"scene", "L'auberge", Map.of("location", "Taverne")))
|
"scene", "L'auberge", Map.of("location", "Taverne")))
|
||||||
.build();
|
.build();
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
package com.loremind.domain.lorecontext;
|
package com.loremind.domain.lorecontext;
|
||||||
|
|
||||||
|
import com.loremind.domain.shared.template.ImageLayout;
|
||||||
|
import com.loremind.domain.shared.template.TemplateField;
|
||||||
import org.junit.jupiter.api.Test;
|
import org.junit.jupiter.api.Test;
|
||||||
|
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
package com.loremind.domain.lorecontext;
|
package com.loremind.domain.shared.template;
|
||||||
|
|
||||||
import org.junit.jupiter.api.Test;
|
import org.junit.jupiter.api.Test;
|
||||||
|
|
||||||
@@ -167,7 +167,7 @@ class BrainChatPayloadBuilderTest {
|
|||||||
ChapterSummary chapter = new ChapterSummary("L'arrivee", "...", 0, List.of(scene));
|
ChapterSummary chapter = new ChapterSummary("L'arrivee", "...", 0, List.of(scene));
|
||||||
ArcSummary arc = new ArcSummary("Acte I", "Mise en place", 1, List.of(chapter));
|
ArcSummary arc = new ArcSummary("Acte I", "Mise en place", 1, List.of(chapter));
|
||||||
CampaignStructuralContext camp = new CampaignStructuralContext(
|
CampaignStructuralContext camp = new CampaignStructuralContext(
|
||||||
"Les Ombres", "dark fantasy", List.of(arc), List.of());
|
"Les Ombres", "dark fantasy", List.of(arc), List.of(), List.of());
|
||||||
ChatRequest req = ChatRequest.builder().messages(sampleMessages).campaignContext(camp).build();
|
ChatRequest req = ChatRequest.builder().messages(sampleMessages).campaignContext(camp).build();
|
||||||
|
|
||||||
Map<String, Object> payload = builder.build(req);
|
Map<String, Object> payload = builder.build(req);
|
||||||
@@ -200,7 +200,7 @@ class BrainChatPayloadBuilderTest {
|
|||||||
void build_arcSummary_omitsIllustrationCount_whenZero() {
|
void build_arcSummary_omitsIllustrationCount_whenZero() {
|
||||||
ArcSummary arc = new ArcSummary("A", "", 0, List.of());
|
ArcSummary arc = new ArcSummary("A", "", 0, List.of());
|
||||||
CampaignStructuralContext camp = new CampaignStructuralContext(
|
CampaignStructuralContext camp = new CampaignStructuralContext(
|
||||||
"X", "", List.of(arc), List.of());
|
"X", "", List.of(arc), List.of(), List.of());
|
||||||
ChatRequest req = ChatRequest.builder().messages(sampleMessages).campaignContext(camp).build();
|
ChatRequest req = ChatRequest.builder().messages(sampleMessages).campaignContext(camp).build();
|
||||||
|
|
||||||
Map<String, Object> payload = builder.build(req);
|
Map<String, Object> payload = builder.build(req);
|
||||||
@@ -217,7 +217,7 @@ class BrainChatPayloadBuilderTest {
|
|||||||
ChapterSummary chapter = new ChapterSummary("Ch", "", 0, List.of(scene));
|
ChapterSummary chapter = new ChapterSummary("Ch", "", 0, List.of(scene));
|
||||||
ArcSummary arc = new ArcSummary("A", "", 0, List.of(chapter));
|
ArcSummary arc = new ArcSummary("A", "", 0, List.of(chapter));
|
||||||
CampaignStructuralContext camp = new CampaignStructuralContext(
|
CampaignStructuralContext camp = new CampaignStructuralContext(
|
||||||
"X", "", List.of(arc), List.of());
|
"X", "", List.of(arc), List.of(), List.of());
|
||||||
ChatRequest req = ChatRequest.builder().messages(sampleMessages).campaignContext(camp).build();
|
ChatRequest req = ChatRequest.builder().messages(sampleMessages).campaignContext(camp).build();
|
||||||
|
|
||||||
Map<String, Object> payload = builder.build(req);
|
Map<String, Object> payload = builder.build(req);
|
||||||
@@ -236,7 +236,7 @@ class BrainChatPayloadBuilderTest {
|
|||||||
ChapterSummary chapter = new ChapterSummary("Ch", "", 0, List.of(scene));
|
ChapterSummary chapter = new ChapterSummary("Ch", "", 0, List.of(scene));
|
||||||
ArcSummary arc = new ArcSummary("A", "", 0, List.of(chapter));
|
ArcSummary arc = new ArcSummary("A", "", 0, List.of(chapter));
|
||||||
CampaignStructuralContext camp = new CampaignStructuralContext(
|
CampaignStructuralContext camp = new CampaignStructuralContext(
|
||||||
"X", "", List.of(arc), List.of());
|
"X", "", List.of(arc), List.of(), List.of());
|
||||||
ChatRequest req = ChatRequest.builder().messages(sampleMessages).campaignContext(camp).build();
|
ChatRequest req = ChatRequest.builder().messages(sampleMessages).campaignContext(camp).build();
|
||||||
|
|
||||||
Map<String, Object> payload = builder.build(req);
|
Map<String, Object> payload = builder.build(req);
|
||||||
@@ -269,7 +269,7 @@ class BrainChatPayloadBuilderTest {
|
|||||||
@Test
|
@Test
|
||||||
void build_campaignScenario_includesBothContextsAndEntity() {
|
void build_campaignScenario_includesBothContextsAndEntity() {
|
||||||
CampaignStructuralContext camp = new CampaignStructuralContext(
|
CampaignStructuralContext camp = new CampaignStructuralContext(
|
||||||
"X", "", List.of(), List.of());
|
"X", "", List.of(), List.of(), List.of());
|
||||||
NarrativeEntityContext entity = new NarrativeEntityContext("arc", "T", Map.of());
|
NarrativeEntityContext entity = new NarrativeEntityContext("arc", "T", Map.of());
|
||||||
ChatRequest req = ChatRequest.builder()
|
ChatRequest req = ChatRequest.builder()
|
||||||
.messages(sampleMessages)
|
.messages(sampleMessages)
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
package com.loremind.infrastructure.persistence.converter;
|
package com.loremind.infrastructure.persistence.converter;
|
||||||
|
|
||||||
import com.loremind.domain.lorecontext.FieldType;
|
import com.loremind.domain.shared.template.FieldType;
|
||||||
import com.loremind.domain.lorecontext.ImageLayout;
|
import com.loremind.domain.shared.template.ImageLayout;
|
||||||
import com.loremind.domain.lorecontext.TemplateField;
|
import com.loremind.domain.shared.template.TemplateField;
|
||||||
import org.junit.jupiter.api.Test;
|
import org.junit.jupiter.api.Test;
|
||||||
|
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
package com.loremind.infrastructure.persistence.postgres;
|
package com.loremind.infrastructure.persistence.postgres;
|
||||||
|
|
||||||
import com.loremind.domain.lorecontext.FieldType;
|
import com.loremind.domain.shared.template.FieldType;
|
||||||
import com.loremind.domain.lorecontext.ImageLayout;
|
import com.loremind.domain.shared.template.ImageLayout;
|
||||||
import com.loremind.domain.lorecontext.Lore;
|
import com.loremind.domain.lorecontext.Lore;
|
||||||
import com.loremind.domain.lorecontext.Template;
|
import com.loremind.domain.lorecontext.Template;
|
||||||
import com.loremind.domain.lorecontext.TemplateField;
|
import com.loremind.domain.shared.template.TemplateField;
|
||||||
import com.loremind.domain.lorecontext.ports.LoreRepository;
|
import com.loremind.domain.lorecontext.ports.LoreRepository;
|
||||||
import com.loremind.domain.lorecontext.ports.TemplateRepository;
|
import com.loremind.domain.lorecontext.ports.TemplateRepository;
|
||||||
import org.junit.jupiter.api.BeforeEach;
|
import org.junit.jupiter.api.BeforeEach;
|
||||||
|
|||||||
@@ -0,0 +1,245 @@
|
|||||||
|
package com.loremind.infrastructure.updates;
|
||||||
|
|
||||||
|
import com.loremind.application.licensing.LicenseService;
|
||||||
|
import com.loremind.infrastructure.updates.UpdateCheckService.ImageStatus;
|
||||||
|
import com.loremind.infrastructure.updates.UpdateCheckService.ImageStatusKind;
|
||||||
|
import com.loremind.infrastructure.updates.UpdateCheckService.TagsListResponse;
|
||||||
|
import com.loremind.infrastructure.updates.UpdateCheckService.UpdateStatus;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
import org.springframework.boot.info.BuildProperties;
|
||||||
|
import org.springframework.boot.web.client.RestTemplateBuilder;
|
||||||
|
import org.springframework.http.HttpMethod;
|
||||||
|
import org.springframework.http.HttpStatus;
|
||||||
|
import org.springframework.http.ResponseEntity;
|
||||||
|
import org.springframework.test.util.ReflectionTestUtils;
|
||||||
|
import org.springframework.web.client.RestTemplate;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Properties;
|
||||||
|
|
||||||
|
import static org.junit.jupiter.api.Assertions.*;
|
||||||
|
import static org.mockito.ArgumentMatchers.any;
|
||||||
|
import static org.mockito.ArgumentMatchers.eq;
|
||||||
|
import static org.mockito.Mockito.*;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tests UpdateCheckService - approche semver (post-refactor v0.8.x).
|
||||||
|
*
|
||||||
|
* Couvre :
|
||||||
|
* - feature desactivee si WATCHTOWER_TOKEN absent
|
||||||
|
* - UP_TO_DATE quand version locale == max(tags remote)
|
||||||
|
* - UPDATE_AVAILABLE quand un tag plus eleve existe
|
||||||
|
* - UNKNOWN quand le registry echoue
|
||||||
|
* - UNKNOWN quand BuildProperties est absent (currentVersion = null)
|
||||||
|
* - parseSemver / findMaxSemver / compareSemver utilitaires
|
||||||
|
*/
|
||||||
|
public class UpdateCheckServiceTest {
|
||||||
|
|
||||||
|
private static UpdateCheckService newService(String token, String currentVersion) {
|
||||||
|
BuildProperties bp = null;
|
||||||
|
if (currentVersion != null) {
|
||||||
|
Properties p = new Properties();
|
||||||
|
p.setProperty("version", currentVersion);
|
||||||
|
bp = new BuildProperties(p);
|
||||||
|
}
|
||||||
|
// licenseService null : la beta est testee separement, ces tests
|
||||||
|
// couvrent uniquement le canal stable.
|
||||||
|
return new UpdateCheckService(
|
||||||
|
new RestTemplateBuilder(),
|
||||||
|
"ghcr.io",
|
||||||
|
"igmlcreation/loremind-core,igmlcreation/loremind-brain",
|
||||||
|
"http://watchtower:8080",
|
||||||
|
token,
|
||||||
|
"",
|
||||||
|
null,
|
||||||
|
bp
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static RestTemplate stubHttp(UpdateCheckService svc) {
|
||||||
|
RestTemplate http = mock(RestTemplate.class);
|
||||||
|
ReflectionTestUtils.setField(svc, "http", http);
|
||||||
|
return http;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void stubTags(RestTemplate http, String image, List<String> tags) {
|
||||||
|
TagsListResponse body = new TagsListResponse();
|
||||||
|
body.name = image;
|
||||||
|
body.tags = tags;
|
||||||
|
ResponseEntity<TagsListResponse> resp = new ResponseEntity<>(body, HttpStatus.OK);
|
||||||
|
when(http.exchange(eq("https://ghcr.io/v2/" + image + "/tags/list"),
|
||||||
|
eq(HttpMethod.GET), any(), eq(TagsListResponse.class)))
|
||||||
|
.thenReturn(resp);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void stubTagsFailure(RestTemplate http, String image) {
|
||||||
|
when(http.exchange(eq("https://ghcr.io/v2/" + image + "/tags/list"),
|
||||||
|
eq(HttpMethod.GET), any(), eq(TagsListResponse.class)))
|
||||||
|
.thenThrow(new RuntimeException("network down"));
|
||||||
|
}
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------
|
||||||
|
// Comportement du service
|
||||||
|
// -----------------------------------------------------------------
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void disabledWhenTokenMissing() {
|
||||||
|
UpdateCheckService svc = newService("", "0.8.0");
|
||||||
|
UpdateStatus status = svc.check();
|
||||||
|
assertFalse(status.enabled());
|
||||||
|
assertFalse(status.updateAvailable());
|
||||||
|
assertFalse(status.anyUnknown());
|
||||||
|
assertTrue(status.images().isEmpty());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void upToDate_whenCurrentEqualsMaxRemote() {
|
||||||
|
UpdateCheckService svc = newService("token", "0.8.0");
|
||||||
|
RestTemplate http = stubHttp(svc);
|
||||||
|
stubTags(http, "igmlcreation/loremind-core",
|
||||||
|
List.of("0.7.0", "0.7.1", "0.7.2", "0.8.0", "latest"));
|
||||||
|
stubTags(http, "igmlcreation/loremind-brain",
|
||||||
|
List.of("0.7.0", "0.8.0", "latest"));
|
||||||
|
|
||||||
|
UpdateStatus status = svc.check();
|
||||||
|
|
||||||
|
assertTrue(status.enabled());
|
||||||
|
assertFalse(status.updateAvailable());
|
||||||
|
assertFalse(status.anyUnknown());
|
||||||
|
assertEquals("0.8.0", status.currentVersion());
|
||||||
|
for (ImageStatus img : status.images()) {
|
||||||
|
assertEquals(ImageStatusKind.UP_TO_DATE, img.status());
|
||||||
|
assertEquals("0.8.0", img.localVersion());
|
||||||
|
assertEquals("0.8.0", img.remoteVersion());
|
||||||
|
assertFalse(img.updateAvailable(), "back-compat bool");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void updateAvailable_whenRemoteHigher() {
|
||||||
|
UpdateCheckService svc = newService("token", "0.7.2");
|
||||||
|
RestTemplate http = stubHttp(svc);
|
||||||
|
stubTags(http, "igmlcreation/loremind-core",
|
||||||
|
List.of("0.7.0", "0.7.1", "0.7.2", "0.8.0", "latest"));
|
||||||
|
stubTags(http, "igmlcreation/loremind-brain",
|
||||||
|
List.of("0.7.2", "latest"));
|
||||||
|
|
||||||
|
UpdateStatus status = svc.check();
|
||||||
|
|
||||||
|
assertTrue(status.updateAvailable());
|
||||||
|
assertFalse(status.anyUnknown());
|
||||||
|
|
||||||
|
ImageStatus core = status.images().stream()
|
||||||
|
.filter(i -> i.image().endsWith("core")).findFirst().orElseThrow();
|
||||||
|
assertEquals(ImageStatusKind.UPDATE_AVAILABLE, core.status());
|
||||||
|
assertEquals("0.7.2", core.localVersion());
|
||||||
|
assertEquals("0.8.0", core.remoteVersion());
|
||||||
|
assertTrue(core.updateAvailable(), "back-compat bool");
|
||||||
|
|
||||||
|
ImageStatus brain = status.images().stream()
|
||||||
|
.filter(i -> i.image().endsWith("brain")).findFirst().orElseThrow();
|
||||||
|
assertEquals(ImageStatusKind.UP_TO_DATE, brain.status());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void unknown_whenRegistryFails() {
|
||||||
|
UpdateCheckService svc = newService("token", "0.8.0");
|
||||||
|
RestTemplate http = stubHttp(svc);
|
||||||
|
stubTagsFailure(http, "igmlcreation/loremind-core");
|
||||||
|
stubTags(http, "igmlcreation/loremind-brain", List.of("0.8.0"));
|
||||||
|
|
||||||
|
UpdateStatus status = svc.check();
|
||||||
|
|
||||||
|
assertTrue(status.anyUnknown());
|
||||||
|
ImageStatus core = status.images().stream()
|
||||||
|
.filter(i -> i.image().endsWith("core")).findFirst().orElseThrow();
|
||||||
|
assertEquals(ImageStatusKind.UNKNOWN, core.status());
|
||||||
|
assertNull(core.remoteVersion());
|
||||||
|
assertEquals("0.8.0", core.localVersion());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void unknown_whenNoValidSemverTags() {
|
||||||
|
UpdateCheckService svc = newService("token", "0.8.0");
|
||||||
|
RestTemplate http = stubHttp(svc);
|
||||||
|
stubTags(http, "igmlcreation/loremind-core", List.of("latest", "stable", "main"));
|
||||||
|
stubTags(http, "igmlcreation/loremind-brain", List.of("0.8.0"));
|
||||||
|
|
||||||
|
UpdateStatus status = svc.check();
|
||||||
|
|
||||||
|
assertTrue(status.anyUnknown());
|
||||||
|
ImageStatus core = status.images().stream()
|
||||||
|
.filter(i -> i.image().endsWith("core")).findFirst().orElseThrow();
|
||||||
|
assertEquals(ImageStatusKind.UNKNOWN, core.status());
|
||||||
|
assertNull(core.remoteVersion());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void unknown_whenBuildPropertiesAbsent() {
|
||||||
|
// INVARIANT : pas de version courante => tout UNKNOWN, jamais "a jour"
|
||||||
|
// par defaut. Evite de declarer "a jour" un build dev sans build-info.
|
||||||
|
UpdateCheckService svc = newService("token", null);
|
||||||
|
RestTemplate http = stubHttp(svc);
|
||||||
|
// Meme si on stub des tags, le service doit bypass et renvoyer UNKNOWN
|
||||||
|
stubTags(http, "igmlcreation/loremind-core", List.of("0.8.0"));
|
||||||
|
|
||||||
|
UpdateStatus status = svc.check();
|
||||||
|
|
||||||
|
assertTrue(status.enabled());
|
||||||
|
assertFalse(status.updateAvailable());
|
||||||
|
assertTrue(status.anyUnknown());
|
||||||
|
assertNull(status.currentVersion());
|
||||||
|
for (ImageStatus img : status.images()) {
|
||||||
|
assertEquals(ImageStatusKind.UNKNOWN, img.status());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------
|
||||||
|
// Utilitaires semver
|
||||||
|
// -----------------------------------------------------------------
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void parseSemver_acceptsCommonFormats() {
|
||||||
|
assertArrayEquals(new int[]{0, 8, 0}, UpdateCheckService.parseSemver("0.8.0"));
|
||||||
|
assertArrayEquals(new int[]{0, 8, 0}, UpdateCheckService.parseSemver("v0.8.0"));
|
||||||
|
assertArrayEquals(new int[]{1, 0, 0}, UpdateCheckService.parseSemver("1.0.0"));
|
||||||
|
assertArrayEquals(new int[]{0, 8, 0}, UpdateCheckService.parseSemver("0.8.0-beta.1"));
|
||||||
|
assertArrayEquals(new int[]{0, 8, 0}, UpdateCheckService.parseSemver("0.8.0+build.42"));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void parseSemver_rejectsInvalid() {
|
||||||
|
assertNull(UpdateCheckService.parseSemver(null));
|
||||||
|
assertNull(UpdateCheckService.parseSemver(""));
|
||||||
|
assertNull(UpdateCheckService.parseSemver("latest"));
|
||||||
|
assertNull(UpdateCheckService.parseSemver("stable"));
|
||||||
|
assertNull(UpdateCheckService.parseSemver("0.8.0.1.2"));
|
||||||
|
assertNull(UpdateCheckService.parseSemver("0.x.0"));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void compareSemver_basic() {
|
||||||
|
assertTrue(UpdateCheckService.compareSemver("0.7.2", "0.8.0") < 0);
|
||||||
|
assertTrue(UpdateCheckService.compareSemver("0.8.0", "0.7.2") > 0);
|
||||||
|
assertEquals(0, UpdateCheckService.compareSemver("0.8.0", "0.8.0"));
|
||||||
|
assertEquals(0, UpdateCheckService.compareSemver("v0.8.0", "0.8.0"));
|
||||||
|
assertTrue(UpdateCheckService.compareSemver("0.8.0", "0.10.0") < 0);
|
||||||
|
assertTrue(UpdateCheckService.compareSemver("1.0.0", "0.99.99") > 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void findMaxSemver_picksHighest() {
|
||||||
|
assertEquals("0.8.0", UpdateCheckService.findMaxSemver(
|
||||||
|
List.of("0.7.0", "0.7.1", "0.7.2", "0.8.0", "latest")));
|
||||||
|
assertEquals("0.10.0", UpdateCheckService.findMaxSemver(
|
||||||
|
List.of("0.8.0", "0.10.0", "0.9.5")));
|
||||||
|
assertEquals("v1.0.0", UpdateCheckService.findMaxSemver(
|
||||||
|
List.of("v0.8.0", "v1.0.0", "latest")));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void findMaxSemver_returnsNullWhenNoValidTag() {
|
||||||
|
assertNull(UpdateCheckService.findMaxSemver(List.of("latest", "stable", "main")));
|
||||||
|
assertNull(UpdateCheckService.findMaxSemver(List.of()));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,108 @@
|
|||||||
|
package com.loremind.infrastructure.web.controller;
|
||||||
|
|
||||||
|
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||||
|
import com.loremind.infrastructure.web.dto.gamesystemcontext.GameSystemDTO;
|
||||||
|
import com.loremind.infrastructure.web.dto.shared.TemplateFieldDTO;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
import org.springframework.beans.factory.annotation.Autowired;
|
||||||
|
import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc;
|
||||||
|
import org.springframework.boot.test.context.SpringBootTest;
|
||||||
|
import org.springframework.http.MediaType;
|
||||||
|
import org.springframework.test.web.servlet.MockMvc;
|
||||||
|
import org.springframework.test.web.servlet.MvcResult;
|
||||||
|
import org.springframework.transaction.annotation.Transactional;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
|
||||||
|
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
|
||||||
|
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.put;
|
||||||
|
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
|
||||||
|
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tests d'integration du GameSystemController centres sur la persistance
|
||||||
|
* des templates PJ/PNJ via l'API REST. Le CRUD de base est suppose stable.
|
||||||
|
*/
|
||||||
|
@SpringBootTest
|
||||||
|
@AutoConfigureMockMvc
|
||||||
|
@Transactional
|
||||||
|
class GameSystemControllerTest {
|
||||||
|
|
||||||
|
@Autowired private MockMvc mockMvc;
|
||||||
|
@Autowired private ObjectMapper objectMapper;
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void create_persistsCharacterAndNpcTemplates() throws Exception {
|
||||||
|
GameSystemDTO dto = new GameSystemDTO();
|
||||||
|
dto.setName("Nimble Test");
|
||||||
|
dto.setRulesMarkdown("## Combat\n- d20");
|
||||||
|
dto.setCharacterTemplate(List.of(
|
||||||
|
new TemplateFieldDTO("Histoire", "TEXT", null),
|
||||||
|
new TemplateFieldDTO("PV", "NUMBER", null),
|
||||||
|
new TemplateFieldDTO("Portrait", "IMAGE", "HERO")));
|
||||||
|
dto.setNpcTemplate(List.of(
|
||||||
|
new TemplateFieldDTO("Motivation", "TEXT", null)));
|
||||||
|
|
||||||
|
mockMvc.perform(post("/api/game-systems")
|
||||||
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
|
.content(objectMapper.writeValueAsString(dto)))
|
||||||
|
.andExpect(status().isOk())
|
||||||
|
.andExpect(jsonPath("$.id").exists())
|
||||||
|
.andExpect(jsonPath("$.characterTemplate.length()").value(3))
|
||||||
|
.andExpect(jsonPath("$.characterTemplate[1].type").value("NUMBER"))
|
||||||
|
.andExpect(jsonPath("$.characterTemplate[2].layout").value("HERO"))
|
||||||
|
.andExpect(jsonPath("$.npcTemplate.length()").value(1))
|
||||||
|
.andExpect(jsonPath("$.npcTemplate[0].name").value("Motivation"));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void update_replacesTemplates() throws Exception {
|
||||||
|
// Creation initiale avec un seul champ.
|
||||||
|
GameSystemDTO dto = new GameSystemDTO();
|
||||||
|
dto.setName("RuleSet");
|
||||||
|
dto.setCharacterTemplate(List.of(new TemplateFieldDTO("Old", "TEXT", null)));
|
||||||
|
|
||||||
|
MvcResult posted = mockMvc.perform(post("/api/game-systems")
|
||||||
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
|
.content(objectMapper.writeValueAsString(dto)))
|
||||||
|
.andExpect(status().isOk())
|
||||||
|
.andReturn();
|
||||||
|
|
||||||
|
GameSystemDTO created = objectMapper.readValue(
|
||||||
|
posted.getResponse().getContentAsString(), GameSystemDTO.class);
|
||||||
|
|
||||||
|
// Replace template integralement.
|
||||||
|
created.setCharacterTemplate(List.of(
|
||||||
|
new TemplateFieldDTO("Histoire", "TEXT", null),
|
||||||
|
new TemplateFieldDTO("Niveau", "NUMBER", null)));
|
||||||
|
|
||||||
|
mockMvc.perform(put("/api/game-systems/{id}", created.getId())
|
||||||
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
|
.content(objectMapper.writeValueAsString(created)))
|
||||||
|
.andExpect(status().isOk())
|
||||||
|
.andExpect(jsonPath("$.characterTemplate.length()").value(2))
|
||||||
|
.andExpect(jsonPath("$.characterTemplate[0].name").value("Histoire"))
|
||||||
|
.andExpect(jsonPath("$.characterTemplate[1].type").value("NUMBER"));
|
||||||
|
|
||||||
|
// Verification que le GET independant retourne bien les nouveaux champs (pas de cache stale).
|
||||||
|
mockMvc.perform(get("/api/game-systems/{id}", created.getId()))
|
||||||
|
.andExpect(status().isOk())
|
||||||
|
.andExpect(jsonPath("$.characterTemplate[?(@.name == 'Old')]").doesNotExist())
|
||||||
|
.andExpect(jsonPath("$.characterTemplate[?(@.name == 'Histoire')]").exists());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void create_rejectsDuplicateFieldNames() throws Exception {
|
||||||
|
GameSystemDTO dto = new GameSystemDTO();
|
||||||
|
dto.setName("BadRules");
|
||||||
|
dto.setCharacterTemplate(List.of(
|
||||||
|
new TemplateFieldDTO("Nom", "TEXT", null),
|
||||||
|
new TemplateFieldDTO("nom", "NUMBER", null)));
|
||||||
|
|
||||||
|
mockMvc.perform(post("/api/game-systems")
|
||||||
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
|
.content(objectMapper.writeValueAsString(dto)))
|
||||||
|
.andExpect(status().is4xxClientError());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -6,7 +6,7 @@ import com.loremind.domain.lorecontext.Template;
|
|||||||
import com.loremind.domain.lorecontext.ports.LoreRepository;
|
import com.loremind.domain.lorecontext.ports.LoreRepository;
|
||||||
import com.loremind.domain.lorecontext.ports.TemplateRepository;
|
import com.loremind.domain.lorecontext.ports.TemplateRepository;
|
||||||
import com.loremind.infrastructure.web.dto.lorecontext.TemplateDTO;
|
import com.loremind.infrastructure.web.dto.lorecontext.TemplateDTO;
|
||||||
import com.loremind.infrastructure.web.dto.lorecontext.TemplateFieldDTO;
|
import com.loremind.infrastructure.web.dto.shared.TemplateFieldDTO;
|
||||||
import org.junit.jupiter.api.BeforeEach;
|
import org.junit.jupiter.api.BeforeEach;
|
||||||
import org.junit.jupiter.api.Test;
|
import org.junit.jupiter.api.Test;
|
||||||
import org.springframework.beans.factory.annotation.Autowired;
|
import org.springframework.beans.factory.annotation.Autowired;
|
||||||
|
|||||||
@@ -60,7 +60,12 @@ services:
|
|||||||
"
|
"
|
||||||
|
|
||||||
core:
|
core:
|
||||||
image: ${REGISTRY:-git.igmlcreation.fr}/ietm64/core:${TAG:-latest}
|
# Defaut : GHCR (registry public, reputation domaine elevee).
|
||||||
|
# Pour les anciennes installs qui pointaient sur Gitea, REGISTRY et
|
||||||
|
# IMAGE_NAMESPACE peuvent etre overrides dans .env :
|
||||||
|
# REGISTRY=git.igmlcreation.fr
|
||||||
|
# IMAGE_NAMESPACE=ietm64/ (le slash final est important : voir image: ci-dessous)
|
||||||
|
image: ${REGISTRY:-ghcr.io}/${IMAGE_NAMESPACE:-igmlcreation/loremind-}core:${TAG:-latest}
|
||||||
container_name: loremind-core
|
container_name: loremind-core
|
||||||
labels:
|
labels:
|
||||||
- "com.centurylinklabs.watchtower.enable=true"
|
- "com.centurylinklabs.watchtower.enable=true"
|
||||||
@@ -84,11 +89,24 @@ services:
|
|||||||
# Detection des mises a jour : interroge le registry et delegue le pull/restart
|
# Detection des mises a jour : interroge le registry et delegue le pull/restart
|
||||||
# a Watchtower. Si WATCHTOWER_TOKEN est vide, la feature est desactivee
|
# a Watchtower. Si WATCHTOWER_TOKEN est vide, la feature est desactivee
|
||||||
# (l'UI masque le badge et le bouton).
|
# (l'UI masque le badge et le bouton).
|
||||||
UPDATE_CHECK_REGISTRY: ${REGISTRY:-git.igmlcreation.fr}
|
UPDATE_CHECK_REGISTRY: ${REGISTRY:-ghcr.io}
|
||||||
UPDATE_CHECK_IMAGES: ietm64/core,ietm64/brain,ietm64/web
|
UPDATE_CHECK_IMAGES: ${IMAGE_NAMESPACE:-igmlcreation/loremind-}core,${IMAGE_NAMESPACE:-igmlcreation/loremind-}brain,${IMAGE_NAMESPACE:-igmlcreation/loremind-}web
|
||||||
UPDATE_CHECK_TAG: ${TAG:-latest}
|
UPDATE_CHECK_TAG: ${TAG:-latest}
|
||||||
WATCHTOWER_URL: http://watchtower:8080
|
WATCHTOWER_URL: http://watchtower:8080
|
||||||
WATCHTOWER_TOKEN: ${WATCHTOWER_TOKEN:-}
|
WATCHTOWER_TOKEN: ${WATCHTOWER_TOKEN:-}
|
||||||
|
# Licensing : la cle publique JWT est embarquee dans le binaire
|
||||||
|
# (core/src/main/resources/licensing/jwt-public-key.pem).
|
||||||
|
# LICENSING_JWT_PUBLIC_KEY est un override optionnel (rotation de cle
|
||||||
|
# sans rebuild) - non defini par defaut.
|
||||||
|
LICENSING_JWT_PUBLIC_KEY: ${LICENSING_JWT_PUBLIC_KEY:-}
|
||||||
|
LICENSING_RELAY_BASE_URL: ${LICENSING_RELAY_BASE_URL:-https://loremind-auth.igmlcreation.fr}
|
||||||
|
# Chemin du docker config.json partage avec Watchtower
|
||||||
|
LICENSING_DOCKER_CONFIG_PATH: /shared/docker/config.json
|
||||||
|
volumes:
|
||||||
|
# Volume partage avec Watchtower : Core ecrit les credentials registry
|
||||||
|
# GHCR (recus du relais) ici, Watchtower les utilise pour pull les images
|
||||||
|
# privees du canal beta. Pas de creds = no-op.
|
||||||
|
- docker-config:/shared/docker
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
|
|
||||||
# Ollama embarque (option par defaut pour les utilisateurs sans Ollama installe).
|
# Ollama embarque (option par defaut pour les utilisateurs sans Ollama installe).
|
||||||
@@ -115,7 +133,7 @@ services:
|
|||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
|
|
||||||
brain:
|
brain:
|
||||||
image: ${REGISTRY:-git.igmlcreation.fr}/ietm64/brain:${TAG:-latest}
|
image: ${REGISTRY:-ghcr.io}/${IMAGE_NAMESPACE:-igmlcreation/loremind-}brain:${TAG:-latest}
|
||||||
container_name: loremind-brain
|
container_name: loremind-brain
|
||||||
labels:
|
labels:
|
||||||
- "com.centurylinklabs.watchtower.enable=true"
|
- "com.centurylinklabs.watchtower.enable=true"
|
||||||
@@ -138,7 +156,7 @@ services:
|
|||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
|
|
||||||
web:
|
web:
|
||||||
image: ${REGISTRY:-git.igmlcreation.fr}/ietm64/web:${TAG:-latest}
|
image: ${REGISTRY:-ghcr.io}/${IMAGE_NAMESPACE:-igmlcreation/loremind-}web:${TAG:-latest}
|
||||||
container_name: loremind-web
|
container_name: loremind-web
|
||||||
labels:
|
labels:
|
||||||
- "com.centurylinklabs.watchtower.enable=true"
|
- "com.centurylinklabs.watchtower.enable=true"
|
||||||
@@ -164,7 +182,14 @@ services:
|
|||||||
profiles: ["autoupdate"]
|
profiles: ["autoupdate"]
|
||||||
volumes:
|
volumes:
|
||||||
- /var/run/docker.sock:/var/run/docker.sock
|
- /var/run/docker.sock:/var/run/docker.sock
|
||||||
|
# Volume partage avec Core : credentials registry GHCR (canal beta).
|
||||||
|
# Watchtower lit le config.json depuis DOCKER_CONFIG.
|
||||||
|
- docker-config:/shared/docker
|
||||||
environment:
|
environment:
|
||||||
|
# Indique a Watchtower (et au CLI Docker embarque) ou trouver le
|
||||||
|
# config.json. Active automatiquement l'auth GHCR pour les images
|
||||||
|
# du canal beta des que Core a ecrit le fichier.
|
||||||
|
DOCKER_CONFIG: /shared/docker
|
||||||
WATCHTOWER_LABEL_ENABLE: "true"
|
WATCHTOWER_LABEL_ENABLE: "true"
|
||||||
WATCHTOWER_CLEANUP: "true"
|
WATCHTOWER_CLEANUP: "true"
|
||||||
WATCHTOWER_INCLUDE_RESTARTING: "true"
|
WATCHTOWER_INCLUDE_RESTARTING: "true"
|
||||||
@@ -186,3 +211,6 @@ volumes:
|
|||||||
minio-data:
|
minio-data:
|
||||||
brain-data:
|
brain-data:
|
||||||
ollama-data:
|
ollama-data:
|
||||||
|
# Volume partage Core <-> Watchtower : config.json Docker pour
|
||||||
|
# l'authentification au registry prive GHCR (canal beta Patreon).
|
||||||
|
docker-config:
|
||||||
|
|||||||
@@ -1,207 +0,0 @@
|
|||||||
# LoreMindMJ — Installation rapide
|
|
||||||
|
|
||||||
Ces scripts installent Docker (si nécessaire), génèrent un `.env` sécurisé
|
|
||||||
et lancent la stack. Aucune configuration manuelle requise.
|
|
||||||
|
|
||||||
## Windows 10 / 11
|
|
||||||
|
|
||||||
**Procédure recommandée :**
|
|
||||||
|
|
||||||
1. Téléchargez les trois fichiers suivants dans un même dossier
|
|
||||||
(par ex. `Téléchargements\LoreMind\`) :
|
|
||||||
- [`install.bat`](install.bat) — lanceur
|
|
||||||
- [`install.ps1`](install.ps1) — script principal
|
|
||||||
- [`secure-host-ollama.ps1`](secure-host-ollama.ps1) — *uniquement si vous avez déjà Ollama sur votre PC*
|
|
||||||
2. **Clic-droit** sur `install.bat` → **Exécuter en tant qu'administrateur**.
|
|
||||||
3. Acceptez le prompt UAC.
|
|
||||||
|
|
||||||
Le script :
|
|
||||||
1. Vérifie / installe **WSL2** (un reboot peut être nécessaire — relancer le script après).
|
|
||||||
2. Vérifie / installe **Docker Desktop** via `winget`.
|
|
||||||
3. Vous demande quelques choix (admin, fournisseur LLM, mode Ollama, mises à jour auto).
|
|
||||||
4. Génère `%LOCALAPPDATA%\LoreMind\.env` avec mots de passe aléatoires.
|
|
||||||
5. Lance la stack et ouvre `http://localhost:8081`.
|
|
||||||
|
|
||||||
Le `install.bat` sert juste à lancer `install.ps1` proprement (avec UAC + ExecutionPolicy
|
|
||||||
adaptée à la session, sans modifier les paramètres système). Il est purement
|
|
||||||
déclaratif et auditable en quelques lignes.
|
|
||||||
|
|
||||||
## Linux (Debian / Ubuntu / Fedora / Arch)
|
|
||||||
|
|
||||||
```bash
|
|
||||||
curl -fsSL https://git.igmlcreation.fr/ietm64/loremind/raw/branch/main/installers/install.sh | bash
|
|
||||||
```
|
|
||||||
|
|
||||||
Le script :
|
|
||||||
1. Installe **Docker** via le script officiel `get.docker.com` si absent.
|
|
||||||
2. Ajoute l'utilisateur courant au groupe `docker` (relogin nécessaire la 1ʳᵉ fois).
|
|
||||||
3. Installe dans `~/.local/share/loremind`.
|
|
||||||
4. Lance la stack et ouvre `http://localhost:8081`.
|
|
||||||
|
|
||||||
## Mode Ollama (moteur LLM local)
|
|
||||||
|
|
||||||
Pendant l'installation, l'installeur pose deux questions successives pour
|
|
||||||
déterminer comment LoreMind utilisera Ollama :
|
|
||||||
|
|
||||||
### 1. *« Avez-vous déjà Ollama installé sur cette machine ? »*
|
|
||||||
|
|
||||||
#### Réponse : **Oui** → mode **hôte sécurisé**
|
|
||||||
|
|
||||||
L'installeur appelle automatiquement le helper `secure-host-ollama.{sh,ps1}`
|
|
||||||
qui configure votre Ollama existant pour qu'il soit joignable par le conteneur
|
|
||||||
Docker LoreMind **sans être exposé sur le réseau local ni Internet**.
|
|
||||||
|
|
||||||
- **Linux** : Ollama écoute sur l'IP de la passerelle Docker (`172.17.0.1`
|
|
||||||
par défaut). Cette IP n'est jamais routée hors de la machine. Override
|
|
||||||
systemd écrit dans `/etc/systemd/system/ollama.service.d/loremind-host.conf`.
|
|
||||||
- **Windows** : Ollama écoute sur `0.0.0.0` (techniquement nécessaire avec
|
|
||||||
Docker Desktop) mais le pare-feu Windows est configuré pour ne **laisser
|
|
||||||
passer que** le loopback et les sous-réseaux Docker Desktop. Règles
|
|
||||||
ajoutées préfixées `LoreMind-Ollama-*`.
|
|
||||||
|
|
||||||
L'URL configurée dans `.env` est `OLLAMA_BASE_URL=http://host.docker.internal:11434`.
|
|
||||||
|
|
||||||
#### Réponse : **Non** → l'installeur pose la question 2.
|
|
||||||
|
|
||||||
### 2. *« Voulez-vous installer Ollama via Docker maintenant ? »*
|
|
||||||
|
|
||||||
#### Réponse : **Oui (défaut)** → mode **embarqué**
|
|
||||||
|
|
||||||
Un service `ollama` est ajouté à la stack via le profile Docker `local-ollama`.
|
|
||||||
Ollama tourne dans un conteneur dédié, sur le réseau interne Docker, **jamais
|
|
||||||
exposé au LAN ni à Internet**. Les modèles sont stockés dans le volume
|
|
||||||
Docker `ollama-data` (persistants entre redémarrages et mises à jour).
|
|
||||||
|
|
||||||
- URL : `OLLAMA_BASE_URL=http://ollama:11434` (DNS interne Docker).
|
|
||||||
- Aucune configuration réseau ou pare-feu requise.
|
|
||||||
- Support GPU NVIDIA automatique si disponible.
|
|
||||||
|
|
||||||
Pour télécharger un modèle :
|
|
||||||
|
|
||||||
```bash
|
|
||||||
docker exec -it loremind-ollama ollama pull gemma3:27b
|
|
||||||
docker exec -it loremind-ollama ollama list
|
|
||||||
```
|
|
||||||
|
|
||||||
#### Réponse : **Non** → mode **différé**
|
|
||||||
|
|
||||||
Aucune configuration Ollama n'est appliquée. L'installeur termine sans
|
|
||||||
Ollama. Vous configurez Ollama plus tard via la page **Paramètres** de LoreMind
|
|
||||||
en y indiquant l'URL de votre serveur Ollama.
|
|
||||||
|
|
||||||
### Lancer le helper de sécurisation manuellement
|
|
||||||
|
|
||||||
Si vous avez choisi le mode différé puis installé Ollama plus tard sur votre
|
|
||||||
poste, ou si vous voulez basculer du mode embarqué vers le mode hôte :
|
|
||||||
|
|
||||||
**Linux :**
|
|
||||||
```bash
|
|
||||||
bash secure-host-ollama.sh
|
|
||||||
# Puis dans .env du dossier d'installation :
|
|
||||||
# OLLAMA_BASE_URL=http://host.docker.internal:11434
|
|
||||||
# Et : docker compose up -d
|
|
||||||
```
|
|
||||||
|
|
||||||
**Windows (PowerShell admin) :**
|
|
||||||
```powershell
|
|
||||||
.\secure-host-ollama.ps1
|
|
||||||
# Puis editez .env (dans %LOCALAPPDATA%\LoreMind\) :
|
|
||||||
# OLLAMA_BASE_URL=http://host.docker.internal:11434
|
|
||||||
# Et : docker compose up -d
|
|
||||||
```
|
|
||||||
|
|
||||||
Les helpers sont **réexécutables sans risque** : ils suppriment leurs
|
|
||||||
anciennes règles avant de les recréer. Utile par exemple si vous avez
|
|
||||||
réinitialisé Docker Desktop et que les sous-réseaux ont changé.
|
|
||||||
|
|
||||||
### Annuler la configuration de sécurisation
|
|
||||||
|
|
||||||
**Linux :**
|
|
||||||
```bash
|
|
||||||
sudo rm /etc/systemd/system/ollama.service.d/loremind-host.conf
|
|
||||||
sudo systemctl daemon-reload && sudo systemctl restart ollama
|
|
||||||
```
|
|
||||||
|
|
||||||
**Windows (PowerShell admin) :**
|
|
||||||
```powershell
|
|
||||||
Get-NetFirewallRule -DisplayName "LoreMind-Ollama-*" | Remove-NetFirewallRule
|
|
||||||
[Environment]::SetEnvironmentVariable("OLLAMA_HOST", $null, "User")
|
|
||||||
```
|
|
||||||
|
|
||||||
## Variables disponibles
|
|
||||||
|
|
||||||
| Variable | Défaut | Effet |
|
|
||||||
|-------------------|---------------------------------|----------------------------------------|
|
|
||||||
| `WEB_PORT` | `8081` | Port HTTP de l'UI |
|
|
||||||
| `INSTALL_DIR` | `~/.local/share/loremind` (Lin) | Dossier d'installation |
|
|
||||||
| `NON_INTERACTIVE` | `0` | `1` = aucune question, valeurs par défaut |
|
|
||||||
|
|
||||||
Exemple Linux non-interactif sur port 9000 :
|
|
||||||
|
|
||||||
```bash
|
|
||||||
WEB_PORT=9000 NON_INTERACTIVE=1 bash install.sh
|
|
||||||
```
|
|
||||||
|
|
||||||
## Mises à jour automatiques (Watchtower)
|
|
||||||
|
|
||||||
Si vous avez répondu **oui** à la question "Activer les mises à jour auto",
|
|
||||||
un container [Watchtower](https://containrrr.dev/watchtower/) est lancé en
|
|
||||||
parallèle. Il vérifie chaque nuit à 4h les nouvelles versions de
|
|
||||||
`core`, `brain` et `web` sur le registry, télécharge et redémarre les
|
|
||||||
conteneurs concernés. **Postgres et MinIO sont volontairement exclus**
|
|
||||||
(données persistantes — montée de version à valider manuellement).
|
|
||||||
|
|
||||||
### Activer / désactiver après coup
|
|
||||||
|
|
||||||
Éditer `.env` dans le dossier d'installation :
|
|
||||||
|
|
||||||
```env
|
|
||||||
COMPOSE_PROFILES=autoupdate # active
|
|
||||||
COMPOSE_PROFILES= # desactive
|
|
||||||
```
|
|
||||||
|
|
||||||
Puis :
|
|
||||||
|
|
||||||
```bash
|
|
||||||
docker compose up -d # applique le changement
|
|
||||||
docker compose stop watchtower # si on vient de le desactiver
|
|
||||||
```
|
|
||||||
|
|
||||||
### Changer l'horaire
|
|
||||||
|
|
||||||
`WATCHTOWER_SCHEDULE` dans `.env` accepte la syntaxe
|
|
||||||
[cron 6 champs](https://pkg.go.dev/github.com/robfig/cron) (sec min h jour mois j-sem).
|
|
||||||
Exemples : `0 0 4 * * *` (4h du matin, défaut), `0 30 3 * * 0` (dimanche 3h30).
|
|
||||||
|
|
||||||
### Mode "notification seulement" (sans auto-apply)
|
|
||||||
|
|
||||||
Si vous préférez être notifié *sans* que les conteneurs redémarrent
|
|
||||||
automatiquement la nuit, éditez `.env` :
|
|
||||||
|
|
||||||
```env
|
|
||||||
WATCHTOWER_MONITOR_ONLY=true
|
|
||||||
```
|
|
||||||
|
|
||||||
Puis `docker compose up -d watchtower`. Watchtower continuera à vérifier
|
|
||||||
le registry chaque nuit, le badge **MAJ** apparaîtra dans la sidebar de
|
|
||||||
l'UI, et un bouton **Mettre à jour maintenant** sera disponible dans
|
|
||||||
*Paramètres → Mises à jour*.
|
|
||||||
|
|
||||||
### Mise à jour manuelle (à tout moment)
|
|
||||||
|
|
||||||
Depuis l'interface : *Paramètres → Mises à jour → Mettre à jour maintenant*.
|
|
||||||
|
|
||||||
Ou en CLI :
|
|
||||||
|
|
||||||
```bash
|
|
||||||
docker compose pull && docker compose up -d
|
|
||||||
```
|
|
||||||
|
|
||||||
## Désinstallation
|
|
||||||
|
|
||||||
```bash
|
|
||||||
cd <dossier d'install>
|
|
||||||
docker compose down -v # -v supprime aussi les volumes (données effacées !)
|
|
||||||
```
|
|
||||||
|
|
||||||
Puis supprimer le dossier d'installation.
|
|
||||||
@@ -40,16 +40,16 @@
|
|||||||
Auteur : ietm64
|
Auteur : ietm64
|
||||||
Licence : AGPL-3.0
|
Licence : AGPL-3.0
|
||||||
Projet : LoreMindMJ - assistant pour Maitres de Jeu de JDR
|
Projet : LoreMindMJ - assistant pour Maitres de Jeu de JDR
|
||||||
Version : 0.6.12
|
Version : 0.8.1
|
||||||
|
|
||||||
.LINK
|
.LINK
|
||||||
https://git.igmlcreation.fr/ietm64/loremind
|
https://github.com/IGMLcreation/LoreMind
|
||||||
#>
|
#>
|
||||||
|
|
||||||
[CmdletBinding()]
|
[CmdletBinding()]
|
||||||
param(
|
param(
|
||||||
[string]$InstallDir = "$env:LOCALAPPDATA\LoreMind",
|
[string]$InstallDir = "$env:LOCALAPPDATA\LoreMind",
|
||||||
[string]$ComposeUrl = "https://git.igmlcreation.fr/ietm64/loremind/raw/branch/main/docker-compose.yml",
|
[string]$ComposeUrl = "https://raw.githubusercontent.com/IGMLcreation/LoreMind/main/docker-compose.yml",
|
||||||
[int]$WebPort = 8081,
|
[int]$WebPort = 8081,
|
||||||
[switch]$NonInteractive
|
[switch]$NonInteractive
|
||||||
)
|
)
|
||||||
@@ -316,7 +316,8 @@ $composeProfiles = $profilesList -join ','
|
|||||||
|
|
||||||
$envContent = @"
|
$envContent = @"
|
||||||
# Genere par install.ps1 le $(Get-Date -Format 'yyyy-MM-dd HH:mm')
|
# Genere par install.ps1 le $(Get-Date -Format 'yyyy-MM-dd HH:mm')
|
||||||
REGISTRY=git.igmlcreation.fr
|
REGISTRY=ghcr.io
|
||||||
|
IMAGE_NAMESPACE=igmlcreation/loremind-
|
||||||
TAG=latest
|
TAG=latest
|
||||||
|
|
||||||
WEB_PORT=$WebPort
|
WEB_PORT=$WebPort
|
||||||
|
|||||||
@@ -2,12 +2,12 @@
|
|||||||
# ==========================================================================
|
# ==========================================================================
|
||||||
# Installeur LoreMindMJ pour Linux (Debian/Ubuntu/Fedora/Arch)
|
# Installeur LoreMindMJ pour Linux (Debian/Ubuntu/Fedora/Arch)
|
||||||
# Usage :
|
# Usage :
|
||||||
# curl -fsSL https://git.igmlcreation.fr/ietm64/loremind/raw/branch/main/installers/install.sh | bash
|
# curl -fsSL https://raw.githubusercontent.com/IGMLcreation/LoreMind/main/installers/install.sh | bash
|
||||||
# ==========================================================================
|
# ==========================================================================
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
|
|
||||||
INSTALL_DIR="${INSTALL_DIR:-$HOME/.local/share/loremind}"
|
INSTALL_DIR="${INSTALL_DIR:-$HOME/.local/share/loremind}"
|
||||||
COMPOSE_URL="${COMPOSE_URL:-https://git.igmlcreation.fr/ietm64/loremind/raw/branch/main/docker-compose.yml}"
|
COMPOSE_URL="${COMPOSE_URL:-https://raw.githubusercontent.com/IGMLcreation/LoreMind/main/docker-compose.yml}"
|
||||||
WEB_PORT="${WEB_PORT:-8081}"
|
WEB_PORT="${WEB_PORT:-8081}"
|
||||||
NON_INTERACTIVE="${NON_INTERACTIVE:-0}"
|
NON_INTERACTIVE="${NON_INTERACTIVE:-0}"
|
||||||
|
|
||||||
@@ -190,7 +190,8 @@ COMPOSE_PROFILES="$(IFS=,; echo "${PROFILES_ARR[*]}")"
|
|||||||
|
|
||||||
cat > .env <<EOF
|
cat > .env <<EOF
|
||||||
# Genere par install.sh le $(date '+%Y-%m-%d %H:%M')
|
# Genere par install.sh le $(date '+%Y-%m-%d %H:%M')
|
||||||
REGISTRY=git.igmlcreation.fr
|
REGISTRY=ghcr.io
|
||||||
|
IMAGE_NAMESPACE=igmlcreation/loremind-
|
||||||
TAG=latest
|
TAG=latest
|
||||||
|
|
||||||
WEB_PORT=${WEB_PORT}
|
WEB_PORT=${WEB_PORT}
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user