feat(admin): authorize batch purchase starts
This commit is contained in:
@@ -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
|
||||||
```
|
```
|
||||||
|
|
||||||
采购服务会话仅保存在当前进程内;进程重启后既有登录会话会安全失效。
|
采购服务会话仅保存在当前进程内;进程重启后既有登录会话会安全失效。
|
||||||
|
管理员的“开始采购(只创建待付款订单)”只签发一次性授权并创建待付款订单的资格;服务不会自动付款,也不包含任何支付操作。
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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 == "" {
|
||||||
|
|||||||
@@ -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) == "" {
|
||||||
|
|||||||
@@ -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
@@ -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)
|
||||||
|
|||||||
@@ -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+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,
|
||||||
|
}},
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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,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}}
|
||||||
|
|||||||
@@ -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 "未知状态"
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user