Files
code-utils/workbench_store.go

448 lines
14 KiB
Go
Raw Normal View History

2026-08-14 07:52:01 +08:00
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
}