160 lines
4.6 KiB
Go
160 lines
4.6 KiB
Go
// 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
|
||
|
|
}
|