From cea27ff7ef9f7b909645bbb427f57209bf95548d Mon Sep 17 00:00:00 2001 From: QiuSW <105186638@qq.com> Date: Tue, 4 Aug 2026 15:08:13 +0800 Subject: [PATCH 1/2] docs(tasks): define T-202 draft creation --- docs/tasks/T-202.md | 73 +++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 73 insertions(+) create mode 100644 docs/tasks/T-202.md diff --git a/docs/tasks/T-202.md b/docs/tasks/T-202.md new file mode 100644 index 0000000..ac53125 --- /dev/null +++ b/docs/tasks/T-202.md @@ -0,0 +1,73 @@ +--- +id: T-202 +title: 手工建单与 DRAFT 基础列表 +phase: 2 +deps: [T-201, T-004, T-005] +status: DOING +created: 2026-08-04 +vikunja_task_id: 27 +context_ref: 1c35155 +work_branch: task/t-202-admin-draft +needs_device: false +needs_human_review: false +write_paths: + - docs/tasks/T-202.md + - admin/cmd/server/main.go + - admin/internal/config/** + - admin/internal/server/** + - admin/internal/tasks/** + - admin/internal/storage/sqlite/** + - admin/internal/transport/webui/** + - admin/README.md +--- + + +## 问题 / 背景 + +T-201 已提供管理员会话;T-004 已提供 tasks 表。根据 T-010 加速门禁,T-103 尚未完成时只允许实现不启动试选的 DRAFT 手工建单与基础列表。 + +## 关联需求与交互 + +F-001、US-001、IX-002;GET /tasks、GET /tasks/new、POST /tasks;沿用已确认的传统表格与创建弹窗/直达页。 + +## 方案 + +1. 显式数据库配置并打开已迁移 SQLite;以仓储接口隔离 HTTP 和 SQL,创建事务只写 MANUAL、DRAFT、version=1。 +2. 表单校验任务名称、canonical 拼多多链接、颜色分类、尺码、正整数数量和正十进制总额上限;金额只用字符串并规范为两位小数。链接只接受 HTTPS mobile.yangkeduo.com/goods.html 且 goods_id 为唯一纯数字参数,额外查询参数不进入数据库。 +3. 以服务端生成的 create_key 同时作为任务 ID;重复相同 key 和相同内容返回原结果,不创建第二条,内容不同则冲突。 +4. GET /tasks 默认 created_at DESC 显示 DRAFT 基础表格;创建入口用服务端渲染的 modal 状态,/tasks/new 复用同一表单作为无脚本兜底;失败保留非密码输入并显示字段错误,成功 303 回列表且新任务第一行。 +5. 页面只显示需求字段、采购结果占位、DRAFT 状态与创建时间;不读取或伪造规格面板价格/证据,不提供勾选开始试选、状态推进、详情或设备接口。 + +## 验收要点 + +- 覆盖创建成功、倒序第一行、严格链接/goods_id、数量、金额、空白/长度、CSRF/未登录、幂等重放与冲突、SQL 错误 fail closed。 +- 弹窗与 /tasks/new 共享校验;错误保留输入并可访问;标题只链接到由 goods_id 重建的 canonical PDD URL并使用安全新标签属性。 +- go test ./...、go test -race ./...、go vet ./...、go build ./...、完整 init.ps1、上下文校验和 diff-check 通过。 + +## 执行记录 + +(暂无) + + +## 边界 + +- 本任务只创建 `source=MANUAL`、`status=DRAFT`、`version=1` 的任务并显示 DRAFT 基础列表;不得 + 实现勾选、批量开始试选、`DRAFT → PENDING` 或任何其他状态流转,也不得新增设备领取接口。 +- 不增加或修改数据库 schema,不读写 `spec_trials`、`order_authorizations`、`order_submissions`, + 不生成或展示机器实际规格、规格面板单价、截图、证据哈希或 PDD 页面判据。 +- 启动服务必须从显式 `CMBUYER_DATABASE_SOURCE` 读取 SQLite data source;缺失时明确失败,不提供 + 隐式内存库或仓库内默认数据库。服务不自动猜迁移目录;README 必须先给出显式迁移命令。 +- 商品链接只接受 `https://mobile.yangkeduo.com/goods.html`,且必须恰有一个纯数字 `goods_id`; + 拒绝 userinfo、端口、fragment、重复参数、其他 host/scheme/path 和编码绕过。数据库只保存 goods_id, + 展示链接由 goods_id 重建 canonical URL;`uin` 等额外查询参数既不保存也不回显。 +- 标题、颜色分类、尺码必须去除首尾空白后非空并受明确长度上限约束;数量必须是可表示的正整数; + 总额上限必须是大于零、最多两位小数的十进制字符串并规范为两位小数。金额校验、保存与展示均不得 + 使用浮点数或从其他数字推测。 +- `create_key` 由服务端用 `crypto/rand` 生成并验证格式,同时作为任务 ID;相同 key 与相同规范化内容 + 重放只能返回原任务,不得二次 INSERT,相同 key 携带不同内容必须冲突。SQL 必须参数化,创建失败 + 不得留下半条或未知状态记录。 +- `GET /tasks`、`GET /tasks/new`、`POST /tasks` 都必须复用 T-201 管理会话;POST 必须验证 CSRF。 + 校验失败保留非敏感输入并逐字段提示,数据库内部错误只给通用响应,不泄露 SQL、路径或凭据。 +- 页面只使用服务端模板转义;标题商品链接在新标签打开时必须带 `noopener noreferrer`。导入按钮只作 + 禁用占位;不得加载外部资源或把原型假数据、真机数据、地址、手机号带进生产页面。 +- 不实现或引用试选、数量设置、订单确认、提交围栏、提交订单、付款、免密支付或先用后付能力。 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 2/2] 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{`