session_handler.go 8.7 KB

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