feat(tasks): add categories templates and batch controls
This commit is contained in:
1 parent
3bedbdc669
commit
04fda4cd01
9 files changed
+2337
-295
No files matched your search
@@ -0,0 +1,159 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/http"
|
||||
"strings"
|
||||
"unicode/utf8"
|
||||
|
||||
"github.com/Autumn-27/artex/db"
|
||||
)
|
||||
|
||||
const maxTaskCategoryRequestBytes = 16 << 10
|
||||
|
||||
type taskCategoryRequest struct {
|
||||
Name *string `json:"name"`
|
||||
}
|
||||
|
||||
func decodeTaskCategoryRequest(w http.ResponseWriter, r *http.Request) (*taskCategoryRequest, bool) {
|
||||
r.Body = http.MaxBytesReader(w, r.Body, maxTaskCategoryRequestBytes)
|
||||
var request taskCategoryRequest
|
||||
if err := decode(r, &request); err != nil {
|
||||
var tooLarge *http.MaxBytesError
|
||||
if errors.As(err, &tooLarge) {
|
||||
writeErr(w, http.StatusRequestEntityTooLarge, "请求正文过大")
|
||||
} else {
|
||||
writeErr(w, http.StatusBadRequest, err.Error())
|
||||
}
|
||||
return nil, false
|
||||
}
|
||||
if request.Name == nil || strings.TrimSpace(*request.Name) == "" {
|
||||
writeErr(w, http.StatusBadRequest, "分类名称不能为空")
|
||||
return nil, false
|
||||
}
|
||||
if utf8.RuneCountInString(strings.TrimSpace(*request.Name)) > db.MaxTaskCategoryNameRunes {
|
||||
writeErr(w, http.StatusBadRequest, "分类名称最多 80 个字符")
|
||||
return nil, false
|
||||
}
|
||||
return &request, true
|
||||
}
|
||||
|
||||
func writeTaskCategoryError(w http.ResponseWriter, err error) {
|
||||
switch {
|
||||
case errors.Is(err, db.ErrTaskCategoryInvalid):
|
||||
writeErr(w, http.StatusBadRequest, err.Error())
|
||||
case errors.Is(err, db.ErrTaskCategoryNameConflict):
|
||||
writeErr(w, http.StatusConflict, "分类名称已存在")
|
||||
case errors.Is(err, db.ErrTaskCategoryNotFound), errors.Is(err, db.ErrTaskCategoryTaskNotFound):
|
||||
writeErr(w, http.StatusNotFound, err.Error())
|
||||
default:
|
||||
writeErr(w, http.StatusInternalServerError, err.Error())
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Server) pgListTaskCategories(w http.ResponseWriter, _ *http.Request) {
|
||||
pg := s.pg(w)
|
||||
if pg == nil {
|
||||
return
|
||||
}
|
||||
categories, err := pg.ListTaskCategories()
|
||||
if err != nil {
|
||||
writeErr(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{"categories": categories})
|
||||
}
|
||||
|
||||
func (s *Server) pgCreateTaskCategory(w http.ResponseWriter, r *http.Request) {
|
||||
pg := s.pg(w)
|
||||
if pg == nil {
|
||||
return
|
||||
}
|
||||
request, ok := decodeTaskCategoryRequest(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
category, err := pg.CreateTaskCategory(*request.Name)
|
||||
if err != nil {
|
||||
writeTaskCategoryError(w, err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusCreated, category)
|
||||
}
|
||||
|
||||
func (s *Server) pgRenameTaskCategory(w http.ResponseWriter, r *http.Request) {
|
||||
id, ok := pathInt(r, "id")
|
||||
if !ok {
|
||||
writeErr(w, http.StatusBadRequest, "bad task category id")
|
||||
return
|
||||
}
|
||||
request, ok := decodeTaskCategoryRequest(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
category, err := s.m.RenameTaskCategory(id, *request.Name)
|
||||
if err != nil {
|
||||
writeTaskCategoryError(w, err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, category)
|
||||
}
|
||||
|
||||
func (s *Server) pgDeleteTaskCategory(w http.ResponseWriter, r *http.Request) {
|
||||
id, ok := pathInt(r, "id")
|
||||
if !ok {
|
||||
writeErr(w, http.StatusBadRequest, "bad task category id")
|
||||
return
|
||||
}
|
||||
deleted, err := s.m.DeleteTaskCategory(id)
|
||||
if err != nil {
|
||||
writeTaskCategoryError(w, err)
|
||||
return
|
||||
}
|
||||
if !deleted {
|
||||
writeErr(w, http.StatusNotFound, "task category not found")
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{"deleted": id})
|
||||
}
|
||||
|
||||
func (s *Server) updateTaskCategory(w http.ResponseWriter, r *http.Request) {
|
||||
taskID := r.PathValue("id")
|
||||
if _, ok := s.m.Task(taskID); !ok {
|
||||
writeErr(w, http.StatusNotFound, "task not found")
|
||||
return
|
||||
}
|
||||
r.Body = http.MaxBytesReader(w, r.Body, maxTaskCategoryRequestBytes)
|
||||
var request struct {
|
||||
CategoryID json.RawMessage `json:"category_id"`
|
||||
}
|
||||
if err := decode(r, &request); err != nil {
|
||||
writeErr(w, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
if len(request.CategoryID) == 0 {
|
||||
writeErr(w, http.StatusBadRequest, "category_id is required")
|
||||
return
|
||||
}
|
||||
var categoryID *int64
|
||||
if !bytes.Equal(bytes.TrimSpace(request.CategoryID), []byte("null")) {
|
||||
var id int64
|
||||
if err := json.Unmarshal(request.CategoryID, &id); err != nil {
|
||||
writeErr(w, http.StatusBadRequest, "任务分类 id 无效")
|
||||
return
|
||||
}
|
||||
categoryID = &id
|
||||
}
|
||||
if categoryID != nil && *categoryID <= 0 {
|
||||
writeErr(w, http.StatusBadRequest, "任务分类 id 无效")
|
||||
return
|
||||
}
|
||||
if _, err := s.m.SetTaskCategory(taskID, categoryID); err != nil {
|
||||
writeTaskCategoryError(w, err)
|
||||
return
|
||||
}
|
||||
task, _ := s.m.Task(taskID)
|
||||
writeJSON(w, http.StatusOK, taskDTO(task, s.resolvedTaskStatus(task)))
|
||||
}
|
||||
@@ -0,0 +1,172 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strings"
|
||||
"unicode/utf8"
|
||||
|
||||
"github.com/Autumn-27/artex/db"
|
||||
)
|
||||
|
||||
const maxTaskTemplateRequestBytes = 512 << 10
|
||||
|
||||
// taskTemplateRequest uses pointers so PATCH can distinguish omitted fields from
|
||||
// explicit empty values. Empty values are still rejected by the DB validator.
|
||||
type taskTemplateRequest struct {
|
||||
Name *string `json:"name"`
|
||||
Description *string `json:"description"`
|
||||
Goal *string `json:"goal"`
|
||||
}
|
||||
|
||||
func decodeTaskTemplateRequest(w http.ResponseWriter, r *http.Request, req *taskTemplateRequest) bool {
|
||||
r.Body = http.MaxBytesReader(w, r.Body, maxTaskTemplateRequestBytes)
|
||||
if err := decode(r, req); err != nil {
|
||||
var tooLarge *http.MaxBytesError
|
||||
if errors.As(err, &tooLarge) {
|
||||
writeErr(w, http.StatusRequestEntityTooLarge, "请求正文过大")
|
||||
} else {
|
||||
writeErr(w, http.StatusBadRequest, err.Error())
|
||||
}
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func validateTaskTemplateRequest(req taskTemplateRequest) error {
|
||||
checks := []struct {
|
||||
name string
|
||||
value *string
|
||||
limit int
|
||||
}{
|
||||
{name: "name", value: req.Name, limit: db.MaxTaskTemplateNameRunes},
|
||||
{name: "description", value: req.Description, limit: db.MaxTaskTemplateTextRunes},
|
||||
{name: "goal", value: req.Goal, limit: db.MaxTaskTemplateTextRunes},
|
||||
}
|
||||
for _, check := range checks {
|
||||
if check.value == nil {
|
||||
continue
|
||||
}
|
||||
value := strings.TrimSpace(*check.value)
|
||||
if check.name == "name" {
|
||||
value = strings.Join(strings.Fields(value), " ")
|
||||
}
|
||||
if utf8.RuneCountInString(value) > check.limit {
|
||||
return fmt.Errorf("%s 最多 %d 个字符", check.name, check.limit)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func writeTaskTemplateErr(w http.ResponseWriter, err error) {
|
||||
switch {
|
||||
case errors.Is(err, db.ErrTaskTemplateInvalid):
|
||||
writeErr(w, http.StatusBadRequest, err.Error())
|
||||
case errors.Is(err, db.ErrTaskTemplateNameConflict):
|
||||
writeErr(w, http.StatusConflict, "模板名称已存在")
|
||||
case errors.Is(err, db.ErrTaskTemplateNotFound):
|
||||
writeErr(w, http.StatusNotFound, "task template not found")
|
||||
default:
|
||||
writeErr(w, http.StatusInternalServerError, err.Error())
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Server) pgListTaskTemplates(w http.ResponseWriter, _ *http.Request) {
|
||||
pg := s.pg(w)
|
||||
if pg == nil {
|
||||
return
|
||||
}
|
||||
templates, err := pg.ListTaskTemplates()
|
||||
if err != nil {
|
||||
writeErr(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{"templates": templates})
|
||||
}
|
||||
|
||||
func (s *Server) pgCreateTaskTemplate(w http.ResponseWriter, r *http.Request) {
|
||||
pg := s.pg(w)
|
||||
if pg == nil {
|
||||
return
|
||||
}
|
||||
var req taskTemplateRequest
|
||||
if !decodeTaskTemplateRequest(w, r, &req) {
|
||||
return
|
||||
}
|
||||
if err := validateTaskTemplateRequest(req); err != nil {
|
||||
writeErr(w, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
template, err := pg.CreateTaskTemplate(db.TaskTemplateInput{
|
||||
Name: stringValue(req.Name),
|
||||
Description: stringValue(req.Description),
|
||||
Goal: stringValue(req.Goal),
|
||||
})
|
||||
if err != nil {
|
||||
writeTaskTemplateErr(w, err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusCreated, template)
|
||||
}
|
||||
|
||||
func (s *Server) pgUpdateTaskTemplate(w http.ResponseWriter, r *http.Request) {
|
||||
pg := s.pg(w)
|
||||
if pg == nil {
|
||||
return
|
||||
}
|
||||
id, ok := pathInt(r, "id")
|
||||
if !ok {
|
||||
writeErr(w, http.StatusBadRequest, "bad task template id")
|
||||
return
|
||||
}
|
||||
var req taskTemplateRequest
|
||||
if !decodeTaskTemplateRequest(w, r, &req) {
|
||||
return
|
||||
}
|
||||
if err := validateTaskTemplateRequest(req); err != nil {
|
||||
writeErr(w, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
if req.Name == nil && req.Description == nil && req.Goal == nil {
|
||||
writeErr(w, http.StatusBadRequest, "至少需要提供 name、description 或 goal")
|
||||
return
|
||||
}
|
||||
template, err := pg.PatchTaskTemplate(id, db.TaskTemplatePatch{
|
||||
Name: req.Name, Description: req.Description, Goal: req.Goal,
|
||||
})
|
||||
if err != nil {
|
||||
writeTaskTemplateErr(w, err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, template)
|
||||
}
|
||||
|
||||
func (s *Server) pgDeleteTaskTemplate(w http.ResponseWriter, r *http.Request) {
|
||||
pg := s.pg(w)
|
||||
if pg == nil {
|
||||
return
|
||||
}
|
||||
id, ok := pathInt(r, "id")
|
||||
if !ok {
|
||||
writeErr(w, http.StatusBadRequest, "bad task template id")
|
||||
return
|
||||
}
|
||||
deleted, err := pg.DeleteTaskTemplate(id)
|
||||
if err != nil {
|
||||
writeErr(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
if !deleted {
|
||||
writeErr(w, http.StatusNotFound, "task template not found")
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{"deleted": id})
|
||||
}
|
||||
|
||||
func stringValue(value *string) string {
|
||||
if value == nil {
|
||||
return ""
|
||||
}
|
||||
return *value
|
||||
}
|
||||
@@ -0,0 +1,163 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/Autumn-27/artex/db"
|
||||
)
|
||||
|
||||
func TestTaskTemplateHTTPCRUD(t *testing.T) {
|
||||
m, err := NewManager(t.TempDir(), "")
|
||||
if err != nil {
|
||||
t.Skipf("postgres unavailable (%v) — skipping", err)
|
||||
}
|
||||
defer m.Close()
|
||||
s := New(context.Background(), m, t.TempDir(), t.TempDir(), t.TempDir())
|
||||
h := s.Handler()
|
||||
token, err := signJWT(s.jwtKey)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
do := func(method, path string, body any) (int, map[string]any) {
|
||||
var data []byte
|
||||
if body != nil {
|
||||
data, _ = json.Marshal(body)
|
||||
}
|
||||
req := httptest.NewRequest(method, path, bytes.NewReader(data))
|
||||
req.Header.Set("Authorization", "Bearer "+token)
|
||||
rec := httptest.NewRecorder()
|
||||
h.ServeHTTP(rec, req)
|
||||
var out map[string]any
|
||||
_ = json.Unmarshal(rec.Body.Bytes(), &out)
|
||||
return rec.Code, out
|
||||
}
|
||||
|
||||
name := fmt.Sprintf("http-template-%d", time.Now().UnixNano())
|
||||
code, created := do(http.MethodPost, "/api/task-templates", map[string]any{
|
||||
"name": name, "description": "description", "goal": "goal",
|
||||
})
|
||||
if code != http.StatusCreated {
|
||||
t.Fatalf("create status=%d body=%v", code, created)
|
||||
}
|
||||
id := int64(created["id"].(float64))
|
||||
t.Cleanup(func() { _, _ = m.pg.DeleteTaskTemplate(id) })
|
||||
|
||||
code, duplicate := do(http.MethodPost, "/api/task-templates", map[string]any{
|
||||
"name": " " + name + " ", "description": "other", "goal": "other",
|
||||
})
|
||||
if code != http.StatusConflict {
|
||||
t.Fatalf("duplicate status=%d body=%v", code, duplicate)
|
||||
}
|
||||
|
||||
code, updated := do(http.MethodPatch, fmt.Sprintf("/api/task-templates/%d", id), map[string]any{"goal": "patched goal"})
|
||||
if code != http.StatusOK || updated["goal"] != "patched goal" || updated["description"] != "description" {
|
||||
t.Fatalf("patch status=%d body=%v", code, updated)
|
||||
}
|
||||
code, _ = do(http.MethodPatch, fmt.Sprintf("/api/task-templates/%d", id), map[string]any{
|
||||
"goal": strings.Repeat("界", db.MaxTaskTemplateTextRunes+1),
|
||||
})
|
||||
if code != http.StatusBadRequest {
|
||||
t.Fatalf("overlong patch status=%d, want %d", code, http.StatusBadRequest)
|
||||
}
|
||||
code, _ = do(http.MethodPost, "/api/task-templates", map[string]any{
|
||||
"name": "oversized", "description": strings.Repeat("x", maxTaskTemplateRequestBytes+1), "goal": "goal",
|
||||
})
|
||||
if code != http.StatusRequestEntityTooLarge {
|
||||
t.Fatalf("oversized create status=%d, want %d", code, http.StatusRequestEntityTooLarge)
|
||||
}
|
||||
code, listed := do(http.MethodGet, "/api/task-templates", nil)
|
||||
if code != http.StatusOK || listed["templates"] == nil {
|
||||
t.Fatalf("list status=%d body=%v", code, listed)
|
||||
}
|
||||
code, deleted := do(http.MethodDelete, fmt.Sprintf("/api/task-templates/%d", id), nil)
|
||||
if code != http.StatusOK || int64(deleted["deleted"].(float64)) != id {
|
||||
t.Fatalf("delete status=%d body=%v", code, deleted)
|
||||
}
|
||||
}
|
||||
|
||||
func TestConversationPatchReturnsPinState(t *testing.T) {
|
||||
m, err := NewManager(t.TempDir(), "")
|
||||
if err != nil {
|
||||
t.Skipf("postgres unavailable (%v) — skipping", err)
|
||||
}
|
||||
defer m.Close()
|
||||
s := New(context.Background(), m, t.TempDir(), t.TempDir(), t.TempDir())
|
||||
conversation, err := m.pg.CreateConversation("mainagent", "pin through http", nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { _ = m.pg.DeleteConversation(conversation.ID) })
|
||||
token, err := signJWT(s.jwtKey)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
body, _ := json.Marshal(map[string]any{"title": " pinned title ", "pinned": true})
|
||||
req := httptest.NewRequest(http.MethodPatch, fmt.Sprintf("/api/conversations/%d", conversation.ID), bytes.NewReader(body))
|
||||
req.Header.Set("Authorization", "Bearer "+token)
|
||||
rec := httptest.NewRecorder()
|
||||
s.Handler().ServeHTTP(rec, req)
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("patch status=%d body=%s", rec.Code, rec.Body.String())
|
||||
}
|
||||
var updated struct {
|
||||
Title string `json:"title"`
|
||||
Pinned bool `json:"pinned"`
|
||||
PinnedAt *string `json:"pinned_at"`
|
||||
}
|
||||
if err := json.Unmarshal(rec.Body.Bytes(), &updated); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if updated.Title != "pinned title" || !updated.Pinned || updated.PinnedAt == nil {
|
||||
t.Fatalf("unexpected conversation patch response: %+v", updated)
|
||||
}
|
||||
|
||||
body, _ = json.Marshal(map[string]any{"title": strings.Repeat("会", maxConversationTitleRunes+1)})
|
||||
req = httptest.NewRequest(http.MethodPatch, fmt.Sprintf("/api/conversations/%d", conversation.ID), bytes.NewReader(body))
|
||||
req.Header.Set("Authorization", "Bearer "+token)
|
||||
rec = httptest.NewRecorder()
|
||||
s.Handler().ServeHTTP(rec, req)
|
||||
if rec.Code != http.StatusBadRequest {
|
||||
t.Fatalf("overlong conversation title status=%d body=%s", rec.Code, rec.Body.String())
|
||||
}
|
||||
|
||||
body, _ = json.Marshal(map[string]any{"title": strings.Repeat("x", maxConversationRequestBytes+1)})
|
||||
req = httptest.NewRequest(http.MethodPatch, fmt.Sprintf("/api/conversations/%d", conversation.ID), bytes.NewReader(body))
|
||||
req.Header.Set("Authorization", "Bearer "+token)
|
||||
rec = httptest.NewRecorder()
|
||||
s.Handler().ServeHTTP(rec, req)
|
||||
if rec.Code != http.StatusRequestEntityTooLarge {
|
||||
t.Fatalf("oversized conversation patch status=%d body=%s", rec.Code, rec.Body.String())
|
||||
}
|
||||
|
||||
body, _ = json.Marshal(map[string]any{
|
||||
"agent_key": "mainagent", "title": strings.Repeat("会", maxConversationTitleRunes+1),
|
||||
})
|
||||
req = httptest.NewRequest(http.MethodPost, "/api/conversations", bytes.NewReader(body))
|
||||
req.Header.Set("Authorization", "Bearer "+token)
|
||||
rec = httptest.NewRecorder()
|
||||
s.Handler().ServeHTTP(rec, req)
|
||||
if rec.Code != http.StatusBadRequest {
|
||||
t.Fatalf("overlong conversation create status=%d body=%s", rec.Code, rec.Body.String())
|
||||
}
|
||||
|
||||
body, _ = json.Marshal(map[string]any{
|
||||
"agent_key": "mainagent", "title": strings.Repeat("x", maxConversationRequestBytes+1),
|
||||
})
|
||||
req = httptest.NewRequest(http.MethodPost, "/api/conversations", bytes.NewReader(body))
|
||||
req.Header.Set("Authorization", "Bearer "+token)
|
||||
rec = httptest.NewRecorder()
|
||||
s.Handler().ServeHTTP(rec, req)
|
||||
if rec.Code != http.StatusRequestEntityTooLarge {
|
||||
t.Fatalf("oversized conversation create status=%d body=%s", rec.Code, rec.Body.String())
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user