448 lines
14 KiB
Go
448 lines
14 KiB
Go
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
|
||
}
|