package main import ( "crypto/rand" "database/sql" "encoding/hex" "encoding/json" "errors" "strings" "sync" "time" ) // newUUID 生成 v4 风格 UUID(同步元数据用)。 func newUUID() string { b := make([]byte, 16) _, _ = rand.Read(b) b[6] = (b[6] & 0x0f) | 0x40 b[8] = (b[8] & 0x3f) | 0x80 h := hex.EncodeToString(b) return h[:8] + "-" + h[8:12] + "-" + h[12:16] + "-" + h[16:20] + "-" + h[20:] } // nowRFC 返回 UTC RFC3339 时间戳,进程内严格递增:同一秒内的连续写入 // 借用下一秒,保证 LWW(按字符串比较 updated_at)能区分先后。 func nowRFC() string { nowMu.Lock() defer nowMu.Unlock() s := time.Now().UTC().Format(time.RFC3339) if s <= lastNowRFC { if t, e := time.Parse(time.RFC3339, lastNowRFC); e == nil { s = t.Add(time.Second).UTC().Format(time.RFC3339) } } lastNowRFC = s return s } var ( nowMu sync.Mutex lastNowRFC string ) var todoStatuses = map[string]bool{"open": true, "doing": true, "done": true} var ticketStatuses = map[string]bool{"open": true, "in_progress": true, "resolved": true, "closed": true} var priorities = map[string]bool{"low": true, "medium": true, "high": true} // histEntry 生命周期节点:何时进入了哪个状态。 type histEntry struct { Status string `json:"status"` At string `json:"at"` } // appendHistory 在既有轨迹(JSON 数组,可为空)末尾追加一个状态节点。 func appendHistory(prev, status, at string) string { var list []histEntry if strings.TrimSpace(prev) != "" { _ = json.Unmarshal([]byte(prev), &list) } list = append(list, histEntry{Status: status, At: at}) b, _ := json.Marshal(list) return string(b) } // ---------- Todos ---------- const todoCols = `t.id,t.uuid,t.title,t.content,t.project_id,COALESCE(p.name,''),t.due_at,t.priority,t.status,t.created_at,t.updated_at,t.history,t.team_id` func scanTodo(r interface{ Scan(...any) error }) (Todo, error) { var x Todo e := r.Scan(&x.ID, &x.UUID, &x.Title, &x.Content, &x.ProjectID, &x.ProjectName, &x.DueAt, &x.Priority, &x.Status, &x.CreatedAt, &x.UpdatedAt, &x.History, &x.TeamID) return x, e } func (s *Store) ListTodos(status string, projectID int64) ([]Todo, error) { q := `SELECT ` + todoCols + ` FROM todos t LEFT JOIN projects p ON p.id=t.project_id WHERE t.deleted=0` args := []any{} if status != "" && status != "all" { q += ` AND t.status=?` args = append(args, status) } if projectID > 0 { q += ` AND t.project_id=?` args = append(args, projectID) } q += ` ORDER BY CASE t.status WHEN 'doing' THEN 0 WHEN 'open' THEN 1 ELSE 2 END, CASE WHEN t.due_at='' THEN 1 ELSE 0 END, t.due_at, t.id DESC` rows, e := s.db.Query(q, args...) if e != nil { return nil, e } defer rows.Close() out := []Todo{} for rows.Next() { x, e := scanTodo(rows) if e != nil { return nil, e } out = append(out, x) } return out, rows.Err() } func (s *Store) GetTodo(id int64) (Todo, error) { return scanTodo(s.db.QueryRow(`SELECT `+todoCols+` FROM todos t LEFT JOIN projects p ON p.id=t.project_id WHERE t.id=? AND t.deleted=0`, id)) } func (s *Store) SaveTodo(x Todo) (Todo, error) { x.Title = strings.TrimSpace(x.Title) if x.Title == "" { return x, errors.New("TODO_TITLE_REQUIRED") } if !priorities[x.Priority] { x.Priority = "medium" } if !todoStatuses[x.Status] { x.Status = "open" } now := nowRFC() if x.ID == 0 { res, e := s.db.Exec(`INSERT INTO todos(uuid,title,content,project_id,due_at,priority,status,reminded,created_at,updated_at,deleted,dirty,history) VALUES(?,?,?,?,?,?,?,0,?,?,0,1,?)`, newUUID(), x.Title, x.Content, x.ProjectID, x.DueAt, x.Priority, x.Status, now, now, appendHistory("", x.Status, now)) if e != nil { return x, e } id, _ := res.LastInsertId() return s.GetTodo(id) } // 编辑时若状态发生变化,为生命周期追加一个节点。 var oldStatus, hist string if e := s.db.QueryRow(`SELECT status,history FROM todos WHERE id=? AND deleted=0`, x.ID).Scan(&oldStatus, &hist); e != nil { return x, e } if oldStatus != x.Status { hist = appendHistory(hist, x.Status, now) } if _, e := s.db.Exec(`UPDATE todos SET title=?,content=?,project_id=?,due_at=?,priority=?,status=?,reminded=0,updated_at=?,dirty=1,history=? WHERE id=? AND deleted=0`, x.Title, x.Content, x.ProjectID, x.DueAt, x.Priority, x.Status, now, hist, x.ID); e != nil { return x, e } return s.GetTodo(x.ID) } func (s *Store) SetTodoStatus(id int64, status string) error { if !todoStatuses[status] { return errors.New("TODO_STATUS_INVALID") } var oldStatus, hist string switch e := s.db.QueryRow(`SELECT status,history FROM todos WHERE id=? AND deleted=0`, id).Scan(&oldStatus, &hist); { case e == sql.ErrNoRows: return nil case e != nil: return e } if oldStatus == status { return nil } now := nowRFC() _, e := s.db.Exec(`UPDATE todos SET status=?,updated_at=?,dirty=1,history=? WHERE id=? AND deleted=0`, status, now, appendHistory(hist, status, now), id) return e } func (s *Store) DeleteTodo(id int64) error { _, e := s.db.Exec(`UPDATE todos SET deleted=1,updated_at=?,dirty=1 WHERE id=?`, nowRFC(), id) return e } // ---------- Tickets ---------- const ticketCols = `t.id,t.uuid,t.title,t.description,t.type,t.project_id,COALESCE(p.name,''),t.start_at,t.due_at,t.status,t.priority,t.created_at,t.updated_at,t.history,t.team_id` func scanTicket(r interface{ Scan(...any) error }) (Ticket, error) { var x Ticket e := r.Scan(&x.ID, &x.UUID, &x.Title, &x.Description, &x.Type, &x.ProjectID, &x.ProjectName, &x.StartAt, &x.DueAt, &x.Status, &x.Priority, &x.CreatedAt, &x.UpdatedAt, &x.History, &x.TeamID) return x, e } func (s *Store) ListTickets(status string, projectID int64) ([]Ticket, error) { q := `SELECT ` + ticketCols + ` FROM tickets t LEFT JOIN projects p ON p.id=t.project_id WHERE t.deleted=0` args := []any{} if status != "" && status != "all" { q += ` AND t.status=?` args = append(args, status) } if projectID > 0 { q += ` AND t.project_id=?` args = append(args, projectID) } q += ` ORDER BY CASE t.status WHEN 'in_progress' THEN 0 WHEN 'open' THEN 1 WHEN 'resolved' THEN 2 ELSE 3 END, t.due_at, t.id DESC` rows, e := s.db.Query(q, args...) if e != nil { return nil, e } defer rows.Close() out := []Ticket{} for rows.Next() { x, e := scanTicket(rows) if e != nil { return nil, e } out = append(out, x) } return out, rows.Err() } func (s *Store) GetTicket(id int64) (Ticket, error) { return scanTicket(s.db.QueryRow(`SELECT `+ticketCols+` FROM tickets t LEFT JOIN projects p ON p.id=t.project_id WHERE t.id=? AND t.deleted=0`, id)) } func (s *Store) SaveTicket(x Ticket) (Ticket, error) { x.Title = strings.TrimSpace(x.Title) if x.Title == "" { return x, errors.New("TICKET_TITLE_REQUIRED") } if x.ProjectID <= 0 { return x, errors.New("TICKET_PROJECT_REQUIRED") } if p, e := s.GetProject(x.ProjectID); e != nil || p.ID == 0 { return x, errors.New("TICKET_PROJECT_REQUIRED") } if strings.TrimSpace(x.StartAt) == "" || strings.TrimSpace(x.DueAt) == "" { return x, errors.New("TICKET_SCHEDULE_REQUIRED") } if x.DueAt < x.StartAt { return x, errors.New("TICKET_SCHEDULE_INVALID") } if !priorities[x.Priority] { x.Priority = "medium" } if !ticketStatuses[x.Status] { x.Status = "open" } switch x.Type { case "feature", "bug", "task", "improvement": default: x.Type = "task" } now := nowRFC() if x.ID == 0 { res, e := s.db.Exec(`INSERT INTO tickets(uuid,title,description,type,project_id,start_at,due_at,status,priority,reminded,created_at,updated_at,deleted,dirty,history) VALUES(?,?,?,?,?,?,?,?,?,0,?,?,0,1,?)`, newUUID(), x.Title, x.Description, x.Type, x.ProjectID, x.StartAt, x.DueAt, x.Status, x.Priority, now, now, appendHistory("", x.Status, now)) if e != nil { return x, e } id, _ := res.LastInsertId() return s.GetTicket(id) } // 编辑时若状态发生变化,为生命周期追加一个节点。 var oldStatus, hist string if e := s.db.QueryRow(`SELECT status,history FROM tickets WHERE id=? AND deleted=0`, x.ID).Scan(&oldStatus, &hist); e != nil { return x, e } if oldStatus != x.Status { hist = appendHistory(hist, x.Status, now) } if _, e := s.db.Exec(`UPDATE tickets SET title=?,description=?,type=?,project_id=?,start_at=?,due_at=?,status=?,priority=?,reminded=0,updated_at=?,dirty=1,history=? WHERE id=? AND deleted=0`, x.Title, x.Description, x.Type, x.ProjectID, x.StartAt, x.DueAt, x.Status, x.Priority, now, hist, x.ID); e != nil { return x, e } return s.GetTicket(x.ID) } func (s *Store) SetTicketStatus(id int64, status string) error { if !ticketStatuses[status] { return errors.New("TICKET_STATUS_INVALID") } var oldStatus, hist string switch e := s.db.QueryRow(`SELECT status,history FROM tickets WHERE id=? AND deleted=0`, id).Scan(&oldStatus, &hist); { case e == sql.ErrNoRows: return nil case e != nil: return e } if oldStatus == status { return nil } now := nowRFC() _, e := s.db.Exec(`UPDATE tickets SET status=?,updated_at=?,dirty=1,history=? WHERE id=? AND deleted=0`, status, now, appendHistory(hist, status, now), id) return e } func (s *Store) DeleteTicket(id int64) error { _, e := s.db.Exec(`UPDATE tickets SET deleted=1,updated_at=?,dirty=1 WHERE id=?`, nowRFC(), id) return e } // ---------- Notes(多条笔记:顶栏笔记中心 + 工作台记事本) ---------- // GetNote 返回最近更新的一条笔记(工作台记事本入口),无笔记时创建一条空笔记。 func (s *Store) GetNote() (Note, error) { var x Note e := s.db.QueryRow(`SELECT id,uuid,content,updated_at FROM notes WHERE deleted=0 ORDER BY updated_at DESC,id DESC LIMIT 1`). Scan(&x.ID, &x.UUID, &x.Content, &x.UpdatedAt) if e == nil { return x, nil } x = Note{UUID: newUUID(), UpdatedAt: nowRFC()} res, e := s.db.Exec(`INSERT INTO notes(uuid,content,updated_at,deleted,dirty) VALUES(?, '', ?, 0, 1)`, x.UUID, x.UpdatedAt) if e != nil { return x, e } x.ID, _ = res.LastInsertId() return x, nil } // SaveNote 兼容旧入口:更新最近一条笔记(无则创建)。 func (s *Store) SaveNote(content string) (Note, error) { n, e := s.GetNote() if e != nil { return n, e } return s.SaveNoteByID(n.ID, content) } func (s *Store) ListNotes(limit int) ([]Note, error) { if limit <= 0 { limit = 100 } rows, e := s.db.Query(`SELECT id,uuid,content,updated_at FROM notes WHERE deleted=0 ORDER BY updated_at DESC,id DESC LIMIT ?`, limit) if e != nil { return nil, e } defer rows.Close() out := []Note{} for rows.Next() { var x Note if e = rows.Scan(&x.ID, &x.UUID, &x.Content, &x.UpdatedAt); e != nil { return nil, e } out = append(out, x) } return out, rows.Err() } // SaveNoteByID 保存指定笔记;id=0 时新建。 func (s *Store) SaveNoteByID(id int64, content string) (Note, error) { now := nowRFC() if id == 0 { x := Note{UUID: newUUID(), Content: content, UpdatedAt: now} res, e := s.db.Exec(`INSERT INTO notes(uuid,content,updated_at,deleted,dirty) VALUES(?,?,?,0,1)`, x.UUID, content, now) if e != nil { return x, e } x.ID, _ = res.LastInsertId() return x, nil } if _, e := s.db.Exec(`UPDATE notes SET content=?,updated_at=?,dirty=1 WHERE id=? AND deleted=0`, content, now, id); e != nil { return Note{}, e } var x Note e := s.db.QueryRow(`SELECT id,uuid,content,updated_at FROM notes WHERE id=?`, id). Scan(&x.ID, &x.UUID, &x.Content, &x.UpdatedAt) if e == sql.ErrNoRows { return Note{}, errors.New("NOTE_NOT_FOUND") } return x, e } func (s *Store) DeleteNote(id int64) error { _, e := s.db.Exec(`UPDATE notes SET deleted=1,updated_at=?,dirty=1 WHERE id=?`, nowRFC(), id) return e } // ---------- Favorites(纯本地) ---------- func (s *Store) ListFavorites() ([]int64, error) { rows, e := s.db.Query(`SELECT project_id FROM favorites ORDER BY created_at DESC`) if e != nil { return nil, e } defer rows.Close() out := []int64{} for rows.Next() { var id int64 if e = rows.Scan(&id); e != nil { return nil, e } out = append(out, id) } return out, rows.Err() } func (s *Store) ToggleFavorite(projectID int64) (bool, error) { var exists int _ = s.db.QueryRow(`SELECT 1 FROM favorites WHERE project_id=?`, projectID).Scan(&exists) if exists == 1 { _, e := s.db.Exec(`DELETE FROM favorites WHERE project_id=?`, projectID) return false, e } _, e := s.db.Exec(`INSERT INTO favorites(project_id,created_at) VALUES(?,?)`, projectID, nowRFC()) return true, e } // ---------- Messages(纯本地) ---------- func (s *Store) AddMessage(kind, title, body, sourceType string, sourceID int64) (Message, error) { now := nowRFC() res, e := s.db.Exec(`INSERT INTO messages(kind,title,body,source_type,source_id,is_read,created_at) VALUES(?,?,?,?,?,0,?)`, kind, title, body, sourceType, sourceID, now) if e != nil { return Message{}, e } id, _ := res.LastInsertId() return Message{ID: id, Kind: kind, Title: title, Body: body, SourceType: sourceType, SourceID: sourceID, CreatedAt: now}, nil } func (s *Store) ListMessages(limit int64) ([]Message, error) { if limit <= 0 || limit > 200 { limit = 50 } rows, e := s.db.Query(`SELECT id,kind,title,body,source_type,source_id,is_read,created_at FROM messages ORDER BY id DESC LIMIT ?`, limit) if e != nil { return nil, e } defer rows.Close() out := []Message{} for rows.Next() { var x Message var read int if e = rows.Scan(&x.ID, &x.Kind, &x.Title, &x.Body, &x.SourceType, &x.SourceID, &read, &x.CreatedAt); e != nil { return nil, e } x.Read = read == 1 out = append(out, x) } return out, rows.Err() } func (s *Store) UnreadMessageCount() int64 { var n int64 _ = s.db.QueryRow(`SELECT COUNT(*) FROM messages WHERE is_read=0`).Scan(&n) return n } func (s *Store) MarkMessageRead(id int64) error { _, e := s.db.Exec(`UPDATE messages SET is_read=1 WHERE id=?`, id) return e } func (s *Store) MarkAllMessagesRead() error { _, e := s.db.Exec(`UPDATE messages SET is_read=1 WHERE is_read=0`) return e } func (s *Store) ClearMessages() error { _, e := s.db.Exec(`DELETE FROM messages`) return e }