Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
76a522f13f | ||
|
|
6e001b2729 |
@@ -1,25 +0,0 @@
|
|||||||
# 采购服务
|
|
||||||
|
|
||||||
启动前必须显式设置下列环境变量;服务不提供默认管理员账号、密码或会话密钥。
|
|
||||||
|
|
||||||
| 变量 | 要求 |
|
|
||||||
| --- | --- |
|
|
||||||
| `CMBUYER_ADMIN_USERNAME` | 非空管理员账号。 |
|
|
||||||
| `CMBUYER_ADMIN_PASSWORD_BCRYPT` | 非空 bcrypt 密码哈希,不接受明文密码。 |
|
|
||||||
| `CMBUYER_SESSION_SECRET` | 至少 32 字节的会话签名密钥。 |
|
|
||||||
| `CMBUYER_COOKIE_SECURE` | 可选;存在时只能精确为 `true` 或 `false`。HTTPS 部署应设为 `true`。 |
|
|
||||||
| `CMBUYER_DATABASE_SOURCE` | 已迁移 SQLite 的显式 data source。 |
|
|
||||||
|
|
||||||
示例仅展示变量名,不提供可运行凭据:
|
|
||||||
|
|
||||||
```powershell
|
|
||||||
$env:CMBUYER_ADMIN_USERNAME = '<管理员账号>'
|
|
||||||
$env:CMBUYER_ADMIN_PASSWORD_BCRYPT = '<bcrypt 密码哈希>'
|
|
||||||
$env:CMBUYER_SESSION_SECRET = '<至少 32 字节的随机密钥>'
|
|
||||||
$env:CMBUYER_COOKIE_SECURE = 'true'
|
|
||||||
$env:CMBUYER_DATABASE_SOURCE = '<SQLite data source>'
|
|
||||||
go run ./cmd/migrate -database $env:CMBUYER_DATABASE_SOURCE up
|
|
||||||
go run ./cmd/server
|
|
||||||
```
|
|
||||||
|
|
||||||
采购服务会话仅保存在当前进程内;进程重启后既有登录会话会安全失效。
|
|
||||||
@@ -5,11 +5,7 @@ import (
|
|||||||
"log"
|
"log"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
|
||||||
"cmbuyer/admin/internal/auth"
|
|
||||||
"cmbuyer/admin/internal/config"
|
|
||||||
"cmbuyer/admin/internal/server"
|
"cmbuyer/admin/internal/server"
|
||||||
"cmbuyer/admin/internal/storage/sqlite"
|
|
||||||
"cmbuyer/admin/internal/tasks"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
const listenAddress = ":8080"
|
const listenAddress = ":8080"
|
||||||
@@ -21,31 +17,7 @@ func main() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func run() error {
|
func run() error {
|
||||||
configuration, err := config.LoadFromEnv()
|
err := http.ListenAndServe(listenAddress, server.NewRouter())
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
database, err := sqlite.Open(configuration.DatabaseSource)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
defer database.Close()
|
|
||||||
taskStore, err := tasks.NewSQLiteStore(database)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
router, err := server.NewRouter(server.Options{
|
|
||||||
AdminUsername: configuration.AdminUsername,
|
|
||||||
AdminPasswordBcrypt: configuration.AdminPasswordBcrypt,
|
|
||||||
Sessions: auth.NewManager(configuration.SessionSecret, configuration.CookieSecure),
|
|
||||||
Tasks: taskStore,
|
|
||||||
})
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
err = http.ListenAndServe(listenAddress, router)
|
|
||||||
if errors.Is(err, http.ErrServerClosed) {
|
if errors.Is(err, http.ErrServerClosed) {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|||||||
+1
-1
@@ -6,7 +6,6 @@ require (
|
|||||||
github.com/gin-gonic/gin v1.11.0
|
github.com/gin-gonic/gin v1.11.0
|
||||||
github.com/mattn/go-sqlite3 v1.14.49
|
github.com/mattn/go-sqlite3 v1.14.49
|
||||||
github.com/pressly/goose/v3 v3.24.0
|
github.com/pressly/goose/v3 v3.24.0
|
||||||
golang.org/x/crypto v0.40.0
|
|
||||||
)
|
)
|
||||||
|
|
||||||
require (
|
require (
|
||||||
@@ -36,6 +35,7 @@ require (
|
|||||||
go.uber.org/mock v0.5.0 // indirect
|
go.uber.org/mock v0.5.0 // indirect
|
||||||
go.uber.org/multierr v1.11.0 // indirect
|
go.uber.org/multierr v1.11.0 // indirect
|
||||||
golang.org/x/arch v0.20.0 // indirect
|
golang.org/x/arch v0.20.0 // indirect
|
||||||
|
golang.org/x/crypto v0.40.0 // indirect
|
||||||
golang.org/x/mod v0.25.0 // indirect
|
golang.org/x/mod v0.25.0 // indirect
|
||||||
golang.org/x/net v0.42.0 // indirect
|
golang.org/x/net v0.42.0 // indirect
|
||||||
golang.org/x/sync v0.16.0 // indirect
|
golang.org/x/sync v0.16.0 // indirect
|
||||||
|
|||||||
@@ -1,198 +0,0 @@
|
|||||||
// Package auth 提供内存会话与 CSRF 防护。会话不落库,服务重启会安全地使所有登录失效。
|
|
||||||
package auth
|
|
||||||
|
|
||||||
import (
|
|
||||||
"crypto/hmac"
|
|
||||||
"crypto/rand"
|
|
||||||
"crypto/sha256"
|
|
||||||
"crypto/subtle"
|
|
||||||
"encoding/base64"
|
|
||||||
"io"
|
|
||||||
"net/http"
|
|
||||||
"strconv"
|
|
||||||
"strings"
|
|
||||||
"sync"
|
|
||||||
"time"
|
|
||||||
)
|
|
||||||
|
|
||||||
const (
|
|
||||||
CookieName = "cmbuyer_session"
|
|
||||||
SessionLifetime = 8 * time.Hour
|
|
||||||
csrfTokenByteSize = 32
|
|
||||||
)
|
|
||||||
|
|
||||||
type session struct {
|
|
||||||
csrfToken string
|
|
||||||
authenticated bool
|
|
||||||
expiresAt time.Time
|
|
||||||
}
|
|
||||||
|
|
||||||
// Manager 签发、验证并撤销进程内会话。cookie 仅承载经过 HMAC 签名的随机 session ID。
|
|
||||||
type Manager struct {
|
|
||||||
secret []byte
|
|
||||||
cookieSecure bool
|
|
||||||
now func() time.Time
|
|
||||||
random io.Reader
|
|
||||||
|
|
||||||
mu sync.Mutex
|
|
||||||
sessions map[string]session
|
|
||||||
}
|
|
||||||
|
|
||||||
// NewManager 创建会话管理器。secret 在启动时已由 config 验证为足够长度。
|
|
||||||
func NewManager(secret []byte, cookieSecure bool) *Manager {
|
|
||||||
return &Manager{
|
|
||||||
secret: append([]byte(nil), secret...),
|
|
||||||
cookieSecure: cookieSecure,
|
|
||||||
now: time.Now,
|
|
||||||
random: rand.Reader,
|
|
||||||
sessions: make(map[string]session),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Ensure 返回当前有效会话;不存在或过期时签发匿名会话,以保护登录表单本身的 POST。
|
|
||||||
func (manager *Manager) Ensure(writer http.ResponseWriter, request *http.Request) (csrfToken string, authenticated bool) {
|
|
||||||
if id, current, ok := manager.current(request); ok {
|
|
||||||
return current.csrfToken, current.authenticated
|
|
||||||
} else if id != "" {
|
|
||||||
manager.delete(id)
|
|
||||||
}
|
|
||||||
|
|
||||||
id, current := manager.create(false)
|
|
||||||
manager.writeCookie(writer, id, current.expiresAt)
|
|
||||||
return current.csrfToken, false
|
|
||||||
}
|
|
||||||
|
|
||||||
// VerifyCSRF 只接受当前未过期会话中以恒定时间比较匹配的 token。
|
|
||||||
func (manager *Manager) VerifyCSRF(request *http.Request, token string) (authenticated bool, ok bool) {
|
|
||||||
_, current, found := manager.current(request)
|
|
||||||
if !found || token == "" {
|
|
||||||
return false, false
|
|
||||||
}
|
|
||||||
|
|
||||||
if subtle.ConstantTimeCompare([]byte(current.csrfToken), []byte(token)) != 1 {
|
|
||||||
return false, false
|
|
||||||
}
|
|
||||||
|
|
||||||
return current.authenticated, true
|
|
||||||
}
|
|
||||||
|
|
||||||
// RotateAuthenticated 在登录成功后撤销旧会话并签发全新认证会话,避免 session fixation 与 CSRF 复用。
|
|
||||||
func (manager *Manager) RotateAuthenticated(writer http.ResponseWriter, request *http.Request) string {
|
|
||||||
if id, _, ok := manager.current(request); ok {
|
|
||||||
manager.delete(id)
|
|
||||||
}
|
|
||||||
|
|
||||||
id, current := manager.create(true)
|
|
||||||
manager.writeCookie(writer, id, current.expiresAt)
|
|
||||||
return current.csrfToken
|
|
||||||
}
|
|
||||||
|
|
||||||
// Logout 撤销当前会话并立即清除浏览器 cookie。
|
|
||||||
func (manager *Manager) Logout(writer http.ResponseWriter, request *http.Request) {
|
|
||||||
if id, _, ok := manager.current(request); ok {
|
|
||||||
manager.delete(id)
|
|
||||||
}
|
|
||||||
http.SetCookie(writer, &http.Cookie{
|
|
||||||
Name: CookieName,
|
|
||||||
Value: "",
|
|
||||||
Path: "/",
|
|
||||||
MaxAge: -1,
|
|
||||||
HttpOnly: true,
|
|
||||||
Secure: manager.cookieSecure,
|
|
||||||
SameSite: http.SameSiteLaxMode,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
func (manager *Manager) current(request *http.Request) (string, session, bool) {
|
|
||||||
cookie, err := request.Cookie(CookieName)
|
|
||||||
if err != nil {
|
|
||||||
return "", session{}, false
|
|
||||||
}
|
|
||||||
|
|
||||||
id, expiresAt, ok := manager.verifyCookie(cookie.Value)
|
|
||||||
if !ok || !manager.now().Before(expiresAt) {
|
|
||||||
return id, session{}, false
|
|
||||||
}
|
|
||||||
|
|
||||||
manager.mu.Lock()
|
|
||||||
defer manager.mu.Unlock()
|
|
||||||
current, found := manager.sessions[id]
|
|
||||||
if !found || !manager.now().Before(current.expiresAt) {
|
|
||||||
return id, session{}, false
|
|
||||||
}
|
|
||||||
|
|
||||||
return id, current, true
|
|
||||||
}
|
|
||||||
|
|
||||||
func (manager *Manager) create(authenticated bool) (string, session) {
|
|
||||||
id := manager.randomToken()
|
|
||||||
current := session{
|
|
||||||
csrfToken: manager.randomToken(),
|
|
||||||
authenticated: authenticated,
|
|
||||||
expiresAt: manager.now().Add(SessionLifetime),
|
|
||||||
}
|
|
||||||
|
|
||||||
manager.mu.Lock()
|
|
||||||
manager.sessions[id] = current
|
|
||||||
manager.mu.Unlock()
|
|
||||||
return id, current
|
|
||||||
}
|
|
||||||
|
|
||||||
func (manager *Manager) delete(id string) {
|
|
||||||
manager.mu.Lock()
|
|
||||||
delete(manager.sessions, id)
|
|
||||||
manager.mu.Unlock()
|
|
||||||
}
|
|
||||||
|
|
||||||
func (manager *Manager) randomToken() string {
|
|
||||||
bytes := make([]byte, csrfTokenByteSize)
|
|
||||||
if _, err := io.ReadFull(manager.random, bytes); err != nil {
|
|
||||||
panic("crypto/rand failed while creating a session token")
|
|
||||||
}
|
|
||||||
return base64.RawURLEncoding.EncodeToString(bytes)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (manager *Manager) writeCookie(writer http.ResponseWriter, id string, expiresAt time.Time) {
|
|
||||||
http.SetCookie(writer, &http.Cookie{
|
|
||||||
Name: CookieName,
|
|
||||||
Value: manager.signCookie(id, expiresAt),
|
|
||||||
Path: "/",
|
|
||||||
MaxAge: int(expiresAt.Sub(manager.now()).Seconds()),
|
|
||||||
Expires: expiresAt,
|
|
||||||
HttpOnly: true,
|
|
||||||
Secure: manager.cookieSecure,
|
|
||||||
SameSite: http.SameSiteLaxMode,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
func (manager *Manager) signCookie(id string, expiresAt time.Time) string {
|
|
||||||
payload := id + "." + strconv.FormatInt(expiresAt.Unix(), 10)
|
|
||||||
mac := hmac.New(sha256.New, manager.secret)
|
|
||||||
_, _ = mac.Write([]byte(payload))
|
|
||||||
return payload + "." + base64.RawURLEncoding.EncodeToString(mac.Sum(nil))
|
|
||||||
}
|
|
||||||
|
|
||||||
func (manager *Manager) verifyCookie(value string) (string, time.Time, bool) {
|
|
||||||
parts := strings.Split(value, ".")
|
|
||||||
if len(parts) != 3 || parts[0] == "" {
|
|
||||||
return "", time.Time{}, false
|
|
||||||
}
|
|
||||||
|
|
||||||
expiresUnix, err := strconv.ParseInt(parts[1], 10, 64)
|
|
||||||
if err != nil {
|
|
||||||
return "", time.Time{}, false
|
|
||||||
}
|
|
||||||
provided, err := base64.RawURLEncoding.DecodeString(parts[2])
|
|
||||||
if err != nil {
|
|
||||||
return "", time.Time{}, false
|
|
||||||
}
|
|
||||||
|
|
||||||
payload := parts[0] + "." + parts[1]
|
|
||||||
mac := hmac.New(sha256.New, manager.secret)
|
|
||||||
_, _ = mac.Write([]byte(payload))
|
|
||||||
if !hmac.Equal(provided, mac.Sum(nil)) {
|
|
||||||
return "", time.Time{}, false
|
|
||||||
}
|
|
||||||
|
|
||||||
return parts[0], time.Unix(expiresUnix, 0), true
|
|
||||||
}
|
|
||||||
@@ -1,49 +0,0 @@
|
|||||||
package auth
|
|
||||||
|
|
||||||
import (
|
|
||||||
"net/http"
|
|
||||||
"net/http/httptest"
|
|
||||||
"strings"
|
|
||||||
"testing"
|
|
||||||
"time"
|
|
||||||
)
|
|
||||||
|
|
||||||
func TestManagerRejectsTamperedAndExpiredCookies(t *testing.T) {
|
|
||||||
manager := NewManager([]byte(strings.Repeat("s", 32)), true)
|
|
||||||
request := httptest.NewRequest(http.MethodGet, "/login", nil)
|
|
||||||
response := httptest.NewRecorder()
|
|
||||||
csrf, authenticated := manager.Ensure(response, request)
|
|
||||||
if csrf == "" || authenticated {
|
|
||||||
t.Fatalf("Ensure = (%q, %t), want anonymous CSRF session", csrf, authenticated)
|
|
||||||
}
|
|
||||||
cookie := response.Result().Cookies()[0]
|
|
||||||
if !cookie.HttpOnly || !cookie.Secure || cookie.SameSite != http.SameSiteLaxMode || cookie.Path != "/" {
|
|
||||||
t.Fatalf("session cookie is missing security attributes: %#v", cookie)
|
|
||||||
}
|
|
||||||
|
|
||||||
tampered := *cookie
|
|
||||||
tampered.Value = flipCookieValue(t, cookie.Value)
|
|
||||||
tamperedRequest := httptest.NewRequest(http.MethodPost, "/login", nil)
|
|
||||||
tamperedRequest.AddCookie(&tampered)
|
|
||||||
if _, ok := manager.VerifyCSRF(tamperedRequest, csrf); ok {
|
|
||||||
t.Fatal("tampered signed cookie passed CSRF verification")
|
|
||||||
}
|
|
||||||
|
|
||||||
manager.now = func() time.Time { return time.Now().Add(9 * time.Hour) }
|
|
||||||
expiredRequest := httptest.NewRequest(http.MethodPost, "/login", nil)
|
|
||||||
expiredRequest.AddCookie(cookie)
|
|
||||||
if _, ok := manager.VerifyCSRF(expiredRequest, csrf); ok {
|
|
||||||
t.Fatal("expired cookie passed CSRF verification")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func flipCookieValue(t *testing.T, value string) string {
|
|
||||||
t.Helper()
|
|
||||||
if value == "" {
|
|
||||||
t.Fatal("cannot tamper with an empty cookie")
|
|
||||||
}
|
|
||||||
if value[0] == 'A' {
|
|
||||||
return "B" + value[1:]
|
|
||||||
}
|
|
||||||
return "A" + value[1:]
|
|
||||||
}
|
|
||||||
@@ -1,91 +0,0 @@
|
|||||||
// Package config 读取采购服务的启动配置。凭据只允许来自显式环境变量,避免把秘密写入代码或仓库。
|
|
||||||
package config
|
|
||||||
|
|
||||||
import (
|
|
||||||
"errors"
|
|
||||||
"fmt"
|
|
||||||
"os"
|
|
||||||
"strings"
|
|
||||||
|
|
||||||
"golang.org/x/crypto/bcrypt"
|
|
||||||
)
|
|
||||||
|
|
||||||
const (
|
|
||||||
adminUsernameEnv = "CMBUYER_ADMIN_USERNAME"
|
|
||||||
adminPasswordBcryptEnv = "CMBUYER_ADMIN_PASSWORD_BCRYPT"
|
|
||||||
sessionSecretEnv = "CMBUYER_SESSION_SECRET"
|
|
||||||
cookieSecureEnv = "CMBUYER_COOKIE_SECURE"
|
|
||||||
databaseSourceEnv = "CMBUYER_DATABASE_SOURCE"
|
|
||||||
minimumSecretLength = 32
|
|
||||||
)
|
|
||||||
|
|
||||||
// Config 是启动采购服务所需的最小安全配置。
|
|
||||||
type Config struct {
|
|
||||||
AdminUsername string
|
|
||||||
AdminPasswordBcrypt string
|
|
||||||
SessionSecret []byte
|
|
||||||
CookieSecure bool
|
|
||||||
DatabaseSource string
|
|
||||||
}
|
|
||||||
|
|
||||||
// LoadFromEnv 从进程环境读取配置。错误只指出缺失或非法的变量名,绝不回显秘密。
|
|
||||||
func LoadFromEnv() (Config, error) {
|
|
||||||
return Load(os.LookupEnv)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Load 使用 lookup 读取配置,以便在不污染进程环境的情况下测试启动边界。
|
|
||||||
func Load(lookup func(string) (string, bool)) (Config, error) {
|
|
||||||
username, err := required(lookup, adminUsernameEnv)
|
|
||||||
if err != nil {
|
|
||||||
return Config{}, err
|
|
||||||
}
|
|
||||||
|
|
||||||
passwordHash, err := required(lookup, adminPasswordBcryptEnv)
|
|
||||||
if err != nil {
|
|
||||||
return Config{}, err
|
|
||||||
}
|
|
||||||
if _, err := bcrypt.Cost([]byte(passwordHash)); err != nil {
|
|
||||||
return Config{}, fmt.Errorf("%s is not a valid bcrypt hash", adminPasswordBcryptEnv)
|
|
||||||
}
|
|
||||||
|
|
||||||
secret, err := required(lookup, sessionSecretEnv)
|
|
||||||
if err != nil {
|
|
||||||
return Config{}, err
|
|
||||||
}
|
|
||||||
if len([]byte(secret)) < minimumSecretLength {
|
|
||||||
return Config{}, fmt.Errorf("%s must be at least %d bytes", sessionSecretEnv, minimumSecretLength)
|
|
||||||
}
|
|
||||||
|
|
||||||
cookieSecure := false
|
|
||||||
if value, present := lookup(cookieSecureEnv); present {
|
|
||||||
switch value {
|
|
||||||
case "true":
|
|
||||||
cookieSecure = true
|
|
||||||
case "false":
|
|
||||||
cookieSecure = false
|
|
||||||
default:
|
|
||||||
return Config{}, fmt.Errorf("%s must be exactly true or false", cookieSecureEnv)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
databaseSource, err := required(lookup, databaseSourceEnv)
|
|
||||||
if err != nil {
|
|
||||||
return Config{}, err
|
|
||||||
}
|
|
||||||
|
|
||||||
return Config{
|
|
||||||
AdminUsername: username,
|
|
||||||
AdminPasswordBcrypt: passwordHash,
|
|
||||||
SessionSecret: []byte(secret),
|
|
||||||
CookieSecure: cookieSecure,
|
|
||||||
DatabaseSource: databaseSource,
|
|
||||||
}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func required(lookup func(string) (string, bool), name string) (string, error) {
|
|
||||||
value, present := lookup(name)
|
|
||||||
if !present || strings.TrimSpace(value) == "" {
|
|
||||||
return "", errors.New(name + " must be set")
|
|
||||||
}
|
|
||||||
|
|
||||||
return value, nil
|
|
||||||
}
|
|
||||||
@@ -1,85 +0,0 @@
|
|||||||
package config_test
|
|
||||||
|
|
||||||
import (
|
|
||||||
"strings"
|
|
||||||
"testing"
|
|
||||||
|
|
||||||
"cmbuyer/admin/internal/config"
|
|
||||||
|
|
||||||
"golang.org/x/crypto/bcrypt"
|
|
||||||
)
|
|
||||||
|
|
||||||
func TestLoad(t *testing.T) {
|
|
||||||
hash, err := bcrypt.GenerateFromPassword([]byte("test-password"), bcrypt.MinCost)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("generate bcrypt hash: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
values := map[string]string{
|
|
||||||
"CMBUYER_ADMIN_USERNAME": "admin",
|
|
||||||
"CMBUYER_ADMIN_PASSWORD_BCRYPT": string(hash),
|
|
||||||
"CMBUYER_SESSION_SECRET": strings.Repeat("s", 32),
|
|
||||||
"CMBUYER_COOKIE_SECURE": "true",
|
|
||||||
"CMBUYER_DATABASE_SOURCE": ":memory:",
|
|
||||||
}
|
|
||||||
|
|
||||||
got, err := config.Load(lookup(values))
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("Load: %v", err)
|
|
||||||
}
|
|
||||||
if got.AdminUsername != "admin" || !got.CookieSecure {
|
|
||||||
t.Fatalf("Load returned unexpected public configuration: %#v", got)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestLoadRejectsMissingOrInvalidConfiguration(t *testing.T) {
|
|
||||||
hash, err := bcrypt.GenerateFromPassword([]byte("test-password"), bcrypt.MinCost)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("generate bcrypt hash: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
base := map[string]string{
|
|
||||||
"CMBUYER_ADMIN_USERNAME": "admin",
|
|
||||||
"CMBUYER_ADMIN_PASSWORD_BCRYPT": string(hash),
|
|
||||||
"CMBUYER_SESSION_SECRET": strings.Repeat("s", 32),
|
|
||||||
"CMBUYER_DATABASE_SOURCE": ":memory:",
|
|
||||||
}
|
|
||||||
|
|
||||||
tests := []struct {
|
|
||||||
name string
|
|
||||||
mutate func(map[string]string)
|
|
||||||
want string
|
|
||||||
}{
|
|
||||||
{"missing username", func(values map[string]string) { delete(values, "CMBUYER_ADMIN_USERNAME") }, "CMBUYER_ADMIN_USERNAME"},
|
|
||||||
{"invalid bcrypt", func(values map[string]string) { values["CMBUYER_ADMIN_PASSWORD_BCRYPT"] = "not-a-bcrypt-hash" }, "CMBUYER_ADMIN_PASSWORD_BCRYPT"},
|
|
||||||
{"short secret", func(values map[string]string) { values["CMBUYER_SESSION_SECRET"] = "short" }, "CMBUYER_SESSION_SECRET"},
|
|
||||||
{"invalid secure flag", func(values map[string]string) { values["CMBUYER_COOKIE_SECURE"] = "1" }, "CMBUYER_COOKIE_SECURE"},
|
|
||||||
{"missing database", func(values map[string]string) { delete(values, "CMBUYER_DATABASE_SOURCE") }, "CMBUYER_DATABASE_SOURCE"},
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, test := range tests {
|
|
||||||
t.Run(test.name, func(t *testing.T) {
|
|
||||||
values := copyValues(base)
|
|
||||||
test.mutate(values)
|
|
||||||
_, err := config.Load(lookup(values))
|
|
||||||
if err == nil || !strings.Contains(err.Error(), test.want) {
|
|
||||||
t.Fatalf("Load error = %v, want mention of %s", err, test.want)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func lookup(values map[string]string) func(string) (string, bool) {
|
|
||||||
return func(key string) (string, bool) {
|
|
||||||
value, ok := values[key]
|
|
||||||
return value, ok
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func copyValues(values map[string]string) map[string]string {
|
|
||||||
copy := make(map[string]string, len(values))
|
|
||||||
for key, value := range values {
|
|
||||||
copy[key] = value
|
|
||||||
}
|
|
||||||
return copy
|
|
||||||
}
|
|
||||||
@@ -2,293 +2,18 @@
|
|||||||
package server
|
package server
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"crypto/subtle"
|
|
||||||
"errors"
|
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/url"
|
|
||||||
"strings"
|
|
||||||
|
|
||||||
"cmbuyer/admin/internal/auth"
|
|
||||||
"cmbuyer/admin/internal/tasks"
|
|
||||||
"cmbuyer/admin/internal/transport/webui"
|
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
"golang.org/x/crypto/bcrypt"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
const maxFormBytes = 8 << 10
|
|
||||||
|
|
||||||
// Options 是路由层需要的安全依赖。凭据由启动配置注入,不能在路由中设置默认值。
|
|
||||||
type Options struct {
|
|
||||||
AdminUsername string
|
|
||||||
AdminPasswordBcrypt string
|
|
||||||
Sessions *auth.Manager
|
|
||||||
Tasks tasks.Store
|
|
||||||
}
|
|
||||||
|
|
||||||
// NewRouter 返回当前服务范围内的完整 HTTP 路由。
|
// NewRouter 返回当前服务范围内的完整 HTTP 路由。
|
||||||
func NewRouter(options Options) (*gin.Engine, error) {
|
func NewRouter() *gin.Engine {
|
||||||
if options.AdminUsername == "" || options.AdminPasswordBcrypt == "" || options.Sessions == nil || options.Tasks == nil {
|
|
||||||
return nil, errors.New("server authentication options are incomplete")
|
|
||||||
}
|
|
||||||
|
|
||||||
router := gin.New()
|
router := gin.New()
|
||||||
router.Use(gin.Recovery())
|
|
||||||
router.Use(securityHeaders())
|
|
||||||
router.GET("/healthz", healthz)
|
|
||||||
router.GET("/login", loginPage(options))
|
|
||||||
router.POST("/login", login(options))
|
|
||||||
router.POST("/logout", logout(options))
|
|
||||||
router.GET("/tasks", tasksPage(options))
|
|
||||||
router.GET("/tasks/new", newTaskPage(options))
|
|
||||||
router.POST("/tasks", createTask(options))
|
|
||||||
|
|
||||||
return router, nil
|
router.GET("/healthz", func(context *gin.Context) {
|
||||||
}
|
|
||||||
|
|
||||||
func healthz(context *gin.Context) {
|
|
||||||
context.JSON(http.StatusOK, gin.H{"status": "ok"})
|
context.JSON(http.StatusOK, gin.H{"status": "ok"})
|
||||||
}
|
})
|
||||||
|
|
||||||
func securityHeaders() gin.HandlerFunc {
|
return router
|
||||||
return func(context *gin.Context) {
|
|
||||||
context.Header("Cache-Control", "no-store")
|
|
||||||
context.Header("X-Content-Type-Options", "nosniff")
|
|
||||||
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.Next()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func loginPage(options Options) gin.HandlerFunc {
|
|
||||||
return func(context *gin.Context) {
|
|
||||||
csrfToken, authenticated := options.Sessions.Ensure(context.Writer, context.Request)
|
|
||||||
if authenticated {
|
|
||||||
context.Redirect(http.StatusSeeOther, "/tasks")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
renderLogin(context, http.StatusOK, csrfToken, returnTo(context.Query("return_to")), "", "")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func login(options Options) gin.HandlerFunc {
|
|
||||||
return func(context *gin.Context) {
|
|
||||||
if !parseForm(context) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
form := context.Request.PostForm
|
|
||||||
csrfToken := form.Get("csrf_token")
|
|
||||||
returnPath := returnTo(form.Get("return_to"))
|
|
||||||
username := form.Get("username")
|
|
||||||
password := form.Get("password")
|
|
||||||
|
|
||||||
if _, ok := options.Sessions.VerifyCSRF(context.Request, csrfToken); !ok {
|
|
||||||
newCSRF, _ := options.Sessions.Ensure(context.Writer, context.Request)
|
|
||||||
renderLogin(context, http.StatusForbidden, newCSRF, returnPath, "", "请求已过期,请重新登录。")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
usernameMatches := subtle.ConstantTimeCompare([]byte(options.AdminUsername), []byte(username)) == 1
|
|
||||||
passwordMatches := bcrypt.CompareHashAndPassword([]byte(options.AdminPasswordBcrypt), []byte(password)) == nil
|
|
||||||
if !usernameMatches || !passwordMatches {
|
|
||||||
csrf, _ := options.Sessions.Ensure(context.Writer, context.Request)
|
|
||||||
renderLogin(context, http.StatusUnauthorized, csrf, returnPath, "", "账号或密码不正确,请检查后重试。")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
options.Sessions.RotateAuthenticated(context.Writer, context.Request)
|
|
||||||
context.Redirect(http.StatusSeeOther, returnPath)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func logout(options Options) gin.HandlerFunc {
|
|
||||||
return func(context *gin.Context) {
|
|
||||||
if !parseForm(context) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
authenticated, ok := options.Sessions.VerifyCSRF(context.Request, context.Request.PostForm.Get("csrf_token"))
|
|
||||||
if !ok || !authenticated {
|
|
||||||
context.Status(http.StatusForbidden)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
options.Sessions.Logout(context.Writer, context.Request)
|
|
||||||
context.Redirect(http.StatusSeeOther, "/login")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func tasksPage(options Options) gin.HandlerFunc {
|
|
||||||
return func(context *gin.Context) {
|
|
||||||
csrfToken, authenticated := options.Sessions.Ensure(context.Writer, context.Request)
|
|
||||||
if !authenticated {
|
|
||||||
context.Redirect(http.StatusSeeOther, "/login?return_to="+url.QueryEscape(context.Request.URL.RequestURI()))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
drafts, err := options.Tasks.ListDrafts(context.Request.Context())
|
|
||||||
if err != nil {
|
|
||||||
context.Status(http.StatusInternalServerError)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
data := webui.TasksData{CSRFToken: csrfToken, Drafts: drafts}
|
|
||||||
for _, draft := range drafts {
|
|
||||||
if draft.ID == context.Query("created") {
|
|
||||||
data.Success = true
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if context.Query("create") == "1" {
|
|
||||||
form, err := newTaskForm()
|
|
||||||
if err != nil {
|
|
||||||
context.Status(http.StatusInternalServerError)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
data.OpenForm = true
|
|
||||||
data.Form = form
|
|
||||||
data.FocusField = "title"
|
|
||||||
}
|
|
||||||
renderTasks(context, http.StatusOK, data)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func newTaskPage(options Options) gin.HandlerFunc {
|
|
||||||
return func(context *gin.Context) {
|
|
||||||
csrf, authenticated := options.Sessions.Ensure(context.Writer, context.Request)
|
|
||||||
if !authenticated {
|
|
||||||
context.Redirect(http.StatusSeeOther, "/login?return_to=%2Ftasks%2Fnew")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
form, err := newTaskForm()
|
|
||||||
if err != nil {
|
|
||||||
context.Status(http.StatusInternalServerError)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
renderTasks(context, http.StatusOK, webui.TasksData{CSRFToken: csrf, Form: form, FullPage: true, FocusField: "title"})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
func createTask(options Options) gin.HandlerFunc {
|
|
||||||
return func(context *gin.Context) {
|
|
||||||
if !parseForm(context) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
requestForm := context.Request.PostForm
|
|
||||||
authenticated, csrfOK := options.Sessions.VerifyCSRF(context.Request, requestForm.Get("csrf_token"))
|
|
||||||
if !csrfOK || !authenticated {
|
|
||||||
context.Status(http.StatusForbidden)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
form := taskForm(requestForm)
|
|
||||||
draft, validation := tasks.Validate(form)
|
|
||||||
if draft.GoodsID != "" {
|
|
||||||
form.ProductURL = tasks.CanonicalURL(draft.GoodsID)
|
|
||||||
}
|
|
||||||
fullPage := requestForm.Get("form_mode") == "full"
|
|
||||||
if !validation.Valid() {
|
|
||||||
drafts, err := options.Tasks.ListDrafts(context.Request.Context())
|
|
||||||
if err != nil {
|
|
||||||
context.Status(http.StatusInternalServerError)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
renderTasks(context, http.StatusBadRequest, webui.TasksData{CSRFToken: csrfFor(context, options), Drafts: drafts, Form: form, Errors: validation, OpenForm: !fullPage, FullPage: fullPage, FocusField: firstError(validation)})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
created, err := options.Tasks.CreateDraft(context.Request.Context(), draft)
|
|
||||||
if err != nil {
|
|
||||||
if errors.Is(err, tasks.ErrCreateKeyConflict) {
|
|
||||||
validation["create_key"] = "该创建请求已用于另一条任务,请重新打开表单。"
|
|
||||||
drafts, listErr := options.Tasks.ListDrafts(context.Request.Context())
|
|
||||||
if listErr != nil {
|
|
||||||
context.Status(http.StatusInternalServerError)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
renderTasks(context, http.StatusConflict, webui.TasksData{CSRFToken: csrfFor(context, options), Drafts: drafts, Form: form, Errors: validation, OpenForm: !fullPage, FullPage: fullPage, FocusField: firstError(validation)})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
context.Status(http.StatusInternalServerError)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
context.Redirect(http.StatusSeeOther, "/tasks?created="+url.QueryEscape(created.ID))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func newTaskForm() (tasks.Form, error) {
|
|
||||||
key, err := tasks.NewCreateKey()
|
|
||||||
if err != nil {
|
|
||||||
return tasks.Form{}, err
|
|
||||||
}
|
|
||||||
return tasks.Form{CreateKey: key}, nil
|
|
||||||
}
|
|
||||||
func taskForm(form url.Values) tasks.Form {
|
|
||||||
return tasks.Form{CreateKey: form.Get("create_key"), Title: form.Get("title"), ProductURL: form.Get("product_url"), SKUColor: form.Get("sku_color"), SKUSize: form.Get("sku_size"), Quantity: form.Get("quantity"), MaxTotalPrice: form.Get("max_total_price")}
|
|
||||||
}
|
|
||||||
func csrfFor(context *gin.Context, options Options) string {
|
|
||||||
csrf, _ := options.Sessions.Ensure(context.Writer, context.Request)
|
|
||||||
return csrf
|
|
||||||
}
|
|
||||||
func renderTasks(context *gin.Context, status int, data webui.TasksData) {
|
|
||||||
context.Header("Content-Type", "text/html; charset=utf-8")
|
|
||||||
context.Status(status)
|
|
||||||
if err := webui.RenderTasks(context.Writer, data); err != nil {
|
|
||||||
_ = context.Error(err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func renderLogin(context *gin.Context, status int, csrfToken, returnPath, username, message string) {
|
|
||||||
context.Header("Content-Type", "text/html; charset=utf-8")
|
|
||||||
context.Status(status)
|
|
||||||
if err := webui.RenderLogin(context.Writer, webui.LoginData{
|
|
||||||
CSRFToken: csrfToken,
|
|
||||||
ReturnTo: returnPath,
|
|
||||||
Username: username,
|
|
||||||
Error: message,
|
|
||||||
}); err != nil {
|
|
||||||
_ = context.Error(err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func parseForm(context *gin.Context) bool {
|
|
||||||
context.Request.Body = http.MaxBytesReader(context.Writer, context.Request.Body, maxFormBytes)
|
|
||||||
if err := context.Request.ParseForm(); err != nil {
|
|
||||||
var tooLarge *http.MaxBytesError
|
|
||||||
if errors.As(err, &tooLarge) {
|
|
||||||
context.Status(http.StatusRequestEntityTooLarge)
|
|
||||||
} else {
|
|
||||||
context.Status(http.StatusBadRequest)
|
|
||||||
}
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|
||||||
func firstError(validation tasks.Errors) string {
|
|
||||||
for _, field := range []string{"title", "product_url", "sku_color", "sku_size", "quantity", "max_total_price"} {
|
|
||||||
if _, ok := validation[field]; ok {
|
|
||||||
return field
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return "title"
|
|
||||||
}
|
|
||||||
|
|
||||||
func returnTo(value string) string {
|
|
||||||
if value == "/tasks" || strings.HasPrefix(value, "/tasks/") || strings.HasPrefix(value, "/tasks?") {
|
|
||||||
if strings.Contains(value, "\\") || strings.Contains(value, "%") || strings.HasPrefix(value, "//") {
|
|
||||||
return "/tasks"
|
|
||||||
}
|
|
||||||
parsed, err := url.ParseRequestURI(value)
|
|
||||||
if err == nil && parsed.IsAbs() == false && parsed.Host == "" && hasSafeTaskPath(parsed.Path) {
|
|
||||||
return value
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return "/tasks"
|
|
||||||
}
|
|
||||||
|
|
||||||
func hasSafeTaskPath(path string) bool {
|
|
||||||
for _, segment := range strings.Split(path, "/") {
|
|
||||||
if segment == "." || segment == ".." {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return true
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,481 +1,28 @@
|
|||||||
package server_test
|
package server_test
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/http/httptest"
|
"net/http/httptest"
|
||||||
"net/url"
|
|
||||||
"regexp"
|
|
||||||
"strings"
|
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"cmbuyer/admin/internal/auth"
|
|
||||||
"cmbuyer/admin/internal/server"
|
"cmbuyer/admin/internal/server"
|
||||||
"cmbuyer/admin/internal/tasks"
|
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
|
||||||
"golang.org/x/crypto/bcrypt"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
var csrfPattern = regexp.MustCompile(`name="csrf_token" value="([^"]+)"`)
|
func TestHealthz(t *testing.T) {
|
||||||
var createKeyPattern = regexp.MustCompile(`name="create_key" value="([^"]+)"`)
|
|
||||||
|
|
||||||
func TestHealthzIsPublic(t *testing.T) {
|
|
||||||
router, _ := newRouter(t)
|
|
||||||
request := httptest.NewRequest(http.MethodGet, "/healthz", nil)
|
request := httptest.NewRequest(http.MethodGet, "/healthz", nil)
|
||||||
response := httptest.NewRecorder()
|
response := httptest.NewRecorder()
|
||||||
|
|
||||||
router.ServeHTTP(response, request)
|
server.NewRouter().ServeHTTP(response, request)
|
||||||
|
|
||||||
if response.Code != http.StatusOK {
|
if response.Code != http.StatusOK {
|
||||||
t.Fatalf("healthz status = %d, want %d", response.Code, http.StatusOK)
|
t.Fatalf("healthz status = %d, want %d", response.Code, http.StatusOK)
|
||||||
}
|
}
|
||||||
|
|
||||||
if contentType := response.Header().Get("Content-Type"); contentType != "application/json; charset=utf-8" {
|
if contentType := response.Header().Get("Content-Type"); contentType != "application/json; charset=utf-8" {
|
||||||
t.Fatalf("healthz content type = %q, want application/json; charset=utf-8", contentType)
|
t.Fatalf("healthz content type = %q, want application/json; charset=utf-8", contentType)
|
||||||
}
|
}
|
||||||
|
|
||||||
if body := response.Body.String(); body != "{\"status\":\"ok\"}" {
|
if body := response.Body.String(); body != "{\"status\":\"ok\"}" {
|
||||||
t.Fatalf("healthz body = %q, want {\"status\":\"ok\"}", body)
|
t.Fatalf("healthz body = %q, want {\"status\":\"ok\"}", body)
|
||||||
}
|
}
|
||||||
assertSecurityHeaders(t, response)
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestTasksRequiresLoginAndBlocksOpenRedirects(t *testing.T) {
|
|
||||||
router, _ := newRouter(t)
|
|
||||||
|
|
||||||
tasks := serve(router, http.MethodGet, "/tasks", nil, nil)
|
|
||||||
if tasks.Code != http.StatusSeeOther {
|
|
||||||
t.Fatalf("GET /tasks status = %d, want %d", tasks.Code, http.StatusSeeOther)
|
|
||||||
}
|
|
||||||
if location := tasks.Header().Get("Location"); location != "/login?return_to=%2Ftasks" {
|
|
||||||
t.Fatalf("GET /tasks location = %q, want login return path", location)
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, target := range []string{"https://example.invalid", "//example.invalid", `\\example.invalid`, "/other", "/tasks/..", "/tasks/../other", "/tasks/%2e%2e", "%2F%2Fevil.invalid", "%252F%252Fevil.invalid"} {
|
|
||||||
response := serve(router, http.MethodGet, "/login?return_to="+url.QueryEscape(target), nil, nil)
|
|
||||||
if response.Code != http.StatusOK {
|
|
||||||
t.Fatalf("GET /login return_to=%q status = %d, want 200", target, response.Code)
|
|
||||||
}
|
|
||||||
if strings.Contains(response.Body.String(), target) || !strings.Contains(response.Body.String(), `name="return_to" value="/tasks"`) {
|
|
||||||
t.Fatalf("GET /login accepted unsafe return_to %q", target)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
encodedPath := serve(router, http.MethodGet, "/login?return_to=%2Ftasks%252F..", nil, nil)
|
|
||||||
if !strings.Contains(encodedPath.Body.String(), `name="return_to" value="/tasks"`) {
|
|
||||||
t.Fatal("encoded parent path was accepted as return_to")
|
|
||||||
}
|
|
||||||
encodedQuery := serve(router, http.MethodGet, "/login?return_to=%2Ftasks%3Fnext%3D%252Ftasks%252F..", nil, nil)
|
|
||||||
if !strings.Contains(encodedQuery.Body.String(), `name="return_to" value="/tasks"`) {
|
|
||||||
t.Fatal("encoded query bypass was accepted as return_to")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestLoginRotatesSessionAndCSRF(t *testing.T) {
|
|
||||||
router, _ := newRouter(t)
|
|
||||||
initial := serve(router, http.MethodGet, "/login?return_to=%2Ftasks%3Fview%3Dmine", nil, nil)
|
|
||||||
oldCookie := sessionCookie(t, initial)
|
|
||||||
oldCSRF := csrfToken(t, initial.Body.String())
|
|
||||||
|
|
||||||
login := serve(router, http.MethodPost, "/login", url.Values{
|
|
||||||
"csrf_token": {oldCSRF},
|
|
||||||
"return_to": {"/tasks?view=mine"},
|
|
||||||
"username": {"admin"},
|
|
||||||
"password": {"test-password"},
|
|
||||||
}, oldCookie)
|
|
||||||
if login.Code != http.StatusSeeOther || login.Header().Get("Location") != "/tasks?view=mine" {
|
|
||||||
t.Fatalf("successful login = (%d, %q), want 303 /tasks?view=mine", login.Code, login.Header().Get("Location"))
|
|
||||||
}
|
|
||||||
newCookie := sessionCookie(t, login)
|
|
||||||
if newCookie.Value == oldCookie.Value {
|
|
||||||
t.Fatal("successful login reused the anonymous session cookie")
|
|
||||||
}
|
|
||||||
|
|
||||||
tasks := serve(router, http.MethodGet, "/tasks", nil, newCookie)
|
|
||||||
if tasks.Code != http.StatusOK {
|
|
||||||
t.Fatalf("GET /tasks after login status = %d, want 200", tasks.Code)
|
|
||||||
}
|
|
||||||
if newCSRF := csrfToken(t, tasks.Body.String()); newCSRF == oldCSRF {
|
|
||||||
t.Fatal("successful login reused the anonymous CSRF token")
|
|
||||||
}
|
|
||||||
for _, forbidden := range []string{"建单", "试选", "拼多多", "规格", "单价", "证据"} {
|
|
||||||
if strings.Contains(tasks.Body.String(), forbidden) {
|
|
||||||
t.Fatalf("task shell must not expose deferred feature content %q", forbidden)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
assertSecurityHeaders(t, initial)
|
|
||||||
assertSecurityHeaders(t, tasks)
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestLoginPageIncludesAccessibleFormBasics(t *testing.T) {
|
|
||||||
router, _ := newRouter(t)
|
|
||||||
page := serve(router, http.MethodGet, "/login", nil, nil)
|
|
||||||
body := page.Body.String()
|
|
||||||
for _, want := range []string{
|
|
||||||
`<label for="username">`,
|
|
||||||
`<label for="password">`,
|
|
||||||
`autocomplete="username"`,
|
|
||||||
`autocomplete="current-password"`,
|
|
||||||
`min-height:44px`,
|
|
||||||
`:focus-visible`,
|
|
||||||
`prefers-reduced-motion`,
|
|
||||||
} {
|
|
||||||
if !strings.Contains(body, want) {
|
|
||||||
t.Fatalf("login page is missing %q", want)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if strings.Contains(body, "http://") || strings.Contains(body, "https://") || strings.Contains(body, "<script") {
|
|
||||||
t.Fatal("login page must not load external resources or require client-side JavaScript")
|
|
||||||
}
|
|
||||||
|
|
||||||
failure := serve(router, http.MethodPost, "/login", url.Values{
|
|
||||||
"csrf_token": {csrfToken(t, body)},
|
|
||||||
"username": {"admin"},
|
|
||||||
"password": {"wrong"},
|
|
||||||
}, sessionCookie(t, page))
|
|
||||||
if !strings.Contains(failure.Body.String(), `role="alert"`) {
|
|
||||||
t.Fatal("login failure must announce its error")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestLoginCSRFAndCredentialFailuresAreSafe(t *testing.T) {
|
|
||||||
router, _ := newRouter(t)
|
|
||||||
page := serve(router, http.MethodGet, "/login", nil, nil)
|
|
||||||
cookie := sessionCookie(t, page)
|
|
||||||
|
|
||||||
withoutCSRF := serve(router, http.MethodPost, "/login", url.Values{
|
|
||||||
"username": {"admin"},
|
|
||||||
"password": {"test-password"},
|
|
||||||
}, cookie)
|
|
||||||
if withoutCSRF.Code != http.StatusForbidden || !strings.Contains(withoutCSRF.Body.String(), "请求已过期") {
|
|
||||||
t.Fatalf("login without CSRF = (%d, %q), want rejected form", withoutCSRF.Code, withoutCSRF.Body.String())
|
|
||||||
}
|
|
||||||
|
|
||||||
page = serve(router, http.MethodGet, "/login", nil, cookie)
|
|
||||||
badCredentials := serve(router, http.MethodPost, "/login", url.Values{
|
|
||||||
"csrf_token": {csrfToken(t, page.Body.String())},
|
|
||||||
"username": {"unknown"},
|
|
||||||
"password": {"wrong"},
|
|
||||||
}, cookie)
|
|
||||||
if badCredentials.Code != http.StatusUnauthorized {
|
|
||||||
t.Fatalf("login with invalid credentials status = %d, want 401", badCredentials.Code)
|
|
||||||
}
|
|
||||||
if body := badCredentials.Body.String(); !strings.Contains(body, "账号或密码不正确") || strings.Contains(body, "unknown") {
|
|
||||||
t.Fatalf("invalid login leaked account detail: %q", body)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestTamperedCookieCannotAccessTasks(t *testing.T) {
|
|
||||||
router, _ := newRouter(t)
|
|
||||||
page := serve(router, http.MethodGet, "/login", nil, nil)
|
|
||||||
cookie := sessionCookie(t, page)
|
|
||||||
|
|
||||||
tampered := *cookie
|
|
||||||
tampered.Value = flipCookieValue(t, cookie.Value)
|
|
||||||
response := serve(router, http.MethodGet, "/tasks", nil, &tampered)
|
|
||||||
if response.Code != http.StatusSeeOther {
|
|
||||||
t.Fatalf("tampered cookie status = %d, want 303", response.Code)
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestTaskCreationRendersSharedFormsAndPersistsOnlyDraft(t *testing.T) {
|
|
||||||
router, _ := newRouter(t)
|
|
||||||
cookie := authenticate(t, router)
|
|
||||||
|
|
||||||
modal := serve(router, http.MethodGet, "/tasks?create=1", nil, cookie)
|
|
||||||
if modal.Code != http.StatusOK {
|
|
||||||
t.Fatalf("GET dialog form status = %d, want 200", modal.Code)
|
|
||||||
}
|
|
||||||
fullPage := serve(router, http.MethodGet, "/tasks/new", nil, cookie)
|
|
||||||
if fullPage.Code != http.StatusOK {
|
|
||||||
t.Fatalf("GET full form status = %d, want 200", fullPage.Code)
|
|
||||||
}
|
|
||||||
for _, want := range []string{`<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`} {
|
|
||||||
if !strings.Contains(modal.Body.String(), want) {
|
|
||||||
t.Fatalf("dialog form is missing %q", want)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
for _, want := range []string{`name="title"`, `name="product_url"`, `name="sku_color"`, `name="sku_size"`, `name="quantity"`, `name="max_total_price"`, `name="form_mode" value="full"`} {
|
|
||||||
if !strings.Contains(fullPage.Body.String(), want) {
|
|
||||||
t.Fatalf("full-page form is missing %q", want)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
invalid := serve(router, http.MethodPost, "/tasks", url.Values{
|
|
||||||
"csrf_token": {csrfToken(t, modal.Body.String())},
|
|
||||||
"create_key": {createKey(t, modal.Body.String())},
|
|
||||||
"title": {`<script>alert(1)</script>`},
|
|
||||||
"product_url": {"https://mobile.yangkeduo.com/goods.html?goods_id=937122477375&uin=discard"},
|
|
||||||
"sku_color": {"black"},
|
|
||||||
"sku_size": {"M"},
|
|
||||||
"quantity": {"0"},
|
|
||||||
"max_total_price": {"12.80"},
|
|
||||||
"form_mode": {"dialog"},
|
|
||||||
}, cookie)
|
|
||||||
if invalid.Code != http.StatusBadRequest || !strings.Contains(invalid.Body.String(), `<dialog open`) || !strings.Contains(invalid.Body.String(), "数量必须是正整数") || !strings.Contains(invalid.Body.String(), `role="alert"`) || !strings.Contains(invalid.Body.String(), `href="#quantity"`) || !strings.Contains(invalid.Body.String(), `aria-describedby="quantity-error"`) || !strings.Contains(invalid.Body.String(), `autofocus`) {
|
|
||||||
t.Fatalf("invalid create = (%d, %q), want dialog validation response", invalid.Code, invalid.Body.String())
|
|
||||||
}
|
|
||||||
if strings.Contains(invalid.Body.String(), `<script>alert(1)</script>`) || !strings.Contains(invalid.Body.String(), `<script>alert(1)</script>`) {
|
|
||||||
t.Fatalf("invalid create did not safely preserve title: %q", invalid.Body.String())
|
|
||||||
}
|
|
||||||
if strings.Contains(invalid.Body.String(), "uin=discard") || !strings.Contains(invalid.Body.String(), `value="https://mobile.yangkeduo.com/goods.html?goods_id=937122477375"`) {
|
|
||||||
t.Fatalf("invalid create did not canonicalize product URL: %q", invalid.Body.String())
|
|
||||||
}
|
|
||||||
|
|
||||||
createPage := serve(router, http.MethodGet, "/tasks?create=1", nil, cookie)
|
|
||||||
key := createKey(t, createPage.Body.String())
|
|
||||||
created := serve(router, http.MethodPost, "/tasks", url.Values{
|
|
||||||
"csrf_token": {csrfToken(t, createPage.Body.String())},
|
|
||||||
"create_key": {key},
|
|
||||||
"title": {"<b>夏季上衣</b>"},
|
|
||||||
"product_url": {"https://mobile.yangkeduo.com/goods.html?goods_id=937122477375&utm_source=discard"},
|
|
||||||
"sku_color": {"black"},
|
|
||||||
"sku_size": {"M"},
|
|
||||||
"quantity": {"2"},
|
|
||||||
"max_total_price": {"12.8"},
|
|
||||||
"form_mode": {"dialog"},
|
|
||||||
}, cookie)
|
|
||||||
if created.Code != http.StatusSeeOther || !strings.HasPrefix(created.Header().Get("Location"), "/tasks?created=") {
|
|
||||||
t.Fatalf("valid create = (%d, %q), want 303 to a created-task acknowledgement", created.Code, created.Header().Get("Location"))
|
|
||||||
}
|
|
||||||
replay := serve(router, http.MethodPost, "/tasks", url.Values{
|
|
||||||
"csrf_token": {csrfToken(t, createPage.Body.String())},
|
|
||||||
"create_key": {key},
|
|
||||||
"title": {"<b>夏季上衣</b>"},
|
|
||||||
"product_url": {"https://mobile.yangkeduo.com/goods.html?goods_id=937122477375&utm_source=discard"},
|
|
||||||
"sku_color": {"black"},
|
|
||||||
"sku_size": {"M"},
|
|
||||||
"quantity": {"2"},
|
|
||||||
"max_total_price": {"12.8"},
|
|
||||||
"form_mode": {"dialog"},
|
|
||||||
}, cookie)
|
|
||||||
if replay.Code != http.StatusSeeOther {
|
|
||||||
t.Fatalf("idempotent replay status = %d, want 303", replay.Code)
|
|
||||||
}
|
|
||||||
conflict := serve(router, http.MethodPost, "/tasks", url.Values{
|
|
||||||
"csrf_token": {csrfToken(t, createPage.Body.String())},
|
|
||||||
"create_key": {key},
|
|
||||||
"title": {"different task"},
|
|
||||||
"product_url": {"https://mobile.yangkeduo.com/goods.html?goods_id=937122477375"},
|
|
||||||
"sku_color": {"black"},
|
|
||||||
"sku_size": {"M"},
|
|
||||||
"quantity": {"2"},
|
|
||||||
"max_total_price": {"12.80"},
|
|
||||||
"form_mode": {"dialog"},
|
|
||||||
}, cookie)
|
|
||||||
if conflict.Code != http.StatusConflict || !strings.Contains(conflict.Body.String(), "该创建请求已用于另一条任务") {
|
|
||||||
t.Fatalf("conflicting create = (%d, %q), want a 409 form error", conflict.Code, conflict.Body.String())
|
|
||||||
}
|
|
||||||
|
|
||||||
list := serve(router, http.MethodGet, created.Header().Get("Location"), nil, cookie)
|
|
||||||
if list.Code != http.StatusOK {
|
|
||||||
t.Fatalf("GET /tasks status = %d, want 200", list.Code)
|
|
||||||
}
|
|
||||||
body := list.Body.String()
|
|
||||||
for _, want := range []string{`任务已创建,已显示在列表首行。`, `<b>夏季上衣</b>`, `https://mobile.yangkeduo.com/goods.html?goods_id=937122477375`, `target="_blank"`, `rel="noopener noreferrer"`, `¥12.80`, `待开始`, `选择全部任务`, `选择任务`} {
|
|
||||||
if !strings.Contains(body, want) {
|
|
||||||
t.Fatalf("task list is missing %q", want)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
for _, forbidden := range []string{"utm_source", "试选", "PENDING", "支付", "订单确认", "真机", "提交订单"} {
|
|
||||||
if strings.Contains(body, forbidden) {
|
|
||||||
t.Fatalf("task list exposed deferred scope %q", forbidden)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestTaskCreationRequiresAuthenticationAndCSRF(t *testing.T) {
|
|
||||||
router, _ := newRouter(t)
|
|
||||||
if response := serve(router, http.MethodPost, "/tasks", url.Values{}, nil); response.Code != http.StatusForbidden {
|
|
||||||
t.Fatalf("anonymous POST /tasks = %d, want 403", response.Code)
|
|
||||||
}
|
|
||||||
cookie := authenticate(t, router)
|
|
||||||
if response := serve(router, http.MethodPost, "/tasks", url.Values{}, cookie); response.Code != http.StatusForbidden {
|
|
||||||
t.Fatalf("POST /tasks without CSRF = %d, want 403", response.Code)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestTaskCreationFailsClosedForMalformedOrOversizedForms(t *testing.T) {
|
|
||||||
router, _ := newRouter(t)
|
|
||||||
cookie := authenticate(t, router)
|
|
||||||
page := serve(router, http.MethodGet, "/tasks?create=1", nil, cookie)
|
|
||||||
base := url.Values{
|
|
||||||
"csrf_token": {csrfToken(t, page.Body.String())},
|
|
||||||
"create_key": {createKey(t, page.Body.String())},
|
|
||||||
"title": {"title"},
|
|
||||||
"product_url": {"https://mobile.yangkeduo.com/goods.html?goods_id=1;uin=malformed"},
|
|
||||||
"sku_color": {"black"},
|
|
||||||
"sku_size": {"M"},
|
|
||||||
"quantity": {"1"},
|
|
||||||
"max_total_price": {"1.00"},
|
|
||||||
"form_mode": {"dialog"},
|
|
||||||
}
|
|
||||||
malformed := serve(router, http.MethodPost, "/tasks", base, cookie)
|
|
||||||
if malformed.Code != http.StatusBadRequest || !strings.Contains(malformed.Body.String(), "canonical 商品链接") {
|
|
||||||
t.Fatalf("malformed URL create = (%d, %q), want validation failure", malformed.Code, malformed.Body.String())
|
|
||||||
}
|
|
||||||
|
|
||||||
oversized := url.Values{"csrf_token": {csrfToken(t, page.Body.String())}, "title": {strings.Repeat("x", 9<<10)}}
|
|
||||||
if response := serve(router, http.MethodPost, "/tasks", oversized, cookie); response.Code != http.StatusRequestEntityTooLarge {
|
|
||||||
t.Fatalf("oversized form status = %d, want 413", response.Code)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func assertSecurityHeaders(t *testing.T, response *httptest.ResponseRecorder) {
|
|
||||||
t.Helper()
|
|
||||||
want := map[string]string{
|
|
||||||
"Cache-Control": "no-store",
|
|
||||||
"X-Content-Type-Options": "nosniff",
|
|
||||||
"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'",
|
|
||||||
}
|
|
||||||
for name, expected := range want {
|
|
||||||
if got := response.Header().Get(name); got != expected {
|
|
||||||
t.Fatalf("%s = %q, want %q", name, got, expected)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func flipCookieValue(t *testing.T, value string) string {
|
|
||||||
t.Helper()
|
|
||||||
if value == "" {
|
|
||||||
t.Fatal("cannot tamper with an empty cookie")
|
|
||||||
}
|
|
||||||
if value[0] == 'A' {
|
|
||||||
return "B" + value[1:]
|
|
||||||
}
|
|
||||||
return "A" + value[1:]
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestLogoutRequiresCSRFAndRevokesSession(t *testing.T) {
|
|
||||||
router, _ := newRouter(t)
|
|
||||||
loginPage := serve(router, http.MethodGet, "/login", nil, nil)
|
|
||||||
loginCookie := sessionCookie(t, loginPage)
|
|
||||||
login := serve(router, http.MethodPost, "/login", url.Values{
|
|
||||||
"csrf_token": {csrfToken(t, loginPage.Body.String())},
|
|
||||||
"username": {"admin"},
|
|
||||||
"password": {"test-password"},
|
|
||||||
}, loginCookie)
|
|
||||||
authenticatedCookie := sessionCookie(t, login)
|
|
||||||
|
|
||||||
missingCSRF := serve(router, http.MethodPost, "/logout", url.Values{}, authenticatedCookie)
|
|
||||||
if missingCSRF.Code != http.StatusForbidden {
|
|
||||||
t.Fatalf("logout without CSRF status = %d, want 403", missingCSRF.Code)
|
|
||||||
}
|
|
||||||
|
|
||||||
tasks := serve(router, http.MethodGet, "/tasks", nil, authenticatedCookie)
|
|
||||||
logout := serve(router, http.MethodPost, "/logout", url.Values{
|
|
||||||
"csrf_token": {csrfToken(t, tasks.Body.String())},
|
|
||||||
}, authenticatedCookie)
|
|
||||||
if logout.Code != http.StatusSeeOther || logout.Header().Get("Location") != "/login" {
|
|
||||||
t.Fatalf("logout = (%d, %q), want 303 /login", logout.Code, logout.Header().Get("Location"))
|
|
||||||
}
|
|
||||||
if cookie := sessionCookie(t, logout); cookie.MaxAge >= 0 {
|
|
||||||
t.Fatalf("logout cookie MaxAge = %d, want a deletion cookie", cookie.MaxAge)
|
|
||||||
}
|
|
||||||
|
|
||||||
reused := serve(router, http.MethodGet, "/tasks", nil, authenticatedCookie)
|
|
||||||
if reused.Code != http.StatusSeeOther {
|
|
||||||
t.Fatalf("revoked session status = %d, want 303", reused.Code)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func newRouter(t *testing.T) (*gin.Engine, *auth.Manager) {
|
|
||||||
t.Helper()
|
|
||||||
gin.SetMode(gin.TestMode)
|
|
||||||
hash, err := bcrypt.GenerateFromPassword([]byte("test-password"), bcrypt.MinCost)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("generate bcrypt hash: %v", err)
|
|
||||||
}
|
|
||||||
manager := auth.NewManager([]byte(strings.Repeat("s", 32)), false)
|
|
||||||
router, err := server.NewRouter(server.Options{
|
|
||||||
AdminUsername: "admin",
|
|
||||||
AdminPasswordBcrypt: string(hash),
|
|
||||||
Sessions: manager,
|
|
||||||
Tasks: &memoryStore{},
|
|
||||||
})
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("NewRouter: %v", err)
|
|
||||||
}
|
|
||||||
return router, manager
|
|
||||||
}
|
|
||||||
|
|
||||||
type memoryStore struct{ drafts []tasks.Draft }
|
|
||||||
|
|
||||||
func (store *memoryStore) CreateDraft(_ context.Context, draft tasks.Draft) (tasks.Draft, error) {
|
|
||||||
for _, existing := range store.drafts {
|
|
||||||
if existing.ID == draft.ID {
|
|
||||||
if existing.Title != draft.Title || existing.GoodsID != draft.GoodsID || existing.SKUColor != draft.SKUColor || existing.SKUSize != draft.SKUSize || existing.Quantity != draft.Quantity || existing.MaxTotalPrice != draft.MaxTotalPrice {
|
|
||||||
return tasks.Draft{}, tasks.ErrCreateKeyConflict
|
|
||||||
}
|
|
||||||
return existing, nil
|
|
||||||
}
|
|
||||||
}
|
|
||||||
store.drafts = append(store.drafts, draft)
|
|
||||||
return draft, nil
|
|
||||||
}
|
|
||||||
func (store *memoryStore) ListDrafts(_ context.Context) ([]tasks.Draft, error) {
|
|
||||||
return append([]tasks.Draft(nil), store.drafts...), nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func serve(router http.Handler, method, target string, form url.Values, cookie *http.Cookie) *httptest.ResponseRecorder {
|
|
||||||
var body *strings.Reader
|
|
||||||
if form == nil {
|
|
||||||
body = strings.NewReader("")
|
|
||||||
} else {
|
|
||||||
body = strings.NewReader(form.Encode())
|
|
||||||
}
|
|
||||||
request := httptest.NewRequest(method, target, body)
|
|
||||||
if form != nil {
|
|
||||||
request.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
|
||||||
}
|
|
||||||
if cookie != nil {
|
|
||||||
request.AddCookie(cookie)
|
|
||||||
}
|
|
||||||
response := httptest.NewRecorder()
|
|
||||||
router.ServeHTTP(response, request)
|
|
||||||
return response
|
|
||||||
}
|
|
||||||
|
|
||||||
func sessionCookie(t *testing.T, response *httptest.ResponseRecorder) *http.Cookie {
|
|
||||||
t.Helper()
|
|
||||||
for _, cookie := range response.Result().Cookies() {
|
|
||||||
if cookie.Name == auth.CookieName {
|
|
||||||
return cookie
|
|
||||||
}
|
|
||||||
}
|
|
||||||
t.Fatalf("response did not set %s cookie", auth.CookieName)
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func csrfToken(t *testing.T, body string) string {
|
|
||||||
t.Helper()
|
|
||||||
matches := csrfPattern.FindStringSubmatch(body)
|
|
||||||
if len(matches) != 2 || matches[1] == "" {
|
|
||||||
t.Fatalf("no CSRF token in response body: %q", body)
|
|
||||||
}
|
|
||||||
return matches[1]
|
|
||||||
}
|
|
||||||
|
|
||||||
func createKey(t *testing.T, body string) string {
|
|
||||||
t.Helper()
|
|
||||||
matches := createKeyPattern.FindStringSubmatch(body)
|
|
||||||
if len(matches) != 2 || matches[1] == "" {
|
|
||||||
t.Fatalf("no create key in response body: %q", body)
|
|
||||||
}
|
|
||||||
return matches[1]
|
|
||||||
}
|
|
||||||
|
|
||||||
func authenticate(t *testing.T, router http.Handler) *http.Cookie {
|
|
||||||
t.Helper()
|
|
||||||
page := serve(router, http.MethodGet, "/login", nil, nil)
|
|
||||||
login := serve(router, http.MethodPost, "/login", url.Values{
|
|
||||||
"csrf_token": {csrfToken(t, page.Body.String())},
|
|
||||||
"username": {"admin"},
|
|
||||||
"password": {"test-password"},
|
|
||||||
}, sessionCookie(t, page))
|
|
||||||
if login.Code != http.StatusSeeOther {
|
|
||||||
t.Fatalf("authenticate status = %d, want 303", login.Code)
|
|
||||||
}
|
|
||||||
return sessionCookie(t, login)
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,129 +0,0 @@
|
|||||||
package tasks
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"database/sql"
|
|
||||||
"errors"
|
|
||||||
"fmt"
|
|
||||||
"time"
|
|
||||||
)
|
|
||||||
|
|
||||||
const sqliteWriteTimeout = 2 * time.Second
|
|
||||||
|
|
||||||
type Store interface {
|
|
||||||
CreateDraft(context.Context, Draft) (Draft, error)
|
|
||||||
ListDrafts(context.Context) ([]Draft, error)
|
|
||||||
}
|
|
||||||
type SQLiteStore struct {
|
|
||||||
database *sql.DB
|
|
||||||
now func() time.Time
|
|
||||||
createGate chan struct{}
|
|
||||||
}
|
|
||||||
|
|
||||||
func NewSQLiteStore(database *sql.DB) (*SQLiteStore, error) {
|
|
||||||
if database == nil {
|
|
||||||
return nil, errors.New("database is required")
|
|
||||||
}
|
|
||||||
if _, err := database.Exec("SELECT 1 FROM tasks LIMIT 1"); err != nil {
|
|
||||||
return nil, fmt.Errorf("tasks migration is not available: %w", err)
|
|
||||||
}
|
|
||||||
return &SQLiteStore{database: database, now: time.Now, createGate: make(chan struct{}, 1)}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (store *SQLiteStore) CreateDraft(ctx context.Context, draft Draft) (Draft, error) {
|
|
||||||
writeContext, cancel := context.WithTimeout(ctx, sqliteWriteTimeout)
|
|
||||||
defer cancel()
|
|
||||||
// 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.
|
|
||||||
select {
|
|
||||||
case store.createGate <- struct{}{}:
|
|
||||||
defer func() { <-store.createGate }()
|
|
||||||
case <-writeContext.Done():
|
|
||||||
return Draft{}, writeContext.Err()
|
|
||||||
}
|
|
||||||
draft.CreatedAt = store.now().UTC()
|
|
||||||
transaction, err := store.database.BeginTx(writeContext, nil)
|
|
||||||
if err != nil {
|
|
||||||
return Draft{}, err
|
|
||||||
}
|
|
||||||
defer transaction.Rollback()
|
|
||||||
_, err = transaction.ExecContext(writeContext, `INSERT INTO tasks (id, source, title, goods_id, sku_color, sku_size, quantity, max_total_price, status, version, created_at, updated_at) VALUES (?, 'MANUAL', ?, ?, ?, ?, ?, ?, 'DRAFT', 1, ?, ?)`, draft.ID, draft.Title, draft.GoodsID, draft.SKUColor, draft.SKUSize, draft.Quantity, draft.MaxTotalPrice, draft.CreatedAt.Format(time.RFC3339Nano), draft.CreatedAt.Format(time.RFC3339Nano))
|
|
||||||
if err == nil {
|
|
||||||
if err := transaction.Commit(); err != nil {
|
|
||||||
return Draft{}, err
|
|
||||||
}
|
|
||||||
return draft, nil
|
|
||||||
}
|
|
||||||
existing, found, currentPhase, lookupErr := findDraft(writeContext, transaction, draft.ID)
|
|
||||||
if lookupErr != nil {
|
|
||||||
return Draft{}, lookupErr
|
|
||||||
}
|
|
||||||
if found && currentPhase && samePayload(existing, draft) {
|
|
||||||
if err := transaction.Commit(); err != nil {
|
|
||||||
return Draft{}, err
|
|
||||||
}
|
|
||||||
return existing, nil
|
|
||||||
}
|
|
||||||
if found {
|
|
||||||
return Draft{}, ErrCreateKeyConflict
|
|
||||||
}
|
|
||||||
return Draft{}, err
|
|
||||||
}
|
|
||||||
|
|
||||||
func (store *SQLiteStore) ListDrafts(ctx context.Context) ([]Draft, error) {
|
|
||||||
// rowid makes equal timestamps deterministic: SQLite assigns it in insertion order,
|
|
||||||
// whereas UUID v4 is deliberately not time-sortable.
|
|
||||||
rows, err := store.database.QueryContext(ctx, `SELECT id, title, goods_id, sku_color, sku_size, quantity, max_total_price, created_at FROM tasks WHERE source = 'MANUAL' AND status = 'DRAFT' ORDER BY created_at DESC, rowid DESC`)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
defer rows.Close()
|
|
||||||
result := []Draft{}
|
|
||||||
for rows.Next() {
|
|
||||||
draft, err := scanDraft(rows)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
result = append(result, draft)
|
|
||||||
}
|
|
||||||
return result, rows.Err()
|
|
||||||
}
|
|
||||||
|
|
||||||
func findDraft(ctx context.Context, transaction *sql.Tx, id string) (Draft, bool, bool, error) {
|
|
||||||
row := transaction.QueryRowContext(ctx, `SELECT id, title, goods_id, sku_color, sku_size, quantity, max_total_price, created_at, source, status, version FROM tasks WHERE id = ?`, id)
|
|
||||||
var draft Draft
|
|
||||||
var created, source, status string
|
|
||||||
var version int
|
|
||||||
err := row.Scan(&draft.ID, &draft.Title, &draft.GoodsID, &draft.SKUColor, &draft.SKUSize, &draft.Quantity, &draft.MaxTotalPrice, &created, &source, &status, &version)
|
|
||||||
if errors.Is(err, sql.ErrNoRows) {
|
|
||||||
return Draft{}, false, false, nil
|
|
||||||
}
|
|
||||||
if err != nil {
|
|
||||||
return Draft{}, false, false, err
|
|
||||||
}
|
|
||||||
parsed, err := time.Parse(time.RFC3339Nano, created)
|
|
||||||
if err != nil {
|
|
||||||
return Draft{}, false, false, err
|
|
||||||
}
|
|
||||||
draft.CreatedAt = parsed
|
|
||||||
return draft, true, source == "MANUAL" && status == "DRAFT" && version == 1, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
type scanner interface{ Scan(...any) error }
|
|
||||||
|
|
||||||
func scanDraft(row scanner) (Draft, error) {
|
|
||||||
var draft Draft
|
|
||||||
var created string
|
|
||||||
if err := row.Scan(&draft.ID, &draft.Title, &draft.GoodsID, &draft.SKUColor, &draft.SKUSize, &draft.Quantity, &draft.MaxTotalPrice, &created); err != nil {
|
|
||||||
return Draft{}, err
|
|
||||||
}
|
|
||||||
parsed, err := time.Parse(time.RFC3339Nano, created)
|
|
||||||
if err != nil {
|
|
||||||
return Draft{}, err
|
|
||||||
}
|
|
||||||
draft.CreatedAt = parsed
|
|
||||||
return draft, nil
|
|
||||||
}
|
|
||||||
func samePayload(left, right Draft) bool {
|
|
||||||
return left.ID == right.ID && left.Title == right.Title && left.GoodsID == right.GoodsID && left.SKUColor == right.SKUColor && left.SKUSize == right.SKUSize && left.Quantity == right.Quantity && left.MaxTotalPrice == right.MaxTotalPrice
|
|
||||||
}
|
|
||||||
@@ -1,159 +0,0 @@
|
|||||||
// Package tasks 定义手工 DRAFT 任务的校验与窄仓储边界。
|
|
||||||
package tasks
|
|
||||||
|
|
||||||
import (
|
|
||||||
"crypto/rand"
|
|
||||||
"encoding/hex"
|
|
||||||
"errors"
|
|
||||||
"net/url"
|
|
||||||
"strconv"
|
|
||||||
"strings"
|
|
||||||
"time"
|
|
||||||
)
|
|
||||||
|
|
||||||
const (
|
|
||||||
maxTitleLength = 120
|
|
||||||
maxSKUText = 80
|
|
||||||
)
|
|
||||||
|
|
||||||
var ErrCreateKeyConflict = errors.New("create key conflicts with a different task")
|
|
||||||
|
|
||||||
type Draft struct {
|
|
||||||
ID string
|
|
||||||
Title string
|
|
||||||
GoodsID string
|
|
||||||
SKUColor string
|
|
||||||
SKUSize string
|
|
||||||
Quantity int
|
|
||||||
MaxTotalPrice string
|
|
||||||
CreatedAt time.Time
|
|
||||||
}
|
|
||||||
|
|
||||||
type Form struct{ CreateKey, Title, ProductURL, SKUColor, SKUSize, Quantity, MaxTotalPrice string }
|
|
||||||
type Errors map[string]string
|
|
||||||
|
|
||||||
func (errors Errors) Valid() bool { return len(errors) == 0 }
|
|
||||||
|
|
||||||
// Validate trims and normalizes a user form. It never reads a product page or derives price data.
|
|
||||||
func Validate(form Form) (Draft, Errors) {
|
|
||||||
draft := Draft{ID: strings.TrimSpace(form.CreateKey), Title: strings.TrimSpace(form.Title), SKUColor: strings.TrimSpace(form.SKUColor), SKUSize: strings.TrimSpace(form.SKUSize)}
|
|
||||||
errors := Errors{}
|
|
||||||
if !validUUID(draft.ID) {
|
|
||||||
errors["create_key"] = "创建请求已过期,请重新打开表单。"
|
|
||||||
}
|
|
||||||
if draft.Title == "" || len([]rune(draft.Title)) > maxTitleLength {
|
|
||||||
errors["title"] = "任务名称不能为空,且不能超过 120 个字符。"
|
|
||||||
}
|
|
||||||
if draft.SKUColor == "" || len([]rune(draft.SKUColor)) > maxSKUText {
|
|
||||||
errors["sku_color"] = "颜色分类不能为空,且不能超过 80 个字符。"
|
|
||||||
}
|
|
||||||
if draft.SKUSize == "" || len([]rune(draft.SKUSize)) > maxSKUText {
|
|
||||||
errors["sku_size"] = "尺码不能为空,且不能超过 80 个字符。"
|
|
||||||
}
|
|
||||||
goodsID, ok := CanonicalGoodsID(strings.TrimSpace(form.ProductURL))
|
|
||||||
if !ok {
|
|
||||||
errors["product_url"] = "请输入唯一的 canonical 商品链接。"
|
|
||||||
} else {
|
|
||||||
draft.GoodsID = goodsID
|
|
||||||
}
|
|
||||||
quantity, err := strconv.ParseInt(strings.TrimSpace(form.Quantity), 10, 0)
|
|
||||||
if err != nil || quantity < 1 {
|
|
||||||
errors["quantity"] = "数量必须是正整数。"
|
|
||||||
} else {
|
|
||||||
draft.Quantity = int(quantity)
|
|
||||||
}
|
|
||||||
money, ok := normalizeMoney(strings.TrimSpace(form.MaxTotalPrice))
|
|
||||||
if !ok {
|
|
||||||
errors["max_total_price"] = "价格上限必须大于零,且最多两位小数。"
|
|
||||||
} else {
|
|
||||||
draft.MaxTotalPrice = money
|
|
||||||
}
|
|
||||||
return draft, errors
|
|
||||||
}
|
|
||||||
|
|
||||||
// CanonicalGoodsID only accepts the one verified manual-entry URL shape; untrusted query data is discarded.
|
|
||||||
func CanonicalGoodsID(value string) (string, bool) {
|
|
||||||
if value == "" || strings.Contains(value, "\\") || strings.Contains(value, "%") {
|
|
||||||
return "", false
|
|
||||||
}
|
|
||||||
parsed, err := url.ParseRequestURI(value)
|
|
||||||
if err != nil || parsed.Scheme != "https" || parsed.Host != "mobile.yangkeduo.com" || parsed.User != nil || parsed.Port() != "" || parsed.Path != "/goods.html" || parsed.Fragment != "" {
|
|
||||||
return "", false
|
|
||||||
}
|
|
||||||
values, err := url.ParseQuery(parsed.RawQuery)
|
|
||||||
if err != nil {
|
|
||||||
return "", false
|
|
||||||
}
|
|
||||||
goodsIDs := values["goods_id"]
|
|
||||||
if len(goodsIDs) != 1 || goodsIDs[0] == "" {
|
|
||||||
return "", false
|
|
||||||
}
|
|
||||||
for _, character := range goodsIDs[0] {
|
|
||||||
if character < '0' || character > '9' {
|
|
||||||
return "", false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return goodsIDs[0], true
|
|
||||||
}
|
|
||||||
|
|
||||||
func CanonicalURL(goodsID string) string {
|
|
||||||
return "https://mobile.yangkeduo.com/goods.html?goods_id=" + goodsID
|
|
||||||
}
|
|
||||||
|
|
||||||
func NewCreateKey() (string, error) {
|
|
||||||
bytes := make([]byte, 16)
|
|
||||||
if _, err := rand.Read(bytes); err != nil {
|
|
||||||
return "", err
|
|
||||||
}
|
|
||||||
bytes[6] = (bytes[6] & 0x0f) | 0x40
|
|
||||||
bytes[8] = (bytes[8] & 0x3f) | 0x80
|
|
||||||
hexValue := hex.EncodeToString(bytes)
|
|
||||||
return hexValue[0:8] + "-" + hexValue[8:12] + "-" + hexValue[12:16] + "-" + hexValue[16:20] + "-" + hexValue[20:32], nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func validUUID(value string) bool {
|
|
||||||
if len(value) != 36 {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
for index, character := range value {
|
|
||||||
if index == 8 || index == 13 || index == 18 || index == 23 {
|
|
||||||
if character != '-' {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if !(character >= '0' && character <= '9' || character >= 'a' && character <= 'f') {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return value[14] == '4' && (value[19] == '8' || value[19] == '9' || value[19] == 'a' || value[19] == 'b')
|
|
||||||
}
|
|
||||||
|
|
||||||
func normalizeMoney(value string) (string, bool) {
|
|
||||||
parts := strings.Split(value, ".")
|
|
||||||
if len(parts) > 2 || parts[0] == "" || len(parts) == 2 && (len(parts[1]) == 0 || len(parts[1]) > 2) {
|
|
||||||
return "", false
|
|
||||||
}
|
|
||||||
for _, character := range parts[0] {
|
|
||||||
if character < '0' || character > '9' {
|
|
||||||
return "", false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
fraction := ""
|
|
||||||
if len(parts) == 2 {
|
|
||||||
fraction = parts[1]
|
|
||||||
for _, character := range fraction {
|
|
||||||
if character < '0' || character > '9' {
|
|
||||||
return "", false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
whole := strings.TrimLeft(parts[0], "0")
|
|
||||||
if whole == "" {
|
|
||||||
whole = "0"
|
|
||||||
}
|
|
||||||
if whole == "0" && strings.Trim(fraction, "0") == "" {
|
|
||||||
return "", false
|
|
||||||
}
|
|
||||||
return whole + "." + (fraction + "00")[:2], true
|
|
||||||
}
|
|
||||||
@@ -1,300 +0,0 @@
|
|||||||
package tasks
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"database/sql"
|
|
||||||
"errors"
|
|
||||||
"path/filepath"
|
|
||||||
"regexp"
|
|
||||||
"runtime"
|
|
||||||
"sync"
|
|
||||||
"testing"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"cmbuyer/admin/internal/migrations"
|
|
||||||
"cmbuyer/admin/internal/storage/sqlite"
|
|
||||||
)
|
|
||||||
|
|
||||||
const testKey = "a3c9f507-7473-4fa6-8d71-8786c34c6301"
|
|
||||||
|
|
||||||
func TestValidateNormalizesManualDraft(t *testing.T) {
|
|
||||||
draft, validation := Validate(Form{
|
|
||||||
CreateKey: " " + testKey + " ",
|
|
||||||
Title: " 夏季上衣 ",
|
|
||||||
ProductURL: "https://mobile.yangkeduo.com/goods.html?goods_id=937122477375&utm_source=untrusted",
|
|
||||||
SKUColor: " 黑色CHA(纯棉) ",
|
|
||||||
SKUSize: " M(建议100-115) ",
|
|
||||||
Quantity: "2",
|
|
||||||
MaxTotalPrice: "00012.8",
|
|
||||||
})
|
|
||||||
if !validation.Valid() {
|
|
||||||
t.Fatalf("Validate errors = %#v", validation)
|
|
||||||
}
|
|
||||||
if draft.ID != testKey || draft.GoodsID != "937122477375" || draft.Title != "夏季上衣" || draft.SKUColor != "黑色CHA(纯棉)" || draft.SKUSize != "M(建议100-115)" || draft.Quantity != 2 || draft.MaxTotalPrice != "12.80" {
|
|
||||||
t.Fatalf("normalized draft = %#v", draft)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestValidateRejectsInvalidFieldsAndURLs(t *testing.T) {
|
|
||||||
base := Form{CreateKey: testKey, Title: "title", ProductURL: "https://mobile.yangkeduo.com/goods.html?goods_id=1", SKUColor: "black", SKUSize: "M", Quantity: "1", MaxTotalPrice: "1"}
|
|
||||||
for name, update := range map[string]func(*Form){
|
|
||||||
"empty title": func(form *Form) { form.Title = " " },
|
|
||||||
"long color": func(form *Form) { form.SKUColor = string(make([]rune, maxSKUText+1)) },
|
|
||||||
"fraction quantity": func(form *Form) { form.Quantity = "1.5" },
|
|
||||||
"zero quantity": func(form *Form) { form.Quantity = "0" },
|
|
||||||
"too many decimals": func(form *Form) { form.MaxTotalPrice = "1.234" },
|
|
||||||
"trailing decimal": func(form *Form) { form.MaxTotalPrice = "1." },
|
|
||||||
"zero money": func(form *Form) { form.MaxTotalPrice = "0.00" },
|
|
||||||
} {
|
|
||||||
t.Run(name, func(t *testing.T) {
|
|
||||||
form := base
|
|
||||||
update(&form)
|
|
||||||
if _, validation := Validate(form); validation.Valid() {
|
|
||||||
t.Fatal("invalid form was accepted")
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, value := range []string{
|
|
||||||
"http://mobile.yangkeduo.com/goods.html?goods_id=1",
|
|
||||||
"https://yangkeduo.com/goods.html?goods_id=1",
|
|
||||||
"https://mobile.yangkeduo.com:443/goods.html?goods_id=1",
|
|
||||||
"https://user@mobile.yangkeduo.com/goods.html?goods_id=1",
|
|
||||||
"https://mobile.yangkeduo.com/goods.html?goods_id=1#fragment",
|
|
||||||
"https://mobile.yangkeduo.com/goods.html?goods_id=1&goods_id=2",
|
|
||||||
"https://mobile.yangkeduo.com/goods.html?goods_id=one",
|
|
||||||
"https://mobile.yangkeduo.com/goods.html?goods_id=%31",
|
|
||||||
"https://mobile.yangkeduo.com/goods.html?goods_id=1%26goods_id%3D2",
|
|
||||||
"https://mobile.yangkeduo.com/goods.html?goods_id=1;uin=bad",
|
|
||||||
"https://mobile.yangkeduo.com/other.html?goods_id=1",
|
|
||||||
} {
|
|
||||||
if _, ok := CanonicalGoodsID(value); ok {
|
|
||||||
t.Fatalf("CanonicalGoodsID accepted %q", value)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestNormalizeMoneyBoundaries(t *testing.T) {
|
|
||||||
for value, want := range map[string]string{"1": "1.00", "1.2": "1.20", "000.01": "0.01", "999999999999999999": "999999999999999999.00"} {
|
|
||||||
got, ok := normalizeMoney(value)
|
|
||||||
if !ok || got != want {
|
|
||||||
t.Fatalf("normalizeMoney(%q) = (%q, %t), want (%q, true)", value, got, ok, want)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
for _, value := range []string{"0", "0.0", "0.00", "1.", ".1", "1.000", "-1", "1e2", " 1"} {
|
|
||||||
if got, ok := normalizeMoney(value); ok {
|
|
||||||
t.Fatalf("normalizeMoney(%q) = %q, want rejection", value, got)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestNewCreateKeyIsUUIDv4(t *testing.T) {
|
|
||||||
key, err := NewCreateKey()
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("NewCreateKey: %v", err)
|
|
||||||
}
|
|
||||||
if !regexp.MustCompile(`^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$`).MatchString(key) {
|
|
||||||
t.Fatalf("create key %q is not UUID v4", key)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestSQLiteStoreRequiresMigratedDatabase(t *testing.T) {
|
|
||||||
database := openDatabase(t)
|
|
||||||
if _, err := NewSQLiteStore(database); err == nil {
|
|
||||||
t.Fatal("NewSQLiteStore accepted an unmigrated database")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestSQLiteStoreCreatesListsAndHandlesIdempotency(t *testing.T) {
|
|
||||||
database := migratedDatabase(t)
|
|
||||||
store, err := NewSQLiteStore(database)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("NewSQLiteStore: %v", err)
|
|
||||||
}
|
|
||||||
baseTime := time.Date(2026, 8, 4, 9, 0, 0, 0, time.UTC)
|
|
||||||
call := 0
|
|
||||||
store.now = func() time.Time {
|
|
||||||
result := baseTime.Add(time.Duration(call) * time.Minute)
|
|
||||||
call++
|
|
||||||
return result
|
|
||||||
}
|
|
||||||
first := testDraft(testKey, "first")
|
|
||||||
created, err := store.CreateDraft(context.Background(), first)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("create first draft: %v", err)
|
|
||||||
}
|
|
||||||
replayed, err := store.CreateDraft(context.Background(), first)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("replay first draft: %v", err)
|
|
||||||
}
|
|
||||||
if replayed.CreatedAt != created.CreatedAt {
|
|
||||||
t.Fatalf("replayed CreatedAt = %s, want original %s", replayed.CreatedAt, created.CreatedAt)
|
|
||||||
}
|
|
||||||
second := testDraft("b3c9f507-7473-4fa6-8d71-8786c34c6301", "second")
|
|
||||||
if _, err := store.CreateDraft(context.Background(), second); err != nil {
|
|
||||||
t.Fatalf("create second draft: %v", err)
|
|
||||||
}
|
|
||||||
drafts, err := store.ListDrafts(context.Background())
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("list drafts: %v", err)
|
|
||||||
}
|
|
||||||
if len(drafts) != 2 || drafts[0].ID != second.ID || drafts[1].ID != first.ID {
|
|
||||||
t.Fatalf("draft order = %#v, want second then first", drafts)
|
|
||||||
}
|
|
||||||
var source, status string
|
|
||||||
var version int
|
|
||||||
if err := database.QueryRow(`SELECT source, status, version FROM tasks WHERE id = ?`, first.ID).Scan(&source, &status, &version); err != nil {
|
|
||||||
t.Fatalf("read stored task: %v", err)
|
|
||||||
}
|
|
||||||
if source != "MANUAL" || status != "DRAFT" || version != 1 {
|
|
||||||
t.Fatalf("stored metadata = (%q, %q, %d)", source, status, version)
|
|
||||||
}
|
|
||||||
|
|
||||||
conflicting := first
|
|
||||||
conflicting.Title = "different"
|
|
||||||
if _, err := store.CreateDraft(context.Background(), conflicting); !errors.Is(err, ErrCreateKeyConflict) {
|
|
||||||
t.Fatalf("conflicting create error = %v, want ErrCreateKeyConflict", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestSQLiteStoreRollsBackFailedCreate(t *testing.T) {
|
|
||||||
database := migratedDatabase(t)
|
|
||||||
store, err := NewSQLiteStore(database)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("NewSQLiteStore: %v", err)
|
|
||||||
}
|
|
||||||
if _, err := database.Exec(`CREATE TRIGGER reject_task BEFORE INSERT ON tasks BEGIN SELECT RAISE(ABORT, 'reject test insert'); END`); err != nil {
|
|
||||||
t.Fatalf("create trigger: %v", err)
|
|
||||||
}
|
|
||||||
if _, err := store.CreateDraft(context.Background(), testDraft(testKey, "blocked")); err == nil {
|
|
||||||
t.Fatal("CreateDraft succeeded despite rejecting trigger")
|
|
||||||
}
|
|
||||||
drafts, err := store.ListDrafts(context.Background())
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("list after failed create: %v", err)
|
|
||||||
}
|
|
||||||
if len(drafts) != 0 {
|
|
||||||
t.Fatalf("failed create persisted drafts: %#v", drafts)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestSQLiteStoreUsesInsertionOrderForEqualTimesAndFiltersPhase(t *testing.T) {
|
|
||||||
database := migratedDatabase(t)
|
|
||||||
store, err := NewSQLiteStore(database)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("NewSQLiteStore: %v", err)
|
|
||||||
}
|
|
||||||
store.now = func() time.Time { return time.Date(2026, 8, 4, 9, 0, 0, 0, time.UTC) }
|
|
||||||
first := testDraft(testKey, "first")
|
|
||||||
second := testDraft("b3c9f507-7473-4fa6-8d71-8786c34c6301", "second")
|
|
||||||
for _, draft := range []Draft{first, second} {
|
|
||||||
if _, err := store.CreateDraft(context.Background(), draft); err != nil {
|
|
||||||
t.Fatalf("create %s: %v", draft.Title, err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
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 ('excel-draft', 'EXCEL', 'other', '1', 'black', 'M', 1, '1.00', 'DRAFT', 1, '2026-08-04T10:00:00Z', '2026-08-04T10:00:00Z'), ('manual-pending', 'MANUAL', 'other', '2', 'black', 'M', 1, '1.00', 'PENDING', 1, '2026-08-04T10:00:00Z', '2026-08-04T10:00:00Z')`); err != nil {
|
|
||||||
t.Fatalf("insert out-of-scope tasks: %v", err)
|
|
||||||
}
|
|
||||||
drafts, err := store.ListDrafts(context.Background())
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("list drafts: %v", err)
|
|
||||||
}
|
|
||||||
if len(drafts) != 2 || drafts[0].ID != second.ID || drafts[1].ID != first.ID {
|
|
||||||
t.Fatalf("equal-time draft order/filter = %#v, want second then first only", drafts)
|
|
||||||
}
|
|
||||||
if _, err := database.Exec(`UPDATE tasks SET status = 'PENDING' WHERE id = ?`, first.ID); err != nil {
|
|
||||||
t.Fatalf("move draft outside current phase: %v", err)
|
|
||||||
}
|
|
||||||
if _, err := store.CreateDraft(context.Background(), first); !errors.Is(err, ErrCreateKeyConflict) {
|
|
||||||
t.Fatalf("replay of non-DRAFT record error = %v, want conflict", err)
|
|
||||||
}
|
|
||||||
third := testDraft("c3c9f507-7473-4fa6-8d71-8786c34c6301", "third")
|
|
||||||
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 (?, 'EXCEL', ?, ?, ?, ?, ?, ?, 'DRAFT', 1, '2026-08-04T09:00:00Z', '2026-08-04T09:00:00Z')`, third.ID, third.Title, third.GoodsID, third.SKUColor, third.SKUSize, third.Quantity, third.MaxTotalPrice); err != nil {
|
|
||||||
t.Fatalf("insert same-payload EXCEL record: %v", err)
|
|
||||||
}
|
|
||||||
if _, err := store.CreateDraft(context.Background(), third); !errors.Is(err, ErrCreateKeyConflict) {
|
|
||||||
t.Fatalf("replay of non-MANUAL record error = %v, want conflict", err)
|
|
||||||
}
|
|
||||||
if _, err := database.Exec(`UPDATE tasks SET version = 2, source = 'MANUAL' WHERE id = ?`, third.ID); err != nil {
|
|
||||||
t.Fatalf("change replay record version: %v", err)
|
|
||||||
}
|
|
||||||
if _, err := store.CreateDraft(context.Background(), third); !errors.Is(err, ErrCreateKeyConflict) {
|
|
||||||
t.Fatalf("replay of non-v1 record error = %v, want conflict", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestSQLiteStoreConcurrentIdenticalCreateIsOneDraft(t *testing.T) {
|
|
||||||
store, err := NewSQLiteStore(migratedDatabase(t))
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("NewSQLiteStore: %v", err)
|
|
||||||
}
|
|
||||||
const callers = 20
|
|
||||||
start := make(chan struct{})
|
|
||||||
errors := make(chan error, callers)
|
|
||||||
results := make(chan Draft, callers)
|
|
||||||
var group sync.WaitGroup
|
|
||||||
for range callers {
|
|
||||||
group.Add(1)
|
|
||||||
go func() {
|
|
||||||
defer group.Done()
|
|
||||||
<-start
|
|
||||||
draft, err := store.CreateDraft(context.Background(), testDraft(testKey, "same"))
|
|
||||||
if err != nil {
|
|
||||||
errors <- err
|
|
||||||
return
|
|
||||||
}
|
|
||||||
results <- draft
|
|
||||||
}()
|
|
||||||
}
|
|
||||||
close(start)
|
|
||||||
group.Wait()
|
|
||||||
close(errors)
|
|
||||||
close(results)
|
|
||||||
for err := range errors {
|
|
||||||
t.Fatalf("concurrent create: %v", err)
|
|
||||||
}
|
|
||||||
for result := range results {
|
|
||||||
if result.ID != testKey {
|
|
||||||
t.Fatalf("concurrent result = %#v", result)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
drafts, err := store.ListDrafts(context.Background())
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("list after concurrent create: %v", err)
|
|
||||||
}
|
|
||||||
if len(drafts) != 1 || drafts[0].ID != testKey {
|
|
||||||
t.Fatalf("concurrent creates persisted %#v, want exactly one", drafts)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func testDraft(id, title string) Draft {
|
|
||||||
return Draft{ID: id, Title: title, GoodsID: "937122477375", SKUColor: "black", SKUSize: "M", Quantity: 2, MaxTotalPrice: "12.80"}
|
|
||||||
}
|
|
||||||
|
|
||||||
func openDatabase(t *testing.T) *sql.DB {
|
|
||||||
t.Helper()
|
|
||||||
database, err := sqlite.Open(filepath.Join(t.TempDir(), "tasks.db"))
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("open database: %v", err)
|
|
||||||
}
|
|
||||||
t.Cleanup(func() { _ = database.Close() })
|
|
||||||
return database
|
|
||||||
}
|
|
||||||
|
|
||||||
func migratedDatabase(t *testing.T) *sql.DB {
|
|
||||||
t.Helper()
|
|
||||||
database := openDatabase(t)
|
|
||||||
if err := migrations.Up(context.Background(), database, migrationDirectory(t)); err != nil {
|
|
||||||
t.Fatalf("migrate database: %v", err)
|
|
||||||
}
|
|
||||||
return database
|
|
||||||
}
|
|
||||||
|
|
||||||
func migrationDirectory(t *testing.T) string {
|
|
||||||
t.Helper()
|
|
||||||
_, file, _, ok := runtime.Caller(0)
|
|
||||||
if !ok {
|
|
||||||
t.Fatal("locate test source")
|
|
||||||
}
|
|
||||||
return filepath.Join(filepath.Dir(file), "..", "..", "migrations")
|
|
||||||
}
|
|
||||||
@@ -1,63 +0,0 @@
|
|||||||
{{define "login.html"}}
|
|
||||||
<!doctype html>
|
|
||||||
<html lang="zh-CN">
|
|
||||||
<head>
|
|
||||||
<meta charset="utf-8">
|
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
||||||
<title>登录 · 采购服务</title>
|
|
||||||
<style>
|
|
||||||
:root { color-scheme: light; --bg:#f4f7fb; --surface:#fff; --text:#172033; --muted:#526079; --border:#cfd8e6; --primary:#155eef; --primary-hover:#0b4ed1; --primary-soft:#eaf1ff; --danger:#b42318; --danger-soft:#fef3f2; --focus:#ffbf47; --shadow:0 12px 30px rgba(23,32,51,.1); 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-link { position:fixed; z-index:10; top:8px; left:8px; padding:10px 14px; color:#fff; background:var(--text); transform:translateY(-160%); }
|
|
||||||
.skip-link:focus { transform:translateY(0); }
|
|
||||||
main { display:grid; min-height:100dvh; place-items:center; padding:24px 16px; }
|
|
||||||
.card { width:min(100%,440px); padding:32px; border:1px solid var(--border); border-radius:14px; background:var(--surface); box-shadow:var(--shadow); }
|
|
||||||
.brand { display:flex; align-items:center; gap:10px; margin:0 0 24px; font-size:1rem; font-weight:700; }
|
|
||||||
.brand-mark { display:grid; width:32px; height:32px; place-items:center; border-radius:8px; color:#fff; background:var(--primary); font-size:.82rem; }
|
|
||||||
h1 { margin:0; font-size:clamp(1.6rem,5vw,2rem); line-height:1.25; }
|
|
||||||
.intro { margin:8px 0 24px; color:var(--muted); }
|
|
||||||
.field { margin-top:16px; }
|
|
||||||
label { display:block; margin-bottom:6px; font-weight:700; }
|
|
||||||
input { width:100%; min-height:44px; padding:10px 12px; border:1px solid #9ba9bc; border-radius:8px; color:var(--text); background:#fff; }
|
|
||||||
input[aria-invalid="true"] { border-color:var(--danger); box-shadow:0 0 0 1px var(--danger); }
|
|
||||||
.hint { margin:5px 0 0; color:var(--muted); font-size:.875rem; }
|
|
||||||
.error { margin:0 0 18px; padding:12px 14px; border-left:4px solid var(--danger); border-radius:6px; color:var(--danger); background:var(--danger-soft); font-weight:650; }
|
|
||||||
.submit { width:100%; min-height:44px; margin-top:24px; padding:10px 16px; border:1px solid transparent; border-radius:8px; color:#fff; background:var(--primary); font-weight:700; cursor:pointer; transition:background-color 180ms ease-out; }
|
|
||||||
.submit:hover { background:var(--primary-hover); }
|
|
||||||
.notice { margin:20px 0 0; padding:12px 14px; border:1px solid #b9cffc; border-radius:8px; color:#29466f; background:var(--primary-soft); font-size:.9rem; }
|
|
||||||
@media (max-width:420px) { main { padding-inline:12px; } .card { padding:24px 16px; } }
|
|
||||||
@media (prefers-reduced-motion:reduce) { *,*::before,*::after { transition-duration:.01ms !important; animation-duration:.01ms !important; } }
|
|
||||||
</style>
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
<a class="skip-link" href="#main">跳到主要内容</a>
|
|
||||||
<main id="main">
|
|
||||||
<section class="card" aria-labelledby="login-title">
|
|
||||||
<p class="brand"><span class="brand-mark" aria-hidden="true">采</span><span>采购服务</span></p>
|
|
||||||
<h1 id="login-title">管理端登录</h1>
|
|
||||||
<p class="intro">登录后进入采购任务工作台。设备身份不能使用此入口。</p>
|
|
||||||
{{if .Error}}<p class="error" role="alert">{{.Error}}</p>{{end}}
|
|
||||||
<form method="post" action="/login">
|
|
||||||
<input type="hidden" name="csrf_token" value="{{.CSRFToken}}">
|
|
||||||
<input type="hidden" name="return_to" value="{{.ReturnTo}}">
|
|
||||||
<div class="field">
|
|
||||||
<label for="username">账号</label>
|
|
||||||
<input id="username" name="username" type="text" value="{{.Username}}" autocomplete="username" required aria-invalid="{{if .Error}}true{{else}}false{{end}}" aria-describedby="username-hint">
|
|
||||||
<p class="hint" id="username-hint">使用采购管理员账号登录。</p>
|
|
||||||
</div>
|
|
||||||
<div class="field">
|
|
||||||
<label for="password">密码</label>
|
|
||||||
<input id="password" name="password" type="password" autocomplete="current-password" required aria-invalid="{{if .Error}}true{{else}}false{{end}}">
|
|
||||||
</div>
|
|
||||||
<button class="submit" type="submit">登录并继续</button>
|
|
||||||
</form>
|
|
||||||
<p class="notice">系统只创建待付款订单,付款始终由人完成。</p>
|
|
||||||
</section>
|
|
||||||
</main>
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
{{end}}
|
|
||||||
@@ -1,19 +0,0 @@
|
|||||||
{{define "tasks.html"}}
|
|
||||||
<!doctype html>
|
|
||||||
<html lang="zh-CN">
|
|
||||||
<head>
|
|
||||||
<meta charset="utf-8">
|
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
||||||
<title>采购任务 · 采购服务</title>
|
|
||||||
<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>
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
<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>
|
|
||||||
{{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}}
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
{{end}}
|
|
||||||
{{define "form"}}<h1 id="form-title">创建采购任务</h1><p class="muted">保存后仅生成待开始任务,不会执行其他动作。</p>{{if .Errors}}<div class="summary" role="alert" aria-live="assertive"><p>请修正下列字段后再保存。</p><ul>{{with index .Errors "title"}}<li><a href="#title">任务名称:{{.}}</a></li>{{end}}{{with index .Errors "product_url"}}<li><a href="#product_url">商品链接:{{.}}</a></li>{{end}}{{with index .Errors "sku_color"}}<li><a href="#sku_color">颜色分类:{{.}}</a></li>{{end}}{{with index .Errors "sku_size"}}<li><a href="#sku_size">尺码:{{.}}</a></li>{{end}}{{with index .Errors "quantity"}}<li><a href="#quantity">数量:{{.}}</a></li>{{end}}{{with index .Errors "max_total_price"}}<li><a href="#max_total_price">价格上限:{{.}}</a></li>{{end}}{{with index .Errors "create_key"}}<li>{{.}}</li>{{end}}</ul></div>{{end}}<form method="post" action="/tasks" class="form-grid"><input type="hidden" name="csrf_token" value="{{.CSRFToken}}"><input type="hidden" name="create_key" value="{{.Form.CreateKey}}"><input type="hidden" name="form_mode" value="{{if .FullPage}}full{{else}}dialog{{end}}">{{template "field" (list "title" "任务名称" .Form.Title .Errors .FocusField)}}{{template "field" (list "product_url" "商品链接" .Form.ProductURL .Errors .FocusField)}}{{template "field" (list "sku_color" "颜色分类" .Form.SKUColor .Errors .FocusField)}}{{template "field" (list "sku_size" "尺码" .Form.SKUSize .Errors .FocusField)}}{{template "field" (list "quantity" "数量" .Form.Quantity .Errors .FocusField)}}{{template "field" (list "max_total_price" "价格上限" .Form.MaxTotalPrice .Errors .FocusField)}}<div class="actions"><button class="button primary" type="submit">保存任务</button><a class="button" href="/tasks">取消</a></div></form>{{end}}
|
|
||||||
{{define "field"}}{{$name:=index . 0}}{{$label:=index . 1}}{{$value:=index . 2}}{{$errors:=index . 3}}{{$focus:=index . 4}}<div class="field"><label for="{{$name}}">{{$label}} <span class="required" aria-hidden="true">*</span><span class="sr-only">(必填)</span></label><input id="{{$name}}" name="{{$name}}" value="{{$value}}" required {{if eq $focus $name}}autofocus{{end}} aria-invalid="{{if index $errors $name}}true{{else}}false{{end}}"{{with index $errors $name}} aria-describedby="{{$name}}-error"{{end}} {{if eq $name "product_url"}}type="url" inputmode="url" maxlength="2048"{{else if eq $name "quantity"}}type="number" inputmode="numeric" min="1" step="1"{{else if eq $name "max_total_price"}}type="text" inputmode="decimal" pattern="[0-9]+(\.[0-9]{1,2})?" maxlength="64"{{else if eq $name "title"}}type="text" maxlength="120"{{else}}type="text" maxlength="80"{{end}}>{{with index $errors $name}}<p class="error" id="{{$name}}-error">{{.}}</p>{{end}}</div>{{end}}
|
|
||||||
@@ -1,45 +0,0 @@
|
|||||||
// Package webui 渲染采购服务当前可用的服务端页面。
|
|
||||||
package webui
|
|
||||||
|
|
||||||
import (
|
|
||||||
"embed"
|
|
||||||
"html/template"
|
|
||||||
"io"
|
|
||||||
|
|
||||||
"cmbuyer/admin/internal/tasks"
|
|
||||||
)
|
|
||||||
|
|
||||||
//go:embed templates/*.html
|
|
||||||
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"))
|
|
||||||
|
|
||||||
// LoginData 是登录页面所需的非敏感展示数据。
|
|
||||||
type LoginData struct {
|
|
||||||
CSRFToken string
|
|
||||||
ReturnTo string
|
|
||||||
Username string
|
|
||||||
Error string
|
|
||||||
}
|
|
||||||
|
|
||||||
// TasksData 是受保护的 DRAFT 建单与列表页面所需数据。
|
|
||||||
type TasksData struct {
|
|
||||||
CSRFToken string
|
|
||||||
Drafts []tasks.Draft
|
|
||||||
Form tasks.Form
|
|
||||||
Errors tasks.Errors
|
|
||||||
OpenForm bool
|
|
||||||
FullPage bool
|
|
||||||
FocusField string
|
|
||||||
Success bool
|
|
||||||
}
|
|
||||||
|
|
||||||
// RenderLogin 写入登录页。
|
|
||||||
func RenderLogin(writer io.Writer, data LoginData) error {
|
|
||||||
return templates.ExecuteTemplate(writer, "login.html", data)
|
|
||||||
}
|
|
||||||
|
|
||||||
// RenderTasks 写入登录后的受保护任务页。
|
|
||||||
func RenderTasks(writer io.Writer, data TasksData) error {
|
|
||||||
return templates.ExecuteTemplate(writer, "tasks.html", data)
|
|
||||||
}
|
|
||||||
@@ -22,7 +22,7 @@ from ..pdd.product_url import ProductUrl, ProductUrlError, parse_product_url
|
|||||||
from ..pdd.sku_panel_state import HUMAN_DECLARED_STATES
|
from ..pdd.sku_panel_state import HUMAN_DECLARED_STATES
|
||||||
|
|
||||||
|
|
||||||
SANITIZER_VERSION = "t103-privacy-v4"
|
SANITIZER_VERSION = "t103-privacy-v5"
|
||||||
EXPECTED_GOODS_ID = "937122477375"
|
EXPECTED_GOODS_ID = "937122477375"
|
||||||
EXPECTED_PDD_VERSION = "8.17.0"
|
EXPECTED_PDD_VERSION = "8.17.0"
|
||||||
EXPECTED_DEVICE_MODEL = "PKG110"
|
EXPECTED_DEVICE_MODEL = "PKG110"
|
||||||
@@ -56,9 +56,11 @@ _PRICE_PROJECTION_ATTRIBUTES = (
|
|||||||
)
|
)
|
||||||
# 仅接受普通 ASCII 空格,且每个可分隔位置最多一个;禁止换行、折扣、支付/提交文案和
|
# 仅接受普通 ASCII 空格,且每个可分隔位置最多一个;禁止换行、折扣、支付/提交文案和
|
||||||
# 任何其它字符。前缀捕获组用于区分当前价与至多一个划线/原价候选。
|
# 任何其它字符。前缀捕获组用于区分当前价与至多一个划线/原价候选。
|
||||||
_CROSSING_PRICE_TEXT_RE = re.compile(r" {0,1}(?:(快卖光) {0,1})?[¥¥] {0,1}[1-9]\d*\.\d{2} {0,1}\Z")
|
# T-103 人工在 live 规格面板确认当前价槽的完整非敏感前缀仅为“快卖完”;不得兼容
|
||||||
_CROSSING_PRICE_PREFIX_RE = re.compile(r" {0,1}(?:快卖光 {0,1})?[¥¥] {0,1}[1-9]\d*\.\d{2} {0,1}")
|
# 未取证的“快卖光”或其它相近文案。
|
||||||
_CROSSING_PRICE_ALLOWED_CHARACTERS = frozenset(" 快卖光¥¥0123456789.")
|
_CROSSING_PRICE_TEXT_RE = re.compile(r" {0,1}(?:(快卖完) {0,1})?[¥¥] {0,1}[1-9]\d*\.\d{2} {0,1}\Z")
|
||||||
|
_CROSSING_PRICE_PREFIX_RE = re.compile(r" {0,1}(?:快卖完 {0,1})?[¥¥] {0,1}[1-9]\d*\.\d{2} {0,1}")
|
||||||
|
_CROSSING_PRICE_ALLOWED_CHARACTERS = frozenset(" 快卖完¥¥0123456789.")
|
||||||
|
|
||||||
|
|
||||||
class SkuEvidenceSanitizationError(RuntimeError):
|
class SkuEvidenceSanitizationError(RuntimeError):
|
||||||
@@ -423,8 +425,10 @@ def _crossing_price_text_mismatch_reason(text: str) -> str:
|
|||||||
return "non_ascii_whitespace"
|
return "non_ascii_whitespace"
|
||||||
if any(marker in text for marker in ("提交订单", "支付", "下单", "优惠")):
|
if any(marker in text for marker in ("提交订单", "支付", "下单", "优惠")):
|
||||||
return "extra_or_order"
|
return "extra_or_order"
|
||||||
without_leading_space = text.lstrip(" ")
|
without_one_leading_space = text[1:] if text.startswith(" ") else text
|
||||||
if without_leading_space.startswith("快") and not without_leading_space.startswith("快卖光"):
|
if without_one_leading_space.startswith("快要抢光"):
|
||||||
|
return "observed_prefix_kuaiyaoqiangguang"
|
||||||
|
if without_one_leading_space.startswith("快") and not without_one_leading_space.startswith("快卖完"):
|
||||||
return "known_prefix_missing"
|
return "known_prefix_missing"
|
||||||
if "¥" not in text and "¥" not in text:
|
if "¥" not in text and "¥" not in text:
|
||||||
return "currency_missing"
|
return "currency_missing"
|
||||||
|
|||||||
@@ -34,7 +34,7 @@ TEST_ADDRESS = "SYNTHETIC_ADDRESS_NEVER_PUBLISH"
|
|||||||
FULL_PHONE = "13800138000"
|
FULL_PHONE = "13800138000"
|
||||||
MASKED_PHONE = "138****0000"
|
MASKED_PHONE = "138****0000"
|
||||||
SAFE_TEXT = "synthetic-safe-lower-content"
|
SAFE_TEXT = "synthetic-safe-lower-content"
|
||||||
CURRENT_PRICE = "快卖光 ¥12.88"
|
CURRENT_PRICE = "快卖完 ¥12.88"
|
||||||
ORIGINAL_PRICE = "¥29.00"
|
ORIGINAL_PRICE = "¥29.00"
|
||||||
PRICE_CURRENT_BOUNDS = "[396,503][712,570]"
|
PRICE_CURRENT_BOUNDS = "[396,503][712,570]"
|
||||||
PRICE_ORIGINAL_BOUNDS = "[730,503][895,570]"
|
PRICE_ORIGINAL_BOUNDS = "[730,503][895,570]"
|
||||||
@@ -166,7 +166,7 @@ class SkuEvidenceSanitizerTests(unittest.TestCase):
|
|||||||
self.assertNotIn(MASKED_PHONE, derived_xml)
|
self.assertNotIn(MASKED_PHONE, derived_xml)
|
||||||
self.assertIn(f'"human_declared_state": "{state}"', manifest)
|
self.assertIn(f'"human_declared_state": "{state}"', manifest)
|
||||||
self.assertIn('"privacy_tier": "SANITIZED"', manifest)
|
self.assertIn('"privacy_tier": "SANITIZED"', manifest)
|
||||||
self.assertIn('"sanitizer_version": "t103-privacy-v4"', manifest)
|
self.assertIn('"sanitizer_version": "t103-privacy-v5"', manifest)
|
||||||
self.assertIn('"screenshot_space": {', manifest)
|
self.assertIn('"screenshot_space": {', manifest)
|
||||||
self.assertIn('"xml_coordinate_space": {', manifest)
|
self.assertIn('"xml_coordinate_space": {', manifest)
|
||||||
self.assertIn('"height": 2376', manifest)
|
self.assertIn('"height": 2376', manifest)
|
||||||
@@ -256,16 +256,16 @@ class SkuEvidenceSanitizerTests(unittest.TestCase):
|
|||||||
|
|
||||||
def test_crossing_price_window_rejects_text_and_structure_drift(self) -> None:
|
def test_crossing_price_window_rejects_text_and_structure_drift(self) -> None:
|
||||||
bad_texts = (
|
bad_texts = (
|
||||||
f"快卖光 ¥12.88 {TEST_ADDRESS}",
|
f"快卖完 ¥12.88 {TEST_ADDRESS}",
|
||||||
f"快卖光 ¥12.88 {FULL_PHONE}",
|
f"快卖完 ¥12.88 {FULL_PHONE}",
|
||||||
"快卖光 ¥12.88 使用微信支付",
|
"快卖完 ¥12.88 使用微信支付",
|
||||||
"快卖光 ¥12.88 提交订单",
|
"快卖完 ¥12.88 提交订单",
|
||||||
"快卖光 ¥12.88 优惠-11元",
|
"快卖完 ¥12.88 优惠-11元",
|
||||||
"快要抢光 ¥12.88",
|
"快要抢光 ¥12.88",
|
||||||
"快卖光 ¥0.00",
|
"快卖完 ¥0.00",
|
||||||
"快卖光 ¥12.8",
|
"快卖完 ¥12.8",
|
||||||
"快卖光 ¥12.880",
|
"快卖完 ¥12.880",
|
||||||
"快卖光 ¥12.88",
|
"快卖完 ¥12.88",
|
||||||
)
|
)
|
||||||
for text in bad_texts:
|
for text in bad_texts:
|
||||||
with self.subTest(text=text), TemporaryDirectory() as temporary:
|
with self.subTest(text=text), TemporaryDirectory() as temporary:
|
||||||
@@ -276,31 +276,37 @@ class SkuEvidenceSanitizerTests(unittest.TestCase):
|
|||||||
|
|
||||||
def test_crossing_price_text_mismatch_reports_only_fixed_slot_and_reason(self) -> None:
|
def test_crossing_price_text_mismatch_reports_only_fixed_slot_and_reason(self) -> None:
|
||||||
newline_node = _price_node(CURRENT_PRICE, PRICE_CURRENT_BOUNDS).replace(
|
newline_node = _price_node(CURRENT_PRICE, PRICE_CURRENT_BOUNDS).replace(
|
||||||
"快卖光 ¥12.88", "快卖光 ¥12.88"
|
"快卖完 ¥12.88", "快卖完 ¥12.88"
|
||||||
)
|
)
|
||||||
cases = (
|
cases = (
|
||||||
("newline", _xml_with_prices(current_node=newline_node), "newline", PRICE_CURRENT_BOUNDS),
|
("newline", _xml_with_prices(current_node=newline_node), "newline", PRICE_CURRENT_BOUNDS),
|
||||||
(
|
(
|
||||||
"non-ascii-whitespace",
|
"non-ascii-whitespace",
|
||||||
_xml_with_prices(current="快卖光 ¥12.88"),
|
_xml_with_prices(current="快卖完 ¥12.88"),
|
||||||
"non_ascii_whitespace",
|
"non_ascii_whitespace",
|
||||||
PRICE_CURRENT_BOUNDS,
|
PRICE_CURRENT_BOUNDS,
|
||||||
),
|
),
|
||||||
(
|
(
|
||||||
"known-prefix-missing",
|
"known-prefix-missing",
|
||||||
_xml_with_prices(current="快要抢光 ¥12.88"),
|
_xml_with_prices(current="快要抢光 ¥12.88"),
|
||||||
|
"observed_prefix_kuaiyaoqiangguang",
|
||||||
|
PRICE_CURRENT_BOUNDS,
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"other-kuai-prefix-remains-generic",
|
||||||
|
_xml_with_prices(current="快递地址 ¥12.88"),
|
||||||
"known_prefix_missing",
|
"known_prefix_missing",
|
||||||
PRICE_CURRENT_BOUNDS,
|
PRICE_CURRENT_BOUNDS,
|
||||||
),
|
),
|
||||||
(
|
(
|
||||||
"currency-missing",
|
"currency-missing",
|
||||||
_xml_with_prices(current="快卖光 12.88"),
|
_xml_with_prices(current="快卖完 12.88"),
|
||||||
"currency_missing",
|
"currency_missing",
|
||||||
PRICE_CURRENT_BOUNDS,
|
PRICE_CURRENT_BOUNDS,
|
||||||
),
|
),
|
||||||
(
|
(
|
||||||
"amount-shape",
|
"amount-shape",
|
||||||
_xml_with_prices(current="快卖光 ¥12.8"),
|
_xml_with_prices(current="快卖完 ¥12.8"),
|
||||||
"amount_shape",
|
"amount_shape",
|
||||||
PRICE_CURRENT_BOUNDS,
|
PRICE_CURRENT_BOUNDS,
|
||||||
),
|
),
|
||||||
@@ -337,10 +343,10 @@ class SkuEvidenceSanitizerTests(unittest.TestCase):
|
|||||||
|
|
||||||
def test_crossing_price_text_mismatch_never_echoes_sensitive_or_order_text(self) -> None:
|
def test_crossing_price_text_mismatch_never_echoes_sensitive_or_order_text(self) -> None:
|
||||||
cases = (
|
cases = (
|
||||||
f"快卖光 ¥12.88 {TEST_ADDRESS}",
|
f"快卖完 ¥12.88 {TEST_ADDRESS}",
|
||||||
f"快卖光 ¥12.88 {FULL_PHONE}",
|
f"快卖完 ¥12.88 {FULL_PHONE}",
|
||||||
"快卖光 ¥12.88 使用微信支付",
|
"快卖完 ¥12.88 使用微信支付",
|
||||||
"快卖光 ¥12.88 提交订单",
|
"快卖完 ¥12.88 提交订单",
|
||||||
)
|
)
|
||||||
for text in cases:
|
for text in cases:
|
||||||
with self.subTest(text=text), TemporaryDirectory() as temporary:
|
with self.subTest(text=text), TemporaryDirectory() as temporary:
|
||||||
@@ -354,14 +360,44 @@ class SkuEvidenceSanitizerTests(unittest.TestCase):
|
|||||||
for raw_fragment in (TEST_ADDRESS, FULL_PHONE, "使用微信支付", "提交订单", "¥12.88"):
|
for raw_fragment in (TEST_ADDRESS, FULL_PHONE, "使用微信支付", "提交订单", "¥12.88"):
|
||||||
self.assertNotIn(raw_fragment, message)
|
self.assertNotIn(raw_fragment, message)
|
||||||
|
|
||||||
|
def test_observed_prefix_diagnostic_does_not_echo_its_suffix(self) -> None:
|
||||||
|
suffix = "地址和金额都不得回显"
|
||||||
|
text = f"快要抢光 ¥12.88 {suffix}"
|
||||||
|
with TemporaryDirectory() as temporary:
|
||||||
|
raw = _write_raw(Path(temporary), xml=_xml_with_prices(current=text))
|
||||||
|
with self.assertRaises(SkuEvidenceSanitizationError) as raised:
|
||||||
|
sanitize_sku_panel_evidence(raw, raw.parent / "derived")
|
||||||
|
|
||||||
|
message = str(raised.exception)
|
||||||
|
self.assertEqual(
|
||||||
|
message,
|
||||||
|
"跨界价格节点文本不匹配:slot=[396,503][712,570];"
|
||||||
|
"reason=observed_prefix_kuaiyaoqiangguang。",
|
||||||
|
)
|
||||||
|
self.assertNotIn(suffix, message)
|
||||||
|
self.assertNotIn("¥12.88", message)
|
||||||
|
|
||||||
def test_crossing_price_projection_allows_only_limited_ascii_spaces_and_yen_variants(self) -> None:
|
def test_crossing_price_projection_allows_only_limited_ascii_spaces_and_yen_variants(self) -> None:
|
||||||
for current in ("快卖光 ¥12.88", " 快卖光 ¥ 12.88 ", "快卖光 ¥12.88"):
|
for current in ("快卖完 ¥12.88", " 快卖完 ¥ 12.88 ", "快卖完 ¥12.88"):
|
||||||
with self.subTest(current=current), TemporaryDirectory() as temporary:
|
with self.subTest(current=current), TemporaryDirectory() as temporary:
|
||||||
raw = _write_raw(Path(temporary), xml=_xml_with_prices(current=current))
|
raw = _write_raw(Path(temporary), xml=_xml_with_prices(current=current))
|
||||||
result = sanitize_sku_panel_evidence(raw, raw.parent / "derived")
|
result = sanitize_sku_panel_evidence(raw, raw.parent / "derived")
|
||||||
hierarchy = result.hierarchy_path.read_text(encoding="utf-8")
|
hierarchy = result.hierarchy_path.read_text(encoding="utf-8")
|
||||||
self.assertIn(current, hierarchy)
|
self.assertIn(current, hierarchy)
|
||||||
|
|
||||||
|
def test_unverified_or_missing_current_price_prefixes_fail_closed(self) -> None:
|
||||||
|
cases = (
|
||||||
|
("old-prefix", "快卖光 ¥12.88"),
|
||||||
|
("bottom-button-prefix", "快要抢光 ¥12.88"),
|
||||||
|
("missing-prefix", "¥12.88"),
|
||||||
|
)
|
||||||
|
for name, current in cases:
|
||||||
|
with self.subTest(name=name), TemporaryDirectory() as temporary:
|
||||||
|
raw = _write_raw(Path(temporary), xml=_xml_with_prices(current=current))
|
||||||
|
with self.assertRaises(SkuEvidenceSanitizationError):
|
||||||
|
sanitize_sku_panel_evidence(raw, raw.parent / "derived")
|
||||||
|
self.assertFalse((raw.parent / "derived").exists())
|
||||||
|
|
||||||
def test_unique_current_price_without_original_price_is_published(self) -> None:
|
def test_unique_current_price_without_original_price_is_published(self) -> None:
|
||||||
with TemporaryDirectory() as temporary:
|
with TemporaryDirectory() as temporary:
|
||||||
raw = _write_raw(Path(temporary), xml=_xml_with_prices(include_original=False))
|
raw = _write_raw(Path(temporary), xml=_xml_with_prices(include_original=False))
|
||||||
@@ -375,7 +411,7 @@ class SkuEvidenceSanitizerTests(unittest.TestCase):
|
|||||||
|
|
||||||
def test_crossing_price_projection_rejects_newline_and_structure_drift(self) -> None:
|
def test_crossing_price_projection_rejects_newline_and_structure_drift(self) -> None:
|
||||||
encoded_newline = _price_node(CURRENT_PRICE, PRICE_CURRENT_BOUNDS).replace(
|
encoded_newline = _price_node(CURRENT_PRICE, PRICE_CURRENT_BOUNDS).replace(
|
||||||
"快卖光 ¥12.88", "快卖光 ¥12.88"
|
"快卖完 ¥12.88", "快卖完 ¥12.88"
|
||||||
)
|
)
|
||||||
with TemporaryDirectory() as temporary:
|
with TemporaryDirectory() as temporary:
|
||||||
raw = _write_raw(Path(temporary), xml=_xml_with_prices(current_node=encoded_newline))
|
raw = _write_raw(Path(temporary), xml=_xml_with_prices(current_node=encoded_newline))
|
||||||
@@ -409,7 +445,7 @@ class SkuEvidenceSanitizerTests(unittest.TestCase):
|
|||||||
),
|
),
|
||||||
(
|
(
|
||||||
"duplicate-current",
|
"duplicate-current",
|
||||||
_xml_with_prices(current=CURRENT_PRICE, original=f"快卖光 {ORIGINAL_PRICE}"),
|
_xml_with_prices(current=CURRENT_PRICE, original=f"快卖完 {ORIGINAL_PRICE}"),
|
||||||
),
|
),
|
||||||
(
|
(
|
||||||
"multiple-original",
|
"multiple-original",
|
||||||
|
|||||||
@@ -1,69 +0,0 @@
|
|||||||
---
|
|
||||||
id: T-201
|
|
||||||
title: 管理员登录与会话
|
|
||||||
phase: 2
|
|
||||||
deps: [T-004, T-005]
|
|
||||||
status: DONE
|
|
||||||
created: 2026-08-04
|
|
||||||
vikunja_task_id: 26
|
|
||||||
context_ref: 80ed9b7
|
|
||||||
work_branch: task/t-201-admin-session
|
|
||||||
needs_device: false
|
|
||||||
needs_human_review: false
|
|
||||||
write_paths:
|
|
||||||
- docs/tasks/T-201.md
|
|
||||||
- admin/cmd/server/main.go
|
|
||||||
- admin/internal/config/**
|
|
||||||
- admin/internal/server/**
|
|
||||||
- admin/internal/auth/**
|
|
||||||
- admin/internal/transport/webui/**
|
|
||||||
- admin/go.mod
|
|
||||||
- admin/go.sum
|
|
||||||
- admin/README.md
|
|
||||||
---
|
|
||||||
|
|
||||||
<!-- BEGIN VIKUNJA EXPORT id=26 synced=2026-08-04T07:04:10Z sha256=25607da2b9c35781f56fdea1f399bdfb65b76fa9a5ca8210693714793f5ef5f4 -->
|
|
||||||
## 问题 / 背景
|
|
||||||
|
|
||||||
采购服务已有 Go/Gin/SQLite 骨架和核心模型,但没有管理员会话。T-201 与真机规格字段无关,可在 T-103 进行时并行。
|
|
||||||
|
|
||||||
## 关联需求与交互
|
|
||||||
|
|
||||||
F-013、US-007;路由 GET/POST /login、POST /logout、受保护的 GET /tasks 空壳;沿用已确认采购服务原型。
|
|
||||||
|
|
||||||
## 方案
|
|
||||||
|
|
||||||
1. 启动时从环境变量读取管理员用户名、bcrypt 密码哈希、至少 32 字节 session secret;缺失或无效时明确失败,不提供默认凭据,不打印秘密。
|
|
||||||
2. 使用签名会话和 CSRF;登录成功轮换会话,Cookie 为 HttpOnly、SameSite,Secure 由显式配置控制。
|
|
||||||
3. return_to 仅接受 /tasks 及其子路径,拒绝绝对 URL、双斜杠、反斜杠和其他站内路径。
|
|
||||||
4. 实现登录、登出与受保护的任务空壳;/healthz 保持公开。
|
|
||||||
5. 不改数据库 schema,不实现设备 Bearer、建单、试选、授权、提交或付款。
|
|
||||||
|
|
||||||
## 验收要点
|
|
||||||
|
|
||||||
- 覆盖未登录跳转、成功登录与会话轮换、统一失败错误、CSRF 拒绝、登出失效和开放重定向拦截。
|
|
||||||
- 无默认账号/密码/密钥,日志与响应不泄露凭据。
|
|
||||||
- go test ./...、go vet ./...、go build ./...、上下文校验与 diff-check 通过。
|
|
||||||
|
|
||||||
## 执行记录
|
|
||||||
|
|
||||||
### 2026-08-04T06:58:30Z · ila
|
|
||||||
|
|
||||||
2026-08-04 T-201 实现与主审完成:提交 47c0844 新增强制环境配置、bcrypt 校验、HMAC-SHA256 签名进程内 session、随机 CSRF、登录轮换、登出撤销、受保护 /tasks 空壳和无外部资源的 SSR 登录页。主 agent 首轮退回 /tasks/.. 返回路径绕过、含 CSRF 页面缺安全响应头、Cookie 篡改测试概率失效三项;修正后独立复跑 go test ./...、go test -race ./...、go vet ./...、go build ./...、上下文校验与 diff-check,全部通过。实现未包含建单、试选、真机字段、授权、提交或付款;T-201 验收通过。
|
|
||||||
|
|
||||||
### 2026-08-04T07:03:54Z · ila
|
|
||||||
|
|
||||||
集成门禁补充:T-201 分支已合入 main@88d8f77(含 T-103 最新安全诊断),随后在独立 Windows worktree 运行完整 init.ps1,admin test/vet/build、client 78 项测试/compileall、editable install 与 agent-context 校验全部通过。首次运行因 120 秒工具超时中断在大体积 PySide6 依赖安装,未产生代码或证据问题;复用已建 venv 后完整成功。
|
|
||||||
<!-- END VIKUNJA EXPORT -->
|
|
||||||
|
|
||||||
## 边界
|
|
||||||
|
|
||||||
- 启动配置只接受显式环境变量中的管理员用户名、bcrypt 密码哈希和至少 32 字节 session secret;
|
|
||||||
不得内置开发默认值、明文密码或密钥,不得打印、回显或提交凭据。
|
|
||||||
- 本任务只实现 `/login`、`/logout`、受保护的 `/tasks` 空壳和保持公开的 `/healthz`;不实现建单、
|
|
||||||
查询、批量开始试选、设备 Bearer、真机自动化或任务状态流转。
|
|
||||||
- 不增加或修改数据库 schema,不读写 `spec_trials`、授权或 `order_submissions`,不实现任何提交订单、
|
|
||||||
付款、免密支付、先用后付或资金控件代码。
|
|
||||||
- 页面和测试不得夹带机器实际规格、规格面板价格、截图、证据哈希或任何 PDD 页面判据;这些仍等待
|
|
||||||
T-103 真机结论。
|
|
||||||
- `return_to` 必须是 `/tasks` 或其子路径;绝对 URL、`//`、反斜杠、编码绕过和其他站内路径均拒绝。
|
|
||||||
@@ -1,75 +0,0 @@
|
|||||||
---
|
|
||||||
id: T-202
|
|
||||||
title: 手工建单与 DRAFT 基础列表
|
|
||||||
phase: 2
|
|
||||||
deps: [T-201, T-004, T-005]
|
|
||||||
status: DONE
|
|
||||||
created: 2026-08-04
|
|
||||||
vikunja_task_id: 27
|
|
||||||
context_ref: 1c35155
|
|
||||||
work_branch: task/t-202-admin-draft
|
|
||||||
needs_device: false
|
|
||||||
needs_human_review: false
|
|
||||||
write_paths:
|
|
||||||
- docs/tasks/T-202.md
|
|
||||||
- admin/cmd/server/main.go
|
|
||||||
- admin/internal/config/**
|
|
||||||
- admin/internal/server/**
|
|
||||||
- admin/internal/tasks/**
|
|
||||||
- admin/internal/storage/sqlite/**
|
|
||||||
- admin/internal/transport/webui/**
|
|
||||||
- admin/README.md
|
|
||||||
---
|
|
||||||
|
|
||||||
<!-- BEGIN VIKUNJA EXPORT id=27 synced=2026-08-04T08:32:16Z sha256=39e3b06bab4ca86e97b961a4eb6bb0a4f1e88b29dae4d50f916779f7e761424c -->
|
|
||||||
## 问题 / 背景
|
|
||||||
|
|
||||||
T-201 已提供管理员会话;T-004 已提供 tasks 表。根据 T-010 加速门禁,T-103 尚未完成时只允许实现不启动试选的 DRAFT 手工建单与基础列表。
|
|
||||||
|
|
||||||
## 关联需求与交互
|
|
||||||
|
|
||||||
F-001、US-001、IX-002;GET /tasks、GET /tasks/new、POST /tasks;沿用已确认的传统表格与创建弹窗/直达页。
|
|
||||||
|
|
||||||
## 方案
|
|
||||||
|
|
||||||
1. 显式数据库配置并打开已迁移 SQLite;以仓储接口隔离 HTTP 和 SQL,创建事务只写 MANUAL、DRAFT、version=1。
|
|
||||||
2. 表单校验任务名称、canonical 拼多多链接、颜色分类、尺码、正整数数量和正十进制总额上限;金额只用字符串并规范为两位小数。链接只接受 HTTPS mobile.yangkeduo.com/goods.html 且 goods_id 为唯一纯数字参数,额外查询参数不进入数据库。
|
|
||||||
3. 以服务端生成的 create_key 同时作为任务 ID;重复相同 key 和相同内容返回原结果,不创建第二条,内容不同则冲突。
|
|
||||||
4. GET /tasks 默认 created_at DESC 显示 DRAFT 基础表格;创建入口用服务端渲染的 modal 状态,/tasks/new 复用同一表单作为无脚本兜底;失败保留非密码输入并显示字段错误,成功 303 回列表且新任务第一行。
|
|
||||||
5. 页面只显示需求字段、采购结果占位、DRAFT 状态与创建时间;不读取或伪造规格面板价格/证据,不提供勾选开始试选、状态推进、详情或设备接口。
|
|
||||||
|
|
||||||
## 验收要点
|
|
||||||
|
|
||||||
- 覆盖创建成功、倒序第一行、严格链接/goods_id、数量、金额、空白/长度、CSRF/未登录、幂等重放与冲突、SQL 错误 fail closed。
|
|
||||||
- 弹窗与 /tasks/new 共享校验;错误保留输入并可访问;标题只链接到由 goods_id 重建的 canonical PDD URL并使用安全新标签属性。
|
|
||||||
- go test ./...、go test -race ./...、go vet ./...、go build ./...、完整 init.ps1、上下文校验和 diff-check 通过。
|
|
||||||
|
|
||||||
## 执行记录
|
|
||||||
|
|
||||||
### 2026-08-04T08:30:47Z · ila
|
|
||||||
|
|
||||||
已完成:DRAFT 手工建单与基础列表;已验证链接、金额、CSRF、幂等、SQLite 并发和 SSR 无障碍,Go 与上下文门禁均通过。
|
|
||||||
<!-- END VIKUNJA EXPORT -->
|
|
||||||
|
|
||||||
## 边界
|
|
||||||
|
|
||||||
- 本任务只创建 `source=MANUAL`、`status=DRAFT`、`version=1` 的任务并显示 DRAFT 基础列表;不得
|
|
||||||
实现勾选、批量开始试选、`DRAFT → PENDING` 或任何其他状态流转,也不得新增设备领取接口。
|
|
||||||
- 不增加或修改数据库 schema,不读写 `spec_trials`、`order_authorizations`、`order_submissions`,
|
|
||||||
不生成或展示机器实际规格、规格面板单价、截图、证据哈希或 PDD 页面判据。
|
|
||||||
- 启动服务必须从显式 `CMBUYER_DATABASE_SOURCE` 读取 SQLite data source;缺失时明确失败,不提供
|
|
||||||
隐式内存库或仓库内默认数据库。服务不自动猜迁移目录;README 必须先给出显式迁移命令。
|
|
||||||
- 商品链接只接受 `https://mobile.yangkeduo.com/goods.html`,且必须恰有一个纯数字 `goods_id`;
|
|
||||||
拒绝 userinfo、端口、fragment、重复参数、其他 host/scheme/path 和编码绕过。数据库只保存 goods_id,
|
|
||||||
展示链接由 goods_id 重建 canonical URL;`uin` 等额外查询参数既不保存也不回显。
|
|
||||||
- 标题、颜色分类、尺码必须去除首尾空白后非空并受明确长度上限约束;数量必须是可表示的正整数;
|
|
||||||
总额上限必须是大于零、最多两位小数的十进制字符串并规范为两位小数。金额校验、保存与展示均不得
|
|
||||||
使用浮点数或从其他数字推测。
|
|
||||||
- `create_key` 由服务端用 `crypto/rand` 生成并验证格式,同时作为任务 ID;相同 key 与相同规范化内容
|
|
||||||
重放只能返回原任务,不得二次 INSERT,相同 key 携带不同内容必须冲突。SQL 必须参数化,创建失败
|
|
||||||
不得留下半条或未知状态记录。
|
|
||||||
- `GET /tasks`、`GET /tasks/new`、`POST /tasks` 都必须复用 T-201 管理会话;POST 必须验证 CSRF。
|
|
||||||
校验失败保留非敏感输入并逐字段提示,数据库内部错误只给通用响应,不泄露 SQL、路径或凭据。
|
|
||||||
- 页面只使用服务端模板转义;标题商品链接在新标签打开时必须带 `noopener noreferrer`。导入按钮只作
|
|
||||||
禁用占位;不得加载外部资源或把原型假数据、真机数据、地址、手机号带进生产页面。
|
|
||||||
- 不实现或引用试选、数量设置、订单确认、提交围栏、提交订单、付款、免密支付或先用后付能力。
|
|
||||||
Reference in New Issue
Block a user