session_handler.go 8.3 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262
  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. event.LocationName = input.LocationName
  116. event.LocationAddress = input.LocationAddress
  117. event.Latitude = input.Latitude
  118. event.Longitude = input.Longitude
  119. if event.ID == "" {
  120. if err := tx.Create(&event).Error; err != nil {
  121. return err
  122. }
  123. } else if err := tx.Save(&event).Error; err != nil {
  124. return err
  125. }
  126. }
  127. // Delete events that were removed on the client
  128. if len(req.DeletedEventClientIds) > 0 {
  129. if err := tx.Where("session_id = ? AND client_id IN ?", session.ID, req.DeletedEventClientIds).
  130. Delete(&models.TimelineEvent{}).Error; err != nil {
  131. return err
  132. }
  133. // Recalculate counts from the remaining DB events
  134. var remaining []models.TimelineEvent
  135. if err := tx.Where("session_id = ?", session.ID).Find(&remaining).Error; err != nil {
  136. return err
  137. }
  138. session.PhotoCount, session.NoteCount = countPersistedEvents(remaining)
  139. if err := tx.Save(&session).Error; err != nil {
  140. return err
  141. }
  142. }
  143. return nil
  144. })
  145. if isConflict {
  146. c.JSON(http.StatusConflict, gin.H{
  147. "code": 409, "message": "version conflict",
  148. "data": gin.H{"serverRevision": conflictRevision},
  149. })
  150. return
  151. }
  152. if err != nil {
  153. respondError(c, http.StatusInternalServerError, 500, "failed to save record")
  154. return
  155. }
  156. if err := sessionPreloads(h.DB).First(&session, "id = ?", session.ID).Error; err != nil {
  157. respondError(c, http.StatusInternalServerError, 500, "failed to reload record")
  158. return
  159. }
  160. respondSuccess(c, session)
  161. }
  162. func countEvents(events []models.CreateEventSubSchema) (photoCount, noteCount int) {
  163. for _, event := range events {
  164. switch event.EventType {
  165. case "PHOTO":
  166. photoCount++
  167. case "NOTE":
  168. noteCount++
  169. case "MARKER":
  170. // Exclude continuation markers (e.g. "续录时间:...") from note count
  171. if !strings.HasPrefix(event.TextContent, "续录时间:") {
  172. noteCount++
  173. }
  174. case "CONTINUATION":
  175. // Dedicated continuation type, not counted as a note
  176. }
  177. }
  178. return
  179. }
  180. // countPersistedEvents recalculates counts from persisted TimelineEvent rows
  181. // (used after event deletion to keep counts accurate).
  182. func countPersistedEvents(events []models.TimelineEvent) (photoCount, noteCount int) {
  183. for _, event := range events {
  184. switch event.EventType {
  185. case "PHOTO":
  186. photoCount++
  187. case "NOTE":
  188. noteCount++
  189. case "MARKER":
  190. if !strings.HasPrefix(event.TextContent, "续录时间:") {
  191. noteCount++
  192. }
  193. case "CONTINUATION":
  194. // Not counted
  195. }
  196. }
  197. return
  198. }
  199. func (h *SessionHandler) AddEventToSession(c *gin.Context) {
  200. userID := c.GetString("userID")
  201. sessionID := c.Param("id")
  202. var session models.CelestiaSession
  203. if err := h.DB.Where("id = ? AND user_id = ? AND deleted_at IS NULL", sessionID, userID).First(&session).Error; err != nil {
  204. respondError(c, http.StatusNotFound, 404, "record not found")
  205. return
  206. }
  207. var req models.CreateEventRequest
  208. if err := c.ShouldBindJSON(&req); err != nil {
  209. respondError(c, http.StatusBadRequest, 400, "invalid event payload")
  210. return
  211. }
  212. clientID := req.ClientID
  213. event := models.TimelineEvent{
  214. SessionID: session.ID, ClientID: &clientID,
  215. RelativeTimeMs: req.RelativeTimeMs, EventType: req.EventType,
  216. TextContent: req.TextContent, VoiceStartOffsetMs: req.VoiceStartOffsetMs,
  217. VoiceEndOffsetMs: req.VoiceEndOffsetMs,
  218. LocationName: req.LocationName, LocationAddress: req.LocationAddress,
  219. Latitude: req.Latitude, Longitude: req.Longitude,
  220. }
  221. if err := h.DB.Where("session_id = ? AND client_id = ?", session.ID, req.ClientID).
  222. Assign(event).FirstOrCreate(&event).Error; err != nil {
  223. respondError(c, http.StatusInternalServerError, 500, "failed to add event")
  224. return
  225. }
  226. h.DB.Model(&session).Updates(map[string]interface{}{"revision": gorm.Expr("revision + 1")})
  227. respondSuccess(c, event)
  228. }
  229. // DeleteSession creates a tombstone rather than hard deleting cloud data.
  230. func (h *SessionHandler) DeleteSession(c *gin.Context) {
  231. userID := c.GetString("userID")
  232. sessionID := c.Param("id")
  233. now := time.Now().UTC()
  234. result := h.DB.Model(&models.CelestiaSession{}).
  235. Where("id = ? AND user_id = ? AND deleted_at IS NULL", sessionID, userID).
  236. Updates(map[string]interface{}{"deleted_at": &now, "revision": gorm.Expr("revision + 1")})
  237. if result.Error != nil {
  238. respondError(c, http.StatusInternalServerError, 500, "failed to delete record")
  239. return
  240. }
  241. if result.RowsAffected == 0 {
  242. respondError(c, http.StatusNotFound, 404, "record not found")
  243. return
  244. }
  245. respondSuccess(c, gin.H{"deletedId": sessionID, "deletedAt": now})
  246. }