session_handler.go 8.0 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256
  1. package handlers
  2. import (
  3. "errors"
  4. "net/http"
  5. "strings"
  6. "time"
  7. "github.com/celestia-trace/backend/models"
  8. "github.com/gin-gonic/gin"
  9. "gorm.io/gorm"
  10. )
  11. type SessionHandler struct {
  12. DB *gorm.DB
  13. }
  14. func sessionPreloads(db *gorm.DB) *gorm.DB {
  15. return db.Preload("Events", func(events *gorm.DB) *gorm.DB {
  16. return events.Order("relative_time_ms asc")
  17. }).Preload("Assets")
  18. }
  19. // GetSessions supports cursor-like incremental pulls with updatedAfter and can
  20. // include deletion tombstones for multi-device reconciliation.
  21. func (h *SessionHandler) GetSessions(c *gin.Context) {
  22. userID := c.GetString("userID")
  23. query := sessionPreloads(h.DB).Where("user_id = ?", userID)
  24. if c.Query("includeDeleted") != "true" {
  25. query = query.Where("deleted_at IS NULL")
  26. }
  27. if search := c.Query("search"); search != "" {
  28. query = query.Where("title ILIKE ?", "%"+search+"%")
  29. }
  30. if value := c.Query("updatedAfter"); value != "" {
  31. updatedAfter, err := time.Parse(time.RFC3339Nano, value)
  32. if err != nil {
  33. respondError(c, http.StatusBadRequest, 400, "invalid updatedAfter timestamp")
  34. return
  35. }
  36. query = query.Where("updated_at > ?", updatedAfter)
  37. }
  38. var sessions []models.CelestiaSession
  39. if err := query.Order("updated_at asc, id asc").Find(&sessions).Error; err != nil {
  40. respondError(c, http.StatusInternalServerError, 500, "failed to query field records")
  41. return
  42. }
  43. respondSuccess(c, sessions)
  44. }
  45. func (h *SessionHandler) GetSessionDetail(c *gin.Context) {
  46. userID := c.GetString("userID")
  47. var session models.CelestiaSession
  48. if err := sessionPreloads(h.DB).
  49. Where("id = ? AND user_id = ? AND deleted_at IS NULL", c.Param("id"), userID).
  50. First(&session).Error; err != nil {
  51. respondError(c, http.StatusNotFound, 404, "record not found")
  52. return
  53. }
  54. respondSuccess(c, session)
  55. }
  56. // CreateSession is an idempotent upsert keyed by (user_id, client_id).
  57. func (h *SessionHandler) CreateSession(c *gin.Context) {
  58. userID := c.GetString("userID")
  59. var req models.CreateSessionRequest
  60. if err := c.ShouldBindJSON(&req); err != nil {
  61. respondError(c, http.StatusBadRequest, 400, "invalid input data")
  62. return
  63. }
  64. var session models.CelestiaSession
  65. var conflictRevision int64
  66. var isConflict bool
  67. err := h.DB.Transaction(func(tx *gorm.DB) error {
  68. err := tx.Where("user_id = ? AND client_id = ?", userID, req.ClientID).First(&session).Error
  69. if errors.Is(err, gorm.ErrRecordNotFound) {
  70. clientID := req.ClientID
  71. session = models.CelestiaSession{
  72. UserID: userID, ClientID: &clientID, Revision: 1,
  73. }
  74. } else if err != nil {
  75. return err
  76. } else {
  77. // Optimistic locking: reject if client baseRevision doesn't match
  78. if req.BaseRevision != nil && *req.BaseRevision != session.Revision {
  79. conflictRevision = session.Revision
  80. isConflict = true
  81. return nil // exit transaction without error; handled below
  82. }
  83. session.Revision++
  84. }
  85. session.Title = req.Title
  86. session.StartTime = req.StartTime
  87. session.EndTime = req.EndTime
  88. session.DurationMs = req.DurationMs
  89. session.LocalAudioPath = ""
  90. session.IsSynced = true
  91. session.DeletedAt = nil
  92. session.PhotoCount, session.NoteCount = countEvents(req.Events)
  93. if session.ID == "" {
  94. if err := tx.Create(&session).Error; err != nil {
  95. return err
  96. }
  97. } else if err := tx.Save(&session).Error; err != nil {
  98. return err
  99. }
  100. for _, input := range req.Events {
  101. var event models.TimelineEvent
  102. err := tx.Where("session_id = ? AND client_id = ?", session.ID, input.ClientID).First(&event).Error
  103. if errors.Is(err, gorm.ErrRecordNotFound) {
  104. clientID := input.ClientID
  105. event = models.TimelineEvent{SessionID: session.ID, ClientID: &clientID}
  106. } else if err != nil {
  107. return err
  108. }
  109. event.RelativeTimeMs = input.RelativeTimeMs
  110. event.EventType = input.EventType
  111. event.TextContent = input.TextContent
  112. event.LocalFilePath = ""
  113. event.VoiceStartOffsetMs = input.VoiceStartOffsetMs
  114. event.VoiceEndOffsetMs = input.VoiceEndOffsetMs
  115. if event.ID == "" {
  116. if err := tx.Create(&event).Error; err != nil {
  117. return err
  118. }
  119. } else if err := tx.Save(&event).Error; err != nil {
  120. return err
  121. }
  122. }
  123. // Delete events that were removed on the client
  124. if len(req.DeletedEventClientIds) > 0 {
  125. if err := tx.Where("session_id = ? AND client_id IN ?", session.ID, req.DeletedEventClientIds).
  126. Delete(&models.TimelineEvent{}).Error; err != nil {
  127. return err
  128. }
  129. // Recalculate counts from the remaining DB events
  130. var remaining []models.TimelineEvent
  131. if err := tx.Where("session_id = ?", session.ID).Find(&remaining).Error; err != nil {
  132. return err
  133. }
  134. session.PhotoCount, session.NoteCount = countPersistedEvents(remaining)
  135. if err := tx.Save(&session).Error; err != nil {
  136. return err
  137. }
  138. }
  139. return nil
  140. })
  141. if isConflict {
  142. c.JSON(http.StatusConflict, gin.H{
  143. "code": 409, "message": "version conflict",
  144. "data": gin.H{"serverRevision": conflictRevision},
  145. })
  146. return
  147. }
  148. if err != nil {
  149. respondError(c, http.StatusInternalServerError, 500, "failed to save record")
  150. return
  151. }
  152. if err := sessionPreloads(h.DB).First(&session, "id = ?", session.ID).Error; err != nil {
  153. respondError(c, http.StatusInternalServerError, 500, "failed to reload record")
  154. return
  155. }
  156. respondSuccess(c, session)
  157. }
  158. func countEvents(events []models.CreateEventSubSchema) (photoCount, noteCount int) {
  159. for _, event := range events {
  160. switch event.EventType {
  161. case "PHOTO":
  162. photoCount++
  163. case "NOTE":
  164. noteCount++
  165. case "MARKER":
  166. // Exclude continuation markers (e.g. "续录时间:...") from note count
  167. if !strings.HasPrefix(event.TextContent, "续录时间:") {
  168. noteCount++
  169. }
  170. case "CONTINUATION":
  171. // Dedicated continuation type, not counted as a note
  172. }
  173. }
  174. return
  175. }
  176. // countPersistedEvents recalculates counts from persisted TimelineEvent rows
  177. // (used after event deletion to keep counts accurate).
  178. func countPersistedEvents(events []models.TimelineEvent) (photoCount, noteCount int) {
  179. for _, event := range events {
  180. switch event.EventType {
  181. case "PHOTO":
  182. photoCount++
  183. case "NOTE":
  184. noteCount++
  185. case "MARKER":
  186. if !strings.HasPrefix(event.TextContent, "续录时间:") {
  187. noteCount++
  188. }
  189. case "CONTINUATION":
  190. // Not counted
  191. }
  192. }
  193. return
  194. }
  195. func (h *SessionHandler) AddEventToSession(c *gin.Context) {
  196. userID := c.GetString("userID")
  197. sessionID := c.Param("id")
  198. var session models.CelestiaSession
  199. if err := h.DB.Where("id = ? AND user_id = ? AND deleted_at IS NULL", sessionID, userID).First(&session).Error; err != nil {
  200. respondError(c, http.StatusNotFound, 404, "record not found")
  201. return
  202. }
  203. var req models.CreateEventRequest
  204. if err := c.ShouldBindJSON(&req); err != nil {
  205. respondError(c, http.StatusBadRequest, 400, "invalid event payload")
  206. return
  207. }
  208. clientID := req.ClientID
  209. event := models.TimelineEvent{
  210. SessionID: session.ID, ClientID: &clientID,
  211. RelativeTimeMs: req.RelativeTimeMs, EventType: req.EventType,
  212. TextContent: req.TextContent, VoiceStartOffsetMs: req.VoiceStartOffsetMs,
  213. VoiceEndOffsetMs: req.VoiceEndOffsetMs,
  214. }
  215. if err := h.DB.Where("session_id = ? AND client_id = ?", session.ID, req.ClientID).
  216. Assign(event).FirstOrCreate(&event).Error; err != nil {
  217. respondError(c, http.StatusInternalServerError, 500, "failed to add event")
  218. return
  219. }
  220. h.DB.Model(&session).Updates(map[string]interface{}{"revision": gorm.Expr("revision + 1")})
  221. respondSuccess(c, event)
  222. }
  223. // DeleteSession creates a tombstone rather than hard deleting cloud data.
  224. func (h *SessionHandler) DeleteSession(c *gin.Context) {
  225. userID := c.GetString("userID")
  226. sessionID := c.Param("id")
  227. now := time.Now().UTC()
  228. result := h.DB.Model(&models.CelestiaSession{}).
  229. Where("id = ? AND user_id = ? AND deleted_at IS NULL", sessionID, userID).
  230. Updates(map[string]interface{}{"deleted_at": &now, "revision": gorm.Expr("revision + 1")})
  231. if result.Error != nil {
  232. respondError(c, http.StatusInternalServerError, 500, "failed to delete record")
  233. return
  234. }
  235. if result.RowsAffected == 0 {
  236. respondError(c, http.StatusNotFound, 404, "record not found")
  237. return
  238. }
  239. respondSuccess(c, gin.H{"deletedId": sessionID, "deletedAt": now})
  240. }