session_service.go 10 KB

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