Compare commits

...
21 changed files with 2052 additions and 48 deletions
+7
View File
@@ -9,6 +9,9 @@
| `CMBUYER_SESSION_SECRET` | 至少 32 字节的会话签名密钥。 | | `CMBUYER_SESSION_SECRET` | 至少 32 字节的会话签名密钥。 |
| `CMBUYER_COOKIE_SECURE` | 可选;存在时只能精确为 `true` 或 `false`。HTTPS 部署应设为 `true`。 | | `CMBUYER_COOKIE_SECURE` | 可选;存在时只能精确为 `true` 或 `false`。HTTPS 部署应设为 `true`。 |
| `CMBUYER_DATABASE_SOURCE` | 已迁移 SQLite 的显式 data source。 | | `CMBUYER_DATABASE_SOURCE` | 已迁移 SQLite 的显式 data source。 |
| `CMBUYER_AUTHORIZATION_TTL` | 一次性授权的正 Go duration,例如 `10m`。 |
| `CMBUYER_MAX_TASK_QUANTITY` | 每条任务允许的正整数数量上限。 |
| `CMBUYER_MAX_TOTAL_PRICE` | 每条任务允许的规范正数总价上限,例如 `999.99`。 |
示例仅展示变量名,不提供可运行凭据: 示例仅展示变量名,不提供可运行凭据:
@@ -18,8 +21,12 @@ $env:CMBUYER_ADMIN_PASSWORD_BCRYPT = '<bcrypt 密码哈希>'
$env:CMBUYER_SESSION_SECRET = '<至少 32 字节的随机密钥>' $env:CMBUYER_SESSION_SECRET = '<至少 32 字节的随机密钥>'
$env:CMBUYER_COOKIE_SECURE = 'true' $env:CMBUYER_COOKIE_SECURE = 'true'
$env:CMBUYER_DATABASE_SOURCE = '<SQLite data source>' $env:CMBUYER_DATABASE_SOURCE = '<SQLite data source>'
$env:CMBUYER_AUTHORIZATION_TTL = '10m'
$env:CMBUYER_MAX_TASK_QUANTITY = '99'
$env:CMBUYER_MAX_TOTAL_PRICE = '999.99'
go run ./cmd/migrate -database $env:CMBUYER_DATABASE_SOURCE up go run ./cmd/migrate -database $env:CMBUYER_DATABASE_SOURCE up
go run ./cmd/server go run ./cmd/server
``` ```
采购服务会话仅保存在当前进程内;进程重启后既有登录会话会安全失效。 采购服务会话仅保存在当前进程内;进程重启后既有登录会话会安全失效。
管理员的“开始采购(只创建待付款订单)”只签发一次性授权并创建待付款订单的资格;服务不会自动付款,也不包含任何支付操作。
+1
View File
@@ -34,6 +34,7 @@ func run() error {
if err != nil { if err != nil {
return err return err
} }
taskStore.SetStartPolicy(tasks.StartPolicy{AuthorizationTTL: configuration.AuthorizationTTL, MaxQuantity: configuration.MaxTaskQuantity, MaxTotalPrice: configuration.MaxTotalPrice})
router, err := server.NewRouter(server.Options{ router, err := server.NewRouter(server.Options{
AdminUsername: configuration.AdminUsername, AdminUsername: configuration.AdminUsername,
+6
View File
@@ -62,6 +62,12 @@ func (manager *Manager) Ensure(writer http.ResponseWriter, request *http.Request
return current.csrfToken, false return current.csrfToken, false
} }
// IsAuthenticated 只读检查当前请求是否持有有效管理会话;它不会像 Ensure 一样创建匿名会话。
func (manager *Manager) IsAuthenticated(request *http.Request) bool {
_, current, found := manager.current(request)
return found && current.authenticated
}
// VerifyCSRF 只接受当前未过期会话中以恒定时间比较匹配的 token。 // VerifyCSRF 只接受当前未过期会话中以恒定时间比较匹配的 token。
func (manager *Manager) VerifyCSRF(request *http.Request, token string) (authenticated bool, ok bool) { func (manager *Manager) VerifyCSRF(request *http.Request, token string) (authenticated bool, ok bool) {
_, current, found := manager.current(request) _, current, found := manager.current(request)
+45
View File
@@ -37,6 +37,51 @@ func TestManagerRejectsTamperedAndExpiredCookies(t *testing.T) {
} }
} }
func TestIsAuthenticatedDoesNotCreateOrDependOnCSRFValidation(t *testing.T) {
manager := NewManager([]byte(strings.Repeat("s", 32)), false)
missingSession := httptest.NewRequest(http.MethodPost, "/tasks/start-purchases", nil)
if manager.IsAuthenticated(missingSession) {
t.Fatal("missing session was treated as authenticated")
}
if len(manager.sessions) != 0 {
t.Fatalf("read-only authentication check created %d sessions", len(manager.sessions))
}
anonymousRequest := httptest.NewRequest(http.MethodGet, "/login", nil)
anonymousResponse := httptest.NewRecorder()
manager.Ensure(anonymousResponse, anonymousRequest)
anonymousCookie := anonymousResponse.Result().Cookies()[0]
anonymousCheck := httptest.NewRequest(http.MethodPost, "/tasks/start-purchases", nil)
anonymousCheck.AddCookie(anonymousCookie)
if manager.IsAuthenticated(anonymousCheck) {
t.Fatal("anonymous CSRF session was treated as authenticated")
}
loginRequest := httptest.NewRequest(http.MethodPost, "/login", nil)
loginRequest.AddCookie(anonymousCookie)
authenticatedResponse := httptest.NewRecorder()
csrf := manager.RotateAuthenticated(authenticatedResponse, loginRequest)
authenticatedCookie := authenticatedResponse.Result().Cookies()[0]
authenticatedCheck := httptest.NewRequest(http.MethodPost, "/tasks/start-purchases", nil)
authenticatedCheck.AddCookie(authenticatedCookie)
if !manager.IsAuthenticated(authenticatedCheck) {
t.Fatal("valid authenticated session was not recognized")
}
if authenticated, csrfOK := manager.VerifyCSRF(authenticatedCheck, "wrong-token"); authenticated || csrfOK {
t.Fatalf("wrong token result = (%t, %t), want (false, false)", authenticated, csrfOK)
}
validRequest := httptest.NewRequest(http.MethodPost, "/tasks/start-purchases", nil)
validRequest.AddCookie(authenticatedCookie)
if authenticated, csrfOK := manager.VerifyCSRF(validRequest, csrf); !authenticated || !csrfOK {
t.Fatalf("valid token result = (%t, %t), want (true, true)", authenticated, csrfOK)
}
if authenticated, csrfOK := manager.VerifyCSRF(httptest.NewRequest(http.MethodPost, "/tasks/start-purchases", nil), csrf); authenticated || csrfOK {
t.Fatalf("missing session result = (%t, %t), want (false, false)", authenticated, csrfOK)
}
}
func flipCookieValue(t *testing.T, value string) string { func flipCookieValue(t *testing.T, value string) string {
t.Helper() t.Helper()
if value == "" { if value == "" {
+47
View File
@@ -5,7 +5,9 @@ import (
"errors" "errors"
"fmt" "fmt"
"os" "os"
"strconv"
"strings" "strings"
"time"
"golang.org/x/crypto/bcrypt" "golang.org/x/crypto/bcrypt"
) )
@@ -16,6 +18,9 @@ const (
sessionSecretEnv = "CMBUYER_SESSION_SECRET" sessionSecretEnv = "CMBUYER_SESSION_SECRET"
cookieSecureEnv = "CMBUYER_COOKIE_SECURE" cookieSecureEnv = "CMBUYER_COOKIE_SECURE"
databaseSourceEnv = "CMBUYER_DATABASE_SOURCE" databaseSourceEnv = "CMBUYER_DATABASE_SOURCE"
authorizationTTLEnv = "CMBUYER_AUTHORIZATION_TTL"
maxTaskQuantityEnv = "CMBUYER_MAX_TASK_QUANTITY"
maxTotalPriceEnv = "CMBUYER_MAX_TOTAL_PRICE"
minimumSecretLength = 32 minimumSecretLength = 32
) )
@@ -26,6 +31,9 @@ type Config struct {
SessionSecret []byte SessionSecret []byte
CookieSecure bool CookieSecure bool
DatabaseSource string DatabaseSource string
AuthorizationTTL time.Duration
MaxTaskQuantity int
MaxTotalPrice string
} }
// LoadFromEnv 从进程环境读取配置。错误只指出缺失或非法的变量名,绝不回显秘密。 // LoadFromEnv 从进程环境读取配置。错误只指出缺失或非法的变量名,绝不回显秘密。
@@ -71,6 +79,29 @@ func Load(lookup func(string) (string, bool)) (Config, error) {
if err != nil { if err != nil {
return Config{}, err return Config{}, err
} }
ttlText, err := required(lookup, authorizationTTLEnv)
if err != nil {
return Config{}, err
}
ttl, err := time.ParseDuration(ttlText)
if err != nil || ttl <= 0 {
return Config{}, fmt.Errorf("%s must be a positive duration", authorizationTTLEnv)
}
quantityText, err := required(lookup, maxTaskQuantityEnv)
if err != nil {
return Config{}, err
}
maxQuantity, err := strconv.Atoi(quantityText)
if err != nil || maxQuantity < 1 {
return Config{}, fmt.Errorf("%s must be a positive integer", maxTaskQuantityEnv)
}
maxPrice, err := required(lookup, maxTotalPriceEnv)
if err != nil {
return Config{}, err
}
if !canonicalMoney(maxPrice) {
return Config{}, fmt.Errorf("%s must be a canonical positive decimal", maxTotalPriceEnv)
}
return Config{ return Config{
AdminUsername: username, AdminUsername: username,
@@ -78,9 +109,25 @@ func Load(lookup func(string) (string, bool)) (Config, error) {
SessionSecret: []byte(secret), SessionSecret: []byte(secret),
CookieSecure: cookieSecure, CookieSecure: cookieSecure,
DatabaseSource: databaseSource, DatabaseSource: databaseSource,
AuthorizationTTL: ttl, MaxTaskQuantity: maxQuantity, MaxTotalPrice: maxPrice,
}, nil }, nil
} }
func canonicalMoney(value string) bool {
parts := strings.Split(value, ".")
if len(parts) != 2 || len(parts[0]) == 0 || len(parts[1]) != 2 || (len(parts[0]) > 1 && parts[0][0] == '0') {
return false
}
for _, part := range parts {
for _, ch := range part {
if ch < '0' || ch > '9' {
return false
}
}
}
return strings.Trim(parts[0]+parts[1], "0") != ""
}
func required(lookup func(string) (string, bool), name string) (string, error) { func required(lookup func(string) (string, bool), name string) (string, error) {
value, present := lookup(name) value, present := lookup(name)
if !present || strings.TrimSpace(value) == "" { if !present || strings.TrimSpace(value) == "" {
+9
View File
@@ -21,6 +21,9 @@ func TestLoad(t *testing.T) {
"CMBUYER_SESSION_SECRET": strings.Repeat("s", 32), "CMBUYER_SESSION_SECRET": strings.Repeat("s", 32),
"CMBUYER_COOKIE_SECURE": "true", "CMBUYER_COOKIE_SECURE": "true",
"CMBUYER_DATABASE_SOURCE": ":memory:", "CMBUYER_DATABASE_SOURCE": ":memory:",
"CMBUYER_AUTHORIZATION_TTL": "10m",
"CMBUYER_MAX_TASK_QUANTITY": "99",
"CMBUYER_MAX_TOTAL_PRICE": "999.99",
} }
got, err := config.Load(lookup(values)) got, err := config.Load(lookup(values))
@@ -43,6 +46,9 @@ func TestLoadRejectsMissingOrInvalidConfiguration(t *testing.T) {
"CMBUYER_ADMIN_PASSWORD_BCRYPT": string(hash), "CMBUYER_ADMIN_PASSWORD_BCRYPT": string(hash),
"CMBUYER_SESSION_SECRET": strings.Repeat("s", 32), "CMBUYER_SESSION_SECRET": strings.Repeat("s", 32),
"CMBUYER_DATABASE_SOURCE": ":memory:", "CMBUYER_DATABASE_SOURCE": ":memory:",
"CMBUYER_AUTHORIZATION_TTL": "10m",
"CMBUYER_MAX_TASK_QUANTITY": "99",
"CMBUYER_MAX_TOTAL_PRICE": "999.99",
} }
tests := []struct { tests := []struct {
@@ -55,6 +61,9 @@ func TestLoadRejectsMissingOrInvalidConfiguration(t *testing.T) {
{"short secret", func(values map[string]string) { values["CMBUYER_SESSION_SECRET"] = "short" }, "CMBUYER_SESSION_SECRET"}, {"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"}, {"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"}, {"missing database", func(values map[string]string) { delete(values, "CMBUYER_DATABASE_SOURCE") }, "CMBUYER_DATABASE_SOURCE"},
{"invalid authorization ttl", func(values map[string]string) { values["CMBUYER_AUTHORIZATION_TTL"] = "0s" }, "CMBUYER_AUTHORIZATION_TTL"},
{"invalid maximum quantity", func(values map[string]string) { values["CMBUYER_MAX_TASK_QUANTITY"] = "0" }, "CMBUYER_MAX_TASK_QUANTITY"},
{"invalid maximum total price", func(values map[string]string) { values["CMBUYER_MAX_TOTAL_PRICE"] = "1" }, "CMBUYER_MAX_TOTAL_PRICE"},
} }
for _, test := range tests { for _, test := range tests {
+142 -13
View File
@@ -2,11 +2,16 @@
package server package server
import ( import (
"bytes"
"crypto/subtle" "crypto/subtle"
"encoding/json"
"errors" "errors"
"io"
"mime"
"net/http" "net/http"
"net/url" "net/url"
"strings" "strings"
"unicode/utf8"
"cmbuyer/admin/internal/auth" "cmbuyer/admin/internal/auth"
"cmbuyer/admin/internal/tasks" "cmbuyer/admin/internal/tasks"
@@ -17,6 +22,7 @@ import (
) )
const maxFormBytes = 8 << 10 const maxFormBytes = 8 << 10
const maxJSONBytes = 64 << 10
// Options 是路由层需要的安全依赖。凭据由启动配置注入,不能在路由中设置默认值。 // Options 是路由层需要的安全依赖。凭据由启动配置注入,不能在路由中设置默认值。
type Options struct { type Options struct {
@@ -42,10 +48,84 @@ func NewRouter(options Options) (*gin.Engine, error) {
router.GET("/tasks", tasksPage(options)) router.GET("/tasks", tasksPage(options))
router.GET("/tasks/new", newTaskPage(options)) router.GET("/tasks/new", newTaskPage(options))
router.POST("/tasks", createTask(options)) router.POST("/tasks", createTask(options))
router.POST("/tasks/start-purchases", startPurchases(options))
router.GET("/static/tasks.js", func(context *gin.Context) {
context.Data(http.StatusOK, "application/javascript; charset=utf-8", webui.TasksScript())
})
return router, nil return router, nil
} }
func startPurchases(options Options) gin.HandlerFunc {
return func(context *gin.Context) {
if !options.Sessions.IsAuthenticated(context.Request) {
context.Status(http.StatusUnauthorized)
return
}
authenticated, csrfOK := options.Sessions.VerifyCSRF(context.Request, context.GetHeader("X-CSRF-Token"))
if !authenticated || !csrfOK {
context.Status(http.StatusForbidden)
return
}
if !isJSONContentType(context.GetHeader("Content-Type")) {
context.Status(http.StatusUnsupportedMediaType)
return
}
context.Request.Body = http.MaxBytesReader(context.Writer, context.Request.Body, maxJSONBytes)
raw, err := io.ReadAll(context.Request.Body)
if err != nil {
var tooLarge *http.MaxBytesError
if errors.As(err, &tooLarge) {
context.Status(http.StatusRequestEntityTooLarge)
} else {
context.Status(http.StatusBadRequest)
}
return
}
if !utf8.Valid(raw) {
context.Status(http.StatusBadRequest)
return
}
decoder := json.NewDecoder(bytes.NewReader(raw))
decoder.DisallowUnknownFields()
var command tasks.StartCommand
if err := decoder.Decode(&command); err != nil {
context.Status(http.StatusBadRequest)
return
}
var extra any
if err := decoder.Decode(&extra); err != io.EOF {
context.Status(http.StatusBadRequest)
return
}
result, err := options.Tasks.StartPurchases(context.Request.Context(), command, options.AdminUsername)
if err != nil {
if errors.Is(err, tasks.ErrInvalidStart) {
context.Status(http.StatusBadRequest)
} else if errors.Is(err, tasks.ErrStartConflict) {
context.Status(http.StatusConflict)
} else {
context.Status(http.StatusInternalServerError)
}
return
}
context.JSON(http.StatusOK, result)
}
}
func isJSONContentType(value string) bool {
mediaType, parameters, err := mime.ParseMediaType(value)
if err != nil || mediaType != "application/json" {
return false
}
for name, value := range parameters {
if name != "charset" || !strings.EqualFold(value, "utf-8") {
return false
}
}
return true
}
func healthz(context *gin.Context) { func healthz(context *gin.Context) {
context.JSON(http.StatusOK, gin.H{"status": "ok"}) context.JSON(http.StatusOK, gin.H{"status": "ok"})
} }
@@ -55,7 +135,7 @@ func securityHeaders() gin.HandlerFunc {
context.Header("Cache-Control", "no-store") context.Header("Cache-Control", "no-store")
context.Header("X-Content-Type-Options", "nosniff") context.Header("X-Content-Type-Options", "nosniff")
context.Header("Referrer-Policy", "no-referrer") context.Header("Referrer-Policy", "no-referrer")
context.Header("Content-Security-Policy", "default-src 'self'; style-src 'self' 'unsafe-inline'; script-src 'none'; object-src 'none'; base-uri 'none'; frame-ancestors 'none'; form-action 'self'") context.Header("Content-Security-Policy", "default-src 'self'; style-src 'self' 'unsafe-inline'; script-src 'self'; object-src 'none'; base-uri 'none'; frame-ancestors 'none'; form-action 'self'")
context.Next() context.Next()
} }
} }
@@ -126,14 +206,23 @@ func tasksPage(options Options) gin.HandlerFunc {
return return
} }
drafts, err := options.Tasks.ListDrafts(context.Request.Context()) filter := tasks.TaskFilter{Keyword: context.Query("keyword"), Status: context.Query("status"), CreatedFrom: context.Query("created_from"), CreatedTo: context.Query("created_to")}
if validation := tasks.ValidateTaskFilter(filter); !validation.Valid() {
startKey, err := tasks.NewCreateKey()
if err != nil {
context.Status(http.StatusInternalServerError)
return
}
renderTasks(context, http.StatusBadRequest, webui.TasksData{CSRFToken: csrfToken, Filter: filter, FilterErrors: validation, HasFilter: true, StartKey: startKey})
return
}
data, err := taskListData(context, options, csrfToken, filter)
if err != nil { if err != nil {
context.Status(http.StatusInternalServerError) context.Status(http.StatusInternalServerError)
return return
} }
data := webui.TasksData{CSRFToken: csrfToken, Drafts: drafts} for _, row := range data.Tasks {
for _, draft := range drafts { if row.ID == context.Query("created") {
if draft.ID == context.Query("created") {
data.Success = true data.Success = true
break break
} }
@@ -185,24 +274,32 @@ func createTask(options Options) gin.HandlerFunc {
} }
fullPage := requestForm.Get("form_mode") == "full" fullPage := requestForm.Get("form_mode") == "full"
if !validation.Valid() { if !validation.Valid() {
drafts, err := options.Tasks.ListDrafts(context.Request.Context()) data, ok := createErrorData(context, options, fullPage)
if err != nil { if !ok {
context.Status(http.StatusInternalServerError)
return return
} }
renderTasks(context, http.StatusBadRequest, webui.TasksData{CSRFToken: csrfFor(context, options), Drafts: drafts, Form: form, Errors: validation, OpenForm: !fullPage, FullPage: fullPage, FocusField: firstError(validation)}) data.Form = form
data.Errors = validation
data.OpenForm = !fullPage
data.FullPage = fullPage
data.FocusField = firstError(validation)
renderTasks(context, http.StatusBadRequest, data)
return return
} }
created, err := options.Tasks.CreateDraft(context.Request.Context(), draft) created, err := options.Tasks.CreateDraft(context.Request.Context(), draft)
if err != nil { if err != nil {
if errors.Is(err, tasks.ErrCreateKeyConflict) { if errors.Is(err, tasks.ErrCreateKeyConflict) {
validation["create_key"] = "该创建请求已用于另一条任务,请重新打开表单。" validation["create_key"] = "该创建请求已用于另一条任务,请重新打开表单。"
drafts, listErr := options.Tasks.ListDrafts(context.Request.Context()) data, ok := createErrorData(context, options, fullPage)
if listErr != nil { if !ok {
context.Status(http.StatusInternalServerError)
return return
} }
renderTasks(context, http.StatusConflict, webui.TasksData{CSRFToken: csrfFor(context, options), Drafts: drafts, Form: form, Errors: validation, OpenForm: !fullPage, FullPage: fullPage, FocusField: firstError(validation)}) data.Form = form
data.Errors = validation
data.OpenForm = !fullPage
data.FullPage = fullPage
data.FocusField = firstError(validation)
renderTasks(context, http.StatusConflict, data)
return return
} }
context.Status(http.StatusInternalServerError) context.Status(http.StatusInternalServerError)
@@ -226,6 +323,38 @@ func csrfFor(context *gin.Context, options Options) string {
csrf, _ := options.Sessions.Ensure(context.Writer, context.Request) csrf, _ := options.Sessions.Ensure(context.Writer, context.Request)
return csrf return csrf
} }
func taskListData(context *gin.Context, options Options, csrfToken string, filter tasks.TaskFilter) (webui.TasksData, error) {
rows, err := options.Tasks.ListTasks(context.Request.Context(), filter)
if err != nil {
return webui.TasksData{}, err
}
startKey, err := tasks.NewCreateKey()
if err != nil {
return webui.TasksData{}, err
}
return webui.TasksData{
CSRFToken: csrfToken,
Tasks: rows,
Filter: filter,
HasFilter: filter.Keyword != "" || filter.Status != "" || filter.CreatedFrom != "" || filter.CreatedTo != "",
StartKey: startKey,
}, nil
}
func createErrorData(context *gin.Context, options Options, fullPage bool) (webui.TasksData, bool) {
csrfToken := csrfFor(context, options)
if fullPage {
return webui.TasksData{CSRFToken: csrfToken}, true
}
data, err := taskListData(context, options, csrfToken, tasks.TaskFilter{})
if err != nil {
context.Status(http.StatusInternalServerError)
return webui.TasksData{}, false
}
return data, true
}
func renderTasks(context *gin.Context, status int, data webui.TasksData) { func renderTasks(context *gin.Context, status int, data webui.TasksData) {
context.Header("Content-Type", "text/html; charset=utf-8") context.Header("Content-Type", "text/html; charset=utf-8")
context.Status(status) context.Status(status)
+125 -5
View File
@@ -8,6 +8,7 @@ import (
"regexp" "regexp"
"strings" "strings"
"testing" "testing"
"time"
"cmbuyer/admin/internal/auth" "cmbuyer/admin/internal/auth"
"cmbuyer/admin/internal/server" "cmbuyer/admin/internal/server"
@@ -190,7 +191,7 @@ func TestTaskCreationRendersSharedFormsAndPersistsOnlyDraft(t *testing.T) {
if fullPage.Code != http.StatusOK { if fullPage.Code != http.StatusOK {
t.Fatalf("GET full form status = %d, want 200", fullPage.Code) t.Fatalf("GET full form status = %d, want 200", fullPage.Code)
} }
for _, want := range []string{`<div class="modal-scrim"`, `<dialog open`, `aria-modal="true"`, `name="title"`, `name="product_url"`, `name="sku_color"`, `name="sku_size"`, `name="quantity"`, `name="max_total_price"`, `type="url" inputmode="url" maxlength="2048"`, `type="number" inputmode="numeric" min="1" step="1"`, `inputmode="decimal" pattern="[0-9]+(\.[0-9]{1,2})?"`, `maxlength="120"`, `maxlength="80"`, `required`, `autofocus`, `导入</button><a class="button primary"`, `type="search" disabled`, `disabled>筛选</button>`, `disabled>清除</button>`, `min-height:44px`, `overflow-x:auto`, `prefers-reduced-motion`} { for _, want := range []string{`<div class="modal-scrim"`, `<dialog open`, `aria-modal="true"`, `name="title"`, `name="product_url"`, `name="sku_color"`, `name="sku_size"`, `name="quantity"`, `name="max_total_price"`, `type="url" inputmode="url" maxlength="2048"`, `type="number" inputmode="numeric" min="1" step="1"`, `inputmode="decimal" pattern="[0-9]+(\.[0-9]{1,2})?"`, `maxlength="120"`, `maxlength="80"`, `required`, `autofocus`, `导入</button><a class="button primary"`, `type="search"`, `data-start-purchases`, `data-select-all`, `最高总额`, `min-height:44px`, `:focus-visible`, `overflow-x:auto`, `prefers-reduced-motion`} {
if !strings.Contains(modal.Body.String(), want) { if !strings.Contains(modal.Body.String(), want) {
t.Fatalf("dialog form is missing %q", want) t.Fatalf("dialog form is missing %q", want)
} }
@@ -277,13 +278,108 @@ func TestTaskCreationRendersSharedFormsAndPersistsOnlyDraft(t *testing.T) {
t.Fatalf("task list is missing %q", want) t.Fatalf("task list is missing %q", want)
} }
} }
for _, forbidden := range []string{"utm_source", "试选", "PENDING", "支付", "订单确认", "真机", "提交订单"} { for _, forbidden := range []string{"utm_source", "试选", "订单确认", "真机", "提交订单"} {
if strings.Contains(body, forbidden) { if strings.Contains(body, forbidden) {
t.Fatalf("task list exposed deferred scope %q", forbidden) t.Fatalf("task list exposed deferred scope %q", forbidden)
} }
} }
} }
func TestTasksPageKeepsOriginalShellAndRendersFilteredWorkbench(t *testing.T) {
store := &memoryStore{rows: []tasks.TaskRow{
{ID: "b3c9f507-7473-4fa6-8d71-8786c34c6301", Title: "待开始衬衫", GoodsID: "937122477375", SKUColor: "黑色", SKUSize: "M", Quantity: 2, MaxTotalPrice: "12.80", Status: "DRAFT", Version: 3, CreatedAt: time.Date(2026, 8, 4, 1, 2, 3, 0, time.UTC)},
{ID: "c3c9f507-7473-4fa6-8d71-8786c34c6301", Title: "等待领取衬衫", GoodsID: "958756616606", SKUColor: "白色", SKUSize: "L", Quantity: 1, MaxTotalPrice: "20.00", Status: "PENDING", Version: 4, CreatedAt: time.Date(2026, 8, 4, 2, 3, 4, 0, time.UTC)},
}}
router, _ := newRouterWithStore(t, store)
cookie := authenticate(t, router)
query := url.Values{"keyword": {"衬衫"}, "created_from": {"2026-08-04"}, "created_to": {"2026-08-04"}}
response := serve(router, http.MethodGet, "/tasks?"+query.Encode(), nil, cookie)
if response.Code != http.StatusOK {
t.Fatalf("filtered tasks status = %d, want 200", response.Code)
}
body := response.Body.String()
for _, want := range []string{
`<a class="skip" href="#main">`,
`:focus-visible`,
`min-height:44px`,
`@media(max-width:420px)`,
`prefers-reduced-motion`,
`<button class="button" type="button" disabled>导入</button><a class="button primary" href="/tasks?create=1">创建任务</a>`,
`name="keyword" type="search" value="衬衫"`,
`name="created_from" type="date" value="2026-08-04"`,
`name="created_to" type="date" value="2026-08-04"`,
`data-start-purchases`,
`data-selection-summary aria-live="polite"`,
`系统不会付款`,
`开始采购(只创建待付款订单)`,
`采购结果`,
`创建时间(上海)`,
`https://mobile.yangkeduo.com/goods.html?goods_id=937122477375`,
`target="_blank" rel="noopener noreferrer"`,
`待开始`,
`已授权待领取`,
`datetime="2026-08-04T09:02:03&#43;08:00">2026-08-04 09:02`,
`<script src="/static/tasks.js" defer></script>`,
} {
if !strings.Contains(body, want) {
t.Fatalf("workbench is missing %q", want)
}
}
if strings.Index(body, `name="keyword"`) > strings.Index(body, `data-start-purchases`) || strings.Index(body, `data-start-purchases`) > strings.Index(body, `<div class="table-wrap">`) {
t.Fatal("workbench rows are not ordered as toolbar, filters, batch actions, table")
}
if count := strings.Count(body, `data-task-id=`); count != 1 {
t.Fatalf("selectable row count = %d, want only the DRAFT row", count)
}
for _, forbidden := range []string{`<th scope="col">操作</th>`, `确认开始采购`, `确认机器选对了吗`} {
if strings.Contains(body, forbidden) {
t.Fatalf("workbench exposed forbidden per-row or confirmation UI %q", forbidden)
}
}
if store.listTasksCalls != 1 || store.listDraftsCalls != 0 {
t.Fatalf("GET /tasks calls = (ListTasks %d, ListDrafts %d), want (1, 0)", store.listTasksCalls, store.listDraftsCalls)
}
}
func TestTasksPageRerendersAccessibleFilterErrorsAndKeepsValues(t *testing.T) {
store := &memoryStore{}
router, _ := newRouterWithStore(t, store)
cookie := authenticate(t, router)
query := url.Values{
"keyword": {`保留%_\`},
"status": {"UNKNOWN"},
"created_from": {"2026-02-30"},
"created_to": {"not-a-date"},
}
response := serve(router, http.MethodGet, "/tasks?"+query.Encode(), nil, cookie)
if response.Code != http.StatusBadRequest {
t.Fatalf("invalid filter status = %d, want 400", response.Code)
}
body := response.Body.String()
for _, want := range []string{
`role="alert" aria-live="assertive"`,
`href="#filter-status"`,
`href="#filter-created-from"`,
`href="#filter-created-to"`,
`name="keyword" type="search" value="保留%_\"`,
`<option value="UNKNOWN" selected>无效状态:UNKNOWN</option>`,
`name="created_from" type="date" value="2026-02-30" aria-invalid="true" aria-describedby="filter-created-from-error"`,
`name="created_to" type="date" value="not-a-date" aria-invalid="true" aria-describedby="filter-created-to-error"`,
`id="filter-status-error"`,
`id="filter-created-from-error"`,
`id="filter-created-to-error"`,
`筛选条件有误`,
} {
if !strings.Contains(body, want) {
t.Fatalf("invalid filter page is missing %q", want)
}
}
if store.listTasksCalls != 0 || store.listDraftsCalls != 0 {
t.Fatalf("invalid filter queried stores: ListTasks=%d ListDrafts=%d", store.listTasksCalls, store.listDraftsCalls)
}
assertSecurityHeaders(t, response)
}
func TestTaskCreationRequiresAuthenticationAndCSRF(t *testing.T) { func TestTaskCreationRequiresAuthenticationAndCSRF(t *testing.T) {
router, _ := newRouter(t) router, _ := newRouter(t)
if response := serve(router, http.MethodPost, "/tasks", url.Values{}, nil); response.Code != http.StatusForbidden { if response := serve(router, http.MethodPost, "/tasks", url.Values{}, nil); response.Code != http.StatusForbidden {
@@ -327,7 +423,7 @@ func assertSecurityHeaders(t *testing.T, response *httptest.ResponseRecorder) {
"Cache-Control": "no-store", "Cache-Control": "no-store",
"X-Content-Type-Options": "nosniff", "X-Content-Type-Options": "nosniff",
"Referrer-Policy": "no-referrer", "Referrer-Policy": "no-referrer",
"Content-Security-Policy": "default-src 'self'; style-src 'self' 'unsafe-inline'; script-src 'none'; object-src 'none'; base-uri 'none'; frame-ancestors 'none'; form-action 'self'", "Content-Security-Policy": "default-src 'self'; style-src 'self' 'unsafe-inline'; script-src 'self'; object-src 'none'; base-uri 'none'; frame-ancestors 'none'; form-action 'self'",
} }
for name, expected := range want { for name, expected := range want {
if got := response.Header().Get(name); got != expected { if got := response.Header().Get(name); got != expected {
@@ -381,6 +477,10 @@ func TestLogoutRequiresCSRFAndRevokesSession(t *testing.T) {
} }
func newRouter(t *testing.T) (*gin.Engine, *auth.Manager) { func newRouter(t *testing.T) (*gin.Engine, *auth.Manager) {
return newRouterWithStore(t, &memoryStore{})
}
func newRouterWithStore(t *testing.T, store tasks.Store) (*gin.Engine, *auth.Manager) {
t.Helper() t.Helper()
gin.SetMode(gin.TestMode) gin.SetMode(gin.TestMode)
hash, err := bcrypt.GenerateFromPassword([]byte("test-password"), bcrypt.MinCost) hash, err := bcrypt.GenerateFromPassword([]byte("test-password"), bcrypt.MinCost)
@@ -392,7 +492,7 @@ func newRouter(t *testing.T) (*gin.Engine, *auth.Manager) {
AdminUsername: "admin", AdminUsername: "admin",
AdminPasswordBcrypt: string(hash), AdminPasswordBcrypt: string(hash),
Sessions: manager, Sessions: manager,
Tasks: &memoryStore{}, Tasks: store,
}) })
if err != nil { if err != nil {
t.Fatalf("NewRouter: %v", err) t.Fatalf("NewRouter: %v", err)
@@ -400,7 +500,12 @@ func newRouter(t *testing.T) (*gin.Engine, *auth.Manager) {
return router, manager return router, manager
} }
type memoryStore struct{ drafts []tasks.Draft } type memoryStore struct {
drafts []tasks.Draft
rows []tasks.TaskRow
listDraftsCalls int
listTasksCalls int
}
func (store *memoryStore) CreateDraft(_ context.Context, draft tasks.Draft) (tasks.Draft, error) { func (store *memoryStore) CreateDraft(_ context.Context, draft tasks.Draft) (tasks.Draft, error) {
for _, existing := range store.drafts { for _, existing := range store.drafts {
@@ -415,8 +520,23 @@ func (store *memoryStore) CreateDraft(_ context.Context, draft tasks.Draft) (tas
return draft, nil return draft, nil
} }
func (store *memoryStore) ListDrafts(_ context.Context) ([]tasks.Draft, error) { func (store *memoryStore) ListDrafts(_ context.Context) ([]tasks.Draft, error) {
store.listDraftsCalls++
return append([]tasks.Draft(nil), store.drafts...), nil return append([]tasks.Draft(nil), store.drafts...), nil
} }
func (store *memoryStore) ListTasks(_ context.Context, _ tasks.TaskFilter) ([]tasks.TaskRow, error) {
store.listTasksCalls++
if store.rows != nil {
return append([]tasks.TaskRow(nil), store.rows...), nil
}
result := make([]tasks.TaskRow, 0, len(store.drafts))
for _, draft := range store.drafts {
result = append(result, tasks.TaskRow{ID: draft.ID, Title: draft.Title, GoodsID: draft.GoodsID, SKUColor: draft.SKUColor, SKUSize: draft.SKUSize, Quantity: draft.Quantity, MaxTotalPrice: draft.MaxTotalPrice, Status: "DRAFT", Version: 1, CreatedAt: draft.CreatedAt})
}
return result, nil
}
func (store *memoryStore) StartPurchases(_ context.Context, _ tasks.StartCommand, _ string) (tasks.StartResult, error) {
return tasks.StartResult{}, tasks.ErrInvalidStart
}
func serve(router http.Handler, method, target string, form url.Values, cookie *http.Cookie) *httptest.ResponseRecorder { func serve(router http.Handler, method, target string, form url.Values, cookie *http.Cookie) *httptest.ResponseRecorder {
var body *strings.Reader var body *strings.Reader
@@ -0,0 +1,328 @@
package server_test
import (
"bytes"
"context"
"encoding/json"
"errors"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
"cmbuyer/admin/internal/tasks"
)
const (
startKeyForHTTP = "c3c9f507-7473-4fa6-8d71-8786c34c6301"
taskIDForHTTP = "a3c9f507-7473-4fa6-8d71-8786c34c6301"
)
func TestStartPurchasesAuthenticatesBeforeInspectingRequestBody(t *testing.T) {
store := &startRecordingStore{}
router, _ := newRouterWithStore(t, store)
hugeMalformed := `{"start_key":"` + strings.Repeat("x", 70<<10)
for name, request := range map[string]*http.Request{
"anonymous malformed": newStartRequest(t, hugeMalformed, "text/plain", "", nil),
"device bearer": newStartRequest(t, validStartBody(), "application/json", "", nil),
} {
t.Run(name, func(t *testing.T) {
if name == "device bearer" {
request.Header.Set("Authorization", "Bearer device-token")
}
response := httptest.NewRecorder()
router.ServeHTTP(response, request)
if response.Code != http.StatusUnauthorized {
t.Fatalf("status = %d, want 401", response.Code)
}
})
}
cookie, csrf := authenticatedStartSession(t, router)
for name, token := range map[string]string{"missing CSRF": "", "wrong CSRF": "wrong-csrf"} {
request := newStartRequest(t, hugeMalformed, "text/plain", token, cookie)
response := httptest.NewRecorder()
router.ServeHTTP(response, request)
if response.Code != http.StatusForbidden {
t.Fatalf("%s status = %d, want 403", name, response.Code)
}
}
if csrf == "" {
t.Fatal("authenticated page did not contain a CSRF token")
}
if store.startCalls != 0 {
t.Fatalf("unauthorized requests called store %d times", store.startCalls)
}
}
func TestStartPurchasesRejectsInvalidUTF8BeforeJSONDecoding(t *testing.T) {
validPrefix := []byte(`{"start_key":"` + startKeyForHTTP + `","tasks":[],"start_key":"`)
duplicateKeyBypass := append(append([]byte(nil), validPrefix...), 0xff)
duplicateKeyBypass = append(duplicateKeyBypass, []byte(`"}`)...)
invalidWhitespace := append([]byte(validStartBody()), 0xfe)
for name, body := range map[string][]byte{
"invalid byte after JSON": invalidWhitespace,
"invalid duplicate-key value": duplicateKeyBypass,
} {
t.Run(name, func(t *testing.T) {
store := &startRecordingStore{}
router, _ := newRouterWithStore(t, store)
cookie, csrf := authenticatedStartSession(t, router)
response := serveStartBytes(t, router, body, "application/json", csrf, cookie)
if response.Code != http.StatusBadRequest || store.startCalls != 0 {
t.Fatalf("status/calls = %d/%d, want 400/0", response.Code, store.startCalls)
}
if response.Body.Len() != 0 {
t.Fatalf("invalid UTF-8 response leaked body %q", response.Body.String())
}
})
}
}
func TestStartPurchasesEnforcesExact64KiBBodyBoundary(t *testing.T) {
const limit = 64 << 10
base := validStartBody()
for name, test := range map[string]struct {
body string
want int
wantCalls int
}{
"exact limit": {body: base + strings.Repeat(" ", limit-len(base)), want: http.StatusOK, wantCalls: 1},
"one over": {body: base + strings.Repeat(" ", limit-len(base)+1), want: http.StatusRequestEntityTooLarge},
} {
t.Run(name, func(t *testing.T) {
store := &startRecordingStore{startResult: successfulStartResult()}
router, _ := newRouterWithStore(t, store)
cookie, csrf := authenticatedStartSession(t, router)
response := serveStartRequest(t, router, test.body, "application/json", csrf, cookie)
if response.Code != test.want || store.startCalls != test.wantCalls {
t.Fatalf("status/calls = %d/%d, want %d/%d", response.Code, store.startCalls, test.want, test.wantCalls)
}
})
}
}
func TestStartPurchasesContentTypeContract(t *testing.T) {
for _, contentType := range []string{
"application/json",
"application/json; charset=utf-8",
"application/json;charset=UTF-8",
} {
t.Run("accept "+contentType, func(t *testing.T) {
store := &startRecordingStore{startResult: successfulStartResult()}
router, _ := newRouterWithStore(t, store)
cookie, csrf := authenticatedStartSession(t, router)
response := serveStartRequest(t, router, validStartBody(), contentType, csrf, cookie)
if response.Code != http.StatusOK || store.startCalls != 1 {
t.Fatalf("status/calls = %d/%d, want 200/1", response.Code, store.startCalls)
}
})
}
for _, contentType := range []string{
"",
"text/plain",
"application/json-patch+json",
"application/json; charset=gbk",
"application/json; profile=unapproved",
"application/json; charset",
} {
t.Run("reject "+contentType, func(t *testing.T) {
store := &startRecordingStore{}
router, _ := newRouterWithStore(t, store)
cookie, csrf := authenticatedStartSession(t, router)
response := serveStartRequest(t, router, validStartBody(), contentType, csrf, cookie)
if response.Code != http.StatusUnsupportedMediaType || store.startCalls != 0 {
t.Fatalf("status/calls = %d/%d, want 415/0", response.Code, store.startCalls)
}
})
}
}
func TestStartPurchasesRejectsMalformedAndOversizedJSON(t *testing.T) {
tests := []struct {
name string
body string
want int
storeErr error
wantCalls int
}{
{name: "empty", body: "", want: http.StatusBadRequest},
{name: "empty object", body: `{}`, want: http.StatusBadRequest, storeErr: tasks.ErrInvalidStart, wantCalls: 1},
{name: "null object", body: `null`, want: http.StatusBadRequest, storeErr: tasks.ErrInvalidStart, wantCalls: 1},
{name: "malformed", body: `{`, want: http.StatusBadRequest},
{name: "wrong top-level type", body: `[]`, want: http.StatusBadRequest},
{name: "unknown field", body: `{"start_key":"` + startKeyForHTTP + `","tasks":[],"created_by":"attacker"}`, want: http.StatusBadRequest},
{name: "wrong field type", body: `{"start_key":"` + startKeyForHTTP + `","tasks":[{"task_id":"` + taskIDForHTTP + `","expected_task_version":"1"}]}`, want: http.StatusBadRequest},
{name: "second JSON value", body: validStartBody() + `{}`, want: http.StatusBadRequest},
{name: "duplicate task ids", body: `{"start_key":"` + startKeyForHTTP + `","tasks":[{"task_id":"` + taskIDForHTTP + `","expected_task_version":1},{"task_id":"` + taskIDForHTTP + `","expected_task_version":1}]}`, want: http.StatusBadRequest, storeErr: tasks.ErrInvalidStart, wantCalls: 1},
{name: "oversized first value", body: `{"start_key":"` + strings.Repeat("x", 70<<10), want: http.StatusRequestEntityTooLarge},
{name: "oversized trailing whitespace", body: validStartBody() + strings.Repeat(" ", 70<<10), want: http.StatusRequestEntityTooLarge},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
store := &startRecordingStore{startErr: test.storeErr}
router, _ := newRouterWithStore(t, store)
cookie, csrf := authenticatedStartSession(t, router)
response := serveStartRequest(t, router, test.body, "application/json", csrf, cookie)
if response.Code != test.want || store.startCalls != test.wantCalls {
t.Fatalf("status/calls = %d/%d, want %d/%d", response.Code, store.startCalls, test.want, test.wantCalls)
}
if response.Body.Len() != 0 {
t.Fatalf("error response leaked body %q", response.Body.String())
}
assertSecurityHeaders(t, response)
})
}
}
func TestStartPurchasesUsesAuthenticatedAdminAndReturnsStableSafeResult(t *testing.T) {
result := successfulStartResult()
store := &startRecordingStore{startResult: result}
router, _ := newRouterWithStore(t, store)
cookie, csrf := authenticatedStartSession(t, router)
first := serveStartRequest(t, router, validStartBody(), "application/json; charset=utf-8", csrf, cookie)
second := serveStartRequest(t, router, validStartBody(), "application/json", csrf, cookie)
for index, response := range []*httptest.ResponseRecorder{first, second} {
if response.Code != http.StatusOK {
t.Fatalf("response %d status = %d, want 200", index, response.Code)
}
if got := response.Header().Get("Content-Type"); got != "application/json; charset=utf-8" {
t.Fatalf("response content type = %q", got)
}
var decoded tasks.StartResult
if err := json.Unmarshal(response.Body.Bytes(), &decoded); err != nil {
t.Fatalf("decode response: %v", err)
}
if decoded.PaymentAutomated || decoded.AuthorizedCount != 1 || decoded.Tasks[0].AuthorizationID != result.Tasks[0].AuthorizationID {
t.Fatalf("unsafe or unstable response = %#v", decoded)
}
assertSecurityHeaders(t, response)
}
if store.startCalls != 2 || len(store.createdBy) != 2 || store.createdBy[0] != "admin" || store.createdBy[1] != "admin" {
t.Fatalf("store calls/created_by = %d/%#v", store.startCalls, store.createdBy)
}
for _, command := range store.commands {
if command.StartKey != startKeyForHTTP || len(command.Tasks) != 1 || command.Tasks[0].TaskID != taskIDForHTTP || command.Tasks[0].ExpectedTaskVersion != 7 {
t.Fatalf("decoded command = %#v", command)
}
}
}
func TestStartPurchasesMapsStoreErrorsWithoutLeakingDetails(t *testing.T) {
for name, test := range map[string]struct {
err error
want int
}{
"invalid": {err: tasks.ErrInvalidStart, want: http.StatusBadRequest},
"conflict": {err: tasks.ErrStartConflict, want: http.StatusConflict},
"internal": {err: errors.New("sqlite secret path and query"), want: http.StatusInternalServerError},
} {
t.Run(name, func(t *testing.T) {
store := &startRecordingStore{startErr: test.err}
router, _ := newRouterWithStore(t, store)
cookie, csrf := authenticatedStartSession(t, router)
response := serveStartRequest(t, router, validStartBody(), "application/json", csrf, cookie)
if response.Code != test.want || store.startCalls != 1 {
t.Fatalf("status/calls = %d/%d, want %d/1", response.Code, store.startCalls, test.want)
}
if response.Body.Len() != 0 || strings.Contains(response.Body.String(), "sqlite") {
t.Fatalf("error leaked details: %q", response.Body.String())
}
})
}
}
type startRecordingStore struct {
startResult tasks.StartResult
startErr error
startCalls int
commands []tasks.StartCommand
createdBy []string
}
func (store *startRecordingStore) CreateDraft(_ context.Context, draft tasks.Draft) (tasks.Draft, error) {
return draft, nil
}
func (store *startRecordingStore) ListDrafts(context.Context) ([]tasks.Draft, error) {
return nil, nil
}
func (store *startRecordingStore) ListTasks(context.Context, tasks.TaskFilter) ([]tasks.TaskRow, error) {
return nil, nil
}
func (store *startRecordingStore) StartPurchases(_ context.Context, command tasks.StartCommand, createdBy string) (tasks.StartResult, error) {
store.startCalls++
store.commands = append(store.commands, command)
store.createdBy = append(store.createdBy, createdBy)
return store.startResult, store.startErr
}
func authenticatedStartSession(t *testing.T, router http.Handler) (*http.Cookie, string) {
t.Helper()
cookie := authenticate(t, router)
page := serve(router, http.MethodGet, "/tasks", nil, cookie)
if page.Code != http.StatusOK {
t.Fatalf("GET /tasks status = %d", page.Code)
}
return cookie, csrfToken(t, page.Body.String())
}
func newStartRequest(t *testing.T, body, contentType, csrf string, cookie *http.Cookie) *http.Request {
t.Helper()
return newStartByteRequest(t, []byte(body), contentType, csrf, cookie)
}
func newStartByteRequest(t *testing.T, body []byte, contentType, csrf string, cookie *http.Cookie) *http.Request {
t.Helper()
request := httptest.NewRequest(http.MethodPost, "/tasks/start-purchases", bytes.NewReader(body))
if contentType != "" {
request.Header.Set("Content-Type", contentType)
}
if csrf != "" {
request.Header.Set("X-CSRF-Token", csrf)
}
if cookie != nil {
request.AddCookie(cookie)
}
return request
}
func serveStartBytes(t *testing.T, router http.Handler, body []byte, contentType, csrf string, cookie *http.Cookie) *httptest.ResponseRecorder {
t.Helper()
response := httptest.NewRecorder()
router.ServeHTTP(response, newStartByteRequest(t, body, contentType, csrf, cookie))
return response
}
func serveStartRequest(t *testing.T, router http.Handler, body, contentType, csrf string, cookie *http.Cookie) *httptest.ResponseRecorder {
t.Helper()
response := httptest.NewRecorder()
router.ServeHTTP(response, newStartRequest(t, body, contentType, csrf, cookie))
return response
}
func validStartBody() string {
return `{"start_key":"` + startKeyForHTTP + `","tasks":[{"task_id":"` + taskIDForHTTP + `","expected_task_version":7}]}`
}
func successfulStartResult() tasks.StartResult {
expires := time.Date(2026, 8, 4, 2, 3, 4, 0, time.UTC)
return tasks.StartResult{
StartKey: startKeyForHTTP,
AuthorizedCount: 1,
PaymentAutomated: false,
Tasks: []tasks.AuthorizedTask{{
TaskID: taskIDForHTTP,
TaskVersion: 8,
AuthorizationID: "d3c9f507-7473-4fa6-8d71-8786c34c6301",
ExpiresAt: expires,
}},
}
}
+128
View File
@@ -0,0 +1,128 @@
package tasks
import (
"errors"
"math"
"math/big"
"sort"
"strings"
"time"
_ "time/tzdata"
)
const maxStartItems = 100
var (
ErrStartConflict = errors.New("purchase start conflicts with current task state")
ErrInvalidStart = errors.New("invalid purchase start request")
)
type StartPolicy struct {
AuthorizationTTL time.Duration
MaxQuantity int
MaxTotalPrice string
}
type StartItem struct {
TaskID string `json:"task_id"`
ExpectedTaskVersion int `json:"expected_task_version"`
}
type StartCommand struct {
StartKey string `json:"start_key"`
Tasks []StartItem `json:"tasks"`
}
type AuthorizedTask struct {
TaskID string `json:"task_id"`
TaskVersion int `json:"task_version"`
AuthorizationID string `json:"authorization_id"`
ExpiresAt time.Time `json:"expires_at"`
}
type StartResult struct {
StartKey string `json:"start_key"`
AuthorizedCount int `json:"authorized_count"`
Tasks []AuthorizedTask `json:"tasks"`
PaymentAutomated bool `json:"payment_automated"`
}
type TaskFilter struct{ Keyword, Status, CreatedFrom, CreatedTo string }
type TaskRow struct {
ID, Title, GoodsID, SKUColor, SKUSize, MaxTotalPrice, Status string
Quantity, Version int
CreatedAt time.Time
}
func normalizeCents(value string) (string, *big.Int, bool) {
if value == "" || strings.TrimSpace(value) != value {
return "", nil, false
}
parts := strings.Split(value, ".")
if len(parts) != 2 || len(parts[0]) == 0 || len(parts[1]) != 2 || (len(parts[0]) > 1 && parts[0][0] == '0') {
return "", nil, false
}
for _, part := range parts {
for _, ch := range part {
if ch < '0' || ch > '9' {
return "", nil, false
}
}
}
cents := new(big.Int)
if _, ok := cents.SetString(parts[0]+parts[1], 10); !ok || cents.Sign() <= 0 {
return "", nil, false
}
return value, cents, true
}
func startItems(command StartCommand) ([]StartItem, error) {
if !validUUID(command.StartKey) || len(command.Tasks) == 0 || len(command.Tasks) > maxStartItems {
return nil, ErrInvalidStart
}
items := append([]StartItem(nil), command.Tasks...)
sort.Slice(items, func(i, j int) bool { return items[i].TaskID < items[j].TaskID })
for i, item := range items {
if !validUUID(item.TaskID) || item.ExpectedTaskVersion <= 0 || item.ExpectedTaskVersion == math.MaxInt || (i > 0 && item.TaskID == items[i-1].TaskID) {
return nil, ErrInvalidStart
}
}
return items, nil
}
func validTaskStatus(value string) bool {
if value == "" {
return true
}
for _, status := range []string{"DRAFT", "PENDING", "CLAIMED", "ORDERING", "NEEDS_MANUAL", "WAITING_PAYMENT", "RECONCILIATION_REQUIRED", "SUCCEEDED", "FAILED", "CANCELED"} {
if value == status {
return true
}
}
return false
}
func ShanghaiRange(from, to string) (time.Time, time.Time, error) {
if from == "" && to == "" {
return time.Time{}, time.Time{}, nil
}
location, err := time.LoadLocation("Asia/Shanghai")
if err != nil {
return time.Time{}, time.Time{}, err
}
parse := func(value string) (time.Time, error) { return time.ParseInLocation("2006-01-02", value, location) }
var start, end time.Time
if from != "" {
start, err = parse(from)
if err != nil {
return time.Time{}, time.Time{}, ErrInvalidStart
}
start = start.UTC()
}
if to != "" {
end, err = parse(to)
if err != nil {
return time.Time{}, time.Time{}, ErrInvalidStart
}
end = end.AddDate(0, 0, 1).UTC()
}
if !start.IsZero() && !end.IsZero() && !start.Before(end) {
return time.Time{}, time.Time{}, ErrInvalidStart
}
return start, end, nil
}
@@ -0,0 +1,424 @@
package tasks
import (
"context"
"database/sql"
"errors"
"fmt"
"math"
"reflect"
"sort"
"sync"
"testing"
"time"
"cmbuyer/admin/internal/migrations"
)
var fixedStartTime = time.Date(2026, 8, 4, 9, 2, 3, 456000000, time.FixedZone("UTC+8", 8*60*60))
func TestStartPurchasesPersistsCompleteSnapshotsForOneAndHundredTasks(t *testing.T) {
for _, count := range []int{1, 100} {
t.Run(fmt.Sprintf("%d tasks", count), func(t *testing.T) {
database := migratedDatabase(t)
store := configuredStartStore(t, database)
store.now = func() time.Time { return fixedStartTime }
items := make([]StartItem, 0, count)
wantDrafts := make(map[string]Draft, count)
for index := 1; index <= count; index++ {
id := startTestUUID(index)
draft := Draft{
ID: id,
Title: fmt.Sprintf("task-%03d", index),
GoodsID: fmt.Sprintf("937122%06d", index),
SKUColor: fmt.Sprintf("color-%03d", index),
SKUSize: fmt.Sprintf("size-%03d", index),
Quantity: index%10 + 1,
MaxTotalPrice: fmt.Sprintf("%d.%02d", index+10, index%100),
}
if _, err := store.CreateDraft(context.Background(), draft); err != nil {
t.Fatalf("create draft %d: %v", index, err)
}
items = append(items, StartItem{TaskID: id, ExpectedTaskVersion: 1})
wantDrafts[id] = draft
}
sort.Slice(items, func(i, j int) bool { return items[i].TaskID > items[j].TaskID })
command := StartCommand{StartKey: startTestUUID(1001 + count), Tasks: items}
result, err := store.StartPurchases(context.Background(), command, "authenticated-admin")
if err != nil {
t.Fatalf("StartPurchases: %v", err)
}
if result.StartKey != command.StartKey || result.AuthorizedCount != count || result.PaymentAutomated || len(result.Tasks) != count {
t.Fatalf("result = %#v", result)
}
wantCreated := fixedStartTime.UTC()
wantExpires := wantCreated.Add(15 * time.Minute)
seenAuthorizationIDs := map[string]bool{}
for index, authorized := range result.Tasks {
if index > 0 && result.Tasks[index-1].TaskID >= authorized.TaskID {
t.Fatalf("result is not in canonical task order: %#v", result.Tasks)
}
if authorized.TaskVersion != 2 || !authorized.ExpiresAt.Equal(wantExpires) || !validUUID(authorized.AuthorizationID) || seenAuthorizationIDs[authorized.AuthorizationID] {
t.Fatalf("authorized task = %#v", authorized)
}
seenAuthorizationIDs[authorized.AuthorizationID] = true
want := wantDrafts[authorized.TaskID]
var taskStatus, taskUpdated, authTaskID, authStartKey, goodsID, color, size, priceCap, authStatus, createdBy, createdAt, expiresAt string
var taskVersion, authTaskVersion, quantity int
err := database.QueryRow(`
SELECT t.status,t.version,t.updated_at,
a.task_id,a.task_version,a.start_key,a.goods_id,a.sku_color,a.sku_size,a.quantity,a.total_price_cap,a.status,a.created_by,a.created_at,a.expires_at
FROM tasks t JOIN order_authorizations a ON a.task_id=t.id WHERE a.id=?`, authorized.AuthorizationID).
Scan(&taskStatus, &taskVersion, &taskUpdated, &authTaskID, &authTaskVersion, &authStartKey, &goodsID, &color, &size, &quantity, &priceCap, &authStatus, &createdBy, &createdAt, &expiresAt)
if err != nil {
t.Fatalf("read authorization snapshot: %v", err)
}
if taskStatus != "PENDING" || taskVersion != 2 || taskUpdated != wantCreated.Format(time.RFC3339Nano) ||
authTaskID != want.ID || authTaskVersion != 2 || authStartKey != command.StartKey ||
goodsID != want.GoodsID || color != want.SKUColor || size != want.SKUSize || quantity != want.Quantity || priceCap != want.MaxTotalPrice ||
authStatus != "ACTIVE" || createdBy != "authenticated-admin" || createdAt != wantCreated.Format(time.RFC3339Nano) || expiresAt != wantExpires.Format(time.RFC3339Nano) {
t.Fatalf("stored task/authorization mismatch for %s", want.ID)
}
}
var distinctCreated, distinctExpires int
if err := database.QueryRow(`SELECT COUNT(DISTINCT created_at), COUNT(DISTINCT expires_at) FROM order_authorizations WHERE start_key=?`, command.StartKey).Scan(&distinctCreated, &distinctExpires); err != nil {
t.Fatal(err)
}
if distinctCreated != 1 || distinctExpires != 1 {
t.Fatalf("batch timestamps are not shared: created=%d expires=%d", distinctCreated, distinctExpires)
}
})
}
}
func TestStartPurchasesRejectsInvalidCommandsAndPolicyWithoutWrites(t *testing.T) {
validItem := StartItem{TaskID: startTestUUID(1), ExpectedTaskVersion: 1}
hundredOne := make([]StartItem, 101)
for index := range hundredOne {
hundredOne[index] = StartItem{TaskID: startTestUUID(index + 1), ExpectedTaskVersion: 1}
}
for name, command := range map[string]StartCommand{
"invalid start key": {StartKey: "not-a-uuid", Tasks: []StartItem{validItem}},
"empty tasks": {StartKey: startTestUUID(1001)},
"over batch limit": {StartKey: startTestUUID(1001), Tasks: hundredOne},
"invalid task id": {StartKey: startTestUUID(1001), Tasks: []StartItem{{TaskID: "1", ExpectedTaskVersion: 1}}},
"duplicate task": {StartKey: startTestUUID(1001), Tasks: []StartItem{validItem, validItem}},
"zero version": {StartKey: startTestUUID(1001), Tasks: []StartItem{{TaskID: validItem.TaskID}}},
"overflow version": {StartKey: startTestUUID(1001), Tasks: []StartItem{{TaskID: validItem.TaskID, ExpectedTaskVersion: math.MaxInt}}},
} {
t.Run(name, func(t *testing.T) {
database := migratedDatabase(t)
store := configuredStartStore(t, database)
_, err := store.StartPurchases(context.Background(), command, "admin")
if !errors.Is(err, ErrInvalidStart) {
t.Fatalf("error = %v, want ErrInvalidStart", err)
}
assertAuthorizationCount(t, database, 0)
})
}
for name, mutate := range map[string]func(*SQLiteStore){
"zero ttl": func(store *SQLiteStore) { store.policy.AuthorizationTTL = 0 },
"zero quantity": func(store *SQLiteStore) { store.policy.MaxQuantity = 0 },
"bad max price": func(store *SQLiteStore) { store.policy.MaxTotalPrice = "999" },
} {
t.Run(name, func(t *testing.T) {
database := migratedDatabase(t)
store := configuredStartStore(t, database)
createStartDraft(t, store, validItem.TaskID)
mutate(store)
_, err := store.StartPurchases(context.Background(), StartCommand{StartKey: startTestUUID(1001), Tasks: []StartItem{validItem}}, "admin")
if !errors.Is(err, ErrInvalidStart) {
t.Fatalf("error = %v, want ErrInvalidStart", err)
}
assertDraftUnchanged(t, database, validItem.TaskID)
assertAuthorizationCount(t, database, 0)
})
}
database := migratedDatabase(t)
store := configuredStartStore(t, database)
createStartDraft(t, store, validItem.TaskID)
_, err := store.StartPurchases(context.Background(), StartCommand{StartKey: startTestUUID(1001), Tasks: []StartItem{validItem}}, "")
if !errors.Is(err, ErrInvalidStart) {
t.Fatalf("empty created_by error = %v", err)
}
assertDraftUnchanged(t, database, validItem.TaskID)
}
func TestStartPurchasesRejectsEveryTaskConflictWithoutAuthorization(t *testing.T) {
for name, mutate := range map[string]func(*testing.T, *SQLiteStore, string, *StartItem){
"missing": func(_ *testing.T, _ *SQLiteStore, _ string, item *StartItem) {
item.TaskID = startTestUUID(99)
},
"not draft": func(t *testing.T, store *SQLiteStore, id string, _ *StartItem) {
execTestSQL(t, store.database, `UPDATE tasks SET status='PENDING' WHERE id=?`, id)
},
"version mismatch": func(_ *testing.T, _ *SQLiteStore, _ string, item *StartItem) {
item.ExpectedTaskVersion = 2
},
"empty goods id": func(t *testing.T, store *SQLiteStore, id string, _ *StartItem) {
execTestSQL(t, store.database, `UPDATE tasks SET goods_id='' WHERE id=?`, id)
},
"nondigit goods id": func(t *testing.T, store *SQLiteStore, id string, _ *StartItem) {
execTestSQL(t, store.database, `UPDATE tasks SET goods_id='937x' WHERE id=?`, id)
},
"empty color": func(t *testing.T, store *SQLiteStore, id string, _ *StartItem) {
execTestSQL(t, store.database, `UPDATE tasks SET sku_color='' WHERE id=?`, id)
},
"empty size": func(t *testing.T, store *SQLiteStore, id string, _ *StartItem) {
execTestSQL(t, store.database, `UPDATE tasks SET sku_size='' WHERE id=?`, id)
},
"quantity over policy": func(_ *testing.T, store *SQLiteStore, _ string, _ *StartItem) {
store.policy.MaxQuantity = 1
},
"noncanonical price one decimal": func(t *testing.T, store *SQLiteStore, id string, _ *StartItem) {
execTestSQL(t, store.database, `UPDATE tasks SET max_total_price='12.8' WHERE id=?`, id)
},
"noncanonical leading zero": func(t *testing.T, store *SQLiteStore, id string, _ *StartItem) {
execTestSQL(t, store.database, `UPDATE tasks SET max_total_price='012.80' WHERE id=?`, id)
},
"price over policy": func(_ *testing.T, store *SQLiteStore, _ string, _ *StartItem) {
store.policy.MaxTotalPrice = "12.79"
},
} {
t.Run(name, func(t *testing.T) {
database := migratedDatabase(t)
store := configuredStartStore(t, database)
id := startTestUUID(1)
createStartDraft(t, store, id)
item := StartItem{TaskID: id, ExpectedTaskVersion: 1}
mutate(t, store, id, &item)
_, err := store.StartPurchases(context.Background(), StartCommand{StartKey: startTestUUID(1001), Tasks: []StartItem{item}}, "admin")
if !errors.Is(err, ErrStartConflict) {
t.Fatalf("error = %v, want ErrStartConflict", err)
}
assertAuthorizationCount(t, database, 0)
})
}
}
func TestStartPurchasesRollsBackWholeBatchForLateConflictAndSQLFailure(t *testing.T) {
for _, test := range []struct {
name string
breakBatch func(*testing.T, *SQLiteStore, string)
}{
{name: "late validation conflict", breakBatch: func(t *testing.T, store *SQLiteStore, secondID string) {
execTestSQL(t, store.database, `UPDATE tasks SET sku_size='' WHERE id=?`, secondID)
}},
{name: "late SQL failure", breakBatch: func(t *testing.T, store *SQLiteStore, secondID string) {
statement := fmt.Sprintf(`CREATE TRIGGER reject_second_authorization BEFORE INSERT ON order_authorizations WHEN NEW.task_id='%s' BEGIN SELECT RAISE(ABORT, 'test failure'); END`, secondID)
execTestSQL(t, store.database, statement)
}},
} {
t.Run(test.name, func(t *testing.T) {
database := migratedDatabase(t)
store := configuredStartStore(t, database)
firstID, secondID := startTestUUID(1), startTestUUID(2)
createStartDraft(t, store, firstID)
createStartDraft(t, store, secondID)
test.breakBatch(t, store, secondID)
_, err := store.StartPurchases(context.Background(), StartCommand{StartKey: startTestUUID(1001), Tasks: []StartItem{{TaskID: firstID, ExpectedTaskVersion: 1}, {TaskID: secondID, ExpectedTaskVersion: 1}}}, "admin")
if err == nil {
t.Fatal("StartPurchases unexpectedly succeeded")
}
assertDraftUnchanged(t, database, firstID)
var secondStatus string
var secondVersion int
if err := database.QueryRow(`SELECT status,version FROM tasks WHERE id=?`, secondID).Scan(&secondStatus, &secondVersion); err != nil {
t.Fatal(err)
}
if secondStatus != "DRAFT" || secondVersion != 1 {
t.Fatalf("second task = %s/v%d, want DRAFT/v1", secondStatus, secondVersion)
}
assertAuthorizationCount(t, database, 0)
})
}
}
func TestStartPurchasesReplayIsStableAndRejectsDifferentOrIncompleteSets(t *testing.T) {
database := migratedDatabase(t)
store := configuredStartStore(t, database)
firstID, secondID, thirdID := startTestUUID(1), startTestUUID(2), startTestUUID(3)
for _, id := range []string{firstID, secondID, thirdID} {
createStartDraft(t, store, id)
}
command := StartCommand{StartKey: startTestUUID(1001), Tasks: []StartItem{{TaskID: secondID, ExpectedTaskVersion: 1}, {TaskID: firstID, ExpectedTaskVersion: 1}}}
first, err := store.StartPurchases(context.Background(), command, "admin")
if err != nil {
t.Fatal(err)
}
command.Tasks[0], command.Tasks[1] = command.Tasks[1], command.Tasks[0]
replay, err := store.StartPurchases(context.Background(), command, "admin")
if err != nil || !reflect.DeepEqual(replay, first) {
t.Fatalf("replay = (%#v, %v), want %#v", replay, err, first)
}
assertAuthorizationCount(t, database, 2)
conflicting := []StartCommand{
{StartKey: command.StartKey, Tasks: command.Tasks[:1]},
{StartKey: command.StartKey, Tasks: []StartItem{{TaskID: firstID, ExpectedTaskVersion: 2}, {TaskID: secondID, ExpectedTaskVersion: 1}}},
{StartKey: command.StartKey, Tasks: []StartItem{{TaskID: firstID, ExpectedTaskVersion: 1}, {TaskID: secondID, ExpectedTaskVersion: 1}, {TaskID: thirdID, ExpectedTaskVersion: 1}}},
}
for _, changed := range conflicting {
if _, err := store.StartPurchases(context.Background(), changed, "admin"); !errors.Is(err, ErrStartConflict) {
t.Fatalf("different payload error = %v", err)
}
}
assertAuthorizationCount(t, database, 2)
assertDraftUnchanged(t, database, thirdID)
execTestSQL(t, database, `DELETE FROM order_authorizations WHERE task_id=?`, secondID)
if _, err := store.StartPurchases(context.Background(), command, "admin"); !errors.Is(err, ErrStartConflict) {
t.Fatalf("incomplete replay error = %v", err)
}
assertAuthorizationCount(t, database, 1)
}
func TestStartPurchasesConcurrentReplayAndVersionRace(t *testing.T) {
t.Run("same key replays one stable result", func(t *testing.T) {
database := migratedDatabase(t)
store := configuredStartStore(t, database)
id := startTestUUID(1)
createStartDraft(t, store, id)
command := StartCommand{StartKey: startTestUUID(1001), Tasks: []StartItem{{TaskID: id, ExpectedTaskVersion: 1}}}
const callers = 16
start := make(chan struct{})
results := make(chan StartResult, callers)
errorsChannel := make(chan error, callers)
var group sync.WaitGroup
for range callers {
group.Add(1)
go func() {
defer group.Done()
<-start
result, err := store.StartPurchases(context.Background(), command, "admin")
if err != nil {
errorsChannel <- err
return
}
results <- result
}()
}
close(start)
group.Wait()
close(results)
close(errorsChannel)
for err := range errorsChannel {
t.Fatalf("concurrent replay: %v", err)
}
var want StartResult
for result := range results {
if want.StartKey == "" {
want = result
} else if !reflect.DeepEqual(result, want) {
t.Fatalf("unstable replay: %#v != %#v", result, want)
}
}
assertAuthorizationCount(t, database, 1)
var version int
if err := database.QueryRow(`SELECT version FROM tasks WHERE id=?`, id).Scan(&version); err != nil || version != 2 {
t.Fatalf("task version = %d, err=%v", version, err)
}
})
t.Run("different keys race one expected version", func(t *testing.T) {
database := migratedDatabase(t)
store := configuredStartStore(t, database)
id := startTestUUID(1)
createStartDraft(t, store, id)
start := make(chan struct{})
errorsChannel := make(chan error, 2)
var group sync.WaitGroup
for _, key := range []string{startTestUUID(1001), startTestUUID(1002)} {
group.Add(1)
go func(startKey string) {
defer group.Done()
<-start
_, err := store.StartPurchases(context.Background(), StartCommand{StartKey: startKey, Tasks: []StartItem{{TaskID: id, ExpectedTaskVersion: 1}}}, "admin")
errorsChannel <- err
}(key)
}
close(start)
group.Wait()
close(errorsChannel)
successes, conflicts := 0, 0
for err := range errorsChannel {
switch {
case err == nil:
successes++
case errors.Is(err, ErrStartConflict):
conflicts++
default:
t.Fatalf("unexpected race error: %v", err)
}
}
if successes != 1 || conflicts != 1 {
t.Fatalf("success/conflict = %d/%d, want 1/1", successes, conflicts)
}
assertAuthorizationCount(t, database, 1)
})
}
func TestSQLiteStoreRejectsV1SchemaAtStartup(t *testing.T) {
database := openDatabase(t)
if err := migrations.Run(context.Background(), database, migrationDirectory(t), "up-by-one"); err != nil {
t.Fatalf("migrate to v1: %v", err)
}
if _, err := NewSQLiteStore(database); err == nil {
t.Fatal("NewSQLiteStore accepted the v1 two-pass schema")
}
}
func configuredStartStore(t *testing.T, database *sql.DB) *SQLiteStore {
t.Helper()
store, err := NewSQLiteStore(database)
if err != nil {
t.Fatalf("NewSQLiteStore: %v", err)
}
store.SetStartPolicy(StartPolicy{AuthorizationTTL: 15 * time.Minute, MaxQuantity: 10, MaxTotalPrice: "999.99"})
return store
}
func createStartDraft(t *testing.T, store *SQLiteStore, id string) {
t.Helper()
draft := Draft{ID: id, Title: "test", GoodsID: "937122477375", SKUColor: "黑色", SKUSize: "M", Quantity: 2, MaxTotalPrice: "12.80"}
if _, err := store.CreateDraft(context.Background(), draft); err != nil {
t.Fatalf("CreateDraft: %v", err)
}
}
func startTestUUID(number int) string {
return fmt.Sprintf("%08x-1234-4abc-a123-%012x", number, number)
}
func assertAuthorizationCount(t *testing.T, database *sql.DB, want int) {
t.Helper()
var got int
if err := database.QueryRow(`SELECT COUNT(*) FROM order_authorizations`).Scan(&got); err != nil {
t.Fatal(err)
}
if got != want {
t.Fatalf("authorization count = %d, want %d", got, want)
}
}
func assertDraftUnchanged(t *testing.T, database *sql.DB, id string) {
t.Helper()
var status string
var version int
if err := database.QueryRow(`SELECT status,version FROM tasks WHERE id=?`, id).Scan(&status, &version); err != nil {
t.Fatal(err)
}
if status != "DRAFT" || version != 1 {
t.Fatalf("task %s = %s/v%d, want DRAFT/v1", id, status, version)
}
}
func execTestSQL(t *testing.T, database *sql.DB, statement string, arguments ...any) {
t.Helper()
if _, err := database.Exec(statement, arguments...); err != nil {
t.Fatalf("execute test SQL: %v", err)
}
}
+229
View File
@@ -0,0 +1,229 @@
package tasks
import (
"context"
"database/sql"
"errors"
"strings"
"time"
"cmbuyer/admin/internal/domain"
)
// ErrInvalidFilter 表示任务筛选值无效,路由应按字段重新渲染而不是泄露内部错误。
var ErrInvalidFilter = errors.New("invalid task filter")
// SetStartPolicy is called during startup; policy is explicit because authorization limits must not be implicit defaults.
func (store *SQLiteStore) SetStartPolicy(policy StartPolicy) { store.policy = policy }
func (store *SQLiteStore) ListTasks(ctx context.Context, filter TaskFilter) ([]TaskRow, error) {
if !ValidateTaskFilter(filter).Valid() {
return nil, ErrInvalidFilter
}
from, to, err := ShanghaiRange(filter.CreatedFrom, filter.CreatedTo)
if err != nil {
return nil, ErrInvalidFilter
}
clauses, args := []string{"1=1"}, []any{}
if filter.Status != "" {
clauses = append(clauses, "status = ?")
args = append(args, filter.Status)
}
if filter.Keyword != "" {
escaped := strings.NewReplacer("\\", "\\\\", "%", "\\%", "_", "\\_").Replace(filter.Keyword)
clauses = append(clauses, "(title LIKE ? ESCAPE '\\' OR goods_id LIKE ? ESCAPE '\\')")
args = append(args, "%"+escaped+"%", "%"+escaped+"%")
}
if !from.IsZero() {
clauses = append(clauses, "julianday(created_at) >= julianday(?)")
args = append(args, from.Format(time.RFC3339Nano))
}
if !to.IsZero() {
clauses = append(clauses, "julianday(created_at) < julianday(?)")
args = append(args, to.Format(time.RFC3339Nano))
}
rows, err := store.database.QueryContext(ctx, "SELECT id,title,goods_id,sku_color,sku_size,quantity,max_total_price,status,version,created_at FROM tasks WHERE "+strings.Join(clauses, " AND ")+" ORDER BY julianday(created_at) DESC,rowid DESC", args...)
if err != nil {
return nil, err
}
defer rows.Close()
result := []TaskRow{}
for rows.Next() {
var item TaskRow
var created string
if err := rows.Scan(&item.ID, &item.Title, &item.GoodsID, &item.SKUColor, &item.SKUSize, &item.Quantity, &item.MaxTotalPrice, &item.Status, &item.Version, &created); err != nil {
return nil, err
}
item.CreatedAt, err = time.Parse(time.RFC3339Nano, created)
if err != nil {
return nil, err
}
result = append(result, item)
}
return result, rows.Err()
}
// ValidateTaskFilter 返回可关联到字段的错误,使服务端页面拒绝篡改参数时仍能保留输入值。
func ValidateTaskFilter(filter TaskFilter) Errors {
validation := Errors{}
if !validTaskStatus(filter.Status) {
validation["status"] = "请选择有效的任务状态。"
}
location, err := time.LoadLocation("Asia/Shanghai")
if err != nil {
validation["created_from"] = "日期筛选暂不可用,请稍后重试。"
validation["created_to"] = "日期筛选暂不可用,请稍后重试。"
return validation
}
parseDate := func(field, value string) (time.Time, bool) {
if value == "" {
return time.Time{}, true
}
parsed, parseErr := time.ParseInLocation("2006-01-02", value, location)
if parseErr != nil {
validation[field] = "请输入有效日期。"
return time.Time{}, false
}
return parsed, true
}
from, fromOK := parseDate("created_from", filter.CreatedFrom)
to, toOK := parseDate("created_to", filter.CreatedTo)
if fromOK && toOK && !from.IsZero() && !to.IsZero() && from.After(to) {
validation["created_to"] = "结束日期不能早于开始日期。"
}
return validation
}
func (store *SQLiteStore) StartPurchases(ctx context.Context, command StartCommand, createdBy string) (StartResult, error) {
items, err := startItems(command)
if err != nil || createdBy == "" {
return StartResult{}, ErrInvalidStart
}
if store.policy.AuthorizationTTL <= 0 || store.policy.MaxQuantity <= 0 {
return StartResult{}, ErrInvalidStart
}
_, ceiling, ok := normalizeCents(store.policy.MaxTotalPrice)
if !ok {
return StartResult{}, ErrInvalidStart
}
writeCtx, cancel := context.WithTimeout(ctx, sqliteWriteTimeout)
defer cancel()
select {
case store.writeGate <- struct{}{}:
defer func() { <-store.writeGate }()
case <-writeCtx.Done():
return StartResult{}, writeCtx.Err()
}
tx, err := store.database.BeginTx(writeCtx, nil)
if err != nil {
return StartResult{}, err
}
defer tx.Rollback()
// Replay precedes any DRAFT check. One service process serializes this check with creation; SQLite uniqueness remains the cross-transaction backstop.
result, found, err := replayStart(writeCtx, tx, command.StartKey, items)
if err != nil {
return StartResult{}, err
}
if found {
if err := tx.Commit(); err != nil {
return StartResult{}, err
}
return result, nil
}
now := store.now().UTC()
expires := now.Add(store.policy.AuthorizationTTL)
result = StartResult{StartKey: command.StartKey, AuthorizedCount: len(items), Tasks: make([]AuthorizedTask, 0, len(items)), PaymentAutomated: false}
for _, item := range items {
var title, goods, color, size, price, status string
var quantity, version int
if err := tx.QueryRowContext(writeCtx, "SELECT title,goods_id,sku_color,sku_size,quantity,max_total_price,status,version FROM tasks WHERE id=?", item.TaskID).Scan(&title, &goods, &color, &size, &quantity, &price, &status, &version); err != nil {
if err == sql.ErrNoRows {
return StartResult{}, ErrStartConflict
}
return StartResult{}, err
}
if status != "DRAFT" || version != item.ExpectedTaskVersion || !goodsIDValid(goods) || color == "" || size == "" || quantity < 1 || quantity > store.policy.MaxQuantity {
return StartResult{}, ErrStartConflict
}
canonical, cents, ok := normalizeCents(price)
if !ok || canonical != price || cents.Cmp(ceiling) > 0 {
return StartResult{}, ErrStartConflict
}
if _, err := domain.TransitionTask(domain.TaskStatusDraft, domain.TaskStatusPending); err != nil {
return StartResult{}, err
}
id, err := NewCreateKey()
if err != nil {
return StartResult{}, err
}
next := version + 1
if _, err = tx.ExecContext(writeCtx, "INSERT INTO order_authorizations (id,task_id,task_version,start_key,goods_id,sku_color,sku_size,quantity,total_price_cap,status,created_by,created_at,expires_at) VALUES (?,?,?,?,?,?,?,?,?,'ACTIVE',?,?,?)", id, item.TaskID, next, command.StartKey, goods, color, size, quantity, price, createdBy, now.Format(time.RFC3339Nano), expires.Format(time.RFC3339Nano)); err != nil {
return StartResult{}, err
}
updated, err := tx.ExecContext(writeCtx, "UPDATE tasks SET status='PENDING',version=version+1,updated_at=? WHERE id=? AND status='DRAFT' AND version=?", now.Format(time.RFC3339Nano), item.TaskID, version)
if err != nil {
return StartResult{}, err
}
affected, err := updated.RowsAffected()
if err != nil {
return StartResult{}, err
}
if affected != 1 {
return StartResult{}, ErrStartConflict
}
result.Tasks = append(result.Tasks, AuthorizedTask{TaskID: item.TaskID, TaskVersion: next, AuthorizationID: id, ExpiresAt: expires})
}
if err := tx.Commit(); err != nil {
return StartResult{}, err
}
return result, nil
}
func goodsIDValid(value string) bool {
if value == "" {
return false
}
for _, ch := range value {
if ch < '0' || ch > '9' {
return false
}
}
return true
}
func replayStart(ctx context.Context, tx *sql.Tx, startKey string, items []StartItem) (StartResult, bool, error) {
rows, err := tx.QueryContext(ctx, "SELECT id,task_id,task_version,expires_at FROM order_authorizations WHERE start_key=? ORDER BY task_id", startKey)
if err != nil {
return StartResult{}, false, err
}
defer rows.Close()
result := StartResult{StartKey: startKey, PaymentAutomated: false}
for rows.Next() {
var item AuthorizedTask
var expires string
if err := rows.Scan(&item.AuthorizationID, &item.TaskID, &item.TaskVersion, &expires); err != nil {
return StartResult{}, false, err
}
item.ExpiresAt, err = time.Parse(time.RFC3339Nano, expires)
if err != nil {
return StartResult{}, false, err
}
result.Tasks = append(result.Tasks, item)
}
if err := rows.Err(); err != nil {
return StartResult{}, false, err
}
if len(result.Tasks) == 0 {
return StartResult{}, false, nil
}
if len(result.Tasks) != len(items) {
return StartResult{}, false, ErrStartConflict
}
for i := range items {
if result.Tasks[i].TaskID != items[i].TaskID || result.Tasks[i].TaskVersion-1 != items[i].ExpectedTaskVersion {
return StartResult{}, false, ErrStartConflict
}
}
result.AuthorizedCount = len(result.Tasks)
return result, true, nil
}
+195
View File
@@ -0,0 +1,195 @@
package tasks
import (
"context"
"database/sql"
"errors"
"testing"
"time"
)
func TestListTasksTreatsLikeMetacharactersLiterally(t *testing.T) {
database := migratedDatabase(t)
store, err := NewSQLiteStore(database)
if err != nil {
t.Fatal(err)
}
created := "2026-08-04T01:00:00Z"
insertTaskRow(t, database, "percent", "100%纯棉", "100", "DRAFT", created)
insertTaskRow(t, database, "underscore", "尺码_A", "101", "DRAFT", created)
insertTaskRow(t, database, "backslash", `路径\名称`, "102", "DRAFT", created)
insertTaskRow(t, database, "plain", "普通商品", "103", "DRAFT", created)
for _, test := range []struct {
keyword string
wantID string
}{
{keyword: "%", wantID: "percent"},
{keyword: "_", wantID: "underscore"},
{keyword: `\`, wantID: "backslash"},
} {
t.Run(test.wantID, func(t *testing.T) {
rows, err := store.ListTasks(context.Background(), TaskFilter{Keyword: test.keyword})
if err != nil {
t.Fatal(err)
}
if len(rows) != 1 || rows[0].ID != test.wantID {
t.Fatalf("keyword %q rows = %#v, want only %q", test.keyword, rows, test.wantID)
}
})
}
}
func TestListTasksSupportsEveryStatusAndEmptyMeansAll(t *testing.T) {
database := migratedDatabase(t)
store, err := NewSQLiteStore(database)
if err != nil {
t.Fatal(err)
}
statuses := []string{"DRAFT", "PENDING", "CLAIMED", "ORDERING", "NEEDS_MANUAL", "WAITING_PAYMENT", "RECONCILIATION_REQUIRED", "SUCCEEDED", "FAILED", "CANCELED"}
for index, status := range statuses {
insertTaskRow(t, database, status, status, "200", status, time.Date(2026, 8, 4, 1, 0, index, 0, time.UTC).Format(time.RFC3339Nano))
}
all, err := store.ListTasks(context.Background(), TaskFilter{})
if err != nil {
t.Fatal(err)
}
if len(all) != len(statuses) {
t.Fatalf("all-status rows = %d, want %d", len(all), len(statuses))
}
for _, status := range statuses {
rows, err := store.ListTasks(context.Background(), TaskFilter{Status: status})
if err != nil {
t.Fatalf("status %s: %v", status, err)
}
if len(rows) != 1 || rows[0].Status != status {
t.Fatalf("status %s rows = %#v", status, rows)
}
}
}
func TestListTasksUsesShanghaiHalfOpenDateRange(t *testing.T) {
database := migratedDatabase(t)
store, err := NewSQLiteStore(database)
if err != nil {
t.Fatal(err)
}
insertTaskRow(t, database, "before", "before", "300", "DRAFT", "2026-08-03T15:59:59Z")
insertTaskRow(t, database, "at-start", "at-start", "301", "DRAFT", "2026-08-03T16:00:00Z")
insertTaskRow(t, database, "before-end", "before-end", "302", "DRAFT", "2026-08-04T15:59:59Z")
insertTaskRow(t, database, "at-end", "at-end", "303", "DRAFT", "2026-08-04T16:00:00Z")
rows, err := store.ListTasks(context.Background(), TaskFilter{CreatedFrom: "2026-08-04", CreatedTo: "2026-08-04"})
if err != nil {
t.Fatal(err)
}
if len(rows) != 2 || rows[0].ID != "before-end" || rows[1].ID != "at-start" {
t.Fatalf("Shanghai day rows = %#v, want [before-end at-start]", rows)
}
}
func TestListTasksBreaksEqualTimestampsByDescendingRowID(t *testing.T) {
database := migratedDatabase(t)
store, err := NewSQLiteStore(database)
if err != nil {
t.Fatal(err)
}
created := "2026-08-04T01:02:03Z"
insertTaskRow(t, database, "first", "first", "400", "DRAFT", created)
insertTaskRow(t, database, "second", "second", "401", "DRAFT", created)
rows, err := store.ListTasks(context.Background(), TaskFilter{})
if err != nil {
t.Fatal(err)
}
if len(rows) != 2 || rows[0].ID != "second" || rows[1].ID != "first" {
t.Fatalf("equal-time rows = %#v, want descending rowid", rows)
}
}
func TestListTasksRejectsInvalidStatusAndDates(t *testing.T) {
store, err := NewSQLiteStore(migratedDatabase(t))
if err != nil {
t.Fatal(err)
}
for name, filter := range map[string]TaskFilter{
"status": {Status: "UNKNOWN"},
"from date": {CreatedFrom: "2026-02-30"},
"to date": {CreatedTo: "04/08/2026"},
"reverse range": {CreatedFrom: "2026-08-05", CreatedTo: "2026-08-04"},
} {
t.Run(name, func(t *testing.T) {
rows, err := store.ListTasks(context.Background(), filter)
if !errors.Is(err, ErrInvalidFilter) || rows != nil {
t.Fatalf("ListTasks(%#v) = (%#v, %v), want ErrInvalidFilter", filter, rows, err)
}
})
}
}
func TestStartPurchasesIsAtomicAndReplaysSameSet(t *testing.T) {
database := migratedDatabase(t)
store, err := NewSQLiteStore(database)
if err != nil {
t.Fatal(err)
}
store.SetStartPolicy(StartPolicy{AuthorizationTTL: time.Hour, MaxQuantity: 10, MaxTotalPrice: "999.99"})
store.now = func() time.Time { return time.Date(2026, 8, 4, 1, 2, 3, 0, time.UTC) }
for _, draft := range []Draft{testDraft(testKey, "one"), testDraft("b3c9f507-7473-4fa6-8d71-8786c34c6301", "two")} {
if _, err := store.CreateDraft(context.Background(), draft); err != nil {
t.Fatal(err)
}
}
command := StartCommand{StartKey: "c3c9f507-7473-4fa6-8d71-8786c34c6301", Tasks: []StartItem{{TaskID: "b3c9f507-7473-4fa6-8d71-8786c34c6301", ExpectedTaskVersion: 1}, {TaskID: testKey, ExpectedTaskVersion: 1}}}
first, err := store.StartPurchases(context.Background(), command, "admin")
if err != nil {
t.Fatal(err)
}
if first.AuthorizedCount != 2 || first.PaymentAutomated {
t.Fatalf("start result=%#v", first)
}
command.Tasks[0], command.Tasks[1] = command.Tasks[1], command.Tasks[0]
replay, err := store.StartPurchases(context.Background(), command, "admin")
if err != nil {
t.Fatal(err)
}
if replay.Tasks[0].AuthorizationID != first.Tasks[0].AuthorizationID || replay.Tasks[1].AuthorizationID != first.Tasks[1].AuthorizationID {
t.Fatalf("replay=%#v first=%#v", replay, first)
}
var pending, auths int
if err := database.QueryRow(`SELECT COUNT(*) FROM tasks WHERE status='PENDING' AND version=2`).Scan(&pending); err != nil || pending != 2 {
t.Fatalf("pending=%d err=%v", pending, err)
}
if err := database.QueryRow(`SELECT COUNT(*) FROM order_authorizations WHERE status='ACTIVE' AND created_by='admin'`).Scan(&auths); err != nil || auths != 2 {
t.Fatalf("auths=%d err=%v", auths, err)
}
_, err = store.StartPurchases(context.Background(), StartCommand{StartKey: command.StartKey, Tasks: command.Tasks[:1]}, "admin")
if !errors.Is(err, ErrStartConflict) {
t.Fatalf("subset err=%v", err)
}
}
func TestShanghaiRangeAndMoneyAreFailClosed(t *testing.T) {
start, end, err := ShanghaiRange("2026-08-04", "2026-08-04")
if err != nil || start.Format(time.RFC3339) != "2026-08-03T16:00:00Z" || end.Format(time.RFC3339) != "2026-08-04T16:00:00Z" {
t.Fatalf("range=(%s,%s,%v)", start, end, err)
}
for _, value := range []string{"0.01", "12.80", "999999999999999999999999.99"} {
if _, _, ok := normalizeCents(value); !ok {
t.Fatalf("money %q rejected", value)
}
}
for _, value := range []string{"1", "01.20", "0.00", "1.234", "1.", " 1.00", "1e2"} {
if _, _, ok := normalizeCents(value); ok {
t.Fatalf("money %q accepted", value)
}
}
}
func insertTaskRow(t *testing.T, database *sql.DB, id, title, goodsID, status, createdAt string) {
t.Helper()
if _, err := database.Exec(`INSERT INTO tasks (id, source, title, goods_id, sku_color, sku_size, quantity, max_total_price, status, version, created_at, updated_at) VALUES (?, 'MANUAL', ?, ?, '黑色', 'M', 2, '12.80', ?, 1, ?, ?)`, id, title, goodsID, status, createdAt, createdAt); err != nil {
t.Fatalf("insert task %s: %v", id, err)
}
}
+13 -7
View File
@@ -13,21 +13,27 @@ const sqliteWriteTimeout = 2 * time.Second
type Store interface { type Store interface {
CreateDraft(context.Context, Draft) (Draft, error) CreateDraft(context.Context, Draft) (Draft, error)
ListDrafts(context.Context) ([]Draft, error) ListDrafts(context.Context) ([]Draft, error)
ListTasks(context.Context, TaskFilter) ([]TaskRow, error)
StartPurchases(context.Context, StartCommand, string) (StartResult, error)
} }
type SQLiteStore struct { type SQLiteStore struct {
database *sql.DB database *sql.DB
now func() time.Time now func() time.Time
createGate chan struct{} writeGate chan struct{}
policy StartPolicy
} }
func NewSQLiteStore(database *sql.DB) (*SQLiteStore, error) { func NewSQLiteStore(database *sql.DB) (*SQLiteStore, error) {
if database == nil { if database == nil {
return nil, errors.New("database is required") return nil, errors.New("database is required")
} }
if _, err := database.Exec("SELECT 1 FROM tasks LIMIT 1"); err != nil { if _, err := database.Exec("SELECT task_version, start_key, total_price_cap FROM order_authorizations LIMIT 1"); err != nil {
return nil, fmt.Errorf("tasks migration is not available: %w", err) return nil, fmt.Errorf("tasks migration is not available: %w", err)
} }
return &SQLiteStore{database: database, now: time.Now, createGate: make(chan struct{}, 1)}, nil if _, err := database.Exec("SELECT 1 FROM purchase_attempts LIMIT 1"); err != nil {
return nil, fmt.Errorf("single-pass migration is not available: %w", err)
}
return &SQLiteStore{database: database, now: time.Now, writeGate: make(chan struct{}, 1)}, nil
} }
func (store *SQLiteStore) CreateDraft(ctx context.Context, draft Draft) (Draft, error) { func (store *SQLiteStore) CreateDraft(ctx context.Context, draft Draft) (Draft, error) {
@@ -36,8 +42,8 @@ func (store *SQLiteStore) CreateDraft(ctx context.Context, draft Draft) (Draft,
// SQLite permits one writer at a time. Serializing this store's short create // SQLite permits one writer at a time. Serializing this store's short create
// transaction prevents concurrent retries of one create key from surfacing as busy. // transaction prevents concurrent retries of one create key from surfacing as busy.
select { select {
case store.createGate <- struct{}{}: case store.writeGate <- struct{}{}:
defer func() { <-store.createGate }() defer func() { <-store.writeGate }()
case <-writeContext.Done(): case <-writeContext.Done():
return Draft{}, writeContext.Err() return Draft{}, writeContext.Err()
} }
@@ -0,0 +1,60 @@
(() => {
"use strict";
const form = document.querySelector("[data-start-purchases]");
if (!form) return;
const all = form.querySelector("[data-select-all]");
const summary = form.querySelector("[data-selection-summary]");
const button = form.querySelector("[data-start-button]");
const feedback = form.querySelector("[data-start-feedback]");
const boxes = () => [...form.querySelectorAll("input[data-task-id]")];
let selectionFrozen = false;
const parseCents = (value) => {
const match = /^(0|[1-9]\d*)\.(\d{2})$/.exec(value);
return match ? BigInt(match[1] + match[2]) : null;
};
const refresh = () => {
const available = boxes();
const selected = available.filter((box) => box.checked);
let cents = 0n;
let pricesValid = true;
selected.forEach((box) => {
const price = parseCents(box.dataset.price);
if (price === null) pricesValid = false;
else cents += price;
});
summary.textContent = `已选 ${selected.length} 条,最高总额 ¥${cents / 100n}.${(cents % 100n).toString().padStart(2, "0")}`;
button.disabled = !selected.length || !pricesValid;
if (!pricesValid) feedback.textContent = "所选任务金额无法安全汇总,请刷新后重选。";
if (all) {
all.checked = selected.length > 0 && selected.length === available.length;
all.indeterminate = selected.length > 0 && selected.length < available.length;
all.disabled = selectionFrozen || available.length === 0;
}
};
const freezeSelection = (frozen) => {
selectionFrozen = frozen;
boxes().forEach((box) => { box.disabled = frozen; });
refresh();
};
boxes().forEach((box) => box.addEventListener("change", refresh));
if (all) all.addEventListener("change", () => { boxes().forEach((box) => { box.checked = all.checked; }); refresh(); });
let frozenPayload = null;
let inFlight = false;
form.addEventListener("submit", async (event) => {
event.preventDefault();
const selected = boxes().filter((box) => box.checked);
if (!selected.length || inFlight) return;
const tasks = selected.map((box) => ({task_id: box.dataset.taskId, expected_task_version: Number(box.dataset.taskVersion)}));
if (tasks.some((item) => !Number.isSafeInteger(item.expected_task_version) || item.expected_task_version < 1)) { feedback.textContent = "任务版本无效,请刷新后重选。"; return; }
frozenPayload = frozenPayload || JSON.stringify({start_key: form.dataset.startKey, tasks});
inFlight = true; freezeSelection(true); button.disabled = true; button.textContent = "正在授权…";
try { const response = await fetch("/tasks/start-purchases", {method:"POST", headers:{"Content-Type":"application/json", "X-CSRF-Token":form.dataset.csrf}, body:frozenPayload});
if (response.ok) { window.location.reload(); return; }
if (response.status === 409) { feedback.textContent = "任务已变化,请刷新后重选。"; frozenPayload = null; freezeSelection(false); boxes().forEach((box) => { box.checked = false; }); refresh(); return; }
if (response.status === 400 || response.status === 401 || response.status === 403) { feedback.textContent = "请求未被接受,请刷新页面后重试。"; frozenPayload = null; freezeSelection(false); return; }
feedback.textContent = "结果暂时不明确,只能使用同一按钮原样重放。";
} catch (_) { feedback.textContent = "网络结果不明确,请使用同一按钮原样重试。"; }
finally { inFlight = false; button.textContent = "开始采购(只创建待付款订单)"; if (frozenPayload) button.disabled = false; }
});
refresh();
})();
@@ -0,0 +1,138 @@
"use strict";
const test = require("node:test");
const assert = require("node:assert/strict");
const fs = require("node:fs");
const path = require("node:path");
const vm = require("node:vm");
const source = fs.readFileSync(path.join(__dirname, "tasks.js"), "utf8");
test("successful authorization sends numeric version and reloads", async () => {
const requests = [];
const harness = createHarness(async (_url, options) => {
requests.push(options);
return {ok: true, status: 200};
});
await harness.submit();
assert.equal(requests.length, 1);
assert.equal(requests[0].headers["Content-Type"], "application/json");
assert.equal(requests[0].headers["X-CSRF-Token"], "csrf-token");
const payload = JSON.parse(requests[0].body);
assert.equal(payload.start_key, "start-key");
assert.equal(typeof payload.tasks[0].expected_task_version, "number");
assert.equal(payload.tasks[0].expected_task_version, 7);
assert.equal(harness.reloads(), 1);
});
test("409 clears stale selection and requires a fresh choice", async () => {
const harness = createHarness(async () => ({ok: false, status: 409}));
await harness.submit();
assert.equal(harness.box.checked, false);
assert.equal(harness.box.disabled, false);
assert.equal(harness.button.disabled, true);
assert.match(harness.feedback.textContent, /任务已变化/);
});
for (const status of [400, 401, 403]) {
test(`${status} releases the frozen payload for a page refresh`, async () => {
const harness = createHarness(async () => ({ok: false, status}));
await harness.submit();
assert.equal(harness.box.checked, true);
assert.equal(harness.box.disabled, false);
assert.equal(harness.button.disabled, false);
assert.match(harness.feedback.textContent, /刷新页面后重试/);
});
}
test("5xx retries the byte-identical frozen payload", async () => {
const bodies = [];
const harness = createHarness(async (_url, options) => {
bodies.push(options.body);
return {ok: false, status: 503};
});
await harness.submit();
assert.equal(harness.box.disabled, true);
assert.equal(harness.button.disabled, false);
assert.match(harness.feedback.textContent, /原样重放/);
await harness.submit();
assert.equal(bodies.length, 2);
assert.equal(bodies[1], bodies[0]);
});
test("network ambiguity retries the same payload and can finish", async () => {
const bodies = [];
let call = 0;
const harness = createHarness(async (_url, options) => {
bodies.push(options.body);
call++;
if (call === 1) throw new Error("network result unknown");
return {ok: true, status: 200};
});
await harness.submit();
assert.equal(harness.box.disabled, true);
assert.match(harness.feedback.textContent, /原样重试/);
await harness.submit();
assert.deepEqual(bodies, [bodies[0], bodies[0]]);
assert.equal(harness.reloads(), 1);
});
function createHarness(fetchImplementation) {
class FakeElement {
constructor() {
this.dataset = {};
this.checked = false;
this.disabled = false;
this.indeterminate = false;
this.textContent = "";
this.listeners = {};
}
addEventListener(type, listener) {
this.listeners[type] = listener;
}
}
const box = new FakeElement();
box.checked = true;
box.dataset = {taskId: "task-id", taskVersion: "7", price: "12.80"};
const selectAll = new FakeElement();
const summary = new FakeElement();
const button = new FakeElement();
const feedback = new FakeElement();
const form = new FakeElement();
form.dataset = {startKey: "start-key", csrf: "csrf-token"};
form.querySelector = (selector) => ({
"[data-select-all]": selectAll,
"[data-selection-summary]": summary,
"[data-start-button]": button,
"[data-start-feedback]": feedback,
})[selector] || null;
form.querySelectorAll = (selector) => selector === "input[data-task-id]" ? [box] : [];
let reloadCount = 0;
const context = {
document: {querySelector: (selector) => selector === "[data-start-purchases]" ? form : null},
fetch: fetchImplementation,
window: {location: {reload: () => { reloadCount++; }}},
};
vm.runInNewContext(source, context, {filename: "tasks.js"});
return {
box,
button,
feedback,
reloads: () => reloadCount,
submit: () => form.listeners.submit({preventDefault() {}}),
};
}
@@ -6,12 +6,27 @@
<meta name="viewport" content="width=device-width, initial-scale=1"> <meta name="viewport" content="width=device-width, initial-scale=1">
<title>采购任务 · 采购服务</title> <title>采购任务 · 采购服务</title>
<style> <style>
:root{--bg:#f4f7fb;--surface:#fff;--text:#172033;--muted:#526079;--border:#cfd8e6;--primary:#155eef;--danger:#b42318;--success:#067647;--focus:#ffbf47;font-family:"Segoe UI","Microsoft YaHei UI",system-ui,sans-serif}*{box-sizing:border-box}html{min-width:320px;background:var(--bg)}body{min-height:100dvh;margin:0;color:var(--text);background:var(--bg);font-size:16px;line-height:1.55}button,input{font:inherit}:focus-visible{outline:3px solid var(--focus);outline-offset:3px}.skip{position:fixed;z-index:100;top:8px;left:8px;padding:10px;color:#fff;background:#172033;transform:translateY(-160%)}.skip:focus{transform:translateY(0)}header{display:flex;align-items:center;justify-content:space-between;gap:16px;min-height:64px;padding:10px clamp(16px,4vw,40px);border-bottom:1px solid var(--border);background:var(--surface)}.brand{font-weight:700}.brand b{display:inline-grid;place-items:center;width:32px;height:32px;margin-right:8px;border-radius:8px;background:var(--primary);color:#fff;font-size:.82rem}.logout,.button{display:inline-flex;align-items:center;justify-content:center;min-height:44px;padding:9px 14px;border:1px solid var(--border);border-radius:8px;color:var(--text);background:#fff;font-weight:700;text-decoration:none;cursor:pointer}.button.primary{border-color:var(--primary);background:var(--primary);color:#fff}.button:disabled,.filter input:disabled{opacity:.5;cursor:not-allowed}main{width:min(100% - 32px,1200px);margin:32px auto}.toolbar{display:flex;align-items:center;justify-content:space-between;gap:16px;margin-bottom:16px}.toolbar-actions,.filters,.actions{display:flex;flex-wrap:wrap;gap:10px}.muted,.placeholder{color:var(--muted)}.filters{align-items:end;margin:0 0 16px}.filters label{display:grid;gap:4px;font-weight:700}.filters input{min-height:44px;min-width:180px;padding:8px 10px;border:1px solid var(--border);border-radius:8px;background:#fff}.table-wrap{overflow-x:auto;border:1px solid var(--border);border-radius:12px;background:var(--surface)}table{width:100%;min-width:880px;border-collapse:collapse}th,td{padding:12px 14px;border-bottom:1px solid var(--border);text-align:left;vertical-align:top}th{background:#f8fafc;font-size:.88rem}td a{color:#124cc5;font-weight:700;text-underline-offset:3px}.status{display:inline-block;padding:3px 8px;border-radius:999px;background:#eaf1ff;color:#173d8f;font-size:.85rem;font-weight:700}.empty,.success{padding:20px;border:1px solid var(--border);border-radius:12px;background:var(--surface)}.success{margin:0 0 16px;border-color:#9dd9b8;background:#ecfdf3;color:var(--success)}.modal-scrim{position:fixed;z-index:20;inset:0;background:rgba(23,32,51,.52)}dialog[open]{position:fixed;z-index:30;top:50%;left:50%;width:min(calc(100% - 24px),640px);max-height:calc(100dvh - 24px);margin:0;padding:28px;overflow-y:auto;border:1px solid var(--border);border-radius:14px;box-shadow:0 18px 48px rgba(23,32,51,.24);transform:translate(-50%,-50%);background:var(--surface)}.form-page{width:min(100% - 32px,640px);margin:32px auto;padding:28px;border:1px solid var(--border);border-radius:14px;background:var(--surface)}.form-grid{display:grid;gap:16px}.field label{display:block;margin-bottom:6px;font-weight:700}.required{color:var(--danger)}.field input{width:100%;min-height:44px;padding:10px 12px;border:1px solid #9ba9bc;border-radius:8px}.field input[aria-invalid=true]{border-color:var(--danger)}.error{margin:5px 0 0;color:var(--danger);font-size:.9rem}.summary{margin:0 0 16px;padding:12px;border-left:4px solid var(--danger);background:#fef3f2;color:var(--danger)}.summary p{margin:0}.summary ul{margin:8px 0 0;padding-left:20px}.summary a{color:inherit}.sr-only{position:absolute;width:1px;height:1px;padding:0;margin:-1px;overflow:hidden;clip:rect(0,0,0,0);white-space:nowrap;border:0}@media(max-width:420px){main,.form-page{width:calc(100% - 24px);margin:24px auto}.toolbar{align-items:stretch;flex-direction:column}.toolbar-actions,.toolbar .button{width:100%}.toolbar-actions .button{flex:1}.filters{align-items:stretch;flex-direction:column}.filters input,.filters .button{width:100%}}@media(prefers-reduced-motion:reduce){*,*::before,*::after{transition-duration:.01ms!important;animation-duration:.01ms!important}}</style> :root{--bg:#f4f7fb;--surface:#fff;--text:#172033;--muted:#526079;--border:#cfd8e6;--primary:#155eef;--danger:#b42318;--success:#067647;--focus:#ffbf47;font-family:"Segoe UI","Microsoft YaHei UI",system-ui,sans-serif}*{box-sizing:border-box}html{min-width:320px;background:var(--bg)}body{min-height:100dvh;margin:0;color:var(--text);background:var(--bg);font-size:16px;line-height:1.55}button,input,select{font:inherit}:focus-visible{outline:3px solid var(--focus);outline-offset:3px}.skip{position:fixed;z-index:100;top:8px;left:8px;padding:10px;color:#fff;background:#172033;transform:translateY(-160%)}.skip:focus{transform:translateY(0)}header{display:flex;align-items:center;justify-content:space-between;gap:16px;min-height:64px;padding:10px clamp(16px,4vw,40px);border-bottom:1px solid var(--border);background:var(--surface)}.brand{font-weight:700}.brand b{display:inline-grid;place-items:center;width:32px;height:32px;margin-right:8px;border-radius:8px;background:var(--primary);color:#fff;font-size:.82rem}.logout,.button{display:inline-flex;align-items:center;justify-content:center;min-height:44px;padding:9px 14px;border:1px solid var(--border);border-radius:8px;color:var(--text);background:#fff;font-weight:700;text-decoration:none;cursor:pointer}.button.primary{border-color:var(--primary);background:var(--primary);color:#fff}.button:disabled,.filters input:disabled,.filters select:disabled{opacity:.5;cursor:not-allowed}main{width:min(100% - 32px,1200px);margin:32px auto}.toolbar{display:flex;align-items:center;justify-content:space-between;gap:16px;margin-bottom:16px}.toolbar-actions,.filters,.actions,.batch-actions{display:flex;flex-wrap:wrap;gap:10px}.muted,.placeholder,.not-selectable{color:var(--muted)}.filters{align-items:end;margin:0 0 16px}.filter-field{display:grid;gap:4px}.filter-field label{font-weight:700}.filters input,.filters select{min-height:44px;min-width:180px;padding:8px 10px;border:1px solid var(--border);border-radius:8px;background:#fff}.filters [aria-invalid=true]{border-color:var(--danger)}.batch-bar{display:flex;align-items:center;justify-content:space-between;gap:16px;margin:0 0 16px;padding:14px 16px;border:1px solid var(--border);border-radius:12px;background:var(--surface)}.batch-bar p{margin:2px 0}.batch-summary{font-weight:700}.batch-message{min-height:1.55em;color:var(--muted)}.table-wrap{overflow-x:auto;border:1px solid var(--border);border-radius:12px;background:var(--surface)}table{width:100%;min-width:880px;border-collapse:collapse}th,td{padding:12px 14px;border-bottom:1px solid var(--border);text-align:left;vertical-align:top}th{background:#f8fafc;font-size:.88rem}td a{color:#124cc5;font-weight:700;text-underline-offset:3px}.select-cell{width:64px;text-align:center}.checkbox-target{display:inline-grid;place-items:center;min-width:44px;min-height:44px;margin:-10px;cursor:pointer}.checkbox-target input{width:18px;height:18px}.status{display:inline-block;padding:3px 8px;border-radius:999px;background:#eaf1ff;color:#173d8f;font-size:.85rem;font-weight:700}.empty,.success{padding:20px;border:1px solid var(--border);border-radius:12px;background:var(--surface)}.success{margin:0 0 16px;border-color:#9dd9b8;background:#ecfdf3;color:var(--success)}.modal-scrim{position:fixed;z-index:20;inset:0;background:rgba(23,32,51,.52)}dialog[open]{position:fixed;z-index:30;top:50%;left:50%;width:min(calc(100% - 24px),640px);max-height:calc(100dvh - 24px);margin:0;padding:28px;overflow-y:auto;border:1px solid var(--border);border-radius:14px;box-shadow:0 18px 48px rgba(23,32,51,.24);transform:translate(-50%,-50%);background:var(--surface)}.form-page{width:min(100% - 32px,640px);margin:32px auto;padding:28px;border:1px solid var(--border);border-radius:14px;background:var(--surface)}.form-grid{display:grid;gap:16px}.field label{display:block;margin-bottom:6px;font-weight:700}.required{color:var(--danger)}.field input{width:100%;min-height:44px;padding:10px 12px;border:1px solid #9ba9bc;border-radius:8px}.field input[aria-invalid=true]{border-color:var(--danger)}.error{margin:5px 0 0;color:var(--danger);font-size:.9rem}.summary{margin:0 0 16px;padding:12px;border-left:4px solid var(--danger);background:#fef3f2;color:var(--danger)}.summary p{margin:0}.summary ul{margin:8px 0 0;padding-left:20px}.summary a{color:inherit}.sr-only{position:absolute;width:1px;height:1px;padding:0;margin:-1px;overflow:hidden;clip:rect(0,0,0,0);white-space:nowrap;border:0}@media(max-width:420px){main,.form-page{width:calc(100% - 24px);margin:24px auto}.toolbar,.batch-bar{align-items:stretch;flex-direction:column}.toolbar-actions,.toolbar .button,.batch-actions,.batch-actions .button{width:100%}.toolbar-actions .button,.batch-actions .button{flex:1}.filters{align-items:stretch;flex-direction:column}.filters input,.filters select,.filters .button{width:100%}}@media(prefers-reduced-motion:reduce){*,*::before,*::after{transition-duration:.01ms!important;animation-duration:.01ms!important}}</style>
</head> </head>
<body> <body>
<a class="skip" href="#main">跳到主要内容</a> <a class="skip" href="#main">跳到主要内容</a>
<header><div class="brand"><b aria-hidden="true">采</b>采购服务</div><form method="post" action="/logout"><input type="hidden" name="csrf_token" value="{{.CSRFToken}}"><button class="logout" type="submit">退出登录</button></form></header> <header><div class="brand"><b aria-hidden="true">采</b>采购服务</div><form method="post" action="/logout"><input type="hidden" name="csrf_token" value="{{.CSRFToken}}"><button class="logout" type="submit">退出登录</button></form></header>
{{if .FullPage}}<main class="form-page" id="main">{{template "form" .}}</main>{{else}}<main id="main"><div class="toolbar"><div><h1>采购任务</h1><p class="muted">只显示待开始的手工任务。</p></div><div class="toolbar-actions"><button class="button" type="button" disabled>导入</button><a class="button primary" href="/tasks?create=1">创建任务</a></div></div><div class="filters" aria-label="暂不可用的列表条件"><label>关键词<input type="search" disabled></label><button class="button" type="button" disabled>筛选</button><button class="button" type="button" disabled>清除</button></div>{{if .Success}}<p class="success" role="status">任务已创建,已显示在列表首行。</p>{{end}}{{if .Drafts}}<div class="table-wrap"><table><thead><tr><th scope="col"><input type="checkbox" disabled aria-label="选择全部任务"></th><th scope="col">标题</th><th scope="col">颜色分类</th><th scope="col">尺码</th><th scope="col">价格上限</th><th scope="col">数量</th><th scope="col">采购结果</th><th scope="col">状态</th><th scope="col">创建时间</th></tr></thead><tbody>{{range .Drafts}}<tr><td><input type="checkbox" disabled aria-label="选择任务 {{.Title}}"></td><td><a href="https://mobile.yangkeduo.com/goods.html?goods_id={{.GoodsID}}" target="_blank" rel="noopener noreferrer">{{.Title}}</a></td><td>{{.SKUColor}}</td><td>{{.SKUSize}}</td><td>¥{{.MaxTotalPrice}}</td><td>{{.Quantity}}</td><td>—</td><td><span class="status">待开始</span></td><td><time datetime="{{.CreatedAt.Format "2006-01-02T15:04:05Z07:00"}}">{{.CreatedAt.Format "2006-01-02 15:04 UTC"}}</time></td></tr>{{end}}</tbody></table></div>{{else}}<section class="empty"><h2>还没有待开始任务</h2><p>创建一条手工任务后会显示在这里。</p></section>{{end}}</main>{{if .OpenForm}}<div class="modal-scrim" aria-hidden="true"></div><dialog open aria-modal="true" aria-labelledby="form-title">{{template "form" .}}</dialog>{{end}}{{end}} {{if .FullPage}}<main class="form-page" id="main">{{template "form" .}}</main>{{else}}<main id="main">
<div class="toolbar"><div><h1>采购任务</h1><p class="muted">查询任务并统一授权待开始任务。</p></div><div class="toolbar-actions"><button class="button" type="button" disabled>导入</button><a class="button primary" href="/tasks?create=1">创建任务</a></div></div>
{{if .FilterErrors}}<div class="summary" role="alert" aria-live="assertive"><p>请修正筛选条件后重新查询。</p><ul>{{with index .FilterErrors "status"}}<li><a href="#filter-status">状态:{{.}}</a></li>{{end}}{{with index .FilterErrors "created_from"}}<li><a href="#filter-created-from">开始日期:{{.}}</a></li>{{end}}{{with index .FilterErrors "created_to"}}<li><a href="#filter-created-to">结束日期:{{.}}</a></li>{{end}}</ul></div>{{end}}
<form class="filters" method="get" action="/tasks" aria-label="任务筛选">
<div class="filter-field"><label for="filter-keyword">关键词</label><input id="filter-keyword" name="keyword" type="search" value="{{.Filter.Keyword}}" placeholder="标题或商品编号"></div>
<div class="filter-field"><label for="filter-status">状态</label><select id="filter-status" name="status" aria-invalid="{{if index .FilterErrors "status"}}true{{else}}false{{end}}"{{with index .FilterErrors "status"}} aria-describedby="filter-status-error"{{end}}>{{if index .FilterErrors "status"}}<option value="{{.Filter.Status}}" selected>无效状态:{{.Filter.Status}}</option>{{end}}<option value=""{{if eq .Filter.Status ""}} selected{{end}}>全部状态</option><option value="DRAFT"{{if eq .Filter.Status "DRAFT"}} selected{{end}}>待开始</option><option value="PENDING"{{if eq .Filter.Status "PENDING"}} selected{{end}}>已授权待领取</option><option value="CLAIMED"{{if eq .Filter.Status "CLAIMED"}} selected{{end}}>已领取</option><option value="ORDERING"{{if eq .Filter.Status "ORDERING"}} selected{{end}}>执行中</option><option value="NEEDS_MANUAL"{{if eq .Filter.Status "NEEDS_MANUAL"}} selected{{end}}>待人工处理</option><option value="WAITING_PAYMENT"{{if eq .Filter.Status "WAITING_PAYMENT"}} selected{{end}}>待付款</option><option value="RECONCILIATION_REQUIRED"{{if eq .Filter.Status "RECONCILIATION_REQUIRED"}} selected{{end}}>围栏后待调和</option><option value="SUCCEEDED"{{if eq .Filter.Status "SUCCEEDED"}} selected{{end}}>已完成</option><option value="FAILED"{{if eq .Filter.Status "FAILED"}} selected{{end}}>失败</option><option value="CANCELED"{{if eq .Filter.Status "CANCELED"}} selected{{end}}>已取消</option></select>{{with index .FilterErrors "status"}}<p class="error" id="filter-status-error">{{.}}</p>{{end}}</div>
<div class="filter-field"><label for="filter-created-from">开始日期</label><input id="filter-created-from" name="created_from" type="date" value="{{.Filter.CreatedFrom}}" aria-invalid="{{if index .FilterErrors "created_from"}}true{{else}}false{{end}}"{{with index .FilterErrors "created_from"}} aria-describedby="filter-created-from-error"{{end}}>{{with index .FilterErrors "created_from"}}<p class="error" id="filter-created-from-error">{{.}}</p>{{end}}</div>
<div class="filter-field"><label for="filter-created-to">结束日期</label><input id="filter-created-to" name="created_to" type="date" value="{{.Filter.CreatedTo}}" aria-invalid="{{if index .FilterErrors "created_to"}}true{{else}}false{{end}}"{{with index .FilterErrors "created_to"}} aria-describedby="filter-created-to-error"{{end}}>{{with index .FilterErrors "created_to"}}<p class="error" id="filter-created-to-error">{{.}}</p>{{end}}</div>
<div class="actions"><button class="button primary" type="submit">筛选</button><a class="button" href="/tasks">清除筛选</a></div>
</form>
{{if .Success}}<p class="success" role="status">任务已创建,已显示在列表首行。</p>{{end}}
<form data-start-purchases data-start-key="{{.StartKey}}" data-csrf="{{.CSRFToken}}">
<section class="batch-bar" aria-label="批量开始采购"><div><p class="batch-summary" data-selection-summary aria-live="polite">已选 0 条,最高总额 ¥0.00</p><p class="muted" id="payment-note">采购工具会逐条创建待付款订单,系统不会付款。</p><p class="batch-message" id="start-feedback" data-start-feedback role="status" aria-live="polite"></p></div><div class="batch-actions"><button class="button primary" type="submit" data-start-button aria-describedby="payment-note start-feedback" disabled>开始采购(只创建待付款订单)</button></div></section>
<div class="table-wrap"><table><thead><tr><th class="select-cell" scope="col"><label class="checkbox-target"><span class="sr-only">选择全部当前筛选结果中的待开始任务</span><input type="checkbox" data-select-all aria-label="选择全部任务"{{if not .Tasks}} disabled{{end}}></label></th><th scope="col">标题</th><th scope="col">颜色分类</th><th scope="col">尺码</th><th scope="col">价格上限</th><th scope="col">数量</th><th scope="col">采购结果</th><th scope="col">状态</th><th scope="col">创建时间(上海)</th></tr></thead><tbody>{{if .Tasks}}{{range .Tasks}}<tr><td class="select-cell">{{if eq .Status "DRAFT"}}<label class="checkbox-target"><span class="sr-only">选择任务 {{.Title}}</span><input type="checkbox" name="task_ids" value="{{.ID}}" data-task-id="{{.ID}}" data-task-version="{{.Version}}" data-price="{{.MaxTotalPrice}}" aria-label="选择任务 {{.Title}}"></label>{{else}}<span class="not-selectable">—<span class="sr-only">{{statusLabel .Status}}任务不可选择</span></span>{{end}}</td><td><a href="https://mobile.yangkeduo.com/goods.html?goods_id={{.GoodsID}}" target="_blank" rel="noopener noreferrer">{{.Title}}</a></td><td>{{.SKUColor}}</td><td>{{.SKUSize}}</td><td>¥{{.MaxTotalPrice}}</td><td>{{.Quantity}}</td><td>—</td><td><span class="status">{{statusLabel .Status}}</span></td><td><time datetime="{{shanghaiDateTime .CreatedAt}}">{{shanghaiTime .CreatedAt}}</time></td></tr>{{end}}{{else}}<tr><td colspan="9">{{if .FilterErrors}}<section class="empty"><h2>筛选条件有误</h2><p>请修正上方标出的字段后重新查询。</p></section>{{else if .HasFilter}}<section class="empty"><h2>没有符合筛选条件的任务</h2><p><a class="button" href="/tasks">清除筛选</a></p></section>{{else}}<section class="empty"><h2>还没有采购任务</h2><p>创建一条手工任务后会显示在这里。</p></section>{{end}}</td></tr>{{end}}</tbody></table></div>
</form>
</main>{{if .OpenForm}}<div class="modal-scrim" aria-hidden="true"></div><dialog open aria-modal="true" aria-labelledby="form-title">{{template "form" .}}</dialog>{{end}}<script src="/static/tasks.js" defer></script>{{end}}
</body> </body>
</html> </html>
{{end}} {{end}}
+46 -10
View File
@@ -5,6 +5,7 @@ import (
"embed" "embed"
"html/template" "html/template"
"io" "io"
"time"
"cmbuyer/admin/internal/tasks" "cmbuyer/admin/internal/tasks"
) )
@@ -12,7 +13,17 @@ import (
//go:embed templates/*.html //go:embed templates/*.html
var templateFiles embed.FS var templateFiles embed.FS
var templates = template.Must(template.New("webui").Funcs(template.FuncMap{"list": func(values ...any) []any { return values }}).ParseFS(templateFiles, "templates/*.html")) //go:embed static/tasks.js
var tasksScript []byte
var shanghaiLocation = time.FixedZone("Asia/Shanghai", 8*60*60)
var templates = template.Must(template.New("webui").Funcs(template.FuncMap{
"list": func(values ...any) []any { return values },
"statusLabel": statusLabel,
"shanghaiDateTime": func(value time.Time) string { return value.In(shanghaiLocation).Format(time.RFC3339) },
"shanghaiTime": func(value time.Time) string { return value.In(shanghaiLocation).Format("2006-01-02 15:04") },
}).ParseFS(templateFiles, "templates/*.html"))
// LoginData 是登录页面所需的非敏感展示数据。 // LoginData 是登录页面所需的非敏感展示数据。
type LoginData struct { type LoginData struct {
@@ -22,16 +33,20 @@ type LoginData struct {
Error string Error string
} }
// TasksData 是受保护的 DRAFT 建单与列表页面所需数据。 // TasksData 是受保护的建单与任务工作台页面所需数据。
type TasksData struct { type TasksData struct {
CSRFToken string CSRFToken string
Drafts []tasks.Draft Tasks []tasks.TaskRow
Form tasks.Form Filter tasks.TaskFilter
Errors tasks.Errors FilterErrors tasks.Errors
OpenForm bool HasFilter bool
FullPage bool StartKey string
FocusField string Form tasks.Form
Success bool Errors tasks.Errors
OpenForm bool
FullPage bool
FocusField string
Success bool
} }
// RenderLogin 写入登录页。 // RenderLogin 写入登录页。
@@ -43,3 +58,24 @@ func RenderLogin(writer io.Writer, data LoginData) error {
func RenderTasks(writer io.Writer, data TasksData) error { func RenderTasks(writer io.Writer, data TasksData) error {
return templates.ExecuteTemplate(writer, "tasks.html", data) return templates.ExecuteTemplate(writer, "tasks.html", data)
} }
func TasksScript() []byte { return tasksScript }
func statusLabel(status string) string {
labels := map[string]string{
"DRAFT": "待开始",
"PENDING": "已授权待领取",
"CLAIMED": "已领取",
"ORDERING": "执行中",
"NEEDS_MANUAL": "待人工处理",
"WAITING_PAYMENT": "待付款",
"RECONCILIATION_REQUIRED": "围栏后待调和",
"SUCCEEDED": "已完成",
"FAILED": "失败",
"CANCELED": "已取消",
}
if label, ok := labels[status]; ok {
return label
}
return "未知状态"
}
+12 -9
View File
@@ -25,9 +25,9 @@
以及绑定 PKG110 / Android 16 / 拼多多 8.17.0 的规格证据确定性脱敏 CLI;尚无规格选择、价格读取或下单流程 以及绑定 PKG110 / Android 16 / 拼多多 8.17.0 的规格证据确定性脱敏 CLI;尚无规格选择、价格读取或下单流程
- 测试:采购服务已覆盖健康检查、核心模型、迁移与状态机等离线包级测试;采购工具 80 项离线单元测试 - 测试:采购服务已覆盖健康检查、核心模型、迁移与状态机等离线包级测试;采购工具 80 项离线单元测试
(全部 mock,不连接真机) (全部 mock,不连接真机)
- 数据:SQLite v1 核心表与迁移已落成,但仍是旧两趟 schema(含 `spec_trials`、 - 数据:SQLite v2 单趟核心表与领域状态机已落成,旧 `spec_trials`、`authorized_unit_price` 和
`authorized_unit_price` 和旧状态);无业务实例数据。T-111 只冻结目标契约,不改生产代码;T-209 两趟状态已由受保护迁移移除;无业务实例数据。T-203 可以基于新模型实现批量“开始采购”与
必须先迁移 schema / 领域状态机,T-203 才能实现新“开始采购”事务。 一次性授权事务。
- 标准启动路径:Windows PowerShell 运行 `./init.ps1`,Unix shell 运行 `./init.sh`。Windows 入口 - 标准启动路径:Windows PowerShell 运行 `./init.ps1`,Unix shell 运行 `./init.sh`。Windows 入口
优先使用合规的既有 venv;仅在其缺失时才从 Python Launcher 已安装版本中选择最高的 Python 3.11+, 优先使用合规的既有 venv;仅在其缺失时才从 Python Launcher 已安装版本中选择最高的 Python 3.11+,
并且不覆盖低版本环境;成功后打印真实启动命令。 并且不覆盖低版本环境;成功后打印真实启动命令。
@@ -40,15 +40,15 @@
手机号,并保留目标预选规格、顶部当前价“快卖完 ¥12.88”和原价“¥29.88”;底部“提交订单 ¥12.88” 手机号,并保留目标预选规格、顶部当前价“快卖完 ¥12.88”和原价“¥29.88”;底部“提交订单 ¥12.88”
继续属于硬拒绝区。派生截图虽然把顶部价格遮住一半,但项目已停止遮罩器开发,视觉完整性不再阻塞 继续属于硬拒绝区。派生截图虽然把顶部价格遮住一半,但项目已停止遮罩器开发,视觉完整性不再阻塞
规格选择与读价;T-204 将直接上传内部原始截图供管理员查看。T-010 已允许不依赖真机字段的 T-201 规格选择与读价;T-204 将直接上传内部原始截图供管理员查看。T-010 已允许不依赖真机字段的 T-201
和只创建 `DRAFT` 的 T-202 并行。T-209 的 schema / 状态机迁移不依赖页面选择器,可在 T-111 和只创建 `DRAFT` 的 T-202 并行。T-209 已完成 schema / 状态机单趟迁移;T-203 服务端
完成后推进;随后做 T-203 服务端“开始采购”授权事务。T-205 起实际 attempt / 真机字段继续等待 T-103。 “开始采购”授权事务可立即推进。T-205 起实际 attempt / 真机字段继续等待 T-103。
## 当前目录要点 ## 当前目录要点
| 路径 | 状态 | 说明 | | 路径 | 状态 | 说明 |
| --- | --- | --- | | --- | --- | --- |
| `docs/` | 已有 | 项目规范化文档,本次已完整生成 | | `docs/` | 已有 | 项目规范化文档,本次已完整生成 |
| `docs/tasks/` | 已有(含 T-001~T-111、T-201~T-202) | T-111 单趟契约已完成;T-103 已恢复;T-202 在独立工作树待主审提交 | | `docs/tasks/` | 已有(含 T-001~T-111、T-201~T-203、T-209) | T-111 单趟契约、T-202 手工 DRAFT 建单及 T-209 单趟 schema 已完成;T-103、T-203 并行推进 |
| `docs/design/` | 已有(6 个原型) | web 登录 / 建单 / 工作台 / 详情,desk 采购执行 / 配置;均已人工确认 | | `docs/design/` | 已有(6 个原型) | web 登录 / 建单 / 工作台 / 详情,desk 采购执行 / 配置;均已人工确认 |
| `scripts/` | 已有 | 上下文门禁、Vikunja 单向导出与 MCP 启动包装 | | `scripts/` | 已有 | 上下文门禁、Vikunja 单向导出与 MCP 启动包装 |
| `admin/` | 已初始化 | Go 1.23+ / gin / SQLite,含核心模型、迁移与状态机;无真机采购执行 | | `admin/` | 已初始化 | Go 1.23+ / gin / SQLite,含核心模型、迁移与状态机;无真机采购执行 |
@@ -65,11 +65,14 @@
- 已完成:T-002(采购工具 Python 骨架)、T-003(双端统一初始化与验证入口)、 - 已完成:T-002(采购工具 Python 骨架)、T-003(双端统一初始化与验证入口)、
T-004(核心数据模型)、T-101(真机环境盘点与 USB/WiFi 双通道人工验收)、T-102(canonical T-004(核心数据模型)、T-101(真机环境盘点与 USB/WiFi 双通道人工验收)、T-102(canonical
链接打开与目标商品/隐私人工验收)。 链接打开与目标商品/隐私人工验收)。
- 已完成 T-010(安全并行门禁)与 T-201(管理员登录与会话)。T-202 已由 admin agent 实现且 - 已完成 T-010(安全并行门禁)、T-201(管理员登录与会话)与 T-202(手工 DRAFT 建单和
测试通过,仍只创建/展示 `DRAFT`;当前在独立工作树等待主 agent 审阅、提交和推送。 基础列表)。T-202 已通过主 agent 独立审查、竞态测试与完整门禁并合入主线,仍只创建/展示
`DRAFT`,未实现授权、设备领取或采购执行。
- 已完成 T-110(受控规格入口边界)与 T-111(开始采购授权的单趟契约)。T-103 已恢复为 - 已完成 T-110(受控规格入口边界)与 T-111(开始采购授权的单趟契约)。T-103 已恢复为
`DOING`,以 `SkuSelectionFlow` 继续最小 fixture、精确规格和读价,不实现数量、确认页或提交; `DOING`,以 `SkuSelectionFlow` 继续最小 fixture、精确规格和读价,不实现数量、确认页或提交;
内部原始截图上传交给 T-204。admin 方向在 T-202 主审合入后,先落 T-209 迁移旧 schema/状态机。 内部原始截图上传交给 T-204。admin 方向已完成 T-209,正在转入 T-203“开始采购”授权事务。
- 已完成 T-209:SQLite v2 已迁移为单趟授权、采购尝试和提交围栏模型,并删除旧试选领域模型;
迁移和状态机护栏已通过完整门禁。下一项采购服务任务为 T-203 批量“开始采购”与一次性授权。
- 已确认原型继续只作信息架构依据;原型假数据不调用真实接口、不驱动真机。真机结论改变 - 已确认原型继续只作信息架构依据;原型假数据不调用真实接口、不驱动真机。真机结论改变
可读字段时必须先回修原型与交互清单。 可读字段时必须先回修原型与交互清单。
+76
View File
@@ -0,0 +1,76 @@
---
id: T-203
title: 表格查询与批量开始采购授权
phase: 2
deps: [T-202, T-209]
status: DOING
created: 2026-08-04
vikunja_task_id: 30
context_ref: 1f20271
work_branch: task/t-203-start-purchases
needs_device: false
needs_human_review: false
write_paths:
- docs/tasks/T-203.md
- admin/internal/auth/**
- admin/internal/tasks/**
- admin/internal/server/**
- admin/internal/transport/webui/**
- admin/internal/config/**
- admin/cmd/server/**
- admin/README.md
---
<!-- BEGIN VIKUNJA EXPORT id=30 synced=2026-08-04T09:16:48Z sha256=6c62ba368a760e96b8feb06f3f3ced8a2a40d10f7754a99471f7429c3862a80c -->
## 问题 / 背景
T-202 已完成手工 DRAFT 建单;T-209 将生产 schema/领域状态机迁移到单趟模型。项目所有者明确:管理员点击“开始采购(只创建待付款订单)”本身就是授权,不再增加试选后确认。T-203 负责采购服务查询和批量授权事务,使设备后续只能领取显式授权的 PENDING 任务。
## 关联需求与交互
F-004、F-008、F-018;US-003、US-005;IX-005;GET /tasks、POST /tasks/start-purchases;依赖 T-202、T-209。
## 方案
1. GET /tasks 支持 keyword、status、created_from、created_to;日期按 Asia/Shanghai 转为 UTC 半开区间,非法筛选返回可访问字段错误;默认全部状态并按 created_at DESC,rowid DESC。
2. 页面只让 DRAFT 行可勾选;表格上方显示选中数量、最高总额字符串合计、“系统不会付款”和唯一主按钮“开始采购(只创建待付款订单)”,不加逐行操作或重复确认弹窗。JS 只用同源静态文件,金额以分/BigInt 累计,不用浮点。
3. POST 使用服务端生成并渲染的 UUID v4 start_key,接收非空去重任务 id + expected_task_version;批量上限 100。created_by 只取已认证管理员,不接受请求字段。
4. 在一个有界 SQLite 写事务内先按 start_key 检查重放,再按 task_id 稳定排序读取并复核全部任务:存在、DRAFT、版本相等、锁定字段完整、数量/总额上限满足显式配置。任一失败整批不变。
5. 新请求为整批使用同一 created_at/expires_at;逐条创建 ACTIVE 一次性授权,锁定新 task_version、goods_id、颜色、尺码、数量和 total_price_cap;条件更新每条 DRAFT/version 为 PENDING/version+1,任一 RowsAffected != 1 则全批回滚。
6. 相同 start_key + 相同规范集合(输入顺序无关)返回原 authorization ids/版本/有效期,不再次改任务;同 key 子集、超集、不同版本或残缺集合返回 409。网络结果不明时前端冻结原 key/载荷,只允许原样重放。
7. 显式配置并启动时校验授权 TTL、最大任务数量、最大总额;金额只用规范十进制字符串。T-207 才关闭过期授权,T-203 只写 expires_at。
## 验收要点
- 覆盖单条/100条成功,任务版本仅加一次、快照逐字段相等、管理员来自服务端、UTC 过期时间正确。
- 任一缺失/非 DRAFT/版本冲突/字段不完整/数量或金额超限/中途 SQL 失败均整批零修改。
- 同 key 同集合、倒序集合、并发重放返回同一结果;同 key 不同集合冲突;不同 key 并发抢同一版本仅一个成功。
- 未登录、CSRF、空/重复/畸形/超大请求、配置边界和数据库故障 fail closed,不泄露内部错误。
- UI 只有 DRAFT 可选;筛选、全选当前可见项、选择反馈、网络不明重放、焦点/aria-live/横向滚动与不付款文案有测试;无同义确认弹窗。
- go test ./...、go test -race ./...、go vet ./...、go build ./...、node --check 静态 JS、完整 init.ps1、上下文校验和 diff-check 通过。
## 执行记录
### 2026-08-04T09:16:39Z · ila
2026-08-04 开始 T-203:依赖 T-209 已完成并合入 main。主 agent 已完成开工前只读审计,冻结 v2 schema 启动校验、单进程 writeGate、start_key 规范集合重放、Asia/Shanghai 到 UTC 半开区间、julianday 查询及 big.Int 分金额边界;本地状态转 DOING,分支 task/t-203-start-purchases。
<!-- END VIKUNJA EXPORT -->
## 边界
- 本任务只实现管理员任务查询与批量“开始采购”授权;不实现任务详情/截图、设备 Bearer 身份、领取/
租约、purchase attempt API、提交围栏、结果调和、真机自动化、创建订单点击或付款。
- “开始采购(只创建待付款订单)”按钮本身就是明确授权,不得再增加同义确认弹窗,也不得把它拆回
试选后确认。按钮附近必须持续显示系统不付款;页面不得提供逐行“开始采购”操作列。
- 只有 `DRAFT` 行可以勾选;批量事务必须全有或全无。任一任务缺失、状态/版本变化、锁定字段非法、
配置超限、授权插入失败或条件更新未命中,都不得留下部分授权或部分 `PENDING`。
- 幂等重放必须先于 DRAFT 状态检查:相同 `start_key` 和相同规范任务集合只返回原结果,任务版本不得
再增加;同 key 的子集、超集、不同 expected version 或残缺授权集合一律冲突。网络结果不明只能
原样重放同一个 key 和载荷,不能生成新 key。
- 授权只锁定任务的新版本、goods_id、颜色、尺码、数量和 `total_price_cap`;不得写入观察单价或
`authorized_unit_price`。金额校验、配置比较和浏览器合计均使用十进制字符串/整数分,不用浮点。
- `created_by` 只能来自已认证管理员会话;POST 必须验证 CSRF。设备凭据不能调用本接口,本任务也不
新增设备接口。内部数据库错误不得回显 SQL、路径、配置值或凭据。
- 过期授权的关闭/重置属于 T-207;本任务只创建 `expires_at`。授权一旦进入 `FENCED`,本任务没有
释放、取消、重新授权或重试入口。
- 本任务不实现、不引用通用真机点击、`submit_order_once()` 或任何支付、免密支付、先用后付能力。
+4 -2
View File
@@ -17,7 +17,7 @@ write_paths:
- admin/internal/domain/** - admin/internal/domain/**
--- ---
<!-- BEGIN VIKUNJA EXPORT id=29 synced=2026-08-04T08:38:26Z sha256=2cac52971eda168b1d17bc4b6930f97876324d5685af26dc26495de07b44cb7d --> <!-- BEGIN VIKUNJA EXPORT id=29 synced=2026-08-04T09:15:04Z sha256=162d329ca4dac4535882e84fa12b73e5023a4c04e75a2c32548e7b3b84be8abb -->
## 问题 / 背景 ## 问题 / 背景
T-111 已把业务契约改为管理员点击“开始采购”即授权桌面端在同一趟创建待付款订单;现有 SQLite v1 与领域模型仍是旧两趟结构,包含 spec_trials、authorized_unit_price 及 WAITING_CONFIRMATION/PENDING_RETRIAL/AUTHORIZED/RUNNING 等旧状态。T-203 不能在旧结构上继续实现。 T-111 已把业务契约改为管理员点击“开始采购”即授权桌面端在同一趟创建待付款订单;现有 SQLite v1 与领域模型仍是旧两趟结构,包含 spec_trials、authorized_unit_price 及 WAITING_CONFIRMATION/PENDING_RETRIAL/AUTHORIZED/RUNNING 等旧状态。T-203 不能在旧结构上继续实现。
@@ -45,7 +45,9 @@ T-111;F-004、F-005、F-008、F-017、F-018;docs/04-architecture.md 第四
## 执行记录 ## 执行记录
(暂无) ### 2026-08-04T09:14:34Z · ila
2026-08-04 完成 T-209:SQLite v2 单趟 schema、领域状态机、迁移 up/down 与 fail-closed 护栏已实现。任务提交 e04f05b,合并提交 f85ef5f;主 agent 独立执行完整 init.ps1、Go test/race/vet/build、上下文校验与 diff-check 均通过。本地任务状态已置 DONE,主线已推送。
<!-- END VIKUNJA EXPORT --> <!-- END VIKUNJA EXPORT -->
## 边界 ## 边界