// 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 }