219 lines
6.6 KiB
Go
219 lines
6.6 KiB
Go
// Package tasks 定义手工 DRAFT 任务的校验与窄仓储边界。
|
|
package tasks
|
|
|
|
import (
|
|
"crypto/rand"
|
|
"encoding/hex"
|
|
"errors"
|
|
"net/url"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
"unicode/utf8"
|
|
)
|
|
|
|
const (
|
|
MaxTitleCodePoints = 120
|
|
MaxSKUTextCodePoints = 80
|
|
MaxGoodsIDCharacters = 32
|
|
MaxMoneyASCIICharacters = 32
|
|
maxSKUText = MaxSKUTextCodePoints
|
|
)
|
|
|
|
var (
|
|
ErrCreateKeyConflict = errors.New("create key conflicts with a different task")
|
|
ErrInvalidDraft = errors.New("invalid draft")
|
|
)
|
|
|
|
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 !validBoundedText(draft.Title, MaxTitleCodePoints) {
|
|
errors["title"] = "任务名称不能为空,且不能超过 120 个字符。"
|
|
}
|
|
if !validBoundedText(draft.SKUColor, MaxSKUTextCodePoints) {
|
|
errors["sku_color"] = "颜色分类不能为空,且不能超过 80 个字符。"
|
|
}
|
|
if !validBoundedText(draft.SKUSize, MaxSKUTextCodePoints) {
|
|
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
|
|
}
|
|
}
|
|
if !ValidGoodsID(goodsIDs[0]) {
|
|
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
|
|
}
|
|
canonical := whole + "." + (fraction + "00")[:2]
|
|
if len(canonical) > MaxMoneyASCIICharacters {
|
|
return "", false
|
|
}
|
|
return canonical, true
|
|
}
|
|
|
|
// ValidTaskWireFields is shared by creation, authorization and claim. Keeping one
|
|
// bounded domain prevents a database row from being valid in one stage but impossible
|
|
// to encode inside the fixed claim response budget in another stage.
|
|
func ValidTaskWireFields(title, goodsID, skuColor, skuSize, maxTotalPrice string) bool {
|
|
return validBoundedText(title, MaxTitleCodePoints) &&
|
|
ValidAuthorizationFields(goodsID, skuColor, skuSize, maxTotalPrice)
|
|
}
|
|
|
|
func ValidAuthorizationFields(goodsID, skuColor, skuSize, totalPriceCap string) bool {
|
|
return ValidGoodsID(goodsID) &&
|
|
validBoundedText(skuColor, MaxSKUTextCodePoints) &&
|
|
validBoundedText(skuSize, MaxSKUTextCodePoints) &&
|
|
ValidCanonicalMoney(totalPriceCap)
|
|
}
|
|
|
|
func ValidGoodsID(value string) bool {
|
|
if value == "" || len(value) > MaxGoodsIDCharacters {
|
|
return false
|
|
}
|
|
for index := 0; index < len(value); index++ {
|
|
if value[index] < '0' || value[index] > '9' {
|
|
return false
|
|
}
|
|
}
|
|
return true
|
|
}
|
|
|
|
func validBoundedText(value string, maximum int) bool {
|
|
// RuneCountInString replaces malformed byte sequences with RuneError. Validate first
|
|
// so corrupt SQLite text cannot consume the code-point budget as if it were legitimate.
|
|
if !utf8.ValidString(value) || value == "" || strings.TrimSpace(value) != value ||
|
|
utf8.RuneCountInString(value) > maximum {
|
|
return false
|
|
}
|
|
for _, character := range value {
|
|
// Python str.strip treats these four C0 separators as whitespace while Go
|
|
// TrimSpace does not. Reject them everywhere so both wire models have one
|
|
// explicit persisted-text domain instead of runtime-dependent trimming.
|
|
if character >= '\u001c' && character <= '\u001f' {
|
|
return false
|
|
}
|
|
}
|
|
return true
|
|
}
|