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