feat: add Java→Python migration plan with 9 self-contained phase files

Converts Spring Boot 4.0.3 ARMA Server Web GUI to FastAPI/Python.
Each phase file is fully self-contained: lists Java source files to
read, output files to create, implementation patterns, REST endpoint
contracts, and a completion checklist. A future agent can execute any
single phase without rescanning the Java project.

Phases:
- 01: Foundation — SQLAlchemy models, Alembic, settings, base schemas
- 02: Auth & Users — JWT middleware, RBAC, user CRUD
- 03: CFG parser + server process — server.cfg round-trip, start/stop
- 04: Server settings — general/network/logging/security/difficulty
- 05: Mod management — mod CRUD, presets, settings, WebSocket progress
- 06: Steam integration — SteamCMD queue, Workshop API, python-a2s
- 07: Missions, CDLC, Discord, APScheduler jobs
- 08: Middleware & polish — global exception handler, SPA redirect, structlog
- 09: Testing — pytest-asyncio, respx, 80% coverage target
This commit is contained in:
Khoa (Revenovich) Tran Gia
2026-04-14 15:06:56 +07:00
commit e02db3ddde
10 changed files with 2817 additions and 0 deletions

View File

@@ -0,0 +1,313 @@
# Phase 7 — Missions, CDLC, Discord, Jobs
**Status**: PENDING
**Depends on**: Phase 3 (ProcessService, ServerConfigStorage); Phase 4 (DifficultyScanJob stub); Phase 5 (ModService, mod jobs stubs); Phase 6 (SteamService, run_task_loop)
**Next phase**: `phase-08-middleware-polish.md`
---
## Goal
Mission CRUD + file upload, CDLC management, Discord webhook notifications, and all APScheduler jobs registered. After this phase every domain feature is complete and the scheduler drives automated background work.
---
## Java Source Files to Read
`<JAVA_SRC>` = `E:\TestScript\ARMA-Server-Web-Gui\src\main\java\pl\bartlomiejstepien\armaserverwebgui\`
| File | What to extract |
|------|----------------|
| `<JAVA_SRC>domain/server/mission/MissionServiceImpl.java` | getMissions(), saveEnabledMissionList(), addMission(), deleteMission(), updateMission(), save(MultipartFile), checkMissionFileExists() |
| `<JAVA_SRC>domain/server/mission/VanillaMissionsImporter.java` | Import built-in missions on startup |
| `<JAVA_SRC>domain/server/mission/dto/Mission.java` | Fields: id, name, template, enabled, difficulty |
| `<JAVA_SRC>domain/server/mission/dto/Missions.java` | Fields: disabledMissions, enabledMissions |
| `<JAVA_SRC>domain/server/mission/job/MissionScannerJob.java` | Filesystem scan + DB sync logic |
| `<JAVA_SRC>domain/server/cdlc/CdlcService.java` | findAll(), toggleCdlc(id) |
| `<JAVA_SRC>domain/server/cdlc/CdlcFileStorageService.java` | Filesystem existence check |
| `<JAVA_SRC>domain/server/cdlc/dto/Cdlc.java` | Fields: id, name, enabled, fileExists |
| `<JAVA_SRC>domain/discord/DiscordIntegration.java` | sendMessage(MessageKind) — async, checks enabled flag |
| `<JAVA_SRC>domain/discord/DiscordWebhookHandler.java` | HTTP POST to webhook URL |
| `<JAVA_SRC>domain/discord/message/MessageKind.java` | Enum: SERVER_STARTED, SERVER_STARTING, SERVER_STOPPED, SERVER_UPDATING, PLAYER_JOINED |
| `<JAVA_SRC>domain/discord/message/ServerStartedMessageCreator.java` | Message text |
| `<JAVA_SRC>domain/discord/message/ServerStoppedMessageCreator.java` | Message text |
| `<JAVA_SRC>domain/discord/model/DiscordMessage.java` | Fields: username, content, embeds:[{title,description,color}] |
| `<JAVA_SRC>application/scheduling/AswgJob.java` | Base job interface: getName(), run() |
| `<JAVA_SRC>application/scheduling/AswgTaskScheduler.java` | schedule(job, cron), runNow(job), cancel(name) |
| `<JAVA_SRC>application/scheduling/JobExecutionInfoService.java` | saveJobExecution(), getJobHistory() |
| `<JAVA_SRC>application/scheduling/dto/JobExecution.java` | Fields: jobName, startDate, finishDate, status, message |
| `<JAVA_SRC>domain/server/difficulty/DifficultyScanJob.java` | Scan Users/*.Arma3Profile + DB sync |
| `<JAVA_SRC>domain/server/mod/job/InstallDeleteModsFromFilesystemJob.java` | Scan mods/ + DB sync |
| `<JAVA_SRC>domain/server/mod/job/ModSettingsScanJob.java` | Scan mod dirs for settings files |
| `<JAVA_SRC>domain/server/mod/job/ModUpdateJob.java` | Check Steam API lastUpdated per mod |
| `<JAVA_SRC>application/security/jwt/cleaner/InvalidJwtCleaner.java` | Delete expired JWT rows from DB |
| `<JAVA_SRC>web/MissionRestController.java` | Exact paths and JSON shapes |
| `<JAVA_SRC>web/MissionFilesRestController.java` | Mission file upload |
| `<JAVA_SRC>web/CdlcRestController.java` | GET + POST toggle |
---
## Output Files to Create
```
src/domain/server/mission/__init__.py
src/domain/server/mission/mission_service.py
src/domain/server/mission/mission_file_storage.py (scan MPMissions/ for .pbo files)
src/domain/server/mission/vanilla_missions_importer.py
src/domain/server/mission/jobs.py (MissionScannerJob)
src/domain/server/mission/models.py (Mission, Missions dataclasses)
src/domain/server/cdlc/__init__.py
src/domain/server/cdlc/cdlc_service.py
src/domain/server/cdlc/cdlc_file_storage.py
src/domain/server/cdlc/models.py (Cdlc dataclass)
src/domain/discord/__init__.py
src/domain/discord/discord_integration.py
src/domain/discord/discord_webhook_handler.py
src/domain/discord/models.py (DiscordMessage, MessageKind enum)
src/application/scheduler/__init__.py
src/application/scheduler/scheduler.py (APScheduler setup + job registry)
src/application/scheduler/job_execution_service.py
src/web/schemas/missions.py
src/web/schemas/cdlc.py
src/web/mission_router.py
src/web/mission_files_router.py
src/web/cdlc_router.py
```
Update `src/main.py`:
- Register `mission_router`, `mission_files_router`, `cdlc_router`
- In `lifespan`: `scheduler.start()`, register all 6 jobs, call `vanilla_missions_importer.run()` on first startup
- In `lifespan` teardown: `scheduler.shutdown()`
---
## Implementation Notes
### APScheduler setup
```python
# src/application/scheduler/scheduler.py
from apscheduler.schedulers.asyncio import AsyncIOScheduler
from apscheduler.triggers.cron import CronTrigger
scheduler = AsyncIOScheduler()
def schedule_job(job_fn, cron: str, job_id: str) -> None:
try:
scheduler.remove_job(job_id)
except Exception:
pass
scheduler.add_job(job_fn, CronTrigger.from_crontab(cron), id=job_id,
misfire_grace_time=60)
```
Register in lifespan (cron strings come from `settings.*_cron` properties):
```python
scheduler.start()
schedule_job(difficulty_scan_job, settings.difficulty_scan_cron, "difficulty_scan")
schedule_job(mission_scanner_job, settings.mission_scan_cron, "mission_scan")
schedule_job(mod_filesystem_job, settings.mod_scan_cron, "mod_filesystem_scan")
schedule_job(mod_settings_scan_job,settings.mod_settings_scan_cron, "mod_settings_scan")
schedule_job(mod_update_job, settings.mod_update_cron, "mod_update")
schedule_job(jwt_cleaner_job, "0 * * * *", "jwt_cleaner")
```
Read the cron expressions from `aswg-default-config.properties` (Java file) and add them as `settings` fields.
### Job execution tracking
Each job records execution in the `job_execution` table:
```python
# src/application/scheduler/job_execution_service.py
async def wrap_job(job_name: str, job_fn) -> None:
start = datetime.utcnow()
status, message = "SUCCESS", None
try:
await job_fn()
except Exception as e:
status, message = "FAILED", str(e)
log.error("Job %s failed: %s", job_name, e)
finally:
await job_execution_repo.save(JobExecutionEntity(
job_name=job_name,
start_date=start,
finish_date=datetime.utcnow(),
status=status,
message=message,
))
```
### MissionService
Missions are `.pbo` files in `<server_dir>/MPMissions/`.
```python
async def get_missions(self) -> Missions:
db_missions = await self.mission_repo.find_all()
return Missions(
disabled_missions=[m for m in db_missions if not m.enabled],
enabled_missions=[m for m in db_missions if m.enabled],
)
async def save_enabled_mission_list(self, missions: list[Mission]) -> None:
await self.mission_repo.disable_all()
for m in missions:
await self.mission_repo.enable_by_template(m.template)
await self.mission_repo.update_difficulty(m.template, m.difficulty)
# Write missions block to server.cfg via ServerConfigStorage
cfg = self.config_storage.read()
cfg.missions = [CfgMission(template=m.template, difficulty=m.difficulty)
for m in missions]
self.config_storage.write(cfg)
async def add_mission(self, name: str, template: str) -> None:
await self.mission_repo.save(MissionEntity(name=name, template=template,
enabled=False, difficulty="Regular"))
```
`template` format: `"filename.MapName"` e.g. `"tdm_stratis.Stratis"` (`.pbo` extension excluded).
### MissionScannerJob
Scans `<server_dir>/MPMissions/` for `*.pbo` files. Derives template name by stripping `.pbo`. Inserts missing DB entries, removes DB entries where file is gone.
### VanillaMissionsImporter
Reads a bundled JSON/text list of vanilla Arma 3 mission templates. On first startup (`@app.on_event` / lifespan), insert any missing. Check existence with `mission_repo.find_by_template()`.
### CDLC service
```python
async def find_all(self) -> list[Cdlc]:
entities = await self.cdlc_repo.find_all()
return [
Cdlc(
id=e.id, name=e.name, enabled=e.enabled,
file_exists=(Path(settings.server_directory_path) / e.name).exists()
)
for e in entities
]
async def toggle_cdlc(self, id: int) -> None:
entity = await self.cdlc_repo.find_by_id(id)
entity.enabled = not entity.enabled
await self.cdlc_repo.save(entity)
```
### Discord Integration
```python
# src/domain/discord/models.py
from enum import StrEnum
from dataclasses import dataclass
class MessageKind(StrEnum):
SERVER_STARTED = "SERVER_STARTED"
SERVER_STARTING = "SERVER_STARTING"
SERVER_STOPPED = "SERVER_STOPPED"
SERVER_UPDATING = "SERVER_UPDATING"
PLAYER_JOINED = "PLAYER_JOINED"
@dataclass
class DiscordMessage:
username: str
content: str
embeds: list[dict]
def to_dict(self) -> dict:
return {"username": self.username, "content": self.content,
"embeds": self.embeds}
```
```python
# src/domain/discord/discord_integration.py
import asyncio
_MESSAGE_TEXTS = {
MessageKind.SERVER_STARTED: "Server has started.",
MessageKind.SERVER_STARTING: "Server is starting...",
MessageKind.SERVER_STOPPED: "Server has stopped.",
MessageKind.SERVER_UPDATING: "Server is updating...",
}
class DiscordIntegration:
async def send_message(self, kind: MessageKind) -> None:
if not settings.discord_enabled:
return
text = _MESSAGE_TEXTS.get(kind, kind.value)
msg = DiscordMessage(username="ASWG", content=text, embeds=[])
asyncio.create_task(self._webhook.send(msg)) # fire-and-forget
class DiscordWebhookHandler:
async def send(self, message: DiscordMessage) -> None:
try:
async with httpx.AsyncClient() as client:
await client.post(settings.discord_webhook_url,
json=message.to_dict(), timeout=10.0)
except Exception as e:
log.warning("Discord webhook failed: %s", e)
```
Wire into `ProcessService` (edit Phase 3's `process_service.py`):
- After `start_server()` completes: `await discord.send_message(MessageKind.SERVER_STARTED)`
- After `stop_server()`: `await discord.send_message(MessageKind.SERVER_STOPPED)`
### REST Endpoints
**Missions** (`/api/v1/missions`)
```
GET /api/v1/missions → {disabledMissions, enabledMissions} MISSION_VIEW
POST /api/v1/missions/enabled body:{missions:[{name,template,difficulty,enabled}]} MISSION_UPDATE → 200
POST /api/v1/missions/template body:{name,template} MISSION_ADD → 200
DELETE /api/v1/missions/template body:{template} MISSION_DELETE → 200
PUT /api/v1/missions/id/{id} body:Mission MISSION_UPDATE → 200
```
**Mission Files** (`/api/v1/missions-files`)
```
POST /api/v1/missions-files multipart: file(s) + overwrite? MISSION_UPLOAD → 200
GET /api/v1/missions-files/{name}/exists → {exists: bool} MISSION_VIEW
```
**CDLC** (`/api/v1/cdlc`)
```
GET /api/v1/cdlc → {cdlcs:[{id,name,enabled,fileExists}]} CDLC_VIEW
POST /api/v1/cdlc/{id}/toggle → 200 CDLC_UPDATE
```
### JSON shapes
Mission:
```json
{"id": 1, "name": "TDM Stratis", "template": "tdm_stratis.Stratis", "enabled": true, "difficulty": "Regular"}
```
CDLC:
```json
{"id": 1, "name": "gm", "enabled": false, "fileExists": true}
```
---
## Completion Checklist
- [ ] `GET /api/v1/missions` returns `{disabledMissions, enabledMissions}`
- [ ] `POST /api/v1/missions/enabled` persists and writes mission block to server.cfg
- [ ] `POST /api/v1/missions/template` inserts mission row with `enabled=false`
- [ ] `POST /api/v1/missions-files` saves .pbo and inserts DB record
- [ ] `GET /api/v1/cdlc` computes `fileExists` from filesystem at request time
- [ ] `POST /api/v1/cdlc/{id}/toggle` flips enabled flag
- [ ] APScheduler starts in lifespan; all 6 jobs registered with cron from settings
- [ ] Each job records execution row in `job_execution` table
- [ ] Discord fires on server start/stop when `discord.enabled=true`
- [ ] `InvalidJwtCleaner` hourly job deletes rows where `expiration_datetime < now()`
- [ ] `VanillaMissionsImporter` inserts default missions on first startup
## Contract for Phase 8
Phase 8 adds no new service classes. It wraps everything built in Phases 17 with:
- Global exception handler (FastAPI `@app.exception_handler`)
- SPA redirect middleware (non-API paths → `index.html`)
- Optional rate limiting middleware
- Structured logging via `structlog`