From d38cfb61af264a7cf57d5eba6b9c28c22e997dff Mon Sep 17 00:00:00 2001 From: QiuSW <105186638@qq.com> Date: Tue, 4 Aug 2026 16:33:22 +0800 Subject: [PATCH] feat(admin): add draft task creation --- admin/README.md | 3 + admin/cmd/server/main.go | 12 + admin/internal/config/config.go | 7 + admin/internal/config/config_test.go | 3 + admin/internal/server/router.go | 153 ++++++++- admin/internal/server/router_test.go | 188 +++++++++++ admin/internal/tasks/store.go | 129 ++++++++ admin/internal/tasks/tasks.go | 159 ++++++++++ admin/internal/tasks/tasks_test.go | 300 ++++++++++++++++++ .../transport/webui/templates/tasks.html | 20 +- admin/internal/transport/webui/webui.go | 17 +- docs/tasks/T-202.md | 8 +- 12 files changed, 966 insertions(+), 33 deletions(-) create mode 100644 admin/internal/tasks/store.go create mode 100644 admin/internal/tasks/tasks.go create mode 100644 admin/internal/tasks/tasks_test.go diff --git a/admin/README.md b/admin/README.md index 595b968..2ef7b8f 100644 --- a/admin/README.md +++ b/admin/README.md @@ -8,6 +8,7 @@ | `CMBUYER_ADMIN_PASSWORD_BCRYPT` | 非空 bcrypt 密码哈希,不接受明文密码。 | | `CMBUYER_SESSION_SECRET` | 至少 32 字节的会话签名密钥。 | | `CMBUYER_COOKIE_SECURE` | 可选;存在时只能精确为 `true` 或 `false`。HTTPS 部署应设为 `true`。 | +| `CMBUYER_DATABASE_SOURCE` | 已迁移 SQLite 的显式 data source。 | 示例仅展示变量名,不提供可运行凭据: @@ -16,6 +17,8 @@ $env:CMBUYER_ADMIN_USERNAME = '<管理员账号>' $env:CMBUYER_ADMIN_PASSWORD_BCRYPT = '' $env:CMBUYER_SESSION_SECRET = '<至少 32 字节的随机密钥>' $env:CMBUYER_COOKIE_SECURE = 'true' +$env:CMBUYER_DATABASE_SOURCE = '' +go run ./cmd/migrate -database $env:CMBUYER_DATABASE_SOURCE up go run ./cmd/server ``` diff --git a/admin/cmd/server/main.go b/admin/cmd/server/main.go index 19720a3..ff1596b 100644 --- a/admin/cmd/server/main.go +++ b/admin/cmd/server/main.go @@ -8,6 +8,8 @@ import ( "cmbuyer/admin/internal/auth" "cmbuyer/admin/internal/config" "cmbuyer/admin/internal/server" + "cmbuyer/admin/internal/storage/sqlite" + "cmbuyer/admin/internal/tasks" ) const listenAddress = ":8080" @@ -23,11 +25,21 @@ func run() error { if err != nil { return err } + database, err := sqlite.Open(configuration.DatabaseSource) + if err != nil { + return err + } + defer database.Close() + taskStore, err := tasks.NewSQLiteStore(database) + if err != nil { + return err + } router, err := server.NewRouter(server.Options{ AdminUsername: configuration.AdminUsername, AdminPasswordBcrypt: configuration.AdminPasswordBcrypt, Sessions: auth.NewManager(configuration.SessionSecret, configuration.CookieSecure), + Tasks: taskStore, }) if err != nil { return err diff --git a/admin/internal/config/config.go b/admin/internal/config/config.go index 312c91c..81c63c4 100644 --- a/admin/internal/config/config.go +++ b/admin/internal/config/config.go @@ -15,6 +15,7 @@ const ( adminPasswordBcryptEnv = "CMBUYER_ADMIN_PASSWORD_BCRYPT" sessionSecretEnv = "CMBUYER_SESSION_SECRET" cookieSecureEnv = "CMBUYER_COOKIE_SECURE" + databaseSourceEnv = "CMBUYER_DATABASE_SOURCE" minimumSecretLength = 32 ) @@ -24,6 +25,7 @@ type Config struct { AdminPasswordBcrypt string SessionSecret []byte CookieSecure bool + DatabaseSource string } // LoadFromEnv 从进程环境读取配置。错误只指出缺失或非法的变量名,绝不回显秘密。 @@ -65,12 +67,17 @@ func Load(lookup func(string) (string, bool)) (Config, error) { return Config{}, fmt.Errorf("%s must be exactly true or false", cookieSecureEnv) } } + databaseSource, err := required(lookup, databaseSourceEnv) + if err != nil { + return Config{}, err + } return Config{ AdminUsername: username, AdminPasswordBcrypt: passwordHash, SessionSecret: []byte(secret), CookieSecure: cookieSecure, + DatabaseSource: databaseSource, }, nil } diff --git a/admin/internal/config/config_test.go b/admin/internal/config/config_test.go index b94b776..f321d00 100644 --- a/admin/internal/config/config_test.go +++ b/admin/internal/config/config_test.go @@ -20,6 +20,7 @@ func TestLoad(t *testing.T) { "CMBUYER_ADMIN_PASSWORD_BCRYPT": string(hash), "CMBUYER_SESSION_SECRET": strings.Repeat("s", 32), "CMBUYER_COOKIE_SECURE": "true", + "CMBUYER_DATABASE_SOURCE": ":memory:", } got, err := config.Load(lookup(values)) @@ -41,6 +42,7 @@ func TestLoadRejectsMissingOrInvalidConfiguration(t *testing.T) { "CMBUYER_ADMIN_USERNAME": "admin", "CMBUYER_ADMIN_PASSWORD_BCRYPT": string(hash), "CMBUYER_SESSION_SECRET": strings.Repeat("s", 32), + "CMBUYER_DATABASE_SOURCE": ":memory:", } tests := []struct { @@ -52,6 +54,7 @@ func TestLoadRejectsMissingOrInvalidConfiguration(t *testing.T) { {"invalid bcrypt", func(values map[string]string) { values["CMBUYER_ADMIN_PASSWORD_BCRYPT"] = "not-a-bcrypt-hash" }, "CMBUYER_ADMIN_PASSWORD_BCRYPT"}, {"short secret", func(values map[string]string) { values["CMBUYER_SESSION_SECRET"] = "short" }, "CMBUYER_SESSION_SECRET"}, {"invalid secure flag", func(values map[string]string) { values["CMBUYER_COOKIE_SECURE"] = "1" }, "CMBUYER_COOKIE_SECURE"}, + {"missing database", func(values map[string]string) { delete(values, "CMBUYER_DATABASE_SOURCE") }, "CMBUYER_DATABASE_SOURCE"}, } for _, test := range tests { diff --git a/admin/internal/server/router.go b/admin/internal/server/router.go index 037a1af..e574a73 100644 --- a/admin/internal/server/router.go +++ b/admin/internal/server/router.go @@ -9,6 +9,7 @@ import ( "strings" "cmbuyer/admin/internal/auth" + "cmbuyer/admin/internal/tasks" "cmbuyer/admin/internal/transport/webui" "github.com/gin-gonic/gin" @@ -22,11 +23,12 @@ type Options struct { AdminUsername string AdminPasswordBcrypt string Sessions *auth.Manager + Tasks tasks.Store } // NewRouter 返回当前服务范围内的完整 HTTP 路由。 func NewRouter(options Options) (*gin.Engine, error) { - if options.AdminUsername == "" || options.AdminPasswordBcrypt == "" || options.Sessions == nil { + if options.AdminUsername == "" || options.AdminPasswordBcrypt == "" || options.Sessions == nil || options.Tasks == nil { return nil, errors.New("server authentication options are incomplete") } @@ -38,6 +40,8 @@ func NewRouter(options Options) (*gin.Engine, error) { router.POST("/login", login(options)) router.POST("/logout", logout(options)) router.GET("/tasks", tasksPage(options)) + router.GET("/tasks/new", newTaskPage(options)) + router.POST("/tasks", createTask(options)) return router, nil } @@ -70,11 +74,14 @@ func loginPage(options Options) gin.HandlerFunc { func login(options Options) gin.HandlerFunc { return func(context *gin.Context) { - limitFormBody(context) - csrfToken := context.PostForm("csrf_token") - returnPath := returnTo(context.PostForm("return_to")) - username := context.PostForm("username") - password := context.PostForm("password") + if !parseForm(context) { + return + } + form := context.Request.PostForm + csrfToken := form.Get("csrf_token") + returnPath := returnTo(form.Get("return_to")) + username := form.Get("username") + password := form.Get("password") if _, ok := options.Sessions.VerifyCSRF(context.Request, csrfToken); !ok { newCSRF, _ := options.Sessions.Ensure(context.Writer, context.Request) @@ -97,8 +104,10 @@ func login(options Options) gin.HandlerFunc { func logout(options Options) gin.HandlerFunc { return func(context *gin.Context) { - limitFormBody(context) - authenticated, ok := options.Sessions.VerifyCSRF(context.Request, context.PostForm("csrf_token")) + if !parseForm(context) { + return + } + authenticated, ok := options.Sessions.VerifyCSRF(context.Request, context.Request.PostForm.Get("csrf_token")) if !ok || !authenticated { context.Status(http.StatusForbidden) return @@ -117,10 +126,111 @@ func tasksPage(options Options) gin.HandlerFunc { return } - context.Header("Content-Type", "text/html; charset=utf-8") - if err := webui.RenderTasks(context.Writer, webui.TasksData{CSRFToken: csrfToken}); err != nil { - _ = context.Error(err) + drafts, err := options.Tasks.ListDrafts(context.Request.Context()) + if err != nil { + context.Status(http.StatusInternalServerError) + return } + data := webui.TasksData{CSRFToken: csrfToken, Drafts: drafts} + for _, draft := range drafts { + if draft.ID == context.Query("created") { + data.Success = true + break + } + } + if context.Query("create") == "1" { + form, err := newTaskForm() + if err != nil { + context.Status(http.StatusInternalServerError) + return + } + data.OpenForm = true + data.Form = form + data.FocusField = "title" + } + renderTasks(context, http.StatusOK, data) + } +} + +func newTaskPage(options Options) gin.HandlerFunc { + return func(context *gin.Context) { + csrf, authenticated := options.Sessions.Ensure(context.Writer, context.Request) + if !authenticated { + context.Redirect(http.StatusSeeOther, "/login?return_to=%2Ftasks%2Fnew") + return + } + form, err := newTaskForm() + if err != nil { + context.Status(http.StatusInternalServerError) + return + } + renderTasks(context, http.StatusOK, webui.TasksData{CSRFToken: csrf, Form: form, FullPage: true, FocusField: "title"}) + } +} +func createTask(options Options) gin.HandlerFunc { + return func(context *gin.Context) { + if !parseForm(context) { + return + } + requestForm := context.Request.PostForm + authenticated, csrfOK := options.Sessions.VerifyCSRF(context.Request, requestForm.Get("csrf_token")) + if !csrfOK || !authenticated { + context.Status(http.StatusForbidden) + return + } + form := taskForm(requestForm) + draft, validation := tasks.Validate(form) + if draft.GoodsID != "" { + form.ProductURL = tasks.CanonicalURL(draft.GoodsID) + } + fullPage := requestForm.Get("form_mode") == "full" + if !validation.Valid() { + drafts, err := options.Tasks.ListDrafts(context.Request.Context()) + if err != nil { + context.Status(http.StatusInternalServerError) + return + } + renderTasks(context, http.StatusBadRequest, webui.TasksData{CSRFToken: csrfFor(context, options), Drafts: drafts, Form: form, Errors: validation, OpenForm: !fullPage, FullPage: fullPage, FocusField: firstError(validation)}) + return + } + created, err := options.Tasks.CreateDraft(context.Request.Context(), draft) + if err != nil { + if errors.Is(err, tasks.ErrCreateKeyConflict) { + validation["create_key"] = "该创建请求已用于另一条任务,请重新打开表单。" + drafts, listErr := options.Tasks.ListDrafts(context.Request.Context()) + if listErr != nil { + context.Status(http.StatusInternalServerError) + return + } + renderTasks(context, http.StatusConflict, webui.TasksData{CSRFToken: csrfFor(context, options), Drafts: drafts, Form: form, Errors: validation, OpenForm: !fullPage, FullPage: fullPage, FocusField: firstError(validation)}) + return + } + context.Status(http.StatusInternalServerError) + return + } + context.Redirect(http.StatusSeeOther, "/tasks?created="+url.QueryEscape(created.ID)) + } +} + +func newTaskForm() (tasks.Form, error) { + key, err := tasks.NewCreateKey() + if err != nil { + return tasks.Form{}, err + } + return tasks.Form{CreateKey: key}, nil +} +func taskForm(form url.Values) tasks.Form { + return tasks.Form{CreateKey: form.Get("create_key"), Title: form.Get("title"), ProductURL: form.Get("product_url"), SKUColor: form.Get("sku_color"), SKUSize: form.Get("sku_size"), Quantity: form.Get("quantity"), MaxTotalPrice: form.Get("max_total_price")} +} +func csrfFor(context *gin.Context, options Options) string { + csrf, _ := options.Sessions.Ensure(context.Writer, context.Request) + return csrf +} +func renderTasks(context *gin.Context, status int, data webui.TasksData) { + context.Header("Content-Type", "text/html; charset=utf-8") + context.Status(status) + if err := webui.RenderTasks(context.Writer, data); err != nil { + _ = context.Error(err) } } @@ -137,8 +247,27 @@ func renderLogin(context *gin.Context, status int, csrfToken, returnPath, userna } } -func limitFormBody(context *gin.Context) { +func parseForm(context *gin.Context) bool { context.Request.Body = http.MaxBytesReader(context.Writer, context.Request.Body, maxFormBytes) + if err := context.Request.ParseForm(); err != nil { + var tooLarge *http.MaxBytesError + if errors.As(err, &tooLarge) { + context.Status(http.StatusRequestEntityTooLarge) + } else { + context.Status(http.StatusBadRequest) + } + return false + } + return true +} + +func firstError(validation tasks.Errors) string { + for _, field := range []string{"title", "product_url", "sku_color", "sku_size", "quantity", "max_total_price"} { + if _, ok := validation[field]; ok { + return field + } + } + return "title" } func returnTo(value string) string { diff --git a/admin/internal/server/router_test.go b/admin/internal/server/router_test.go index 54aa508..e4547cb 100644 --- a/admin/internal/server/router_test.go +++ b/admin/internal/server/router_test.go @@ -1,6 +1,7 @@ package server_test import ( + "context" "net/http" "net/http/httptest" "net/url" @@ -10,12 +11,14 @@ import ( "cmbuyer/admin/internal/auth" "cmbuyer/admin/internal/server" + "cmbuyer/admin/internal/tasks" "github.com/gin-gonic/gin" "golang.org/x/crypto/bcrypt" ) var csrfPattern = regexp.MustCompile(`name="csrf_token" value="([^"]+)"`) +var createKeyPattern = regexp.MustCompile(`name="create_key" value="([^"]+)"`) func TestHealthzIsPublic(t *testing.T) { router, _ := newRouter(t) @@ -175,6 +178,149 @@ func TestTamperedCookieCannotAccessTasks(t *testing.T) { } +func TestTaskCreationRendersSharedFormsAndPersistsOnlyDraft(t *testing.T) { + router, _ := newRouter(t) + cookie := authenticate(t, router) + + modal := serve(router, http.MethodGet, "/tasks?create=1", nil, cookie) + if modal.Code != http.StatusOK { + t.Fatalf("GET dialog form status = %d, want 200", modal.Code) + } + fullPage := serve(router, http.MethodGet, "/tasks/new", nil, cookie) + if fullPage.Code != http.StatusOK { + t.Fatalf("GET full form status = %d, want 200", fullPage.Code) + } + for _, want := range []string{`