session_service.go 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352
  1. package service
  2. import (
  3. "context"
  4. "encoding/json"
  5. "net/http"
  6. "strings"
  7. "time"
  8. "cmr-backend/internal/apperr"
  9. "cmr-backend/internal/platform/security"
  10. "cmr-backend/internal/store/postgres"
  11. )
  12. type SessionService struct {
  13. store *postgres.Store
  14. }
  15. type sessionTokenPolicy struct {
  16. AllowExpired bool
  17. }
  18. type SessionResult struct {
  19. Session struct {
  20. ID string `json:"id"`
  21. Status string `json:"status"`
  22. ClientType string `json:"clientType"`
  23. DeviceKey string `json:"deviceKey"`
  24. AssignmentMode *string `json:"assignmentMode,omitempty"`
  25. VariantID *string `json:"variantId,omitempty"`
  26. VariantName *string `json:"variantName,omitempty"`
  27. RouteCode *string `json:"routeCode,omitempty"`
  28. SessionTokenExpiresAt string `json:"sessionTokenExpiresAt"`
  29. LaunchedAt string `json:"launchedAt"`
  30. StartedAt *string `json:"startedAt,omitempty"`
  31. EndedAt *string `json:"endedAt,omitempty"`
  32. } `json:"session"`
  33. Event struct {
  34. ID string `json:"id"`
  35. DisplayName string `json:"displayName"`
  36. } `json:"event"`
  37. ResolvedRelease *ResolvedReleaseView `json:"resolvedRelease,omitempty"`
  38. }
  39. type SessionActionInput struct {
  40. SessionPublicID string
  41. SessionToken string `json:"sessionToken"`
  42. }
  43. type FinishSessionInput struct {
  44. SessionPublicID string
  45. SessionToken string `json:"sessionToken"`
  46. Status string `json:"status"`
  47. Summary *SessionSummaryInput `json:"summary,omitempty"`
  48. }
  49. type SessionSummaryInput struct {
  50. FinalDurationSec *int `json:"finalDurationSec,omitempty"`
  51. FinalScore *int `json:"finalScore,omitempty"`
  52. CompletedControls *int `json:"completedControls,omitempty"`
  53. TotalControls *int `json:"totalControls,omitempty"`
  54. DistanceMeters *float64 `json:"distanceMeters,omitempty"`
  55. AverageSpeedKmh *float64 `json:"averageSpeedKmh,omitempty"`
  56. MaxHeartRateBpm *int `json:"maxHeartRateBpm,omitempty"`
  57. }
  58. func NewSessionService(store *postgres.Store) *SessionService {
  59. return &SessionService{store: store}
  60. }
  61. func (s *SessionService) GetSession(ctx context.Context, sessionPublicID, userID string) (*SessionResult, error) {
  62. sessionPublicID = strings.TrimSpace(sessionPublicID)
  63. if sessionPublicID == "" {
  64. return nil, apperr.New(http.StatusBadRequest, "invalid_params", "session id is required")
  65. }
  66. session, err := s.store.GetSessionByPublicID(ctx, sessionPublicID)
  67. if err != nil {
  68. return nil, err
  69. }
  70. if session == nil {
  71. return nil, apperr.New(http.StatusNotFound, "session_not_found", "session not found")
  72. }
  73. if userID != "" && session.UserID != userID {
  74. return nil, apperr.New(http.StatusForbidden, "session_forbidden", "session does not belong to current user")
  75. }
  76. return buildSessionResult(session), nil
  77. }
  78. func (s *SessionService) ListMySessions(ctx context.Context, userID string, limit int) ([]SessionResult, error) {
  79. if userID == "" {
  80. return nil, apperr.New(http.StatusUnauthorized, "unauthorized", "user is required")
  81. }
  82. sessions, err := s.store.ListSessionsByUserID(ctx, userID, limit)
  83. if err != nil {
  84. return nil, err
  85. }
  86. results := make([]SessionResult, 0, len(sessions))
  87. for i := range sessions {
  88. results = append(results, *buildSessionResult(&sessions[i]))
  89. }
  90. return results, nil
  91. }
  92. func (s *SessionService) StartSession(ctx context.Context, input SessionActionInput) (*SessionResult, error) {
  93. session, err := s.validateSessionAction(ctx, input.SessionPublicID, input.SessionToken, sessionTokenPolicy{})
  94. if err != nil {
  95. return nil, err
  96. }
  97. if session.Status == SessionStatusRunning || isSessionTerminalStatus(session.Status) {
  98. return buildSessionResult(session), nil
  99. }
  100. tx, err := s.store.Begin(ctx)
  101. if err != nil {
  102. return nil, err
  103. }
  104. defer tx.Rollback(ctx)
  105. locked, err := s.store.GetSessionByPublicIDForUpdate(ctx, tx, input.SessionPublicID)
  106. if err != nil {
  107. return nil, err
  108. }
  109. if locked == nil {
  110. return nil, apperr.New(http.StatusNotFound, "session_not_found", "session not found")
  111. }
  112. if err := s.verifySessionToken(locked, input.SessionToken, sessionTokenPolicy{}); err != nil {
  113. return nil, err
  114. }
  115. if locked.Status == SessionStatusRunning || isSessionTerminalStatus(locked.Status) {
  116. if err := tx.Commit(ctx); err != nil {
  117. return nil, err
  118. }
  119. return buildSessionResult(locked), nil
  120. }
  121. if locked.Status == SessionStatusLaunched {
  122. if err := s.store.StartSession(ctx, tx, locked.ID); err != nil {
  123. return nil, err
  124. }
  125. }
  126. updated, err := s.store.GetSessionByPublicIDForUpdate(ctx, tx, input.SessionPublicID)
  127. if err != nil {
  128. return nil, err
  129. }
  130. if updated == nil {
  131. return nil, apperr.New(http.StatusNotFound, "session_not_found", "session not found")
  132. }
  133. if err := tx.Commit(ctx); err != nil {
  134. return nil, err
  135. }
  136. return buildSessionResult(updated), nil
  137. }
  138. func (s *SessionService) FinishSession(ctx context.Context, input FinishSessionInput) (*SessionResult, error) {
  139. status, err := normalizeFinishStatus(input.Status)
  140. if err != nil {
  141. return nil, err
  142. }
  143. input.Status = status
  144. session, err := s.validateSessionAction(ctx, input.SessionPublicID, input.SessionToken, sessionTokenPolicy{
  145. AllowExpired: input.Status == SessionStatusCancelled,
  146. })
  147. if err != nil {
  148. return nil, err
  149. }
  150. if isSessionTerminalStatus(session.Status) {
  151. return buildSessionResult(session), nil
  152. }
  153. tx, err := s.store.Begin(ctx)
  154. if err != nil {
  155. return nil, err
  156. }
  157. defer tx.Rollback(ctx)
  158. locked, err := s.store.GetSessionByPublicIDForUpdate(ctx, tx, input.SessionPublicID)
  159. if err != nil {
  160. return nil, err
  161. }
  162. if locked == nil {
  163. return nil, apperr.New(http.StatusNotFound, "session_not_found", "session not found")
  164. }
  165. if err := s.verifySessionToken(locked, input.SessionToken, sessionTokenPolicy{
  166. AllowExpired: input.Status == SessionStatusCancelled || isSessionTerminalStatus(locked.Status),
  167. }); err != nil {
  168. return nil, err
  169. }
  170. if isSessionTerminalStatus(locked.Status) {
  171. if err := tx.Commit(ctx); err != nil {
  172. return nil, err
  173. }
  174. return buildSessionResult(locked), nil
  175. }
  176. if err := s.store.FinishSession(ctx, tx, postgres.FinishSessionParams{
  177. SessionID: locked.ID,
  178. Status: input.Status,
  179. }); err != nil {
  180. return nil, err
  181. }
  182. updated, err := s.store.GetSessionByPublicIDForUpdate(ctx, tx, input.SessionPublicID)
  183. if err != nil {
  184. return nil, err
  185. }
  186. if _, err := s.store.UpsertSessionResult(ctx, tx, postgres.UpsertSessionResultParams{
  187. SessionID: updated.ID,
  188. ResultStatus: input.Status,
  189. Summary: buildSummaryMap(input.Summary),
  190. FinalDurationSec: resolveDurationSeconds(updated, input.Summary),
  191. FinalScore: summaryInt(input.Summary, func(v *SessionSummaryInput) *int { return v.FinalScore }),
  192. CompletedControls: summaryInt(input.Summary, func(v *SessionSummaryInput) *int { return v.CompletedControls }),
  193. TotalControls: summaryInt(input.Summary, func(v *SessionSummaryInput) *int { return v.TotalControls }),
  194. DistanceMeters: summaryFloat(input.Summary, func(v *SessionSummaryInput) *float64 { return v.DistanceMeters }),
  195. AverageSpeedKmh: summaryFloat(input.Summary, func(v *SessionSummaryInput) *float64 { return v.AverageSpeedKmh }),
  196. MaxHeartRateBpm: summaryInt(input.Summary, func(v *SessionSummaryInput) *int { return v.MaxHeartRateBpm }),
  197. }); err != nil {
  198. return nil, err
  199. }
  200. if err := tx.Commit(ctx); err != nil {
  201. return nil, err
  202. }
  203. return buildSessionResult(updated), nil
  204. }
  205. func (s *SessionService) validateSessionAction(ctx context.Context, sessionPublicID, sessionToken string, policy sessionTokenPolicy) (*postgres.Session, error) {
  206. sessionPublicID = strings.TrimSpace(sessionPublicID)
  207. sessionToken = strings.TrimSpace(sessionToken)
  208. if sessionPublicID == "" || sessionToken == "" {
  209. return nil, apperr.New(http.StatusBadRequest, "invalid_params", "session id and sessionToken are required")
  210. }
  211. session, err := s.store.GetSessionByPublicID(ctx, sessionPublicID)
  212. if err != nil {
  213. return nil, err
  214. }
  215. if session == nil {
  216. return nil, apperr.New(http.StatusNotFound, "session_not_found", "session not found")
  217. }
  218. if err := s.verifySessionToken(session, sessionToken, policy); err != nil {
  219. return nil, err
  220. }
  221. return session, nil
  222. }
  223. func (s *SessionService) verifySessionToken(session *postgres.Session, sessionToken string, policy sessionTokenPolicy) error {
  224. if session.SessionTokenHash != security.HashText(sessionToken) {
  225. return apperr.New(http.StatusUnauthorized, "invalid_session_token", "invalid session token")
  226. }
  227. if !policy.AllowExpired && session.SessionTokenExpiresAt.Before(time.Now().UTC()) {
  228. return apperr.New(http.StatusUnauthorized, "session_token_expired", "session token expired")
  229. }
  230. return nil
  231. }
  232. func buildSessionResult(session *postgres.Session) *SessionResult {
  233. result := &SessionResult{}
  234. result.Session.ID = session.SessionPublicID
  235. result.Session.Status = session.Status
  236. result.Session.ClientType = session.ClientType
  237. result.Session.DeviceKey = session.DeviceKey
  238. result.Session.AssignmentMode = session.AssignmentMode
  239. result.Session.VariantID = session.VariantID
  240. result.Session.VariantName = session.VariantName
  241. result.Session.RouteCode = session.RouteCode
  242. result.Session.SessionTokenExpiresAt = session.SessionTokenExpiresAt.Format(time.RFC3339)
  243. result.Session.LaunchedAt = session.LaunchedAt.Format(time.RFC3339)
  244. if session.StartedAt != nil {
  245. value := session.StartedAt.Format(time.RFC3339)
  246. result.Session.StartedAt = &value
  247. }
  248. if session.EndedAt != nil {
  249. value := session.EndedAt.Format(time.RFC3339)
  250. result.Session.EndedAt = &value
  251. }
  252. if session.EventPublicID != nil {
  253. result.Event.ID = *session.EventPublicID
  254. }
  255. if session.EventDisplayName != nil {
  256. result.Event.DisplayName = *session.EventDisplayName
  257. }
  258. result.ResolvedRelease = buildResolvedReleaseFromSession(session, LaunchSourceEventCurrentRelease)
  259. return result
  260. }
  261. func normalizeFinishStatus(value string) (string, error) {
  262. switch strings.TrimSpace(value) {
  263. case "", SessionStatusFinished:
  264. return SessionStatusFinished, nil
  265. case SessionStatusFailed:
  266. return SessionStatusFailed, nil
  267. case SessionStatusCancelled:
  268. return SessionStatusCancelled, nil
  269. default:
  270. return "", apperr.New(http.StatusBadRequest, "invalid_finish_status", "status must be finished, failed or cancelled")
  271. }
  272. }
  273. func buildSummaryMap(summary *SessionSummaryInput) map[string]any {
  274. if summary == nil {
  275. return map[string]any{}
  276. }
  277. raw, err := json.Marshal(summary)
  278. if err != nil {
  279. return map[string]any{}
  280. }
  281. result := map[string]any{}
  282. if err := json.Unmarshal(raw, &result); err != nil {
  283. return map[string]any{}
  284. }
  285. return result
  286. }
  287. func resolveDurationSeconds(session *postgres.Session, summary *SessionSummaryInput) *int {
  288. if summary != nil && summary.FinalDurationSec != nil {
  289. return summary.FinalDurationSec
  290. }
  291. if session.StartedAt != nil {
  292. endAt := time.Now().UTC()
  293. if session.EndedAt != nil {
  294. endAt = *session.EndedAt
  295. }
  296. seconds := int(endAt.Sub(*session.StartedAt).Seconds())
  297. if seconds < 0 {
  298. seconds = 0
  299. }
  300. return &seconds
  301. }
  302. return nil
  303. }
  304. func summaryInt(summary *SessionSummaryInput, getter func(*SessionSummaryInput) *int) *int {
  305. if summary == nil {
  306. return nil
  307. }
  308. return getter(summary)
  309. }
  310. func summaryFloat(summary *SessionSummaryInput, getter func(*SessionSummaryInput) *float64) *float64 {
  311. if summary == nil {
  312. return nil
  313. }
  314. return getter(summary)
  315. }