Add backend foundation and config-driven workbench
This commit is contained in:
324
backend/internal/service/session_service.go
Normal file
324
backend/internal/service/session_service.go
Normal file
@@ -0,0 +1,324 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"cmr-backend/internal/apperr"
|
||||
"cmr-backend/internal/platform/security"
|
||||
"cmr-backend/internal/store/postgres"
|
||||
)
|
||||
|
||||
type SessionService struct {
|
||||
store *postgres.Store
|
||||
}
|
||||
|
||||
type SessionResult struct {
|
||||
Session struct {
|
||||
ID string `json:"id"`
|
||||
Status string `json:"status"`
|
||||
ClientType string `json:"clientType"`
|
||||
DeviceKey string `json:"deviceKey"`
|
||||
RouteCode *string `json:"routeCode,omitempty"`
|
||||
SessionTokenExpiresAt string `json:"sessionTokenExpiresAt"`
|
||||
LaunchedAt string `json:"launchedAt"`
|
||||
StartedAt *string `json:"startedAt,omitempty"`
|
||||
EndedAt *string `json:"endedAt,omitempty"`
|
||||
} `json:"session"`
|
||||
Event struct {
|
||||
ID string `json:"id"`
|
||||
DisplayName string `json:"displayName"`
|
||||
} `json:"event"`
|
||||
ResolvedRelease *ResolvedReleaseView `json:"resolvedRelease,omitempty"`
|
||||
}
|
||||
|
||||
type SessionActionInput struct {
|
||||
SessionPublicID string
|
||||
SessionToken string `json:"sessionToken"`
|
||||
}
|
||||
|
||||
type FinishSessionInput struct {
|
||||
SessionPublicID string
|
||||
SessionToken string `json:"sessionToken"`
|
||||
Status string `json:"status"`
|
||||
Summary *SessionSummaryInput `json:"summary,omitempty"`
|
||||
}
|
||||
|
||||
type SessionSummaryInput struct {
|
||||
FinalDurationSec *int `json:"finalDurationSec,omitempty"`
|
||||
FinalScore *int `json:"finalScore,omitempty"`
|
||||
CompletedControls *int `json:"completedControls,omitempty"`
|
||||
TotalControls *int `json:"totalControls,omitempty"`
|
||||
DistanceMeters *float64 `json:"distanceMeters,omitempty"`
|
||||
AverageSpeedKmh *float64 `json:"averageSpeedKmh,omitempty"`
|
||||
MaxHeartRateBpm *int `json:"maxHeartRateBpm,omitempty"`
|
||||
}
|
||||
|
||||
func NewSessionService(store *postgres.Store) *SessionService {
|
||||
return &SessionService{store: store}
|
||||
}
|
||||
|
||||
func (s *SessionService) GetSession(ctx context.Context, sessionPublicID, userID string) (*SessionResult, error) {
|
||||
sessionPublicID = strings.TrimSpace(sessionPublicID)
|
||||
if sessionPublicID == "" {
|
||||
return nil, apperr.New(http.StatusBadRequest, "invalid_params", "session id is required")
|
||||
}
|
||||
|
||||
session, err := s.store.GetSessionByPublicID(ctx, sessionPublicID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if session == nil {
|
||||
return nil, apperr.New(http.StatusNotFound, "session_not_found", "session not found")
|
||||
}
|
||||
if userID != "" && session.UserID != userID {
|
||||
return nil, apperr.New(http.StatusForbidden, "session_forbidden", "session does not belong to current user")
|
||||
}
|
||||
|
||||
return buildSessionResult(session), nil
|
||||
}
|
||||
|
||||
func (s *SessionService) ListMySessions(ctx context.Context, userID string, limit int) ([]SessionResult, error) {
|
||||
if userID == "" {
|
||||
return nil, apperr.New(http.StatusUnauthorized, "unauthorized", "user is required")
|
||||
}
|
||||
|
||||
sessions, err := s.store.ListSessionsByUserID(ctx, userID, limit)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
results := make([]SessionResult, 0, len(sessions))
|
||||
for i := range sessions {
|
||||
results = append(results, *buildSessionResult(&sessions[i]))
|
||||
}
|
||||
return results, nil
|
||||
}
|
||||
|
||||
func (s *SessionService) StartSession(ctx context.Context, input SessionActionInput) (*SessionResult, error) {
|
||||
session, err := s.validateSessionAction(ctx, input.SessionPublicID, input.SessionToken)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if session.Status == "finished" || session.Status == "cancelled" || session.Status == "failed" {
|
||||
return nil, apperr.New(http.StatusConflict, "session_not_startable", "session cannot be started")
|
||||
}
|
||||
|
||||
tx, err := s.store.Begin(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer tx.Rollback(ctx)
|
||||
|
||||
locked, err := s.store.GetSessionByPublicIDForUpdate(ctx, tx, input.SessionPublicID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if locked == nil {
|
||||
return nil, apperr.New(http.StatusNotFound, "session_not_found", "session not found")
|
||||
}
|
||||
if err := s.verifySessionToken(locked, input.SessionToken); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if locked.Status == "finished" || locked.Status == "cancelled" || locked.Status == "failed" {
|
||||
return nil, apperr.New(http.StatusConflict, "session_not_startable", "session cannot be started")
|
||||
}
|
||||
|
||||
if err := s.store.StartSession(ctx, tx, locked.ID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
updated, err := s.store.GetSessionByPublicIDForUpdate(ctx, tx, input.SessionPublicID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := tx.Commit(ctx); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return buildSessionResult(updated), nil
|
||||
}
|
||||
|
||||
func (s *SessionService) FinishSession(ctx context.Context, input FinishSessionInput) (*SessionResult, error) {
|
||||
input.Status = normalizeFinishStatus(input.Status)
|
||||
session, err := s.validateSessionAction(ctx, input.SessionPublicID, input.SessionToken)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if session.Status == "finished" || session.Status == "cancelled" || session.Status == "failed" {
|
||||
return buildSessionResult(session), nil
|
||||
}
|
||||
|
||||
tx, err := s.store.Begin(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer tx.Rollback(ctx)
|
||||
|
||||
locked, err := s.store.GetSessionByPublicIDForUpdate(ctx, tx, input.SessionPublicID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if locked == nil {
|
||||
return nil, apperr.New(http.StatusNotFound, "session_not_found", "session not found")
|
||||
}
|
||||
if err := s.verifySessionToken(locked, input.SessionToken); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if locked.Status == "finished" || locked.Status == "cancelled" || locked.Status == "failed" {
|
||||
if err := tx.Commit(ctx); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return buildSessionResult(locked), nil
|
||||
}
|
||||
|
||||
if err := s.store.FinishSession(ctx, tx, postgres.FinishSessionParams{
|
||||
SessionID: locked.ID,
|
||||
Status: input.Status,
|
||||
}); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
updated, err := s.store.GetSessionByPublicIDForUpdate(ctx, tx, input.SessionPublicID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if _, err := s.store.UpsertSessionResult(ctx, tx, postgres.UpsertSessionResultParams{
|
||||
SessionID: updated.ID,
|
||||
ResultStatus: input.Status,
|
||||
Summary: buildSummaryMap(input.Summary),
|
||||
FinalDurationSec: resolveDurationSeconds(updated, input.Summary),
|
||||
FinalScore: summaryInt(input.Summary, func(v *SessionSummaryInput) *int { return v.FinalScore }),
|
||||
CompletedControls: summaryInt(input.Summary, func(v *SessionSummaryInput) *int { return v.CompletedControls }),
|
||||
TotalControls: summaryInt(input.Summary, func(v *SessionSummaryInput) *int { return v.TotalControls }),
|
||||
DistanceMeters: summaryFloat(input.Summary, func(v *SessionSummaryInput) *float64 { return v.DistanceMeters }),
|
||||
AverageSpeedKmh: summaryFloat(input.Summary, func(v *SessionSummaryInput) *float64 { return v.AverageSpeedKmh }),
|
||||
MaxHeartRateBpm: summaryInt(input.Summary, func(v *SessionSummaryInput) *int { return v.MaxHeartRateBpm }),
|
||||
}); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := tx.Commit(ctx); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return buildSessionResult(updated), nil
|
||||
}
|
||||
|
||||
func (s *SessionService) validateSessionAction(ctx context.Context, sessionPublicID, sessionToken string) (*postgres.Session, error) {
|
||||
sessionPublicID = strings.TrimSpace(sessionPublicID)
|
||||
sessionToken = strings.TrimSpace(sessionToken)
|
||||
if sessionPublicID == "" || sessionToken == "" {
|
||||
return nil, apperr.New(http.StatusBadRequest, "invalid_params", "session id and sessionToken are required")
|
||||
}
|
||||
|
||||
session, err := s.store.GetSessionByPublicID(ctx, sessionPublicID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if session == nil {
|
||||
return nil, apperr.New(http.StatusNotFound, "session_not_found", "session not found")
|
||||
}
|
||||
if err := s.verifySessionToken(session, sessionToken); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return session, nil
|
||||
}
|
||||
|
||||
func (s *SessionService) verifySessionToken(session *postgres.Session, sessionToken string) error {
|
||||
if session.SessionTokenExpiresAt.Before(time.Now().UTC()) {
|
||||
return apperr.New(http.StatusUnauthorized, "session_token_expired", "session token expired")
|
||||
}
|
||||
if session.SessionTokenHash != security.HashText(sessionToken) {
|
||||
return apperr.New(http.StatusUnauthorized, "invalid_session_token", "invalid session token")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func buildSessionResult(session *postgres.Session) *SessionResult {
|
||||
result := &SessionResult{}
|
||||
result.Session.ID = session.SessionPublicID
|
||||
result.Session.Status = session.Status
|
||||
result.Session.ClientType = session.ClientType
|
||||
result.Session.DeviceKey = session.DeviceKey
|
||||
result.Session.RouteCode = session.RouteCode
|
||||
result.Session.SessionTokenExpiresAt = session.SessionTokenExpiresAt.Format(time.RFC3339)
|
||||
result.Session.LaunchedAt = session.LaunchedAt.Format(time.RFC3339)
|
||||
if session.StartedAt != nil {
|
||||
value := session.StartedAt.Format(time.RFC3339)
|
||||
result.Session.StartedAt = &value
|
||||
}
|
||||
if session.EndedAt != nil {
|
||||
value := session.EndedAt.Format(time.RFC3339)
|
||||
result.Session.EndedAt = &value
|
||||
}
|
||||
if session.EventPublicID != nil {
|
||||
result.Event.ID = *session.EventPublicID
|
||||
}
|
||||
if session.EventDisplayName != nil {
|
||||
result.Event.DisplayName = *session.EventDisplayName
|
||||
}
|
||||
result.ResolvedRelease = buildResolvedReleaseFromSession(session, LaunchSourceEventCurrentRelease)
|
||||
return result
|
||||
}
|
||||
|
||||
func normalizeFinishStatus(value string) string {
|
||||
switch strings.TrimSpace(value) {
|
||||
case "failed":
|
||||
return "failed"
|
||||
case "cancelled":
|
||||
return "cancelled"
|
||||
default:
|
||||
return "finished"
|
||||
}
|
||||
}
|
||||
|
||||
func buildSummaryMap(summary *SessionSummaryInput) map[string]any {
|
||||
if summary == nil {
|
||||
return map[string]any{}
|
||||
}
|
||||
raw, err := json.Marshal(summary)
|
||||
if err != nil {
|
||||
return map[string]any{}
|
||||
}
|
||||
result := map[string]any{}
|
||||
if err := json.Unmarshal(raw, &result); err != nil {
|
||||
return map[string]any{}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func resolveDurationSeconds(session *postgres.Session, summary *SessionSummaryInput) *int {
|
||||
if summary != nil && summary.FinalDurationSec != nil {
|
||||
return summary.FinalDurationSec
|
||||
}
|
||||
if session.StartedAt != nil {
|
||||
endAt := time.Now().UTC()
|
||||
if session.EndedAt != nil {
|
||||
endAt = *session.EndedAt
|
||||
}
|
||||
seconds := int(endAt.Sub(*session.StartedAt).Seconds())
|
||||
if seconds < 0 {
|
||||
seconds = 0
|
||||
}
|
||||
return &seconds
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func summaryInt(summary *SessionSummaryInput, getter func(*SessionSummaryInput) *int) *int {
|
||||
if summary == nil {
|
||||
return nil
|
||||
}
|
||||
return getter(summary)
|
||||
}
|
||||
|
||||
func summaryFloat(summary *SessionSummaryInput, getter func(*SessionSummaryInput) *float64) *float64 {
|
||||
if summary == nil {
|
||||
return nil
|
||||
}
|
||||
return getter(summary)
|
||||
}
|
||||
Reference in New Issue
Block a user