From f6cd65208db5f35155946a7855773ee56e7a9d65 Mon Sep 17 00:00:00 2001 From: QiuSW <105186638@qq.com> Date: Wed, 5 Aug 2026 01:39:35 +0800 Subject: [PATCH] fix(api): align claim bounds across runtime snapshots --- admin/internal/server/task_claims_test.go | 6 ++- admin/internal/taskclaim/store.go | 28 ++++++++--- admin/internal/taskclaim/store_test.go | 52 +++++++++++++------- admin/internal/tasks/tasks.go | 15 +++++- admin/internal/tasks/tasks_test.go | 30 +++++++++++ client/src/cmbuyer_client/core/models.py | 13 ++--- client/src/cmbuyer_client/core/validation.py | 18 +++++++ client/tests/core/test_models.py | 16 +++++- docs/api.md | 22 ++++++--- docs/tasks/T-211.md | 18 +++++-- 10 files changed, 165 insertions(+), 53 deletions(-) diff --git a/admin/internal/server/task_claims_test.go b/admin/internal/server/task_claims_test.go index 0b25a54..5b83a78 100644 --- a/admin/internal/server/task_claims_test.go +++ b/admin/internal/server/task_claims_test.go @@ -6,6 +6,7 @@ import ( "encoding/json" "errors" "io" + "math" "net/http" "net/http/httptest" "strings" @@ -103,12 +104,12 @@ func TestClaimResponseWorstLegalFieldsStayBelowCapAndInvalidServiceOutputFailsCl goodsID := strings.Repeat("1", 32) worst := taskclaim.ClaimResponse{ Task: taskclaim.ClaimedTask{ - ID: claimTaskID, Version: 3, Title: strings.Repeat("<", 120), + ID: claimTaskID, Version: math.MaxInt, Title: strings.Repeat("<", 120), ProductURL: "https://mobile.yangkeduo.com/goods.html?goods_id=" + goodsID, GoodsID: goodsID, SKUColor: strings.Repeat("<", 80), SKUSize: strings.Repeat("<", 80), Quantity: 9_223_372_036_854_775_807, MaxTotalPrice: strings.Repeat("9", 29) + ".00", }, - Authorization: taskclaim.ClaimedAuthorization{ID: "70000000-0000-4000-8000-000000000001", TaskVersion: 2, ExpiresAt: "9999-12-31T23:59:59.999999999Z"}, + Authorization: taskclaim.ClaimedAuthorization{ID: "70000000-0000-4000-8000-000000000001", TaskVersion: math.MaxInt - 1, ExpiresAt: "9999-12-31T23:59:59.999999999Z"}, Attempt: taskclaim.ClaimedAttempt{ID: claimAttemptID, ClaimToken: strings.Repeat("a", 64), ClaimGeneration: 9_223_372_036_854_775_807, LeaseExpiresAt: "9999-12-31T23:59:59.999999999Z"}, } authenticator := &fakeDeviceAuthenticator{principal: deviceauth.Principal{ID: claimDeviceID}} @@ -122,6 +123,7 @@ func TestClaimResponseWorstLegalFieldsStayBelowCapAndInvalidServiceOutputFailsCl mutations := map[string]func(*taskclaim.ClaimResponse){ "invalid utf8 title": func(response *taskclaim.ClaimResponse) { response.Task.Title = string([]byte{0xff}) }, + "c0 separator title": func(response *taskclaim.ClaimResponse) { response.Task.Title = "visible\u001dhidden" }, "overlong title": func(response *taskclaim.ClaimResponse) { response.Task.Title += "<" }, "overlong goods id": func(response *taskclaim.ClaimResponse) { response.Task.GoodsID += "1" diff --git a/admin/internal/taskclaim/store.go b/admin/internal/taskclaim/store.go index 30d0faa..a8ba91c 100644 --- a/admin/internal/taskclaim/store.go +++ b/admin/internal/taskclaim/store.go @@ -647,6 +647,9 @@ func (store *Store) responseFor(record claimRecord, responseLease string) (Claim type candidate struct { AuthorizationID, TaskID, Title, GoodsID, SKUColor, SKUSize, TotalPriceCap string TaskVersion, Quantity int + AuthorizationTaskVersion, AuthorizationQuantity int + AuthorizationGoodsID, AuthorizationSKUColor, AuthorizationSKUSize string + AuthorizationTotalPriceCap string AuthorizationExpiresText string AuthorizationExpiresAt time.Time } @@ -654,16 +657,12 @@ type candidate struct { func findCandidate(ctx context.Context, transaction *sql.Tx, now time.Time) (candidate, bool, error) { rows, err := transaction.QueryContext(ctx, `SELECT authorizations.id, tasks.id, tasks.version, tasks.title, tasks.goods_id, tasks.sku_color, tasks.sku_size, tasks.quantity, - tasks.max_total_price, authorizations.expires_at + tasks.max_total_price, authorizations.task_version, authorizations.goods_id, + authorizations.sku_color, authorizations.sku_size, authorizations.quantity, + authorizations.total_price_cap, authorizations.expires_at FROM order_authorizations AS authorizations JOIN tasks ON tasks.id = authorizations.task_id WHERE authorizations.status = 'ACTIVE' AND tasks.status = 'PENDING' - AND authorizations.task_version = tasks.version - AND authorizations.goods_id = tasks.goods_id - AND authorizations.sku_color = tasks.sku_color - AND authorizations.sku_size = tasks.sku_size - AND authorizations.quantity = tasks.quantity - AND authorizations.total_price_cap = tasks.max_total_price ORDER BY authorizations.created_at, authorizations.rowid, authorizations.id`) if err != nil { return candidate{}, false, err @@ -673,6 +672,8 @@ func findCandidate(ctx context.Context, transaction *sql.Tx, now time.Time) (can var item candidate if err := rows.Scan(&item.AuthorizationID, &item.TaskID, &item.TaskVersion, &item.Title, &item.GoodsID, &item.SKUColor, &item.SKUSize, &item.Quantity, &item.TotalPriceCap, + &item.AuthorizationTaskVersion, &item.AuthorizationGoodsID, &item.AuthorizationSKUColor, + &item.AuthorizationSKUSize, &item.AuthorizationQuantity, &item.AuthorizationTotalPriceCap, &item.AuthorizationExpiresText); err != nil { return candidate{}, false, err } @@ -683,6 +684,9 @@ func findCandidate(ctx context.Context, transaction *sql.Tx, now time.Time) (can if !validCandidate(item) { return candidate{}, false, errors.New("stored claim candidate is invalid") } + if !candidateSnapshotMatches(item) { + continue + } if item.AuthorizationExpiresAt.After(now) { if err := rows.Close(); err != nil { return candidate{}, false, err @@ -699,7 +703,15 @@ func findCandidate(ctx context.Context, transaction *sql.Tx, now time.Time) (can func validCandidate(item candidate) bool { return validUUID(item.AuthorizationID) && validUUID(item.TaskID) && item.TaskVersion > 0 && item.TaskVersion < math.MaxInt && taskmodel.ValidTaskWireFields(item.Title, item.GoodsID, item.SKUColor, item.SKUSize, item.TotalPriceCap) && - item.Quantity > 0 + item.Quantity > 0 && item.AuthorizationTaskVersion > 0 && item.AuthorizationTaskVersion < math.MaxInt && + taskmodel.ValidAuthorizationFields(item.AuthorizationGoodsID, item.AuthorizationSKUColor, + item.AuthorizationSKUSize, item.AuthorizationTotalPriceCap) && item.AuthorizationQuantity > 0 +} + +func candidateSnapshotMatches(item candidate) bool { + return item.AuthorizationTaskVersion == item.TaskVersion && item.AuthorizationGoodsID == item.GoodsID && + item.AuthorizationSKUColor == item.SKUColor && item.AuthorizationSKUSize == item.SKUSize && + item.AuthorizationQuantity == item.Quantity && item.AuthorizationTotalPriceCap == item.TotalPriceCap } func validAttemptStatus(value sql.NullString) bool { diff --git a/admin/internal/taskclaim/store_test.go b/admin/internal/taskclaim/store_test.go index 4986e87..d0a04b8 100644 --- a/admin/internal/taskclaim/store_test.go +++ b/admin/internal/taskclaim/store_test.go @@ -195,31 +195,41 @@ func TestClaimRollsBackEveryBusinessMutationOnLateFailure(t *testing.T) { func TestClaimRejectsOutOfBoundsCandidatesWithoutBusinessMutation(t *testing.T) { mutations := map[string]func(*testing.T, *sql.DB){ - "invalid utf8 title": func(t *testing.T, database *sql.DB) { + "task invalid utf8 title": func(t *testing.T, database *sql.DB) { execClaimSQL(t, database, `UPDATE tasks SET title=? WHERE id=?`, string([]byte{0xff}), testTaskA) }, - "overlong title": func(t *testing.T, database *sql.DB) { + "task overlong title": func(t *testing.T, database *sql.DB) { execClaimSQL(t, database, `UPDATE tasks SET title=? WHERE id=?`, strings.Repeat("😀", 121), testTaskA) }, - "overlong goods id": func(t *testing.T, database *sql.DB) { - value := strings.Repeat("1", 33) - execClaimSQL(t, database, `UPDATE tasks SET goods_id=? WHERE id=?`, value, testTaskA) - execClaimSQL(t, database, `UPDATE order_authorizations SET goods_id=? WHERE id=?`, value, testAuthA) + "task overlong goods id": func(t *testing.T, database *sql.DB) { + execClaimSQL(t, database, `UPDATE tasks SET goods_id=? WHERE id=?`, strings.Repeat("1", 33), testTaskA) }, - "overlong color": func(t *testing.T, database *sql.DB) { - value := strings.Repeat("色", 81) - execClaimSQL(t, database, `UPDATE tasks SET sku_color=? WHERE id=?`, value, testTaskA) - execClaimSQL(t, database, `UPDATE order_authorizations SET sku_color=? WHERE id=?`, value, testAuthA) + "task invalid utf8 color": func(t *testing.T, database *sql.DB) { + execClaimSQL(t, database, `UPDATE tasks SET sku_color=? WHERE id=?`, string([]byte{0xff}), testTaskA) }, - "overlong size": func(t *testing.T, database *sql.DB) { - value := strings.Repeat("码", 81) - execClaimSQL(t, database, `UPDATE tasks SET sku_size=? WHERE id=?`, value, testTaskA) - execClaimSQL(t, database, `UPDATE order_authorizations SET sku_size=? WHERE id=?`, value, testAuthA) + "task overlong color": func(t *testing.T, database *sql.DB) { + execClaimSQL(t, database, `UPDATE tasks SET sku_color=? WHERE id=?`, strings.Repeat("色", 81), testTaskA) }, - "overlong money": func(t *testing.T, database *sql.DB) { - value := strings.Repeat("1", 30) + ".00" - execClaimSQL(t, database, `UPDATE tasks SET max_total_price=? WHERE id=?`, value, testTaskA) - execClaimSQL(t, database, `UPDATE order_authorizations SET total_price_cap=? WHERE id=?`, value, testAuthA) + "task overlong size": func(t *testing.T, database *sql.DB) { + execClaimSQL(t, database, `UPDATE tasks SET sku_size=? WHERE id=?`, strings.Repeat("码", 81), testTaskA) + }, + "task overlong money": func(t *testing.T, database *sql.DB) { + execClaimSQL(t, database, `UPDATE tasks SET max_total_price=? WHERE id=?`, strings.Repeat("1", 30)+".00", testTaskA) + }, + "authorization overlong goods id": func(t *testing.T, database *sql.DB) { + execClaimSQL(t, database, `UPDATE order_authorizations SET goods_id=? WHERE id=?`, strings.Repeat("1", 33), testAuthA) + }, + "authorization invalid utf8 color": func(t *testing.T, database *sql.DB) { + execClaimSQL(t, database, `UPDATE order_authorizations SET sku_color=? WHERE id=?`, string([]byte{0xff}), testAuthA) + }, + "authorization overlong color": func(t *testing.T, database *sql.DB) { + execClaimSQL(t, database, `UPDATE order_authorizations SET sku_color=? WHERE id=?`, strings.Repeat("色", 81), testAuthA) + }, + "authorization overlong size": func(t *testing.T, database *sql.DB) { + execClaimSQL(t, database, `UPDATE order_authorizations SET sku_size=? WHERE id=?`, strings.Repeat("码", 81), testAuthA) + }, + "authorization overlong money": func(t *testing.T, database *sql.DB) { + execClaimSQL(t, database, `UPDATE order_authorizations SET total_price_cap=? WHERE id=?`, strings.Repeat("1", 30)+".00", testAuthA) }, } for name, mutate := range mutations { @@ -234,6 +244,12 @@ func TestClaimRejectsOutOfBoundsCandidatesWithoutBusinessMutation(t *testing.T) t.Fatalf("ClaimNext = found %v, err %v; want closed failure", found, err) } assertClaimState(t, database, 0, "PENDING", "ACTIVE") + for _, table := range []string{"purchase_attempts", "task_claim_requests"} { + var count int + if err := database.QueryRow("SELECT COUNT(*) FROM " + table).Scan(&count); err != nil || count != 0 { + t.Fatalf("%s rows after invalid candidate = %d, err %v", table, count, err) + } + } }) } } diff --git a/admin/internal/tasks/tasks.go b/admin/internal/tasks/tasks.go index 8382edd..7d9ab90 100644 --- a/admin/internal/tasks/tasks.go +++ b/admin/internal/tasks/tasks.go @@ -202,6 +202,17 @@ func ValidGoodsID(value string) bool { 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. - return utf8.ValidString(value) && value != "" && strings.TrimSpace(value) == value && - utf8.RuneCountInString(value) <= maximum + 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 } diff --git a/admin/internal/tasks/tasks_test.go b/admin/internal/tasks/tasks_test.go index 555302d..c495540 100644 --- a/admin/internal/tasks/tasks_test.go +++ b/admin/internal/tasks/tasks_test.go @@ -107,6 +107,35 @@ func TestValidateAcceptsWorstLegalUnicodeFieldBounds(t *testing.T) { } } +func TestPersistedTextHasRuntimeIndependentC0AndNBSPDomain(t *testing.T) { + for name, invalid := range map[string]string{ + "c0 prefix": "\u001cvalue", + "c0 suffix": "value\u001f", + "c0 interior": "value\u001dinside", + "nbsp prefix": "\u00a0value", + "nbsp suffix": "value\u00a0", + } { + t.Run(name, func(t *testing.T) { + if validBoundedText(invalid, MaxTitleCodePoints) { + t.Fatalf("validBoundedText(%q) accepted runtime-dependent text", invalid) + } + }) + } + if !validBoundedText("left\u00a0right", MaxTitleCodePoints) { + t.Fatal("interior NBSP must remain a valid Unicode code point") + } + + // Manual form input is normalized with Go TrimSpace before persistence. + draft, validation := Validate(Form{ + CreateKey: testKey, Title: "\u00a0title\u00a0", + ProductURL: CanonicalURL("1"), SKUColor: "\u00a0black\u00a0", + SKUSize: "\u00a0M\u00a0", Quantity: "1", MaxTotalPrice: "1", + }) + if !validation.Valid() || draft.Title != "title" || draft.SKUColor != "black" || draft.SKUSize != "M" { + t.Fatalf("NBSP form normalization = %#v, errors = %#v", draft, validation) + } +} + func TestNewCreateKeyIsUUIDv4(t *testing.T) { key, err := NewCreateKey() if err != nil { @@ -179,6 +208,7 @@ func TestSQLiteStoreCreatesListsAndHandlesIdempotency(t *testing.T) { func TestSQLiteStoreRejectsInvalidDraftAtPersistenceBoundary(t *testing.T) { mutations := map[string]func(*Draft){ "untrimmed title": func(draft *Draft) { draft.Title = " title" }, + "c0 interior title": func(draft *Draft) { draft.Title = "title\u001dhidden" }, "invalid utf8 title": func(draft *Draft) { draft.Title = string([]byte{0xff}) }, "long title": func(draft *Draft) { draft.Title = strings.Repeat("😀", MaxTitleCodePoints+1) }, "long color": func(draft *Draft) { draft.SKUColor = strings.Repeat("色", MaxSKUTextCodePoints+1) }, diff --git a/client/src/cmbuyer_client/core/models.py b/client/src/cmbuyer_client/core/models.py index 7003301..f58f026 100644 --- a/client/src/cmbuyer_client/core/models.py +++ b/client/src/cmbuyer_client/core/models.py @@ -15,6 +15,7 @@ from .validation import ( require_goods_id, require_lower_hex_64, require_money, + require_persisted_text, require_positive_int, require_rfc3339_z, rfc3339_z_nanoseconds, @@ -85,18 +86,12 @@ class PurchaseTask: def __post_init__(self) -> None: require_uuid4(self.id, "invalid_task_id") require_positive_int(self.version, "invalid_task_version") - require_string(self.title, "invalid_task_title", maximum=MAX_TITLE_CODE_POINTS) - if not self.title.strip() or self.title.strip() != self.title: - raise ValidationError("invalid_task_title") + require_persisted_text(self.title, "invalid_task_title", maximum=MAX_TITLE_CODE_POINTS) require_goods_id(self.goods_id) if self.product_url != canonical_product_url(self.goods_id): raise ValidationError("invalid_product_url") - require_string(self.sku_color, "invalid_sku_color", maximum=MAX_SKU_TEXT_CODE_POINTS) - require_string(self.sku_size, "invalid_sku_size", maximum=MAX_SKU_TEXT_CODE_POINTS) - if self.sku_color.strip() != self.sku_color: - raise ValidationError("invalid_sku_color") - if self.sku_size.strip() != self.sku_size: - raise ValidationError("invalid_sku_size") + require_persisted_text(self.sku_color, "invalid_sku_color", maximum=MAX_SKU_TEXT_CODE_POINTS) + require_persisted_text(self.sku_size, "invalid_sku_size", maximum=MAX_SKU_TEXT_CODE_POINTS) require_positive_int(self.quantity, "invalid_quantity") require_money(self.max_total_price, "invalid_max_total_price") diff --git a/client/src/cmbuyer_client/core/validation.py b/client/src/cmbuyer_client/core/validation.py index 90e82e8..4a5fe11 100644 --- a/client/src/cmbuyer_client/core/validation.py +++ b/client/src/cmbuyer_client/core/validation.py @@ -25,6 +25,10 @@ MAX_TITLE_CODE_POINTS = 120 MAX_SKU_TEXT_CODE_POINTS = 80 MAX_GOODS_ID_ASCII_CHARACTERS = 32 MAX_MONEY_ASCII_CHARACTERS = 32 +# Go strings.TrimSpace uses Unicode White_Space plus the six ASCII space +# characters below, but unlike Python str.strip it does not include U+001C-- +# U+001F. Keep the wire contract independent of either runtime's defaults. +GO_UNICODE_WHITE_SPACE = "\t\n\v\f\r \u0085\u00a0\u1680\u2000\u2001\u2002\u2003\u2004\u2005\u2006\u2007\u2008\u2009\u200a\u2028\u2029\u202f\u205f\u3000" def require_string(value: object, reason: str, *, maximum: int = 4096) -> str: @@ -35,6 +39,20 @@ def require_string(value: object, reason: str, *, maximum: int = 4096) -> str: return value +def require_persisted_text(value: object, reason: str, *, maximum: int) -> str: + """Validate text stored by Go after TrimSpace, without Python trim drift.""" + + text = require_string(value, reason, maximum=maximum) + if text.strip(GO_UNICODE_WHITE_SPACE) != text: + raise ValidationError(reason) + # Python str.strip treats these C0 separators as whitespace while Go does + # not. Reject them anywhere on both ends instead of assigning them two + # runtime-dependent meanings. + if any(0x1C <= ord(character) <= 0x1F for character in text): + raise ValidationError(reason) + return text + + def require_uuid4(value: object, reason: str = "invalid_uuid") -> str: text = require_string(value, reason, maximum=36) if UUID4_RE.fullmatch(text) is None: diff --git a/client/tests/core/test_models.py b/client/tests/core/test_models.py index 87ff739..a312a3e 100644 --- a/client/tests/core/test_models.py +++ b/client/tests/core/test_models.py @@ -118,6 +118,7 @@ class CoreModelsTests(unittest.TestCase): legal = claim_wire() legal_goods = "1" * 32 legal["task"].update( + version=9_223_372_036_854_775_807, title="😀" * 120, goods_id=legal_goods, product_url="https://mobile.yangkeduo.com/goods.html?goods_id=" + legal_goods, @@ -125,6 +126,7 @@ class CoreModelsTests(unittest.TestCase): sku_size="码" * 80, max_total_price="1" * 29 + ".00", ) + legal["authorization"]["task_version"] = 9_223_372_036_854_775_806 claimed = ClaimedTask.from_wire(legal) self.assertEqual(len(claimed.task.title), 120) # Python's default ensure_ascii=True expands astral characters to surrogate @@ -166,8 +168,20 @@ class CoreModelsTests(unittest.TestCase): self.assertEqual(ClaimedTask.from_wire(value).task.title, "😀") def test_title_rejects_ascii_and_unicode_whitespace_only(self) -> None: - for title in ("", " \t\r\n", "\u3000", " \u3000\t"): + for title in ("", " \t\r\n", "\u3000", " \u3000\t", "\u00a0title", "title\u00a0"): value = claim_wire() value["task"]["title"] = title with self.subTest(title=repr(title)), self.assertRaises(ValidationError): ClaimedTask.from_wire(value) + + def test_persisted_text_has_runtime_independent_c0_and_nbsp_domain(self) -> None: + for field in ("title", "sku_color", "sku_size"): + for invalid in ("\u001cvalue", "value\u001f", "value\u001dinside", "\u00a0value", "value\u00a0"): + value = claim_wire() + value["task"][field] = invalid + with self.subTest(field=field, invalid=repr(invalid)), self.assertRaises(ValidationError): + ClaimedTask.from_wire(value) + + value = claim_wire() + value["task"][field] = "left\u00a0right" + self.assertEqual(getattr(ClaimedTask.from_wire(value).task, field), "left\u00a0right") diff --git a/docs/api.md b/docs/api.md index 1dab938..8eb110c 100644 --- a/docs/api.md +++ b/docs/api.md @@ -96,9 +96,10 @@ ``` - 服务端解析并保存 canonical URL 与 `goods_id`;URL 非拼多多商品页、`goods_id` 缺失或含歧义则拒绝。 -- `title` 以 `TrimSpace` 后持久化值计,最多 120 个 Unicode code point;`sku_color`、`sku_size` - 同样按持久化值计,各最多 80 个 Unicode code point。非法 UTF-8 必须先拒绝,不能把替换字符当作 - 合法 code point;超限不得截断。 +- `title` 以 Go `strings.TrimSpace`(Unicode White_Space)后的持久化值计,最多 120 个 Unicode code + point;`sku_color`、`sku_size` 同样按持久化值计,各最多 80 个 Unicode code point。非法 UTF-8 + 必须先拒绝,不能把替换字符当作合法 code point;为消除 Go 与 Python 默认 trim 差异,U+001C--U+001F + 四个 C0 分隔符无论位置一律拒绝;超限不得截断。 - `goods_id` 只允许 1--32 位 ASCII 数字;规范金额只允许 1--32 个 ASCII 字符。 - `max_total_price` 是本任务允许创建待付款订单的总额上限,不是参考单价。 - 成功只产生 `DRAFT`;不得创建授权、开放设备领取或触发真机。 @@ -228,7 +229,9 @@ - 响应内 `title` 最多 120 个 Unicode code point,`sku_color` / `sku_size` 各最多 80 个;`goods_id` 为 1--32 位 ASCII 数字,`max_total_price` 为最多 32 个 ASCII 字符的规范金额。创建、授权快照、 candidate、持久化 claim snapshot 和 HTTP 输出共用同一合法域;既有畸形行只失败闭合,不迁移、 - 截断或改写。最坏合法字段组合编码后必须明确小于既有 32 KiB claim 响应上限。 + 截断或改写。candidate 查询必须先读取 task 与 authorization 两侧字段并分别验证;任一侧畸形必须 + 回滚且不得持久化 `EMPTY`,只有两侧均合法但快照不一致时才跳过。最坏合法字段组合编码后必须明确 + 小于既有 32 KiB claim 响应上限。 - 一个设备最多有一个未关闭 claim。同 session 且租约有效时重放原 attempt;同一 attempt 已按服务端 首事件原子进入 `ORDERING` 时也只在 task version 恰好为 claim 版本 +1 时恢复。不同 session、租约 过期或业务状态异常固定返回 `409 {"error":"claim_requires_manual"}`,不释放、不转领、不新建 attempt。 @@ -374,9 +377,11 @@ claim/renew 的格式错误固定为 `400 {"error":"invalid_request"}`,超限 ### 文本和金额校验 -- 标题/规格字段:输入按既有 `TrimSpace` 形成实际持久化值;title 最多 120 个 Unicode code point, - 颜色与尺码各最多 80 个。服务端先拒绝非法 UTF-8,再计 code point;不按 UTF-8 字节或视觉 grapheme - 计数,不截断超限值。规格比较仍为规范化后精确相等;不得包含、前缀、编辑距离或 AI 猜测。 +- 标题/规格字段:输入按 Go `strings.TrimSpace` 的 Unicode White_Space 集形成实际持久化值;title 最多 + 120 个 Unicode code point,颜色与尺码各最多 80 个。服务端先拒绝非法 UTF-8,再计 code point; + U+001C--U+001F 四个 C0 分隔符在任意位置均拒绝,客户端使用同一固定空白集而不依赖 Python + `str.strip()` 默认语义。不按 UTF-8 字节或视觉 grapheme 计数,不截断超限值。规格比较仍为规范化后 + 精确相等;不得包含、前缀、编辑距离或 AI 猜测。 - `goods_id`:仅 1--32 位 ASCII 十进制数字,canonical URL 中唯一。 - 金额:`0.01` 到系统配置上限,至多两位小数;规范化后必须是最多 32 个 ASCII 字符,再比较和持久化。 - 数量:正整数,服务端与设备均设置合理上限;不能从字符串静默截断。 @@ -402,7 +407,8 @@ events/fail/fence/result 或完整 `ResultSink`。T-304/T-306 必须通过 `Dura 金额按服务端合法域接受规范 ASCII 十进制正数字符串(最低 `0.01`,恰好两位小数、无前导零,最多 32 个 ASCII 字符);`goods_id` 只接受 1--32 位 ASCII 数字,title 最多 120 个 Unicode code point, -颜色与尺码各最多 80 个。客户端不得截断或修复漂移响应。wire 整数为正 int64,拒绝 bool。claim 成功 +颜色与尺码各最多 80 个。客户端用与 Go `strings.TrimSpace` 相同的固定 Unicode White_Space 集校验 +持久化文本,并与服务端共同拒绝任意位置的 U+001C--U+001F;不得截断或修复漂移响应。wire 整数为正 int64,拒绝 bool。claim 成功 响应总上限仍为 32 KiB,最坏合法字段组合由双端契约测试证明严格小于该值。RFC3339Nano 按 0--9 位 小数的纳秒时间轴比较,不能用 Python 微秒精度截断。 diff --git a/docs/tasks/T-211.md b/docs/tasks/T-211.md index d624811..e461b7c 100644 --- a/docs/tasks/T-211.md +++ b/docs/tasks/T-211.md @@ -22,7 +22,7 @@ write_paths: - client/tests/remote/** --- - + ## 问题 / 背景 claim 成功响应受 32 KiB 总 body 上限保护,但创建、授权快照、candidate 和重放链路尚未统一约束各自由文本字段,客户端也未冻结同一单字段合法域。异常或历史超长值可能让合法业务事实变成不可传输响应。 @@ -69,20 +69,28 @@ claim 成功响应受 32 KiB 总 body 上限保护,但创建、授权快照、 ### 2026-08-04T17:25:07Z · ila 2026-08-05 实现里程碑:服务端共享 validator 已覆盖创建持久化、start/authorization、candidate、claim snapshot/replay 和 handler 输出;客户端 wire 同界。最坏合法服务端 JSON 使用会被 escapeHTML 为 6 字节的 < 字符证明严格小于 32 KiB,客户端用 ensure_ascii=True 做保守预算证明。focused:Go tasks/taskclaim/server 全绿;client core 8/8、remote 15/15。 + +### 2026-08-04T17:38:44Z · ila + +2026-08-05 审阅修复:统一 Go/Python 持久化文本域,客户端使用 Go Unicode White_Space 固定集,两端均拒绝任意位置 U+001C--U+001F,并补 NBSP/C0 回归。候选查询不再用 SQL 相等条件静默过滤:先读取 task 与 authorization 两侧快照并分别验证,单侧畸形立即回滚且不写 EMPTY/attempt;两侧均合法但不一致才按既有语义跳过。最坏合法 JSON 也覆盖 int64 版本编码上界。 ## 边界 - `title` 最多 120 个 Unicode code point,`sku_color` / `sku_size` 各最多 80 个 Unicode - code point;边界以既有 `TrimSpace` 后实际持久化值为准。Go 必须先通过 `utf8.ValidString`,不能把 + code point;边界以 Go `strings.TrimSpace` 的 Unicode White_Space 集处理后的实际持久化值为准。 + 客户端使用同一固定空白集,不依赖 Python `str.strip()` 默认语义;两端均拒绝任意位置的 + U+001C--U+001F 四个 C0 分隔符。Go 必须先通过 `utf8.ValidString`,不能把 非法 UTF-8 解码出的 `RuneError` 当成合法 code point;不按 UTF-8 字节、不按视觉 grapheme, 也不得为通过校验截断或规范化改写非空内容。 - `goods_id` 只允许 1--32 位 ASCII 数字;canonical money 只允许 1--32 个 ASCII 字符,并继续 满足既有正数、恰好两位小数、无非法前导零的规范格式。数量、版本与 generation 的正 int64 边界保持不变,不能接受 bool、浮点数或字符串整数。 -- 创建任务、开始采购的授权快照、claim candidate、首次响应快照与幂等重放必须使用同一字段合法域; - start/authorization 遇到既有畸形 task 必须原子失败,claim replay 还必须重新校验持久化响应快照。 - 任一层发现超限或畸形既有数据都失败闭合,不迁移、不截断、不覆盖或修复历史行。 +- 创建任务、开始采购的授权快照、claim candidate、首次响应快照与幂等重放必须使用同一字段合法域。 + candidate 必须先读取并分别校验 task 与 authorization 两侧字段;任一侧畸形立即回滚且不写 `EMPTY`, + 只有两侧均合法但快照不一致时才跳过。start/authorization 遇到既有畸形 task 必须原子失败,claim + replay 还必须重新校验持久化响应快照。任一层发现超限或畸形既有数据都失败闭合,不迁移、不截断、 + 不覆盖或修复历史行。 - claim 成功响应继续受既有 32 KiB HTTP body 上限约束;最坏合法字段组合的编码结果必须由契约测试 明确证明小于该上限。HTTP handler 在序列化前重验完整 claim response,并拒绝任何越界 service 输出;不得调高响应上限掩盖无界字段。