Files
code-utils/workbench_store.go
2026-08-14 07:52:01 +08:00

448 lines
14 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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
}