first commit

This commit is contained in:
Chris
2026-03-23 20:23:58 +08:00
commit 74d612aca1
3193 changed files with 692056 additions and 0 deletions

1
backend/app/__init__.py Normal file
View File

@@ -0,0 +1 @@
"""mkt.ose.tw FastAPI application package."""

Binary file not shown.

Binary file not shown.

View File

@@ -0,0 +1 @@
"""API routers."""

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

@@ -0,0 +1 @@
"""Admin API routers."""

View File

@@ -0,0 +1,76 @@
from fastapi import APIRouter, Depends, HTTPException, status
from app.api.dependencies.auth import get_access_token
from app.api.dependencies.permissions import require_permission
from app.application.admin.activity import ActivityService
from app.application.admin.experiments import ExperimentService
from app.schemas.auth import AuthenticatedUser
from app.schemas.admin import ActivityLogListResponse, ExperimentCreate, ExperimentListResponse, ExperimentRead, ExperimentUpdate
router = APIRouter()
service = ExperimentService()
activity_service = ActivityService()
@router.get("", response_model=ExperimentListResponse)
async def list_experiments(
access_token: str = Depends(get_access_token),
_: AuthenticatedUser = Depends(require_permission("can_manage_experiments")),
) -> ExperimentListResponse:
# Admin list should always come from the application/service layer,
# not from routes talking to Directus directly.
items = await service.list_experiments(access_token=access_token)
return ExperimentListResponse(items=items)
@router.get("/{experiment_id}", response_model=ExperimentRead)
async def get_experiment(
experiment_id: str,
access_token: str = Depends(get_access_token),
_: AuthenticatedUser = Depends(require_permission("can_manage_experiments")),
) -> ExperimentRead:
item = await service.get_experiment(experiment_id, access_token=access_token)
if not item:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Experiment '{experiment_id}' not found.",
)
return item
@router.post("", response_model=ExperimentRead, status_code=status.HTTP_201_CREATED)
async def create_experiment(
payload: ExperimentCreate,
access_token: str = Depends(get_access_token),
_: AuthenticatedUser = Depends(require_permission("can_manage_experiments")),
) -> ExperimentRead:
return await service.create_experiment(payload, access_token=access_token)
@router.patch("/{experiment_id}", response_model=ExperimentRead)
async def update_experiment(
experiment_id: str,
payload: ExperimentUpdate,
access_token: str = Depends(get_access_token),
_: AuthenticatedUser = Depends(require_permission("can_manage_experiments")),
) -> ExperimentRead:
item = await service.update_experiment(experiment_id, payload, access_token=access_token)
if not item:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Experiment '{experiment_id}' not found.",
)
return item
@router.get("/{experiment_id}/activity", response_model=ActivityLogListResponse)
async def list_experiment_activity(
experiment_id: str,
access_token: str = Depends(get_access_token),
_: AuthenticatedUser = Depends(require_permission("can_manage_experiments")),
) -> ActivityLogListResponse:
items = await activity_service.list_for_experiment(
experiment_id=experiment_id,
access_token=access_token,
)
return ActivityLogListResponse(items=items)

View File

@@ -0,0 +1,35 @@
from fastapi import APIRouter, Depends, HTTPException, Query, status
from app.api.dependencies.auth import get_access_token
from app.api.dependencies.permissions import require_permission
from app.application.admin.goals import GoalService
from app.schemas.auth import AuthenticatedUser
from app.schemas.admin import GoalListResponse, GoalRead
router = APIRouter()
service = GoalService()
@router.get("", response_model=GoalListResponse)
async def list_goals(
site_id: str | None = Query(default=None),
access_token: str = Depends(get_access_token),
_: AuthenticatedUser = Depends(require_permission("can_manage_goals")),
) -> GoalListResponse:
items = await service.list_goals(site_id=site_id, access_token=access_token)
return GoalListResponse(items=items)
@router.get("/{goal_id}", response_model=GoalRead)
async def get_goal(
goal_id: str,
access_token: str = Depends(get_access_token),
_: AuthenticatedUser = Depends(require_permission("can_manage_goals")),
) -> GoalRead:
item = await service.get_goal(goal_id, access_token=access_token)
if not item:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Goal '{goal_id}' not found.",
)
return item

View File

@@ -0,0 +1,32 @@
from fastapi import APIRouter, Depends, HTTPException, status
from app.api.dependencies.permissions import require_permission
from app.application.admin.marketing_cards import MarketingCardService
from app.schemas.auth import AuthenticatedUser
from app.schemas.marketing_card import MarketingCardListResponse, MarketingCardRead
router = APIRouter()
service = MarketingCardService()
@router.get("", response_model=MarketingCardListResponse)
async def list_marketing_cards(
_: AuthenticatedUser = Depends(require_permission("can_manage_experiments")),
) -> MarketingCardListResponse:
items = await service.list_marketing_cards()
return MarketingCardListResponse(items=items)
@router.get("/{card_id}", response_model=MarketingCardRead)
async def get_marketing_card(
card_id: str,
_: AuthenticatedUser = Depends(require_permission("can_manage_experiments")),
) -> MarketingCardRead:
item = await service.get_marketing_card(card_id)
if not item:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Marketing card '{card_id}' not found.",
)
return item

View File

@@ -0,0 +1,77 @@
from fastapi import APIRouter, Depends, HTTPException, Query, status
from app.api.dependencies.auth import get_access_token
from app.api.dependencies.permissions import require_permission
from app.application.admin.releases import ReleaseService
from app.schemas.auth import AuthenticatedUser
from app.schemas.admin import BuildReleaseRequest, ReleaseLifecycleResponse, ReleaseListResponse, ReleaseRead
router = APIRouter()
service = ReleaseService()
@router.get("", response_model=ReleaseListResponse)
async def list_releases(
experiment_id: str | None = Query(default=None),
access_token: str = Depends(get_access_token),
_: AuthenticatedUser = Depends(require_permission("can_manage_releases")),
) -> ReleaseListResponse:
items = await service.list_releases(experiment_id=experiment_id, access_token=access_token)
return ReleaseListResponse(items=items)
@router.get("/{release_id}", response_model=ReleaseRead)
async def get_release(
release_id: str,
access_token: str = Depends(get_access_token),
_: AuthenticatedUser = Depends(require_permission("can_manage_releases")),
) -> ReleaseRead:
item = await service.get_release(release_id, access_token=access_token)
if not item:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=f"Release '{release_id}' not found.")
return item
@router.post("/build", response_model=ReleaseRead, status_code=status.HTTP_201_CREATED)
async def build_release(
payload: BuildReleaseRequest,
access_token: str = Depends(get_access_token),
_: AuthenticatedUser = Depends(require_permission("can_manage_releases")),
) -> ReleaseRead:
return await service.build_release(payload, access_token=access_token)
@router.post("/{release_id}/publish", response_model=ReleaseLifecycleResponse)
async def publish_release(
release_id: str,
access_token: str = Depends(get_access_token),
_: AuthenticatedUser = Depends(require_permission("can_manage_releases")),
) -> ReleaseLifecycleResponse:
item = await service.publish_release(release_id, access_token=access_token)
if not item:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=f"Release '{release_id}' not found.")
return item
@router.post("/{release_id}/rollback", response_model=ReleaseLifecycleResponse)
async def rollback_release(
release_id: str,
access_token: str = Depends(get_access_token),
_: AuthenticatedUser = Depends(require_permission("can_manage_releases")),
) -> ReleaseLifecycleResponse:
item = await service.rollback_release(release_id, access_token=access_token)
if not item:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=f"Release '{release_id}' not found.")
return item
@router.post("/{release_id}/archive", response_model=ReleaseLifecycleResponse)
async def archive_release(
release_id: str,
access_token: str = Depends(get_access_token),
_: AuthenticatedUser = Depends(require_permission("can_manage_releases")),
) -> ReleaseLifecycleResponse:
item = await service.archive_release(release_id, access_token=access_token)
if not item:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=f"Release '{release_id}' not found.")
return item

View File

@@ -0,0 +1,18 @@
from fastapi import APIRouter
from app.api.admin import (
experiments,
goals,
releases,
sdk_configs,
sites,
variants,
)
router = APIRouter()
router.include_router(sites.router, prefix="/sites")
router.include_router(experiments.router, prefix="/experiments")
router.include_router(variants.router, prefix="/variants")
router.include_router(releases.router, prefix="/releases")
router.include_router(goals.router, prefix="/goals")
router.include_router(sdk_configs.router, prefix="/sdk-configs")

View File

@@ -0,0 +1,34 @@
from fastapi import APIRouter, Depends, HTTPException, status
from app.api.dependencies.auth import get_access_token
from app.api.dependencies.permissions import require_permission
from app.application.admin.sdk_configs import SdkConfigService
from app.schemas.auth import AuthenticatedUser
from app.schemas.admin import SdkConfigListResponse, SdkConfigRead
router = APIRouter()
service = SdkConfigService()
@router.get("", response_model=SdkConfigListResponse)
async def list_sdk_configs(
access_token: str = Depends(get_access_token),
_: AuthenticatedUser = Depends(require_permission("can_manage_sdk_configs")),
) -> SdkConfigListResponse:
items = await service.list_sdk_configs(access_token=access_token)
return SdkConfigListResponse(items=items)
@router.get("/{sdk_config_id}", response_model=SdkConfigRead)
async def get_sdk_config(
sdk_config_id: str,
access_token: str = Depends(get_access_token),
_: AuthenticatedUser = Depends(require_permission("can_manage_sdk_configs")),
) -> SdkConfigRead:
item = await service.get_sdk_config(sdk_config_id, access_token=access_token)
if not item:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"SDK config '{sdk_config_id}' not found.",
)
return item

View File

@@ -0,0 +1,34 @@
from fastapi import APIRouter, Depends, HTTPException, status
from app.api.dependencies.auth import get_access_token
from app.api.dependencies.permissions import require_permission
from app.application.admin.sites import SiteService
from app.schemas.auth import AuthenticatedUser
from app.schemas.admin import SiteListResponse, SiteRead
router = APIRouter()
service = SiteService()
@router.get("", response_model=SiteListResponse)
async def list_sites(
access_token: str = Depends(get_access_token),
_: AuthenticatedUser = Depends(require_permission("can_manage_sites")),
) -> SiteListResponse:
items = await service.list_sites(access_token=access_token)
return SiteListResponse(items=items)
@router.get("/{site_id}", response_model=SiteRead)
async def get_site(
site_id: str,
access_token: str = Depends(get_access_token),
_: AuthenticatedUser = Depends(require_permission("can_manage_sites")),
) -> SiteRead:
item = await service.get_site(site_id, access_token=access_token)
if not item:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Site '{site_id}' not found.",
)
return item

View File

@@ -0,0 +1,63 @@
from fastapi import APIRouter, Depends, HTTPException, Query, status
from app.api.dependencies.auth import get_access_token
from app.api.dependencies.permissions import require_permission
from app.application.admin.variants import VariantService
from app.schemas.auth import AuthenticatedUser
from app.schemas.admin import VariantCreate, VariantListResponse, VariantRead, VariantUpdate
router = APIRouter()
service = VariantService()
@router.get("", response_model=VariantListResponse)
async def list_variants(
experiment_id: str | None = Query(default=None),
access_token: str = Depends(get_access_token),
_: AuthenticatedUser = Depends(require_permission("can_manage_variants")),
) -> VariantListResponse:
items = await service.list_variants(
experiment_id=experiment_id,
access_token=access_token,
)
return VariantListResponse(items=items)
@router.get("/{variant_id}", response_model=VariantRead)
async def get_variant(
variant_id: str,
access_token: str = Depends(get_access_token),
_: AuthenticatedUser = Depends(require_permission("can_manage_variants")),
) -> VariantRead:
item = await service.get_variant(variant_id, access_token=access_token)
if not item:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Variant '{variant_id}' not found.",
)
return item
@router.post("", response_model=VariantRead, status_code=status.HTTP_201_CREATED)
async def create_variant(
payload: VariantCreate,
access_token: str = Depends(get_access_token),
_: AuthenticatedUser = Depends(require_permission("can_manage_variants")),
) -> VariantRead:
return await service.create_variant(payload, access_token=access_token)
@router.patch("/{variant_id}", response_model=VariantRead)
async def update_variant(
variant_id: str,
payload: VariantUpdate,
access_token: str = Depends(get_access_token),
_: AuthenticatedUser = Depends(require_permission("can_manage_variants")),
) -> VariantRead:
item = await service.update_variant(variant_id, payload, access_token=access_token)
if not item:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Variant '{variant_id}' not found.",
)
return item

16
backend/app/api/auth.py Normal file
View File

@@ -0,0 +1,16 @@
from fastapi import APIRouter, Depends
from app.api.dependencies.auth import get_current_user
from app.schemas.auth import AuthMeResponse, AuthenticatedUser
router = APIRouter(prefix="/auth", tags=["auth"])
@router.get("/me", response_model=AuthMeResponse)
async def get_me(
current_user: AuthenticatedUser = Depends(get_current_user),
) -> AuthMeResponse:
"""Return normalized current-user context for frontend session bootstrapping."""
return AuthMeResponse(user=current_user)

View File

@@ -0,0 +1,2 @@
"""Reusable FastAPI dependencies."""

View File

@@ -0,0 +1,42 @@
from __future__ import annotations
from fastapi import Depends, HTTPException, status
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
from app.application.auth.context import AuthContextService
from app.schemas.auth import AuthenticatedUser
bearer_scheme = HTTPBearer(auto_error=False)
async def get_current_user(
credentials: HTTPAuthorizationCredentials | None = Depends(bearer_scheme),
) -> AuthenticatedUser:
"""Resolve current user from a Directus-issued bearer token.
This keeps auth enforcement centralized and makes later permission mapping
easier to add without rewriting every route.
"""
if not credentials or not credentials.credentials:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Authorization header is required.",
)
service = AuthContextService()
return await service.get_authenticated_user(credentials.credentials)
async def get_access_token(
credentials: HTTPAuthorizationCredentials | None = Depends(bearer_scheme),
) -> str:
"""Return the raw Directus bearer token for repository passthrough reads."""
if not credentials or not credentials.credentials:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Authorization header is required.",
)
return credentials.credentials

View File

@@ -0,0 +1,36 @@
from __future__ import annotations
from collections.abc import Callable
from fastapi import Depends, HTTPException, status
from app.api.dependencies.auth import get_current_user
from app.schemas.auth import AuthenticatedUser
def require_permission(permission_name: str) -> Callable[..., AuthenticatedUser]:
"""Create a dependency that enforces a translated permission flag.
The flag names intentionally match `PermissionContextRead` fields so
reviewers can trace permission checks end to end without indirection.
"""
async def dependency(
current_user: AuthenticatedUser = Depends(get_current_user),
) -> AuthenticatedUser:
if not hasattr(current_user.permissions, permission_name):
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"Unknown permission flag '{permission_name}'.",
)
if not getattr(current_user.permissions, permission_name):
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail=f"Missing permission '{permission_name}'.",
)
return current_user
return dependency

View File

@@ -0,0 +1 @@
"""Editor API routers."""

View File

@@ -0,0 +1,90 @@
from fastapi import APIRouter, Depends, HTTPException, status
from app.api.dependencies.auth import get_access_token
from app.api.dependencies.permissions import require_permission
from app.application.editor.service import EditorService
from app.schemas.auth import AuthenticatedUser
from app.schemas.editor import (
BuildPreviewRequest,
BuildPreviewResponse,
EditorChangeListResponse,
EditorSessionCreateRequest,
EditorSessionRead,
EditorSessionUpdateRequest,
SaveVariantChangesRequest,
)
router = APIRouter()
service = EditorService()
@router.post("/sessions", response_model=EditorSessionRead)
async def create_editor_session(
request: EditorSessionCreateRequest,
current_user: AuthenticatedUser = Depends(require_permission("can_use_editor")),
) -> EditorSessionRead:
return await service.create_session(request, current_user)
@router.get("/sessions/{session_id}", response_model=EditorSessionRead)
async def get_editor_session(
session_id: str,
_: AuthenticatedUser = Depends(require_permission("can_use_editor")),
) -> EditorSessionRead:
item = await service.get_session(session_id)
if not item:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Editor session '{session_id}' not found.",
)
return item
@router.patch("/sessions/{session_id}", response_model=EditorSessionRead)
async def update_editor_session(
session_id: str,
request: EditorSessionUpdateRequest,
_: AuthenticatedUser = Depends(require_permission("can_use_editor")),
) -> EditorSessionRead:
item = await service.update_session(session_id, request)
if not item:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Editor session '{session_id}' not found.",
)
return item
@router.delete("/sessions/{session_id}", status_code=status.HTTP_204_NO_CONTENT)
async def delete_editor_session(
session_id: str,
_: AuthenticatedUser = Depends(require_permission("can_use_editor")),
) -> None:
await service.delete_session(session_id)
@router.get("/variants/{variant_id}/changes", response_model=EditorChangeListResponse)
async def list_variant_changes(
variant_id: str,
_: AuthenticatedUser = Depends(require_permission("can_use_editor")),
access_token: str = Depends(get_access_token),
) -> EditorChangeListResponse:
return await service.list_changes_with_access_token(variant_id, access_token)
@router.put("/variants/{variant_id}/changes", response_model=EditorChangeListResponse)
async def save_variant_changes(
variant_id: str,
request: SaveVariantChangesRequest,
_: AuthenticatedUser = Depends(require_permission("can_use_editor")),
access_token: str = Depends(get_access_token),
) -> EditorChangeListResponse:
return await service.save_changes_with_access_token(variant_id, request, access_token)
@router.post("/previews/build", response_model=BuildPreviewResponse)
async def build_preview(
request: BuildPreviewRequest,
_: AuthenticatedUser = Depends(require_permission("can_use_editor")),
) -> BuildPreviewResponse:
return await service.build_preview(request)

12
backend/app/api/router.py Normal file
View File

@@ -0,0 +1,12 @@
from fastapi import APIRouter
from app.api.auth import router as auth_router
from app.api.admin.router import router as admin_router
from app.api.editor.router import router as editor_router
from app.api.runtime.router import router as runtime_router
api_router = APIRouter()
api_router.include_router(auth_router)
api_router.include_router(admin_router, prefix="/admin", tags=["admin"])
api_router.include_router(editor_router, prefix="/editor", tags=["editor"])
api_router.include_router(runtime_router, prefix="/runtime", tags=["runtime"])

View File

@@ -0,0 +1 @@
"""Runtime API routers."""

View File

@@ -0,0 +1,49 @@
from fastapi import APIRouter, HTTPException, status
from app.application.runtime.assignment import RuntimeService
from app.schemas.runtime import (
RuntimeAssignRequest,
RuntimeAssignResponse,
RuntimeBootstrapRequest,
RuntimeBootstrapResponse,
RuntimeEventRequest,
RuntimeEventResponse,
RuntimePayloadRequest,
RuntimePayloadResponse,
)
router = APIRouter()
service = RuntimeService()
@router.post("/bootstrap", response_model=RuntimeBootstrapResponse)
async def runtime_bootstrap(
request: RuntimeBootstrapRequest,
) -> RuntimeBootstrapResponse:
return await service.bootstrap(request)
@router.post("/assign", response_model=RuntimeAssignResponse)
async def runtime_assign(request: RuntimeAssignRequest) -> RuntimeAssignResponse:
item = await service.assign(request)
if not item:
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
detail="No assignable variants were provided.",
)
return item
@router.post("/payload", response_model=RuntimePayloadResponse)
async def runtime_payload(request: RuntimePayloadRequest) -> RuntimePayloadResponse:
return await service.payload(request)
@router.post("/events/impression", response_model=RuntimeEventResponse)
async def runtime_impression(request: RuntimeEventRequest) -> RuntimeEventResponse:
return await service.ingest_event(request)
@router.post("/events/conversion", response_model=RuntimeEventResponse)
async def runtime_conversion(request: RuntimeEventRequest) -> RuntimeEventResponse:
return await service.ingest_event(request)

View File

@@ -0,0 +1 @@
"""Application layer packages."""

View File

@@ -0,0 +1 @@
"""Admin application services."""

View File

@@ -0,0 +1,81 @@
from __future__ import annotations
from typing import Any
from app.repositories.directus.activity import DirectusActivityRepository
from app.repositories.directus.variants import VariantRepository
from app.repositories.directus.releases import ReleaseRepository
ACTION_LABELS: dict[str, str] = {
"create": "建立",
"update": "更新",
"delete": "刪除",
}
COLLECTION_LABELS: dict[str, str] = {
"experiments": "實驗設定",
"variants": "變體",
"experiment_releases": "版本",
"variant_changes": "視覺變更",
}
class ActivityService:
def __init__(
self,
activity_repository: DirectusActivityRepository | None = None,
variant_repository: VariantRepository | None = None,
release_repository: ReleaseRepository | None = None,
) -> None:
self.activity_repo = activity_repository or DirectusActivityRepository()
self.variant_repo = variant_repository or VariantRepository()
self.release_repo = release_repository or ReleaseRepository()
async def list_for_experiment(
self,
experiment_id: str,
access_token: str | None = None,
) -> list[dict[str, Any]]:
# Collect all item IDs related to this experiment
item_ids: list[str] = [experiment_id]
variants = await self.variant_repo.list(
params={"filter[experiment_id][_eq]": experiment_id, "fields": "id"},
access_token=access_token,
)
variant_ids = [str(v["id"]) for v in variants]
item_ids.extend(variant_ids)
releases = await self.release_repo.list(
params={"filter[experiment_id][_eq]": experiment_id, "fields": "id"},
access_token=access_token,
)
release_ids = [str(r["id"]) for r in releases]
item_ids.extend(release_ids)
raw_entries = await self.activity_repo.list_for_items(
item_ids=item_ids,
access_token=access_token,
)
result = []
for entry in raw_entries:
action = str(entry.get("action", ""))
collection = str(entry.get("collection", ""))
user = entry.get("user") or {}
actor_email = user.get("email") if isinstance(user, dict) else None
actor_id = user.get("id") if isinstance(user, dict) else str(user) if user else None
result.append({
"id": entry.get("id"),
"action": action,
"action_label": ACTION_LABELS.get(action, action),
"collection": collection,
"collection_label": COLLECTION_LABELS.get(collection, collection),
"item": str(entry.get("item", "")),
"timestamp": entry.get("timestamp"),
"actor_email": actor_email,
"actor_id": actor_id,
})
return result

View File

@@ -0,0 +1,77 @@
from __future__ import annotations
from typing import Any
from app.domain.keys import generate_experiment_key, generate_variant_key
from app.domain.mappers import to_experiment
from app.repositories.directus.experiments import ExperimentRepository
from app.repositories.directus.variants import VariantRepository
from app.schemas.admin import ExperimentCreate, ExperimentRead, ExperimentUpdate, experiment_to_read_model
class ExperimentService:
"""Application service for experiment use-cases.
This is where we will keep:
- validation
- permission checks
- state transitions
- orchestration before writing to Directus
"""
def __init__(
self,
repository: ExperimentRepository | None = None,
variant_repository: VariantRepository | None = None,
) -> None:
self.repository = repository or ExperimentRepository()
self.variant_repository = variant_repository or VariantRepository()
async def list_experiments(self, access_token: str | None = None) -> list[ExperimentRead]:
items = await self.repository.list(access_token=access_token)
return [experiment_to_read_model(to_experiment(item)) for item in items]
async def get_experiment(
self,
experiment_id: str,
access_token: str | None = None,
) -> ExperimentRead | None:
item = await self.repository.get(experiment_id, access_token=access_token)
if not item:
return None
return experiment_to_read_model(to_experiment(item))
async def create_experiment(
self,
payload: ExperimentCreate,
access_token: str | None = None,
) -> ExperimentRead:
data: dict[str, Any] = payload.model_dump(exclude_none=True)
data["experiment_key"] = generate_experiment_key()
item = await self.repository.create(data, access_token=access_token)
experiment = to_experiment(item)
# Auto-create 原始版本 (control) variant
await self.variant_repository.create(
{
"experiment_id": experiment.id,
"variant_key": generate_variant_key(),
"name": "原始版本",
"traffic_weight": 50,
},
access_token=access_token,
)
return experiment_to_read_model(experiment)
async def update_experiment(
self,
experiment_id: str,
payload: ExperimentUpdate,
access_token: str | None = None,
) -> ExperimentRead | None:
data: dict[str, Any] = payload.model_dump(exclude_unset=True)
item = await self.repository.update(experiment_id, data, access_token=access_token)
if not item:
return None
return experiment_to_read_model(to_experiment(item))

View File

@@ -0,0 +1,35 @@
from __future__ import annotations
from typing import Any
from app.domain.mappers import to_goal
from app.repositories.directus.goals import GoalRepository
from app.schemas.admin import GoalRead, goal_to_read_model
class GoalService:
"""Application service for experiment goal management."""
def __init__(self, repository: GoalRepository | None = None) -> None:
self.repository = repository or GoalRepository()
async def list_goals(
self,
site_id: str | None = None,
access_token: str | None = None,
) -> list[GoalRead]:
params: dict[str, Any] | None = None
if site_id:
params = {"filter[site_id][_eq]": site_id}
items = await self.repository.list(params=params, access_token=access_token)
return [goal_to_read_model(to_goal(item)) for item in items]
async def get_goal(
self,
goal_id: str,
access_token: str | None = None,
) -> GoalRead | None:
item = await self.repository.get(goal_id, access_token=access_token)
if not item:
return None
return goal_to_read_model(to_goal(item))

View File

@@ -0,0 +1,27 @@
from __future__ import annotations
from app.repositories.directus.marketing_cards import MarketingCardRepository
from app.schemas.marketing_card import MarketingCardRead
class MarketingCardService:
"""Application service for the current marketing-card module.
This keeps the legacy module reachable while frontend migration is in
progress. Later we can either map it into experiment/variant concepts or
retire it after the new module replaces it.
"""
def __init__(self, repository: MarketingCardRepository | None = None) -> None:
self.repository = repository or MarketingCardRepository()
async def list_marketing_cards(self) -> list[MarketingCardRead]:
items = await self.repository.list()
return [MarketingCardRead.model_validate(item) for item in items]
async def get_marketing_card(self, card_id: str) -> MarketingCardRead | None:
item = await self.repository.get(card_id)
if not item:
return None
return MarketingCardRead.model_validate(item)

View File

@@ -0,0 +1,205 @@
from __future__ import annotations
from dataclasses import asdict
from typing import Any
from app.domain.editor import VariantChange
from app.domain.editor_builder import build_runtime_payload_from_changes
from app.domain.mappers import to_release
from app.repositories.directus.releases import ReleaseRepository
from app.repositories.directus.variant_changes import VariantChangeRepository
from app.repositories.directus.variants import VariantRepository
from app.schemas.admin import BuildReleaseRequest, ReleaseLifecycleResponse, ReleaseRead, release_to_read_model
class ReleaseService:
"""Application service for release and snapshot lifecycle."""
def __init__(
self,
repository: ReleaseRepository | None = None,
variant_repository: VariantRepository | None = None,
change_repository: VariantChangeRepository | None = None,
) -> None:
self.repository = repository or ReleaseRepository()
self.variant_repository = variant_repository or VariantRepository()
self.change_repository = change_repository or VariantChangeRepository()
async def list_releases(
self,
experiment_id: str | None = None,
access_token: str | None = None,
) -> list[ReleaseRead]:
params: dict[str, Any] | None = None
if experiment_id:
params = {"filter[experiment_id][_eq]": experiment_id}
items = await self.repository.list(params=params, access_token=access_token)
return [release_to_read_model(to_release(item)) for item in items]
async def get_release(
self,
release_id: str,
access_token: str | None = None,
) -> ReleaseRead | None:
item = await self.repository.get(release_id, access_token=access_token)
if not item:
return None
return release_to_read_model(to_release(item))
async def build_release(
self,
payload: BuildReleaseRequest,
access_token: str | None = None,
) -> ReleaseRead:
experiment_id = payload.experiment_id
# Fetch all variants for the experiment
variant_items = await self.variant_repository.list(
params={"filter[experiment_id][_eq]": experiment_id},
access_token=access_token,
)
# Build per-variant runtime payloads
variants_payload: list[dict[str, Any]] = []
for variant_item in variant_items:
variant_id = str(variant_item["id"])
change_items = await self.change_repository.list(
params={"filter[variant_id][_eq]": variant_id},
access_token=access_token,
)
changes = [
VariantChange(
id=str(c["id"]),
variant_id=variant_id,
change_type=str(c.get("change_type", "")),
selector_type=str(c.get("selector_type", "")),
selector_value=str(c.get("selector_value", "")),
sort_order=int(c.get("sort_order", 0)),
payload=c.get("payload"),
)
for c in change_items
]
runtime_payload = build_runtime_payload_from_changes(variant_id, changes)
variants_payload.append({
"variant_id": variant_id,
"variant_key": str(variant_item.get("variant_key", "")),
"traffic_weight": int(variant_item.get("traffic_weight", 0)),
"operations": [asdict(op) for op in runtime_payload.operations],
})
# Determine next version_no
existing = await self.repository.list(
params={"filter[experiment_id][_eq]": experiment_id, "sort": "-version_no", "limit": "1"},
access_token=access_token,
)
next_version = (int(existing[0]["version_no"]) + 1) if existing else 1
new_release = await self.repository.create(
{
"experiment_id": experiment_id,
"version_no": next_version,
"status": "draft",
"runtime_payload": {"variants": variants_payload},
},
access_token=access_token,
)
return release_to_read_model(to_release(new_release))
async def publish_release(
self,
release_id: str,
access_token: str | None = None,
) -> ReleaseLifecycleResponse | None:
# Fetch the target release to get experiment_id
target = await self.repository.get(release_id, access_token=access_token)
if not target:
return None
experiment_id = str(target["experiment_id"])
# Demote any currently published release in this experiment to draft
currently_published = await self.repository.list(
params={
"filter[experiment_id][_eq]": experiment_id,
"filter[status][_eq]": "published",
},
access_token=access_token,
)
for other in currently_published:
if str(other["id"]) != release_id:
await self.repository.update(str(other["id"]), {"status": "draft"}, access_token=access_token)
item = await self.repository.update(release_id, {"status": "published"}, access_token=access_token)
if not item:
return None
return ReleaseLifecycleResponse(
id=str(item["id"]),
status=str(item["status"]),
version_no=int(item["version_no"]),
)
async def rollback_release(
self,
release_id: str,
access_token: str | None = None,
) -> ReleaseLifecycleResponse | None:
# Fetch the target release to get experiment_id and version_no
target = await self.repository.get(release_id, access_token=access_token)
if not target:
return None
experiment_id = str(target["experiment_id"])
current_version_no = int(target["version_no"])
# Demote the current release back to draft
await self.repository.update(release_id, {"status": "draft"}, access_token=access_token)
# Find the most recent draft release with an earlier version_no to restore
previous_candidates = await self.repository.list(
params={
"filter[experiment_id][_eq]": experiment_id,
"filter[status][_eq]": "draft",
"filter[version_no][_lt]": str(current_version_no),
"sort": "-version_no",
"limit": "1",
},
access_token=access_token,
)
if previous_candidates:
restored = await self.repository.update(
str(previous_candidates[0]["id"]),
{"status": "published"},
access_token=access_token,
)
if restored:
return ReleaseLifecycleResponse(
id=str(restored["id"]),
status=str(restored["status"]),
version_no=int(restored["version_no"]),
)
# No previous release to restore — return the now-drafted current release
updated = await self.repository.get(release_id, access_token=access_token)
if not updated:
return None
return ReleaseLifecycleResponse(
id=str(updated["id"]),
status=str(updated["status"]),
version_no=int(updated["version_no"]),
)
async def archive_release(
self,
release_id: str,
access_token: str | None = None,
) -> ReleaseLifecycleResponse | None:
item = await self.repository.update(release_id, {"status": "archived"}, access_token=access_token)
if not item:
return None
return ReleaseLifecycleResponse(
id=str(item["id"]),
status=str(item["status"]),
version_no=int(item["version_no"]),
)

View File

@@ -0,0 +1,26 @@
from __future__ import annotations
from app.domain.mappers import to_sdk_config
from app.repositories.directus.sdk_configs import SdkConfigRepository
from app.schemas.admin import SdkConfigRead, sdk_config_to_read_model
class SdkConfigService:
"""Application service for SDK config management."""
def __init__(self, repository: SdkConfigRepository | None = None) -> None:
self.repository = repository or SdkConfigRepository()
async def list_sdk_configs(self, access_token: str | None = None) -> list[SdkConfigRead]:
items = await self.repository.list(access_token=access_token)
return [sdk_config_to_read_model(to_sdk_config(item)) for item in items]
async def get_sdk_config(
self,
sdk_config_id: str,
access_token: str | None = None,
) -> SdkConfigRead | None:
item = await self.repository.get(sdk_config_id, access_token=access_token)
if not item:
return None
return sdk_config_to_read_model(to_sdk_config(item))

View File

@@ -0,0 +1,30 @@
from __future__ import annotations
from app.domain.mappers import to_site
from app.repositories.directus.sites import SiteRepository
from app.schemas.admin import SiteRead, site_to_read_model
class SiteService:
"""Application service for site administration.
Site is a good example of a content-like resource:
FastAPI owns orchestration, while Directus remains the audited store.
"""
def __init__(self, repository: SiteRepository | None = None) -> None:
self.repository = repository or SiteRepository()
async def list_sites(self, access_token: str | None = None) -> list[SiteRead]:
items = await self.repository.list(access_token=access_token)
return [site_to_read_model(to_site(item)) for item in items]
async def get_site(
self,
site_id: str,
access_token: str | None = None,
) -> SiteRead | None:
item = await self.repository.get(site_id, access_token=access_token)
if not item:
return None
return site_to_read_model(to_site(item))

View File

@@ -0,0 +1,97 @@
from __future__ import annotations
from typing import Any
from fastapi import HTTPException
from app.domain.keys import generate_variant_key
from app.domain.mappers import to_variant
from app.repositories.directus.variants import VariantRepository
from app.schemas.admin import VariantCreate, VariantRead, VariantUpdate, variant_to_read_model
class VariantService:
"""Application service for variant management."""
def __init__(self, repository: VariantRepository | None = None) -> None:
self.repository = repository or VariantRepository()
async def _assert_weights_sum_100(
self,
experiment_id: str,
new_weight: int,
exclude_variant_id: str | None = None,
access_token: str | None = None,
) -> None:
items = await self.repository.list(
params={"filter[experiment_id][_eq]": experiment_id},
access_token=access_token,
)
existing_sum = sum(
int(item.get("traffic_weight", 0))
for item in items
if str(item["id"]) != exclude_variant_id
)
if existing_sum + new_weight != 100:
raise HTTPException(
status_code=400,
detail=f"所有變體的流量權重加總必須等於 100目前其他變體合計 {existing_sum},本次輸入 {new_weight}",
)
async def list_variants(
self,
experiment_id: str | None = None,
access_token: str | None = None,
) -> list[VariantRead]:
params: dict[str, Any] | None = None
if experiment_id:
params = {"filter[experiment_id][_eq]": experiment_id}
items = await self.repository.list(params=params, access_token=access_token)
return [variant_to_read_model(to_variant(item)) for item in items]
async def get_variant(
self,
variant_id: str,
access_token: str | None = None,
) -> VariantRead | None:
item = await self.repository.get(variant_id, access_token=access_token)
if not item:
return None
return variant_to_read_model(to_variant(item))
async def create_variant(
self,
payload: VariantCreate,
access_token: str | None = None,
) -> VariantRead:
await self._assert_weights_sum_100(
experiment_id=payload.experiment_id,
new_weight=payload.traffic_weight,
access_token=access_token,
)
data: dict[str, Any] = payload.model_dump(exclude_none=True)
data["variant_key"] = generate_variant_key()
item = await self.repository.create(data, access_token=access_token)
return variant_to_read_model(to_variant(item))
async def update_variant(
self,
variant_id: str,
payload: VariantUpdate,
access_token: str | None = None,
) -> VariantRead | None:
data: dict[str, Any] = payload.model_dump(exclude_unset=True)
if "traffic_weight" in data:
existing = await self.repository.get(variant_id, access_token=access_token)
if existing:
await self._assert_weights_sum_100(
experiment_id=str(existing["experiment_id"]),
new_weight=data["traffic_weight"],
exclude_variant_id=variant_id,
access_token=access_token,
)
item = await self.repository.update(variant_id, data, access_token=access_token)
if not item:
return None
return variant_to_read_model(to_variant(item))

View File

@@ -0,0 +1,2 @@
"""Authentication and user-context services."""

View File

@@ -0,0 +1,70 @@
from __future__ import annotations
from dataclasses import asdict
from fastapi import HTTPException, status
from app.domain.permissions import build_permission_context
from app.repositories.directus.client import DirectusClient
from app.schemas.auth import AuthenticatedUser, PermissionContextRead
class AuthContextService:
"""Validates Directus-issued tokens and normalizes user context.
FastAPI remains the formal API entrypoint, but Directus is still the source
of truth for login/session/role data. This service is the seam between them.
"""
# Keep the first auth bootstrap intentionally conservative.
# Directus `/users/me` on this project reliably returns the core user/role
# fields below, while deeper `user_group.*` expansions can collapse the
# payload back to only `{id}`. We can layer group/domain permissions back in
# later once the base admin flow is verified end to end.
me_fields = [
"email",
"first_name",
"id",
"role.id",
"role.name",
"status",
]
def __init__(self, client: DirectusClient | None = None) -> None:
self.client = client or DirectusClient()
async def get_authenticated_user(self, access_token: str) -> AuthenticatedUser:
if not access_token:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Missing access token.",
)
me = await self.client.get_current_user(
access_token=access_token,
fields=self.me_fields,
)
if not me:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Unable to resolve current user from Directus.",
)
user_group = me.get("user_group") or {}
permissions = user_group.get("domain_permissions") or []
normalized_permissions = [str(permission) for permission in permissions]
role = me.get("role") or {}
permission_context = build_permission_context(
role_name=role.get("name"),
domain_permissions=normalized_permissions,
)
# Keep the normalized payload explicit so downstream services do not need
# to know the exact nested Directus response shape.
return AuthenticatedUser.model_validate(
{
**me,
"domain_permissions": normalized_permissions,
"permissions": PermissionContextRead.model_validate(asdict(permission_context)),
}
)

View File

@@ -0,0 +1,2 @@
"""Editor services and builders."""

View File

@@ -0,0 +1,172 @@
from __future__ import annotations
from dataclasses import asdict
from datetime import datetime, timezone
from uuid import uuid4
from app.domain.editor import EditorSession, VariantChange
from app.domain.editor_builder import build_runtime_payload_from_changes
from app.repositories.directus.variant_changes import VariantChangeRepository
from app.repositories.native.editor_sessions import EditorSessionRepository
from app.schemas.auth import AuthenticatedUser
from app.schemas.editor import (
BuildPreviewRequest,
BuildPreviewResponse,
EditorChangeListResponse,
EditorChangeRead,
EditorSessionCreateRequest,
EditorSessionRead,
EditorSessionUpdateRequest,
SaveVariantChangesRequest,
)
class EditorService:
"""Application service for visual-editor workflows."""
def __init__(
self,
change_repository: VariantChangeRepository | None = None,
session_repository: EditorSessionRepository | None = None,
) -> None:
self.change_repository = change_repository or VariantChangeRepository()
self.session_repository = session_repository or EditorSessionRepository()
async def create_session(
self,
request: EditorSessionCreateRequest,
current_user: AuthenticatedUser,
) -> EditorSessionRead:
session = EditorSession(
variant_id=request.variant_id,
mode=request.mode,
base_url=request.base_url,
actor_id=current_user.id,
actor_email=current_user.email,
)
await self.session_repository.create(session)
return EditorSessionRead.model_validate(asdict(session))
async def get_session(self, session_id: str) -> EditorSessionRead | None:
session = await self.session_repository.get(session_id)
if not session:
return None
return EditorSessionRead.model_validate(asdict(session))
async def update_session(
self,
session_id: str,
request: EditorSessionUpdateRequest,
) -> EditorSessionRead | None:
session = await self.session_repository.get(session_id)
if not session:
return None
if request.status is not None:
session.status = request.status
if request.draft_changes is not None:
session.draft_changes = request.draft_changes
session.updated_at = datetime.now(timezone.utc)
await self.session_repository.update(session)
return EditorSessionRead.model_validate(asdict(session))
async def delete_session(self, session_id: str) -> None:
await self.session_repository.delete(session_id)
async def list_changes(self, variant_id: str) -> EditorChangeListResponse:
items = await self.change_repository.list(params={"filter[variant_id][_eq]": variant_id})
mapped = [EditorChangeRead.model_validate(item) for item in items]
return EditorChangeListResponse(items=mapped)
async def list_changes_with_access_token(
self,
variant_id: str,
access_token: str,
) -> EditorChangeListResponse:
items = await self.change_repository.list(
params={"filter[variant_id][_eq]": variant_id},
access_token=access_token,
)
mapped = [EditorChangeRead.model_validate(item) for item in items]
return EditorChangeListResponse(items=mapped)
async def save_changes(
self,
variant_id: str,
request: SaveVariantChangesRequest,
) -> EditorChangeListResponse:
# Full-replace semantics: delete any existing changes not present in the request.
existing = await self.change_repository.list(params={"filter[variant_id][_eq]": variant_id})
incoming_ids = {item.id for item in request.items if item.id}
for existing_item in existing:
if str(existing_item["id"]) not in incoming_ids:
await self.change_repository.delete(str(existing_item["id"]))
saved_items: list[EditorChangeRead] = []
for item in request.items:
payload = item.model_dump()
payload["variant_id"] = variant_id
change_id = payload.pop("id", None)
if change_id:
saved = await self.change_repository.update(change_id, payload)
else:
payload["id"] = str(uuid4())
saved = await self.change_repository.create(payload)
saved_items.append(EditorChangeRead.model_validate(saved))
return EditorChangeListResponse(items=saved_items)
async def save_changes_with_access_token(
self,
variant_id: str,
request: SaveVariantChangesRequest,
access_token: str,
) -> EditorChangeListResponse:
# Full-replace semantics: delete any existing changes not present in the request.
existing = await self.change_repository.list(
params={"filter[variant_id][_eq]": variant_id},
access_token=access_token,
)
incoming_ids = {item.id for item in request.items if item.id}
for existing_item in existing:
if str(existing_item["id"]) not in incoming_ids:
await self.change_repository.delete(str(existing_item["id"]), access_token=access_token)
saved_items: list[EditorChangeRead] = []
for item in request.items:
payload = item.model_dump()
payload["variant_id"] = variant_id
change_id = payload.pop("id", None)
if change_id:
saved = await self.change_repository.update(change_id, payload, access_token=access_token)
else:
payload["id"] = str(uuid4())
saved = await self.change_repository.create(payload, access_token=access_token)
saved_items.append(EditorChangeRead.model_validate(saved))
return EditorChangeListResponse(items=saved_items)
async def build_preview(self, request: BuildPreviewRequest) -> BuildPreviewResponse:
changes = [
VariantChange(
id=item.id or str(uuid4()),
variant_id=request.variant_id,
change_type=item.change_type,
selector_type=item.selector_type,
selector_value=item.selector_value,
sort_order=item.sort_order,
payload=item.payload,
)
for item in request.items
]
preview = build_runtime_payload_from_changes(request.variant_id, changes)
return BuildPreviewResponse(
variant_id=preview.variant_id,
generated_at=preview.generated_at,
operations=[asdict(operation) for operation in preview.operations],
)

View File

@@ -0,0 +1,2 @@
"""Audit and telemetry services."""

View File

@@ -0,0 +1,31 @@
from __future__ import annotations
from app.domain.observability import AuditLogEntry, SystemEvent
from app.repositories.native.audit_logs import AuditLogRepository
from app.repositories.native.system_events import SystemEventRepository
class ObservabilityService:
"""Facade for audit and telemetry writes.
Keeping this behind one service lets us fan out later to:
- native PostgreSQL tables
- queue/stream pipelines
- external monitoring sinks
without changing runtime/admin services.
"""
def __init__(
self,
audit_repository: AuditLogRepository | None = None,
system_event_repository: SystemEventRepository | None = None,
) -> None:
self.audit_repository = audit_repository or AuditLogRepository()
self.system_event_repository = system_event_repository or SystemEventRepository()
async def record_audit(self, entry: AuditLogEntry) -> AuditLogEntry:
return await self.audit_repository.add(entry)
async def record_system_event(self, event: SystemEvent) -> SystemEvent:
return await self.system_event_repository.add(event)

View File

@@ -0,0 +1,2 @@
"""Runtime services used by the SDK-facing API."""

View File

@@ -0,0 +1,297 @@
from __future__ import annotations
import re
from typing import Any
from app.application.observability.service import ObservabilityService
from app.domain.mappers import to_experiment, to_release, to_site, to_variant
from app.domain.observability import SystemEvent
from app.domain.runtime import RuntimeExperimentContext, RuntimeVariantCandidate, choose_variant
from app.repositories.directus.experiments import ExperimentRepository
from app.repositories.directus.releases import ReleaseRepository
from app.repositories.directus.sites import SiteRepository
from app.repositories.directus.variants import VariantRepository
from app.schemas.runtime import (
RuntimeAssignRequest,
RuntimeAssignResponse,
RuntimeBootstrapRequest,
RuntimeBootstrapResponse,
RuntimeExperimentInput,
RuntimeEventRequest,
RuntimeEventResponse,
RuntimePayloadRequest,
RuntimePayloadResponse,
RuntimeVariantCandidateInput,
)
class RuntimeService:
"""First-pass runtime orchestration service.
The current version focuses on:
- deterministic assignment rules
- stable API contracts for the SDK
- keeping runtime logic out of the frontend
Later we can swap the input source from request payloads to repositories
without changing the external response shapes.
"""
def __init__(
self,
observability: ObservabilityService | None = None,
site_repository: SiteRepository | None = None,
experiment_repository: ExperimentRepository | None = None,
variant_repository: VariantRepository | None = None,
release_repository: ReleaseRepository | None = None,
) -> None:
self.observability = observability or ObservabilityService()
self.site_repository = site_repository or SiteRepository()
self.experiment_repository = experiment_repository or ExperimentRepository()
self.variant_repository = variant_repository or VariantRepository()
self.release_repository = release_repository or ReleaseRepository()
async def bootstrap(self, request: RuntimeBootstrapRequest) -> RuntimeBootstrapResponse:
site = await self._resolve_site(request.site_id, request.site_key)
if not site:
return RuntimeBootstrapResponse(
site_id=request.site_id,
site_key=request.site_key,
url=request.url,
visitor_id=request.visitor_id,
candidate_experiments=[],
)
experiments = await self.experiment_repository.list(
params={
"filter[site_id][_eq]": site.id,
"filter[status][_eq]": "running",
}
)
candidate_experiments = []
for raw_experiment in experiments:
experiment = to_experiment(raw_experiment)
if not self._matches_targeting(request.url, request.user_agent, experiment.targeting_config):
continue
raw_releases = await self.release_repository.list(
params={
"filter[experiment_id][_eq]": experiment.id,
"filter[status][_eq]": "published",
"sort[]": "-version_no",
"limit": 1,
}
)
release = to_release(raw_releases[0]) if raw_releases else None
# Build a traffic weight index from the release snapshot when available.
# This ensures assignment weights are stable and match what was published,
# even if variant weights have since been edited.
snapshot_weight: dict[str, int] = {}
snapshot_key: dict[str, str] = {}
if release and isinstance(release.runtime_payload, dict):
for sv in release.runtime_payload.get("variants", []):
vid = str(sv.get("variant_id", ""))
if vid:
snapshot_weight[vid] = int(sv.get("traffic_weight", 0))
snapshot_key[vid] = str(sv.get("variant_key", ""))
raw_variants = await self.variant_repository.list(
params={"filter[experiment_id][_eq]": experiment.id}
)
variants = [to_variant(item) for item in raw_variants]
if not variants:
continue
candidate_experiments.append(
RuntimeExperimentInput(
experiment_id=experiment.id,
experiment_key=experiment.experiment_key,
status=experiment.status,
site_key=site.site_key,
release_id=release.id if release else None,
release_version=release.version_no if release else None,
payload=release.runtime_payload if release else None,
variants=[
RuntimeVariantCandidateInput(
id=variant.id,
variant_key=snapshot_key.get(variant.id, variant.variant_key),
traffic_weight=snapshot_weight.get(variant.id, variant.traffic_weight),
content_config=variant.content_config,
)
for variant in variants
],
)
)
return RuntimeBootstrapResponse(
site_id=site.id,
site_key=site.site_key,
url=request.url,
visitor_id=request.visitor_id,
candidate_experiments=candidate_experiments,
)
async def assign(self, request: RuntimeAssignRequest) -> RuntimeAssignResponse | None:
experiment = RuntimeExperimentContext(
experiment_id=request.experiment.experiment_id,
experiment_key=request.experiment.experiment_key,
status=request.experiment.status,
site_key=request.experiment.site_key,
assignment_salt=request.experiment.assignment_salt,
release_id=request.experiment.release_id,
release_version=request.experiment.release_version,
payload=request.experiment.payload,
variants=[
RuntimeVariantCandidate(
id=variant.id,
variant_key=variant.variant_key,
traffic_weight=variant.traffic_weight,
content_config=variant.content_config,
)
for variant in request.experiment.variants
],
)
decision = choose_variant(
experiment_id=experiment.experiment_id,
experiment_key=experiment.experiment_key,
visitor_id=request.visitor_id,
site_key=experiment.site_key,
assignment_salt=experiment.assignment_salt,
variants=experiment.variants,
)
if not decision:
return None
await self.observability.record_system_event(
SystemEvent(
category="runtime_assignment",
event_name="assignment_decided",
experiment_id=decision.experiment_id,
experiment_key=decision.experiment_key,
variant_id=decision.variant_id,
variant_key=decision.variant_key,
visitor_id=request.visitor_id,
payload={"bucket": decision.bucket, "reason": decision.reason},
)
)
return RuntimeAssignResponse(
experiment_id=decision.experiment_id,
experiment_key=decision.experiment_key,
variant_id=decision.variant_id,
variant_key=decision.variant_key,
bucket=decision.bucket,
reason=decision.reason,
)
async def payload(self, request: RuntimePayloadRequest) -> RuntimePayloadResponse:
assignment = await self.assign(
RuntimeAssignRequest(
visitor_id=request.visitor_id,
experiment=request.experiment,
)
)
return RuntimePayloadResponse(
experiment_id=request.experiment.experiment_id,
experiment_key=request.experiment.experiment_key,
release_id=request.experiment.release_id,
release_version=request.experiment.release_version,
assigned_variant_id=assignment.variant_id if assignment else None,
assigned_variant_key=assignment.variant_key if assignment else None,
payload=request.experiment.payload,
)
async def ingest_event(self, request: RuntimeEventRequest) -> RuntimeEventResponse:
# Event forwarding/queueing will be introduced when GA4/GTM ingest and
# audit storage are wired in. We preserve the external contract now.
await self.observability.record_system_event(
SystemEvent(
category="runtime_event",
event_name=request.event_name,
site_id=request.site_id,
site_key=request.site_key,
experiment_id=request.experiment_id,
experiment_key=request.experiment_key,
variant_id=request.variant_id,
variant_key=request.variant_key,
visitor_id=request.visitor_id,
payload=request.payload,
)
)
return RuntimeEventResponse(
accepted=True,
event_name=request.event_name,
)
async def _resolve_site(self, site_id: str | None, site_key: str | None):
if site_id:
item = await self.site_repository.get(site_id)
if item:
return to_site(item)
if site_key:
items = await self.site_repository.list(
params={"filter[site_key][_eq]": site_key}
)
if items:
return to_site(items[0])
return None
def _matches_targeting(
self,
request_url: str,
user_agent: str | None,
targeting_config: dict[str, Any] | list[Any] | None,
) -> bool:
if not isinstance(targeting_config, dict):
return True
# Device targeting — empty list = all devices
device_targets = targeting_config.get("device_targets")
if device_targets:
detected = self._detect_device(user_agent or "")
if detected not in device_targets:
return False
# URL rules (all must match — AND logic)
url_rules = targeting_config.get("url_rules")
if url_rules:
return all(self._matches_url_rule(request_url, rule) for rule in url_rules)
# Fallback: base_url prefix check
base_url = targeting_config.get("base_url")
if base_url:
return str(request_url).startswith(str(base_url))
return True
def _detect_device(self, user_agent: str) -> str:
ua = user_agent.lower()
if "ipad" in ua or ("android" in ua and "mobile" not in ua):
return "tablet"
if "mobile" in ua or "iphone" in ua or "android" in ua:
return "mobile"
return "desktop"
def _matches_url_rule(self, url: str, rule: dict[str, Any]) -> bool:
operator = rule.get("operator", "contains")
value = str(rule.get("value", ""))
if not value:
return True
if operator == "contains":
return value in url
if operator == "equals":
return url == value
if operator == "starts_with":
return url.startswith(value)
if operator == "regex":
try:
return bool(re.search(value, url))
except re.error:
return False
return True

View File

@@ -0,0 +1 @@
"""Core configuration and shared helpers."""

Binary file not shown.

Binary file not shown.

View File

@@ -0,0 +1,47 @@
from pydantic_settings import BaseSettings, SettingsConfigDict
class Settings(BaseSettings):
app_name: str = "mktapi.ose.tw"
app_version: str = "0.1.0"
app_env: str = "development"
log_level: str = "INFO"
api_prefix: str = "/api"
db_host: str = "127.0.0.1"
db_port: int = 5432
db_database: str = "mkt.ose.tw"
db_user: str = "mkt_ose"
db_password: str = ""
database_url: str = "postgresql+asyncpg://mkt_ose:@127.0.0.1:5432/mkt.ose.tw"
directus_base_url: str = "https://mktcms.ose.tw"
directus_admin_token: str | None = None
directus_timeout: float = 15.0
cors_allowed_origins: str = (
"http://127.0.0.1:3000,http://localhost:3000,"
"http://127.0.0.1:5173,http://localhost:5173,"
"https://127.0.0.1:3000,https://localhost:3000,"
"https://127.0.0.1:5173,https://localhost:5173,"
"https://mkt.ose.tw"
)
@property
def cors_allowed_origin_list(self) -> list[str]:
"""Return normalized CORS origins from a simple comma-separated env value."""
return [
origin.strip()
for origin in self.cors_allowed_origins.split(",")
if origin.strip()
]
model_config = SettingsConfigDict(
env_file=(".env.fastapi.development", ".env"),
env_file_encoding="utf-8",
case_sensitive=False,
extra="ignore",
)
settings = Settings()

View File

@@ -0,0 +1,2 @@
"""Domain layer types and mappers."""

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

@@ -0,0 +1,69 @@
from __future__ import annotations
from dataclasses import dataclass
from datetime import datetime
from typing import Any
@dataclass(slots=True)
class Site:
id: str
site_key: str
name: str
primary_domain: str
status: str
settings: dict[str, Any] | list[Any] | None = None
@dataclass(slots=True)
class Experiment:
id: str
site_id: str
experiment_key: str
name: str
module_type: str
status: str
start_at: datetime | None = None
end_at: datetime | None = None
targeting_config: dict[str, Any] | list[Any] | None = None
@dataclass(slots=True)
class Variant:
id: str
experiment_id: str
variant_key: str
name: str
traffic_weight: int
content_config: dict[str, Any] | list[Any] | None = None
@dataclass(slots=True)
class ExperimentRelease:
id: str
experiment_id: str
version_no: int
status: str
runtime_payload: dict[str, Any] | list[Any] | None = None
@dataclass(slots=True)
class Goal:
id: str
site_id: str
goal_key: str
name: str
goal_type: str
match_rule: dict[str, Any] | list[Any] | None = None
@dataclass(slots=True)
class SdkConfig:
id: str
site_id: str
sdk_key: str
status: str
origin_url: str | None = None
cdn_url: str | None = None
sdk_config: dict[str, Any] | list[Any] | None = None

View File

@@ -0,0 +1,35 @@
from __future__ import annotations
from dataclasses import dataclass, field
@dataclass(slots=True)
class PermissionContext:
"""Normalized business permission context derived from Directus identity."""
is_admin: bool = False
can_manage_sites: bool = False
can_manage_experiments: bool = False
can_manage_variants: bool = False
can_manage_releases: bool = False
can_manage_goals: bool = False
can_manage_sdk_configs: bool = False
can_use_editor: bool = False
can_read_runtime: bool = False
raw_permissions: list[str] = field(default_factory=list)
@dataclass(slots=True)
class AuthenticatedPrincipal:
id: str
email: str | None = None
first_name: str | None = None
status: str | None = None
fb_token: str | None = None
role_id: str | None = None
role_name: str | None = None
user_group_id: str | None = None
user_group_name: str | None = None
domain_permissions: list[str] = field(default_factory=list)
permissions: PermissionContext = field(default_factory=PermissionContext)

View File

@@ -0,0 +1,51 @@
from __future__ import annotations
from dataclasses import dataclass, field
from datetime import datetime, timezone
from typing import Any
from uuid import uuid4
def utcnow() -> datetime:
return datetime.now(timezone.utc)
@dataclass(slots=True)
class VariantChange:
id: str
variant_id: str
change_type: str
selector_type: str
selector_value: str
sort_order: int
payload: dict[str, Any] | list[Any] | None = None
@dataclass(slots=True)
class EditorSession:
variant_id: str
mode: str
base_url: str
actor_id: str
actor_email: str | None = None
status: str = "active"
draft_changes: list[dict[str, Any]] = field(default_factory=list)
created_at: datetime = field(default_factory=utcnow)
updated_at: datetime = field(default_factory=utcnow)
id: str = field(default_factory=lambda: str(uuid4()))
@dataclass(slots=True)
class RuntimeOperation:
selector_type: str
selector_value: str
action: str
payload: dict[str, Any] | list[Any] | None = None
@dataclass(slots=True)
class VariantRuntimePayload:
variant_id: str
operations: list[RuntimeOperation]
generated_at: datetime = field(default_factory=utcnow)

View File

@@ -0,0 +1,33 @@
from __future__ import annotations
from app.domain.editor import RuntimeOperation, VariantChange, VariantRuntimePayload
def build_runtime_payload_from_changes(
variant_id: str,
changes: list[VariantChange],
) -> VariantRuntimePayload:
"""Convert editor-facing change records into runtime-facing operations.
The builder keeps the runtime contract stable even if the editor UI later
changes how it stores intermediate draft state.
"""
operations: list[RuntimeOperation] = []
for change in sorted(changes, key=lambda item: item.sort_order):
action = change.payload.get("action") if isinstance(change.payload, dict) else None
operations.append(
RuntimeOperation(
selector_type=change.selector_type,
selector_value=change.selector_value,
action=action or change.change_type,
payload=change.payload,
)
)
return VariantRuntimePayload(
variant_id=variant_id,
operations=operations,
)

View File

@@ -0,0 +1,17 @@
from __future__ import annotations
import time
def _timestamp_ms() -> str:
return str(int(time.time() * 1000))
def generate_experiment_key() -> str:
"""Generate a system-managed experiment key, e.g. EX1742601234567."""
return f"EX{_timestamp_ms()}"
def generate_variant_key() -> str:
"""Generate a system-managed variant key, e.g. VA1742601234567."""
return f"VA{_timestamp_ms()}"

View File

@@ -0,0 +1,73 @@
from __future__ import annotations
from app.domain.admin import Experiment, ExperimentRelease, Goal, SdkConfig, Site, Variant
def to_site(raw: dict) -> Site:
return Site(
id=str(raw["id"]),
site_key=str(raw["site_key"]),
name=str(raw["name"]),
primary_domain=str(raw["primary_domain"]),
status=str(raw["status"]),
settings=raw.get("site_settings"),
)
def to_experiment(raw: dict) -> Experiment:
return Experiment(
id=str(raw["id"]),
site_id=str(raw["site_id"]),
experiment_key=str(raw["experiment_key"]),
name=str(raw["name"]),
module_type=str(raw["module_type"]),
status=str(raw["status"]),
start_at=raw.get("start_at"),
end_at=raw.get("end_at"),
targeting_config=raw.get("targeting_config"),
)
def to_variant(raw: dict) -> Variant:
return Variant(
id=str(raw["id"]),
experiment_id=str(raw["experiment_id"]),
variant_key=str(raw.get("variant_key", "")),
name=str(raw.get("name", "")),
traffic_weight=int(raw.get("traffic_weight", 0)),
content_config=raw.get("content_config"),
)
def to_release(raw: dict) -> ExperimentRelease:
return ExperimentRelease(
id=str(raw["id"]),
experiment_id=str(raw["experiment_id"]),
version_no=int(raw["version_no"]),
status=str(raw["status"]),
runtime_payload=raw.get("runtime_payload"),
)
def to_goal(raw: dict) -> Goal:
return Goal(
id=str(raw["id"]),
site_id=str(raw["site_id"]),
goal_key=str(raw["goal_key"]),
name=str(raw["name"]),
goal_type=str(raw["goal_type"]),
match_rule=raw.get("match_rule"),
)
def to_sdk_config(raw: dict) -> SdkConfig:
return SdkConfig(
id=str(raw["id"]),
site_id=str(raw["site_id"]),
sdk_key=str(raw["sdk_key"]),
status=str(raw["status"]),
origin_url=raw.get("origin_url"),
cdn_url=raw.get("cdn_url"),
sdk_config=raw.get("sdk_config"),
)

View File

@@ -0,0 +1,47 @@
from __future__ import annotations
from dataclasses import dataclass, field
from datetime import datetime, timezone
from typing import Any
from uuid import uuid4
def utcnow() -> datetime:
return datetime.now(timezone.utc)
@dataclass(slots=True)
class AuditLogEntry:
"""Business-level audit record for operator actions."""
action: str
actor_id: str | None = None
actor_email: str | None = None
target_type: str | None = None
target_id: str | None = None
source: str = "fastapi"
result: str = "accepted"
meta: dict[str, Any] | list[Any] | None = None
request_id: str | None = None
created_at: datetime = field(default_factory=utcnow)
id: str = field(default_factory=lambda: str(uuid4()))
@dataclass(slots=True)
class SystemEvent:
"""System/runtime event used for telemetry and forwarding pipelines."""
event_name: str
category: str
request_id: str | None = None
site_id: str | None = None
site_key: str | None = None
experiment_id: str | None = None
experiment_key: str | None = None
variant_id: str | None = None
variant_key: str | None = None
visitor_id: str | None = None
payload: dict[str, Any] | list[Any] | None = None
created_at: datetime = field(default_factory=utcnow)
id: str = field(default_factory=lambda: str(uuid4()))

Some files were not shown because too many files have changed in this diff Show More