| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277 |
- package handlers
- import (
- "errors"
- "net/http"
- "strings"
- "time"
- "github.com/celestia-trace/backend/models"
- "github.com/gin-gonic/gin"
- "gorm.io/gorm"
- )
- type SessionHandler struct {
- DB *gorm.DB
- }
- func sessionPreloads(db *gorm.DB) *gorm.DB {
- return db.Preload("Events", func(events *gorm.DB) *gorm.DB {
- return events.Order("relative_time_ms asc")
- }).Preload("Assets")
- }
- // GetSessions is the lightweight first phase of synchronization. It supports
- // cursor-like incremental pulls and deletion tombstones without preloading
- // concrete event or asset content.
- func (h *SessionHandler) GetSessions(c *gin.Context) {
- userID := c.GetString("userID")
- query := h.DB.Where("user_id = ?", userID)
- if c.Query("includeDeleted") != "true" {
- query = query.Where("deleted_at IS NULL")
- }
- if value := c.Query("updatedAfter"); value != "" {
- updatedAfter, err := time.Parse(time.RFC3339Nano, value)
- if err != nil {
- respondError(c, http.StatusBadRequest, 400, "invalid updatedAfter timestamp")
- return
- }
- query = query.Where("updated_at > ?", updatedAfter)
- }
- var sessions []models.CelestiaSession
- if err := query.Order("updated_at asc, id asc").Find(&sessions).Error; err != nil {
- respondError(c, http.StatusInternalServerError, 500, "failed to query field records")
- return
- }
- index := make([]models.SessionSyncIndexItem, 0, len(sessions))
- for _, session := range sessions {
- index = append(index, session.SyncIndexItem())
- }
- respondSuccess(c, index)
- }
- func (h *SessionHandler) GetSessionDetail(c *gin.Context) {
- userID := c.GetString("userID")
- var session models.CelestiaSession
- if err := sessionPreloads(h.DB).
- Where("id = ? AND user_id = ? AND deleted_at IS NULL", c.Param("id"), userID).
- First(&session).Error; err != nil {
- respondError(c, http.StatusNotFound, 404, "record not found")
- return
- }
- respondSuccess(c, session)
- }
- // CreateSession is an idempotent upsert keyed by (user_id, client_id).
- func (h *SessionHandler) CreateSession(c *gin.Context) {
- userID := c.GetString("userID")
- var req models.CreateSessionRequest
- if err := c.ShouldBindJSON(&req); err != nil {
- respondError(c, http.StatusBadRequest, 400, "invalid input data")
- return
- }
- var session models.CelestiaSession
- var conflictRevision int64
- var isConflict bool
- err := h.DB.Transaction(func(tx *gorm.DB) error {
- err := tx.Where("user_id = ? AND client_id = ?", userID, req.ClientID).First(&session).Error
- if errors.Is(err, gorm.ErrRecordNotFound) {
- clientID := req.ClientID
- session = models.CelestiaSession{
- UserID: userID, ClientID: &clientID, Revision: 1,
- }
- } else if err != nil {
- return err
- } else {
- // Optimistic locking: reject if client baseRevision doesn't match
- if req.BaseRevision != nil && *req.BaseRevision != session.Revision {
- conflictRevision = session.Revision
- isConflict = true
- return nil // exit transaction without error; handled below
- }
- session.Revision++
- }
- session.Title = req.Title
- session.StartTime = req.StartTime
- session.EndTime = req.EndTime
- session.DurationMs = req.DurationMs
- session.LocalAudioPath = ""
- session.IsSynced = true
- session.DeletedAt = nil
- session.PhotoCount, session.NoteCount = countEvents(req.Events)
- if session.ID == "" {
- if err := tx.Create(&session).Error; err != nil {
- return err
- }
- } else if err := tx.Save(&session).Error; err != nil {
- return err
- }
- for _, input := range req.Events {
- var event models.TimelineEvent
- err := tx.Where("session_id = ? AND client_id = ?", session.ID, input.ClientID).First(&event).Error
- if errors.Is(err, gorm.ErrRecordNotFound) {
- clientID := input.ClientID
- event = models.TimelineEvent{SessionID: session.ID, ClientID: &clientID}
- } else if err != nil {
- return err
- }
- event.RelativeTimeMs = input.RelativeTimeMs
- event.EventType = input.EventType
- event.TextContent = input.TextContent
- event.LocalFilePath = ""
- event.VoiceStartOffsetMs = input.VoiceStartOffsetMs
- event.VoiceEndOffsetMs = input.VoiceEndOffsetMs
- event.LocationName = input.LocationName
- event.LocationAddress = input.LocationAddress
- event.Latitude = input.Latitude
- event.Longitude = input.Longitude
- if event.ID == "" {
- if err := tx.Create(&event).Error; err != nil {
- return err
- }
- } else if err := tx.Save(&event).Error; err != nil {
- return err
- }
- }
- // Delete events that were removed on the client
- if len(req.DeletedEventClientIds) > 0 {
- if err := tx.Where("session_id = ? AND client_id IN ?", session.ID, req.DeletedEventClientIds).
- Delete(&models.TimelineEvent{}).Error; err != nil {
- return err
- }
- // Recalculate counts from the remaining DB events
- var remaining []models.TimelineEvent
- if err := tx.Where("session_id = ?", session.ID).Find(&remaining).Error; err != nil {
- return err
- }
- session.PhotoCount, session.NoteCount = countPersistedEvents(remaining)
- if err := tx.Save(&session).Error; err != nil {
- return err
- }
- }
- // Events belong to the session's sync unit, so advance its timestamp
- // after all event upserts/deletions have completed.
- if err := tx.Model(&session).Update("updated_at", gorm.Expr("NOW()")).Error; err != nil {
- return err
- }
- return nil
- })
- if isConflict {
- c.JSON(http.StatusConflict, gin.H{
- "code": 409, "message": "version conflict",
- "data": gin.H{"serverRevision": conflictRevision},
- })
- return
- }
- if err != nil {
- respondError(c, http.StatusInternalServerError, 500, "failed to save record")
- return
- }
- if err := sessionPreloads(h.DB).First(&session, "id = ?", session.ID).Error; err != nil {
- respondError(c, http.StatusInternalServerError, 500, "failed to reload record")
- return
- }
- respondSuccess(c, session)
- }
- func countEvents(events []models.CreateEventSubSchema) (photoCount, noteCount int) {
- for _, event := range events {
- switch event.EventType {
- case "PHOTO":
- photoCount++
- case "NOTE":
- noteCount++
- case "MARKER":
- // Exclude continuation markers (e.g. "续录时间:...") from note count
- if !strings.HasPrefix(event.TextContent, "续录时间:") {
- noteCount++
- }
- case "CONTINUATION":
- // Dedicated continuation type, not counted as a note
- }
- }
- return
- }
- // countPersistedEvents recalculates counts from persisted TimelineEvent rows
- // (used after event deletion to keep counts accurate).
- func countPersistedEvents(events []models.TimelineEvent) (photoCount, noteCount int) {
- for _, event := range events {
- switch event.EventType {
- case "PHOTO":
- photoCount++
- case "NOTE":
- noteCount++
- case "MARKER":
- if !strings.HasPrefix(event.TextContent, "续录时间:") {
- noteCount++
- }
- case "CONTINUATION":
- // Not counted
- }
- }
- return
- }
- func (h *SessionHandler) AddEventToSession(c *gin.Context) {
- userID := c.GetString("userID")
- sessionID := c.Param("id")
- var session models.CelestiaSession
- if err := h.DB.Where("id = ? AND user_id = ? AND deleted_at IS NULL", sessionID, userID).First(&session).Error; err != nil {
- respondError(c, http.StatusNotFound, 404, "record not found")
- return
- }
- var req models.CreateEventRequest
- if err := c.ShouldBindJSON(&req); err != nil {
- respondError(c, http.StatusBadRequest, 400, "invalid event payload")
- return
- }
- clientID := req.ClientID
- event := models.TimelineEvent{
- SessionID: session.ID, ClientID: &clientID,
- RelativeTimeMs: req.RelativeTimeMs, EventType: req.EventType,
- TextContent: req.TextContent, VoiceStartOffsetMs: req.VoiceStartOffsetMs,
- VoiceEndOffsetMs: req.VoiceEndOffsetMs,
- LocationName: req.LocationName, LocationAddress: req.LocationAddress,
- Latitude: req.Latitude, Longitude: req.Longitude,
- }
- if err := h.DB.Where("session_id = ? AND client_id = ?", session.ID, req.ClientID).
- Assign(event).FirstOrCreate(&event).Error; err != nil {
- respondError(c, http.StatusInternalServerError, 500, "failed to add event")
- return
- }
- h.DB.Model(&session).Updates(map[string]interface{}{
- "revision": gorm.Expr("revision + 1"),
- "updated_at": gorm.Expr("NOW()"),
- })
- respondSuccess(c, event)
- }
- // DeleteSession creates a tombstone rather than hard deleting cloud data.
- func (h *SessionHandler) DeleteSession(c *gin.Context) {
- userID := c.GetString("userID")
- sessionID := c.Param("id")
- now := time.Now().UTC()
- result := h.DB.Model(&models.CelestiaSession{}).
- Where("id = ? AND user_id = ? AND deleted_at IS NULL", sessionID, userID).
- Updates(map[string]interface{}{
- "deleted_at": &now,
- "revision": gorm.Expr("revision + 1"),
- "updated_at": gorm.Expr("NOW()"),
- })
- if result.Error != nil {
- respondError(c, http.StatusInternalServerError, 500, "failed to delete record")
- return
- }
- if result.RowsAffected == 0 {
- respondError(c, http.StatusNotFound, 404, "record not found")
- return
- }
- respondSuccess(c, gin.H{"deletedId": sessionID, "deletedAt": now})
- }
|