session_service.go 11 KB

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