Compare commits

...
Author SHA1 Message Date
QiuSW db7b61b90b docs(task): complete T-107 device acceptance 2026-08-06 15:36:51 +08:00
QiuSW 50a572f3e3 fix(client): accept confirmed T-107 panel profile 2026-08-06 15:33:15 +08:00
QiuSW 1d60a91ace feat(client): add T-107 Gate3 dry-run observer 2026-08-06 12:10:39 +08:00
QiuSW 665b302a1d docs(task): start T-107 Gate3 observer 2026-08-06 11:27:09 +08:00
QiuSW dbf9b8c902 docs: complete T-106 device evidence 2026-08-06 11:23:14 +08:00
QiuSW 23445d0c4b docs: align Gate3 with combined submit panel 2026-08-06 11:02:41 +08:00
QiuSW 3abbe1040d docs(task): replan Gate3 for combined submit panel 2026-08-06 10:53:27 +08:00
QiuSW e93e2e8202 fix(client): stop unsafe T-106 confirmation path 2026-08-06 10:44:00 +08:00
QiuSW b0b71d5a29 feat(client): add T-106 confirmation evidence capture 2026-08-06 10:21:11 +08:00
QiuSW acc9f1d964 docs(task): claim T-106 confirmation evidence 2026-08-06 10:12:43 +08:00
QiuSW db2b781c8c docs(task): complete T-105 quantity Gate2 2026-08-06 09:54:48 +08:00
QiuSW 3f09fbff66 docs(task): prepare T-105 production acceptance 2026-08-06 09:29:16 +08:00
QiuSW 21628a4dc5 feat(client): verify T-105 quantity Gate2 2026-08-06 09:28:00 +08:00
QiuSW 1d726dd364 docs: align Gate2 with quantity total evidence 2026-08-06 09:13:13 +08:00
QiuSW e18f7983fd fix(client): read Android 16 foreground exactly 2026-08-06 08:52:42 +08:00
QiuSW ad80a7f23c docs(task): prepare T-105 quantity evidence 2026-08-05 18:09:31 +08:00
QiuSW 994b6c8054 feat(client): capture T-105 quantity states 2026-08-05 18:08:11 +08:00
QiuSW 16b2487a2a docs(task): claim T-105 quantity evidence 2026-08-05 17:59:18 +08:00
QiuSW 67b427864a docs(task): complete T-104 safe exit 2026-08-05 17:58:03 +08:00
QiuSW 1bb54927e1 feat(client): verify same-product safe exit 2026-08-05 17:56:16 +08:00
QiuSW 0afe9a53b3 docs(task): accept T-104 phase A device evidence 2026-08-05 17:47:29 +08:00
QiuSW 63bd873c26 fix(client): accept evidenced rolled target color 2026-08-05 17:39:12 +08:00
QiuSW 2e303f3829 docs(task): record T-104 rolled color evidence 2026-08-05 17:38:32 +08:00
QiuSW f97dca95ae docs(task): record T-104 phase A readiness 2026-08-05 17:27:59 +08:00
QiuSW 43fd8ce668 feat(client): capture T-104 safe exit evidence 2026-08-05 17:25:27 +08:00
QiuSW 44a207df7f docs(task): claim T-104 safe exit evidence 2026-08-05 17:04:46 +08:00
QiuSW 29fb528823 docs(task): complete T-103 device acceptance 2026-08-05 17:03:16 +08:00
QiuSW 537159445e docs(task): record production sku device run 2026-08-05 16:59:15 +08:00
QiuSW 91071d4013 docs(task): record production sku flow review 2026-08-05 16:55:52 +08:00
QiuSW 60cf05b08b feat(client): productionize proven sku reveal flow 2026-08-05 16:55:10 +08:00
QiuSW fec21ae857 docs(task): bind target M transition 2026-08-05 16:20:52 +08:00
QiuSW 3984f399b0 docs(task): accept reveal human review 2026-08-05 16:09:37 +08:00
QiuSW 251f3d92dd docs(task): record accepted reveal evidence 2026-08-05 15:57:12 +08:00
QiuSW 5fa8ac6ff2 fix(client): bind revealed current-only price layout 2026-08-05 15:54:00 +08:00
QiuSW a8570f59af docs(task): accept reveal action evidence 2026-08-05 15:31:51 +08:00
QiuSW ee56249014 docs(task): record reveal candidate evidence 2026-08-05 15:29:56 +08:00
QiuSW e552e2e2ca docs(task): record reveal precondition stop 2026-08-05 15:24:30 +08:00
QiuSW e1e534c5d3 fix(client): accept exact current-only sku state 2026-08-05 15:20:09 +08:00
QiuSW 5a4633fb64 docs(safety): bind current-only SKU price evidence 2026-08-05 14:50:38 +08:00
QiuSW 5d0fc81891 fix(client): report safe T-103 reveal stages 2026-08-05 14:19:37 +08:00
QiuSW 230cfdc697 test(client): close T-103 reveal failure gaps 2026-08-05 12:56:56 +08:00
QiuSW b4ff9096c3 test(client): tighten polling test synchronization 2026-08-05 12:47:34 +08:00
QiuSW ae4b9ef08b test(client): accelerate T-103 feedback loop 2026-08-05 12:47:22 +08:00
QiuSW 1bf3acda70 docs(state): record one-shot sku reveal readiness 2026-08-05 12:37:28 +08:00
QiuSW e8ca738b33 feat(client): capture one-shot sku reveal evidence 2026-08-05 12:34:59 +08:00
QiuSW 429c2d33db docs(safety): define one-shot sku reveal evidence 2026-08-05 11:45:58 +08:00
QiuSW a15b6c0911 docs(safety): bind T-103 live panel states 2026-08-05 11:37:19 +08:00
QiuSW 29d1540f7c docs(tasks): record corrected T-103 entry 2026-08-05 10:57:55 +08:00
QiuSW 2f1380d084 fix(client): target verified bottom sku entry 2026-08-05 10:56:55 +08:00
QiuSW 81a44caeef docs(safety): correct verified sku entry boundary 2026-08-05 10:45:28 +08:00
QiuSW 757eca39fd docs(tasks): record T-103 entry substage 2026-08-05 10:34:12 +08:00
QiuSW b754cc9ca9 fix(client): identify sku entry failure substage 2026-08-05 10:32:35 +08:00
QiuSW e45d26fe93 docs(tasks): record T-103 entry evidence 2026-08-05 10:16:05 +08:00
QiuSW a09c58beef fix(client): stabilize verified sku entry 2026-08-05 10:10:56 +08:00
QiuSW 42eac57824 docs(tasks): record safe T-103 diagnostics 2026-08-05 09:24:06 +08:00
QiuSW c9b39a4e05 test(client): remove polling stop race 2026-08-05 09:14:02 +08:00
QiuSW 870e5f861d fix(client): expose safe T-103 failure stages 2026-08-05 09:07:30 +08:00
QiuSW 025edaf273 docs(tasks): record T-304 code-complete review 2026-08-05 02:03:02 +08:00
QiuSW 584c5601c5 task(T-304): record code-complete verification 2026-08-05 01:59:35 +08:00
QiuSW 3a27225977 fix(client): fail closed on polling configuration drift 2026-08-05 01:59:35 +08:00
QiuSW dfd88c3336 feat(client): add safe polling session UI 2026-08-05 01:59:35 +08:00
QiuSW 20112dbd61 feat(client): expose secret-free profile summary 2026-08-05 01:59:35 +08:00
QiuSW 404d0d3ca4 task(T-304): add metadata and stop-latch contract 2026-08-05 01:59:35 +08:00
QiuSW 85e5f1e56e task(T-304): begin polling session UI 2026-08-05 01:59:35 +08:00
QiuSW 6145fc4468 docs(tasks): complete T-211 claim wire bounds 2026-08-05 01:52:49 +08:00
QiuSW f6cd65208d fix(api): align claim bounds across runtime snapshots 2026-08-05 01:39:35 +08:00
QiuSW 7ea1c5349f fix(api): bound claim wire fields end to end 2026-08-05 01:25:23 +08:00
QiuSW 57a5b2e91c chore(tasks): include T-211 claim handler boundary 2026-08-05 01:18:23 +08:00
QiuSW 63c2c59b41 chore(tasks): start T-211 claim wire bounds 2026-08-05 01:11:21 +08:00
QiuSW cfb238d022 docs(tasks): complete T-303 durable client state 2026-08-05 01:04:35 +08:00
QiuSW 31f07ac245 feat(client): add durable HTTP task state 2026-08-05 00:59:32 +08:00
QiuSW 488005ac93 chore(tasks): start T-303 client HTTP state 2026-08-05 00:59:32 +08:00
QiuSW 4186f1315a docs(tasks): tighten exit and evidence contracts 2026-08-04 23:36:01 +08:00
QiuSW 3a0a41db00 docs(tasks): complete MVP safety task graph 2026-08-04 23:20:28 +08:00
QiuSW 8600c33391 docs(tasks): define admin safety closure chain 2026-08-04 22:44:13 +08:00
QiuSW 8092431208 docs(tasks): order evidence before attempt events 2026-08-04 22:41:59 +08:00
QiuSW cd47d0c959 docs(tasks): complete T-302 atomic claim 2026-08-04 22:34:46 +08:00
QiuSW ec42640123 Merge branch 'main' into task/t-302-atomic-claim 2026-08-04 22:33:25 +08:00
QiuSW 5f060f60ee feat(admin): add atomic task claim leases 2026-08-04 22:33:16 +08:00
QiuSW 19f81a5e58 docs(tasks): define T-305 prefence dry run 2026-08-04 22:14:55 +08:00
QiuSW c1cee49fb1 docs(tasks): define T-210 gate evidence kinds 2026-08-04 22:08:58 +08:00
QiuSW 772e379fd6 docs(tasks): define T-107 confirm gate 2026-08-04 22:05:20 +08:00
QiuSW 83b244ff7c docs(tasks): require T-106 navigation-source evidence 2026-08-04 22:02:43 +08:00
QiuSW d9a31cbfa6 docs(tasks): define T-106 confirm evidence 2026-08-04 22:01:45 +08:00
QiuSW 014405466a docs(tasks): define T-105 quantity gate 2026-08-04 21:59:21 +08:00
QiuSW 8c50e1579e docs(tasks): define T-307 attempt sink 2026-08-04 21:50:15 +08:00
QiuSW 35d7ce11d6 docs(tasks): define T-306 evidence publisher 2026-08-04 21:48:22 +08:00
QiuSW 41e54e313e docs(tasks): expose T-104 gate-one artifact 2026-08-04 21:46:54 +08:00
QiuSW e5de76503f docs(tasks): linearize T-205 ordering version 2026-08-04 21:45:31 +08:00
QiuSW 542b2283f4 docs(tasks): freeze T-303 evidence upload slot 2026-08-04 21:42:15 +08:00
QiuSW eb29bcd8b7 docs(tasks): define T-304 polling UI boundary 2026-08-04 21:36:52 +08:00
QiuSW 526af1eb31 docs(tasks): allow T-302 taskdetail fixture repair 2026-08-04 21:33:22 +08:00
QiuSW d9381a644b docs(tasks): define T-303 UI state boundary 2026-08-04 21:28:08 +08:00
QiuSW e863a29641 docs(tasks): allow T-303 roadmap sync 2026-08-04 21:19:58 +08:00
QiuSW 5c97954fa1 docs(tasks): define T-303 HTTP recovery client 2026-08-04 21:19:02 +08:00
QiuSW bd4c855ad2 docs(tasks): harden T-302 replay ownership 2026-08-04 21:15:59 +08:00
QiuSW ee90d76893 docs(tasks): linearize T-302 revocation checks 2026-08-04 21:11:26 +08:00
QiuSW 4a65138fd7 docs(tasks): define T-205 attempt events 2026-08-04 21:07:43 +08:00
QiuSW 8ec740dcef chore(tasks): start T-302 2026-08-04 21:03:38 +08:00
QiuSW 590c84660a docs(tasks): define T-302 atomic claiming 2026-08-04 21:02:57 +08:00
QiuSW 233188297a docs(tasks): complete T-301 2026-08-04 20:58:36 +08:00
QiuSW e674b7f131 merge: T-301 device credential isolation 2026-08-04 20:57:42 +08:00
QiuSW 66355a7f89 feat(admin): add device credential isolation 2026-08-04 20:57:33 +08:00
QiuSW 41881e81f3 docs(tasks): define T-301 auth failure semantics 2026-08-04 20:06:23 +08:00
QiuSW e3c87fdec5 docs(tasks): secure T-301 transport boundary 2026-08-04 20:05:03 +08:00
QiuSW 871cd24d68 fix(tasks): isolate T-301 write paths 2026-08-04 20:01:18 +08:00
QiuSW 4f8e71b256 chore(tasks): start T-301 2026-08-04 20:01:05 +08:00
QiuSW a6ad560f5d docs(tasks): define T-301 device identity 2026-08-04 20:00:29 +08:00
QiuSW 6a5547b323 docs(tasks): complete T-204 2026-08-04 19:51:58 +08:00
QiuSW 46fccc2120 merge: T-204 routed task evidence details 2026-08-04 19:43:20 +08:00
QiuSW 89648880bc feat(admin): add routed task evidence details 2026-08-04 19:42:35 +08:00
QiuSW 4829be972c docs(tasks): record T-103 entry audit 2026-08-04 18:47:47 +08:00
QiuSW 3b2fa3536e docs(tasks): define T-104 safe exit evidence 2026-08-04 18:47:05 +08:00
QiuSW 9a4d11f74b chore(tasks): start T-204 2026-08-04 18:42:09 +08:00
QiuSW 79284576ef docs(tasks): define T-204 detail evidence scope 2026-08-04 18:41:21 +08:00
QiuSW 20aaba0919 docs(tasks): complete T-203 2026-08-04 18:27:21 +08:00
QiuSW 03a067e29d merge: T-203 batch purchase authorization 2026-08-04 18:24:44 +08:00
QiuSW 5dcff4b15a feat(admin): authorize batch purchase starts 2026-08-04 18:24:39 +08:00
QiuSW ce9d6ca285 merge: T-103 verified entry parent chain 2026-08-04 18:21:38 +08:00
QiuSW 44586fef38 fix(client): bind SKU entry to verified parent chain 2026-08-04 18:21:30 +08:00
QiuSW e379d50101 chore(tasks): extend T-203 auth review scope 2026-08-04 18:18:19 +08:00
QiuSW 13547728fc docs(tasks): record T-103 entry parent evidence 2026-08-04 18:01:59 +08:00
QiuSW 0b5c561ed6 docs(tasks): record T-103 offline review 2026-08-04 17:50:46 +08:00
QiuSW 5f650f18b1 merge: T-103 offline SKU selection implementation 2026-08-04 17:48:29 +08:00
QiuSW b49a9b4abe feat(client): implement verified SKU selection flow 2026-08-04 17:47:58 +08:00
QiuSW 96f774ed96 docs(tasks): start T-203 purchase authorization 2026-08-04 17:16:48 +08:00
QiuSW 1f20271366 docs(tasks): sync T-209 completion 2026-08-04 17:15:12 +08:00
QiuSW 27726f4dde docs(state): record T-209 completion 2026-08-04 17:03:33 +08:00
QiuSW f85ef5f714 merge: T-209 single-pass schema 2026-08-04 17:02:29 +08:00
QiuSW e04f05b20b feat(admin): migrate core schema to single-pass model 2026-08-04 17:01:35 +08:00
QiuSW e20457b6db docs(tasks): define T-203 purchase authorization 2026-08-04 16:58:37 +08:00
QiuSW b5f45b87a5 docs(state): record T-202 completion 2026-08-04 16:41:22 +08:00
QiuSW 64a7468cab merge: T-202 draft task creation 2026-08-04 16:38:36 +08:00
QiuSW 442ab88fd7 docs(tasks): define T-209 single-pass schema migration 2026-08-04 16:38:29 +08:00
QiuSW d38cfb61af feat(admin): add draft task creation 2026-08-04 16:33:22 +08:00
QiuSW da540bfdf6 merge: authorized single-pass purchase contract 2026-08-04 16:25:42 +08:00
QiuSW cfd5440ac0 docs(architecture): adopt authorized single-pass purchase 2026-08-04 16:25:34 +08:00
QiuSW 8ba9b231f4 docs(tasks): start single-pass purchase redesign 2026-08-04 15:53:16 +08:00
QiuSW efeb2d958a docs(architecture): allow internal raw evidence screenshots 2026-08-04 15:47:47 +08:00
QiuSW 549099ad24 merge: T-103 observed price prefix 2026-08-04 15:23:10 +08:00
QiuSW 76a522f13f fix(client): bind T-103 observed price prefix 2026-08-04 15:23:02 +08:00
QiuSW 5088d544f2 merge: T-103 refined prefix diagnostics 2026-08-04 15:16:44 +08:00
QiuSW 6e001b2729 fix(client): refine T-103 prefix diagnostics 2026-08-04 15:16:30 +08:00
QiuSW cea27ff7ef docs(tasks): define T-202 draft creation 2026-08-04 15:08:13 +08:00
QiuSW 1c35155c2d merge: T-201 administrator sessions 2026-08-04 15:04:38 +08:00
QiuSW babb530b99 docs(tasks): complete T-201 admin session 2026-08-04 15:04:25 +08:00
QiuSW 55d9e09bda merge: main into T-201 2026-08-04 14:59:28 +08:00
QiuSW 47c0844f9c feat(admin): add administrator sessions 2026-08-04 14:57:40 +08:00
QiuSW 88d8f77417 merge: T-103 safe price diagnostics 2026-08-04 14:56:50 +08:00
QiuSW f7efa4a161 docs(tasks): record T-103 safe diagnostics 2026-08-04 14:56:41 +08:00
QiuSW ef1ac60ac5 fix(client): diagnose T-103 price text safely 2026-08-04 14:55:14 +08:00
QiuSW 5332d67b5e merge: T-103 sanitizer v4 2026-08-04 14:48:44 +08:00
QiuSW 5f6b3ce01a docs(tasks): record T-103 sanitizer v4 2026-08-04 14:48:36 +08:00
QiuSW 824a628733 Merge branch 'main' into task/t-103-sku-panel 2026-08-04 14:42:57 +08:00
QiuSW 1e69d274b8 feat(client): project sanitized SKU price candidates 2026-08-04 14:42:49 +08:00
QiuSW 8ee26be95a docs(tasks): define T-201 admin session 2026-08-04 14:40:59 +08:00
QiuSW 80ed9b71e8 docs(tasks): complete T-010 parallel gate 2026-08-04 14:39:27 +08:00
QiuSW 3f2e0e5598 docs: allow safe admin parallelism 2026-08-04 14:38:20 +08:00
QiuSW a45a1b5afc docs(tasks): review first T-103 derived evidence 2026-08-04 14:20:23 +08:00
QiuSW d27fda6be3 docs(tasks): record T-103 sanitizer v3 2026-08-04 14:08:10 +08:00
QiuSW acf7e11114 fix(client): bind T-103 XML coordinates 2026-08-04 14:04:49 +08:00
QiuSW 946b064470 docs(tasks): record T-103 sanitizer v2 2026-08-04 11:59:48 +08:00
QiuSW 44c027a18f fix(client): separate screenshot and XML coordinates 2026-08-04 11:57:45 +08:00
QiuSW cf3692112c docs(tasks): record preselected SKU evidence flow 2026-08-04 11:41:54 +08:00
QiuSW dbb69a7d5e fix(client): reflect preselected SKU evidence states 2026-08-04 11:38:15 +08:00
QiuSW 3d063b9785 docs(tasks): revise T-103 evidence states 2026-08-04 11:35:18 +08:00
QiuSW 932ca8c7f3 docs(tasks): record T-103 sanitizer milestone 2026-08-04 11:25:40 +08:00
QiuSW 9b2eb74478 feat(client): sanitize T-103 device evidence 2026-08-04 11:16:11 +08:00
QiuSW 17e295e99d docs(tasks): authorize T-103 sanitizer CLI 2026-08-04 11:03:39 +08:00
QiuSW d80e4393b4 docs(tasks): record T-110 architecture validation 2026-08-04 11:00:20 +08:00
QiuSW 82f57c7233 docs(architecture): approve controlled trial SKU entry 2026-08-04 10:59:40 +08:00
QiuSW d81fc722dc docs(tasks): define controlled SKU entry boundary 2026-08-04 10:49:05 +08:00
QiuSW abeefbbea8 docs(architecture): record T-103 purchase-entry no-go 2026-08-04 10:42:28 +08:00
QiuSW b66417d80b docs(tasks): reject unsafe T-103 evidence set 2026-08-04 10:36:53 +08:00
QiuSW 5b56161fd7 docs(tasks): record T-103 evidence scaffold audit 2026-08-04 10:10:11 +08:00
QiuSW a92b8f6be0 feat(client): add T-103 manual SKU evidence capture 2026-08-04 10:08:49 +08:00
QiuSW ec8257ba8b docs(tasks): start T-103 SKU panel spike 2026-08-04 09:58:13 +08:00
QiuSW 27999c8c85 docs(tasks): define T-103 SKU panel spike 2026-08-04 09:57:07 +08:00
QiuSW 1dc83086a0 docs(tasks): complete T-102 product link validation 2026-08-04 09:51:00 +08:00
QiuSW ad55e77bda docs(tasks): record T-102 successful capture 2026-08-04 09:46:52 +08:00
QiuSW 652eca7953 docs(tasks): record T-102 foreground wait fix 2026-08-04 09:41:03 +08:00
QiuSW cb646b4974 fix(client): wait for PDD foreground after intent 2026-08-04 09:40:05 +08:00
QiuSW 9452debf66 docs(tasks): record T-102 foreground evidence 2026-08-04 09:34:53 +08:00
QiuSW 4adeb1b37f docs(tasks): record T-102 foreground mismatch 2026-08-04 09:32:54 +08:00
QiuSW 2ea28c2626 docs(tasks): record T-102 offline implementation 2026-08-04 09:27:23 +08:00
QiuSW 7040bb61d8 feat(client): add guarded product link capture 2026-08-04 09:26:37 +08:00
QiuSW e7a4be1b9b docs(tasks): start T-102 product link spike 2026-08-04 09:10:00 +08:00
QiuSW 4281b06711 docs(tasks): define T-102 product link spike 2026-08-04 09:07:44 +08:00
QiuSW 393f26de53 docs(tasks): complete T-101 device validation 2026-08-04 09:03:26 +08:00
QiuSW 71c66a074a docs(tasks): record T-101 wifi evidence 2026-08-04 08:55:31 +08:00
QiuSW 7905fa0b70 fix(client): accept whitespace in screenshot base64 2026-08-04 08:51:00 +08:00
QiuSW c4cf19fd55 docs(tasks): record T-101 wifi connectivity 2026-08-04 08:36:26 +08:00
QiuSW de8187eb5b feat(client): add T-101 device baseline 2026-08-03 19:28:39 +08:00
QiuSW f3294633c2 docs(tasks): allow T-101 adb dependency update 2026-08-03 19:07:01 +08:00
QiuSW 0fbf66836b docs(tasks): start T-101 device connectivity spike 2026-08-03 18:56:39 +08:00
QiuSW 4f4a95a55e build: establish unified project entry 2026-08-03 18:55:32 +08:00
QiuSW 637c341b95 docs(tasks): define T-101 device connectivity spike 2026-08-03 18:40:19 +08:00
QiuSW 87591e84f9 feat(admin): establish core data model 2026-08-03 18:37:48 +08:00
QiuSW 8be6d206dd docs(tasks): define T-003 unified entry 2026-08-03 18:35:12 +08:00
QiuSW bcec012c8b feat(client): initialize procurement tool skeleton 2026-08-03 18:32:00 +08:00
QiuSW 2ab5baa776 docs(tasks): expand T-002 shared document paths 2026-08-03 18:16:48 +08:00
QiuSW cf293d2382 docs(tasks): define T-004 core data model 2026-08-03 18:16:08 +08:00
QiuSW c72371ce90 feat(admin): initialize procurement service skeleton 2026-08-03 18:13:30 +08:00
QiuSW b7559a9451 docs(tasks): define parallel T-001 and T-002 2026-08-03 17:57:53 +08:00
QiuSW a63bdb8c37 docs(plan): prioritize MVP critical path 2026-08-03 17:47:22 +08:00
QiuSW 4948afc393 docs(tasks): define T-009 MVP critical path plan 2026-08-03 17:41:58 +08:00
241 changed files with 43479 additions and 1855 deletions
+7 -4
View File
@@ -10,7 +10,8 @@ cmbuyer 是一个自动化采购系统:**采购服务**(网页端,`admin/`
**系统只创建待付款订单,任何情况下都不自动付款。**
当前状态:仓库只有文档,尚未开始编码。阶段为 Phase 0。
当前状态:两端骨架、基础模型、登录和真机取证脚手架已落地;Phase 1 真机取证与不依赖页面判据的
Phase 2 服务端任务并行。实时快照见 [`docs/current-state.md`](docs/current-state.md)。
## 必读顺序
@@ -33,7 +34,9 @@ cmbuyer 是一个自动化采购系统:**采购服务**(网页端,`admin/`
系统**会**点击「提交订单」创建待付款订单,但必须满足四个前置条件(授权未消费且服务端
提交围栏已建立、闸门二通过、闸门三通过、控件唯一),且**只点一次**、点击后无论结果
都不重试。围栏申请失败或响应不明时不得点击;围栏建立后只能调和同一提交记录。
**第一趟试选的代码路径不得引用任何下单函数**,必须有测试证明不可达。
管理员点击“开始采购(只创建待付款订单)”是唯一的人类授权动作。T-103 的规格选择/读价隔离
验证路径不得引用数量、确认页、提交或付款函数;后续能力必须按真机取证任务逐段开放,并有静态
调用链测试证明未获准能力不可达。
### 2. 安全边界只能收紧
@@ -141,8 +144,8 @@ python scripts/validate_agent_context.py
**跨端契约改动必跑完整门禁**——两端会同时坏。
代码尚未初始化,上述命令在 T-001 / T-002 完成前不可运行;届时由对应任务替换为真实命令
并同步文档。
两端已初始化;跨端契约改动还需在仓库根运行 `./init.ps1`(或 `./init.sh`)完成安装、测试、
vet/build、compileall 和上下文门禁。
## 风格
+16 -24
View File
@@ -8,13 +8,14 @@
## 它做什么
一笔外部订单进来,采购人员需要去拼多多找到同款、选对颜色尺码、下单、把订单号抄回系统。
cmbuyer 把这个过程自动化,人只在两个点介入:**机器选对了吗**和**付不付款**。
cmbuyer 把这个过程自动化:管理员明确“买什么、买多少、最多多少钱”并点击开始采购,系统只
创建待付款订单,**是否付款始终由人决定**。
```text
手工填链接(MVP) / Excel · ERP(V2)
│
v
采购服务(admin/,Go) 建单 · 试选确认 · 下单授权 · 审计
采购服务(admin/,Go) 建单 · 开始采购授权 · 围栏 · 审计
│ HTTP
v
采购工具(client/,Python) 领任务 · 跑流程 · 回传
@@ -23,31 +24,22 @@ cmbuyer 把这个过程自动化,人只在两个点介入:**机器选对了
Android 手机(拼多多 App)
```
## 两趟执行
## 单趟执行
MVP 只做**任务自带商品链接**的情形,分两趟跑完:
MVP 只做**任务自带商品链接**的情形:管理员先把任务保存为 `DRAFT`,再在表格中勾选并点击
“开始采购(只创建待付款订单)”。该点击创建一次性授权,锁定商品、颜色、尺码、数量和最高总价。
| 趟次 | 做什么 |
| --- | --- |
| **第一趟 · 试选** | 开商品 → 精确勾选颜色分类和尺码 → 读单价 → 截图 → **退出释放手机** → 回传 |
| **人工确认** | 人在网页端看「机器选对了吗」→ 确认并**锁定单价** |
| **第二趟 · 下单** | 重新开商品 → 重新选同一规格 → **三道价格闸门** → 提交订单一次 → 转「待付款」 |
采购工具领取后在同一次设备会话中完成:打开商品 → 精确选择规格 → 闸门一读 SKU 单价并校验
上限 → 设置并复核数量 → 闸门二读取顶部总额 → 在同一最终面板读取闸门三最终控件金额 → 服务端
原子建立提交围栏 → 精确点击一次“提交订单” → 转待付款。中间不再回网页端等“机器选对了吗”。
为什么分两趟:一台手机是瓶颈,不能停在规格面板上等人。代价是走两遍,换来手机不空闲,
且第二趟能抓住价格变动。
**三道价格闸门**:① 第一趟规格面板读价 ② 第二趟重读必须与授权价一致
③ 订单确认页「实付款」不超上限。任一道读不到或不通过即停,转人工。
价格**只在规格面板和订单确认页读**——别处的价格文本被拆成多个节点、带券后前缀、
实付价与原价混在一起,不可靠。
价格只从拼多多 `8.17.0` 已取证合并式最终提交面板的两个独立角色读取:顶部金额承担 Gate1/Gate2,
结构化精确文本 `提交订单 ¥{金额}` 中的金额承担 Gate3;后者必须与 Gate2 严格相等且不能回流兜底。
## 现在处于什么阶段
**Phase 0 · 地基。仓库目前只有文档,尚未开始编码。**
下一步:T-005(网页端 MVP 原型)与 T-006(桌面端 MVP 原型)的文件和自动检查已完成,
请先人工确认 6 个 HTML 原型,再进入 T-001 / T-002 骨架和生产实现。
**Phase 1 真机取证与 Phase 2 的安全服务端工作并行。** 两端骨架、基础模型、登录和真机基线已
落地;T-111 正在冻结单趟采购契约,T-103 随后继续规格精确选择与读价,T-202 等待主审合入。
详见 [`docs/current-state.md`](docs/current-state.md)。
**生死线是 M2**:真机能按链接打开商品、精确勾选颜色分类和尺码、**读到该 SKU 单价**。
@@ -70,11 +62,11 @@ MVP 只做**任务自带商品链接**的情形,分两趟跑完:
1. 不点击任何支付、免密支付、先用后付或扣款控件
2. **提交订单四条件**:授权未消费且服务端提交围栏已建立 + 闸门二通过 + 闸门三通过 +
控件唯一,**只点一次**;围栏后只调和,不释放、不重试
3. 订单确认页上除提交与返回外零点击
3. 合并式最终提交面板上除满足四条件后的最终控件与安全返回外零点击
4. 规格按维度精确匹配,防前缀碰撞,找不到即停
5. 数量设置后必须读回复核
6. **三道价格闸门**,任一道读不到或不通过即停
7. **第一趟绝不下单**——试选路径不得引用下单函数
7. **能力分层**——T-103 规格验证路径不得引用数量、最终提交面板或下单函数;后续能力逐段取证
8. 检测到外部支付交接立即停止,不读取不保存凭据
9. 检测到验证码 / 风控 / 人脸 / 短信校验立即停止,不绕过
10. 只读非敏感摘要,不提取收货地址原文、手机号、支付凭据
@@ -88,7 +80,7 @@ cmbuyer 是 `cmroubao`(Go 后端 + Android AccessibilityService)与 `cmpdd`
(Python + uiautomator2)的合并重启,取各自已验证的一半:
- 保留 cmroubao 的后端任务生命周期、设备侧 API 形状、下单授权状态机、ERP 对接、管理 Web
- 保留 cmpdd 的 uiautomator2 真机自动化、按维度精确选规格、订单确认页读取、付款闸门
- 保留 cmpdd 的 uiautomator2 真机自动化与按维度精确选规格思路;最终提交面板事实在本项目重新取证
- 丢弃自研 Android APK 与 AccessibilityService 感知层
**前序项目是设计依据,不是事实来源。** 其中的页面判据必须在本项目用真机重新验证。
+68
View File
@@ -0,0 +1,68 @@
# 采购服务
启动前必须显式设置下列环境变量;服务不提供默认管理员账号、密码或会话密钥。
| 变量 | 要求 |
| --- | --- |
| `CMBUYER_ADMIN_USERNAME` | 非空管理员账号。 |
| `CMBUYER_ADMIN_PASSWORD_BCRYPT` | 非空 bcrypt 密码哈希,不接受明文密码。 |
| `CMBUYER_SESSION_SECRET` | 至少 32 字节的会话签名密钥。 |
| `CMBUYER_COOKIE_SECURE` | 可选;存在时只能精确为 `true` 或 `false`。HTTPS 部署应设为 `true`。 |
| `CMBUYER_DATABASE_SOURCE` | 已迁移 SQLite 的显式 data source。 |
| `CMBUYER_AUTHORIZATION_TTL` | 一次性授权的正 Go duration,例如 `10m`。 |
| `CMBUYER_MAX_TASK_QUANTITY` | 每条任务允许的正整数数量上限。 |
| `CMBUYER_MAX_TOTAL_PRICE` | 每条任务允许的规范正数总价上限,例如 `999.99`。 |
| `CMBUYER_EVIDENCE_DIR` | 内部原始截图的绝对私有目录;不得指向仓库或公开静态目录。 |
| `CMBUYER_CLAIM_TOKEN_SECRET` | claim token 专用 32 字节密钥的 64 位小写十六进制;不得复用 session 或设备 token。 |
| `CMBUYER_CLAIM_LEASE_TTL` | 正 Go duration,且必须严格短于 `CMBUYER_AUTHORIZATION_TTL`。 |
示例仅展示变量名,不提供可运行凭据:
```powershell
$env:CMBUYER_ADMIN_USERNAME = '<管理员账号>'
$env:CMBUYER_ADMIN_PASSWORD_BCRYPT = '<bcrypt 密码哈希>'
$env:CMBUYER_SESSION_SECRET = '<至少 32 字节的随机密钥>'
$env:CMBUYER_COOKIE_SECURE = 'true'
$env:CMBUYER_DATABASE_SOURCE = '<SQLite data source>'
$env:CMBUYER_AUTHORIZATION_TTL = '10m'
$env:CMBUYER_MAX_TASK_QUANTITY = '99'
$env:CMBUYER_MAX_TOTAL_PRICE = '999.99'
$env:CMBUYER_EVIDENCE_DIR = '<内部截图绝对目录>'
$env:CMBUYER_CLAIM_TOKEN_SECRET = '<64 位小写十六进制随机值>'
$env:CMBUYER_CLAIM_LEASE_TTL = '1m'
go run ./cmd/migrate -database $env:CMBUYER_DATABASE_SOURCE up
go run ./cmd/server
```
MVP 服务固定绑定 IPv4 回环 `127.0.0.1:8080`,只供同一运营电脑上的采购服务和采购工具使用。不要把监听地址
改成 `0.0.0.0` 或局域网地址;未来若需要非回环访问,必须先单独建立并验收 HTTPS/TLS 终止与代理
信任边界,设备 Bearer 不得经过明文局域网。
数据库迁移完成后,用同一个显式 SQLite data source 管理设备凭据:
```powershell
# 签发:token 只在本次成功输出中显示一次,请立即放入采购工具的受控本机配置。
go run ./cmd/device-credentials -database $env:CMBUYER_DATABASE_SOURCE issue -name '<非秘密设备名称>'
# 仅显示设备 id、名称、状态和时间,不显示 token/hash。
go run ./cmd/device-credentials -database $env:CMBUYER_DATABASE_SOURCE list
# 撤销立即影响之后开始的每次设备请求;重复执行保持 REVOKED,不恢复旧 token。
go run ./cmd/device-credentials -database $env:CMBUYER_DATABASE_SOURCE revoke -device-id '<签发时的设备UUID>'
```
该 CLI 只接受已存在、已迁移的文件型 SQLite 普通路径或 `file:` URI,并强制以 `mode=rw` 打开;
路径拼错或文件缺失时 SQLite 原子拒绝且不会留下空数据库,也不接受内存、只读或可创建模式。CLI
不自动迁移。签发 token 是 32 字节加密随机值的 64 位小写十六进制表示;
SQLite 只保存原始 token 的 32 字节 SHA-256 BLOB。签发输出以外的 list/revoke、日志、错误和 HTTP
响应都不会显示 token 或 hash。
采购服务会话仅保存在当前进程内;进程重启后既有登录会话会安全失效。
管理员的“开始采购(只创建待付款订单)”只签发一次性授权并创建待付款订单的资格;服务不会自动付款,也不包含任何支付操作。
`GET /tasks/{id}` 直接访问时渲染完整详情页,任务列表以同一 URL 加载详情抽屉。内部截图只通过
`GET /evidence/{asset_id}` 向有效管理员会话提供,并始终返回 `no-store`;文件不在静态目录中。
`POST /api/v1/tasks/{id}/evidence` 使用 `Authorization: Bearer <token>` 和
`X-CMBuyer-Device-ID: <小写UUIDv4>` 逐请求查库认证;管理员会话不能代替设备身份。凭据错误统一空
401,SQLite 认证故障为空 503,且两者都发生在上传 body 被读取之前。
+200
View File
@@ -0,0 +1,200 @@
package main
import (
"context"
"encoding/json"
"errors"
"flag"
"fmt"
"io"
"log"
"net/url"
"os"
"path/filepath"
"strings"
"time"
"cmbuyer/admin/internal/deviceauth"
"cmbuyer/admin/internal/storage/sqlite"
)
func main() {
if err := run(context.Background(), os.Args[1:], os.Stdout, os.Stderr); err != nil {
log.Print(err)
os.Exit(1)
}
}
func run(ctx context.Context, args []string, stdout, stderr io.Writer) error {
flags := flag.NewFlagSet("device-credentials", flag.ContinueOnError)
flags.SetOutput(stderr)
databaseSource := flags.String("database", "", "explicit migrated SQLite data source")
if err := flags.Parse(args); err != nil {
return err
}
if *databaseSource == "" {
return errors.New("-database is required")
}
if flags.NArg() < 1 {
return errors.New("usage: device-credentials -database <sqlite-data-source> <issue|list|revoke> [options]")
}
command := flags.Arg(0)
commandArgs := flags.Args()[1:]
var issueName, revokeDeviceID string
switch command {
case "issue":
commandFlags := flag.NewFlagSet("issue", flag.ContinueOnError)
commandFlags.SetOutput(stderr)
commandFlags.StringVar(&issueName, "name", "", "non-secret device display name")
if err := commandFlags.Parse(commandArgs); err != nil {
return err
}
if issueName == "" || commandFlags.NArg() != 0 {
return errors.New("usage: device-credentials -database <sqlite-data-source> issue -name <display-name>")
}
case "list":
if len(commandArgs) != 0 {
return errors.New("usage: device-credentials -database <sqlite-data-source> list")
}
case "revoke":
commandFlags := flag.NewFlagSet("revoke", flag.ContinueOnError)
commandFlags.SetOutput(stderr)
commandFlags.StringVar(&revokeDeviceID, "device-id", "", "canonical device UUID")
if err := commandFlags.Parse(commandArgs); err != nil {
return err
}
if revokeDeviceID == "" || commandFlags.NArg() != 0 {
return errors.New("usage: device-credentials -database <sqlite-data-source> revoke -device-id <uuid>")
}
default:
return fmt.Errorf("unsupported device credential command %q", command)
}
if command == "issue" && !deviceauth.ValidDisplayName(issueName) {
return deviceauth.ErrInvalidCredential
}
if command == "revoke" && !deviceauth.ValidDeviceID(revokeDeviceID) {
return deviceauth.ErrInvalidCredential
}
existingSource, err := existingSQLiteDataSource(*databaseSource)
if err != nil {
return err
}
database, err := sqlite.Open(existingSource)
if err != nil {
return fmt.Errorf("open SQLite database: %w", err)
}
defer database.Close()
store, err := deviceauth.NewCredentialStore(database)
if err != nil {
return err
}
switch command {
case "issue":
issued, err := store.Issue(ctx, issueName)
if err != nil {
return err
}
// The token has json:"-" and is printed only by this explicit post-commit path. Generic
// serialization, list, revoke, errors, and server responses therefore cannot disclose it.
if _, err := fmt.Fprintf(stdout, "device_id=%s\ndisplay_name=%s\ntoken=%s\ncreated_at=%s\n",
issued.DeviceID, issued.DisplayName, issued.Token, issued.CreatedAt.Format(time.RFC3339Nano)); err != nil {
return errors.New("write issued device credential")
}
return nil
case "list":
credentials, err := store.List(ctx)
if err != nil {
return err
}
return writeJSON(stdout, credentials)
case "revoke":
credential, changed, err := store.Revoke(ctx, revokeDeviceID)
if err != nil {
return err
}
return writeJSON(stdout, struct {
Credential deviceauth.Credential `json:"credential"`
RevokedNow bool `json:"revoked_now"`
}{Credential: credential, RevokedNow: changed})
}
return errors.New("unreachable device credential command")
}
func existingSQLiteDataSource(value string) (string, error) {
if value == "" || strings.TrimSpace(value) != value {
return "", errors.New("-database must name an existing file-backed SQLite database")
}
var parsed *url.URL
var query url.Values
if strings.HasPrefix(strings.ToLower(value), "file:") {
var err error
parsed, err = url.Parse(value)
if err != nil || !strings.EqualFold(parsed.Scheme, "file") || parsed.User != nil || parsed.Host != "" || parsed.Fragment != "" {
return "", errors.New("-database file URI is invalid")
}
// go-sqlite3 recognizes URI filenames only with the exact lowercase file: prefix.
// Canonicalize accepted scheme casing before mode=rw reaches the driver, otherwise a
// mixed-case input could be treated as a plain filename and recreate a missing database.
parsed.Scheme = "file"
query, err = url.ParseQuery(parsed.RawQuery)
if err != nil {
return "", errors.New("-database query parameters are invalid")
}
fileName := parsed.Path
if parsed.Opaque != "" {
fileName = parsed.Opaque
}
decodedName, err := url.PathUnescape(fileName)
if err != nil || fileName == "" || strings.EqualFold(decodedName, ":memory:") {
return "", errors.New("-database must name an existing file-backed SQLite database")
}
} else {
pathPart, rawQuery, hasQuery := strings.Cut(value, "?")
if pathPart == "" || strings.EqualFold(pathPart, ":memory:") || strings.Contains(pathPart, "://") {
return "", errors.New("-database must name an existing file-backed SQLite database")
}
var err error
query, err = url.ParseQuery(rawQuery)
if err != nil {
return "", errors.New("-database query parameters are invalid")
}
normalizedPath := filepath.ToSlash(pathPart)
if filepath.VolumeName(pathPart) != "" && !strings.HasPrefix(normalizedPath, "/") {
normalizedPath = "/" + normalizedPath
}
parsed = &url.URL{Scheme: "file", Path: normalizedPath}
if !hasQuery {
query = make(url.Values)
}
}
modes := query["mode"]
if len(modes) > 1 || len(modes) == 1 && modes[0] != "rw" {
return "", errors.New("-database only permits SQLite mode=rw")
}
if len(modes) == 0 {
query.Set("mode", "rw")
}
for _, name := range []string{"immutable", "_query_only"} {
for _, setting := range query[name] {
if setting != "0" && !strings.EqualFold(setting, "false") {
return "", errors.New("-database contains a read-only SQLite option")
}
}
}
parsed.RawQuery = query.Encode()
parsed.ForceQuery = false
return parsed.String(), nil
}
func writeJSON(writer io.Writer, value any) error {
encoder := json.NewEncoder(writer)
encoder.SetEscapeHTML(true)
if err := encoder.Encode(value); err != nil {
return errors.New("write device credential metadata")
}
return nil
}
+293
View File
@@ -0,0 +1,293 @@
package main
import (
"bytes"
"context"
"database/sql"
"errors"
"io"
"net/url"
"os"
"path/filepath"
"runtime"
"strings"
"testing"
"cmbuyer/admin/internal/deviceauth"
"cmbuyer/admin/internal/migrations"
"cmbuyer/admin/internal/storage/sqlite"
)
func TestIssueListAndIdempotentRevokeNeverRediscloseSecret(t *testing.T) {
databaseSource := migratedDatabase(t)
var issued bytes.Buffer
if err := run(context.Background(), []string{"-database", databaseSource, "issue", "-name", "采购工具一号"}, &issued, io.Discard); err != nil {
t.Fatalf("issue: %v", err)
}
fields := outputFields(t, issued.String())
deviceID, token := fields["device_id"], fields["token"]
if len(token) != 64 || strings.Count(issued.String(), token) != 1 {
t.Fatalf("issue token occurrence/length = %d/%d", strings.Count(issued.String(), token), len(token))
}
var listed bytes.Buffer
if err := run(context.Background(), []string{"-database", databaseSource, "list"}, &listed, io.Discard); err != nil {
t.Fatalf("list: %v", err)
}
assertNoSecretMetadata(t, listed.String(), token)
if !strings.Contains(listed.String(), deviceID) || !strings.Contains(listed.String(), "采购工具一号") {
t.Fatalf("list omitted safe metadata: %s", listed.String())
}
var revoked bytes.Buffer
if err := run(context.Background(), []string{"-database", databaseSource, "revoke", "-device-id", deviceID}, &revoked, io.Discard); err != nil {
t.Fatalf("revoke: %v", err)
}
assertNoSecretMetadata(t, revoked.String(), token)
if !strings.Contains(revoked.String(), `"revoked_now":true`) {
t.Fatalf("first revoke output = %s", revoked.String())
}
var repeated bytes.Buffer
if err := run(context.Background(), []string{"-database", databaseSource, "revoke", "-device-id", deviceID}, &repeated, io.Discard); err != nil {
t.Fatalf("repeat revoke: %v", err)
}
assertNoSecretMetadata(t, repeated.String(), token)
if !strings.Contains(repeated.String(), `"revoked_now":false`) {
t.Fatalf("repeat revoke output = %s", repeated.String())
}
}
func TestIssueOutputFailureLeavesCommittedCredentialWithoutSecretInError(t *testing.T) {
databaseSource := migratedDatabase(t)
writer := &recordingFailureWriter{}
err := run(context.Background(), []string{"-database", databaseSource, "issue", "-name", "output failure"}, writer, io.Discard)
if err == nil || err.Error() != "write issued device credential" {
t.Fatalf("issue output failure error = %v", err)
}
fields := outputFields(t, writer.contents.String())
if strings.Contains(err.Error(), fields["token"]) {
t.Fatal("output error disclosed token")
}
database, err := sql.Open("sqlite3", databaseSource)
if err != nil {
t.Fatalf("open database: %v", err)
}
defer database.Close()
var count int
if err := database.QueryRow(`SELECT COUNT(*) FROM device_credentials`).Scan(&count); err != nil || count != 1 {
t.Fatalf("committed credential count = %d, err=%v", count, err)
}
}
func TestCLIRequiresPreMigratedExplicitDatabase(t *testing.T) {
if err := run(context.Background(), []string{"list"}, io.Discard, io.Discard); err == nil {
t.Fatal("command without -database succeeded")
}
missing := filepath.Join(t.TempDir(), "missing.db")
if err := run(context.Background(), []string{"-database", missing, "list"}, io.Discard, io.Discard); err == nil {
t.Fatal("list opened a missing database")
}
if _, err := os.Stat(missing); !errors.Is(err, os.ErrNotExist) {
t.Fatalf("missing database was created: %v", err)
}
unmigrated := filepath.Join(t.TempDir(), "unmigrated.db")
unmigratedDatabase, err := sqlite.Open(unmigrated)
if err != nil {
t.Fatalf("create unmigrated database: %v", err)
}
if _, err := unmigratedDatabase.Exec(`CREATE TABLE unrelated (id INTEGER)`); err != nil {
_ = unmigratedDatabase.Close()
t.Fatalf("initialize unmigrated database: %v", err)
}
if err := unmigratedDatabase.Close(); err != nil {
t.Fatalf("close unmigrated database: %v", err)
}
if err := run(context.Background(), []string{"-database", unmigrated, "list"}, io.Discard, io.Discard); err == nil {
t.Fatal("list accepted an unmigrated database")
}
database, err := sql.Open("sqlite3", unmigrated)
if err != nil {
t.Fatalf("open unmigrated database: %v", err)
}
defer database.Close()
var count int
if err := database.QueryRow(`SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name='device_credentials'`).Scan(&count); err != nil || count != 0 {
t.Fatalf("device_credentials table count = %d, err=%v", count, err)
}
undeclared := filepath.Join(t.TempDir(), "undeclared.db")
if err := run(context.Background(), []string{"-database", undeclared, "rotate"}, io.Discard, io.Discard); err == nil {
t.Fatal("undeclared command succeeded")
}
if _, err := os.Stat(undeclared); !errors.Is(err, os.ErrNotExist) {
t.Fatalf("undeclared command opened database: %v", err)
}
invalidIssue := filepath.Join(t.TempDir(), "invalid-issue.db")
if err := run(context.Background(), []string{"-database", invalidIssue, "issue", "-name", " padded"}, io.Discard, io.Discard); !errors.Is(err, deviceauth.ErrInvalidCredential) {
t.Fatalf("invalid issue error = %v", err)
}
if _, err := os.Stat(invalidIssue); !errors.Is(err, os.ErrNotExist) {
t.Fatalf("invalid issue opened database: %v", err)
}
invalidRevoke := filepath.Join(t.TempDir(), "invalid-revoke.db")
if err := run(context.Background(), []string{"-database", invalidRevoke, "revoke", "-device-id", "not-a-uuid"}, io.Discard, io.Discard); !errors.Is(err, deviceauth.ErrInvalidCredential) {
t.Fatalf("invalid revoke error = %v", err)
}
if _, err := os.Stat(invalidRevoke); !errors.Is(err, os.ErrNotExist) {
t.Fatalf("invalid revoke opened database: %v", err)
}
migrated := migratedDatabase(t)
unknownID := "13c9f507-7473-4fa6-8d71-8786c34c6301"
var unknownOutput bytes.Buffer
err = run(context.Background(), []string{"-database", migrated, "revoke", "-device-id", unknownID}, &unknownOutput, io.Discard)
if !errors.Is(err, deviceauth.ErrCredentialNotFound) || unknownOutput.Len() != 0 || strings.Contains(err.Error(), unknownID) {
t.Fatalf("unknown revoke = output %q, error %v", unknownOutput.String(), err)
}
}
func TestExistingSQLiteDataSourcePreservesSafeOptionsAndRejectsCreationModes(t *testing.T) {
databaseSource := migratedDatabase(t)
fileURI := (&url.URL{
Scheme: "file",
Path: sqliteURIPath(databaseSource),
RawQuery: "_busy_timeout=5000&cache=shared",
}).String()
normalized, err := existingSQLiteDataSource(fileURI)
if err != nil {
t.Fatalf("normalize file URI: %v", err)
}
parsed, err := url.Parse(normalized)
if err != nil {
t.Fatalf("parse normalized URI: %v", err)
}
query := parsed.Query()
if query.Get("mode") != "rw" || query.Get("_busy_timeout") != "5000" || query.Get("cache") != "shared" {
t.Fatalf("normalized query = %v", query)
}
if err := run(context.Background(), []string{"-database", fileURI, "list"}, io.Discard, io.Discard); err != nil {
t.Fatalf("list existing file URI: %v", err)
}
plainNormalized, err := existingSQLiteDataSource(databaseSource + "?_foreign_keys=on")
if err != nil {
t.Fatalf("normalize ordinary path: %v", err)
}
plainURI, err := url.Parse(plainNormalized)
if err != nil || plainURI.Scheme != "file" || plainURI.Query().Get("mode") != "rw" || plainURI.Query().Get("_foreign_keys") != "on" {
t.Fatalf("ordinary path normalization = %q, err=%v", plainNormalized, err)
}
missing := filepath.Join(t.TempDir(), "missing-uri.db")
missingURI := (&url.URL{Scheme: "file", Path: sqliteURIPath(missing)}).String()
if err := run(context.Background(), []string{"-database", missingURI, "list"}, io.Discard, io.Discard); err == nil {
t.Fatal("missing file URI succeeded")
}
if _, err := os.Stat(missing); !errors.Is(err, os.ErrNotExist) {
t.Fatalf("missing file URI created a file: %v", err)
}
for _, scheme := range []string{"FILE", "File"} {
mixedMissing := filepath.Join(t.TempDir(), strings.ToLower(scheme)+"-missing.db")
canonical := (&url.URL{Scheme: "file", Path: sqliteURIPath(mixedMissing)}).String()
mixedURI := scheme + canonical[len("file"):]
normalized, err := existingSQLiteDataSource(mixedURI)
if err != nil || !strings.HasPrefix(normalized, "file:") {
t.Fatalf("normalize %s URI = %q, err=%v", scheme, normalized, err)
}
if err := run(context.Background(), []string{"-database", mixedURI, "list"}, io.Discard, io.Discard); err == nil {
t.Fatalf("missing %s URI succeeded", scheme)
}
if _, err := os.Stat(mixedMissing); !errors.Is(err, os.ErrNotExist) {
t.Fatalf("missing %s URI created a file: %v", scheme, err)
}
}
for name, source := range map[string]string{
"plain memory": ":memory:",
"URI memory": "file::memory:?cache=shared",
"memory mode": fileURI + "&mode=memory",
"read only mode": fileURI + "&mode=ro",
"create mode": fileURI + "&mode=rwc",
"duplicate mode": fileURI + "&mode=rw&mode=rw",
"immutable": fileURI + "&immutable=1",
"query only": fileURI + "&_query_only=1",
"remote authority": "file://server/share/database.db?mode=rw",
} {
t.Run(name, func(t *testing.T) {
if _, err := existingSQLiteDataSource(source); err == nil {
t.Fatalf("unsafe source accepted: %q", source)
}
})
}
}
func sqliteURIPath(path string) string {
normalized := filepath.ToSlash(path)
if filepath.VolumeName(path) != "" && !strings.HasPrefix(normalized, "/") {
return "/" + normalized
}
return normalized
}
type recordingFailureWriter struct {
contents bytes.Buffer
}
func (writer *recordingFailureWriter) Write(value []byte) (int, error) {
_, _ = writer.contents.Write(value)
return 0, errors.New("injected stdout failure")
}
func assertNoSecretMetadata(t *testing.T, output, token string) {
t.Helper()
if strings.Contains(output, token) || strings.Contains(output, "token") || strings.Contains(output, "hash") || strings.Contains(output, "sha256") {
t.Fatalf("metadata output disclosed secret material: %s", output)
}
}
func outputFields(t *testing.T, output string) map[string]string {
t.Helper()
fields := make(map[string]string)
for _, line := range strings.Split(strings.TrimSpace(output), "\n") {
name, value, found := strings.Cut(line, "=")
if !found || name == "" || value == "" {
t.Fatalf("invalid issue output line %q", line)
}
fields[name] = value
}
for _, required := range []string{"device_id", "display_name", "token", "created_at"} {
if fields[required] == "" {
t.Fatalf("issue output missing %s: %q", required, output)
}
}
return fields
}
func migratedDatabase(t *testing.T) string {
t.Helper()
databaseSource := filepath.Join(t.TempDir(), "credentials.db")
database, err := sqlite.Open(databaseSource)
if err != nil {
t.Fatalf("open database: %v", err)
}
if err := migrations.Up(context.Background(), database, commandMigrationDirectory(t)); err != nil {
_ = database.Close()
t.Fatalf("migrate database: %v", err)
}
if err := database.Close(); err != nil {
t.Fatalf("close migrated database: %v", err)
}
return databaseSource
}
func commandMigrationDirectory(t *testing.T) string {
t.Helper()
_, file, _, ok := runtime.Caller(0)
if !ok {
t.Fatal("locate migrations")
}
return filepath.Join(filepath.Dir(file), "..", "..", "migrations")
}
+58
View File
@@ -0,0 +1,58 @@
package main
import (
"context"
"errors"
"flag"
"fmt"
"io"
"log"
"os"
"cmbuyer/admin/internal/migrations"
"cmbuyer/admin/internal/storage/sqlite"
)
func main() {
if err := run(context.Background(), os.Args[1:], os.Stderr); err != nil {
log.Print(err)
os.Exit(1)
}
}
func run(ctx context.Context, args []string, stderr io.Writer) error {
flags := flag.NewFlagSet("migrate", flag.ContinueOnError)
flags.SetOutput(stderr)
databaseSource := flags.String("database", "", "SQLite data source")
migrationDirectory := flags.String("dir", "migrations", "migration directory")
if err := flags.Parse(args); err != nil {
return err
}
if *databaseSource == "" {
return errors.New("-database is required")
}
if flags.NArg() != 1 {
return fmt.Errorf("usage: migrate -database <sqlite-data-source> [-dir <migration-directory>] <up|down|status>")
}
command := flags.Arg(0)
if command != "up" && command != "down" && command != "status" {
return fmt.Errorf("unsupported migration command %q", command)
}
database, err := sqlite.Open(*databaseSource)
if err != nil {
return fmt.Errorf("open SQLite database: %w", err)
}
defer func() {
if err := database.Close(); err != nil {
log.Printf("close SQLite database: %v", err)
}
}()
if err := migrations.Run(ctx, database, *migrationDirectory, command); err != nil {
return fmt.Errorf("run migrations: %w", err)
}
return nil
}
+68
View File
@@ -0,0 +1,68 @@
package main
import (
"context"
"database/sql"
"errors"
"io"
"os"
"path/filepath"
"runtime"
"testing"
)
func TestRunUp(t *testing.T) {
databaseSource := filepath.Join(t.TempDir(), "migrate.db")
if err := run(context.Background(), []string{
"-database", databaseSource,
"-dir", migrationDirectory(t),
"up",
}, io.Discard); err != nil {
t.Fatalf("run up migration command: %v", err)
}
database, err := sql.Open("sqlite3", databaseSource)
if err != nil {
t.Fatalf("open migrated database: %v", err)
}
t.Cleanup(func() {
if err := database.Close(); err != nil {
t.Errorf("close migrated database: %v", err)
}
})
var count int
if err := database.QueryRow(`SELECT COUNT(*) FROM sqlite_master WHERE type = 'table' AND name = 'tasks'`).Scan(&count); err != nil {
t.Fatalf("look up tasks table: %v", err)
}
if count != 1 {
t.Fatalf("tasks table count = %d, want 1", count)
}
}
func TestRunRequiresDatabase(t *testing.T) {
if err := run(context.Background(), []string{"up"}, io.Discard); err == nil {
t.Fatal("run without database source succeeded")
}
}
func TestRunRejectsUndeclaredCommand(t *testing.T) {
databaseSource := filepath.Join(t.TempDir(), "migrate.db")
err := run(context.Background(), []string{"-database", databaseSource, "reset"}, io.Discard)
if err == nil {
t.Fatal("run with undeclared command succeeded")
}
if _, err := os.Stat(databaseSource); !errors.Is(err, os.ErrNotExist) {
t.Fatalf("undeclared command opened database source: stat error = %v, want not exist", err)
}
}
func migrationDirectory(t *testing.T) string {
t.Helper()
_, file, _, ok := runtime.Caller(0)
if !ok {
t.Fatal("locate migration command test source")
}
return filepath.Join(filepath.Dir(file), "..", "..", "migrations")
}
+81
View File
@@ -0,0 +1,81 @@
package main
import (
"errors"
"log"
"net/http"
"cmbuyer/admin/internal/auth"
"cmbuyer/admin/internal/config"
"cmbuyer/admin/internal/deviceauth"
"cmbuyer/admin/internal/server"
evidencestorage "cmbuyer/admin/internal/storage/evidence"
"cmbuyer/admin/internal/storage/sqlite"
"cmbuyer/admin/internal/taskclaim"
"cmbuyer/admin/internal/taskdetail"
"cmbuyer/admin/internal/tasks"
)
// Device Bearer credentials must not cross a plaintext LAN. The MVP is a same-computer
// deployment, so widening this address requires a separately reviewed TLS boundary first.
const listenAddress = "127.0.0.1:8080"
func main() {
if err := run(); err != nil {
log.Fatal(err)
}
}
func run() error {
configuration, err := config.LoadFromEnv()
if err != nil {
return err
}
database, err := sqlite.Open(configuration.DatabaseSource)
if err != nil {
return err
}
defer database.Close()
taskStore, err := tasks.NewSQLiteStore(database)
if err != nil {
return err
}
taskStore.SetStartPolicy(tasks.StartPolicy{AuthorizationTTL: configuration.AuthorizationTTL, MaxQuantity: configuration.MaxTaskQuantity, MaxTotalPrice: configuration.MaxTotalPrice})
detailStore, err := taskdetail.NewSQLiteStore(database)
if err != nil {
return err
}
evidenceStore, err := evidencestorage.NewStore(database, configuration.EvidenceDirectory)
if err != nil {
return err
}
deviceAuthenticator, err := deviceauth.NewSQLiteAuthenticator(database)
if err != nil {
return err
}
claimStore, err := taskclaim.NewStore(database, configuration.ClaimTokenSecret, configuration.ClaimLeaseTTL)
if err != nil {
return err
}
router, err := server.NewRouter(server.Options{
AdminUsername: configuration.AdminUsername,
AdminPasswordBcrypt: configuration.AdminPasswordBcrypt,
Sessions: auth.NewManager(configuration.SessionSecret, configuration.CookieSecure),
Tasks: taskStore,
TaskDetails: detailStore,
Evidence: evidenceStore,
DeviceAuthenticator: deviceAuthenticator,
TaskClaims: claimStore,
})
if err != nil {
return err
}
err = http.ListenAndServe(listenAddress, router)
if errors.Is(err, http.ErrServerClosed) {
return nil
}
return err
}
+9
View File
@@ -0,0 +1,9 @@
package main
import "testing"
func TestListenAddressIsIPv4LoopbackOnly(t *testing.T) {
if listenAddress != "127.0.0.1:8080" {
t.Fatalf("listenAddress = %q, want loopback-only endpoint", listenAddress)
}
}
+46
View File
@@ -0,0 +1,46 @@
module cmbuyer/admin
go 1.23.0
require (
github.com/gin-gonic/gin v1.11.0
github.com/mattn/go-sqlite3 v1.14.49
github.com/pressly/goose/v3 v3.24.0
golang.org/x/crypto v0.40.0
)
require (
github.com/bytedance/sonic v1.14.0 // indirect
github.com/bytedance/sonic/loader v0.3.0 // indirect
github.com/cloudwego/base64x v0.1.6 // indirect
github.com/gabriel-vasile/mimetype v1.4.8 // indirect
github.com/gin-contrib/sse v1.1.0 // indirect
github.com/go-playground/locales v0.14.1 // indirect
github.com/go-playground/universal-translator v0.18.1 // indirect
github.com/go-playground/validator/v10 v10.27.0 // indirect
github.com/goccy/go-json v0.10.2 // indirect
github.com/goccy/go-yaml v1.18.0 // indirect
github.com/json-iterator/go v1.1.12 // indirect
github.com/klauspost/cpuid/v2 v2.3.0 // indirect
github.com/leodido/go-urn v1.4.0 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/mfridman/interpolate v0.0.2 // indirect
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421 // indirect
github.com/modern-go/reflect2 v1.0.2 // indirect
github.com/pelletier/go-toml/v2 v2.2.4 // indirect
github.com/quic-go/qpack v0.5.1 // indirect
github.com/quic-go/quic-go v0.54.0 // indirect
github.com/sethvargo/go-retry v0.3.0 // indirect
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
github.com/ugorji/go/codec v1.3.0 // indirect
go.uber.org/mock v0.5.0 // indirect
go.uber.org/multierr v1.11.0 // indirect
golang.org/x/arch v0.20.0 // indirect
golang.org/x/mod v0.25.0 // indirect
golang.org/x/net v0.42.0 // indirect
golang.org/x/sync v0.16.0 // indirect
golang.org/x/sys v0.35.0 // indirect
golang.org/x/text v0.27.0 // indirect
golang.org/x/tools v0.34.0 // indirect
google.golang.org/protobuf v1.36.9 // indirect
)
+122
View File
@@ -0,0 +1,122 @@
github.com/bytedance/sonic v1.14.0 h1:/OfKt8HFw0kh2rj8N0F6C/qPGRESq0BbaNZgcNXXzQQ=
github.com/bytedance/sonic v1.14.0/go.mod h1:WoEbx8WTcFJfzCe0hbmyTGrfjt8PzNEBdxlNUO24NhA=
github.com/bytedance/sonic/loader v0.3.0 h1:dskwH8edlzNMctoruo8FPTJDF3vLtDT0sXZwvZJyqeA=
github.com/bytedance/sonic/loader v0.3.0/go.mod h1:N8A3vUdtUebEY2/VQC0MyhYeKUFosQU6FxH2JmUe6VI=
github.com/cloudwego/base64x v0.1.6 h1:t11wG9AECkCDk5fMSoxmufanudBtJ+/HemLstXDLI2M=
github.com/cloudwego/base64x v0.1.6/go.mod h1:OFcloc187FXDaYHvrNIjxSe8ncn0OOM8gEHfghB2IPU=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
github.com/gabriel-vasile/mimetype v1.4.8 h1:FfZ3gj38NjllZIeJAmMhr+qKL8Wu+nOoI3GqacKw1NM=
github.com/gabriel-vasile/mimetype v1.4.8/go.mod h1:ByKUIKGjh1ODkGM1asKUbQZOLGrPjydw3hYPU2YU9t8=
github.com/gin-contrib/sse v1.1.0 h1:n0w2GMuUpWDVp7qSpvze6fAu9iRxJY4Hmj6AmBOU05w=
github.com/gin-contrib/sse v1.1.0/go.mod h1:hxRZ5gVpWMT7Z0B0gSNYqqsSCNIJMjzvm6fqCz9vjwM=
github.com/gin-gonic/gin v1.11.0 h1:OW/6PLjyusp2PPXtyxKHU0RbX6I/l28FTdDlae5ueWk=
github.com/gin-gonic/gin v1.11.0/go.mod h1:+iq/FyxlGzII0KHiBGjuNn4UNENUlKbGlNmc+W50Dls=
github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s=
github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4=
github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA=
github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY=
github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY=
github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY=
github.com/go-playground/validator/v10 v10.27.0 h1:w8+XrWVMhGkxOaaowyKH35gFydVHOvC0/uWoy2Fzwn4=
github.com/go-playground/validator/v10 v10.27.0/go.mod h1:I5QpIEbmr8On7W0TktmJAumgzX4CA1XNl4ZmDuVHKKo=
github.com/goccy/go-json v0.10.2 h1:CrxCmQqYDkv1z7lO7Wbh2HN93uovUHgrECaO5ZrCXAU=
github.com/goccy/go-json v0.10.2/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I=
github.com/goccy/go-yaml v1.18.0 h1:8W7wMFS12Pcas7KU+VVkaiCng+kG8QiFeFwzFb+rwuw=
github.com/goccy/go-yaml v1.18.0/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA=
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k=
github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM=
github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y=
github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0=
github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ=
github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/mattn/go-sqlite3 v1.14.49 h1:B8jBHC3xhxZgxztrgruTuLucebnULQnx4W7cF7SAE9w=
github.com/mattn/go-sqlite3 v1.14.49/go.mod h1:6JTjA44L93a0QCyJef5YvlPoKXntQPjzWv5gtm9sB6w=
github.com/mfridman/interpolate v0.0.2 h1:pnuTK7MQIxxFz1Gr+rjSIx9u7qVjf5VOoM/u6BbAxPY=
github.com/mfridman/interpolate v0.0.2/go.mod h1:p+7uk6oE07mpE/Ik1b8EckO0O4ZXiGAfshKBWLUM9Xg=
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421 h1:ZqeYNhU3OHLH3mGKHDcjJRFFRrJa6eAM5H+CtDdOsPc=
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M=
github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
github.com/ncruces/go-strftime v0.1.9 h1:bY0MQC28UADQmHmaF5dgpLmImcShSi2kHU9XLdhx/f4=
github.com/ncruces/go-strftime v0.1.9/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4=
github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/pressly/goose/v3 v3.24.0 h1:sFbNms7Bd++2VMq6HSgDHDLWa7kHz1qXzPb3ZIU72VU=
github.com/pressly/goose/v3 v3.24.0/go.mod h1:rEWreU9uVtt0DHCyLzF9gRcWiiTF/V+528DV+4DORug=
github.com/quic-go/qpack v0.5.1 h1:giqksBPnT/HDtZ6VhtFKgoLOWmlyo9Ei6u9PqzIMbhI=
github.com/quic-go/qpack v0.5.1/go.mod h1:+PC4XFrEskIVkcLzpEkbLqq1uCoxPhQuvK5rH1ZgaEg=
github.com/quic-go/quic-go v0.54.0 h1:6s1YB9QotYI6Ospeiguknbp2Znb/jZYjZLRXn9kMQBg=
github.com/quic-go/quic-go v0.54.0/go.mod h1:e68ZEaCdyviluZmy44P6Iey98v/Wfz6HCjQEm+l8zTY=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
github.com/sethvargo/go-retry v0.3.0 h1:EEt31A35QhrcRZtrYFDTBg91cqZVnFL2navjDrah2SE=
github.com/sethvargo/go-retry v0.3.0/go.mod h1:mNX17F0C/HguQMyMyJxcnU471gOZGxCLyYaFyAZraas=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI=
github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08=
github.com/ugorji/go/codec v1.3.0 h1:Qd2W2sQawAfG8XSvzwhBeoGq71zXOC/Q1E9y/wUcsUA=
github.com/ugorji/go/codec v1.3.0/go.mod h1:pRBVtBSKl77K30Bv8R2P+cLSGaTtex6fsA2Wjqmfxj4=
go.uber.org/mock v0.5.0 h1:KAMbZvZPyBPWgD14IrIQ38QCyjwpvVVV6K/bHl1IwQU=
go.uber.org/mock v0.5.0/go.mod h1:ge71pBPLYDk7QIi1LupWxdAykm7KIEFchiOqd6z7qMM=
go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0=
go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y=
golang.org/x/arch v0.20.0 h1:dx1zTU0MAE98U+TQ8BLl7XsJbgze2WnNKF/8tGp/Q6c=
golang.org/x/arch v0.20.0/go.mod h1:bdwinDaKcfZUGpH09BB7ZmOfhalA8lQdzl62l8gGWsk=
golang.org/x/crypto v0.40.0 h1:r4x+VvoG5Fm+eJcxMaY8CQM7Lb0l1lsmjGBQ6s8BfKM=
golang.org/x/crypto v0.40.0/go.mod h1:Qr1vMER5WyS2dfPHAlsOj01wgLbsyWtFn/aY+5+ZdxY=
golang.org/x/mod v0.25.0 h1:n7a+ZbQKQA/Ysbyb0/6IbB1H/X41mKgbhfv7AfG/44w=
golang.org/x/mod v0.25.0/go.mod h1:IXM97Txy2VM4PJ3gI61r1YEk/gAj6zAHN3AdZt6S9Ww=
golang.org/x/net v0.42.0 h1:jzkYrhi3YQWD6MLBJcsklgQsoAcw89EcZbJw8Z614hs=
golang.org/x/net v0.42.0/go.mod h1:FF1RA5d3u7nAYA4z2TkclSCKh68eSXtiFwcWQpPXdt8=
golang.org/x/sync v0.16.0 h1:ycBJEhp9p4vXvUZNszeOq0kGTPghopOL8q0fq3vstxw=
golang.org/x/sync v0.16.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.35.0 h1:vz1N37gP5bs89s7He8XuIYXpyY0+QlsKmzipCbUtyxI=
golang.org/x/sys v0.35.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
golang.org/x/text v0.27.0 h1:4fGWRpyh641NLlecmyl4LOe6yDdfaYNrGb2zdfo4JV4=
golang.org/x/text v0.27.0/go.mod h1:1D28KMCvyooCX9hBiosv5Tz/+YLxj0j7XhWjpSUF7CU=
golang.org/x/tools v0.34.0 h1:qIpSLOxeCYGg9TrcJokLBG4KFA6d795g0xkBkiESGlo=
golang.org/x/tools v0.34.0/go.mod h1:pAP9OwEaY1CAW3HOmg3hLZC5Z0CCmzjAF2UQMSqNARg=
google.golang.org/protobuf v1.36.9 h1:w2gp2mA27hUeUzj9Ex9FBjsBm40zfaDtEWow293U7Iw=
google.golang.org/protobuf v1.36.9/go.mod h1:fuxRtAxBytpl4zzqUh6/eyUujkJdNiuEkXntxiD/uRU=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
modernc.org/gc/v3 v3.0.0-20240107210532-573471604cb6 h1:5D53IMaUuA5InSeMu9eJtlQXS2NxAhyWQvkKEgXZhHI=
modernc.org/gc/v3 v3.0.0-20240107210532-573471604cb6/go.mod h1:Qz0X07sNOR1jWYCrJMEnbW/X55x206Q7Vt4mz6/wHp4=
modernc.org/libc v1.55.3 h1:AzcW1mhlPNrRtjS5sS+eW2ISCgSOLLNyFzRh/V3Qj/U=
modernc.org/libc v1.55.3/go.mod h1:qFXepLhz+JjFThQ4kzwzOjA/y/artDeg+pcYnY+Q83w=
modernc.org/mathutil v1.6.0 h1:fRe9+AmYlaej+64JsEEhoWuAYBkOtQiMEU7n/XgfYi4=
modernc.org/mathutil v1.6.0/go.mod h1:Ui5Q9q1TR2gFm0AQRqQUaBWFLAhQpCwNcuhBOSedWPo=
modernc.org/memory v1.8.0 h1:IqGTL6eFMaDZZhEWwcREgeMXYwmW83LYW8cROZYkg+E=
modernc.org/memory v1.8.0/go.mod h1:XPZ936zp5OMKGWPqbD3JShgd/ZoQ7899TUuQqxY+peU=
modernc.org/sqlite v1.34.1 h1:u3Yi6M0N8t9yKRDwhXcyp1eS5/ErhPTBggxWFuR6Hfk=
modernc.org/sqlite v1.34.1/go.mod h1:pXV2xHxhzXZsgT/RtTFAPY6JJDEvOTcTdwADQCCWD4k=
modernc.org/strutil v1.2.0 h1:agBi9dp1I+eOnxXeiZawM8F4LawKv4NzGWSaLfyeNZA=
modernc.org/strutil v1.2.0/go.mod h1:/mdcBmfOibveCTBxUl5B5l6W+TTH1FXPLHZE6bTosX0=
modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y=
modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM=
+204
View File
@@ -0,0 +1,204 @@
// Package auth 提供内存会话与 CSRF 防护。会话不落库,服务重启会安全地使所有登录失效。
package auth
import (
"crypto/hmac"
"crypto/rand"
"crypto/sha256"
"crypto/subtle"
"encoding/base64"
"io"
"net/http"
"strconv"
"strings"
"sync"
"time"
)
const (
CookieName = "cmbuyer_session"
SessionLifetime = 8 * time.Hour
csrfTokenByteSize = 32
)
type session struct {
csrfToken string
authenticated bool
expiresAt time.Time
}
// Manager 签发、验证并撤销进程内会话。cookie 仅承载经过 HMAC 签名的随机 session ID。
type Manager struct {
secret []byte
cookieSecure bool
now func() time.Time
random io.Reader
mu sync.Mutex
sessions map[string]session
}
// NewManager 创建会话管理器。secret 在启动时已由 config 验证为足够长度。
func NewManager(secret []byte, cookieSecure bool) *Manager {
return &Manager{
secret: append([]byte(nil), secret...),
cookieSecure: cookieSecure,
now: time.Now,
random: rand.Reader,
sessions: make(map[string]session),
}
}
// Ensure 返回当前有效会话;不存在或过期时签发匿名会话,以保护登录表单本身的 POST。
func (manager *Manager) Ensure(writer http.ResponseWriter, request *http.Request) (csrfToken string, authenticated bool) {
if id, current, ok := manager.current(request); ok {
return current.csrfToken, current.authenticated
} else if id != "" {
manager.delete(id)
}
id, current := manager.create(false)
manager.writeCookie(writer, id, current.expiresAt)
return current.csrfToken, false
}
// IsAuthenticated 只读检查当前请求是否持有有效管理会话;它不会像 Ensure 一样创建匿名会话。
func (manager *Manager) IsAuthenticated(request *http.Request) bool {
_, current, found := manager.current(request)
return found && current.authenticated
}
// VerifyCSRF 只接受当前未过期会话中以恒定时间比较匹配的 token。
func (manager *Manager) VerifyCSRF(request *http.Request, token string) (authenticated bool, ok bool) {
_, current, found := manager.current(request)
if !found || token == "" {
return false, false
}
if subtle.ConstantTimeCompare([]byte(current.csrfToken), []byte(token)) != 1 {
return false, false
}
return current.authenticated, true
}
// RotateAuthenticated 在登录成功后撤销旧会话并签发全新认证会话,避免 session fixation 与 CSRF 复用。
func (manager *Manager) RotateAuthenticated(writer http.ResponseWriter, request *http.Request) string {
if id, _, ok := manager.current(request); ok {
manager.delete(id)
}
id, current := manager.create(true)
manager.writeCookie(writer, id, current.expiresAt)
return current.csrfToken
}
// Logout 撤销当前会话并立即清除浏览器 cookie。
func (manager *Manager) Logout(writer http.ResponseWriter, request *http.Request) {
if id, _, ok := manager.current(request); ok {
manager.delete(id)
}
http.SetCookie(writer, &http.Cookie{
Name: CookieName,
Value: "",
Path: "/",
MaxAge: -1,
HttpOnly: true,
Secure: manager.cookieSecure,
SameSite: http.SameSiteLaxMode,
})
}
func (manager *Manager) current(request *http.Request) (string, session, bool) {
cookie, err := request.Cookie(CookieName)
if err != nil {
return "", session{}, false
}
id, expiresAt, ok := manager.verifyCookie(cookie.Value)
if !ok || !manager.now().Before(expiresAt) {
return id, session{}, false
}
manager.mu.Lock()
defer manager.mu.Unlock()
current, found := manager.sessions[id]
if !found || !manager.now().Before(current.expiresAt) {
return id, session{}, false
}
return id, current, true
}
func (manager *Manager) create(authenticated bool) (string, session) {
id := manager.randomToken()
current := session{
csrfToken: manager.randomToken(),
authenticated: authenticated,
expiresAt: manager.now().Add(SessionLifetime),
}
manager.mu.Lock()
manager.sessions[id] = current
manager.mu.Unlock()
return id, current
}
func (manager *Manager) delete(id string) {
manager.mu.Lock()
delete(manager.sessions, id)
manager.mu.Unlock()
}
func (manager *Manager) randomToken() string {
bytes := make([]byte, csrfTokenByteSize)
if _, err := io.ReadFull(manager.random, bytes); err != nil {
panic("crypto/rand failed while creating a session token")
}
return base64.RawURLEncoding.EncodeToString(bytes)
}
func (manager *Manager) writeCookie(writer http.ResponseWriter, id string, expiresAt time.Time) {
http.SetCookie(writer, &http.Cookie{
Name: CookieName,
Value: manager.signCookie(id, expiresAt),
Path: "/",
MaxAge: int(expiresAt.Sub(manager.now()).Seconds()),
Expires: expiresAt,
HttpOnly: true,
Secure: manager.cookieSecure,
SameSite: http.SameSiteLaxMode,
})
}
func (manager *Manager) signCookie(id string, expiresAt time.Time) string {
payload := id + "." + strconv.FormatInt(expiresAt.Unix(), 10)
mac := hmac.New(sha256.New, manager.secret)
_, _ = mac.Write([]byte(payload))
return payload + "." + base64.RawURLEncoding.EncodeToString(mac.Sum(nil))
}
func (manager *Manager) verifyCookie(value string) (string, time.Time, bool) {
parts := strings.Split(value, ".")
if len(parts) != 3 || parts[0] == "" {
return "", time.Time{}, false
}
expiresUnix, err := strconv.ParseInt(parts[1], 10, 64)
if err != nil {
return "", time.Time{}, false
}
provided, err := base64.RawURLEncoding.DecodeString(parts[2])
if err != nil {
return "", time.Time{}, false
}
payload := parts[0] + "." + parts[1]
mac := hmac.New(sha256.New, manager.secret)
_, _ = mac.Write([]byte(payload))
if !hmac.Equal(provided, mac.Sum(nil)) {
return "", time.Time{}, false
}
return parts[0], time.Unix(expiresUnix, 0), true
}
+94
View File
@@ -0,0 +1,94 @@
package auth
import (
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
)
func TestManagerRejectsTamperedAndExpiredCookies(t *testing.T) {
manager := NewManager([]byte(strings.Repeat("s", 32)), true)
request := httptest.NewRequest(http.MethodGet, "/login", nil)
response := httptest.NewRecorder()
csrf, authenticated := manager.Ensure(response, request)
if csrf == "" || authenticated {
t.Fatalf("Ensure = (%q, %t), want anonymous CSRF session", csrf, authenticated)
}
cookie := response.Result().Cookies()[0]
if !cookie.HttpOnly || !cookie.Secure || cookie.SameSite != http.SameSiteLaxMode || cookie.Path != "/" {
t.Fatalf("session cookie is missing security attributes: %#v", cookie)
}
tampered := *cookie
tampered.Value = flipCookieValue(t, cookie.Value)
tamperedRequest := httptest.NewRequest(http.MethodPost, "/login", nil)
tamperedRequest.AddCookie(&tampered)
if _, ok := manager.VerifyCSRF(tamperedRequest, csrf); ok {
t.Fatal("tampered signed cookie passed CSRF verification")
}
manager.now = func() time.Time { return time.Now().Add(9 * time.Hour) }
expiredRequest := httptest.NewRequest(http.MethodPost, "/login", nil)
expiredRequest.AddCookie(cookie)
if _, ok := manager.VerifyCSRF(expiredRequest, csrf); ok {
t.Fatal("expired cookie passed CSRF verification")
}
}
func TestIsAuthenticatedDoesNotCreateOrDependOnCSRFValidation(t *testing.T) {
manager := NewManager([]byte(strings.Repeat("s", 32)), false)
missingSession := httptest.NewRequest(http.MethodPost, "/tasks/start-purchases", nil)
if manager.IsAuthenticated(missingSession) {
t.Fatal("missing session was treated as authenticated")
}
if len(manager.sessions) != 0 {
t.Fatalf("read-only authentication check created %d sessions", len(manager.sessions))
}
anonymousRequest := httptest.NewRequest(http.MethodGet, "/login", nil)
anonymousResponse := httptest.NewRecorder()
manager.Ensure(anonymousResponse, anonymousRequest)
anonymousCookie := anonymousResponse.Result().Cookies()[0]
anonymousCheck := httptest.NewRequest(http.MethodPost, "/tasks/start-purchases", nil)
anonymousCheck.AddCookie(anonymousCookie)
if manager.IsAuthenticated(anonymousCheck) {
t.Fatal("anonymous CSRF session was treated as authenticated")
}
loginRequest := httptest.NewRequest(http.MethodPost, "/login", nil)
loginRequest.AddCookie(anonymousCookie)
authenticatedResponse := httptest.NewRecorder()
csrf := manager.RotateAuthenticated(authenticatedResponse, loginRequest)
authenticatedCookie := authenticatedResponse.Result().Cookies()[0]
authenticatedCheck := httptest.NewRequest(http.MethodPost, "/tasks/start-purchases", nil)
authenticatedCheck.AddCookie(authenticatedCookie)
if !manager.IsAuthenticated(authenticatedCheck) {
t.Fatal("valid authenticated session was not recognized")
}
if authenticated, csrfOK := manager.VerifyCSRF(authenticatedCheck, "wrong-token"); authenticated || csrfOK {
t.Fatalf("wrong token result = (%t, %t), want (false, false)", authenticated, csrfOK)
}
validRequest := httptest.NewRequest(http.MethodPost, "/tasks/start-purchases", nil)
validRequest.AddCookie(authenticatedCookie)
if authenticated, csrfOK := manager.VerifyCSRF(validRequest, csrf); !authenticated || !csrfOK {
t.Fatalf("valid token result = (%t, %t), want (true, true)", authenticated, csrfOK)
}
if authenticated, csrfOK := manager.VerifyCSRF(httptest.NewRequest(http.MethodPost, "/tasks/start-purchases", nil), csrf); authenticated || csrfOK {
t.Fatalf("missing session result = (%t, %t), want (false, false)", authenticated, csrfOK)
}
}
func flipCookieValue(t *testing.T, value string) string {
t.Helper()
if value == "" {
t.Fatal("cannot tamper with an empty cookie")
}
if value[0] == 'A' {
return "B" + value[1:]
}
return "A" + value[1:]
}
+178
View File
@@ -0,0 +1,178 @@
// Package config 读取采购服务的启动配置。凭据只允许来自显式环境变量,避免把秘密写入代码或仓库。
package config
import (
"bytes"
"encoding/hex"
"errors"
"fmt"
"os"
"path/filepath"
"strconv"
"strings"
"time"
"golang.org/x/crypto/bcrypt"
)
const (
adminUsernameEnv = "CMBUYER_ADMIN_USERNAME"
adminPasswordBcryptEnv = "CMBUYER_ADMIN_PASSWORD_BCRYPT"
sessionSecretEnv = "CMBUYER_SESSION_SECRET"
cookieSecureEnv = "CMBUYER_COOKIE_SECURE"
databaseSourceEnv = "CMBUYER_DATABASE_SOURCE"
authorizationTTLEnv = "CMBUYER_AUTHORIZATION_TTL"
maxTaskQuantityEnv = "CMBUYER_MAX_TASK_QUANTITY"
maxTotalPriceEnv = "CMBUYER_MAX_TOTAL_PRICE"
evidenceDirectoryEnv = "CMBUYER_EVIDENCE_DIR"
claimTokenSecretEnv = "CMBUYER_CLAIM_TOKEN_SECRET"
claimLeaseTTLEnv = "CMBUYER_CLAIM_LEASE_TTL"
minimumSecretLength = 32
)
// Config 是启动采购服务所需的最小安全配置。
type Config struct {
AdminUsername string
AdminPasswordBcrypt string
SessionSecret []byte
CookieSecure bool
DatabaseSource string
AuthorizationTTL time.Duration
MaxTaskQuantity int
MaxTotalPrice string
EvidenceDirectory string
ClaimTokenSecret []byte
ClaimLeaseTTL time.Duration
}
// LoadFromEnv 从进程环境读取配置。错误只指出缺失或非法的变量名,绝不回显秘密。
func LoadFromEnv() (Config, error) {
return Load(os.LookupEnv)
}
// Load 使用 lookup 读取配置,以便在不污染进程环境的情况下测试启动边界。
func Load(lookup func(string) (string, bool)) (Config, error) {
username, err := required(lookup, adminUsernameEnv)
if err != nil {
return Config{}, err
}
passwordHash, err := required(lookup, adminPasswordBcryptEnv)
if err != nil {
return Config{}, err
}
if _, err := bcrypt.Cost([]byte(passwordHash)); err != nil {
return Config{}, fmt.Errorf("%s is not a valid bcrypt hash", adminPasswordBcryptEnv)
}
secret, err := required(lookup, sessionSecretEnv)
if err != nil {
return Config{}, err
}
if len([]byte(secret)) < minimumSecretLength {
return Config{}, fmt.Errorf("%s must be at least %d bytes", sessionSecretEnv, minimumSecretLength)
}
cookieSecure := false
if value, present := lookup(cookieSecureEnv); present {
switch value {
case "true":
cookieSecure = true
case "false":
cookieSecure = false
default:
return Config{}, fmt.Errorf("%s must be exactly true or false", cookieSecureEnv)
}
}
databaseSource, err := required(lookup, databaseSourceEnv)
if err != nil {
return Config{}, err
}
ttlText, err := required(lookup, authorizationTTLEnv)
if err != nil {
return Config{}, err
}
ttl, err := time.ParseDuration(ttlText)
if err != nil || ttl <= 0 {
return Config{}, fmt.Errorf("%s must be a positive duration", authorizationTTLEnv)
}
quantityText, err := required(lookup, maxTaskQuantityEnv)
if err != nil {
return Config{}, err
}
maxQuantity, err := strconv.Atoi(quantityText)
if err != nil || maxQuantity < 1 {
return Config{}, fmt.Errorf("%s must be a positive integer", maxTaskQuantityEnv)
}
maxPrice, err := required(lookup, maxTotalPriceEnv)
if err != nil {
return Config{}, err
}
if !canonicalMoney(maxPrice) {
return Config{}, fmt.Errorf("%s must be a canonical positive decimal", maxTotalPriceEnv)
}
evidenceDirectory, err := required(lookup, evidenceDirectoryEnv)
if err != nil {
return Config{}, err
}
if strings.TrimSpace(evidenceDirectory) != evidenceDirectory || !filepath.IsAbs(evidenceDirectory) {
return Config{}, fmt.Errorf("%s must be an absolute path without surrounding whitespace", evidenceDirectoryEnv)
}
claimSecretText, err := required(lookup, claimTokenSecretEnv)
if err != nil {
return Config{}, err
}
claimSecret, err := hex.DecodeString(claimSecretText)
if err != nil || len(claimSecret) != 32 || hex.EncodeToString(claimSecret) != claimSecretText {
return Config{}, fmt.Errorf("%s must be exactly 64 lowercase hexadecimal characters", claimTokenSecretEnv)
}
// Claim ownership, admin sessions and device authentication are separate security domains.
// Reject both identical configuration text and identical effective key bytes.
if claimSecretText == secret || bytes.Equal(claimSecret, []byte(secret)) {
return Config{}, fmt.Errorf("%s must be isolated from %s", claimTokenSecretEnv, sessionSecretEnv)
}
claimTTLText, err := required(lookup, claimLeaseTTLEnv)
if err != nil {
return Config{}, err
}
claimTTL, err := time.ParseDuration(claimTTLText)
if err != nil || claimTTL <= 0 || claimTTL >= ttl {
return Config{}, fmt.Errorf("%s must be positive and shorter than %s", claimLeaseTTLEnv, authorizationTTLEnv)
}
return Config{
AdminUsername: username,
AdminPasswordBcrypt: passwordHash,
SessionSecret: []byte(secret),
CookieSecure: cookieSecure,
DatabaseSource: databaseSource,
AuthorizationTTL: ttl, MaxTaskQuantity: maxQuantity, MaxTotalPrice: maxPrice,
EvidenceDirectory: evidenceDirectory,
ClaimTokenSecret: claimSecret,
ClaimLeaseTTL: claimTTL,
}, nil
}
func canonicalMoney(value string) bool {
parts := strings.Split(value, ".")
if len(parts) != 2 || len(parts[0]) == 0 || len(parts[1]) != 2 || (len(parts[0]) > 1 && parts[0][0] == '0') {
return false
}
for _, part := range parts {
for _, ch := range part {
if ch < '0' || ch > '9' {
return false
}
}
}
return strings.Trim(parts[0]+parts[1], "0") != ""
}
func required(lookup func(string) (string, bool), name string) (string, error) {
value, present := lookup(name)
if !present || strings.TrimSpace(value) == "" {
return "", errors.New(name + " must be set")
}
return value, nil
}
+109
View File
@@ -0,0 +1,109 @@
package config_test
import (
"strings"
"testing"
"time"
"cmbuyer/admin/internal/config"
"golang.org/x/crypto/bcrypt"
)
func TestLoad(t *testing.T) {
hash, err := bcrypt.GenerateFromPassword([]byte("test-password"), bcrypt.MinCost)
if err != nil {
t.Fatalf("generate bcrypt hash: %v", err)
}
values := map[string]string{
"CMBUYER_ADMIN_USERNAME": "admin",
"CMBUYER_ADMIN_PASSWORD_BCRYPT": string(hash),
"CMBUYER_SESSION_SECRET": strings.Repeat("s", 32),
"CMBUYER_COOKIE_SECURE": "true",
"CMBUYER_DATABASE_SOURCE": ":memory:",
"CMBUYER_AUTHORIZATION_TTL": "10m",
"CMBUYER_MAX_TASK_QUANTITY": "99",
"CMBUYER_MAX_TOTAL_PRICE": "999.99",
"CMBUYER_EVIDENCE_DIR": t.TempDir(),
"CMBUYER_CLAIM_TOKEN_SECRET": strings.Repeat("ab", 32),
"CMBUYER_CLAIM_LEASE_TTL": "1m",
}
got, err := config.Load(lookup(values))
if err != nil {
t.Fatalf("Load: %v", err)
}
if got.AdminUsername != "admin" || !got.CookieSecure || len(got.ClaimTokenSecret) != 32 || got.ClaimLeaseTTL != time.Minute {
t.Fatalf("Load returned unexpected public configuration: %#v", got)
}
}
func TestLoadRejectsMissingOrInvalidConfiguration(t *testing.T) {
hash, err := bcrypt.GenerateFromPassword([]byte("test-password"), bcrypt.MinCost)
if err != nil {
t.Fatalf("generate bcrypt hash: %v", err)
}
base := map[string]string{
"CMBUYER_ADMIN_USERNAME": "admin",
"CMBUYER_ADMIN_PASSWORD_BCRYPT": string(hash),
"CMBUYER_SESSION_SECRET": strings.Repeat("s", 32),
"CMBUYER_DATABASE_SOURCE": ":memory:",
"CMBUYER_AUTHORIZATION_TTL": "10m",
"CMBUYER_MAX_TASK_QUANTITY": "99",
"CMBUYER_MAX_TOTAL_PRICE": "999.99",
"CMBUYER_EVIDENCE_DIR": t.TempDir(),
"CMBUYER_CLAIM_TOKEN_SECRET": strings.Repeat("ab", 32),
"CMBUYER_CLAIM_LEASE_TTL": "1m",
}
tests := []struct {
name string
mutate func(map[string]string)
want string
}{
{"missing username", func(values map[string]string) { delete(values, "CMBUYER_ADMIN_USERNAME") }, "CMBUYER_ADMIN_USERNAME"},
{"invalid bcrypt", func(values map[string]string) { values["CMBUYER_ADMIN_PASSWORD_BCRYPT"] = "not-a-bcrypt-hash" }, "CMBUYER_ADMIN_PASSWORD_BCRYPT"},
{"short secret", func(values map[string]string) { values["CMBUYER_SESSION_SECRET"] = "short" }, "CMBUYER_SESSION_SECRET"},
{"invalid secure flag", func(values map[string]string) { values["CMBUYER_COOKIE_SECURE"] = "1" }, "CMBUYER_COOKIE_SECURE"},
{"missing database", func(values map[string]string) { delete(values, "CMBUYER_DATABASE_SOURCE") }, "CMBUYER_DATABASE_SOURCE"},
{"invalid authorization ttl", func(values map[string]string) { values["CMBUYER_AUTHORIZATION_TTL"] = "0s" }, "CMBUYER_AUTHORIZATION_TTL"},
{"invalid maximum quantity", func(values map[string]string) { values["CMBUYER_MAX_TASK_QUANTITY"] = "0" }, "CMBUYER_MAX_TASK_QUANTITY"},
{"invalid maximum total price", func(values map[string]string) { values["CMBUYER_MAX_TOTAL_PRICE"] = "1" }, "CMBUYER_MAX_TOTAL_PRICE"},
{"missing evidence directory", func(values map[string]string) { delete(values, "CMBUYER_EVIDENCE_DIR") }, "CMBUYER_EVIDENCE_DIR"},
{"relative evidence directory", func(values map[string]string) { values["CMBUYER_EVIDENCE_DIR"] = "evidence" }, "CMBUYER_EVIDENCE_DIR"},
{"invalid claim secret", func(values map[string]string) { values["CMBUYER_CLAIM_TOKEN_SECRET"] = strings.Repeat("A", 64) }, "CMBUYER_CLAIM_TOKEN_SECRET"},
{"claim secret same raw session secret", func(values map[string]string) {
values["CMBUYER_SESSION_SECRET"] = values["CMBUYER_CLAIM_TOKEN_SECRET"]
}, "CMBUYER_CLAIM_TOKEN_SECRET"},
{"claim secret same decoded session secret", func(values map[string]string) { values["CMBUYER_SESSION_SECRET"] = strings.Repeat("\xab", 32) }, "CMBUYER_CLAIM_TOKEN_SECRET"},
{"invalid claim lease ttl", func(values map[string]string) { values["CMBUYER_CLAIM_LEASE_TTL"] = "10m" }, "CMBUYER_CLAIM_LEASE_TTL"},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
values := copyValues(base)
test.mutate(values)
_, err := config.Load(lookup(values))
if err == nil || !strings.Contains(err.Error(), test.want) {
t.Fatalf("Load error = %v, want mention of %s", err, test.want)
}
})
}
}
func lookup(values map[string]string) func(string) (string, bool) {
return func(key string) (string, bool) {
value, ok := values[key]
return value, ok
}
}
func copyValues(values map[string]string) map[string]string {
copy := make(map[string]string, len(values))
for key, value := range values {
copy[key] = value
}
return copy
}
+212
View File
@@ -0,0 +1,212 @@
package deviceauth
import (
"context"
"crypto/rand"
"crypto/sha256"
"database/sql"
"encoding/hex"
"errors"
"fmt"
"io"
"strings"
"sync"
"time"
"unicode"
)
const (
StatusActive = "ACTIVE"
StatusRevoked = "REVOKED"
)
var (
ErrInvalidCredential = errors.New("invalid device credential input")
ErrCredentialNotFound = errors.New("device credential not found")
)
type Credential struct {
DeviceID string `json:"device_id"`
DisplayName string `json:"display_name"`
Status string `json:"status"`
CreatedAt time.Time `json:"created_at"`
RevokedAt *time.Time `json:"revoked_at,omitempty"`
}
// IssuedCredential is the only value that can carry the plaintext token. It is returned only
// after SQLite has committed the hash and is intended for the management CLI's one stdout write.
type IssuedCredential struct {
Credential
Token string `json:"-"`
}
type CredentialStore struct {
database *sql.DB
now func() time.Time
random io.Reader
randomMu sync.Mutex
}
func NewCredentialStore(database *sql.DB) (*CredentialStore, error) {
if database == nil {
return nil, errors.New("device credential database is required")
}
if _, err := database.Exec("SELECT device_id FROM device_credentials LIMIT 1"); err != nil {
return nil, errors.New("device credential migration is not available")
}
return &CredentialStore{database: database, now: time.Now, random: rand.Reader}, nil
}
func (store *CredentialStore) Issue(ctx context.Context, displayName string) (IssuedCredential, error) {
if !ValidDisplayName(displayName) {
return IssuedCredential{}, ErrInvalidCredential
}
randomBytes := make([]byte, 16+32)
store.randomMu.Lock()
_, randomErr := io.ReadFull(store.random, randomBytes)
store.randomMu.Unlock()
if randomErr != nil {
return IssuedCredential{}, fmt.Errorf("generate device credential: %w", randomErr)
}
deviceID := formatUUIDv4(randomBytes[:16])
token := hex.EncodeToString(randomBytes[16:])
tokenHash := sha256.Sum256(randomBytes[16:])
createdAt := store.now().UTC()
if createdAt.IsZero() {
return IssuedCredential{}, errors.New("device credential clock is invalid")
}
_, err := store.database.ExecContext(ctx, `INSERT INTO device_credentials
(device_id, display_name, token_sha256, status, created_at, revoked_at)
VALUES (?, ?, ?, ?, ?, NULL)`,
deviceID, displayName, tokenHash[:], StatusActive, createdAt.Format(time.RFC3339Nano))
if err != nil {
return IssuedCredential{}, fmt.Errorf("persist device credential: %w", err)
}
return IssuedCredential{Credential: Credential{
DeviceID: deviceID, DisplayName: displayName, Status: StatusActive, CreatedAt: createdAt,
}, Token: token}, nil
}
func (store *CredentialStore) List(ctx context.Context) ([]Credential, error) {
rows, err := store.database.QueryContext(ctx, `SELECT device_id, display_name, status, created_at, revoked_at
FROM device_credentials ORDER BY created_at, device_id`)
if err != nil {
return nil, fmt.Errorf("list device credentials: %w", err)
}
defer rows.Close()
credentials := make([]Credential, 0)
for rows.Next() {
credential, err := scanCredential(rows)
if err != nil {
return nil, err
}
credentials = append(credentials, credential)
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("list device credentials: %w", err)
}
return credentials, nil
}
func (store *CredentialStore) Revoke(ctx context.Context, deviceID string) (Credential, bool, error) {
if !ValidDeviceID(deviceID) {
return Credential{}, false, ErrInvalidCredential
}
revokedAt := store.now().UTC()
if revokedAt.IsZero() {
return Credential{}, false, errors.New("device credential clock is invalid")
}
transaction, err := store.database.BeginTx(ctx, nil)
if err != nil {
return Credential{}, false, fmt.Errorf("begin device credential revocation: %w", err)
}
defer transaction.Rollback()
result, err := transaction.ExecContext(ctx, `UPDATE device_credentials
SET status = ?, revoked_at = ? WHERE device_id = ? AND status = ?`,
StatusRevoked, revokedAt.Format(time.RFC3339Nano), deviceID, StatusActive)
if err != nil {
return Credential{}, false, fmt.Errorf("revoke device credential: %w", err)
}
changedRows, err := result.RowsAffected()
if err != nil {
return Credential{}, false, fmt.Errorf("inspect device credential revocation: %w", err)
}
credential, err := scanCredential(transaction.QueryRowContext(ctx, `SELECT device_id, display_name, status, created_at, revoked_at
FROM device_credentials WHERE device_id = ?`, deviceID))
if errors.Is(err, sql.ErrNoRows) {
return Credential{}, false, ErrCredentialNotFound
}
if err != nil {
return Credential{}, false, err
}
if err := transaction.Commit(); err != nil {
return Credential{}, false, fmt.Errorf("commit device credential revocation: %w", err)
}
return credential, changedRows == 1, nil
}
type rowScanner interface {
Scan(...any) error
}
func scanCredential(row rowScanner) (Credential, error) {
var credential Credential
var created string
var revoked sql.NullString
if err := row.Scan(&credential.DeviceID, &credential.DisplayName, &credential.Status, &created, &revoked); err != nil {
return Credential{}, err
}
if !ValidDeviceID(credential.DeviceID) || !ValidDisplayName(credential.DisplayName) || (credential.Status != StatusActive && credential.Status != StatusRevoked) {
return Credential{}, errors.New("stored device credential metadata is invalid")
}
createdAt, err := parseStoredTime(created)
if err != nil {
return Credential{}, err
}
credential.CreatedAt = createdAt
if revoked.Valid {
revokedAt, err := parseStoredTime(revoked.String)
if err != nil {
return Credential{}, err
}
if revokedAt.Before(createdAt) {
return Credential{}, errors.New("stored device credential status is invalid")
}
credential.RevokedAt = &revokedAt
}
if (credential.Status == StatusActive) != (credential.RevokedAt == nil) {
return Credential{}, errors.New("stored device credential status is invalid")
}
return credential, nil
}
func ValidDisplayName(value string) bool {
if value == "" || len([]rune(value)) > 128 || strings.TrimSpace(value) != value {
return false
}
for _, character := range value {
if unicode.IsControl(character) {
return false
}
}
return true
}
func parseStoredTime(value string) (time.Time, error) {
if strings.TrimSpace(value) != value || !strings.HasSuffix(value, "Z") {
return time.Time{}, errors.New("stored device credential time is invalid")
}
parsed, err := time.Parse(time.RFC3339Nano, value)
if err != nil || parsed.Location() != time.UTC {
return time.Time{}, errors.New("stored device credential time is invalid")
}
return parsed, nil
}
func formatUUIDv4(bytes []byte) string {
copyBytes := append([]byte(nil), bytes...)
copyBytes[6] = (copyBytes[6] & 0x0f) | 0x40
copyBytes[8] = (copyBytes[8] & 0x3f) | 0x80
encoded := hex.EncodeToString(copyBytes)
return encoded[:8] + "-" + encoded[8:12] + "-" + encoded[12:16] + "-" + encoded[16:20] + "-" + encoded[20:]
}
+168
View File
@@ -0,0 +1,168 @@
// Package deviceauth owns the machine identity boundary shared by all device routes.
package deviceauth
import (
"crypto/sha256"
"crypto/subtle"
"database/sql"
"encoding/hex"
"errors"
"net/http"
"strings"
)
const (
AuthorizationHeader = "Authorization"
DeviceIDHeader = "X-CMBuyer-Device-ID"
tokenHexLength = 64
)
var (
// ErrUnauthenticated deliberately covers every credential defect. Callers must not reveal
// whether a device exists, is revoked, or supplied a mismatched token.
ErrUnauthenticated = errors.New("device authentication failed")
// ErrUnavailable is distinct so a storage outage is not disguised as a bad credential.
// HTTP callers still return no diagnostic body because database details are server-only.
ErrUnavailable = errors.New("device authentication unavailable")
)
type Principal struct {
ID string
}
type Authenticator interface {
Authenticate(*http.Request) (Principal, error)
}
// RejectAllAuthenticator is useful for tests and for fail-closed wiring where no credential
// store is available. Production startup uses SQLiteAuthenticator.
type RejectAllAuthenticator struct{}
func (RejectAllAuthenticator) Authenticate(*http.Request) (Principal, error) {
return Principal{}, ErrUnauthenticated
}
type SQLiteAuthenticator struct {
database *sql.DB
}
func NewSQLiteAuthenticator(database *sql.DB) (*SQLiteAuthenticator, error) {
if database == nil {
return nil, errors.New("device credential database is required")
}
if _, err := database.Exec("SELECT device_id FROM device_credentials LIMIT 1"); err != nil {
return nil, errors.New("device credential migration is not available")
}
return &SQLiteAuthenticator{database: database}, nil
}
func (authenticator *SQLiteAuthenticator) Authenticate(request *http.Request) (Principal, error) {
if request == nil {
return Principal{}, ErrUnauthenticated
}
deviceID, token, ok := requestCredentials(request)
if !ok {
return Principal{}, ErrUnauthenticated
}
candidateHash := sha256.Sum256(token)
var storedHash []byte
var hashType string
var hashLength sql.NullInt64
var status sql.NullString
var revokedAt sql.NullString
var found bool
err := authenticator.database.QueryRowContext(
request.Context(),
`SELECT CASE WHEN credentials.device_id IS NULL THEN zeroblob(32) ELSE credentials.token_sha256 END,
typeof(credentials.token_sha256),
length(credentials.token_sha256),
credentials.status,
credentials.revoked_at,
credentials.device_id IS NOT NULL
FROM (SELECT 1) AS singleton
LEFT JOIN device_credentials AS credentials ON credentials.device_id = ?`,
deviceID,
).Scan(&storedHash, &hashType, &hashLength, &status, &revokedAt, &found)
if err != nil {
return Principal{}, ErrUnavailable
}
if len(storedHash) != sha256.Size {
return Principal{}, ErrUnavailable
}
matched := subtle.ConstantTimeCompare(candidateHash[:], storedHash) == 1
if !found {
// The LEFT JOIN supplies a 32-byte dummy hash, so unknown ids take the same compare path
// as known credentials without requiring a plaintext token lookup.
return Principal{}, ErrUnauthenticated
}
if hashType != "blob" || !hashLength.Valid || hashLength.Int64 != sha256.Size || len(storedHash) != sha256.Size || !status.Valid {
return Principal{}, ErrUnavailable
}
switch status.String {
case StatusActive:
if revokedAt.Valid {
return Principal{}, ErrUnavailable
}
case StatusRevoked:
if !revokedAt.Valid {
return Principal{}, ErrUnavailable
}
if _, err := parseStoredTime(revokedAt.String); err != nil {
return Principal{}, ErrUnavailable
}
default:
return Principal{}, ErrUnavailable
}
if !matched || status.String == StatusRevoked {
return Principal{}, ErrUnauthenticated
}
return Principal{ID: deviceID}, nil
}
func requestCredentials(request *http.Request) (string, []byte, bool) {
authorizations := request.Header.Values(AuthorizationHeader)
deviceIDs := request.Header.Values(DeviceIDHeader)
if len(authorizations) != 1 || len(deviceIDs) != 1 {
return "", nil, false
}
authorization := authorizations[0]
if len(authorization) != len("Bearer ")+tokenHexLength || !strings.EqualFold(authorization[:len("Bearer")], "Bearer") || authorization[len("Bearer")] != ' ' {
return "", nil, false
}
tokenHex := authorization[len("Bearer "):]
if !validLowerHex(tokenHex, tokenHexLength) || !ValidDeviceID(deviceIDs[0]) {
return "", nil, false
}
token, err := hex.DecodeString(tokenHex)
if err != nil {
return "", nil, false
}
return deviceIDs[0], token, true
}
func ValidDeviceID(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 validLowerHex(value string, length int) bool {
if len(value) != length {
return false
}
decoded, err := hex.DecodeString(value)
return err == nil && hex.EncodeToString(decoded) == value
}
@@ -0,0 +1,407 @@
package deviceauth
import (
"context"
"crypto/rand"
"crypto/sha256"
"database/sql"
"encoding/hex"
"encoding/json"
"errors"
"net/http"
"net/http/httptest"
"path/filepath"
"runtime"
"strings"
"sync"
"testing"
"time"
"cmbuyer/admin/internal/migrations"
"cmbuyer/admin/internal/storage/sqlite"
)
func TestIssueStoresOnlyRawTokenHashAndGenericJSONOmitsSecret(t *testing.T) {
database, store := newCredentialStore(t)
first, err := store.Issue(context.Background(), "采购工具一号")
if err != nil {
t.Fatalf("Issue first: %v", err)
}
second, err := store.Issue(context.Background(), "采购工具二号")
if err != nil {
t.Fatalf("Issue second: %v", err)
}
if first.DeviceID == second.DeviceID || first.Token == second.Token || !ValidDeviceID(first.DeviceID) || !validLowerHex(first.Token, tokenHexLength) {
t.Fatalf("issued identifiers are not independent canonical values")
}
rawToken, err := hex.DecodeString(first.Token)
if err != nil {
t.Fatalf("decode issued token: %v", err)
}
wantHash := sha256.Sum256(rawToken)
var storedHash []byte
var storageType string
if err := database.QueryRow(`SELECT token_sha256, typeof(token_sha256) FROM device_credentials WHERE device_id = ?`, first.DeviceID).Scan(&storedHash, &storageType); err != nil {
t.Fatalf("read stored hash: %v", err)
}
if storageType != "blob" || len(storedHash) != sha256.Size || !equalBytes(storedHash, wantHash[:]) {
t.Fatalf("stored hash type/length/value = %q/%d/%t", storageType, len(storedHash), equalBytes(storedHash, wantHash[:]))
}
var leakedCopies int
if err := database.QueryRow(`SELECT COUNT(*) FROM device_credentials WHERE CAST(token_sha256 AS TEXT) IN (?, ?)`, first.Token, hex.EncodeToString(wantHash[:])).Scan(&leakedCopies); err != nil {
t.Fatalf("search token copies: %v", err)
}
if leakedCopies != 0 {
t.Fatal("database stored a plaintext or hex-encoded token/hash copy")
}
encoded, err := json.Marshal(first)
if err != nil {
t.Fatalf("marshal issued credential: %v", err)
}
if strings.Contains(string(encoded), first.Token) || strings.Contains(string(encoded), "token") {
t.Fatalf("generic serialization disclosed token field: %s", encoded)
}
}
func TestAuthenticateStrictHeaderMatrixAndBinding(t *testing.T) {
_, store := newCredentialStore(t)
first, err := store.Issue(context.Background(), "one")
if err != nil {
t.Fatalf("issue first: %v", err)
}
second, err := store.Issue(context.Background(), "two")
if err != nil {
t.Fatalf("issue second: %v", err)
}
authenticator := authenticatorForStore(t, store)
for _, scheme := range []string{"Bearer", "bearer", "BEARER"} {
request := credentialRequest(first.DeviceID, scheme+" "+first.Token)
principal, err := authenticator.Authenticate(request)
if err != nil || principal.ID != first.DeviceID {
t.Fatalf("scheme %q Authenticate = (%q, %v)", scheme, principal.ID, err)
}
}
unknownID := newRuntimeUUID(t)
wrongToken := newRuntimeToken(t)
tests := []struct {
name string
mutate func(*http.Request)
}{
{"missing authorization", func(request *http.Request) { request.Header.Del(AuthorizationHeader) }},
{"missing device", func(request *http.Request) { request.Header.Del(DeviceIDHeader) }},
{"empty authorization", func(request *http.Request) { request.Header.Set(AuthorizationHeader, "") }},
{"empty device", func(request *http.Request) { request.Header.Set(DeviceIDHeader, "") }},
{"duplicate authorization", func(request *http.Request) { request.Header.Add(AuthorizationHeader, "Bearer "+first.Token) }},
{"duplicate device", func(request *http.Request) { request.Header.Add(DeviceIDHeader, first.DeviceID) }},
{"combined authorization", func(request *http.Request) {
request.Header.Set(AuthorizationHeader, "Bearer "+first.Token+", Bearer "+first.Token)
}},
{"combined device", func(request *http.Request) { request.Header.Set(DeviceIDHeader, first.DeviceID+", "+first.DeviceID) }},
{"extra separator", func(request *http.Request) { request.Header.Set(AuthorizationHeader, "Bearer "+first.Token) }},
{"tab separator", func(request *http.Request) { request.Header.Set(AuthorizationHeader, "Bearer\t"+first.Token) }},
{"uppercase token", func(request *http.Request) {
request.Header.Set(AuthorizationHeader, "Bearer "+strings.ToUpper(first.Token))
}},
{"short token", func(request *http.Request) { request.Header.Set(AuthorizationHeader, "Bearer "+first.Token[:62]) }},
{"long token", func(request *http.Request) { request.Header.Set(AuthorizationHeader, "Bearer "+first.Token+"00") }},
{"non hex token", func(request *http.Request) { request.Header.Set(AuthorizationHeader, "Bearer "+first.Token[:63]+"g") }},
{"token separator", func(request *http.Request) {
request.Header.Set(AuthorizationHeader, "Bearer "+first.Token[:32]+"-"+first.Token[33:])
}},
{"uppercase device", func(request *http.Request) { request.Header.Set(DeviceIDHeader, strings.ToUpper(first.DeviceID)) }},
{"padded device", func(request *http.Request) { request.Header.Set(DeviceIDHeader, " "+first.DeviceID) }},
{"wrong uuid version", func(request *http.Request) {
request.Header.Set(DeviceIDHeader, first.DeviceID[:14]+"3"+first.DeviceID[15:])
}},
{"wrong uuid variant", func(request *http.Request) {
request.Header.Set(DeviceIDHeader, first.DeviceID[:19]+"7"+first.DeviceID[20:])
}},
{"unknown device", func(request *http.Request) { request.Header.Set(DeviceIDHeader, unknownID) }},
{"wrong token", func(request *http.Request) { request.Header.Set(AuthorizationHeader, "Bearer "+wrongToken) }},
{"token device mismatch", func(request *http.Request) { request.Header.Set(DeviceIDHeader, second.DeviceID) }},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
request := credentialRequest(first.DeviceID, "Bearer "+first.Token)
test.mutate(request)
principal, err := authenticator.Authenticate(request)
if !errors.Is(err, ErrUnauthenticated) || principal != (Principal{}) {
t.Fatalf("Authenticate = (%#v, %v), want empty unauthenticated", principal, err)
}
})
}
if principal, err := authenticator.Authenticate(nil); !errors.Is(err, ErrUnauthenticated) || principal != (Principal{}) {
t.Fatalf("Authenticate(nil) = (%#v, %v)", principal, err)
}
}
func TestRevokeIsImmediateAndIdempotent(t *testing.T) {
_, store := newCredentialStore(t)
issued, err := store.Issue(context.Background(), "device")
if err != nil {
t.Fatalf("Issue: %v", err)
}
authenticator := authenticatorForStore(t, store)
request := credentialRequest(issued.DeviceID, "Bearer "+issued.Token)
if _, err := authenticator.Authenticate(request); err != nil {
t.Fatalf("Authenticate before revoke: %v", err)
}
first, changed, err := store.Revoke(context.Background(), issued.DeviceID)
if err != nil || !changed || first.Status != StatusRevoked || first.RevokedAt == nil {
t.Fatalf("first Revoke = (%#v, %t, %v)", first, changed, err)
}
if principal, err := authenticator.Authenticate(request); !errors.Is(err, ErrUnauthenticated) || principal != (Principal{}) {
t.Fatalf("Authenticate after committed revoke = (%#v, %v)", principal, err)
}
second, changed, err := store.Revoke(context.Background(), issued.DeviceID)
if err != nil || changed || second.RevokedAt == nil || !second.RevokedAt.Equal(*first.RevokedAt) {
t.Fatalf("second Revoke = (%#v, %t, %v)", second, changed, err)
}
listed, err := store.List(context.Background())
if err != nil || len(listed) != 1 || listed[0].Status != StatusRevoked {
t.Fatalf("List = (%#v, %v)", listed, err)
}
}
func TestConcurrentAuthenticationAndRevocation(t *testing.T) {
_, store := newCredentialStore(t)
issued, err := store.Issue(context.Background(), "concurrent")
if err != nil {
t.Fatalf("Issue: %v", err)
}
authenticator := authenticatorForStore(t, store)
request := func() *http.Request { return credentialRequest(issued.DeviceID, "Bearer "+issued.Token) }
start := make(chan struct{})
results := make(chan error, 16)
var wait sync.WaitGroup
for index := 0; index < 16; index++ {
wait.Add(1)
go func() {
defer wait.Done()
<-start
_, err := authenticator.Authenticate(request())
results <- err
}()
}
close(start)
if _, _, err := store.Revoke(context.Background(), issued.DeviceID); err != nil {
t.Fatalf("Revoke: %v", err)
}
wait.Wait()
close(results)
for err := range results {
if err != nil && !errors.Is(err, ErrUnauthenticated) {
t.Fatalf("concurrent Authenticate error = %v", err)
}
}
for index := 0; index < 16; index++ {
if _, err := authenticator.Authenticate(request()); !errors.Is(err, ErrUnauthenticated) {
t.Fatalf("post-commit Authenticate %d error = %v", index, err)
}
}
}
func TestAuthenticationDatabaseFaultAndCorruptionAreUnavailable(t *testing.T) {
database, store := newCredentialStore(t)
issued, err := store.Issue(context.Background(), "device")
if err != nil {
t.Fatalf("Issue: %v", err)
}
authenticator := authenticatorForStore(t, store)
if err := database.Close(); err != nil {
t.Fatalf("close database: %v", err)
}
if _, err := authenticator.Authenticate(credentialRequest(issued.DeviceID, "Bearer "+issued.Token)); !errors.Is(err, ErrUnavailable) {
t.Fatalf("closed database Authenticate error = %v", err)
}
corruptDB, err := sqlite.Open(filepath.Join(t.TempDir(), "corrupt.db"))
if err != nil {
t.Fatalf("open corrupt database: %v", err)
}
t.Cleanup(func() { _ = corruptDB.Close() })
if _, err := corruptDB.Exec(`CREATE TABLE device_credentials (device_id TEXT PRIMARY KEY, token_sha256 BLOB, status TEXT, revoked_at TEXT)`); err != nil {
t.Fatalf("create corrupt table: %v", err)
}
corruptAuthenticator, err := NewSQLiteAuthenticator(corruptDB)
if err != nil {
t.Fatalf("new corrupt authenticator: %v", err)
}
tests := []struct {
name string
hashValue func([sha256.Size]byte) any
status string
revokedAt any
}{
{name: "null hash", hashValue: func([sha256.Size]byte) any { return nil }, status: StatusActive},
{name: "matching text hash", hashValue: func(hash [sha256.Size]byte) any { return string(hash[:]) }, status: StatusActive},
{name: "unknown status", hashValue: func(hash [sha256.Size]byte) any { return hash[:] }, status: "BROKEN"},
{name: "active with revoked time", hashValue: func(hash [sha256.Size]byte) any { return hash[:] }, status: StatusActive, revokedAt: "2026-08-04T00:00:00Z"},
{name: "revoked without time", hashValue: func(hash [sha256.Size]byte) any { return hash[:] }, status: StatusRevoked},
{name: "revoked with invalid time", hashValue: func(hash [sha256.Size]byte) any { return hash[:] }, status: StatusRevoked, revokedAt: "not-a-time"},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
rawToken, token := newRuntimeTokenPair(t)
hash := sha256.Sum256(rawToken)
deviceID := newRuntimeUUID(t)
if _, err := corruptDB.Exec(`INSERT INTO device_credentials VALUES (?, ?, ?, ?)`, deviceID, test.hashValue(hash), test.status, test.revokedAt); err != nil {
t.Fatalf("insert corrupt row: %v", err)
}
principal, err := corruptAuthenticator.Authenticate(credentialRequest(deviceID, "Bearer "+token))
if !errors.Is(err, ErrUnavailable) || principal != (Principal{}) {
t.Fatalf("corrupt Authenticate = (%#v, %v), want unavailable", principal, err)
}
})
}
}
func TestCredentialAuthenticationSurvivesDatabaseReopen(t *testing.T) {
databaseSource := filepath.Join(t.TempDir(), "reopen.db")
database, err := sqlite.Open(databaseSource)
if err != nil {
t.Fatalf("open database: %v", err)
}
if err := migrations.Up(context.Background(), database, deviceMigrationDirectory(t)); err != nil {
_ = database.Close()
t.Fatalf("migrate database: %v", err)
}
store, err := NewCredentialStore(database)
if err != nil {
_ = database.Close()
t.Fatalf("new store: %v", err)
}
issued, err := store.Issue(context.Background(), "reopen")
if err != nil {
_ = database.Close()
t.Fatalf("issue: %v", err)
}
if err := database.Close(); err != nil {
t.Fatalf("close database: %v", err)
}
reopened, err := sqlite.Open(databaseSource)
if err != nil {
t.Fatalf("reopen database: %v", err)
}
defer reopened.Close()
authenticator, err := NewSQLiteAuthenticator(reopened)
if err != nil {
t.Fatalf("new reopened authenticator: %v", err)
}
principal, err := authenticator.Authenticate(credentialRequest(issued.DeviceID, "Bearer "+issued.Token))
if err != nil || principal.ID != issued.DeviceID {
t.Fatalf("Authenticate after reopen = (%#v, %v)", principal, err)
}
}
func TestCredentialInputAndMigrationAreRequired(t *testing.T) {
database, err := sqlite.Open(filepath.Join(t.TempDir(), "unmigrated.db"))
if err != nil {
t.Fatalf("open database: %v", err)
}
t.Cleanup(func() { _ = database.Close() })
if _, err := NewCredentialStore(database); err == nil {
t.Fatal("NewCredentialStore accepted an unmigrated database")
}
if _, err := NewSQLiteAuthenticator(database); err == nil {
t.Fatal("NewSQLiteAuthenticator accepted an unmigrated database")
}
_, store := newCredentialStore(t)
for _, name := range []string{"", " leading", "trailing ", "line\nbreak", strings.Repeat("名", 129)} {
if _, err := store.Issue(context.Background(), name); !errors.Is(err, ErrInvalidCredential) {
t.Fatalf("Issue(%q) error = %v", name, err)
}
}
if _, _, err := store.Revoke(context.Background(), "not-a-uuid"); !errors.Is(err, ErrInvalidCredential) {
t.Fatalf("Revoke invalid id error = %v", err)
}
if _, _, err := store.Revoke(context.Background(), newRuntimeUUID(t)); !errors.Is(err, ErrCredentialNotFound) {
t.Fatalf("Revoke unknown id error = %v", err)
}
}
func newCredentialStore(t *testing.T) (*sql.DB, *CredentialStore) {
t.Helper()
databaseSource := filepath.Join(t.TempDir(), "device-auth.db") + "?_busy_timeout=5000&_journal_mode=WAL"
database, err := sqlite.Open(databaseSource)
if err != nil {
t.Fatalf("open database: %v", err)
}
t.Cleanup(func() { _ = database.Close() })
if err := migrations.Up(context.Background(), database, deviceMigrationDirectory(t)); err != nil {
t.Fatalf("migrate database: %v", err)
}
store, err := NewCredentialStore(database)
if err != nil {
t.Fatalf("NewCredentialStore: %v", err)
}
store.now = func() time.Time { return time.Date(2026, 8, 4, 12, 0, 0, 123, time.UTC) }
return database, store
}
func authenticatorForStore(t *testing.T, store *CredentialStore) *SQLiteAuthenticator {
t.Helper()
authenticator, err := NewSQLiteAuthenticator(store.database)
if err != nil {
t.Fatalf("NewSQLiteAuthenticator: %v", err)
}
return authenticator
}
func credentialRequest(deviceID, authorization string) *http.Request {
request := httptest.NewRequest(http.MethodPost, "/api/v1/tasks/id/evidence", nil)
request.Header.Set(DeviceIDHeader, deviceID)
request.Header.Set(AuthorizationHeader, authorization)
return request
}
func newRuntimeToken(t *testing.T) string {
t.Helper()
_, token := newRuntimeTokenPair(t)
return token
}
func newRuntimeTokenPair(t *testing.T) ([]byte, string) {
t.Helper()
raw := make([]byte, 32)
if _, err := rand.Read(raw); err != nil {
t.Fatalf("generate runtime token: %v", err)
}
return raw, hex.EncodeToString(raw)
}
func newRuntimeUUID(t *testing.T) string {
t.Helper()
raw := make([]byte, 16)
if _, err := rand.Read(raw); err != nil {
t.Fatalf("generate runtime UUID: %v", err)
}
return formatUUIDv4(raw)
}
func equalBytes(left, right []byte) bool {
if len(left) != len(right) {
return false
}
for index := range left {
if left[index] != right[index] {
return false
}
}
return true
}
func deviceMigrationDirectory(t *testing.T) string {
t.Helper()
_, file, _, ok := runtime.Caller(0)
if !ok {
t.Fatal("locate migrations")
}
return filepath.Join(filepath.Dir(file), "..", "..", "migrations")
}
+66
View File
@@ -0,0 +1,66 @@
package domain
import (
"errors"
"time"
)
var ErrInvalidAuthorizationTransition = errors.New("invalid authorization status transition")
type AuthorizationStatus string
const (
AuthorizationStatusActive AuthorizationStatus = "ACTIVE"
AuthorizationStatusClaimed AuthorizationStatus = "CLAIMED"
AuthorizationStatusFenced AuthorizationStatus = "FENCED"
AuthorizationStatusConsumed AuthorizationStatus = "CONSUMED"
AuthorizationStatusExpired AuthorizationStatus = "EXPIRED"
AuthorizationStatusAbandoned AuthorizationStatus = "ABANDONED"
)
type OrderAuthorization struct {
ID string
TaskID string
TaskVersion int
StartKey string
GoodsID string
SKUColor string
SKUSize string
Quantity int
TotalPriceCap string
Status AuthorizationStatus
CreatedBy string
CreatedAt time.Time
ExpiresAt time.Time
}
// CanTransitionTo 围栏后的授权只能消费,不能回到可领取或可过期状态,以防重复采购。
func (status AuthorizationStatus) CanTransitionTo(next AuthorizationStatus) bool {
_, allowed := authorizationTransitions[status][next]
return allowed
}
// TransitionAuthorization 返回下一状态;未定义的授权状态转移一律失败。
func TransitionAuthorization(current, next AuthorizationStatus) (AuthorizationStatus, error) {
if !current.CanTransitionTo(next) {
return current, ErrInvalidAuthorizationTransition
}
return next, nil
}
var authorizationTransitions = map[AuthorizationStatus]map[AuthorizationStatus]struct{}{
AuthorizationStatusActive: {
AuthorizationStatusClaimed: {},
AuthorizationStatusExpired: {},
AuthorizationStatusAbandoned: {},
},
AuthorizationStatusClaimed: {
AuthorizationStatusFenced: {},
AuthorizationStatusExpired: {},
AuthorizationStatusAbandoned: {},
},
AuthorizationStatusFenced: {
AuthorizationStatusConsumed: {},
},
}
@@ -0,0 +1,52 @@
package domain_test
import (
"errors"
"testing"
"cmbuyer/admin/internal/domain"
)
func TestAuthorizationTransitions(t *testing.T) {
tests := []struct {
name string
current domain.AuthorizationStatus
next domain.AuthorizationStatus
allowed bool
}{
{"claim", domain.AuthorizationStatusActive, domain.AuthorizationStatusClaimed, true},
{"fence", domain.AuthorizationStatusClaimed, domain.AuthorizationStatusFenced, true},
{"consume fenced authorization", domain.AuthorizationStatusFenced, domain.AuthorizationStatusConsumed, true},
{"expire active", domain.AuthorizationStatusActive, domain.AuthorizationStatusExpired, true},
{"abandon active", domain.AuthorizationStatusActive, domain.AuthorizationStatusAbandoned, true},
{"expire claimed before fence", domain.AuthorizationStatusClaimed, domain.AuthorizationStatusExpired, true},
{"abandon claimed before fence", domain.AuthorizationStatusClaimed, domain.AuthorizationStatusAbandoned, true},
{"fenced authorization cannot expire", domain.AuthorizationStatusFenced, domain.AuthorizationStatusExpired, false},
{"fenced authorization cannot be abandoned", domain.AuthorizationStatusFenced, domain.AuthorizationStatusAbandoned, false},
{"fenced authorization cannot be claimed again", domain.AuthorizationStatusFenced, domain.AuthorizationStatusClaimed, false},
{"consumed authorization cannot restart", domain.AuthorizationStatusConsumed, domain.AuthorizationStatusClaimed, false},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
if got := test.current.CanTransitionTo(test.next); got != test.allowed {
t.Fatalf("CanTransitionTo(%s, %s) = %t, want %t", test.current, test.next, got, test.allowed)
}
result, err := domain.TransitionAuthorization(test.current, test.next)
if test.allowed {
if err != nil {
t.Fatalf("TransitionAuthorization(%s, %s): %v", test.current, test.next, err)
}
if result != test.next {
t.Fatalf("TransitionAuthorization(%s, %s) = %s, want %s", test.current, test.next, result, test.next)
}
return
}
if !errors.Is(err, domain.ErrInvalidAuthorizationTransition) {
t.Fatalf("TransitionAuthorization(%s, %s) error = %v, want ErrInvalidAuthorizationTransition", test.current, test.next, err)
}
})
}
}
+74
View File
@@ -0,0 +1,74 @@
package domain
import (
"errors"
"time"
)
var ErrInvalidAttemptTransition = errors.New("invalid purchase attempt status transition")
// AttemptStatus 只描述单趟领取的可恢复执行。真实提交结果独立由唯一围栏记录调和。
type AttemptStatus string
const (
AttemptStatusClaimed AttemptStatus = "CLAIMED"
AttemptStatusOrdering AttemptStatus = "ORDERING"
AttemptStatusFailed AttemptStatus = "FAILED"
AttemptStatusFenced AttemptStatus = "FENCED"
AttemptStatusAbandoned AttemptStatus = "ABANDONED"
)
// AttemptFailureCode 是服务端可审计的固定失败摘要,不能承载页面正文或其他自由文本。
type AttemptFailureCode string
const (
AttemptFailureAuthorizationExpired AttemptFailureCode = "AUTHORIZATION_EXPIRED"
AttemptFailureLeaseLost AttemptFailureCode = "LEASE_LOST"
AttemptFailureGate1Rejected AttemptFailureCode = "GATE_1_REJECTED"
AttemptFailureQuantityMismatch AttemptFailureCode = "QUANTITY_MISMATCH"
AttemptFailureGate2Rejected AttemptFailureCode = "GATE_2_REJECTED"
AttemptFailureGate3Rejected AttemptFailureCode = "GATE_3_REJECTED"
AttemptFailureFenceRejected AttemptFailureCode = "FENCE_REJECTED"
AttemptFailureSafeAborted AttemptFailureCode = "SAFE_ABORTED"
)
type PurchaseAttempt struct {
ID string
TaskID string
AuthorizationID string
ClaimGeneration int
Status AttemptStatus
Gate1UnitPrice *string
Gate2UnitPrice *string
QuantityRead *int
ConfirmAmount *string
FailureCode *AttemptFailureCode
StartedAt time.Time
FinishedAt *time.Time
}
// CanTransitionTo 只允许围栏前的领取恢复为安全失败;围栏后不再提供回退或重试路径。
func (status AttemptStatus) CanTransitionTo(next AttemptStatus) bool {
_, allowed := attemptTransitions[status][next]
return allowed
}
func TransitionAttempt(current, next AttemptStatus) (AttemptStatus, error) {
if !current.CanTransitionTo(next) {
return current, ErrInvalidAttemptTransition
}
return next, nil
}
var attemptTransitions = map[AttemptStatus]map[AttemptStatus]struct{}{
AttemptStatusClaimed: {
AttemptStatusOrdering: {},
AttemptStatusFailed: {},
AttemptStatusAbandoned: {},
},
AttemptStatusOrdering: {
AttemptStatusFenced: {},
AttemptStatusFailed: {},
AttemptStatusAbandoned: {},
},
}
@@ -0,0 +1,34 @@
package domain_test
import (
"errors"
"testing"
"cmbuyer/admin/internal/domain"
)
func TestPurchaseAttemptTransitions(t *testing.T) {
for _, test := range []struct {
current domain.AttemptStatus
next domain.AttemptStatus
allowed bool
}{
{domain.AttemptStatusClaimed, domain.AttemptStatusOrdering, true},
{domain.AttemptStatusOrdering, domain.AttemptStatusFenced, true},
{domain.AttemptStatusOrdering, domain.AttemptStatusFailed, true},
{domain.AttemptStatusFenced, domain.AttemptStatusOrdering, false},
{domain.AttemptStatusFenced, domain.AttemptStatusAbandoned, false},
{domain.AttemptStatus("UNKNOWN"), domain.AttemptStatusOrdering, false},
} {
got, err := domain.TransitionAttempt(test.current, test.next)
if test.allowed {
if err != nil || got != test.next {
t.Fatalf("TransitionAttempt(%s, %s) = (%s, %v)", test.current, test.next, got, err)
}
continue
}
if !errors.Is(err, domain.ErrInvalidAttemptTransition) || got != test.current {
t.Fatalf("invalid TransitionAttempt(%s, %s) = (%s, %v)", test.current, test.next, got, err)
}
}
}
+56
View File
@@ -0,0 +1,56 @@
package domain
import (
"errors"
"time"
)
var ErrInvalidSubmissionTransition = errors.New("invalid submission status transition")
type SubmissionStatus string
const (
SubmissionStatusFenced SubmissionStatus = "FENCED"
SubmissionStatusSubmitted SubmissionStatus = "SUBMITTED"
SubmissionStatusReconciliationRequired SubmissionStatus = "RECONCILIATION_REQUIRED"
SubmissionStatusManualResolved SubmissionStatus = "MANUAL_RESOLVED"
)
type OrderSubmission struct {
ID string
TaskID string
AuthorizationID string
AttemptID string
Status SubmissionStatus
Gate1UnitPrice string
Gate2UnitPrice string
QuantityRead int
ConfirmAmount string
CreatedAt time.Time
ResolvedAt *time.Time
}
// CanTransitionTo 只允许围栏记录向最终观察结果调和,拒绝回退以防触发第二次真实动作。
func (status SubmissionStatus) CanTransitionTo(next SubmissionStatus) bool {
_, allowed := submissionTransitions[status][next]
return allowed
}
// TransitionSubmission 返回下一状态;未定义的提交记录状态转移一律失败。
func TransitionSubmission(current, next SubmissionStatus) (SubmissionStatus, error) {
if !current.CanTransitionTo(next) {
return current, ErrInvalidSubmissionTransition
}
return next, nil
}
var submissionTransitions = map[SubmissionStatus]map[SubmissionStatus]struct{}{
SubmissionStatusFenced: {
SubmissionStatusSubmitted: {},
SubmissionStatusReconciliationRequired: {},
},
SubmissionStatusReconciliationRequired: {
SubmissionStatusManualResolved: {},
},
}
+48
View File
@@ -0,0 +1,48 @@
package domain_test
import (
"errors"
"testing"
"cmbuyer/admin/internal/domain"
)
func TestSubmissionTransitions(t *testing.T) {
tests := []struct {
name string
current domain.SubmissionStatus
next domain.SubmissionStatus
allowed bool
}{
{"submitted", domain.SubmissionStatusFenced, domain.SubmissionStatusSubmitted, true},
{"uncertain requires reconciliation", domain.SubmissionStatusFenced, domain.SubmissionStatusReconciliationRequired, true},
{"manual review resolves reconciliation", domain.SubmissionStatusReconciliationRequired, domain.SubmissionStatusManualResolved, true},
{"cannot reopen fenced submission", domain.SubmissionStatusSubmitted, domain.SubmissionStatusFenced, false},
{"submitted cannot require reconciliation", domain.SubmissionStatusSubmitted, domain.SubmissionStatusReconciliationRequired, false},
{"cannot skip reconciliation", domain.SubmissionStatusFenced, domain.SubmissionStatusManualResolved, false},
{"manual resolution cannot create a second submission", domain.SubmissionStatusManualResolved, domain.SubmissionStatusFenced, false},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
if got := test.current.CanTransitionTo(test.next); got != test.allowed {
t.Fatalf("CanTransitionTo(%s, %s) = %t, want %t", test.current, test.next, got, test.allowed)
}
result, err := domain.TransitionSubmission(test.current, test.next)
if test.allowed {
if err != nil {
t.Fatalf("TransitionSubmission(%s, %s): %v", test.current, test.next, err)
}
if result != test.next {
t.Fatalf("TransitionSubmission(%s, %s) = %s, want %s", test.current, test.next, result, test.next)
}
return
}
if !errors.Is(err, domain.ErrInvalidSubmissionTransition) {
t.Fatalf("TransitionSubmission(%s, %s) error = %v, want ErrInvalidSubmissionTransition", test.current, test.next, err)
}
})
}
}
+96
View File
@@ -0,0 +1,96 @@
// Package domain 定义采购服务的业务实体与不依赖外部系统的状态规则。
package domain
import (
"errors"
"time"
)
var ErrInvalidTaskTransition = errors.New("invalid task status transition")
type TaskStatus string
const (
TaskStatusDraft TaskStatus = "DRAFT"
TaskStatusPending TaskStatus = "PENDING"
TaskStatusClaimed TaskStatus = "CLAIMED"
TaskStatusOrdering TaskStatus = "ORDERING"
TaskStatusWaitingPayment TaskStatus = "WAITING_PAYMENT"
TaskStatusReconciliationRequired TaskStatus = "RECONCILIATION_REQUIRED"
TaskStatusNeedsManual TaskStatus = "NEEDS_MANUAL"
TaskStatusSucceeded TaskStatus = "SUCCEEDED"
TaskStatusFailed TaskStatus = "FAILED"
TaskStatusCanceled TaskStatus = "CANCELED"
)
type Source string
const (
SourceManual Source = "MANUAL"
SourceExcel Source = "EXCEL"
SourceERP Source = "ERP"
)
type Task struct {
ID string
Source Source
SourceRef *string
Title string
GoodsID string
SKUColor string
SKUSize string
Quantity int
MaxTotalPrice string
ReferenceAssetID *string
Status TaskStatus
Version int
CreatedAt time.Time
UpdatedAt time.Time
}
// CanTransitionTo 只接受架构已定义的任务状态转移,未知状态或未列出的动作均拒绝。
func (status TaskStatus) CanTransitionTo(next TaskStatus) bool {
_, allowed := taskTransitions[status][next]
return allowed
}
// TransitionTask 返回下一状态;非法转移必须在写库前失败,不能由调用方猜测补救路径。
func TransitionTask(current, next TaskStatus) (TaskStatus, error) {
if !current.CanTransitionTo(next) {
return current, ErrInvalidTaskTransition
}
return next, nil
}
var taskTransitions = map[TaskStatus]map[TaskStatus]struct{}{
TaskStatusDraft: {
TaskStatusPending: {},
TaskStatusCanceled: {},
},
TaskStatusPending: {
TaskStatusClaimed: {},
TaskStatusDraft: {},
TaskStatusCanceled: {},
},
TaskStatusClaimed: {
TaskStatusOrdering: {},
TaskStatusDraft: {},
},
TaskStatusOrdering: {
TaskStatusNeedsManual: {},
TaskStatusWaitingPayment: {},
TaskStatusReconciliationRequired: {},
},
TaskStatusNeedsManual: {
TaskStatusDraft: {},
TaskStatusCanceled: {},
},
TaskStatusWaitingPayment: {
TaskStatusSucceeded: {},
},
TaskStatusReconciliationRequired: {
TaskStatusWaitingPayment: {},
TaskStatusFailed: {},
},
}
+64
View File
@@ -0,0 +1,64 @@
package domain_test
import (
"errors"
"testing"
"cmbuyer/admin/internal/domain"
)
func TestTaskTransitions(t *testing.T) {
tests := []struct {
name string
current domain.TaskStatus
next domain.TaskStatus
allowed bool
}{
{"start purchase", domain.TaskStatusDraft, domain.TaskStatusPending, true},
{"cancel draft before fence", domain.TaskStatusDraft, domain.TaskStatusCanceled, true},
{"claim purchase", domain.TaskStatusPending, domain.TaskStatusClaimed, true},
{"release expired authorization", domain.TaskStatusPending, domain.TaskStatusDraft, true},
{"start ordering", domain.TaskStatusClaimed, domain.TaskStatusOrdering, true},
{"release unstarted claim", domain.TaskStatusClaimed, domain.TaskStatusDraft, true},
{"ordering needs manual review", domain.TaskStatusOrdering, domain.TaskStatusNeedsManual, true},
{"order reaches payment", domain.TaskStatusOrdering, domain.TaskStatusWaitingPayment, true},
{"order needs manual review before fence", domain.TaskStatusOrdering, domain.TaskStatusNeedsManual, true},
{"order needs reconciliation", domain.TaskStatusOrdering, domain.TaskStatusReconciliationRequired, true},
{"manual review resets draft", domain.TaskStatusNeedsManual, domain.TaskStatusDraft, true},
{"manual review cancels before fence", domain.TaskStatusNeedsManual, domain.TaskStatusCanceled, true},
{"payment verified", domain.TaskStatusWaitingPayment, domain.TaskStatusSucceeded, true},
{"reconcile confirms waiting payment", domain.TaskStatusReconciliationRequired, domain.TaskStatusWaitingPayment, true},
{"reconcile confirms failed", domain.TaskStatusReconciliationRequired, domain.TaskStatusFailed, true},
{"cannot skip authorization", domain.TaskStatusDraft, domain.TaskStatusOrdering, false},
{"ordering cannot return pending", domain.TaskStatusOrdering, domain.TaskStatusPending, false},
{"ordering cannot bypass manual review to draft", domain.TaskStatusOrdering, domain.TaskStatusDraft, false},
{"terminal task cannot restart", domain.TaskStatusSucceeded, domain.TaskStatusPending, false},
{"unknown status is rejected", domain.TaskStatus("UNKNOWN"), domain.TaskStatusPending, false},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
if got := test.current.CanTransitionTo(test.next); got != test.allowed {
t.Fatalf("CanTransitionTo(%s, %s) = %t, want %t", test.current, test.next, got, test.allowed)
}
result, err := domain.TransitionTask(test.current, test.next)
if test.allowed {
if err != nil {
t.Fatalf("TransitionTask(%s, %s): %v", test.current, test.next, err)
}
if result != test.next {
t.Fatalf("TransitionTask(%s, %s) = %s, want %s", test.current, test.next, result, test.next)
}
return
}
if !errors.Is(err, domain.ErrInvalidTaskTransition) {
t.Fatalf("TransitionTask(%s, %s) error = %v, want ErrInvalidTaskTransition", test.current, test.next, err)
}
if result != test.current {
t.Fatalf("TransitionTask(%s, %s) result = %s, want unchanged status", test.current, test.next, result)
}
})
}
}
+72
View File
@@ -0,0 +1,72 @@
// Package evidence defines the narrow internal screenshot contract shared by HTTP and storage.
package evidence
import (
"context"
"errors"
"io"
"time"
"cmbuyer/admin/internal/deviceauth"
)
const (
KindSKUPanelGate1 = "SKU_PANEL_GATE_1"
PrivacyInternalRaw = "INTERNAL_RAW"
PNGContentType = "image/png"
MaxFileBytes int64 = 10 << 20
MaxImageSide = 8192
MaxImagePixels = 16_777_216
)
var (
ErrInvalid = errors.New("invalid evidence")
ErrConflict = errors.New("evidence upload key conflict")
ErrNotFound = errors.New("evidence not found")
ErrTooLarge = errors.New("evidence file too large")
)
type UploadMetadata struct {
UploadKey string
TaskID string
AttemptID string
Kind string
PrivacyTier string
SHA256 string
CapturedAt time.Time
}
// StagedFile contains only server-generated state. Multipart filenames and client paths never enter this type.
type StagedFile struct {
Path string
SHA256 string
ByteSize int64
ContentType string
Width int
Height int
}
type Asset struct {
ID string `json:"asset_id"`
TaskID string `json:"task_id"`
AttemptID string `json:"attempt_id"`
Kind string `json:"kind"`
PrivacyTier string `json:"privacy_tier"`
SHA256 string `json:"sha256"`
ByteSize int64 `json:"byte_size"`
ContentType string `json:"content_type"`
Width int `json:"width_px"`
Height int `json:"height_px"`
CapturedAt time.Time `json:"captured_at"`
UploadedByDeviceID string `json:"-"`
StorageKey string `json:"-"`
CreatedAt time.Time `json:"-"`
}
// Store separates bounded multipart staging from metadata commit so field order cannot weaken validation.
type Store interface {
Stage(io.Reader, string) (StagedFile, error)
Discard(StagedFile)
Commit(context.Context, deviceauth.Principal, UploadMetadata, StagedFile) (Asset, bool, error)
Open(context.Context, string) (Asset, io.ReadSeekCloser, error)
}
+28
View File
@@ -0,0 +1,28 @@
// Package migrations 通过 goose 执行采购服务的版本化数据库迁移。
package migrations
import (
"context"
"database/sql"
"github.com/pressly/goose/v3"
)
// Run 执行指定的 goose 命令。迁移目录由调用方显式传入,避免把运行目录当作隐式配置。
func Run(ctx context.Context, database *sql.DB, directory, command string) error {
if err := goose.SetDialect("sqlite3"); err != nil {
return err
}
return goose.RunContext(ctx, command, database, directory)
}
// Up 将数据库迁移到当前版本。
func Up(ctx context.Context, database *sql.DB, directory string) error {
return Run(ctx, database, directory, "up")
}
// Down 回退一个已应用的迁移版本。
func Down(ctx context.Context, database *sql.DB, directory string) error {
return Run(ctx, database, directory, "down")
}
@@ -0,0 +1,693 @@
package migrations_test
import (
"context"
"database/sql"
"os"
"path/filepath"
"runtime"
"strconv"
"strings"
"testing"
"cmbuyer/admin/internal/migrations"
"cmbuyer/admin/internal/storage/sqlite"
"github.com/pressly/goose/v3"
)
const migrationTime = "2026-08-04T00:00:00Z"
func TestUpDownAndIdempotence(t *testing.T) {
database := openTestDatabase(t)
directory := migrationDirectory(t)
context := context.Background()
if err := migrations.Up(context, database, directory); err != nil {
t.Fatalf("apply migrations: %v", err)
}
assertVersion(t, database, 5)
assertTableExists(t, database, "tasks", true)
assertTableExists(t, database, "spec_trials", false)
assertTableExists(t, database, "order_authorizations", true)
assertTableExists(t, database, "purchase_attempts", true)
assertTableExists(t, database, "order_submissions", true)
assertTableExists(t, database, "evidence_assets", true)
assertTableExists(t, database, "device_credentials", true)
assertTableExists(t, database, "purchase_attempt_claims", true)
assertTableExists(t, database, "single_pass_upgrade_guard", false)
if err := migrations.Up(context, database, directory); err != nil {
t.Fatalf("reapply migrations: %v", err)
}
assertVersion(t, database, 5)
if err := migrations.Down(context, database, directory); err != nil {
t.Fatalf("roll back task claim migration: %v", err)
}
assertVersion(t, database, 4)
assertTableExists(t, database, "purchase_attempt_claims", false)
assertTableExists(t, database, "device_credentials", true)
if err := migrations.Down(context, database, directory); err != nil {
t.Fatalf("roll back device credential migration: %v", err)
}
assertVersion(t, database, 3)
assertTableExists(t, database, "device_credentials", false)
assertTableExists(t, database, "evidence_assets", true)
if err := migrations.Down(context, database, directory); err != nil {
t.Fatalf("roll back evidence migration: %v", err)
}
assertVersion(t, database, 2)
assertTableExists(t, database, "evidence_assets", false)
if err := migrations.Down(context, database, directory); err != nil {
t.Fatalf("roll back v2 migration: %v", err)
}
assertVersion(t, database, 1)
assertTableExists(t, database, "spec_trials", true)
assertTableExists(t, database, "purchase_attempts", false)
assertTableExists(t, database, "single_pass_downgrade_guard", false)
if err := migrations.Up(context, database, directory); err != nil {
t.Fatalf("reapply v2 after rollback: %v", err)
}
assertVersion(t, database, 5)
}
func TestUpgradePreservesManualDraftLosslessly(t *testing.T) {
database := openTestDatabase(t)
migrateToV1(t, database)
if _, err := database.Exec(`
INSERT INTO tasks (
id, source, source_ref, title, goods_id, sku_color, sku_size, quantity, max_total_price,
reference_asset_id, status, version, created_at, updated_at
) VALUES ('draft-one', 'MANUAL', 'source-ref', 'title', 'goods', 'white', 'XL', 2, '80.50',
'asset-id', 'DRAFT', 7, '2026-08-03T00:00:00Z', '2026-08-03T01:00:00Z')
`); err != nil {
t.Fatalf("insert v1 draft: %v", err)
}
if err := migrations.Up(context.Background(), database, migrationDirectory(t)); err != nil {
t.Fatalf("upgrade v1 draft: %v", err)
}
assertVersion(t, database, 5)
var got struct {
id, source, sourceRef, title, goodsID, color, size, maxPrice, assetID, status, created, updated string
quantity, version int
}
if err := database.QueryRow(`SELECT id, source, source_ref, title, goods_id, sku_color, sku_size, quantity, max_total_price, reference_asset_id, status, version, created_at, updated_at FROM tasks WHERE id = 'draft-one'`).Scan(
&got.id, &got.source, &got.sourceRef, &got.title, &got.goodsID, &got.color, &got.size, &got.quantity, &got.maxPrice, &got.assetID, &got.status, &got.version, &got.created, &got.updated,
); err != nil {
t.Fatalf("read upgraded draft: %v", err)
}
if got != (struct {
id, source, sourceRef, title, goodsID, color, size, maxPrice, assetID, status, created, updated string
quantity, version int
}{"draft-one", "MANUAL", "source-ref", "title", "goods", "white", "XL", "80.50", "asset-id", "DRAFT", "2026-08-03T00:00:00Z", "2026-08-03T01:00:00Z", 2, 7}) {
t.Fatalf("upgraded draft changed: %#v", got)
}
}
func TestUpgradeRejectsLegacyExecutionDataAtomically(t *testing.T) {
tests := []struct {
name string
setup func(*testing.T, *sql.DB)
}{
{"non-draft task", func(t *testing.T, database *sql.DB) {
insertV1Task(t, database, "pending", "MANUAL", "PENDING", "1.00")
}},
{"non-manual task", func(t *testing.T, database *sql.DB) { insertV1Task(t, database, "excel", "EXCEL", "DRAFT", "1.00") }},
{"invalid v2 money", func(t *testing.T, database *sql.DB) { insertV1Task(t, database, "zero", "MANUAL", "DRAFT", "0.00") }},
{"third decimal place", func(t *testing.T, database *sql.DB) {
insertV1Task(t, database, "third-decimal", "MANUAL", "DRAFT", "1.234")
}},
{"spec trial", func(t *testing.T, database *sql.DB) {
insertV1Task(t, database, "task", "MANUAL", "DRAFT", "1.00")
insertV1SpecTrial(t, database, "trial", "task")
}},
{"authorization", func(t *testing.T, database *sql.DB) {
insertV1Task(t, database, "task", "MANUAL", "DRAFT", "1.00")
insertV1SpecTrial(t, database, "trial", "task")
insertV1Authorization(t, database, "auth", "task", "trial")
}},
{"submission", func(t *testing.T, database *sql.DB) {
insertV1Task(t, database, "task", "MANUAL", "DRAFT", "1.00")
insertV1SpecTrial(t, database, "trial", "task")
insertV1Authorization(t, database, "auth", "task", "trial")
if _, err := database.Exec(`INSERT INTO order_submissions (id, task_id, authorization_id, command_id, dry_run_id, status, verified_unit_price, quantity_read, confirm_page_amount, created_at) VALUES ('submission', 'task', 'auth', 'command', 'dry-run', 'FENCED', '1.00', 1, '1.00', ? )`, migrationTime); err != nil {
t.Fatalf("insert v1 submission: %v", err)
}
}},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
database := openTestDatabase(t)
migrateToV1(t, database)
test.setup(t, database)
before := v1RowCount(t, database)
if err := migrations.Up(context.Background(), database, migrationDirectory(t)); err == nil {
t.Fatal("unsafe legacy data upgraded successfully")
}
assertVersion(t, database, 1)
assertTableExists(t, database, "spec_trials", true)
assertTableExists(t, database, "purchase_attempts", false)
assertTableExists(t, database, "single_pass_upgrade_guard", false)
if after := v1RowCount(t, database); after != before {
t.Fatalf("v1 data changed after rejection: before=%d after=%d", before, after)
}
})
}
}
func TestV2SchemaConstraintsAndRelationships(t *testing.T) {
database := openTestDatabase(t)
if err := migrations.Up(context.Background(), database, migrationDirectory(t)); err != nil {
t.Fatalf("apply migrations: %v", err)
}
for _, column := range []struct{ table, name string }{
{"tasks", "max_total_price"},
{"order_authorizations", "total_price_cap"},
{"purchase_attempts", "gate1_unit_price"},
{"purchase_attempts", "gate2_unit_price"},
{"purchase_attempts", "confirm_amount"},
{"order_submissions", "gate1_unit_price"},
{"order_submissions", "gate2_unit_price"},
{"order_submissions", "confirm_amount"},
} {
assertColumnType(t, database, column.table, column.name, "TEXT")
}
for _, legacy := range []string{"spec_trials", "authorized_unit_price", "spec_trial_id", "command_id", "dry_run_id"} {
var count int
if err := database.QueryRow(`SELECT COUNT(*) FROM sqlite_master WHERE sql LIKE '%' || ? || '%'`, legacy).Scan(&count); err != nil {
t.Fatalf("search schema for %s: %v", legacy, err)
}
if count != 0 {
t.Fatalf("legacy identifier %q remains in v2 schema", legacy)
}
}
insertV2Task(t, database, "task-one", "MANUAL", "DRAFT")
insertV2Task(t, database, "task-two", "MANUAL", "DRAFT")
for index, value := range []string{"", "0", "0.00", "-1.00", "1e2", "1.", "1.234", " 1.00", "one"} {
if _, err := database.Exec(`INSERT INTO tasks (id, source, title, goods_id, sku_color, sku_size, quantity, max_total_price, status, created_at, updated_at) VALUES (?, 'MANUAL', 'title', 'goods', 'white', 'XL', 1, ?, 'DRAFT', ?, ?)`, "bad-price-"+strconv.Itoa(index), value, migrationTime, migrationTime); err == nil {
t.Fatalf("invalid total price %q succeeded", value)
}
}
if _, err := database.Exec(`INSERT INTO tasks (id, source, title, goods_id, sku_color, sku_size, quantity, max_total_price, status, created_at, updated_at) VALUES ('bad-status', 'MANUAL', 'title', 'goods', 'white', 'XL', 1, '1.00', 'UNKNOWN', ?, ?)`, migrationTime, migrationTime); err == nil {
t.Fatal("unknown task status succeeded")
}
insertV2Authorization(t, database, "auth-one", "task-one", 1, "start-one")
insertV2Authorization(t, database, "auth-two", "task-two", 1, "start-two")
if _, err := database.Exec(`INSERT INTO order_authorizations (id, task_id, task_version, start_key, goods_id, sku_color, sku_size, quantity, total_price_cap, status, created_by, created_at, expires_at) VALUES ('bad-auth-price', 'task-one', 2, 'bad-price', 'goods', 'white', 'XL', 1, '1.234', 'ACTIVE', 'admin', ?, ?)`, migrationTime, migrationTime); err == nil {
t.Fatal("third decimal authorization cap succeeded")
}
if _, err := database.Exec(`INSERT INTO order_authorizations (id, task_id, task_version, start_key, goods_id, sku_color, sku_size, quantity, total_price_cap, status, created_by, created_at, expires_at) VALUES ('bad-auth-status', 'task-one', 2, 'bad-status', 'goods', 'white', 'XL', 1, '1.00', 'UNKNOWN', 'admin', ?, ?)`, migrationTime, migrationTime); err == nil {
t.Fatal("unknown authorization status succeeded")
}
insertV2Authorization(t, database, "auth-one-b", "task-one", 2, "start-one-b")
if _, err := database.Exec(`INSERT INTO order_authorizations (id, task_id, task_version, start_key, goods_id, sku_color, sku_size, quantity, total_price_cap, status, created_by, created_at, expires_at) VALUES ('duplicate-version', 'task-one', 1, 'different-start', 'goods', 'white', 'XL', 1, '1.00', 'ACTIVE', 'admin', ?, ?)`, migrationTime, migrationTime); err == nil {
t.Fatal("duplicate task version authorization succeeded")
}
if _, err := database.Exec(`INSERT INTO purchase_attempts (id, task_id, authorization_id, claim_generation, status, started_at) VALUES ('cross-attempt', 'task-one', 'auth-two', 1, 'CLAIMED', ?)`, migrationTime); err == nil {
t.Fatal("attempt using another task authorization succeeded")
}
insertV2Attempt(t, database, "attempt-one", "task-one", "auth-one", 1)
if _, err := database.Exec(`INSERT INTO purchase_attempts (id, task_id, authorization_id, claim_generation, status, gate1_unit_price, started_at) VALUES ('bad-attempt-price', 'task-one', 'auth-one', 2, 'ORDERING', '1.234', ?)`, migrationTime); err == nil {
t.Fatal("third decimal gate price succeeded")
}
if _, err := database.Exec(`INSERT INTO purchase_attempts (id, task_id, authorization_id, claim_generation, status, started_at) VALUES ('bad-attempt-status', 'task-one', 'auth-one', 2, 'UNKNOWN', ?)`, migrationTime); err == nil {
t.Fatal("unknown attempt status succeeded")
}
if _, err := database.Exec(`INSERT INTO purchase_attempts (id, task_id, authorization_id, claim_generation, status, failure_code, started_at) VALUES ('bad-code', 'task-one', 'auth-one', 2, 'FAILED', 'FREE_TEXT', ?)`, migrationTime); err == nil {
t.Fatal("unknown failure code succeeded")
}
if _, err := database.Exec(`INSERT INTO order_submissions (id, task_id, authorization_id, attempt_id, status, gate1_unit_price, gate2_unit_price, quantity_read, confirm_amount, created_at) VALUES ('cross-submission', 'task-one', 'auth-two', 'attempt-one', 'FENCED', '1.00', '1.00', 1, '1.00', ?)`, migrationTime); err == nil {
t.Fatal("submission using another task authorization succeeded")
}
if _, err := database.Exec(`INSERT INTO order_submissions (id, task_id, authorization_id, attempt_id, status, gate1_unit_price, gate2_unit_price, quantity_read, confirm_amount, created_at) VALUES ('cross-authorization-submission', 'task-one', 'auth-one-b', 'attempt-one', 'FENCED', '1.00', '1.00', 1, '1.00', ?)`, migrationTime); err == nil {
t.Fatal("submission combining another same-task authorization and attempt succeeded")
}
if _, err := database.Exec(`INSERT INTO order_submissions (id, task_id, authorization_id, attempt_id, status, gate1_unit_price, gate2_unit_price, quantity_read, confirm_amount, created_at) VALUES ('bad-submission-status', 'task-one', 'auth-one', 'attempt-one', 'UNKNOWN', '1.00', '1.00', 1, '1.00', ?)`, migrationTime); err == nil {
t.Fatal("unknown submission status succeeded")
}
if _, err := database.Exec(`INSERT INTO order_submissions (id, task_id, authorization_id, attempt_id, status, gate1_unit_price, gate2_unit_price, quantity_read, confirm_amount, created_at) VALUES ('bad-submission-price', 'task-one', 'auth-one', 'attempt-one', 'FENCED', '1.234', '1.00', 1, '1.00', ?)`, migrationTime); err == nil {
t.Fatal("third decimal submission price succeeded")
}
insertV2Submission(t, database, "submission-one", "task-one", "auth-one", "attempt-one")
if _, err := database.Exec(`INSERT INTO order_submissions (id, task_id, authorization_id, attempt_id, status, gate1_unit_price, gate2_unit_price, quantity_read, confirm_amount, created_at) VALUES ('duplicate-auth', 'task-one', 'auth-one', 'attempt-one', 'FENCED', '1.00', '1.00', 1, '1.00', ?)`, migrationTime); err == nil {
t.Fatal("second submission for fenced authorization succeeded")
}
}
func TestEvidenceSchemaConstraintsAndDowngradeGuard(t *testing.T) {
database := openTestDatabase(t)
migrateToV3(t, database)
insertV2Task(t, database, "task-one", "MANUAL", "DRAFT")
insertV2Authorization(t, database, "auth-one", "task-one", 1, "start-one")
insertV2Attempt(t, database, "attempt-one", "task-one", "auth-one", 1)
insertV2Task(t, database, "task-two", "MANUAL", "DRAFT")
insertV2Authorization(t, database, "auth-two", "task-two", 1, "start-two")
insertV2Attempt(t, database, "attempt-two", "task-two", "auth-two", 1)
hash := strings.Repeat("a", 64)
insert := `INSERT INTO evidence_assets (id, upload_key, task_id, attempt_id, kind, privacy_tier, sha256, byte_size, content_type, width_px, height_px, storage_key, uploaded_by_device_id, captured_at, created_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`
validArgs := []any{"asset-one", "upload-one", "task-one", "attempt-one", "SKU_PANEL_GATE_1", "INTERNAL_RAW", hash, 100, "image/png", 100, 100, "aa/" + hash + ".png", "device-one", migrationTime, migrationTime}
if _, err := database.Exec(insert, validArgs...); err != nil {
t.Fatalf("insert valid evidence: %v", err)
}
for name, mutate := range map[string]func([]any){
"attempt from another task": func(values []any) { values[0], values[1], values[3] = "bad-task", "upload-bad-task", "attempt-two" },
"unapproved kind": func(values []any) { values[0], values[1], values[4] = "bad-kind", "upload-bad-kind", "ORDER_CONFIRM" },
"wrong privacy": func(values []any) { values[0], values[1], values[5] = "bad-privacy", "upload-bad-privacy", "PUBLIC" },
"uppercase hash": func(values []any) {
values[0], values[1], values[6], values[11] = "bad-hash", "upload-bad-hash", strings.Repeat("A", 64), "AA/"+strings.Repeat("A", 64)+".png"
},
"too many pixels": func(values []any) {
values[0], values[1], values[9], values[10] = "bad-pixels", "upload-bad-pixels", 8192, 8192
},
"client path": func(values []any) { values[0], values[1], values[11] = "bad-path", "upload-bad-path", `..\secret.png` },
} {
t.Run(name, func(t *testing.T) {
values := append([]any(nil), validArgs...)
mutate(values)
if _, err := database.Exec(insert, values...); err == nil {
t.Fatal("invalid evidence row succeeded")
}
})
}
if err := migrations.Down(context.Background(), database, migrationDirectory(t)); err == nil {
t.Fatal("evidence-bearing schema downgraded successfully")
}
assertVersion(t, database, 3)
assertTableExists(t, database, "evidence_assets", true)
var count int
if err := database.QueryRow("SELECT COUNT(*) FROM evidence_assets").Scan(&count); err != nil || count != 1 {
t.Fatalf("evidence after rejected downgrade = %d, err=%v", count, err)
}
}
func TestDeviceCredentialSchemaConstraintsAndDowngradeGuard(t *testing.T) {
database := openTestDatabase(t)
migrateToV4(t, database)
deviceID := "13c9f507-7473-4fa6-8d71-8786c34c6301"
hash := make([]byte, 32)
for index := range hash {
hash[index] = byte(index + 1)
}
insert := `INSERT INTO device_credentials (device_id, display_name, token_sha256, status, created_at, revoked_at) VALUES (?, ?, ?, ?, ?, ?)`
valid := []any{deviceID, "采购工具一号", hash, "ACTIVE", migrationTime, nil}
if _, err := database.Exec(insert, valid...); err != nil {
t.Fatalf("insert valid credential: %v", err)
}
for name, mutate := range map[string]func([]any){
"uppercase uuid": func(values []any) { values[0], values[2] = strings.ToUpper(deviceID), append([]byte(nil), hash...) },
"wrong uuid version": func(values []any) {
values[0], values[2] = "23c9f507-7473-3fa6-8d71-8786c34c6301", append([]byte(nil), hash...)
},
"blank display name": func(values []any) {
values[0], values[1], values[2] = "33c9f507-7473-4fa6-8d71-8786c34c6301", "", append([]byte(nil), hash...)
},
"padded display name": func(values []any) {
values[0], values[1], values[2] = "43c9f507-7473-4fa6-8d71-8786c34c6301", " padded", append([]byte(nil), hash...)
},
"text hash": func(values []any) {
values[0], values[2] = "53c9f507-7473-4fa6-8d71-8786c34c6301", strings.Repeat("a", 32)
},
"short blob hash": func(values []any) { values[0], values[2] = "63c9f507-7473-4fa6-8d71-8786c34c6301", make([]byte, 31) },
"unknown status": func(values []any) {
values[0], values[2], values[3] = "73c9f507-7473-4fa6-8d71-8786c34c6301", append([]byte(nil), hash...), "UNKNOWN"
},
"active with revoke time": func(values []any) {
values[0], values[2], values[5] = "83c9f507-7473-4fa6-8d71-8786c34c6301", append([]byte(nil), hash...), migrationTime
},
"revoked without time": func(values []any) {
values[0], values[2], values[3] = "93c9f507-7473-4fa6-8d71-8786c34c6301", append([]byte(nil), hash...), "REVOKED"
},
"revoke before creation": func(values []any) {
values[0], values[2], values[3], values[4], values[5] = "b3c9f507-7473-4fa6-8d71-8786c34c6301", append([]byte(nil), hash...), "REVOKED", "2026-08-04T01:00:00Z", "2026-08-04T00:00:00Z"
},
"non UTC created time": func(values []any) {
values[0], values[2], values[4] = "a3c9f507-7473-4fa6-8d71-8786c34c6301", append([]byte(nil), hash...), "2026-08-04T08:00:00+08:00"
},
} {
t.Run(name, func(t *testing.T) {
values := append([]any(nil), valid...)
mutate(values)
if bytesValue, ok := values[2].([]byte); ok && len(bytesValue) == 32 {
bytesValue[0]++
}
if _, err := database.Exec(insert, values...); err == nil {
t.Fatal("invalid device credential row succeeded")
}
})
}
if err := migrations.Down(context.Background(), database, migrationDirectory(t)); err == nil {
t.Fatal("credential-bearing schema downgraded successfully")
}
assertVersion(t, database, 4)
assertTableExists(t, database, "device_credentials", true)
var count int
if err := database.QueryRow(`SELECT COUNT(*) FROM device_credentials`).Scan(&count); err != nil || count != 1 {
t.Fatalf("credentials after rejected downgrade = %d, err=%v", count, err)
}
}
func TestTaskClaimMigrationGuardsOwnershipConstraintsAndDowngradeFacts(t *testing.T) {
t.Run("upgrade rejects unmappable execution facts atomically", func(t *testing.T) {
database := openTestDatabase(t)
migrateToV4(t, database)
insertV2Task(t, database, "legacy-task", "MANUAL", "DRAFT")
insertV2Authorization(t, database, "legacy-auth", "legacy-task", 1, "legacy-start")
insertV2Attempt(t, database, "legacy-attempt", "legacy-task", "legacy-auth", 1)
if err := migrations.Up(context.Background(), database, migrationDirectory(t)); err == nil {
t.Fatal("v5 upgrade accepted an attempt without device/session ownership")
}
assertVersion(t, database, 4)
assertTableExists(t, database, "purchase_attempt_claims", false)
var count int
if err := database.QueryRow("SELECT COUNT(*) FROM purchase_attempts").Scan(&count); err != nil || count != 1 {
t.Fatalf("legacy attempt after rejected upgrade = %d, err %v", count, err)
}
})
t.Run("schema binds authorization device session generation and token", func(t *testing.T) {
database := openTestDatabase(t)
if err := migrations.Up(context.Background(), database, migrationDirectory(t)); err != nil {
t.Fatalf("apply migrations: %v", err)
}
deviceA := "13c9f507-7473-4fa6-8d71-8786c34c6301"
deviceB := "23c9f507-7473-4fa6-8d71-8786c34c6301"
sessionA := "33c9f507-7473-4fa6-8d71-8786c34c6301"
sessionB := "43c9f507-7473-4fa6-8d71-8786c34c6301"
taskA := "53c9f507-7473-4fa6-8d71-8786c34c6301"
authA := "63c9f507-7473-4fa6-8d71-8786c34c6301"
attemptA := "73c9f507-7473-4fa6-8d71-8786c34c6301"
taskB := "83c9f507-7473-4fa6-8d71-8786c34c6301"
authB := "93c9f507-7473-4fa6-8d71-8786c34c6301"
attemptB := "a3c9f507-7473-4fa6-8d71-8786c34c6301"
tokenA := make([]byte, 32)
for index := range tokenA {
tokenA[index] = byte(index + 1)
}
for index, device := range []string{deviceA, deviceB} {
hash := make([]byte, 32)
hash[0] = byte(index + 100)
if _, err := database.Exec(`INSERT INTO device_credentials
(device_id,display_name,token_sha256,status,created_at,revoked_at)
VALUES (?, ?, ?, 'ACTIVE', ?, NULL)`, device, "device "+strconv.Itoa(index), hash, migrationTime); err != nil {
t.Fatalf("insert device: %v", err)
}
}
insertV2Task(t, database, taskA, "MANUAL", "DRAFT")
insertV2Authorization(t, database, authA, taskA, 1, "start-a")
insertV2Attempt(t, database, attemptA, taskA, authA, 1)
insertClaim := `INSERT INTO purchase_attempt_claims
(attempt_id,task_id,authorization_id,claimed_by_device_id,session_id,claim_generation,
task_version,task_title,authorization_task_version,goods_id,sku_color,sku_size,quantity,
total_price_cap,authorization_expires_at,claim_nonce,claim_token_sha256,lease_expires_at,claimed_at,closed_at)
VALUES (?, ?, ?, ?, ?, ?, 2, 'task', 1, 'goods', 'white', 'XL', 1, '1.00',
'2026-08-04T01:00:00Z', ?, ?, '2026-08-04T00:05:00Z', ?, NULL)`
if _, err := database.Exec(insertClaim, attemptA, taskA, authA, deviceA, sessionA, 1, make([]byte, 32), tokenA, migrationTime); err != nil {
t.Fatalf("insert valid claim: %v", err)
}
if _, err := database.Exec(`INSERT INTO purchase_attempts
(id,task_id,authorization_id,claim_generation,status,started_at)
VALUES ('b3c9f507-7473-4fa6-8d71-8786c34c6301', ?, ?, 2, 'CLAIMED', ?)`, taskA, authA, migrationTime); err == nil {
t.Fatal("second attempt for one authorization succeeded")
}
insertV2Task(t, database, taskB, "MANUAL", "DRAFT")
insertV2Authorization(t, database, authB, taskB, 1, "start-b")
insertV2Attempt(t, database, attemptB, taskB, authB, 1)
if _, err := database.Exec(insertClaim, attemptB, taskB, authB, deviceB, sessionB, 2, make([]byte, 32), make([]byte, 32), migrationTime); err == nil {
t.Fatal("claim with generation different from its attempt succeeded")
}
if _, err := database.Exec(insertClaim, attemptB, taskB, authB, deviceA, sessionB, 1, make([]byte, 32), make([]byte, 32), migrationTime); err == nil {
t.Fatal("second open claim for one device succeeded")
}
claimRequest := `INSERT INTO task_claim_requests
(claim_request_id,device_id,session_id,outcome,attempt_id,response_lease_expires_at,error_code,created_at)
VALUES (?, ?, ?, 'CLAIMED', ?, '2026-08-04T00:05:00Z', NULL, ?)`
if _, err := database.Exec(claimRequest, "c3c9f507-7473-4fa6-8d71-8786c34c6301", deviceA, sessionB, attemptA, migrationTime); err == nil {
t.Fatal("claim request with another session succeeded")
}
if _, err := database.Exec(claimRequest, "d3c9f507-7473-4fa6-8d71-8786c34c6301", deviceA, sessionA, attemptA, migrationTime); err != nil {
t.Fatalf("insert bound claim request: %v", err)
}
renewal := `INSERT INTO purchase_attempt_lease_renewals
(renew_request_id,task_id,attempt_id,device_id,session_id,claim_generation,
claim_token_sha256,expected_lease_expires_at,lease_expires_at,created_at)
VALUES (?, ?, ?, ?, ?, ?, ?, '2026-08-04T00:05:00Z', '2026-08-04T00:06:00Z', ?)`
if _, err := database.Exec(renewal, "e3c9f507-7473-4fa6-8d71-8786c34c6301", taskA, attemptA, deviceA, sessionA, 2, tokenA, migrationTime); err == nil {
t.Fatal("renewal with another generation succeeded")
}
wrongHash := append([]byte(nil), tokenA...)
wrongHash[0] ^= 0xff
if _, err := database.Exec(renewal, "f3c9f507-7473-4fa6-8d71-8786c34c6301", taskA, attemptA, deviceA, sessionA, 1, wrongHash, migrationTime); err == nil {
t.Fatal("renewal with another token hash succeeded")
}
if err := migrations.Down(context.Background(), database, migrationDirectory(t)); err == nil {
t.Fatal("claim-bearing schema downgraded successfully")
}
assertVersion(t, database, 5)
assertTableExists(t, database, "purchase_attempt_claims", true)
})
t.Run("empty request alone blocks downgrade", func(t *testing.T) {
database := openTestDatabase(t)
if err := migrations.Up(context.Background(), database, migrationDirectory(t)); err != nil {
t.Fatal(err)
}
device := "13c9f507-7473-4fa6-8d71-8786c34c6301"
if _, err := database.Exec(`INSERT INTO device_credentials
(device_id,display_name,token_sha256,status,created_at,revoked_at)
VALUES (?, 'device', ?, 'ACTIVE', ?, NULL)`, device, make([]byte, 32), migrationTime); err != nil {
t.Fatal(err)
}
if _, err := database.Exec(`INSERT INTO task_claim_requests
(claim_request_id,device_id,session_id,outcome,attempt_id,response_lease_expires_at,error_code,created_at)
VALUES ('23c9f507-7473-4fa6-8d71-8786c34c6301', ?,
'33c9f507-7473-4fa6-8d71-8786c34c6301', 'EMPTY', NULL, NULL, NULL, ?)`, device, migrationTime); err != nil {
t.Fatal(err)
}
if err := migrations.Down(context.Background(), database, migrationDirectory(t)); err == nil {
t.Fatal("EMPTY request was silently dropped by downgrade")
}
assertVersion(t, database, 5)
})
}
func TestDowngradeRejectsV2BusinessDataAtomically(t *testing.T) {
tests := []struct {
name string
setup func(*testing.T, *sql.DB)
}{
{"authorization", func(t *testing.T, database *sql.DB) {
insertV2Task(t, database, "task", "MANUAL", "DRAFT")
insertV2Authorization(t, database, "auth", "task", 1, "start")
}},
{"attempt", func(t *testing.T, database *sql.DB) {
insertV2Task(t, database, "task", "MANUAL", "DRAFT")
insertV2Authorization(t, database, "auth", "task", 1, "start")
insertV2Attempt(t, database, "attempt", "task", "auth", 1)
}},
{"submission", func(t *testing.T, database *sql.DB) {
insertV2Task(t, database, "task", "MANUAL", "DRAFT")
insertV2Authorization(t, database, "auth", "task", 1, "start")
insertV2Attempt(t, database, "attempt", "task", "auth", 1)
insertV2Submission(t, database, "submission", "task", "auth", "attempt")
}},
{"non-draft task", func(t *testing.T, database *sql.DB) { insertV2Task(t, database, "pending", "MANUAL", "PENDING") }},
{"non-manual task", func(t *testing.T, database *sql.DB) { insertV2Task(t, database, "excel", "EXCEL", "DRAFT") }},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
database := openTestDatabase(t)
migrateToV2(t, database)
test.setup(t, database)
before := v2RowCount(t, database)
if err := migrations.Down(context.Background(), database, migrationDirectory(t)); err == nil {
t.Fatal("unsafe v2 data downgraded successfully")
}
assertVersion(t, database, 2)
assertTableExists(t, database, "purchase_attempts", true)
assertTableExists(t, database, "spec_trials", false)
assertTableExists(t, database, "single_pass_downgrade_guard", false)
if after := v2RowCount(t, database); after != before {
t.Fatalf("v2 data changed after rejected downgrade: before=%d after=%d", before, after)
}
})
}
}
func migrateToV1(t *testing.T, database *sql.DB) {
t.Helper()
if err := migrations.Run(context.Background(), database, migrationDirectory(t), "up-by-one"); err != nil {
t.Fatalf("apply v1: %v", err)
}
assertVersion(t, database, 1)
}
func migrateToV2(t *testing.T, database *sql.DB) {
t.Helper()
if err := migrations.Run(context.Background(), database, migrationDirectory(t), "up-by-one"); err != nil {
t.Fatalf("apply v1: %v", err)
}
if err := migrations.Run(context.Background(), database, migrationDirectory(t), "up-by-one"); err != nil {
t.Fatalf("apply v2: %v", err)
}
assertVersion(t, database, 2)
}
func migrateToV3(t *testing.T, database *sql.DB) {
t.Helper()
migrateToV2(t, database)
if err := migrations.Run(context.Background(), database, migrationDirectory(t), "up-by-one"); err != nil {
t.Fatalf("apply v3: %v", err)
}
assertVersion(t, database, 3)
}
func migrateToV4(t *testing.T, database *sql.DB) {
t.Helper()
migrateToV3(t, database)
if err := migrations.Run(context.Background(), database, migrationDirectory(t), "up-by-one"); err != nil {
t.Fatalf("apply v4: %v", err)
}
assertVersion(t, database, 4)
}
func insertV1Task(t *testing.T, database *sql.DB, id, source, status, price string) {
t.Helper()
if _, err := database.Exec(`INSERT INTO tasks (id, source, title, goods_id, sku_color, sku_size, quantity, max_total_price, status, created_at, updated_at) VALUES (?, ?, 'title', 'goods', 'white', 'XL', 1, ?, ?, ?, ?)`, id, source, price, status, migrationTime, migrationTime); err != nil {
t.Fatalf("insert v1 task: %v", err)
}
}
func insertV1SpecTrial(t *testing.T, database *sql.DB, id, taskID string) {
t.Helper()
if _, err := database.Exec(`INSERT INTO spec_trials (id, task_id, attempt, product_title, selected_color, selected_size, unit_price, total_price, evidence_sha256, created_at) VALUES (?, ?, 1, 'title', 'white', 'XL', '1.00', '1.00', 'hash', ?)`, id, taskID, migrationTime); err != nil {
t.Fatalf("insert v1 spec trial: %v", err)
}
}
func insertV1Authorization(t *testing.T, database *sql.DB, id, taskID, trialID string) {
t.Helper()
if _, err := database.Exec(`INSERT INTO order_authorizations (id, task_id, spec_trial_id, version, goods_id, sku_color, sku_size, quantity, authorized_unit_price, total_price_cap, status, created_by, created_at, expires_at) VALUES (?, ?, ?, 1, 'goods', 'white', 'XL', 1, '1.00', '1.00', 'PENDING_DELIVERY', 'admin', ?, ?)`, id, taskID, trialID, migrationTime, migrationTime); err != nil {
t.Fatalf("insert v1 authorization: %v", err)
}
}
func insertV2Task(t *testing.T, database *sql.DB, id, source, status string) {
t.Helper()
if _, err := database.Exec(`INSERT INTO tasks (id, source, title, goods_id, sku_color, sku_size, quantity, max_total_price, status, created_at, updated_at) VALUES (?, ?, 'title', 'goods', 'white', 'XL', 1, '1.00', ?, ?, ?)`, id, source, status, migrationTime, migrationTime); err != nil {
t.Fatalf("insert v2 task: %v", err)
}
}
func insertV2Authorization(t *testing.T, database *sql.DB, id, taskID string, version int, startKey string) {
t.Helper()
if _, err := database.Exec(`INSERT INTO order_authorizations (id, task_id, task_version, start_key, goods_id, sku_color, sku_size, quantity, total_price_cap, status, created_by, created_at, expires_at) VALUES (?, ?, ?, ?, 'goods', 'white', 'XL', 1, '1.00', 'ACTIVE', 'admin', ?, ?)`, id, taskID, version, startKey, migrationTime, migrationTime); err != nil {
t.Fatalf("insert v2 authorization: %v", err)
}
}
func insertV2Attempt(t *testing.T, database *sql.DB, id, taskID, authorizationID string, generation int) {
t.Helper()
if _, err := database.Exec(`INSERT INTO purchase_attempts (id, task_id, authorization_id, claim_generation, status, started_at) VALUES (?, ?, ?, ?, 'CLAIMED', ?)`, id, taskID, authorizationID, generation, migrationTime); err != nil {
t.Fatalf("insert v2 attempt: %v", err)
}
}
func insertV2Submission(t *testing.T, database *sql.DB, id, taskID, authorizationID, attemptID string) {
t.Helper()
if _, err := database.Exec(`INSERT INTO order_submissions (id, task_id, authorization_id, attempt_id, status, gate1_unit_price, gate2_unit_price, quantity_read, confirm_amount, created_at) VALUES (?, ?, ?, ?, 'FENCED', '1.00', '1.00', 1, '1.00', ?)`, id, taskID, authorizationID, attemptID, migrationTime); err != nil {
t.Fatalf("insert v2 submission: %v", err)
}
}
func v1RowCount(t *testing.T, database *sql.DB) int {
t.Helper()
var count int
if err := database.QueryRow(`SELECT (SELECT COUNT(*) FROM tasks) + (SELECT COUNT(*) FROM spec_trials) + (SELECT COUNT(*) FROM order_authorizations) + (SELECT COUNT(*) FROM order_submissions)`).Scan(&count); err != nil {
t.Fatalf("count v1 rows: %v", err)
}
return count
}
func v2RowCount(t *testing.T, database *sql.DB) int {
t.Helper()
var count int
if err := database.QueryRow(`SELECT (SELECT COUNT(*) FROM tasks) + (SELECT COUNT(*) FROM order_authorizations) + (SELECT COUNT(*) FROM purchase_attempts) + (SELECT COUNT(*) FROM order_submissions)`).Scan(&count); err != nil {
t.Fatalf("count v2 rows: %v", err)
}
return count
}
func openTestDatabase(t *testing.T) *sql.DB {
t.Helper()
database, err := sqlite.Open(filepath.Join(t.TempDir(), "migrations.db"))
if err != nil {
t.Fatalf("open test database: %v", err)
}
t.Cleanup(func() { _ = database.Close() })
return database
}
func migrationDirectory(t *testing.T) string {
t.Helper()
_, file, _, ok := runtime.Caller(0)
if !ok {
t.Fatal("locate migration test source")
}
return filepath.Join(filepath.Dir(file), "..", "..", "migrations")
}
func assertVersion(t *testing.T, database *sql.DB, want int64) {
t.Helper()
got, err := goose.GetDBVersion(database)
if err != nil {
t.Fatalf("read migration version: %v", err)
}
if got != want {
t.Fatalf("migration version = %d, want %d", got, want)
}
}
func assertTableExists(t *testing.T, database *sql.DB, table string, want bool) {
t.Helper()
var count int
if err := database.QueryRow(`SELECT COUNT(*) FROM sqlite_master WHERE type = 'table' AND name = ?`, table).Scan(&count); err != nil {
t.Fatalf("look up table %s: %v", table, err)
}
if got := count == 1; got != want {
t.Fatalf("table %s exists = %t, want %t", table, got, want)
}
}
func assertColumnType(t *testing.T, database *sql.DB, table, column, want string) {
t.Helper()
var got string
if err := database.QueryRow(`SELECT type FROM pragma_table_info(?) WHERE name = ?`, table, column).Scan(&got); err != nil {
t.Fatalf("read %s.%s type: %v", table, column, err)
}
if got != want {
t.Fatalf("%s.%s type = %s, want %s", table, column, got, want)
}
}
func TestMigrationsDoNotDisableForeignKeys(t *testing.T) {
for _, name := range []string{"00002_single_pass_model.sql", "00003_evidence_assets.sql", "00004_device_credentials.sql"} {
contents, err := os.ReadFile(filepath.Join(migrationDirectory(t), name))
if err != nil {
t.Fatalf("read %s: %v", name, err)
}
if strings.Contains(strings.ToUpper(string(contents)), "PRAGMA FOREIGN_KEYS = OFF") {
t.Fatalf("%s disables foreign keys", name)
}
}
}
+205
View File
@@ -0,0 +1,205 @@
package server
import (
"errors"
"io"
"mime"
"mime/multipart"
"net/http"
"strconv"
"strings"
"time"
"unicode/utf8"
"cmbuyer/admin/internal/deviceauth"
"cmbuyer/admin/internal/evidence"
"github.com/gin-gonic/gin"
)
const (
maxEvidenceRequestBytes = evidence.MaxFileBytes + 64<<10
maxEvidenceFieldBytes = 4 << 10
)
var evidenceFieldNames = map[string]struct{}{
"upload_key": {}, "attempt_id": {}, "kind": {}, "privacy_tier": {}, "sha256": {}, "captured_at": {},
}
func uploadEvidence(options Options) gin.HandlerFunc {
return func(context *gin.Context) {
// Authentication deliberately precedes content-type parsing and every body read. A rejected
// device must not make the service spool or inspect a potentially sensitive upload.
principal, err := options.DeviceAuthenticator.Authenticate(context.Request)
if errors.Is(err, deviceauth.ErrUnauthenticated) {
context.Header("WWW-Authenticate", "Bearer")
context.Status(http.StatusUnauthorized)
return
}
if err != nil {
context.Status(http.StatusServiceUnavailable)
return
}
if !deviceauth.ValidDeviceID(principal.ID) {
// A custom authenticator is still an untrusted boundary. Do not defer principal
// validation until Commit because multipart bytes would already have been read.
context.Status(http.StatusServiceUnavailable)
return
}
boundary, ok := multipartBoundary(context.GetHeader("Content-Type"))
if !ok {
context.Status(http.StatusUnsupportedMediaType)
return
}
context.Request.Body = http.MaxBytesReader(context.Writer, context.Request.Body, maxEvidenceRequestBytes)
reader := multipart.NewReader(context.Request.Body, boundary)
fields := make(map[string]string, len(evidenceFieldNames))
var staged evidence.StagedFile
hasFile := false
discard := func() {
if hasFile {
options.Evidence.Discard(staged)
}
}
for {
part, err := reader.NextPart()
if errors.Is(err, io.EOF) {
break
}
if err != nil {
discard()
writeMultipartError(context, err)
return
}
name := part.FormName()
if name == "file" {
if hasFile || part.FileName() == "" || !exactPNGContentType(part.Header.Get("Content-Type")) {
_ = part.Close()
discard()
context.Status(http.StatusUnsupportedMediaType)
return
}
staged, err = options.Evidence.Stage(part, evidence.PNGContentType)
_ = part.Close()
if err != nil {
writeEvidenceStoreError(context, err)
return
}
hasFile = true
continue
}
if _, allowed := evidenceFieldNames[name]; !allowed || part.FileName() != "" {
_ = part.Close()
discard()
context.Status(http.StatusBadRequest)
return
}
if _, duplicate := fields[name]; duplicate {
_ = part.Close()
discard()
context.Status(http.StatusBadRequest)
return
}
value, err := io.ReadAll(io.LimitReader(part, maxEvidenceFieldBytes+1))
_ = part.Close()
if err != nil || len(value) == 0 || len(value) > maxEvidenceFieldBytes || !utf8.Valid(value) {
discard()
context.Status(http.StatusBadRequest)
return
}
fields[name] = string(value)
}
if !hasFile || len(fields) != len(evidenceFieldNames) {
discard()
context.Status(http.StatusBadRequest)
return
}
captured, err := time.Parse(time.RFC3339Nano, fields["captured_at"])
if err != nil || !strings.HasSuffix(fields["captured_at"], "Z") {
discard()
context.Status(http.StatusBadRequest)
return
}
asset, replayed, err := options.Evidence.Commit(context.Request.Context(), principal, evidence.UploadMetadata{
UploadKey: fields["upload_key"], TaskID: context.Param("id"), AttemptID: fields["attempt_id"],
Kind: fields["kind"], PrivacyTier: fields["privacy_tier"], SHA256: fields["sha256"], CapturedAt: captured.UTC(),
}, staged)
if err != nil {
writeEvidenceStoreError(context, err)
return
}
status := http.StatusCreated
if replayed {
status = http.StatusOK
}
context.JSON(status, asset)
}
}
func readEvidence(options Options) gin.HandlerFunc {
return func(context *gin.Context) {
if !options.Sessions.IsAuthenticated(context.Request) {
context.Status(http.StatusUnauthorized)
return
}
asset, file, err := options.Evidence.Open(context.Request.Context(), context.Param("asset_id"))
if errors.Is(err, evidence.ErrNotFound) {
context.Status(http.StatusNotFound)
return
}
if err != nil {
context.Status(http.StatusInternalServerError)
return
}
defer file.Close()
context.Header("Content-Type", evidence.PNGContentType)
context.Header("Content-Length", strconv.FormatInt(asset.ByteSize, 10))
context.Header("Content-Disposition", `inline; filename="evidence.png"`)
context.Header("Cache-Control", "no-store")
context.Header("X-Content-Type-Options", "nosniff")
context.Status(http.StatusOK)
if _, err := io.Copy(context.Writer, file); err != nil {
_ = context.Error(err)
}
}
}
func multipartBoundary(value string) (string, bool) {
mediaType, parameters, err := mime.ParseMediaType(value)
if err != nil || mediaType != "multipart/form-data" || len(parameters) != 1 || parameters["boundary"] == "" {
return "", false
}
return parameters["boundary"], true
}
func exactPNGContentType(value string) bool {
mediaType, parameters, err := mime.ParseMediaType(value)
return err == nil && mediaType == evidence.PNGContentType && len(parameters) == 0
}
func writeMultipartError(context *gin.Context, err error) {
var tooLarge *http.MaxBytesError
if errors.As(err, &tooLarge) {
context.Status(http.StatusRequestEntityTooLarge)
return
}
context.Status(http.StatusBadRequest)
}
func writeEvidenceStoreError(context *gin.Context, err error) {
var tooLarge *http.MaxBytesError
switch {
case errors.As(err, &tooLarge):
context.Status(http.StatusRequestEntityTooLarge)
case errors.Is(err, evidence.ErrTooLarge):
context.Status(http.StatusRequestEntityTooLarge)
case errors.Is(err, evidence.ErrInvalid):
context.Status(http.StatusBadRequest)
case errors.Is(err, evidence.ErrConflict):
context.Status(http.StatusConflict)
default:
context.Status(http.StatusInternalServerError)
}
}
+478
View File
@@ -0,0 +1,478 @@
package server_test
import (
"bytes"
"context"
"crypto/sha256"
"database/sql"
"encoding/hex"
"encoding/json"
"image"
"image/png"
"io"
"mime/multipart"
"net/http"
"net/http/httptest"
"net/textproto"
"path/filepath"
"runtime"
"strings"
"testing"
"cmbuyer/admin/internal/deviceauth"
"cmbuyer/admin/internal/evidence"
"cmbuyer/admin/internal/migrations"
evidencestorage "cmbuyer/admin/internal/storage/evidence"
"cmbuyer/admin/internal/storage/sqlite"
)
const (
evidenceTaskID = "63c9f507-7473-4fa6-8d71-8786c34c6301"
evidenceAuthID = "73c9f507-7473-4fa6-8d71-8786c34c6301"
evidenceAttemptID = "83c9f507-7473-4fa6-8d71-8786c34c6301"
evidenceUploadKey = "93c9f507-7473-4fa6-8d71-8786c34c6301"
evidenceDeviceID = "13c9f507-7473-4fa6-8d71-8786c34c6301"
)
func TestEvidenceUploadAuthenticatesBeforeReadingBody(t *testing.T) {
authenticator := &fakeDeviceAuthenticator{}
router, _ := newRouterWithDependencies(t, &memoryStore{}, emptyDetailStore{}, emptyEvidenceStore{}, authenticator)
poison := &poisonBody{}
request := httptest.NewRequest(http.MethodPost, "/api/v1/tasks/"+evidenceTaskID+"/evidence", nil)
request.Body = poison
request.Header.Set("Content-Type", "text/plain")
response := httptest.NewRecorder()
router.ServeHTTP(response, request)
if response.Code != http.StatusUnauthorized || response.Body.Len() != 0 || response.Header().Get("WWW-Authenticate") != "Bearer" || poison.reads != 0 || authenticator.calls != 1 {
t.Fatalf("status/reads/auth calls = %d/%d/%d, want 401/0/1", response.Code, poison.reads, authenticator.calls)
}
assertSecurityHeaders(t, response)
}
func TestEvidenceUploadAuthenticationStorageFailureBeforeReadingBody(t *testing.T) {
database, err := sqlite.Open(filepath.Join(t.TempDir(), "authentication-failure.db"))
if err != nil {
t.Fatalf("open database: %v", err)
}
if err := migrations.Up(context.Background(), database, testMigrationDirectory(t)); err != nil {
t.Fatalf("migrate database: %v", err)
}
authenticator, err := deviceauth.NewSQLiteAuthenticator(database)
if err != nil {
t.Fatalf("new authenticator: %v", err)
}
credentialStore, err := deviceauth.NewCredentialStore(database)
if err != nil {
t.Fatalf("new credential store: %v", err)
}
issued, err := credentialStore.Issue(context.Background(), "test device")
if err != nil {
t.Fatalf("issue credential: %v", err)
}
if err := database.Close(); err != nil {
t.Fatalf("close database: %v", err)
}
router, _ := newRouterWithDependencies(t, &memoryStore{}, emptyDetailStore{}, emptyEvidenceStore{}, authenticator)
poison := &poisonBody{}
request := httptest.NewRequest(http.MethodPost, "/api/v1/tasks/"+evidenceTaskID+"/evidence", nil)
request.Body = poison
request.Header.Set(deviceauth.AuthorizationHeader, "Bearer "+issued.Token)
request.Header.Set(deviceauth.DeviceIDHeader, issued.DeviceID)
response := httptest.NewRecorder()
router.ServeHTTP(response, request)
if response.Code != http.StatusServiceUnavailable || response.Body.Len() != 0 || poison.reads != 0 {
t.Fatalf("storage failure status/body/reads = %d/%q/%d, want 503/empty/0", response.Code, response.Body.String(), poison.reads)
}
}
func TestEvidenceUploadRejectsInvalidSuccessfulPrincipalBeforeReadingBody(t *testing.T) {
router, _ := newRouterWithDependencies(t, &memoryStore{}, emptyDetailStore{}, emptyEvidenceStore{}, uncheckedDeviceAuthenticator{})
poison := &poisonBody{}
request := httptest.NewRequest(http.MethodPost, "/api/v1/tasks/"+evidenceTaskID+"/evidence", nil)
request.Body = poison
response := httptest.NewRecorder()
router.ServeHTTP(response, request)
if response.Code != http.StatusServiceUnavailable || response.Body.Len() != 0 || poison.reads != 0 {
t.Fatalf("invalid principal status/body/reads = %d/%q/%d, want 503/empty/0", response.Code, response.Body.String(), poison.reads)
}
}
func TestAdminSessionCannotActAsDeviceUploader(t *testing.T) {
router, _ := newRouter(t)
cookie := authenticate(t, router)
request := httptest.NewRequest(http.MethodPost, "/api/v1/tasks/"+evidenceTaskID+"/evidence", nil)
request.Body = &poisonBody{}
request.AddCookie(cookie)
response := httptest.NewRecorder()
router.ServeHTTP(response, request)
if response.Code != http.StatusUnauthorized {
t.Fatalf("admin upload status = %d, want 401", response.Code)
}
}
func TestRealDeviceCredentialIdentityIsolationAndMixedCredentials(t *testing.T) {
database, err := sqlite.Open(filepath.Join(t.TempDir(), "identity-isolation.db"))
if err != nil {
t.Fatalf("open database: %v", err)
}
t.Cleanup(func() { _ = database.Close() })
if err := migrations.Up(context.Background(), database, testMigrationDirectory(t)); err != nil {
t.Fatalf("migrate database: %v", err)
}
insertEvidenceAttempt(t, database)
assetStore, err := evidencestorage.NewStore(database, filepath.Join(t.TempDir(), "assets"))
if err != nil {
t.Fatalf("new evidence store: %v", err)
}
credentialStore, err := deviceauth.NewCredentialStore(database)
if err != nil {
t.Fatalf("new credential store: %v", err)
}
issued, err := credentialStore.Issue(context.Background(), "采购工具一号")
if err != nil {
t.Fatalf("issue credential: %v", err)
}
insertEvidenceClaim(t, database, issued.DeviceID)
authenticator, err := deviceauth.NewSQLiteAuthenticator(database)
if err != nil {
t.Fatalf("new authenticator: %v", err)
}
taskStore := &memoryStore{}
router, _ := newRouterWithDependencies(t, taskStore, emptyDetailStore{}, assetStore, authenticator)
addDeviceHeaders := func(request *http.Request) {
request.Header.Set(deviceauth.AuthorizationHeader, "Bearer "+issued.Token)
request.Header.Set(deviceauth.DeviceIDHeader, issued.DeviceID)
}
start := newStartRequest(t, validStartBody(), "application/json", "", nil)
addDeviceHeaders(start)
startResponse := httptest.NewRecorder()
router.ServeHTTP(startResponse, start)
create := httptest.NewRequest(http.MethodPost, "/tasks", strings.NewReader("title=device"))
create.Header.Set("Content-Type", "application/x-www-form-urlencoded")
addDeviceHeaders(create)
createResponse := httptest.NewRecorder()
router.ServeHTTP(createResponse, create)
if startResponse.Code != http.StatusUnauthorized || createResponse.Code != http.StatusUnauthorized || taskStore.startCalls != 0 || len(taskStore.drafts) != 0 {
t.Fatalf("device management isolation = start %d/create %d/calls %d/drafts %d", startResponse.Code, createResponse.Code, taskStore.startCalls, len(taskStore.drafts))
}
adminCookie, csrf := authenticatedStartSession(t, router)
mixedWithoutCSRF := newStartRequest(t, validStartBody(), "application/json", "", adminCookie)
addDeviceHeaders(mixedWithoutCSRF)
mixedWithoutCSRFResponse := httptest.NewRecorder()
router.ServeHTTP(mixedWithoutCSRFResponse, mixedWithoutCSRF)
if mixedWithoutCSRFResponse.Code != http.StatusForbidden || taskStore.startCalls != 0 {
t.Fatalf("mixed request bypassed admin CSRF: status/calls=%d/%d", mixedWithoutCSRFResponse.Code, taskStore.startCalls)
}
mixedAdmin := newStartRequest(t, validStartBody(), "application/json", csrf, adminCookie)
addDeviceHeaders(mixedAdmin)
mixedAdminResponse := httptest.NewRecorder()
router.ServeHTTP(mixedAdminResponse, mixedAdmin)
if mixedAdminResponse.Code != http.StatusBadRequest || taskStore.startCalls != 1 {
t.Fatalf("mixed admin request changed identity domain: status/calls=%d/%d", mixedAdminResponse.Code, taskStore.startCalls)
}
pngBytes := serverTestPNG(t, 3, 2)
upload := newEvidenceUploadRequest(t, evidenceTaskID, validEvidenceFields(pngBytes), pngBytes, evidence.PNGContentType, "raw.png", nil)
addDeviceHeaders(upload)
upload.AddCookie(adminCookie)
uploadResponse := httptest.NewRecorder()
router.ServeHTTP(uploadResponse, upload)
if uploadResponse.Code != http.StatusCreated {
t.Fatalf("mixed upload status/body = %d/%q", uploadResponse.Code, uploadResponse.Body.String())
}
var uploadedBy string
if err := database.QueryRow(`SELECT uploaded_by_device_id FROM evidence_assets`).Scan(&uploadedBy); err != nil || uploadedBy != issued.DeviceID {
t.Fatalf("uploaded principal = %q, err=%v", uploadedBy, err)
}
if _, _, err := credentialStore.Revoke(context.Background(), issued.DeviceID); err != nil {
t.Fatalf("revoke credential: %v", err)
}
revokedUpload := newEvidenceUploadRequest(t, evidenceTaskID, validEvidenceFields(pngBytes), pngBytes, evidence.PNGContentType, "raw.png", nil)
addDeviceHeaders(revokedUpload)
revokedResponse := httptest.NewRecorder()
router.ServeHTTP(revokedResponse, revokedUpload)
if revokedResponse.Code != http.StatusUnauthorized || revokedResponse.Body.Len() != 0 {
t.Fatalf("revoked upload = %d/%q", revokedResponse.Code, revokedResponse.Body.String())
}
}
func TestEvidenceUploadReplayConflictAndProtectedRead(t *testing.T) {
router, database := newEvidenceRouter(t, &fakeDeviceAuthenticator{principal: deviceauth.Principal{ID: evidenceDeviceID}})
pngBytes := serverTestPNG(t, 6, 4)
fields := validEvidenceFields(pngBytes)
first := serveEvidenceUpload(t, router, evidenceTaskID, fields, pngBytes, evidence.PNGContentType, `..\private\original.png`, nil)
if first.Code != http.StatusCreated {
t.Fatalf("first upload status/body = %d/%q", first.Code, first.Body.String())
}
var asset evidence.Asset
if err := json.Unmarshal(first.Body.Bytes(), &asset); err != nil {
t.Fatalf("decode upload response: %v", err)
}
if asset.TaskID != evidenceTaskID || asset.AttemptID != evidenceAttemptID || asset.SHA256 != fields["sha256"] || strings.Contains(first.Body.String(), "private") || strings.Contains(first.Body.String(), "original.png") {
t.Fatalf("unsafe upload response = %s", first.Body.String())
}
replay := serveEvidenceUpload(t, router, evidenceTaskID, fields, pngBytes, evidence.PNGContentType, "again.png", nil)
if replay.Code != http.StatusOK {
t.Fatalf("replay status = %d, want 200", replay.Code)
}
var replayed evidence.Asset
if err := json.Unmarshal(replay.Body.Bytes(), &replayed); err != nil || replayed.ID != asset.ID {
t.Fatalf("replay asset = %#v, err %v", replayed, err)
}
conflicting := copyStringMap(fields)
conflicting["captured_at"] = "2026-08-04T09:01:01Z"
if response := serveEvidenceUpload(t, router, evidenceTaskID, conflicting, pngBytes, evidence.PNGContentType, "same.png", nil); response.Code != http.StatusConflict {
t.Fatalf("conflicting replay status = %d, want 409", response.Code)
}
var count int
if err := database.QueryRow("SELECT COUNT(*) FROM evidence_assets").Scan(&count); err != nil || count != 1 {
t.Fatalf("asset count = %d, err %v", count, err)
}
if response := serve(router, http.MethodGet, "/evidence/"+asset.ID, nil, nil); response.Code != http.StatusUnauthorized || response.Body.Len() != 0 {
t.Fatalf("anonymous read = %d/%q", response.Code, response.Body.String())
}
adminCookie := authenticate(t, router)
read := serve(router, http.MethodGet, "/evidence/"+asset.ID, nil, adminCookie)
if read.Code != http.StatusOK || !bytes.Equal(read.Body.Bytes(), pngBytes) {
t.Fatalf("admin read = %d, bytes equal %t", read.Code, bytes.Equal(read.Body.Bytes(), pngBytes))
}
for header, want := range map[string]string{"Content-Type": "image/png", "Cache-Control": "no-store", "X-Content-Type-Options": "nosniff", "Content-Disposition": `inline; filename="evidence.png"`} {
if got := read.Header().Get(header); got != want {
t.Fatalf("%s = %q, want %q", header, got, want)
}
}
missing := serve(router, http.MethodGet, "/evidence/not-a-uuid", nil, adminCookie)
if missing.Code != http.StatusNotFound || missing.Body.Len() != 0 {
t.Fatalf("missing evidence = %d/%q", missing.Code, missing.Body.String())
}
}
func TestEvidenceUploadRejectsStrictMultipartViolations(t *testing.T) {
router, database := newEvidenceRouter(t, &fakeDeviceAuthenticator{principal: deviceauth.Principal{ID: evidenceDeviceID}})
pngBytes := serverTestPNG(t, 2, 2)
base := validEvidenceFields(pngBytes)
wrongHash := copyStringMap(base)
wrongHash["sha256"] = strings.Repeat("b", 64)
uppercaseHash := copyStringMap(base)
uppercaseHash["sha256"] = strings.ToUpper(uppercaseHash["sha256"])
wrongPrivacy := copyStringMap(base)
wrongPrivacy["privacy_tier"] = "PUBLIC"
wrongKind := copyStringMap(base)
wrongKind["kind"] = "ORDER_CONFIRM"
tests := []struct {
name string
fields map[string]string
file []byte
contentType string
extra func(*multipart.Writer) error
want int
}{
{name: "attempt belongs to another task", fields: base, file: pngBytes, contentType: evidence.PNGContentType, want: http.StatusBadRequest},
{name: "xml file", fields: base, file: []byte("<hierarchy/>"), contentType: evidence.PNGContentType, want: http.StatusBadRequest},
{name: "wrong hash", fields: wrongHash, file: pngBytes, contentType: evidence.PNGContentType, want: http.StatusBadRequest},
{name: "uppercase hash", fields: uppercaseHash, file: pngBytes, contentType: evidence.PNGContentType, want: http.StatusBadRequest},
{name: "wrong privacy", fields: wrongPrivacy, file: pngBytes, contentType: evidence.PNGContentType, want: http.StatusBadRequest},
{name: "unapproved kind", fields: wrongKind, file: pngBytes, contentType: evidence.PNGContentType, want: http.StatusBadRequest},
{name: "too large", fields: base, file: make([]byte, evidence.MaxFileBytes+1), contentType: evidence.PNGContentType, want: http.StatusRequestEntityTooLarge},
{name: "wrong file content type", fields: base, file: pngBytes, contentType: "application/xml", want: http.StatusUnsupportedMediaType},
{name: "unknown path field", fields: base, file: pngBytes, contentType: evidence.PNGContentType, extra: func(writer *multipart.Writer) error { return writer.WriteField("path", `C:\secret.xml`) }, want: http.StatusBadRequest},
{name: "duplicate metadata", fields: base, file: pngBytes, contentType: evidence.PNGContentType, extra: func(writer *multipart.Writer) error { return writer.WriteField("sha256", base["sha256"]) }, want: http.StatusBadRequest},
{name: "second file", fields: base, file: pngBytes, contentType: evidence.PNGContentType, extra: func(writer *multipart.Writer) error {
part, err := writer.CreateFormFile("file", "second.png")
if err == nil {
_, err = part.Write(pngBytes)
}
return err
}, want: http.StatusUnsupportedMediaType},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
taskID := evidenceTaskID
if test.name == "attempt belongs to another task" {
taskID = "a3c9f507-7473-4fa6-8d71-8786c34c6301"
}
response := serveEvidenceUpload(t, router, taskID, copyStringMap(test.fields), test.file, test.contentType, "file.png", test.extra)
if response.Code != test.want || response.Body.Len() != 0 {
t.Fatalf("status/body = %d/%q, want %d/empty", response.Code, response.Body.String(), test.want)
}
})
}
var count int
if err := database.QueryRow("SELECT COUNT(*) FROM evidence_assets").Scan(&count); err != nil || count != 0 {
t.Fatalf("invalid requests created %d assets, err %v", count, err)
}
}
type fakeDeviceAuthenticator struct {
principal deviceauth.Principal
err error
calls int
}
func (authenticator *fakeDeviceAuthenticator) Authenticate(*http.Request) (deviceauth.Principal, error) {
authenticator.calls++
if authenticator.err != nil {
return deviceauth.Principal{}, authenticator.err
}
if authenticator.principal.ID == "" {
return deviceauth.Principal{}, deviceauth.ErrUnauthenticated
}
return authenticator.principal, nil
}
type poisonBody struct{ reads int }
type uncheckedDeviceAuthenticator struct{}
func (uncheckedDeviceAuthenticator) Authenticate(*http.Request) (deviceauth.Principal, error) {
return deviceauth.Principal{}, nil
}
func (body *poisonBody) Read([]byte) (int, error) {
body.reads++
return 0, io.ErrUnexpectedEOF
}
func (*poisonBody) Close() error { return nil }
func newEvidenceRouter(t *testing.T, authenticator deviceauth.Authenticator) (http.Handler, *sql.DB) {
t.Helper()
database, err := sqlite.Open(filepath.Join(t.TempDir(), "server-evidence.db"))
if err != nil {
t.Fatalf("open database: %v", err)
}
t.Cleanup(func() { _ = database.Close() })
if err := migrations.Up(context.Background(), database, testMigrationDirectory(t)); err != nil {
t.Fatalf("migrate database: %v", err)
}
insertEvidenceAttempt(t, database)
insertEvidenceClaimDevice(t, database, evidenceDeviceID)
insertEvidenceClaim(t, database, evidenceDeviceID)
store, err := evidencestorage.NewStore(database, filepath.Join(t.TempDir(), "assets"))
if err != nil {
t.Fatalf("new evidence store: %v", err)
}
router, _ := newRouterWithDependencies(t, &memoryStore{}, emptyDetailStore{}, store, authenticator)
return router, database
}
func testMigrationDirectory(t *testing.T) string {
t.Helper()
_, file, _, ok := runtime.Caller(0)
if !ok {
t.Fatal("locate migration directory")
}
return filepath.Join(filepath.Dir(file), "..", "..", "migrations")
}
func insertEvidenceAttempt(t *testing.T, database *sql.DB) {
t.Helper()
timestamp := "2026-08-04T00:00:00Z"
if _, err := database.Exec(`INSERT INTO tasks (id, source, title, goods_id, sku_color, sku_size, quantity, max_total_price, status, version, created_at, updated_at) VALUES (?, 'MANUAL', 'task', '123', 'black', 'M', 1, '1.00', 'DRAFT', 1, ?, ?)`, evidenceTaskID, timestamp, timestamp); err != nil {
t.Fatalf("insert task: %v", err)
}
if _, err := database.Exec(`INSERT INTO order_authorizations (id, task_id, task_version, start_key, goods_id, sku_color, sku_size, quantity, total_price_cap, status, created_by, created_at, expires_at) VALUES (?, ?, 1, 'start', '123', 'black', 'M', 1, '1.00', 'ACTIVE', 'admin', ?, ?)`, evidenceAuthID, evidenceTaskID, timestamp, timestamp); err != nil {
t.Fatalf("insert authorization: %v", err)
}
if _, err := database.Exec(`INSERT INTO purchase_attempts (id, task_id, authorization_id, claim_generation, status, started_at) VALUES (?, ?, ?, 1, 'CLAIMED', ?)`, evidenceAttemptID, evidenceTaskID, evidenceAuthID, timestamp); err != nil {
t.Fatalf("insert attempt: %v", err)
}
}
func insertEvidenceClaimDevice(t *testing.T, database *sql.DB, deviceID string) {
t.Helper()
digest := sha256.Sum256([]byte("fake evidence device"))
if _, err := database.Exec(`INSERT INTO device_credentials
(device_id,display_name,token_sha256,status,created_at,revoked_at)
VALUES (?, 'fake evidence device', ?, 'ACTIVE', '2026-08-04T00:00:00Z', NULL)`, deviceID, digest[:]); err != nil {
t.Fatalf("insert evidence device: %v", err)
}
}
func insertEvidenceClaim(t *testing.T, database *sql.DB, deviceID string) {
t.Helper()
if _, err := database.Exec(`INSERT INTO purchase_attempt_claims
(attempt_id,task_id,authorization_id,claimed_by_device_id,session_id,claim_generation,
task_version,task_title,authorization_task_version,goods_id,sku_color,sku_size,quantity,
total_price_cap,authorization_expires_at,claim_nonce,claim_token_sha256,lease_expires_at,claimed_at,closed_at)
VALUES (?, ?, ?, ?, '23c9f507-7473-4fa6-8d71-8786c34c6301', 1, 1, 'task',
1, '123', 'black', 'M', 1, '1.00', '2026-08-04T00:00:00Z', ?, ?,
'2026-08-04T02:00:00Z', '2026-08-04T00:00:00Z', NULL)`, evidenceAttemptID,
evidenceTaskID, evidenceAuthID, deviceID, bytes.Repeat([]byte{1}, 32), bytes.Repeat([]byte{2}, 32)); err != nil {
t.Fatalf("insert evidence claim: %v", err)
}
}
func serveEvidenceUpload(t *testing.T, router http.Handler, taskID string, fields map[string]string, file []byte, fileContentType, filename string, extra func(*multipart.Writer) error) *httptest.ResponseRecorder {
t.Helper()
request := newEvidenceUploadRequest(t, taskID, fields, file, fileContentType, filename, extra)
response := httptest.NewRecorder()
router.ServeHTTP(response, request)
return response
}
func newEvidenceUploadRequest(t *testing.T, taskID string, fields map[string]string, file []byte, fileContentType, filename string, extra func(*multipart.Writer) error) *http.Request {
t.Helper()
var body bytes.Buffer
writer := multipart.NewWriter(&body)
for _, name := range []string{"upload_key", "attempt_id", "kind", "privacy_tier", "sha256", "captured_at"} {
if err := writer.WriteField(name, fields[name]); err != nil {
t.Fatalf("write field %s: %v", name, err)
}
}
header := make(textproto.MIMEHeader)
header.Set("Content-Disposition", `form-data; name="file"; filename="`+filename+`"`)
header.Set("Content-Type", fileContentType)
part, err := writer.CreatePart(header)
if err != nil {
t.Fatalf("create file part: %v", err)
}
if _, err := part.Write(file); err != nil {
t.Fatalf("write file: %v", err)
}
if extra != nil {
if err := extra(writer); err != nil {
t.Fatalf("write extra part: %v", err)
}
}
if err := writer.Close(); err != nil {
t.Fatalf("close multipart: %v", err)
}
request := httptest.NewRequest(http.MethodPost, "/api/v1/tasks/"+taskID+"/evidence", bytes.NewReader(body.Bytes()))
request.Header.Set("Content-Type", writer.FormDataContentType())
return request
}
func validEvidenceFields(pngBytes []byte) map[string]string {
hash := sha256.Sum256(pngBytes)
return map[string]string{
"upload_key": evidenceUploadKey, "attempt_id": evidenceAttemptID,
"kind": evidence.KindSKUPanelGate1, "privacy_tier": evidence.PrivacyInternalRaw,
"sha256": hex.EncodeToString(hash[:]), "captured_at": "2026-08-04T09:01:00Z",
}
}
func serverTestPNG(t *testing.T, width, height int) []byte {
t.Helper()
var buffer bytes.Buffer
if err := png.Encode(&buffer, image.NewNRGBA(image.Rect(0, 0, width, height))); err != nil {
t.Fatalf("encode PNG: %v", err)
}
return buffer.Bytes()
}
func copyStringMap(values map[string]string) map[string]string {
copy := make(map[string]string, len(values))
for key, value := range values {
copy[key] = value
}
return copy
}
+440
View File
@@ -0,0 +1,440 @@
// Package server 定义采购服务当前拥有的 HTTP 端点。
package server
import (
"bytes"
"crypto/subtle"
"encoding/json"
"errors"
"io"
"mime"
"net/http"
"net/url"
"strings"
"unicode/utf8"
"cmbuyer/admin/internal/auth"
"cmbuyer/admin/internal/deviceauth"
"cmbuyer/admin/internal/evidence"
"cmbuyer/admin/internal/taskclaim"
"cmbuyer/admin/internal/taskdetail"
"cmbuyer/admin/internal/tasks"
"cmbuyer/admin/internal/transport/webui"
"github.com/gin-gonic/gin"
"golang.org/x/crypto/bcrypt"
)
const maxFormBytes = 8 << 10
const maxJSONBytes = 64 << 10
// Options 是路由层需要的安全依赖。凭据由启动配置注入,不能在路由中设置默认值。
type Options struct {
AdminUsername string
AdminPasswordBcrypt string
Sessions *auth.Manager
Tasks tasks.Store
TaskDetails taskdetail.Store
Evidence evidence.Store
DeviceAuthenticator deviceauth.Authenticator
TaskClaims taskclaim.Service
}
// NewRouter 返回当前服务范围内的完整 HTTP 路由。
func NewRouter(options Options) (*gin.Engine, error) {
if options.AdminUsername == "" || options.AdminPasswordBcrypt == "" || options.Sessions == nil || options.Tasks == nil || options.TaskDetails == nil || options.Evidence == nil || options.DeviceAuthenticator == nil || options.TaskClaims == nil {
return nil, errors.New("server authentication options are incomplete")
}
router := gin.New()
router.Use(gin.Recovery())
router.Use(securityHeaders())
router.GET("/healthz", healthz)
router.GET("/login", loginPage(options))
router.POST("/login", login(options))
router.POST("/logout", logout(options))
router.GET("/tasks", tasksPage(options))
router.GET("/tasks/:id", taskDetailPage(options))
router.GET("/tasks/new", newTaskPage(options))
router.POST("/tasks", createTask(options))
router.POST("/tasks/start-purchases", startPurchases(options))
router.POST("/api/v1/tasks/:id/evidence", uploadEvidence(options))
router.POST("/api/v1/tasks/claim-next", claimNext(options))
router.POST("/api/v1/tasks/:id/lease/renew", renewLease(options))
router.GET("/evidence/:asset_id", readEvidence(options))
router.GET("/static/tasks.js", func(context *gin.Context) {
context.Data(http.StatusOK, "application/javascript; charset=utf-8", webui.TasksScript())
})
return router, nil
}
func startPurchases(options Options) gin.HandlerFunc {
return func(context *gin.Context) {
if !options.Sessions.IsAuthenticated(context.Request) {
context.Status(http.StatusUnauthorized)
return
}
authenticated, csrfOK := options.Sessions.VerifyCSRF(context.Request, context.GetHeader("X-CSRF-Token"))
if !authenticated || !csrfOK {
context.Status(http.StatusForbidden)
return
}
if !isJSONContentType(context.GetHeader("Content-Type")) {
context.Status(http.StatusUnsupportedMediaType)
return
}
context.Request.Body = http.MaxBytesReader(context.Writer, context.Request.Body, maxJSONBytes)
raw, err := io.ReadAll(context.Request.Body)
if err != nil {
var tooLarge *http.MaxBytesError
if errors.As(err, &tooLarge) {
context.Status(http.StatusRequestEntityTooLarge)
} else {
context.Status(http.StatusBadRequest)
}
return
}
if !utf8.Valid(raw) {
context.Status(http.StatusBadRequest)
return
}
decoder := json.NewDecoder(bytes.NewReader(raw))
decoder.DisallowUnknownFields()
var command tasks.StartCommand
if err := decoder.Decode(&command); err != nil {
context.Status(http.StatusBadRequest)
return
}
var extra any
if err := decoder.Decode(&extra); err != io.EOF {
context.Status(http.StatusBadRequest)
return
}
result, err := options.Tasks.StartPurchases(context.Request.Context(), command, options.AdminUsername)
if err != nil {
if errors.Is(err, tasks.ErrInvalidStart) {
context.Status(http.StatusBadRequest)
} else if errors.Is(err, tasks.ErrStartConflict) {
context.Status(http.StatusConflict)
} else {
context.Status(http.StatusInternalServerError)
}
return
}
context.JSON(http.StatusOK, result)
}
}
func isJSONContentType(value string) bool {
mediaType, parameters, err := mime.ParseMediaType(value)
if err != nil || mediaType != "application/json" {
return false
}
for name, value := range parameters {
if name != "charset" || !strings.EqualFold(value, "utf-8") {
return false
}
}
return true
}
func healthz(context *gin.Context) {
context.JSON(http.StatusOK, gin.H{"status": "ok"})
}
func securityHeaders() gin.HandlerFunc {
return func(context *gin.Context) {
context.Header("Cache-Control", "no-store")
context.Header("X-Content-Type-Options", "nosniff")
context.Header("Referrer-Policy", "no-referrer")
context.Header("Content-Security-Policy", "default-src 'self'; style-src 'self' 'unsafe-inline'; script-src 'self'; object-src 'none'; base-uri 'none'; frame-ancestors 'none'; form-action 'self'")
context.Next()
}
}
func loginPage(options Options) gin.HandlerFunc {
return func(context *gin.Context) {
csrfToken, authenticated := options.Sessions.Ensure(context.Writer, context.Request)
if authenticated {
context.Redirect(http.StatusSeeOther, "/tasks")
return
}
renderLogin(context, http.StatusOK, csrfToken, returnTo(context.Query("return_to")), "", "")
}
}
func login(options Options) gin.HandlerFunc {
return func(context *gin.Context) {
if !parseForm(context) {
return
}
form := context.Request.PostForm
csrfToken := form.Get("csrf_token")
returnPath := returnTo(form.Get("return_to"))
username := form.Get("username")
password := form.Get("password")
if _, ok := options.Sessions.VerifyCSRF(context.Request, csrfToken); !ok {
newCSRF, _ := options.Sessions.Ensure(context.Writer, context.Request)
renderLogin(context, http.StatusForbidden, newCSRF, returnPath, "", "请求已过期,请重新登录。")
return
}
usernameMatches := subtle.ConstantTimeCompare([]byte(options.AdminUsername), []byte(username)) == 1
passwordMatches := bcrypt.CompareHashAndPassword([]byte(options.AdminPasswordBcrypt), []byte(password)) == nil
if !usernameMatches || !passwordMatches {
csrf, _ := options.Sessions.Ensure(context.Writer, context.Request)
renderLogin(context, http.StatusUnauthorized, csrf, returnPath, "", "账号或密码不正确,请检查后重试。")
return
}
options.Sessions.RotateAuthenticated(context.Writer, context.Request)
context.Redirect(http.StatusSeeOther, returnPath)
}
}
func logout(options Options) gin.HandlerFunc {
return func(context *gin.Context) {
if !parseForm(context) {
return
}
authenticated, ok := options.Sessions.VerifyCSRF(context.Request, context.Request.PostForm.Get("csrf_token"))
if !ok || !authenticated {
context.Status(http.StatusForbidden)
return
}
options.Sessions.Logout(context.Writer, context.Request)
context.Redirect(http.StatusSeeOther, "/login")
}
}
func tasksPage(options Options) gin.HandlerFunc {
return func(context *gin.Context) {
csrfToken, authenticated := options.Sessions.Ensure(context.Writer, context.Request)
if !authenticated {
context.Redirect(http.StatusSeeOther, "/login?return_to="+url.QueryEscape(context.Request.URL.RequestURI()))
return
}
filter := tasks.TaskFilter{Keyword: context.Query("keyword"), Status: context.Query("status"), CreatedFrom: context.Query("created_from"), CreatedTo: context.Query("created_to")}
if validation := tasks.ValidateTaskFilter(filter); !validation.Valid() {
startKey, err := tasks.NewCreateKey()
if err != nil {
context.Status(http.StatusInternalServerError)
return
}
renderTasks(context, http.StatusBadRequest, webui.TasksData{CSRFToken: csrfToken, Filter: filter, FilterErrors: validation, HasFilter: true, StartKey: startKey})
return
}
data, err := taskListData(context, options, csrfToken, filter)
if err != nil {
context.Status(http.StatusInternalServerError)
return
}
for _, row := range data.Tasks {
if row.ID == context.Query("created") {
data.Success = true
break
}
}
if context.Query("create") == "1" {
form, err := newTaskForm()
if err != nil {
context.Status(http.StatusInternalServerError)
return
}
data.OpenForm = true
data.Form = form
data.FocusField = "title"
}
renderTasks(context, http.StatusOK, data)
}
}
func newTaskPage(options Options) gin.HandlerFunc {
return func(context *gin.Context) {
csrf, authenticated := options.Sessions.Ensure(context.Writer, context.Request)
if !authenticated {
context.Redirect(http.StatusSeeOther, "/login?return_to=%2Ftasks%2Fnew")
return
}
form, err := newTaskForm()
if err != nil {
context.Status(http.StatusInternalServerError)
return
}
renderTasks(context, http.StatusOK, webui.TasksData{CSRFToken: csrf, Form: form, FullPage: true, FocusField: "title"})
}
}
func createTask(options Options) gin.HandlerFunc {
return func(context *gin.Context) {
if !options.Sessions.IsAuthenticated(context.Request) {
context.Status(http.StatusUnauthorized)
return
}
if !parseForm(context) {
return
}
requestForm := context.Request.PostForm
authenticated, csrfOK := options.Sessions.VerifyCSRF(context.Request, requestForm.Get("csrf_token"))
if !csrfOK || !authenticated {
context.Status(http.StatusForbidden)
return
}
form := taskForm(requestForm)
draft, validation := tasks.Validate(form)
if draft.GoodsID != "" {
form.ProductURL = tasks.CanonicalURL(draft.GoodsID)
}
fullPage := requestForm.Get("form_mode") == "full"
if !validation.Valid() {
data, ok := createErrorData(context, options, fullPage)
if !ok {
return
}
data.Form = form
data.Errors = validation
data.OpenForm = !fullPage
data.FullPage = fullPage
data.FocusField = firstError(validation)
renderTasks(context, http.StatusBadRequest, data)
return
}
created, err := options.Tasks.CreateDraft(context.Request.Context(), draft)
if err != nil {
if errors.Is(err, tasks.ErrCreateKeyConflict) {
validation["create_key"] = "该创建请求已用于另一条任务,请重新打开表单。"
data, ok := createErrorData(context, options, fullPage)
if !ok {
return
}
data.Form = form
data.Errors = validation
data.OpenForm = !fullPage
data.FullPage = fullPage
data.FocusField = firstError(validation)
renderTasks(context, http.StatusConflict, data)
return
}
context.Status(http.StatusInternalServerError)
return
}
context.Redirect(http.StatusSeeOther, "/tasks?created="+url.QueryEscape(created.ID))
}
}
func newTaskForm() (tasks.Form, error) {
key, err := tasks.NewCreateKey()
if err != nil {
return tasks.Form{}, err
}
return tasks.Form{CreateKey: key}, nil
}
func taskForm(form url.Values) tasks.Form {
return tasks.Form{CreateKey: form.Get("create_key"), Title: form.Get("title"), ProductURL: form.Get("product_url"), SKUColor: form.Get("sku_color"), SKUSize: form.Get("sku_size"), Quantity: form.Get("quantity"), MaxTotalPrice: form.Get("max_total_price")}
}
func csrfFor(context *gin.Context, options Options) string {
csrf, _ := options.Sessions.Ensure(context.Writer, context.Request)
return csrf
}
func taskListData(context *gin.Context, options Options, csrfToken string, filter tasks.TaskFilter) (webui.TasksData, error) {
rows, err := options.Tasks.ListTasks(context.Request.Context(), filter)
if err != nil {
return webui.TasksData{}, err
}
startKey, err := tasks.NewCreateKey()
if err != nil {
return webui.TasksData{}, err
}
return webui.TasksData{
CSRFToken: csrfToken,
Tasks: rows,
Filter: filter,
HasFilter: filter.Keyword != "" || filter.Status != "" || filter.CreatedFrom != "" || filter.CreatedTo != "",
StartKey: startKey,
}, nil
}
func createErrorData(context *gin.Context, options Options, fullPage bool) (webui.TasksData, bool) {
csrfToken := csrfFor(context, options)
if fullPage {
return webui.TasksData{CSRFToken: csrfToken}, true
}
data, err := taskListData(context, options, csrfToken, tasks.TaskFilter{})
if err != nil {
context.Status(http.StatusInternalServerError)
return webui.TasksData{}, false
}
return data, true
}
func renderTasks(context *gin.Context, status int, data webui.TasksData) {
context.Header("Content-Type", "text/html; charset=utf-8")
context.Status(status)
if err := webui.RenderTasks(context.Writer, data); err != nil {
_ = context.Error(err)
}
}
func renderLogin(context *gin.Context, status int, csrfToken, returnPath, username, message string) {
context.Header("Content-Type", "text/html; charset=utf-8")
context.Status(status)
if err := webui.RenderLogin(context.Writer, webui.LoginData{
CSRFToken: csrfToken,
ReturnTo: returnPath,
Username: username,
Error: message,
}); err != nil {
_ = context.Error(err)
}
}
func parseForm(context *gin.Context) bool {
context.Request.Body = http.MaxBytesReader(context.Writer, context.Request.Body, maxFormBytes)
if err := context.Request.ParseForm(); err != nil {
var tooLarge *http.MaxBytesError
if errors.As(err, &tooLarge) {
context.Status(http.StatusRequestEntityTooLarge)
} else {
context.Status(http.StatusBadRequest)
}
return false
}
return true
}
func firstError(validation tasks.Errors) string {
for _, field := range []string{"title", "product_url", "sku_color", "sku_size", "quantity", "max_total_price"} {
if _, ok := validation[field]; ok {
return field
}
}
return "title"
}
func returnTo(value string) string {
if value == "/tasks" || strings.HasPrefix(value, "/tasks/") || strings.HasPrefix(value, "/tasks?") {
if strings.Contains(value, "\\") || strings.Contains(value, "%") || strings.HasPrefix(value, "//") {
return "/tasks"
}
parsed, err := url.ParseRequestURI(value)
if err == nil && parsed.IsAbs() == false && parsed.Host == "" && hasSafeTaskPath(parsed.Path) {
return value
}
}
return "/tasks"
}
func hasSafeTaskPath(path string) bool {
for _, segment := range strings.Split(path, "/") {
if segment == "." || segment == ".." {
return false
}
}
return true
}
+653
View File
@@ -0,0 +1,653 @@
package server_test
import (
"context"
"io"
"net/http"
"net/http/httptest"
"net/url"
"regexp"
"strings"
"testing"
"time"
"cmbuyer/admin/internal/auth"
"cmbuyer/admin/internal/deviceauth"
"cmbuyer/admin/internal/evidence"
"cmbuyer/admin/internal/server"
"cmbuyer/admin/internal/taskclaim"
"cmbuyer/admin/internal/taskdetail"
"cmbuyer/admin/internal/tasks"
"github.com/gin-gonic/gin"
"golang.org/x/crypto/bcrypt"
)
var csrfPattern = regexp.MustCompile(`name="csrf_token" value="([^"]+)"`)
var createKeyPattern = regexp.MustCompile(`name="create_key" value="([^"]+)"`)
func TestHealthzIsPublic(t *testing.T) {
router, _ := newRouter(t)
request := httptest.NewRequest(http.MethodGet, "/healthz", nil)
response := httptest.NewRecorder()
router.ServeHTTP(response, request)
if response.Code != http.StatusOK {
t.Fatalf("healthz status = %d, want %d", response.Code, http.StatusOK)
}
if contentType := response.Header().Get("Content-Type"); contentType != "application/json; charset=utf-8" {
t.Fatalf("healthz content type = %q, want application/json; charset=utf-8", contentType)
}
if body := response.Body.String(); body != "{\"status\":\"ok\"}" {
t.Fatalf("healthz body = %q, want {\"status\":\"ok\"}", body)
}
assertSecurityHeaders(t, response)
}
func TestTasksRequiresLoginAndBlocksOpenRedirects(t *testing.T) {
router, _ := newRouter(t)
tasks := serve(router, http.MethodGet, "/tasks", nil, nil)
if tasks.Code != http.StatusSeeOther {
t.Fatalf("GET /tasks status = %d, want %d", tasks.Code, http.StatusSeeOther)
}
if location := tasks.Header().Get("Location"); location != "/login?return_to=%2Ftasks" {
t.Fatalf("GET /tasks location = %q, want login return path", location)
}
for _, target := range []string{"https://example.invalid", "//example.invalid", `\\example.invalid`, "/other", "/tasks/..", "/tasks/../other", "/tasks/%2e%2e", "%2F%2Fevil.invalid", "%252F%252Fevil.invalid"} {
response := serve(router, http.MethodGet, "/login?return_to="+url.QueryEscape(target), nil, nil)
if response.Code != http.StatusOK {
t.Fatalf("GET /login return_to=%q status = %d, want 200", target, response.Code)
}
if strings.Contains(response.Body.String(), target) || !strings.Contains(response.Body.String(), `name="return_to" value="/tasks"`) {
t.Fatalf("GET /login accepted unsafe return_to %q", target)
}
}
encodedPath := serve(router, http.MethodGet, "/login?return_to=%2Ftasks%252F..", nil, nil)
if !strings.Contains(encodedPath.Body.String(), `name="return_to" value="/tasks"`) {
t.Fatal("encoded parent path was accepted as return_to")
}
encodedQuery := serve(router, http.MethodGet, "/login?return_to=%2Ftasks%3Fnext%3D%252Ftasks%252F..", nil, nil)
if !strings.Contains(encodedQuery.Body.String(), `name="return_to" value="/tasks"`) {
t.Fatal("encoded query bypass was accepted as return_to")
}
}
func TestLoginRotatesSessionAndCSRF(t *testing.T) {
router, _ := newRouter(t)
initial := serve(router, http.MethodGet, "/login?return_to=%2Ftasks%3Fview%3Dmine", nil, nil)
oldCookie := sessionCookie(t, initial)
oldCSRF := csrfToken(t, initial.Body.String())
login := serve(router, http.MethodPost, "/login", url.Values{
"csrf_token": {oldCSRF},
"return_to": {"/tasks?view=mine"},
"username": {"admin"},
"password": {"test-password"},
}, oldCookie)
if login.Code != http.StatusSeeOther || login.Header().Get("Location") != "/tasks?view=mine" {
t.Fatalf("successful login = (%d, %q), want 303 /tasks?view=mine", login.Code, login.Header().Get("Location"))
}
newCookie := sessionCookie(t, login)
if newCookie.Value == oldCookie.Value {
t.Fatal("successful login reused the anonymous session cookie")
}
tasks := serve(router, http.MethodGet, "/tasks", nil, newCookie)
if tasks.Code != http.StatusOK {
t.Fatalf("GET /tasks after login status = %d, want 200", tasks.Code)
}
if newCSRF := csrfToken(t, tasks.Body.String()); newCSRF == oldCSRF {
t.Fatal("successful login reused the anonymous CSRF token")
}
for _, forbidden := range []string{"建单", "试选", "拼多多", "规格", "单价", "证据"} {
if strings.Contains(tasks.Body.String(), forbidden) {
t.Fatalf("task shell must not expose deferred feature content %q", forbidden)
}
}
assertSecurityHeaders(t, initial)
assertSecurityHeaders(t, tasks)
}
func TestLoginPageIncludesAccessibleFormBasics(t *testing.T) {
router, _ := newRouter(t)
page := serve(router, http.MethodGet, "/login", nil, nil)
body := page.Body.String()
for _, want := range []string{
`<label for="username">`,
`<label for="password">`,
`autocomplete="username"`,
`autocomplete="current-password"`,
`min-height:44px`,
`:focus-visible`,
`prefers-reduced-motion`,
} {
if !strings.Contains(body, want) {
t.Fatalf("login page is missing %q", want)
}
}
if strings.Contains(body, "http://") || strings.Contains(body, "https://") || strings.Contains(body, "<script") {
t.Fatal("login page must not load external resources or require client-side JavaScript")
}
failure := serve(router, http.MethodPost, "/login", url.Values{
"csrf_token": {csrfToken(t, body)},
"username": {"admin"},
"password": {"wrong"},
}, sessionCookie(t, page))
if !strings.Contains(failure.Body.String(), `role="alert"`) {
t.Fatal("login failure must announce its error")
}
}
func TestLoginCSRFAndCredentialFailuresAreSafe(t *testing.T) {
router, _ := newRouter(t)
page := serve(router, http.MethodGet, "/login", nil, nil)
cookie := sessionCookie(t, page)
withoutCSRF := serve(router, http.MethodPost, "/login", url.Values{
"username": {"admin"},
"password": {"test-password"},
}, cookie)
if withoutCSRF.Code != http.StatusForbidden || !strings.Contains(withoutCSRF.Body.String(), "请求已过期") {
t.Fatalf("login without CSRF = (%d, %q), want rejected form", withoutCSRF.Code, withoutCSRF.Body.String())
}
page = serve(router, http.MethodGet, "/login", nil, cookie)
badCredentials := serve(router, http.MethodPost, "/login", url.Values{
"csrf_token": {csrfToken(t, page.Body.String())},
"username": {"unknown"},
"password": {"wrong"},
}, cookie)
if badCredentials.Code != http.StatusUnauthorized {
t.Fatalf("login with invalid credentials status = %d, want 401", badCredentials.Code)
}
if body := badCredentials.Body.String(); !strings.Contains(body, "账号或密码不正确") || strings.Contains(body, "unknown") {
t.Fatalf("invalid login leaked account detail: %q", body)
}
}
func TestTamperedCookieCannotAccessTasks(t *testing.T) {
router, _ := newRouter(t)
page := serve(router, http.MethodGet, "/login", nil, nil)
cookie := sessionCookie(t, page)
tampered := *cookie
tampered.Value = flipCookieValue(t, cookie.Value)
response := serve(router, http.MethodGet, "/tasks", nil, &tampered)
if response.Code != http.StatusSeeOther {
t.Fatalf("tampered cookie status = %d, want 303", response.Code)
}
}
func TestTaskCreationRendersSharedFormsAndPersistsOnlyDraft(t *testing.T) {
router, _ := newRouter(t)
cookie := authenticate(t, router)
modal := serve(router, http.MethodGet, "/tasks?create=1", nil, cookie)
if modal.Code != http.StatusOK {
t.Fatalf("GET dialog form status = %d, want 200", modal.Code)
}
fullPage := serve(router, http.MethodGet, "/tasks/new", nil, cookie)
if fullPage.Code != http.StatusOK {
t.Fatalf("GET full form status = %d, want 200", fullPage.Code)
}
for _, want := range []string{`<div class="modal-scrim"`, `<dialog open`, `aria-modal="true"`, `name="title"`, `name="product_url"`, `name="sku_color"`, `name="sku_size"`, `name="quantity"`, `name="max_total_price"`, `type="url" inputmode="url" maxlength="2048"`, `type="number" inputmode="numeric" min="1" step="1"`, `inputmode="decimal" pattern="[0-9]+(\.[0-9]{1,2})?"`, `maxlength="120"`, `maxlength="80"`, `required`, `autofocus`, `导入</button><a class="button primary"`, `type="search"`, `data-start-purchases`, `data-select-all`, `最高总额`, `min-height:44px`, `:focus-visible`, `overflow-x:auto`, `prefers-reduced-motion`} {
if !strings.Contains(modal.Body.String(), want) {
t.Fatalf("dialog form is missing %q", want)
}
}
for _, want := range []string{`name="title"`, `name="product_url"`, `name="sku_color"`, `name="sku_size"`, `name="quantity"`, `name="max_total_price"`, `name="form_mode" value="full"`} {
if !strings.Contains(fullPage.Body.String(), want) {
t.Fatalf("full-page form is missing %q", want)
}
}
invalid := serve(router, http.MethodPost, "/tasks", url.Values{
"csrf_token": {csrfToken(t, modal.Body.String())},
"create_key": {createKey(t, modal.Body.String())},
"title": {`<script>alert(1)</script>`},
"product_url": {"https://mobile.yangkeduo.com/goods.html?goods_id=937122477375&uin=discard"},
"sku_color": {"black"},
"sku_size": {"M"},
"quantity": {"0"},
"max_total_price": {"12.80"},
"form_mode": {"dialog"},
}, cookie)
if invalid.Code != http.StatusBadRequest || !strings.Contains(invalid.Body.String(), `<dialog open`) || !strings.Contains(invalid.Body.String(), "数量必须是正整数") || !strings.Contains(invalid.Body.String(), `role="alert"`) || !strings.Contains(invalid.Body.String(), `href="#quantity"`) || !strings.Contains(invalid.Body.String(), `aria-describedby="quantity-error"`) || !strings.Contains(invalid.Body.String(), `autofocus`) {
t.Fatalf("invalid create = (%d, %q), want dialog validation response", invalid.Code, invalid.Body.String())
}
if strings.Contains(invalid.Body.String(), `<script>alert(1)</script>`) || !strings.Contains(invalid.Body.String(), `&lt;script&gt;alert(1)&lt;/script&gt;`) {
t.Fatalf("invalid create did not safely preserve title: %q", invalid.Body.String())
}
if strings.Contains(invalid.Body.String(), "uin=discard") || !strings.Contains(invalid.Body.String(), `value="https://mobile.yangkeduo.com/goods.html?goods_id=937122477375"`) {
t.Fatalf("invalid create did not canonicalize product URL: %q", invalid.Body.String())
}
createPage := serve(router, http.MethodGet, "/tasks?create=1", nil, cookie)
key := createKey(t, createPage.Body.String())
created := serve(router, http.MethodPost, "/tasks", url.Values{
"csrf_token": {csrfToken(t, createPage.Body.String())},
"create_key": {key},
"title": {"<b>夏季上衣</b>"},
"product_url": {"https://mobile.yangkeduo.com/goods.html?goods_id=937122477375&utm_source=discard"},
"sku_color": {"black"},
"sku_size": {"M"},
"quantity": {"2"},
"max_total_price": {"12.8"},
"form_mode": {"dialog"},
}, cookie)
if created.Code != http.StatusSeeOther || !strings.HasPrefix(created.Header().Get("Location"), "/tasks?created=") {
t.Fatalf("valid create = (%d, %q), want 303 to a created-task acknowledgement", created.Code, created.Header().Get("Location"))
}
replay := serve(router, http.MethodPost, "/tasks", url.Values{
"csrf_token": {csrfToken(t, createPage.Body.String())},
"create_key": {key},
"title": {"<b>夏季上衣</b>"},
"product_url": {"https://mobile.yangkeduo.com/goods.html?goods_id=937122477375&utm_source=discard"},
"sku_color": {"black"},
"sku_size": {"M"},
"quantity": {"2"},
"max_total_price": {"12.8"},
"form_mode": {"dialog"},
}, cookie)
if replay.Code != http.StatusSeeOther {
t.Fatalf("idempotent replay status = %d, want 303", replay.Code)
}
conflict := serve(router, http.MethodPost, "/tasks", url.Values{
"csrf_token": {csrfToken(t, createPage.Body.String())},
"create_key": {key},
"title": {"different task"},
"product_url": {"https://mobile.yangkeduo.com/goods.html?goods_id=937122477375"},
"sku_color": {"black"},
"sku_size": {"M"},
"quantity": {"2"},
"max_total_price": {"12.80"},
"form_mode": {"dialog"},
}, cookie)
if conflict.Code != http.StatusConflict || !strings.Contains(conflict.Body.String(), "该创建请求已用于另一条任务") {
t.Fatalf("conflicting create = (%d, %q), want a 409 form error", conflict.Code, conflict.Body.String())
}
list := serve(router, http.MethodGet, created.Header().Get("Location"), nil, cookie)
if list.Code != http.StatusOK {
t.Fatalf("GET /tasks status = %d, want 200", list.Code)
}
body := list.Body.String()
for _, want := range []string{`任务已创建,已显示在列表首行。`, `&lt;b&gt;夏季上衣&lt;/b&gt;`, `https://mobile.yangkeduo.com/goods.html?goods_id=937122477375`, `target="_blank"`, `rel="noopener noreferrer"`, `¥12.80`, `待开始`, `选择全部任务`, `选择任务`} {
if !strings.Contains(body, want) {
t.Fatalf("task list is missing %q", want)
}
}
for _, forbidden := range []string{"utm_source", "试选", "订单确认", "真机", "提交订单"} {
if strings.Contains(body, forbidden) {
t.Fatalf("task list exposed deferred scope %q", forbidden)
}
}
}
func TestTasksPageKeepsOriginalShellAndRendersFilteredWorkbench(t *testing.T) {
store := &memoryStore{rows: []tasks.TaskRow{
{ID: "b3c9f507-7473-4fa6-8d71-8786c34c6301", Title: "待开始衬衫", GoodsID: "937122477375", SKUColor: "黑色", SKUSize: "M", Quantity: 2, MaxTotalPrice: "12.80", Status: "DRAFT", Version: 3, CreatedAt: time.Date(2026, 8, 4, 1, 2, 3, 0, time.UTC)},
{ID: "c3c9f507-7473-4fa6-8d71-8786c34c6301", Title: "等待领取衬衫", GoodsID: "958756616606", SKUColor: "白色", SKUSize: "L", Quantity: 1, MaxTotalPrice: "20.00", Status: "PENDING", Version: 4, CreatedAt: time.Date(2026, 8, 4, 2, 3, 4, 0, time.UTC)},
}}
router, _ := newRouterWithStore(t, store)
cookie := authenticate(t, router)
query := url.Values{"keyword": {"衬衫"}, "created_from": {"2026-08-04"}, "created_to": {"2026-08-04"}}
response := serve(router, http.MethodGet, "/tasks?"+query.Encode(), nil, cookie)
if response.Code != http.StatusOK {
t.Fatalf("filtered tasks status = %d, want 200", response.Code)
}
body := response.Body.String()
for _, want := range []string{
`<a class="skip" href="#main">`,
`:focus-visible`,
`min-height:44px`,
`@media(max-width:420px)`,
`prefers-reduced-motion`,
`<button class="button" type="button" disabled>导入</button><a class="button primary" href="/tasks?create=1">创建任务</a>`,
`name="keyword" type="search" value="衬衫"`,
`name="created_from" type="date" value="2026-08-04"`,
`name="created_to" type="date" value="2026-08-04"`,
`data-start-purchases`,
`data-selection-summary aria-live="polite"`,
`系统不会付款`,
`开始采购(只创建待付款订单)`,
`采购结果`,
`创建时间(上海)`,
`https://mobile.yangkeduo.com/goods.html?goods_id=937122477375`,
`target="_blank" rel="noopener noreferrer"`,
`data-task-row data-detail-url="/tasks/b3c9f507-7473-4fa6-8d71-8786c34c6301" tabindex="0"`,
`data-open-detail>查看详情</button>`,
`.detail-link-button{display:block;min-height:44px`,
`data-detail-drawer aria-modal="true"`,
`待开始`,
`已授权待领取`,
`datetime="2026-08-04T09:02:03&#43;08:00">2026-08-04 09:02`,
`<script src="/static/tasks.js" defer></script>`,
} {
if !strings.Contains(body, want) {
t.Fatalf("workbench is missing %q", want)
}
}
if strings.Index(body, `name="keyword"`) > strings.Index(body, `data-start-purchases`) || strings.Index(body, `data-start-purchases`) > strings.Index(body, `<div class="table-wrap">`) {
t.Fatal("workbench rows are not ordered as toolbar, filters, batch actions, table")
}
if count := strings.Count(body, `data-task-id=`); count != 1 {
t.Fatalf("selectable row count = %d, want only the DRAFT row", count)
}
for _, forbidden := range []string{`<th scope="col">操作</th>`, `确认开始采购`, `确认机器选对了吗`} {
if strings.Contains(body, forbidden) {
t.Fatalf("workbench exposed forbidden per-row or confirmation UI %q", forbidden)
}
}
if store.listTasksCalls != 1 || store.listDraftsCalls != 0 {
t.Fatalf("GET /tasks calls = (ListTasks %d, ListDrafts %d), want (1, 0)", store.listTasksCalls, store.listDraftsCalls)
}
}
func TestTasksPageRerendersAccessibleFilterErrorsAndKeepsValues(t *testing.T) {
store := &memoryStore{}
router, _ := newRouterWithStore(t, store)
cookie := authenticate(t, router)
query := url.Values{
"keyword": {`保留%_\`},
"status": {"UNKNOWN"},
"created_from": {"2026-02-30"},
"created_to": {"not-a-date"},
}
response := serve(router, http.MethodGet, "/tasks?"+query.Encode(), nil, cookie)
if response.Code != http.StatusBadRequest {
t.Fatalf("invalid filter status = %d, want 400", response.Code)
}
body := response.Body.String()
for _, want := range []string{
`role="alert" aria-live="assertive"`,
`href="#filter-status"`,
`href="#filter-created-from"`,
`href="#filter-created-to"`,
`name="keyword" type="search" value="保留%_\"`,
`<option value="UNKNOWN" selected>无效状态:UNKNOWN</option>`,
`name="created_from" type="date" value="2026-02-30" aria-invalid="true" aria-describedby="filter-created-from-error"`,
`name="created_to" type="date" value="not-a-date" aria-invalid="true" aria-describedby="filter-created-to-error"`,
`id="filter-status-error"`,
`id="filter-created-from-error"`,
`id="filter-created-to-error"`,
`筛选条件有误`,
} {
if !strings.Contains(body, want) {
t.Fatalf("invalid filter page is missing %q", want)
}
}
if store.listTasksCalls != 0 || store.listDraftsCalls != 0 {
t.Fatalf("invalid filter queried stores: ListTasks=%d ListDrafts=%d", store.listTasksCalls, store.listDraftsCalls)
}
assertSecurityHeaders(t, response)
}
func TestTaskCreationRequiresAuthenticationAndCSRF(t *testing.T) {
router, _ := newRouter(t)
if response := serve(router, http.MethodPost, "/tasks", url.Values{}, nil); response.Code != http.StatusUnauthorized {
t.Fatalf("anonymous POST /tasks = %d, want 401", response.Code)
}
cookie := authenticate(t, router)
if response := serve(router, http.MethodPost, "/tasks", url.Values{}, cookie); response.Code != http.StatusForbidden {
t.Fatalf("POST /tasks without CSRF = %d, want 403", response.Code)
}
}
func TestTaskCreationFailsClosedForMalformedOrOversizedForms(t *testing.T) {
router, _ := newRouter(t)
cookie := authenticate(t, router)
page := serve(router, http.MethodGet, "/tasks?create=1", nil, cookie)
base := url.Values{
"csrf_token": {csrfToken(t, page.Body.String())},
"create_key": {createKey(t, page.Body.String())},
"title": {"title"},
"product_url": {"https://mobile.yangkeduo.com/goods.html?goods_id=1;uin=malformed"},
"sku_color": {"black"},
"sku_size": {"M"},
"quantity": {"1"},
"max_total_price": {"1.00"},
"form_mode": {"dialog"},
}
malformed := serve(router, http.MethodPost, "/tasks", base, cookie)
if malformed.Code != http.StatusBadRequest || !strings.Contains(malformed.Body.String(), "canonical 商品链接") {
t.Fatalf("malformed URL create = (%d, %q), want validation failure", malformed.Code, malformed.Body.String())
}
oversized := url.Values{"csrf_token": {csrfToken(t, page.Body.String())}, "title": {strings.Repeat("x", 9<<10)}}
if response := serve(router, http.MethodPost, "/tasks", oversized, cookie); response.Code != http.StatusRequestEntityTooLarge {
t.Fatalf("oversized form status = %d, want 413", response.Code)
}
}
func assertSecurityHeaders(t *testing.T, response *httptest.ResponseRecorder) {
t.Helper()
want := map[string]string{
"Cache-Control": "no-store",
"X-Content-Type-Options": "nosniff",
"Referrer-Policy": "no-referrer",
"Content-Security-Policy": "default-src 'self'; style-src 'self' 'unsafe-inline'; script-src 'self'; object-src 'none'; base-uri 'none'; frame-ancestors 'none'; form-action 'self'",
}
for name, expected := range want {
if got := response.Header().Get(name); got != expected {
t.Fatalf("%s = %q, want %q", name, got, expected)
}
}
}
func flipCookieValue(t *testing.T, value string) string {
t.Helper()
if value == "" {
t.Fatal("cannot tamper with an empty cookie")
}
if value[0] == 'A' {
return "B" + value[1:]
}
return "A" + value[1:]
}
func TestLogoutRequiresCSRFAndRevokesSession(t *testing.T) {
router, _ := newRouter(t)
loginPage := serve(router, http.MethodGet, "/login", nil, nil)
loginCookie := sessionCookie(t, loginPage)
login := serve(router, http.MethodPost, "/login", url.Values{
"csrf_token": {csrfToken(t, loginPage.Body.String())},
"username": {"admin"},
"password": {"test-password"},
}, loginCookie)
authenticatedCookie := sessionCookie(t, login)
missingCSRF := serve(router, http.MethodPost, "/logout", url.Values{}, authenticatedCookie)
if missingCSRF.Code != http.StatusForbidden {
t.Fatalf("logout without CSRF status = %d, want 403", missingCSRF.Code)
}
tasks := serve(router, http.MethodGet, "/tasks", nil, authenticatedCookie)
logout := serve(router, http.MethodPost, "/logout", url.Values{
"csrf_token": {csrfToken(t, tasks.Body.String())},
}, authenticatedCookie)
if logout.Code != http.StatusSeeOther || logout.Header().Get("Location") != "/login" {
t.Fatalf("logout = (%d, %q), want 303 /login", logout.Code, logout.Header().Get("Location"))
}
if cookie := sessionCookie(t, logout); cookie.MaxAge >= 0 {
t.Fatalf("logout cookie MaxAge = %d, want a deletion cookie", cookie.MaxAge)
}
reused := serve(router, http.MethodGet, "/tasks", nil, authenticatedCookie)
if reused.Code != http.StatusSeeOther {
t.Fatalf("revoked session status = %d, want 303", reused.Code)
}
}
func newRouter(t *testing.T) (*gin.Engine, *auth.Manager) {
return newRouterWithStore(t, &memoryStore{})
}
func newRouterWithStore(t *testing.T, store tasks.Store) (*gin.Engine, *auth.Manager) {
return newRouterWithDependencies(t, store, emptyDetailStore{}, emptyEvidenceStore{}, deviceauth.RejectAllAuthenticator{})
}
func newRouterWithDependencies(t *testing.T, store tasks.Store, details taskdetail.Store, evidenceStore evidence.Store, deviceAuthenticator deviceauth.Authenticator) (*gin.Engine, *auth.Manager) {
return newRouterWithClaimService(t, store, details, evidenceStore, deviceAuthenticator, emptyTaskClaimService{})
}
func newRouterWithClaimService(t *testing.T, store tasks.Store, details taskdetail.Store, evidenceStore evidence.Store, deviceAuthenticator deviceauth.Authenticator, claims taskclaim.Service) (*gin.Engine, *auth.Manager) {
t.Helper()
gin.SetMode(gin.TestMode)
hash, err := bcrypt.GenerateFromPassword([]byte("test-password"), bcrypt.MinCost)
if err != nil {
t.Fatalf("generate bcrypt hash: %v", err)
}
manager := auth.NewManager([]byte(strings.Repeat("s", 32)), false)
router, err := server.NewRouter(server.Options{
AdminUsername: "admin",
AdminPasswordBcrypt: string(hash),
Sessions: manager,
Tasks: store,
TaskDetails: details,
Evidence: evidenceStore,
DeviceAuthenticator: deviceAuthenticator,
TaskClaims: claims,
})
if err != nil {
t.Fatalf("NewRouter: %v", err)
}
return router, manager
}
type emptyDetailStore struct{}
type emptyTaskClaimService struct{}
func (emptyTaskClaimService) ClaimNext(context.Context, string, taskclaim.ClaimCommand) (taskclaim.ClaimResponse, bool, error) {
return taskclaim.ClaimResponse{}, false, nil
}
func (emptyTaskClaimService) Renew(context.Context, string, taskclaim.RenewCommand) (taskclaim.RenewResponse, error) {
return taskclaim.RenewResponse{}, taskclaim.ErrNotCurrent
}
func (emptyDetailStore) Get(context.Context, string) (taskdetail.Detail, error) {
return taskdetail.Detail{}, taskdetail.ErrNotFound
}
type emptyEvidenceStore struct{}
func (emptyEvidenceStore) Stage(io.Reader, string) (evidence.StagedFile, error) {
return evidence.StagedFile{}, evidence.ErrInvalid
}
func (emptyEvidenceStore) Discard(evidence.StagedFile) {}
func (emptyEvidenceStore) Commit(context.Context, deviceauth.Principal, evidence.UploadMetadata, evidence.StagedFile) (evidence.Asset, bool, error) {
return evidence.Asset{}, false, evidence.ErrInvalid
}
func (emptyEvidenceStore) Open(context.Context, string) (evidence.Asset, io.ReadSeekCloser, error) {
return evidence.Asset{}, nil, evidence.ErrNotFound
}
type memoryStore struct {
drafts []tasks.Draft
rows []tasks.TaskRow
listDraftsCalls int
listTasksCalls int
startCalls int
}
func (store *memoryStore) CreateDraft(_ context.Context, draft tasks.Draft) (tasks.Draft, error) {
for _, existing := range store.drafts {
if existing.ID == draft.ID {
if existing.Title != draft.Title || existing.GoodsID != draft.GoodsID || existing.SKUColor != draft.SKUColor || existing.SKUSize != draft.SKUSize || existing.Quantity != draft.Quantity || existing.MaxTotalPrice != draft.MaxTotalPrice {
return tasks.Draft{}, tasks.ErrCreateKeyConflict
}
return existing, nil
}
}
store.drafts = append(store.drafts, draft)
return draft, nil
}
func (store *memoryStore) ListDrafts(_ context.Context) ([]tasks.Draft, error) {
store.listDraftsCalls++
return append([]tasks.Draft(nil), store.drafts...), nil
}
func (store *memoryStore) ListTasks(_ context.Context, _ tasks.TaskFilter) ([]tasks.TaskRow, error) {
store.listTasksCalls++
if store.rows != nil {
return append([]tasks.TaskRow(nil), store.rows...), nil
}
result := make([]tasks.TaskRow, 0, len(store.drafts))
for _, draft := range store.drafts {
result = append(result, tasks.TaskRow{ID: draft.ID, Title: draft.Title, GoodsID: draft.GoodsID, SKUColor: draft.SKUColor, SKUSize: draft.SKUSize, Quantity: draft.Quantity, MaxTotalPrice: draft.MaxTotalPrice, Status: "DRAFT", Version: 1, CreatedAt: draft.CreatedAt})
}
return result, nil
}
func (store *memoryStore) StartPurchases(_ context.Context, _ tasks.StartCommand, _ string) (tasks.StartResult, error) {
store.startCalls++
return tasks.StartResult{}, tasks.ErrInvalidStart
}
func serve(router http.Handler, method, target string, form url.Values, cookie *http.Cookie) *httptest.ResponseRecorder {
var body *strings.Reader
if form == nil {
body = strings.NewReader("")
} else {
body = strings.NewReader(form.Encode())
}
request := httptest.NewRequest(method, target, body)
if form != nil {
request.Header.Set("Content-Type", "application/x-www-form-urlencoded")
}
if cookie != nil {
request.AddCookie(cookie)
}
response := httptest.NewRecorder()
router.ServeHTTP(response, request)
return response
}
func sessionCookie(t *testing.T, response *httptest.ResponseRecorder) *http.Cookie {
t.Helper()
for _, cookie := range response.Result().Cookies() {
if cookie.Name == auth.CookieName {
return cookie
}
}
t.Fatalf("response did not set %s cookie", auth.CookieName)
return nil
}
func csrfToken(t *testing.T, body string) string {
t.Helper()
matches := csrfPattern.FindStringSubmatch(body)
if len(matches) != 2 || matches[1] == "" {
t.Fatalf("no CSRF token in response body: %q", body)
}
return matches[1]
}
func createKey(t *testing.T, body string) string {
t.Helper()
matches := createKeyPattern.FindStringSubmatch(body)
if len(matches) != 2 || matches[1] == "" {
t.Fatalf("no create key in response body: %q", body)
}
return matches[1]
}
func authenticate(t *testing.T, router http.Handler) *http.Cookie {
t.Helper()
page := serve(router, http.MethodGet, "/login", nil, nil)
login := serve(router, http.MethodPost, "/login", url.Values{
"csrf_token": {csrfToken(t, page.Body.String())},
"username": {"admin"},
"password": {"test-password"},
}, sessionCookie(t, page))
if login.Code != http.StatusSeeOther {
t.Fatalf("authenticate status = %d, want 303", login.Code)
}
return sessionCookie(t, login)
}
@@ -0,0 +1,328 @@
package server_test
import (
"bytes"
"context"
"encoding/json"
"errors"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
"cmbuyer/admin/internal/tasks"
)
const (
startKeyForHTTP = "c3c9f507-7473-4fa6-8d71-8786c34c6301"
taskIDForHTTP = "a3c9f507-7473-4fa6-8d71-8786c34c6301"
)
func TestStartPurchasesAuthenticatesBeforeInspectingRequestBody(t *testing.T) {
store := &startRecordingStore{}
router, _ := newRouterWithStore(t, store)
hugeMalformed := `{"start_key":"` + strings.Repeat("x", 70<<10)
for name, request := range map[string]*http.Request{
"anonymous malformed": newStartRequest(t, hugeMalformed, "text/plain", "", nil),
"device bearer": newStartRequest(t, validStartBody(), "application/json", "", nil),
} {
t.Run(name, func(t *testing.T) {
if name == "device bearer" {
request.Header.Set("Authorization", "Bearer device-token")
}
response := httptest.NewRecorder()
router.ServeHTTP(response, request)
if response.Code != http.StatusUnauthorized {
t.Fatalf("status = %d, want 401", response.Code)
}
})
}
cookie, csrf := authenticatedStartSession(t, router)
for name, token := range map[string]string{"missing CSRF": "", "wrong CSRF": "wrong-csrf"} {
request := newStartRequest(t, hugeMalformed, "text/plain", token, cookie)
response := httptest.NewRecorder()
router.ServeHTTP(response, request)
if response.Code != http.StatusForbidden {
t.Fatalf("%s status = %d, want 403", name, response.Code)
}
}
if csrf == "" {
t.Fatal("authenticated page did not contain a CSRF token")
}
if store.startCalls != 0 {
t.Fatalf("unauthorized requests called store %d times", store.startCalls)
}
}
func TestStartPurchasesRejectsInvalidUTF8BeforeJSONDecoding(t *testing.T) {
validPrefix := []byte(`{"start_key":"` + startKeyForHTTP + `","tasks":[],"start_key":"`)
duplicateKeyBypass := append(append([]byte(nil), validPrefix...), 0xff)
duplicateKeyBypass = append(duplicateKeyBypass, []byte(`"}`)...)
invalidWhitespace := append([]byte(validStartBody()), 0xfe)
for name, body := range map[string][]byte{
"invalid byte after JSON": invalidWhitespace,
"invalid duplicate-key value": duplicateKeyBypass,
} {
t.Run(name, func(t *testing.T) {
store := &startRecordingStore{}
router, _ := newRouterWithStore(t, store)
cookie, csrf := authenticatedStartSession(t, router)
response := serveStartBytes(t, router, body, "application/json", csrf, cookie)
if response.Code != http.StatusBadRequest || store.startCalls != 0 {
t.Fatalf("status/calls = %d/%d, want 400/0", response.Code, store.startCalls)
}
if response.Body.Len() != 0 {
t.Fatalf("invalid UTF-8 response leaked body %q", response.Body.String())
}
})
}
}
func TestStartPurchasesEnforcesExact64KiBBodyBoundary(t *testing.T) {
const limit = 64 << 10
base := validStartBody()
for name, test := range map[string]struct {
body string
want int
wantCalls int
}{
"exact limit": {body: base + strings.Repeat(" ", limit-len(base)), want: http.StatusOK, wantCalls: 1},
"one over": {body: base + strings.Repeat(" ", limit-len(base)+1), want: http.StatusRequestEntityTooLarge},
} {
t.Run(name, func(t *testing.T) {
store := &startRecordingStore{startResult: successfulStartResult()}
router, _ := newRouterWithStore(t, store)
cookie, csrf := authenticatedStartSession(t, router)
response := serveStartRequest(t, router, test.body, "application/json", csrf, cookie)
if response.Code != test.want || store.startCalls != test.wantCalls {
t.Fatalf("status/calls = %d/%d, want %d/%d", response.Code, store.startCalls, test.want, test.wantCalls)
}
})
}
}
func TestStartPurchasesContentTypeContract(t *testing.T) {
for _, contentType := range []string{
"application/json",
"application/json; charset=utf-8",
"application/json;charset=UTF-8",
} {
t.Run("accept "+contentType, func(t *testing.T) {
store := &startRecordingStore{startResult: successfulStartResult()}
router, _ := newRouterWithStore(t, store)
cookie, csrf := authenticatedStartSession(t, router)
response := serveStartRequest(t, router, validStartBody(), contentType, csrf, cookie)
if response.Code != http.StatusOK || store.startCalls != 1 {
t.Fatalf("status/calls = %d/%d, want 200/1", response.Code, store.startCalls)
}
})
}
for _, contentType := range []string{
"",
"text/plain",
"application/json-patch+json",
"application/json; charset=gbk",
"application/json; profile=unapproved",
"application/json; charset",
} {
t.Run("reject "+contentType, func(t *testing.T) {
store := &startRecordingStore{}
router, _ := newRouterWithStore(t, store)
cookie, csrf := authenticatedStartSession(t, router)
response := serveStartRequest(t, router, validStartBody(), contentType, csrf, cookie)
if response.Code != http.StatusUnsupportedMediaType || store.startCalls != 0 {
t.Fatalf("status/calls = %d/%d, want 415/0", response.Code, store.startCalls)
}
})
}
}
func TestStartPurchasesRejectsMalformedAndOversizedJSON(t *testing.T) {
tests := []struct {
name string
body string
want int
storeErr error
wantCalls int
}{
{name: "empty", body: "", want: http.StatusBadRequest},
{name: "empty object", body: `{}`, want: http.StatusBadRequest, storeErr: tasks.ErrInvalidStart, wantCalls: 1},
{name: "null object", body: `null`, want: http.StatusBadRequest, storeErr: tasks.ErrInvalidStart, wantCalls: 1},
{name: "malformed", body: `{`, want: http.StatusBadRequest},
{name: "wrong top-level type", body: `[]`, want: http.StatusBadRequest},
{name: "unknown field", body: `{"start_key":"` + startKeyForHTTP + `","tasks":[],"created_by":"attacker"}`, want: http.StatusBadRequest},
{name: "wrong field type", body: `{"start_key":"` + startKeyForHTTP + `","tasks":[{"task_id":"` + taskIDForHTTP + `","expected_task_version":"1"}]}`, want: http.StatusBadRequest},
{name: "second JSON value", body: validStartBody() + `{}`, want: http.StatusBadRequest},
{name: "duplicate task ids", body: `{"start_key":"` + startKeyForHTTP + `","tasks":[{"task_id":"` + taskIDForHTTP + `","expected_task_version":1},{"task_id":"` + taskIDForHTTP + `","expected_task_version":1}]}`, want: http.StatusBadRequest, storeErr: tasks.ErrInvalidStart, wantCalls: 1},
{name: "oversized first value", body: `{"start_key":"` + strings.Repeat("x", 70<<10), want: http.StatusRequestEntityTooLarge},
{name: "oversized trailing whitespace", body: validStartBody() + strings.Repeat(" ", 70<<10), want: http.StatusRequestEntityTooLarge},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
store := &startRecordingStore{startErr: test.storeErr}
router, _ := newRouterWithStore(t, store)
cookie, csrf := authenticatedStartSession(t, router)
response := serveStartRequest(t, router, test.body, "application/json", csrf, cookie)
if response.Code != test.want || store.startCalls != test.wantCalls {
t.Fatalf("status/calls = %d/%d, want %d/%d", response.Code, store.startCalls, test.want, test.wantCalls)
}
if response.Body.Len() != 0 {
t.Fatalf("error response leaked body %q", response.Body.String())
}
assertSecurityHeaders(t, response)
})
}
}
func TestStartPurchasesUsesAuthenticatedAdminAndReturnsStableSafeResult(t *testing.T) {
result := successfulStartResult()
store := &startRecordingStore{startResult: result}
router, _ := newRouterWithStore(t, store)
cookie, csrf := authenticatedStartSession(t, router)
first := serveStartRequest(t, router, validStartBody(), "application/json; charset=utf-8", csrf, cookie)
second := serveStartRequest(t, router, validStartBody(), "application/json", csrf, cookie)
for index, response := range []*httptest.ResponseRecorder{first, second} {
if response.Code != http.StatusOK {
t.Fatalf("response %d status = %d, want 200", index, response.Code)
}
if got := response.Header().Get("Content-Type"); got != "application/json; charset=utf-8" {
t.Fatalf("response content type = %q", got)
}
var decoded tasks.StartResult
if err := json.Unmarshal(response.Body.Bytes(), &decoded); err != nil {
t.Fatalf("decode response: %v", err)
}
if decoded.PaymentAutomated || decoded.AuthorizedCount != 1 || decoded.Tasks[0].AuthorizationID != result.Tasks[0].AuthorizationID {
t.Fatalf("unsafe or unstable response = %#v", decoded)
}
assertSecurityHeaders(t, response)
}
if store.startCalls != 2 || len(store.createdBy) != 2 || store.createdBy[0] != "admin" || store.createdBy[1] != "admin" {
t.Fatalf("store calls/created_by = %d/%#v", store.startCalls, store.createdBy)
}
for _, command := range store.commands {
if command.StartKey != startKeyForHTTP || len(command.Tasks) != 1 || command.Tasks[0].TaskID != taskIDForHTTP || command.Tasks[0].ExpectedTaskVersion != 7 {
t.Fatalf("decoded command = %#v", command)
}
}
}
func TestStartPurchasesMapsStoreErrorsWithoutLeakingDetails(t *testing.T) {
for name, test := range map[string]struct {
err error
want int
}{
"invalid": {err: tasks.ErrInvalidStart, want: http.StatusBadRequest},
"conflict": {err: tasks.ErrStartConflict, want: http.StatusConflict},
"internal": {err: errors.New("sqlite secret path and query"), want: http.StatusInternalServerError},
} {
t.Run(name, func(t *testing.T) {
store := &startRecordingStore{startErr: test.err}
router, _ := newRouterWithStore(t, store)
cookie, csrf := authenticatedStartSession(t, router)
response := serveStartRequest(t, router, validStartBody(), "application/json", csrf, cookie)
if response.Code != test.want || store.startCalls != 1 {
t.Fatalf("status/calls = %d/%d, want %d/1", response.Code, store.startCalls, test.want)
}
if response.Body.Len() != 0 || strings.Contains(response.Body.String(), "sqlite") {
t.Fatalf("error leaked details: %q", response.Body.String())
}
})
}
}
type startRecordingStore struct {
startResult tasks.StartResult
startErr error
startCalls int
commands []tasks.StartCommand
createdBy []string
}
func (store *startRecordingStore) CreateDraft(_ context.Context, draft tasks.Draft) (tasks.Draft, error) {
return draft, nil
}
func (store *startRecordingStore) ListDrafts(context.Context) ([]tasks.Draft, error) {
return nil, nil
}
func (store *startRecordingStore) ListTasks(context.Context, tasks.TaskFilter) ([]tasks.TaskRow, error) {
return nil, nil
}
func (store *startRecordingStore) StartPurchases(_ context.Context, command tasks.StartCommand, createdBy string) (tasks.StartResult, error) {
store.startCalls++
store.commands = append(store.commands, command)
store.createdBy = append(store.createdBy, createdBy)
return store.startResult, store.startErr
}
func authenticatedStartSession(t *testing.T, router http.Handler) (*http.Cookie, string) {
t.Helper()
cookie := authenticate(t, router)
page := serve(router, http.MethodGet, "/tasks", nil, cookie)
if page.Code != http.StatusOK {
t.Fatalf("GET /tasks status = %d", page.Code)
}
return cookie, csrfToken(t, page.Body.String())
}
func newStartRequest(t *testing.T, body, contentType, csrf string, cookie *http.Cookie) *http.Request {
t.Helper()
return newStartByteRequest(t, []byte(body), contentType, csrf, cookie)
}
func newStartByteRequest(t *testing.T, body []byte, contentType, csrf string, cookie *http.Cookie) *http.Request {
t.Helper()
request := httptest.NewRequest(http.MethodPost, "/tasks/start-purchases", bytes.NewReader(body))
if contentType != "" {
request.Header.Set("Content-Type", contentType)
}
if csrf != "" {
request.Header.Set("X-CSRF-Token", csrf)
}
if cookie != nil {
request.AddCookie(cookie)
}
return request
}
func serveStartBytes(t *testing.T, router http.Handler, body []byte, contentType, csrf string, cookie *http.Cookie) *httptest.ResponseRecorder {
t.Helper()
response := httptest.NewRecorder()
router.ServeHTTP(response, newStartByteRequest(t, body, contentType, csrf, cookie))
return response
}
func serveStartRequest(t *testing.T, router http.Handler, body, contentType, csrf string, cookie *http.Cookie) *httptest.ResponseRecorder {
t.Helper()
response := httptest.NewRecorder()
router.ServeHTTP(response, newStartRequest(t, body, contentType, csrf, cookie))
return response
}
func validStartBody() string {
return `{"start_key":"` + startKeyForHTTP + `","tasks":[{"task_id":"` + taskIDForHTTP + `","expected_task_version":7}]}`
}
func successfulStartResult() tasks.StartResult {
expires := time.Date(2026, 8, 4, 2, 3, 4, 0, time.UTC)
return tasks.StartResult{
StartKey: startKeyForHTTP,
AuthorizedCount: 1,
PaymentAutomated: false,
Tasks: []tasks.AuthorizedTask{{
TaskID: taskIDForHTTP,
TaskVersion: 8,
AuthorizationID: "d3c9f507-7473-4fa6-8d71-8786c34c6301",
ExpiresAt: expires,
}},
}
}
+176
View File
@@ -0,0 +1,176 @@
package server
import (
"bytes"
"encoding/json"
"errors"
"io"
"net/http"
"unicode/utf8"
"cmbuyer/admin/internal/deviceauth"
"cmbuyer/admin/internal/taskclaim"
"github.com/gin-gonic/gin"
)
const (
maxClaimJSONBytes = 4096
maxClaimResponseJSONBytes = 32 * 1024
)
func claimNext(options Options) gin.HandlerFunc {
return func(context *gin.Context) {
principal, ok := authenticateDevice(context, options)
if !ok {
return
}
var command taskclaim.ClaimCommand
if !decodeClaimJSON(context, &command) {
return
}
response, found, err := options.TaskClaims.ClaimNext(context.Request.Context(), principal.ID, command)
if err != nil {
writeTaskClaimError(context, err)
return
}
if !found {
context.Status(http.StatusNoContent)
return
}
if !taskclaim.ValidClaimResponse(response) {
context.Status(http.StatusServiceUnavailable)
return
}
encoded, err := json.Marshal(response)
if err != nil || len(encoded) > maxClaimResponseJSONBytes {
context.Status(http.StatusServiceUnavailable)
return
}
context.Data(http.StatusOK, "application/json; charset=utf-8", encoded)
}
}
func renewLease(options Options) gin.HandlerFunc {
return func(context *gin.Context) {
principal, ok := authenticateDevice(context, options)
if !ok {
return
}
var command taskclaim.RenewCommand
if !decodeClaimJSON(context, &command) {
return
}
command.TaskID = context.Param("id")
response, err := options.TaskClaims.Renew(context.Request.Context(), principal.ID, command)
if err != nil {
writeTaskClaimError(context, err)
return
}
context.JSON(http.StatusOK, response)
}
}
// Authentication precedes path interpretation, Content-Type parsing and every body read. This
// keeps rejected devices from using parsing differences as an oracle or making the server buffer data.
func authenticateDevice(context *gin.Context, options Options) (deviceauth.Principal, bool) {
principal, err := options.DeviceAuthenticator.Authenticate(context.Request)
if errors.Is(err, deviceauth.ErrUnauthenticated) {
context.Header("WWW-Authenticate", "Bearer")
context.Status(http.StatusUnauthorized)
return deviceauth.Principal{}, false
}
if err != nil || !deviceauth.ValidDeviceID(principal.ID) {
context.Status(http.StatusServiceUnavailable)
return deviceauth.Principal{}, false
}
return principal, true
}
func decodeClaimJSON(context *gin.Context, target any) bool {
if !isJSONContentType(context.GetHeader("Content-Type")) {
writeFixedError(context, http.StatusUnsupportedMediaType, "unsupported_media_type")
return false
}
context.Request.Body = http.MaxBytesReader(context.Writer, context.Request.Body, maxClaimJSONBytes)
raw, err := io.ReadAll(context.Request.Body)
if err != nil {
var tooLarge *http.MaxBytesError
if errors.As(err, &tooLarge) {
writeFixedError(context, http.StatusRequestEntityTooLarge, "request_too_large")
} else {
writeFixedError(context, http.StatusBadRequest, "invalid_request")
}
return false
}
if len(raw) == 0 || !utf8.Valid(raw) {
writeFixedError(context, http.StatusBadRequest, "invalid_request")
return false
}
if !hasUniqueTopLevelJSONFields(raw) {
writeFixedError(context, http.StatusBadRequest, "invalid_request")
return false
}
decoder := json.NewDecoder(bytes.NewReader(raw))
decoder.DisallowUnknownFields()
if err := decoder.Decode(target); err != nil {
writeFixedError(context, http.StatusBadRequest, "invalid_request")
return false
}
var extra any
if err := decoder.Decode(&extra); err != io.EOF {
writeFixedError(context, http.StatusBadRequest, "invalid_request")
return false
}
return true
}
func hasUniqueTopLevelJSONFields(raw []byte) bool {
decoder := json.NewDecoder(bytes.NewReader(raw))
first, err := decoder.Token()
if err != nil || first != json.Delim('{') {
return false
}
seen := make(map[string]struct{})
for decoder.More() {
key, err := decoder.Token()
name, ok := key.(string)
if err != nil || !ok {
return false
}
if _, duplicate := seen[name]; duplicate {
return false
}
seen[name] = struct{}{}
var value json.RawMessage
if err := decoder.Decode(&value); err != nil {
return false
}
}
last, err := decoder.Token()
return err == nil && last == json.Delim('}')
}
func writeTaskClaimError(context *gin.Context, err error) {
switch {
case errors.Is(err, taskclaim.ErrInvalid):
writeFixedError(context, http.StatusBadRequest, "invalid_request")
case errors.Is(err, taskclaim.ErrIdempotencyConflict):
writeFixedError(context, http.StatusConflict, "idempotency_conflict")
case errors.Is(err, taskclaim.ErrRequiresManual):
writeFixedError(context, http.StatusConflict, "claim_requires_manual")
case errors.Is(err, taskclaim.ErrNotCurrent):
writeFixedError(context, http.StatusConflict, "claim_not_current")
case errors.Is(err, taskclaim.ErrDeviceInactive):
context.Header("WWW-Authenticate", "Bearer")
context.Status(http.StatusUnauthorized)
default:
// Storage and transaction failures are intentionally bodyless: SQL, paths and candidate
// details are server-only and must not become a device-facing diagnostic oracle.
context.Status(http.StatusServiceUnavailable)
}
}
func writeFixedError(context *gin.Context, status int, code string) {
context.JSON(status, gin.H{"error": code})
}
+215
View File
@@ -0,0 +1,215 @@
package server_test
import (
"bytes"
"context"
"encoding/json"
"errors"
"io"
"math"
"net/http"
"net/http/httptest"
"strings"
"testing"
"cmbuyer/admin/internal/deviceauth"
"cmbuyer/admin/internal/taskclaim"
)
const (
claimDeviceID = "10000000-0000-4000-8000-000000000001"
claimSessionID = "20000000-0000-4000-8000-000000000001"
claimRequestID = "30000000-0000-4000-8000-000000000001"
claimTaskID = "40000000-0000-4000-8000-000000000001"
claimAttemptID = "50000000-0000-4000-8000-000000000001"
claimRenewID = "60000000-0000-4000-8000-000000000001"
)
func TestTaskClaimEndpointsAuthenticateBeforeBody(t *testing.T) {
for _, authentication := range []struct {
name string
err error
status int
}{
{"unauthenticated", deviceauth.ErrUnauthenticated, http.StatusUnauthorized},
{"authentication storage unavailable", deviceauth.ErrUnavailable, http.StatusServiceUnavailable},
} {
t.Run(authentication.name, func(t *testing.T) {
authenticator := &fakeDeviceAuthenticator{err: authentication.err}
service := &fakeTaskClaimService{}
router, _ := newRouterWithClaimService(t, &memoryStore{}, emptyDetailStore{}, emptyEvidenceStore{}, authenticator, service)
for _, path := range []string{"/api/v1/tasks/claim-next", "/api/v1/tasks/" + claimTaskID + "/lease/renew"} {
body := &poisonBody{}
request := httptest.NewRequest(http.MethodPost, path, nil)
request.Body = body
response := httptest.NewRecorder()
router.ServeHTTP(response, request)
if response.Code != authentication.status || response.Body.Len() != 0 || body.reads != 0 || service.calls != 0 {
t.Fatalf("%s = status %d, body %q, reads %d, calls %d", path, response.Code, response.Body.String(), body.reads, service.calls)
}
}
})
}
}
func TestClaimNextStrictJSONSuccessEmptyAndErrors(t *testing.T) {
authenticator := &fakeDeviceAuthenticator{principal: deviceauth.Principal{ID: claimDeviceID}}
service := &fakeTaskClaimService{claimResponse: taskclaim.ClaimResponse{
Task: taskclaim.ClaimedTask{ID: claimTaskID, Version: 3, Title: "测试", ProductURL: "https://mobile.yangkeduo.com/goods.html?goods_id=1", GoodsID: "1", SKUColor: "黑色", SKUSize: "M", Quantity: 1, MaxTotalPrice: "1.00"},
Authorization: taskclaim.ClaimedAuthorization{ID: "70000000-0000-4000-8000-000000000001", TaskVersion: 2, ExpiresAt: "2026-08-04T01:10:00Z"},
Attempt: taskclaim.ClaimedAttempt{ID: claimAttemptID, ClaimToken: strings.Repeat("a", 64), ClaimGeneration: 1, LeaseExpiresAt: "2026-08-04T01:03:00Z"},
}, claimFound: true}
router, _ := newRouterWithClaimService(t, &memoryStore{}, emptyDetailStore{}, emptyEvidenceStore{}, authenticator, service)
valid := `{"session_id":"` + claimSessionID + `","claim_request_id":"` + claimRequestID + `"}`
response := serveClaimJSON(router, "/api/v1/tasks/claim-next", valid, "application/json; charset=utf-8")
if response.Code != http.StatusOK || !strings.Contains(response.Body.String(), strings.Repeat("a", 64)) || service.claimCommand.ClaimRequestID != claimRequestID {
t.Fatalf("claim success = %d %q command %#v", response.Code, response.Body.String(), service.claimCommand)
}
service.claimFound = false
response = serveClaimJSON(router, "/api/v1/tasks/claim-next", valid, "application/json")
if response.Code != http.StatusNoContent || response.Body.Len() != 0 {
t.Fatalf("claim empty = %d %q", response.Code, response.Body.String())
}
tests := []struct {
name, body, contentType, code string
status int
}{
{"unsupported type", valid, "text/plain", "unsupported_media_type", http.StatusUnsupportedMediaType},
{"unknown field", strings.TrimSuffix(valid, "}") + `,"device_id":"` + claimDeviceID + `"}`, "application/json", "invalid_request", http.StatusBadRequest},
{"duplicate session", `{"session_id":"` + claimSessionID + `","session_id":"` + claimSessionID + `","claim_request_id":"` + claimRequestID + `"}`, "application/json", "invalid_request", http.StatusBadRequest},
{"duplicate request", `{"session_id":"` + claimSessionID + `","claim_request_id":"` + claimRequestID + `","claim_request_id":"` + claimRequestID + `"}`, "application/json", "invalid_request", http.StatusBadRequest},
{"extra json", valid + `{}`, "application/json", "invalid_request", http.StatusBadRequest},
{"too large", strings.Repeat(" ", 4097), "application/json", "request_too_large", http.StatusRequestEntityTooLarge},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
response := serveClaimJSON(router, "/api/v1/tasks/claim-next", test.body, test.contentType)
if response.Code != test.status || response.Body.String() != `{"error":"`+test.code+`"}` {
t.Fatalf("response = %d %q", response.Code, response.Body.String())
}
})
}
invalidUTF8 := httptest.NewRequest(http.MethodPost, "/api/v1/tasks/claim-next", bytes.NewReader([]byte{'{', 0xff, '}'}))
invalidUTF8.Header.Set("Content-Type", "application/json")
invalidResponse := httptest.NewRecorder()
router.ServeHTTP(invalidResponse, invalidUTF8)
if invalidResponse.Code != http.StatusBadRequest || invalidResponse.Body.String() != `{"error":"invalid_request"}` {
t.Fatalf("invalid UTF-8 = %d %q", invalidResponse.Code, invalidResponse.Body.String())
}
}
func TestClaimResponseWorstLegalFieldsStayBelowCapAndInvalidServiceOutputFailsClosed(t *testing.T) {
goodsID := strings.Repeat("1", 32)
worst := taskclaim.ClaimResponse{
Task: taskclaim.ClaimedTask{
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: 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}}
service := &fakeTaskClaimService{claimResponse: worst, claimFound: true}
router, _ := newRouterWithClaimService(t, &memoryStore{}, emptyDetailStore{}, emptyEvidenceStore{}, authenticator, service)
request := `{"session_id":"` + claimSessionID + `","claim_request_id":"` + claimRequestID + `"}`
response := serveClaimJSON(router, "/api/v1/tasks/claim-next", request, "application/json")
if response.Code != http.StatusOK || !json.Valid(response.Body.Bytes()) || response.Body.Len() >= 32*1024 {
t.Fatalf("worst legal response = status %d, bytes %d, valid JSON %v", response.Code, response.Body.Len(), json.Valid(response.Body.Bytes()))
}
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"
response.Task.ProductURL += "1"
},
"overlong color": func(response *taskclaim.ClaimResponse) { response.Task.SKUColor += "<" },
"overlong size": func(response *taskclaim.ClaimResponse) { response.Task.SKUSize += "<" },
"overlong money": func(response *taskclaim.ClaimResponse) { response.Task.MaxTotalPrice = strings.Repeat("9", 30) + ".00" },
}
for name, mutate := range mutations {
t.Run(name, func(t *testing.T) {
invalid := worst
mutate(&invalid)
service := &fakeTaskClaimService{claimResponse: invalid, claimFound: true}
router, _ := newRouterWithClaimService(t, &memoryStore{}, emptyDetailStore{}, emptyEvidenceStore{}, authenticator, service)
response := serveClaimJSON(router, "/api/v1/tasks/claim-next", request, "application/json")
if response.Code != http.StatusServiceUnavailable || response.Body.Len() != 0 {
t.Fatalf("invalid service response = %d %q", response.Code, response.Body.String())
}
})
}
}
func TestRenewStrictBindingResponseAndFixedErrors(t *testing.T) {
authenticator := &fakeDeviceAuthenticator{principal: deviceauth.Principal{ID: claimDeviceID}}
service := &fakeTaskClaimService{renewResponse: taskclaim.RenewResponse{TaskID: claimTaskID, AttemptID: claimAttemptID, ClaimGeneration: 1, LeaseExpiresAt: "2026-08-04T01:04:00Z"}}
router, _ := newRouterWithClaimService(t, &memoryStore{}, emptyDetailStore{}, emptyEvidenceStore{}, authenticator, service)
body := `{"renew_request_id":"` + claimRenewID + `","session_id":"` + claimSessionID + `","attempt_id":"` + claimAttemptID + `","claim_generation":1,"claim_token":"` + strings.Repeat("a", 64) + `","expected_lease_expires_at":"2026-08-04T01:03:00Z"}`
response := serveClaimJSON(router, "/api/v1/tasks/"+claimTaskID+"/lease/renew", body, "application/json")
if response.Code != http.StatusOK || strings.Contains(response.Body.String(), "claim_token") || service.renewCommand.TaskID != claimTaskID {
t.Fatalf("renew response = %d %q command %#v", response.Code, response.Body.String(), service.renewCommand)
}
duplicateToken := strings.Replace(body, `"expected_lease_expires_at"`, `"claim_token":"`+strings.Repeat("a", 64)+`","expected_lease_expires_at"`, 1)
response = serveClaimJSON(router, "/api/v1/tasks/"+claimTaskID+"/lease/renew", duplicateToken, "application/json")
if response.Code != http.StatusBadRequest {
t.Fatalf("duplicate token status = %d", response.Code)
}
errorsToCodes := []struct {
err error
status int
body string
}{
{taskclaim.ErrIdempotencyConflict, http.StatusConflict, `{"error":"idempotency_conflict"}`},
{taskclaim.ErrRequiresManual, http.StatusConflict, `{"error":"claim_requires_manual"}`},
{taskclaim.ErrNotCurrent, http.StatusConflict, `{"error":"claim_not_current"}`},
{taskclaim.ErrDeviceInactive, http.StatusUnauthorized, ""},
{errors.New("database path and SQL must stay private"), http.StatusServiceUnavailable, ""},
}
for _, test := range errorsToCodes {
service.renewErr = test.err
response = serveClaimJSON(router, "/api/v1/tasks/"+claimTaskID+"/lease/renew", body, "application/json")
if response.Code != test.status || response.Body.String() != test.body {
t.Fatalf("error %v = %d %q", test.err, response.Code, response.Body.String())
}
}
}
type fakeTaskClaimService struct {
claimResponse taskclaim.ClaimResponse
claimFound bool
claimErr error
renewResponse taskclaim.RenewResponse
renewErr error
claimCommand taskclaim.ClaimCommand
renewCommand taskclaim.RenewCommand
calls int
}
func (service *fakeTaskClaimService) ClaimNext(_ context.Context, _ string, command taskclaim.ClaimCommand) (taskclaim.ClaimResponse, bool, error) {
service.calls++
service.claimCommand = command
return service.claimResponse, service.claimFound, service.claimErr
}
func (service *fakeTaskClaimService) Renew(_ context.Context, _ string, command taskclaim.RenewCommand) (taskclaim.RenewResponse, error) {
service.calls++
service.renewCommand = command
return service.renewResponse, service.renewErr
}
func serveClaimJSON(router http.Handler, path, body, contentType string) *httptest.ResponseRecorder {
request := httptest.NewRequest(http.MethodPost, path, io.NopCloser(strings.NewReader(body)))
request.Header.Set("Content-Type", contentType)
response := httptest.NewRecorder()
router.ServeHTTP(response, request)
return response
}
+84
View File
@@ -0,0 +1,84 @@
package server
import (
"errors"
"mime"
"net/http"
"net/url"
"strconv"
"strings"
"cmbuyer/admin/internal/taskdetail"
"cmbuyer/admin/internal/transport/webui"
"github.com/gin-gonic/gin"
)
const detailViewHeader = "X-CMBuyer-View"
const detailVaryHeader = "X-CMBuyer-View, Accept, Sec-Fetch-Site"
func taskDetailPage(options Options) gin.HandlerFunc {
return func(context *gin.Context) {
context.Header("Vary", detailVaryHeader)
if !options.Sessions.IsAuthenticated(context.Request) {
context.Redirect(http.StatusSeeOther, "/login?return_to="+url.QueryEscape(context.Request.URL.RequestURI()))
return
}
view := context.GetHeader(detailViewHeader)
if view != "" && view != "drawer" {
context.Status(http.StatusBadRequest)
return
}
if view == "drawer" {
if context.GetHeader("Sec-Fetch-Site") != "same-origin" {
context.Status(http.StatusForbidden)
return
}
if !acceptsHTML(context.GetHeader("Accept")) {
context.Status(http.StatusNotAcceptable)
return
}
}
detail, err := options.TaskDetails.Get(context.Request.Context(), context.Param("id"))
if errors.Is(err, taskdetail.ErrNotFound) {
context.Status(http.StatusNotFound)
return
}
if err != nil {
context.Status(http.StatusInternalServerError)
return
}
context.Header("Content-Type", "text/html; charset=utf-8")
context.Status(http.StatusOK)
data := webui.TaskDetailData{Detail: detail}
if view == "drawer" {
if err := webui.RenderTaskDetailFragment(context.Writer, data); err != nil {
_ = context.Error(err)
}
return
}
if err := webui.RenderTaskDetailPage(context.Writer, data); err != nil {
_ = context.Error(err)
}
}
}
func acceptsHTML(header string) bool {
for _, value := range strings.Split(header, ",") {
mediaType, parameters, err := mime.ParseMediaType(strings.TrimSpace(value))
if err != nil || !strings.EqualFold(mediaType, "text/html") {
continue
}
quality := 1.0
if rawQuality, exists := parameters["q"]; exists {
quality, err = strconv.ParseFloat(rawQuality, 64)
if err != nil || quality < 0 || quality > 1 {
continue
}
}
if quality > 0 {
return true
}
}
return false
}
+110
View File
@@ -0,0 +1,110 @@
package server_test
import (
"context"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
"cmbuyer/admin/internal/deviceauth"
"cmbuyer/admin/internal/taskdetail"
)
const detailTaskID = "a3c9f507-7473-4fa6-8d71-8786c34c6301"
func TestTaskDetailRequiresAdminBeforeLookup(t *testing.T) {
details := &recordingDetailStore{detail: taskDetailFixture()}
router, _ := newRouterWithDependencies(t, &memoryStore{}, details, emptyEvidenceStore{}, deviceauth.RejectAllAuthenticator{})
response := serve(router, http.MethodGet, "/tasks/"+detailTaskID, nil, nil)
if response.Code != http.StatusSeeOther || !strings.HasPrefix(response.Header().Get("Location"), "/login?return_to=") || details.calls != 0 {
t.Fatalf("anonymous detail = %d/%q, calls=%d", response.Code, response.Header().Get("Location"), details.calls)
}
}
func TestTaskDetailFullPageAndDrawerShareAuditContent(t *testing.T) {
details := &recordingDetailStore{detail: taskDetailFixture()}
router, _ := newRouterWithDependencies(t, &memoryStore{}, details, emptyEvidenceStore{}, deviceauth.RejectAllAuthenticator{})
cookie := authenticate(t, router)
full := serve(router, http.MethodGet, "/tasks/"+detailTaskID, nil, cookie)
if full.Code != http.StatusOK || !strings.Contains(full.Body.String(), "<!doctype html>") || !strings.Contains(full.Body.String(), `data-task-detail-content`) {
t.Fatalf("full detail = %d/%q", full.Code, full.Body.String())
}
request := httptest.NewRequest(http.MethodGet, "/tasks/"+detailTaskID, nil)
request.AddCookie(cookie)
request.Header.Set("X-CMBuyer-View", "drawer")
request.Header.Set("Accept", "text/html")
request.Header.Set("Sec-Fetch-Site", "same-origin")
fragment := httptest.NewRecorder()
router.ServeHTTP(fragment, request)
if fragment.Code != http.StatusOK || strings.Contains(fragment.Body.String(), "<!doctype html>") || !strings.Contains(fragment.Body.String(), `data-task-detail-content`) {
t.Fatalf("fragment detail = %d/%q", fragment.Code, fragment.Body.String())
}
for _, text := range []string{"测试&lt;script&gt;", "订单已创建,系统尚未付款", "SKU_PANEL_GATE_1", "/evidence/b3c9f507-7473-4fa6-8d71-8786c34c6301", "暂无规格、价格或数量读数", "本页没有重试、再次提交或付款动作"} {
if !strings.Contains(full.Body.String(), text) || !strings.Contains(fragment.Body.String(), text) {
t.Fatalf("shared detail missing %q", text)
}
}
if strings.Contains(full.Body.String(), "<script>") || strings.Contains(fragment.Body.String(), "<script>") {
t.Fatal("task title was not HTML escaped")
}
if got := fragment.Header().Get("Vary"); got != "X-CMBuyer-View, Accept, Sec-Fetch-Site" {
t.Fatalf("fragment Vary = %q", got)
}
if details.calls != 2 {
t.Fatalf("detail store calls = %d, want 2", details.calls)
}
}
func TestTaskDetailRejectsForgedFragmentAndMissingTask(t *testing.T) {
details := &recordingDetailStore{err: taskdetail.ErrNotFound}
router, _ := newRouterWithDependencies(t, &memoryStore{}, details, emptyEvidenceStore{}, deviceauth.RejectAllAuthenticator{})
cookie := authenticate(t, router)
for name, headers := range map[string]map[string]string{
"unknown view": {"X-CMBuyer-View": "xml", "Accept": "text/html"},
"missing fetch site": {"X-CMBuyer-View": "drawer", "Accept": "text/html"},
"cross-site drawer": {"X-CMBuyer-View": "drawer", "Accept": "text/html", "Sec-Fetch-Site": "cross-site"},
"wrong accept": {"X-CMBuyer-View": "drawer", "Accept": "application/json", "Sec-Fetch-Site": "same-origin"},
"html quality zero": {"X-CMBuyer-View": "drawer", "Accept": "text/html;q=0, application/json", "Sec-Fetch-Site": "same-origin"},
"html substring mime": {"X-CMBuyer-View": "drawer", "Accept": "application/nottext/html", "Sec-Fetch-Site": "same-origin"},
} {
t.Run(name, func(t *testing.T) {
request := httptest.NewRequest(http.MethodGet, "/tasks/"+detailTaskID, nil)
request.AddCookie(cookie)
for key, value := range headers {
request.Header.Set(key, value)
}
response := httptest.NewRecorder()
router.ServeHTTP(response, request)
if response.Code < 400 || response.Code >= 500 || response.Body.Len() != 0 {
t.Fatalf("forged fragment = %d/%q", response.Code, response.Body.String())
}
})
}
missing := serve(router, http.MethodGet, "/tasks/not-a-uuid", nil, cookie)
if missing.Code != http.StatusNotFound || missing.Body.Len() != 0 {
t.Fatalf("missing detail = %d/%q", missing.Code, missing.Body.String())
}
}
type recordingDetailStore struct {
detail taskdetail.Detail
err error
calls int
}
func (store *recordingDetailStore) Get(context.Context, string) (taskdetail.Detail, error) {
store.calls++
return store.detail, store.err
}
func taskDetailFixture() taskdetail.Detail {
started := time.Date(2026, 8, 4, 1, 2, 3, 0, time.UTC)
return taskdetail.Detail{
Task: taskdetail.Task{ID: detailTaskID, Source: "MANUAL", Title: "测试<script>", GoodsID: "937122477375", SKUColor: "黑色", SKUSize: "M", Quantity: 2, MaxTotalPrice: "30.00", Status: "WAITING_PAYMENT", Version: 3, CreatedAt: started, UpdatedAt: started},
Authorizations: []taskdetail.Authorization{{ID: "c3c9f507-7473-4fa6-8d71-8786c34c6301", Status: "FENCED", CreatedBy: "admin", TotalPriceCap: "30.00", TaskVersion: 2, CreatedAt: started, ExpiresAt: started.Add(time.Hour)}},
Attempts: []taskdetail.Attempt{{ID: "d3c9f507-7473-4fa6-8d71-8786c34c6301", AuthorizationID: "c3c9f507-7473-4fa6-8d71-8786c34c6301", Status: "CLAIMED", ClaimGeneration: 1, StartedAt: started}},
Evidence: []taskdetail.Evidence{{ID: "b3c9f507-7473-4fa6-8d71-8786c34c6301", AttemptID: "d3c9f507-7473-4fa6-8d71-8786c34c6301", Kind: "SKU_PANEL_GATE_1", PrivacyTier: "INTERNAL_RAW", SHA256: strings.Repeat("a", 64), ByteSize: 100, ContentType: "image/png", Width: 100, Height: 200, CapturedAt: started}},
}
}
@@ -0,0 +1,20 @@
//go:build !windows
package evidence
import (
"fmt"
"os"
)
func syncDirectory(path string) error {
directory, err := os.Open(path)
if err != nil {
return fmt.Errorf("open directory for durability sync: %w", err)
}
defer directory.Close()
if err := directory.Sync(); err != nil {
return fmt.Errorf("sync directory metadata: %w", err)
}
return nil
}
@@ -0,0 +1,35 @@
//go:build windows
package evidence
import (
"fmt"
"syscall"
)
// syncDirectory uses an explicit directory handle because os.Open(...).Sync is not a portable
// Windows directory durability boundary. Any unsupported filesystem or access failure is fatal:
// callers must not make the corresponding evidence row visible in SQLite.
func syncDirectory(path string) error {
pathPointer, err := syscall.UTF16PtrFromString(path)
if err != nil {
return fmt.Errorf("encode directory path for durability sync: %w", err)
}
handle, err := syscall.CreateFile(
pathPointer,
syscall.GENERIC_WRITE,
syscall.FILE_SHARE_READ|syscall.FILE_SHARE_WRITE|syscall.FILE_SHARE_DELETE,
nil,
syscall.OPEN_EXISTING,
syscall.FILE_FLAG_BACKUP_SEMANTICS,
0,
)
if err != nil {
return fmt.Errorf("open directory for durability sync: %w", err)
}
defer syscall.CloseHandle(handle)
if err := syscall.FlushFileBuffers(handle); err != nil {
return fmt.Errorf("flush directory metadata: %w", err)
}
return nil
}
+550
View File
@@ -0,0 +1,550 @@
// Package evidence stores INTERNAL_RAW PNG assets outside the public web tree.
package evidence
import (
"context"
"crypto/rand"
"crypto/sha256"
"database/sql"
"encoding/hex"
"errors"
"fmt"
"image/png"
"io"
"os"
"path/filepath"
"strings"
"sync"
"time"
"cmbuyer/admin/internal/deviceauth"
core "cmbuyer/admin/internal/evidence"
)
var pngSignature = []byte{0x89, 'P', 'N', 'G', 0x0d, 0x0a, 0x1a, 0x0a}
type Store struct {
database *sql.DB
root string
now func() time.Time
random io.Reader
syncDirectory func(string) error
syncFile func(*os.File) error
renameFile func(string, string) error
commitTx func(*sql.Tx) error
mu sync.Mutex
}
func NewStore(database *sql.DB, root string) (*Store, error) {
return newStore(database, root, syncDirectory)
}
func newStore(database *sql.DB, root string, directorySync func(string) error) (*Store, error) {
if database == nil {
return nil, errors.New("evidence database is required")
}
if directorySync == nil {
return nil, errors.New("evidence directory sync is required")
}
if root == "" || !filepath.IsAbs(root) {
return nil, errors.New("evidence root must be an absolute path")
}
absolute, err := filepath.Abs(filepath.Clean(root))
if err != nil {
return nil, fmt.Errorf("resolve evidence root: %w", err)
}
if filepath.Dir(absolute) == absolute {
return nil, errors.New("evidence root cannot be a filesystem root")
}
if err := ensureDurableDirectory(absolute, 0o700, directorySync); err != nil {
return nil, fmt.Errorf("create evidence root: %w", err)
}
// A prior startup may have created the root and then failed its parent sync.
// Existence is therefore never accepted as proof that the directory entry is durable.
if err := directorySync(filepath.Dir(absolute)); err != nil {
return nil, fmt.Errorf("persist evidence root directory: %w", err)
}
if err := os.Chmod(absolute, 0o700); err != nil {
return nil, fmt.Errorf("protect evidence root: %w", err)
}
staging := filepath.Join(absolute, ".staging")
if err := ensureDurableDirectory(staging, 0o700, directorySync); err != nil {
return nil, fmt.Errorf("create evidence staging directory: %w", err)
}
if err := directorySync(absolute); err != nil {
return nil, fmt.Errorf("persist evidence staging directory: %w", err)
}
if err := os.Chmod(staging, 0o700); err != nil {
return nil, fmt.Errorf("protect evidence staging directory: %w", err)
}
if _, err := database.Exec("SELECT storage_key FROM evidence_assets LIMIT 1"); err != nil {
return nil, fmt.Errorf("evidence migration is not available: %w", err)
}
return &Store{
database: database,
root: absolute,
now: time.Now,
random: rand.Reader,
syncDirectory: directorySync,
syncFile: func(file *os.File) error { return file.Sync() },
renameFile: os.Rename,
commitTx: func(transaction *sql.Tx) error { return transaction.Commit() },
}, nil
}
func (store *Store) Stage(reader io.Reader, contentType string) (staged core.StagedFile, resultErr error) {
if reader == nil || contentType != core.PNGContentType {
return core.StagedFile{}, core.ErrInvalid
}
temporary, err := os.CreateTemp(filepath.Join(store.root, ".staging"), "upload-*.png")
if err != nil {
return core.StagedFile{}, err
}
staged.Path = temporary.Name()
defer func() {
if resultErr != nil {
_ = temporary.Close()
_ = os.Remove(staged.Path)
}
}()
if err := temporary.Chmod(0o600); err != nil {
return core.StagedFile{}, err
}
hasher := sha256.New()
written, err := io.Copy(io.MultiWriter(temporary, hasher), io.LimitReader(reader, core.MaxFileBytes+1))
if err != nil {
return core.StagedFile{}, err
}
if written > core.MaxFileBytes {
return core.StagedFile{}, core.ErrTooLarge
}
if written == 0 {
return core.StagedFile{}, core.ErrInvalid
}
if err := temporary.Sync(); err != nil {
return core.StagedFile{}, err
}
if err := temporary.Close(); err != nil {
return core.StagedFile{}, err
}
imageFile, err := os.Open(staged.Path)
if err != nil {
return core.StagedFile{}, err
}
defer imageFile.Close()
width, height, err := validatePNG(imageFile)
if err != nil {
return core.StagedFile{}, err
}
staged.SHA256 = hex.EncodeToString(hasher.Sum(nil))
staged.ByteSize = written
staged.ContentType = core.PNGContentType
staged.Width = width
staged.Height = height
return staged, nil
}
func (store *Store) Discard(staged core.StagedFile) {
if store.isStagedPath(staged.Path) {
_ = os.Remove(staged.Path)
}
}
func (store *Store) Commit(ctx context.Context, principal deviceauth.Principal, metadata core.UploadMetadata, staged core.StagedFile) (core.Asset, bool, error) {
if !store.isStagedPath(staged.Path) || !validPrincipal(principal) || !validMetadata(metadata) || metadata.SHA256 != staged.SHA256 || staged.ContentType != core.PNGContentType || staged.ByteSize < 1 || staged.ByteSize > core.MaxFileBytes || staged.Width < 1 || staged.Height < 1 || staged.Width > core.MaxImageSide || staged.Height > core.MaxImageSide || int64(staged.Width)*int64(staged.Height) > core.MaxImagePixels {
store.Discard(staged)
return core.Asset{}, false, core.ErrInvalid
}
defer store.Discard(staged)
store.mu.Lock()
defer store.mu.Unlock()
transaction, err := store.database.BeginTx(ctx, nil)
if err != nil {
return core.Asset{}, false, err
}
defer transaction.Rollback()
existing, found, err := findByUploadKey(ctx, transaction, principal.ID, metadata.UploadKey)
if err != nil {
return core.Asset{}, false, err
}
if found {
if !sameUpload(existing, principal, metadata, staged) {
return core.Asset{}, false, core.ErrConflict
}
if err := store.verifyStoredFile(existing); err != nil {
return core.Asset{}, false, err
}
if err := store.commitTx(transaction); err != nil {
return core.Asset{}, false, err
}
return existing, true, nil
}
var ownedClaimCount int
if err := transaction.QueryRowContext(ctx, `SELECT COUNT(*) FROM purchase_attempt_claims
WHERE task_id = ? AND attempt_id = ? AND claimed_by_device_id = ? AND closed_at IS NULL`,
metadata.TaskID, metadata.AttemptID, principal.ID).Scan(&ownedClaimCount); err != nil {
return core.Asset{}, false, err
}
// Evidence is auditable only when the authenticated device owns the current attempt. The
// idempotent asset lookup above deliberately remains first so closing a claim later cannot
// destroy stable replay of an already committed screenshot.
if ownedClaimCount != 1 {
return core.Asset{}, false, core.ErrInvalid
}
storageKey := storageKey(metadata.SHA256)
finalPath, err := store.pathForKey(storageKey)
if err != nil {
return core.Asset{}, false, err
}
finalDirectory := filepath.Dir(finalPath)
if err := ensureDurableDirectory(finalDirectory, 0o700, store.syncDirectory); err != nil {
return core.Asset{}, false, err
}
// Always repeat the shard-parent boundary. If an earlier attempt created this
// directory and its parent sync failed, a retry must not trust mere existence.
if err := store.syncDirectory(store.root); err != nil {
return core.Asset{}, false, fmt.Errorf("persist evidence shard directory: %w", err)
}
if err := os.Chmod(finalDirectory, 0o700); err != nil {
return core.Asset{}, false, err
}
if info, statErr := os.Stat(finalPath); statErr == nil {
if !info.Mode().IsRegular() || info.Size() != staged.ByteSize || fileSHA256(finalPath) != staged.SHA256 {
return core.Asset{}, false, errors.New("stored evidence content does not match its key")
}
} else if !errors.Is(statErr, os.ErrNotExist) {
return core.Asset{}, false, statErr
} else {
publishPath, err := store.preparePublishFile(staged, finalDirectory)
if err != nil {
return core.Asset{}, false, err
}
defer os.Remove(publishPath)
if err := store.renameFile(publishPath, finalPath); err != nil {
return core.Asset{}, false, err
}
}
// The publication file was fsynced in this shard before its same-directory rename.
// Persist the final directory entry before SQLite can expose a referencing row.
// A directory sync failure is deliberately fatal; the unreachable file may remain
// as an orphan, but no evidence_assets row may be committed for it.
if err := store.syncDirectory(finalDirectory); err != nil {
return core.Asset{}, false, fmt.Errorf("persist evidence directory entry: %w", err)
}
id, err := newUUID(store.random)
if err != nil {
return core.Asset{}, false, err
}
now := store.now().UTC()
asset := core.Asset{
ID: id, TaskID: metadata.TaskID, AttemptID: metadata.AttemptID,
Kind: metadata.Kind, PrivacyTier: metadata.PrivacyTier, SHA256: staged.SHA256,
ByteSize: staged.ByteSize, ContentType: staged.ContentType, Width: staged.Width, Height: staged.Height,
CapturedAt: metadata.CapturedAt.UTC(), UploadedByDeviceID: principal.ID,
StorageKey: storageKey, CreatedAt: now,
}
_, err = transaction.ExecContext(ctx, `INSERT INTO evidence_assets
(id, upload_key, task_id, attempt_id, kind, privacy_tier, sha256, byte_size, content_type, width_px, height_px, storage_key, uploaded_by_device_id, captured_at, created_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
asset.ID, metadata.UploadKey, asset.TaskID, asset.AttemptID, asset.Kind, asset.PrivacyTier,
asset.SHA256, asset.ByteSize, asset.ContentType, asset.Width, asset.Height, asset.StorageKey,
asset.UploadedByDeviceID, asset.CapturedAt.Format(time.RFC3339Nano), asset.CreatedAt.Format(time.RFC3339Nano))
if err != nil {
return core.Asset{}, false, err
}
if err := store.commitTx(transaction); err != nil {
return core.Asset{}, false, err
}
return asset, false, nil
}
func (store *Store) Open(ctx context.Context, id string) (core.Asset, io.ReadSeekCloser, error) {
if !validUUID(id) {
return core.Asset{}, nil, core.ErrNotFound
}
asset, found, err := findByID(ctx, store.database, id)
if err != nil {
return core.Asset{}, nil, err
}
if !found || asset.StorageKey != storageKey(asset.SHA256) {
return core.Asset{}, nil, core.ErrNotFound
}
path, err := store.pathForKey(asset.StorageKey)
if err != nil {
return core.Asset{}, nil, core.ErrNotFound
}
file, err := os.Open(path)
if errors.Is(err, os.ErrNotExist) {
return core.Asset{}, nil, core.ErrNotFound
}
if err != nil {
return core.Asset{}, nil, err
}
info, err := file.Stat()
if err != nil || !info.Mode().IsRegular() || info.Size() != asset.ByteSize {
_ = file.Close()
if err != nil {
return core.Asset{}, nil, err
}
return core.Asset{}, nil, core.ErrNotFound
}
return asset, file, nil
}
func (store *Store) verifyStoredFile(asset core.Asset) error {
path, err := store.pathForKey(asset.StorageKey)
if err != nil {
return err
}
info, err := os.Stat(path)
if err != nil || !info.Mode().IsRegular() || info.Size() != asset.ByteSize || fileSHA256(path) != asset.SHA256 {
if err != nil {
return err
}
return errors.New("stored evidence file is invalid")
}
return nil
}
func (store *Store) isStagedPath(path string) bool {
if path == "" {
return false
}
relative, err := filepath.Rel(filepath.Join(store.root, ".staging"), filepath.Clean(path))
return err == nil && relative != "." && relative != "" && relative != ".." && !strings.HasPrefix(relative, ".."+string(filepath.Separator)) && !filepath.IsAbs(relative)
}
func (store *Store) pathForKey(key string) (string, error) {
path := filepath.Join(store.root, filepath.FromSlash(key))
relative, err := filepath.Rel(store.root, path)
if err != nil || relative == "." || relative == "" || relative == ".." || strings.HasPrefix(relative, ".."+string(filepath.Separator)) || filepath.IsAbs(relative) {
return "", errors.New("invalid evidence storage key")
}
return path, nil
}
func (store *Store) preparePublishFile(staged core.StagedFile, directory string) (path string, resultErr error) {
source, err := os.Open(staged.Path)
if err != nil {
return "", err
}
defer source.Close()
temporary, err := os.CreateTemp(directory, ".publish-*.png")
if err != nil {
return "", err
}
temporaryPath := temporary.Name()
path = temporaryPath
defer func() {
if resultErr != nil {
_ = temporary.Close()
_ = os.Remove(temporaryPath)
}
}()
if err := temporary.Chmod(0o600); err != nil {
return "", err
}
hasher := sha256.New()
written, err := io.Copy(io.MultiWriter(temporary, hasher), source)
if err != nil {
return "", err
}
if written != staged.ByteSize || hex.EncodeToString(hasher.Sum(nil)) != staged.SHA256 {
return "", errors.New("staged evidence changed before publication")
}
width, height, err := validatePNG(temporary)
if err != nil {
return "", err
}
if width != staged.Width || height != staged.Height {
return "", errors.New("staged evidence dimensions changed before publication")
}
if err := store.syncFile(temporary); err != nil {
return "", fmt.Errorf("sync evidence publication file: %w", err)
}
if err := temporary.Close(); err != nil {
return "", err
}
return path, nil
}
func validatePNG(reader io.ReadSeeker) (int, int, error) {
if _, err := reader.Seek(0, io.SeekStart); err != nil {
return 0, 0, err
}
signature := make([]byte, len(pngSignature))
if _, err := io.ReadFull(reader, signature); err != nil || string(signature) != string(pngSignature) {
return 0, 0, core.ErrInvalid
}
if _, err := reader.Seek(0, io.SeekStart); err != nil {
return 0, 0, err
}
configuration, err := png.DecodeConfig(reader)
if err != nil || configuration.Width < 1 || configuration.Height < 1 || configuration.Width > core.MaxImageSide || configuration.Height > core.MaxImageSide || int64(configuration.Width)*int64(configuration.Height) > core.MaxImagePixels {
return 0, 0, core.ErrInvalid
}
if _, err := reader.Seek(0, io.SeekStart); err != nil {
return 0, 0, err
}
if _, err := png.Decode(reader); err != nil {
return 0, 0, core.ErrInvalid
}
var trailing [1]byte
if count, err := reader.Read(trailing[:]); count != 0 || !errors.Is(err, io.EOF) {
return 0, 0, core.ErrInvalid
}
return configuration.Width, configuration.Height, nil
}
func ensureDurableDirectory(path string, mode os.FileMode, syncParent func(string) error) error {
info, err := os.Stat(path)
if err == nil {
if !info.IsDir() {
return fmt.Errorf("path exists but is not a directory: %s", path)
}
return nil
}
if !errors.Is(err, os.ErrNotExist) {
return err
}
parent := filepath.Dir(path)
if parent == path {
return fmt.Errorf("cannot create filesystem root as a managed directory: %s", path)
}
if err := ensureDurableDirectory(parent, mode, syncParent); err != nil {
return err
}
if err := os.Mkdir(path, mode); err != nil && !errors.Is(err, os.ErrExist) {
return err
}
info, err = os.Stat(path)
if err != nil {
return err
}
if !info.IsDir() {
return fmt.Errorf("path exists but is not a directory: %s", path)
}
if err := os.Chmod(path, mode); err != nil {
return err
}
// Syncing the parent makes creation of this directory durable. This also covers
// a concurrent creator: returning success without the parent sync could otherwise
// allow the following database transaction to outrun the directory entry.
if err := syncParent(parent); err != nil {
return fmt.Errorf("persist directory creation for %s: %w", path, err)
}
return nil
}
func storageKey(hash string) string { return hash[:2] + "/" + hash + ".png" }
func validMetadata(metadata core.UploadMetadata) bool {
return validUUID(metadata.UploadKey) && validUUID(metadata.TaskID) && validUUID(metadata.AttemptID) && metadata.Kind == core.KindSKUPanelGate1 && metadata.PrivacyTier == core.PrivacyInternalRaw && validSHA256(metadata.SHA256) && !metadata.CapturedAt.IsZero() && metadata.CapturedAt.Location() == time.UTC
}
func validPrincipal(principal deviceauth.Principal) bool {
return deviceauth.ValidDeviceID(principal.ID)
}
func validSHA256(value string) bool {
if len(value) != 64 {
return false
}
for _, character := range value {
if !(character >= '0' && character <= '9' || character >= 'a' && character <= 'f') {
return false
}
}
return true
}
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 newUUID(reader io.Reader) (string, error) {
bytes := make([]byte, 16)
if _, err := io.ReadFull(reader, bytes); err != nil {
return "", err
}
bytes[6] = (bytes[6] & 0x0f) | 0x40
bytes[8] = (bytes[8] & 0x3f) | 0x80
encoded := hex.EncodeToString(bytes)
return encoded[:8] + "-" + encoded[8:12] + "-" + encoded[12:16] + "-" + encoded[16:20] + "-" + encoded[20:], nil
}
func fileSHA256(path string) string {
file, err := os.Open(path)
if err != nil {
return ""
}
defer file.Close()
hasher := sha256.New()
if _, err := io.Copy(hasher, file); err != nil {
return ""
}
return hex.EncodeToString(hasher.Sum(nil))
}
type rowScanner interface{ Scan(...any) error }
func findByUploadKey(ctx context.Context, query interface {
QueryRowContext(context.Context, string, ...any) *sql.Row
}, deviceID, uploadKey string) (core.Asset, bool, error) {
return scanAsset(query.QueryRowContext(ctx, `SELECT id, task_id, attempt_id, kind, privacy_tier, sha256, byte_size, content_type, width_px, height_px, storage_key, uploaded_by_device_id, captured_at, created_at FROM evidence_assets WHERE uploaded_by_device_id = ? AND upload_key = ?`, deviceID, uploadKey))
}
func findByID(ctx context.Context, query interface {
QueryRowContext(context.Context, string, ...any) *sql.Row
}, id string) (core.Asset, bool, error) {
return scanAsset(query.QueryRowContext(ctx, `SELECT id, task_id, attempt_id, kind, privacy_tier, sha256, byte_size, content_type, width_px, height_px, storage_key, uploaded_by_device_id, captured_at, created_at FROM evidence_assets WHERE id = ?`, id))
}
func scanAsset(row rowScanner) (core.Asset, bool, error) {
var asset core.Asset
var captured, created string
err := row.Scan(&asset.ID, &asset.TaskID, &asset.AttemptID, &asset.Kind, &asset.PrivacyTier, &asset.SHA256, &asset.ByteSize, &asset.ContentType, &asset.Width, &asset.Height, &asset.StorageKey, &asset.UploadedByDeviceID, &captured, &created)
if errors.Is(err, sql.ErrNoRows) {
return core.Asset{}, false, nil
}
if err != nil {
return core.Asset{}, false, err
}
asset.CapturedAt, err = time.Parse(time.RFC3339Nano, captured)
if err != nil {
return core.Asset{}, false, err
}
asset.CreatedAt, err = time.Parse(time.RFC3339Nano, created)
if err != nil {
return core.Asset{}, false, err
}
return asset, true, nil
}
func sameUpload(asset core.Asset, principal deviceauth.Principal, metadata core.UploadMetadata, staged core.StagedFile) bool {
return asset.TaskID == metadata.TaskID && asset.AttemptID == metadata.AttemptID && asset.Kind == metadata.Kind && asset.PrivacyTier == metadata.PrivacyTier && asset.SHA256 == metadata.SHA256 && asset.ByteSize == staged.ByteSize && asset.ContentType == staged.ContentType && asset.Width == staged.Width && asset.Height == staged.Height && asset.UploadedByDeviceID == principal.ID && asset.CapturedAt.Equal(metadata.CapturedAt)
}
@@ -0,0 +1,604 @@
package evidence
import (
"bytes"
"context"
"crypto/sha256"
"database/sql"
"encoding/hex"
"errors"
"image"
"image/color"
"image/png"
"io"
"os"
"path/filepath"
"runtime"
"strings"
"sync"
"testing"
"time"
"cmbuyer/admin/internal/deviceauth"
core "cmbuyer/admin/internal/evidence"
"cmbuyer/admin/internal/migrations"
"cmbuyer/admin/internal/storage/sqlite"
)
const (
testTaskID = "13c9f507-7473-4fa6-8d71-8786c34c6301"
testAuthID = "23c9f507-7473-4fa6-8d71-8786c34c6301"
testAttemptID = "33c9f507-7473-4fa6-8d71-8786c34c6301"
testUploadKey = "43c9f507-7473-4fa6-8d71-8786c34c6301"
testDeviceID = "53c9f507-7473-4fa6-8d71-8786c34c6301"
)
func TestStageCommitReplayAndOpen(t *testing.T) {
database, store := newTestStore(t)
insertAttemptFixture(t, database)
pngBytes := makePNG(t, 8, 6)
hash := sha256Hex(pngBytes)
metadata := core.UploadMetadata{UploadKey: testUploadKey, TaskID: testTaskID, AttemptID: testAttemptID, Kind: core.KindSKUPanelGate1, PrivacyTier: core.PrivacyInternalRaw, SHA256: hash, CapturedAt: time.Date(2026, 8, 4, 1, 2, 3, 0, time.UTC)}
principal := deviceauth.Principal{ID: testDeviceID}
staged, err := store.Stage(bytes.NewReader(pngBytes), core.PNGContentType)
if err != nil {
t.Fatalf("Stage: %v", err)
}
asset, replayed, err := store.Commit(context.Background(), principal, metadata, staged)
if err != nil || replayed {
t.Fatalf("Commit = replayed %t, err %v", replayed, err)
}
if asset.SHA256 != hash || asset.ByteSize != int64(len(pngBytes)) || asset.Width != 8 || asset.Height != 6 || asset.StorageKey != hash[:2]+"/"+hash+".png" {
t.Fatalf("asset = %#v", asset)
}
opened, reader, err := store.Open(context.Background(), asset.ID)
if err != nil {
t.Fatalf("Open: %v", err)
}
got, err := io.ReadAll(reader)
_ = reader.Close()
if err != nil || !bytes.Equal(got, pngBytes) || opened.ID != asset.ID {
t.Fatalf("opened asset changed: bytes=%t asset=%#v err=%v", bytes.Equal(got, pngBytes), opened, err)
}
replayStage, err := store.Stage(bytes.NewReader(pngBytes), core.PNGContentType)
if err != nil {
t.Fatalf("stage replay: %v", err)
}
replayedAsset, replayed, err := store.Commit(context.Background(), principal, metadata, replayStage)
if err != nil || !replayed || replayedAsset.ID != asset.ID {
t.Fatalf("replay = %#v, %t, %v", replayedAsset, replayed, err)
}
conflictStage, err := store.Stage(bytes.NewReader(pngBytes), core.PNGContentType)
if err != nil {
t.Fatalf("stage conflict: %v", err)
}
conflicting := metadata
conflicting.CapturedAt = conflicting.CapturedAt.Add(time.Second)
if _, _, err := store.Commit(context.Background(), principal, conflicting, conflictStage); !errors.Is(err, core.ErrConflict) {
t.Fatalf("conflicting replay error = %v", err)
}
}
func TestCommitRequiresCurrentClaimOwnerButClosedClaimKeepsHistoricalReplay(t *testing.T) {
database, store := newTestStore(t)
insertAttemptFixture(t, database)
pngBytes := makePNG(t, 4, 3)
metadata := testMetadata(sha256Hex(pngBytes))
stage := func() core.StagedFile {
staged, err := store.Stage(bytes.NewReader(pngBytes), core.PNGContentType)
if err != nil {
t.Fatalf("Stage: %v", err)
}
return staged
}
otherDevice := deviceauth.Principal{ID: "73c9f507-7473-4fa6-8d71-8786c34c6301"}
if _, _, err := store.Commit(context.Background(), otherDevice, metadata, stage()); !errors.Is(err, core.ErrInvalid) {
t.Fatalf("device B upload to device A attempt error = %v", err)
}
principal := deviceauth.Principal{ID: testDeviceID}
asset, replayed, err := store.Commit(context.Background(), principal, metadata, stage())
if err != nil || replayed {
t.Fatalf("owner first Commit = replayed %v, err %v", replayed, err)
}
if _, err := database.Exec("UPDATE purchase_attempt_claims SET closed_at='2026-08-04T03:00:00Z' WHERE attempt_id=?", testAttemptID); err != nil {
t.Fatalf("close claim: %v", err)
}
replayedAsset, replayed, err := store.Commit(context.Background(), principal, metadata, stage())
if err != nil || !replayed || replayedAsset.ID != asset.ID {
t.Fatalf("closed claim historical replay = %#v replayed %v err %v", replayedAsset, replayed, err)
}
newMetadata := metadata
newMetadata.UploadKey = "83c9f507-7473-4fa6-8d71-8786c34c6301"
if _, _, err := store.Commit(context.Background(), principal, newMetadata, stage()); !errors.Is(err, core.ErrInvalid) {
t.Fatalf("closed claim new upload error = %v", err)
}
}
func TestConcurrentReplayCreatesOneAsset(t *testing.T) {
database, store := newTestStore(t)
insertAttemptFixture(t, database)
pngBytes := makePNG(t, 3, 2)
metadata := core.UploadMetadata{UploadKey: testUploadKey, TaskID: testTaskID, AttemptID: testAttemptID, Kind: core.KindSKUPanelGate1, PrivacyTier: core.PrivacyInternalRaw, SHA256: sha256Hex(pngBytes), CapturedAt: time.Date(2026, 8, 4, 1, 2, 3, 0, time.UTC)}
staged := make([]core.StagedFile, 2)
for index := range staged {
var err error
staged[index], err = store.Stage(bytes.NewReader(pngBytes), core.PNGContentType)
if err != nil {
t.Fatalf("Stage %d: %v", index, err)
}
}
var wait sync.WaitGroup
wait.Add(2)
assets := make([]core.Asset, 2)
replays := make([]bool, 2)
errorsSeen := make([]error, 2)
for index := range staged {
go func(index int) {
defer wait.Done()
assets[index], replays[index], errorsSeen[index] = store.Commit(context.Background(), deviceauth.Principal{ID: testDeviceID}, metadata, staged[index])
}(index)
}
wait.Wait()
if errorsSeen[0] != nil || errorsSeen[1] != nil || assets[0].ID != assets[1].ID || replays[0] == replays[1] {
t.Fatalf("concurrent commits assets=%#v replays=%#v errors=%#v", assets, replays, errorsSeen)
}
var count int
if err := database.QueryRow("SELECT COUNT(*) FROM evidence_assets").Scan(&count); err != nil || count != 1 {
t.Fatalf("asset count = %d, err %v", count, err)
}
}
func TestPlatformDirectorySync(t *testing.T) {
if err := syncDirectory(t.TempDir()); err != nil {
t.Fatalf("syncDirectory must either establish the durability boundary or fail closed: %v", err)
}
}
func TestNewStoreRetriesRootParentSyncWhenRootAlreadyExists(t *testing.T) {
database, _ := newTestStore(t)
parent := t.TempDir()
root := filepath.Join(parent, "retry-root")
injected := errors.New("injected root parent sync failure")
if _, err := newStore(database, root, func(string) error { return injected }); !errors.Is(err, injected) {
t.Fatalf("first newStore error = %v, want injected root sync failure", err)
}
if info, err := os.Stat(root); err != nil || !info.IsDir() {
t.Fatalf("failed parent sync must leave root for retry: info=%v err=%v", info, err)
}
var paths []string
store, err := newStore(database, root, func(path string) error {
paths = append(paths, path)
return syncDirectory(path)
})
if err != nil {
t.Fatalf("retry newStore: %v", err)
}
if store == nil || len(paths) == 0 || paths[0] != parent {
t.Fatalf("retry sync paths = %#v, want root parent %q first", paths, parent)
}
}
func TestCommitSyncsShardAndRenameBeforeDatabaseWrite(t *testing.T) {
database, store := newTestStore(t)
insertAttemptFixture(t, database)
pngBytes := makePNG(t, 4, 3)
hash := sha256Hex(pngBytes)
metadata := testMetadata(hash)
staged, err := store.Stage(bytes.NewReader(pngBytes), core.PNGContentType)
if err != nil {
t.Fatalf("Stage: %v", err)
}
finalPath, err := store.pathForKey(storageKey(hash))
if err != nil {
t.Fatalf("final path: %v", err)
}
finalDirectory := filepath.Dir(finalPath)
var events []string
store.syncDirectory = func(path string) error {
var count int
if err := database.QueryRow("SELECT COUNT(*) FROM evidence_assets").Scan(&count); err != nil {
t.Fatalf("count evidence before directory sync: %v", err)
}
if count != 0 {
t.Fatalf("database row became visible before directory sync: %d", count)
}
switch path {
case store.root:
events = append(events, "sync-root")
if path != store.root {
t.Fatalf("shard parent sync path = %q, want evidence root %q", path, store.root)
}
if info, err := os.Stat(finalDirectory); err != nil || !info.IsDir() {
t.Fatalf("shard directory must exist before parent sync: info=%v err=%v", info, err)
}
if _, err := os.Stat(finalPath); !errors.Is(err, os.ErrNotExist) {
t.Fatalf("final file exists before publication: %v", err)
}
case finalDirectory:
events = append(events, "sync-shard")
if info, err := os.Stat(finalPath); err != nil || !info.Mode().IsRegular() {
t.Fatalf("renamed file must exist before shard sync: info=%v err=%v", info, err)
}
default:
t.Fatalf("unexpected extra directory sync: %q", path)
}
return syncDirectory(path)
}
store.syncFile = func(file *os.File) error {
if filepath.Dir(file.Name()) != finalDirectory || !strings.HasPrefix(filepath.Base(file.Name()), ".publish-") {
t.Fatalf("publication temp is not inside shard: %q", file.Name())
}
events = append(events, "sync-file")
return file.Sync()
}
store.renameFile = func(oldPath, newPath string) error {
if filepath.Dir(oldPath) != filepath.Dir(newPath) || newPath != finalPath {
t.Fatalf("rename is not same-directory publication: %q -> %q", oldPath, newPath)
}
events = append(events, "rename")
return os.Rename(oldPath, newPath)
}
if _, replayed, err := store.Commit(context.Background(), deviceauth.Principal{ID: testDeviceID}, metadata, staged); err != nil || replayed {
t.Fatalf("Commit = replayed %t, err %v", replayed, err)
}
if got, want := strings.Join(events, ","), "sync-root,sync-root,sync-file,rename,sync-shard"; got != want {
t.Fatalf("durability order = %q, want %q", got, want)
}
assertEvidenceCount(t, database, 1)
assertNoPublishTemps(t, finalDirectory)
}
func TestCommitDirectorySyncFailuresNeverWriteDatabase(t *testing.T) {
for _, failAt := range []int{1, 2, 3} {
t.Run(map[int]string{1: "new shard parent", 2: "unconditional shard parent", 3: "rename target"}[failAt], func(t *testing.T) {
database, store := newTestStore(t)
insertAttemptFixture(t, database)
pngBytes := makePNG(t, 4, 3)
hash := sha256Hex(pngBytes)
staged, err := store.Stage(bytes.NewReader(pngBytes), core.PNGContentType)
if err != nil {
t.Fatalf("Stage: %v", err)
}
finalPath, err := store.pathForKey(storageKey(hash))
if err != nil {
t.Fatalf("final path: %v", err)
}
injected := errors.New("injected directory sync failure")
calls := 0
store.syncDirectory = func(path string) error {
calls++
if calls == failAt {
return injected
}
return syncDirectory(path)
}
if _, _, err := store.Commit(context.Background(), deviceauth.Principal{ID: testDeviceID}, testMetadata(hash), staged); !errors.Is(err, injected) {
t.Fatalf("Commit error = %v, want injected sync failure", err)
}
if calls != failAt {
t.Fatalf("sync calls = %d, want %d", calls, failAt)
}
assertEvidenceCount(t, database, 0)
_, statErr := os.Stat(finalPath)
if failAt < 3 && !errors.Is(statErr, os.ErrNotExist) {
t.Fatalf("file exists before rename durability boundary: %v", statErr)
}
if failAt == 3 && statErr != nil {
t.Fatalf("post-rename sync failure may leave an orphan file, stat error = %v", statErr)
}
assertNoPublishTemps(t, filepath.Dir(finalPath))
})
}
}
func TestCommitRetriesShardParentSyncAfterPriorFailure(t *testing.T) {
database, store := newTestStore(t)
insertAttemptFixture(t, database)
pngBytes := makePNG(t, 4, 3)
hash := sha256Hex(pngBytes)
finalPath, err := store.pathForKey(storageKey(hash))
if err != nil {
t.Fatalf("final path: %v", err)
}
firstStage, err := store.Stage(bytes.NewReader(pngBytes), core.PNGContentType)
if err != nil {
t.Fatalf("first Stage: %v", err)
}
injected := errors.New("injected first shard parent sync failure")
store.syncDirectory = func(string) error { return injected }
if _, _, err := store.Commit(context.Background(), deviceauth.Principal{ID: testDeviceID}, testMetadata(hash), firstStage); !errors.Is(err, injected) {
t.Fatalf("first Commit error = %v", err)
}
if info, err := os.Stat(filepath.Dir(finalPath)); err != nil || !info.IsDir() {
t.Fatalf("failed first sync must leave the created shard for retry: info=%v err=%v", info, err)
}
assertEvidenceCount(t, database, 0)
secondStage, err := store.Stage(bytes.NewReader(pngBytes), core.PNGContentType)
if err != nil {
t.Fatalf("second Stage: %v", err)
}
var paths []string
store.syncDirectory = func(path string) error {
paths = append(paths, path)
return syncDirectory(path)
}
if _, replayed, err := store.Commit(context.Background(), deviceauth.Principal{ID: testDeviceID}, testMetadata(hash), secondStage); err != nil || replayed {
t.Fatalf("retry Commit = replayed %t, err %v", replayed, err)
}
if len(paths) != 2 || paths[0] != store.root || paths[1] != filepath.Dir(finalPath) {
t.Fatalf("retry sync paths = %#v, want root then shard", paths)
}
assertEvidenceCount(t, database, 1)
assertNoPublishTemps(t, filepath.Dir(finalPath))
}
func TestCommitPublicationFailuresCleanTempAndNeverWriteDatabase(t *testing.T) {
for _, name := range []string{"file sync", "rename"} {
t.Run(name, func(t *testing.T) {
database, store := newTestStore(t)
insertAttemptFixture(t, database)
pngBytes := makePNG(t, 4, 3)
hash := sha256Hex(pngBytes)
staged, err := store.Stage(bytes.NewReader(pngBytes), core.PNGContentType)
if err != nil {
t.Fatalf("Stage: %v", err)
}
finalPath, err := store.pathForKey(storageKey(hash))
if err != nil {
t.Fatalf("final path: %v", err)
}
injected := errors.New("injected publication failure")
if name == "file sync" {
store.syncFile = func(*os.File) error { return injected }
} else {
store.renameFile = func(string, string) error { return injected }
}
if _, _, err := store.Commit(context.Background(), deviceauth.Principal{ID: testDeviceID}, testMetadata(hash), staged); !errors.Is(err, injected) {
t.Fatalf("Commit error = %v, want injected publication failure", err)
}
if _, err := os.Stat(finalPath); !errors.Is(err, os.ErrNotExist) {
t.Fatalf("final file exists after failed publication: %v", err)
}
assertNoPublishTemps(t, filepath.Dir(finalPath))
assertEvidenceCount(t, database, 0)
})
}
}
func TestCommitDatabaseFailuresAfterDurableRenameLeaveOnlyOrphan(t *testing.T) {
for _, test := range []struct {
name string
inject func(*testing.T, *sql.DB, *Store, error)
}{
{
name: "insert",
inject: func(t *testing.T, database *sql.DB, _ *Store, _ error) {
t.Helper()
if _, err := database.Exec(`CREATE TRIGGER fail_evidence_insert BEFORE INSERT ON evidence_assets BEGIN SELECT RAISE(ABORT, 'injected insert failure'); END`); err != nil {
t.Fatalf("create insert failure trigger: %v", err)
}
},
},
{
name: "commit",
inject: func(_ *testing.T, _ *sql.DB, store *Store, injected error) {
store.commitTx = func(*sql.Tx) error { return injected }
},
},
} {
t.Run(test.name, func(t *testing.T) {
database, store := newTestStore(t)
insertAttemptFixture(t, database)
pngBytes := makePNG(t, 4, 3)
hash := sha256Hex(pngBytes)
staged, err := store.Stage(bytes.NewReader(pngBytes), core.PNGContentType)
if err != nil {
t.Fatalf("Stage: %v", err)
}
finalPath, err := store.pathForKey(storageKey(hash))
if err != nil {
t.Fatalf("final path: %v", err)
}
injected := errors.New("injected database failure")
test.inject(t, database, store, injected)
syncCalls := 0
store.syncDirectory = func(path string) error {
syncCalls++
return syncDirectory(path)
}
if _, _, err := store.Commit(context.Background(), deviceauth.Principal{ID: testDeviceID}, testMetadata(hash), staged); err == nil {
t.Fatal("Commit unexpectedly succeeded")
}
if syncCalls != 3 {
t.Fatalf("database failure occurred before both durability syncs: sync calls = %d", syncCalls)
}
if info, err := os.Stat(finalPath); err != nil || !info.Mode().IsRegular() {
t.Fatalf("durable rename may leave only an orphan file: info=%v err=%v", info, err)
}
assertNoPublishTemps(t, filepath.Dir(finalPath))
assertEvidenceCount(t, database, 0)
})
}
}
func TestStageRejectsUnsafeContent(t *testing.T) {
_, store := newTestStore(t)
largePNG := makePNG(t, core.MaxImageSide+1, 1)
pngWithXML := append(makePNG(t, 1, 1), []byte("<hierarchy/>")...)
for name, test := range map[string]struct {
reader io.Reader
contentType string
}{
"wrong content type": {reader: bytes.NewReader(makePNG(t, 1, 1)), contentType: "application/octet-stream"},
"xml": {reader: bytes.NewBufferString("<hierarchy/>"), contentType: core.PNGContentType},
"png with xml tail": {reader: bytes.NewReader(pngWithXML), contentType: core.PNGContentType},
"truncated png": {reader: bytes.NewReader(pngSignature), contentType: core.PNGContentType},
"too wide": {reader: bytes.NewReader(largePNG), contentType: core.PNGContentType},
"too many bytes": {reader: io.LimitReader(zeroReader{}, core.MaxFileBytes+1), contentType: core.PNGContentType},
} {
t.Run(name, func(t *testing.T) {
staged, err := store.Stage(test.reader, test.contentType)
if !errors.Is(err, core.ErrInvalid) && !errors.Is(err, core.ErrTooLarge) {
store.Discard(staged)
t.Fatalf("Stage error = %v", err)
}
})
}
}
func TestCommitRequiresAttemptOwnedByTaskAndLowercaseHash(t *testing.T) {
database, store := newTestStore(t)
insertAttemptFixture(t, database)
pngBytes := makePNG(t, 2, 2)
base := core.UploadMetadata{UploadKey: testUploadKey, TaskID: testTaskID, AttemptID: testAttemptID, Kind: core.KindSKUPanelGate1, PrivacyTier: core.PrivacyInternalRaw, SHA256: sha256Hex(pngBytes), CapturedAt: time.Date(2026, 8, 4, 1, 2, 3, 0, time.UTC)}
for name, mutate := range map[string]func(*core.UploadMetadata){
"unknown attempt": func(value *core.UploadMetadata) { value.AttemptID = "53c9f507-7473-4fa6-8d71-8786c34c6301" },
"uppercase hash": func(value *core.UploadMetadata) {
value.SHA256 = "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA"
},
"wrong kind": func(value *core.UploadMetadata) { value.Kind = "ORDER_CONFIRM" },
} {
t.Run(name, func(t *testing.T) {
staged, err := store.Stage(bytes.NewReader(pngBytes), core.PNGContentType)
if err != nil {
t.Fatalf("Stage: %v", err)
}
metadata := base
mutate(&metadata)
if _, _, err := store.Commit(context.Background(), deviceauth.Principal{ID: testDeviceID}, metadata, staged); !errors.Is(err, core.ErrInvalid) {
t.Fatalf("Commit error = %v", err)
}
})
}
var count int
if err := database.QueryRow("SELECT COUNT(*) FROM evidence_assets").Scan(&count); err != nil || count != 0 {
t.Fatalf("invalid commits created %d assets, err %v", count, err)
}
}
func TestNewStoreRejectsRelativeAndFilesystemRootPaths(t *testing.T) {
database, _ := newTestStore(t)
if _, err := NewStore(database, "relative-evidence"); err == nil {
t.Fatal("relative evidence root succeeded")
}
volumeRoot := filepath.VolumeName(t.TempDir()) + string(filepath.Separator)
if _, err := NewStore(database, volumeRoot); err == nil {
t.Fatal("filesystem root succeeded")
}
}
type zeroReader struct{}
func (zeroReader) Read(buffer []byte) (int, error) {
for index := range buffer {
buffer[index] = 0
}
return len(buffer), nil
}
func newTestStore(t *testing.T) (*sql.DB, *Store) {
t.Helper()
database, err := sqlite.Open(filepath.Join(t.TempDir(), "evidence.db"))
if err != nil {
t.Fatalf("open database: %v", err)
}
t.Cleanup(func() { _ = database.Close() })
_, file, _, ok := runtime.Caller(0)
if !ok {
t.Fatal("locate test")
}
directory := filepath.Join(filepath.Dir(file), "..", "..", "..", "migrations")
if err := migrations.Up(context.Background(), database, directory); err != nil {
t.Fatalf("migrate database: %v", err)
}
store, err := NewStore(database, filepath.Join(t.TempDir(), "assets"))
if err != nil {
t.Fatalf("NewStore: %v", err)
}
return database, store
}
func insertAttemptFixture(t *testing.T, database *sql.DB) {
t.Helper()
timestamp := "2026-08-04T00:00:00Z"
tokenHash := sha256.Sum256([]byte("evidence-device-token"))
if _, err := database.Exec(`INSERT INTO device_credentials
(device_id,display_name,token_sha256,status,created_at,revoked_at)
VALUES (?, 'evidence device', ?, 'ACTIVE', ?, NULL)`, testDeviceID, tokenHash[:], timestamp); err != nil {
t.Fatalf("insert device: %v", err)
}
if _, err := database.Exec(`INSERT INTO tasks (id, source, title, goods_id, sku_color, sku_size, quantity, max_total_price, status, version, created_at, updated_at) VALUES (?, 'MANUAL', 'task', '123', 'black', 'M', 1, '1.00', 'DRAFT', 1, ?, ?)`, testTaskID, timestamp, timestamp); err != nil {
t.Fatalf("insert task: %v", err)
}
if _, err := database.Exec(`INSERT INTO order_authorizations (id, task_id, task_version, start_key, goods_id, sku_color, sku_size, quantity, total_price_cap, status, created_by, created_at, expires_at) VALUES (?, ?, 1, 'start', '123', 'black', 'M', 1, '1.00', 'ACTIVE', 'admin', ?, ?)`, testAuthID, testTaskID, timestamp, timestamp); err != nil {
t.Fatalf("insert authorization: %v", err)
}
if _, err := database.Exec(`INSERT INTO purchase_attempts (id, task_id, authorization_id, claim_generation, status, started_at) VALUES (?, ?, ?, 1, 'CLAIMED', ?)`, testAttemptID, testTaskID, testAuthID, timestamp); err != nil {
t.Fatalf("insert attempt: %v", err)
}
if _, err := database.Exec(`INSERT INTO purchase_attempt_claims
(attempt_id,task_id,authorization_id,claimed_by_device_id,session_id,claim_generation,
task_version,task_title,authorization_task_version,goods_id,sku_color,sku_size,quantity,
total_price_cap,authorization_expires_at,claim_nonce,claim_token_sha256,lease_expires_at,claimed_at,closed_at)
VALUES (?, ?, ?, ?, '63c9f507-7473-4fa6-8d71-8786c34c6301', 1, 1, 'task',
1, '123', 'black', 'M', 1, '1.00', ?, ?, ?, '2026-08-04T02:00:00Z', ?, NULL)`,
testAttemptID, testTaskID, testAuthID, testDeviceID, timestamp, bytes.Repeat([]byte{1}, 32), bytes.Repeat([]byte{2}, 32), timestamp); err != nil {
t.Fatalf("insert claim: %v", err)
}
}
func testMetadata(hash string) core.UploadMetadata {
return core.UploadMetadata{
UploadKey: testUploadKey, TaskID: testTaskID, AttemptID: testAttemptID,
Kind: core.KindSKUPanelGate1, PrivacyTier: core.PrivacyInternalRaw, SHA256: hash,
CapturedAt: time.Date(2026, 8, 4, 1, 2, 3, 0, time.UTC),
}
}
func assertEvidenceCount(t *testing.T, database *sql.DB, want int) {
t.Helper()
var count int
if err := database.QueryRow("SELECT COUNT(*) FROM evidence_assets").Scan(&count); err != nil {
t.Fatalf("count evidence assets: %v", err)
}
if count != want {
t.Fatalf("evidence asset count = %d, want %d", count, want)
}
}
func assertNoPublishTemps(t *testing.T, directory string) {
t.Helper()
entries, err := os.ReadDir(directory)
if err != nil {
t.Fatalf("read shard directory: %v", err)
}
for _, entry := range entries {
if strings.HasPrefix(entry.Name(), ".publish-") {
t.Fatalf("publication temp leaked: %q", entry.Name())
}
}
}
func makePNG(t *testing.T, width, height int) []byte {
t.Helper()
imageData := image.NewNRGBA(image.Rect(0, 0, width, height))
imageData.Set(0, 0, color.NRGBA{R: 12, G: 34, B: 56, A: 255})
var buffer bytes.Buffer
if err := png.Encode(&buffer, imageData); err != nil {
t.Fatalf("encode PNG: %v", err)
}
return buffer.Bytes()
}
func sha256Hex(value []byte) string {
hash := sha256.Sum256(value)
return hex.EncodeToString(hash[:])
}
+35
View File
@@ -0,0 +1,35 @@
// Package sqlite 提供采购服务的 SQLite 驱动注册。
package sqlite
import (
"database/sql"
"fmt"
"github.com/mattn/go-sqlite3"
)
const driverName = "cmbuyer-sqlite3"
func init() {
sql.Register(driverName, &sqlite3.SQLiteDriver{
ConnectHook: func(connection *sqlite3.SQLiteConn) error {
_, err := connection.Exec("PRAGMA foreign_keys = ON", nil)
return err
},
})
}
// Open 打开 SQLite 数据源并逐连接启用外键,避免连接池配置遗漏而绕过授权与任务的引用约束。
func Open(dataSourceName string) (*sql.DB, error) {
database, err := sql.Open(driverName, dataSourceName)
if err != nil {
return nil, err
}
if err := database.Ping(); err != nil {
_ = database.Close()
return nil, fmt.Errorf("ping SQLite database: %w", err)
}
return database, nil
}
@@ -0,0 +1,35 @@
package sqlite_test
import (
"context"
"testing"
"cmbuyer/admin/internal/storage/sqlite"
)
func TestOpen(t *testing.T) {
database, err := sqlite.Open(":memory:")
if err != nil {
t.Fatalf("open SQLite database: %v", err)
}
t.Cleanup(func() {
if err := database.Close(); err != nil {
t.Errorf("close SQLite database: %v", err)
}
})
if err := database.PingContext(context.Background()); err != nil {
t.Fatalf("ping SQLite database: %v", err)
}
database.SetMaxIdleConns(0)
for attempt := 0; attempt < 2; attempt++ {
var foreignKeysEnabled int
if err := database.QueryRow("PRAGMA foreign_keys").Scan(&foreignKeysEnabled); err != nil {
t.Fatalf("read SQLite foreign key setting: %v", err)
}
if foreignKeysEnabled != 1 {
t.Fatalf("SQLite foreign_keys = %d, want 1", foreignKeysEnabled)
}
}
}
+823
View File
@@ -0,0 +1,823 @@
package taskclaim
import (
"context"
"crypto/rand"
"crypto/sha256"
"database/sql"
"encoding/hex"
"errors"
"fmt"
"io"
"math"
"strings"
"sync"
"time"
"cmbuyer/admin/internal/deviceauth"
taskmodel "cmbuyer/admin/internal/tasks"
)
const writeTimeout = 2 * time.Second
type Store struct {
database *sql.DB
secret []byte
leaseTTL time.Duration
now func() time.Time
random io.Reader
randomMu sync.Mutex
writeGate chan struct{}
// The unexported linearization hooks let package tests coordinate real SQLite
// transactions at the first write. Production construction always leaves them nil.
beforeLinearization func()
afterLinearization func()
}
func NewStore(database *sql.DB, secret []byte, leaseTTL time.Duration) (*Store, error) {
if database == nil {
return nil, errors.New("task claim database is required")
}
if len(secret) != sha256.Size {
return nil, errors.New("task claim secret must be 32 bytes")
}
if leaseTTL <= 0 {
return nil, errors.New("task claim lease TTL must be positive")
}
if _, err := database.Exec("SELECT attempt_id, claim_nonce, claim_token_sha256 FROM purchase_attempt_claims LIMIT 1"); err != nil {
return nil, errors.New("task claim migration is not available")
}
store := &Store{
database: database, secret: append([]byte(nil), secret...), leaseTTL: leaseTTL,
now: time.Now, random: rand.Reader, writeGate: make(chan struct{}, 1),
}
if err := store.validateSecretIsolation(); err != nil {
return nil, err
}
if err := store.validateStoredClaims(context.Background()); err != nil {
return nil, err
}
return store, nil
}
// validateSecretIsolation ensures the HMAC key cannot also authenticate a device. The session
// secret comparison is performed while parsing configuration, before either secret is discarded.
func (store *Store) validateSecretIsolation() error {
digest := sha256.Sum256(store.secret)
var count int
if err := store.database.QueryRow(`SELECT COUNT(*) FROM device_credentials WHERE token_sha256 = ?`, digest[:]).Scan(&count); err != nil {
return errors.New("validate task claim secret isolation")
}
if count != 0 {
return errors.New("task claim secret must be isolated from device credentials")
}
return nil
}
// validateStoredClaims covers open and closed claims. Replacing the secret must fail startup;
// silently signing a new token would destroy idempotent recovery and the ownership audit chain.
func (store *Store) validateStoredClaims(ctx context.Context) error {
rows, err := store.database.QueryContext(ctx, `SELECT claims.claimed_by_device_id, claims.task_id, claims.authorization_id,
claims.attempt_id, claims.claim_generation, claims.claim_nonce, typeof(claims.claim_nonce), length(claims.claim_nonce),
claims.claim_token_sha256, typeof(claims.claim_token_sha256), length(claims.claim_token_sha256),
claims.task_title, claims.authorization_task_version, claims.goods_id, claims.sku_color, claims.sku_size,
claims.quantity, claims.total_price_cap, claims.authorization_expires_at, claims.closed_at,
attempts.claim_generation, attempts.status, authorizations.status, tasks.status
FROM purchase_attempt_claims AS claims
LEFT JOIN purchase_attempts AS attempts ON attempts.id = claims.attempt_id
LEFT JOIN order_authorizations AS authorizations ON authorizations.id = claims.authorization_id
LEFT JOIN tasks ON tasks.id = claims.task_id
ORDER BY claims.attempt_id`)
if err != nil {
return errors.New("validate stored task claims")
}
defer rows.Close()
for rows.Next() {
var deviceID, taskID, authorizationID, attemptID string
var generation, authorizationTaskVersion, quantity int
var nonce, storedHash []byte
var nonceType, hashType, title, goodsID, color, size, price, expires string
var nonceLength, hashLength int
var closed, attemptStatus, authorizationStatus, taskStatus sql.NullString
var attemptGeneration sql.NullInt64
if err := rows.Scan(&deviceID, &taskID, &authorizationID, &attemptID, &generation,
&nonce, &nonceType, &nonceLength, &storedHash, &hashType, &hashLength,
&title, &authorizationTaskVersion, &goodsID, &color, &size, &quantity, &price, &expires, &closed,
&attemptGeneration, &attemptStatus, &authorizationStatus, &taskStatus); err != nil {
return errors.New("validate stored task claims")
}
if !deviceauth.ValidDeviceID(deviceID) || !validUUID(taskID) || !validUUID(authorizationID) || !validUUID(attemptID) ||
generation <= 0 || nonceType != "blob" || nonceLength != sha256.Size || len(nonce) != sha256.Size ||
hashType != "blob" || hashLength != sha256.Size || len(storedHash) != sha256.Size ||
authorizationTaskVersion <= 0 || !taskmodel.ValidTaskWireFields(title, goodsID, color, size, price) || quantity <= 0 ||
!validCanonicalTime(expires) || (closed.Valid && !validCanonicalTime(closed.String)) ||
!attemptGeneration.Valid || attemptGeneration.Int64 != int64(generation) ||
!validAttemptStatus(attemptStatus) || !validAuthorizationStatus(authorizationStatus) || !validTaskStatus(taskStatus) {
return errors.New("stored task claim metadata is invalid")
}
token := deriveToken(store.secret, deviceID, taskID, authorizationID, attemptID, generation, nonce)
if !matchingHash(tokenHash(token), storedHash) {
return errors.New("task claim secret does not match stored claims")
}
}
if err := rows.Err(); err != nil {
return errors.New("validate stored task claims")
}
return nil
}
func (store *Store) ClaimNext(ctx context.Context, deviceID string, command ClaimCommand) (ClaimResponse, bool, error) {
if !deviceauth.ValidDeviceID(deviceID) || !validUUID(command.SessionID) || !validUUID(command.ClaimRequestID) {
return ClaimResponse{}, false, ErrInvalid
}
writeCtx, cancel := context.WithTimeout(ctx, writeTimeout)
defer cancel()
select {
case store.writeGate <- struct{}{}:
defer func() { <-store.writeGate }()
case <-writeCtx.Done():
return ClaimResponse{}, false, writeCtx.Err()
}
transaction, err := store.database.BeginTx(writeCtx, nil)
if err != nil {
return ClaimResponse{}, false, err
}
defer transaction.Rollback()
// This must be the transaction's first database statement. The no-op conditional UPDATE takes
// SQLite's write position and linearizes a concurrent credential revocation before any replay,
// EMPTY response, conflict response, candidate read, or other business write is possible.
if store.beforeLinearization != nil {
store.beforeLinearization()
}
active, err := transaction.ExecContext(writeCtx, `UPDATE device_credentials SET status = status
WHERE device_id = ? AND status = 'ACTIVE' AND revoked_at IS NULL`, deviceID)
if err != nil {
return ClaimResponse{}, false, err
}
if ok, err := exactlyOne(active); err != nil {
return ClaimResponse{}, false, err
} else if !ok {
return ClaimResponse{}, false, ErrDeviceInactive
}
if store.afterLinearization != nil {
store.afterLinearization()
}
now, err := store.serverNow()
if err != nil {
return ClaimResponse{}, false, err
}
request, found, err := findClaimRequest(writeCtx, transaction, command.ClaimRequestID)
if err != nil {
return ClaimResponse{}, false, err
}
if found {
if request.DeviceID != deviceID || request.SessionID != command.SessionID {
return ClaimResponse{}, false, ErrIdempotencyConflict
}
switch request.Outcome {
case "EMPTY":
if err := transaction.Commit(); err != nil {
return ClaimResponse{}, false, err
}
return ClaimResponse{}, false, nil
case "BLOCKED":
if err := transaction.Commit(); err != nil {
return ClaimResponse{}, false, err
}
return ClaimResponse{}, false, ErrRequiresManual
case "CLAIMED":
record, found, err := store.loadClaimByAttempt(writeCtx, transaction, request.AttemptID)
if err != nil || !found {
if err == nil {
err = errors.New("stored claim request has no claim")
}
return ClaimResponse{}, false, err
}
response, err := store.responseFor(record, request.ResponseLeaseExpiresAt)
if err != nil {
return ClaimResponse{}, false, err
}
if err := transaction.Commit(); err != nil {
return ClaimResponse{}, false, err
}
return response, true, nil
default:
return ClaimResponse{}, false, errors.New("stored claim request outcome is invalid")
}
}
existing, found, err := store.loadOpenClaimByDevice(writeCtx, transaction, deviceID)
if err != nil {
return ClaimResponse{}, false, err
}
if found {
current := existing.SessionID == command.SessionID && existing.ClosedAt == "" &&
existing.LeaseExpiresAt.After(now) && existing.AuthorizationExpiresAt.After(now) &&
existing.CurrentAuthorizationExpiresAt.After(now) && existing.AuthorizationStatus == "CLAIMED" &&
existing.authorizationConsistent() && existing.recoverableBusinessState()
if !current {
if err := insertClaimRequest(writeCtx, transaction, command.ClaimRequestID, deviceID, command.SessionID, "BLOCKED", "", "", "manual_recovery_required", now); err != nil {
return ClaimResponse{}, false, err
}
if err := transaction.Commit(); err != nil {
return ClaimResponse{}, false, err
}
return ClaimResponse{}, false, ErrRequiresManual
}
response, err := store.responseFor(existing, existing.LeaseExpiresText)
if err != nil {
return ClaimResponse{}, false, err
}
if err := insertClaimRequest(writeCtx, transaction, command.ClaimRequestID, deviceID, command.SessionID, "CLAIMED", existing.AttemptID, existing.LeaseExpiresText, "", now); err != nil {
return ClaimResponse{}, false, err
}
if err := transaction.Commit(); err != nil {
return ClaimResponse{}, false, err
}
return response, true, nil
}
candidate, found, err := findCandidate(writeCtx, transaction, now)
if err != nil {
return ClaimResponse{}, false, err
}
if !found {
if err := insertClaimRequest(writeCtx, transaction, command.ClaimRequestID, deviceID, command.SessionID, "EMPTY", "", "", "", now); err != nil {
return ClaimResponse{}, false, err
}
if err := transaction.Commit(); err != nil {
return ClaimResponse{}, false, err
}
return ClaimResponse{}, false, nil
}
generation, err := nextGeneration(writeCtx, transaction, candidate.TaskID)
if err != nil {
return ClaimResponse{}, false, err
}
attemptID, err := store.newUUID()
if err != nil {
return ClaimResponse{}, false, err
}
nonce, err := store.randomBytes(sha256.Size)
if err != nil {
return ClaimResponse{}, false, err
}
token := deriveToken(store.secret, deviceID, candidate.TaskID, candidate.AuthorizationID, attemptID, generation, nonce)
storedTokenHash := tokenHash(token)
leaseExpires := now.Add(store.leaseTTL)
if candidate.AuthorizationExpiresAt.Before(leaseExpires) {
leaseExpires = candidate.AuthorizationExpiresAt
}
leaseText := formatTime(leaseExpires)
nowText := formatTime(now)
authorizationUpdate, err := transaction.ExecContext(writeCtx, `UPDATE order_authorizations SET status = 'CLAIMED'
WHERE id = ? AND task_id = ? AND status = 'ACTIVE' AND task_version = ?
AND goods_id = ? AND sku_color = ? AND sku_size = ? AND quantity = ?
AND total_price_cap = ? AND expires_at = ?`,
candidate.AuthorizationID, candidate.TaskID, candidate.TaskVersion, candidate.GoodsID,
candidate.SKUColor, candidate.SKUSize, candidate.Quantity, candidate.TotalPriceCap,
candidate.AuthorizationExpiresText)
if err != nil {
return ClaimResponse{}, false, err
}
if ok, err := exactlyOne(authorizationUpdate); err != nil || !ok {
if err == nil {
err = errors.New("authorization changed during claim")
}
return ClaimResponse{}, false, err
}
taskUpdate, err := transaction.ExecContext(writeCtx, `UPDATE tasks SET status = 'CLAIMED', version = version + 1, updated_at = ?
WHERE id = ? AND status = 'PENDING' AND version = ? AND title = ? AND goods_id = ?
AND sku_color = ? AND sku_size = ? AND quantity = ? AND max_total_price = ?`,
nowText, candidate.TaskID, candidate.TaskVersion, candidate.Title, candidate.GoodsID,
candidate.SKUColor, candidate.SKUSize, candidate.Quantity, candidate.TotalPriceCap)
if err != nil {
return ClaimResponse{}, false, err
}
if ok, err := exactlyOne(taskUpdate); err != nil || !ok {
if err == nil {
err = errors.New("task changed during claim")
}
return ClaimResponse{}, false, err
}
if _, err := transaction.ExecContext(writeCtx, `INSERT INTO purchase_attempts
(id, task_id, authorization_id, claim_generation, status, started_at)
VALUES (?, ?, ?, ?, 'CLAIMED', ?)`, attemptID, candidate.TaskID, candidate.AuthorizationID, generation, nowText); err != nil {
return ClaimResponse{}, false, err
}
if _, err := transaction.ExecContext(writeCtx, `INSERT INTO purchase_attempt_claims
(attempt_id, task_id, authorization_id, claimed_by_device_id, session_id, claim_generation,
task_version, task_title, authorization_task_version, goods_id, sku_color, sku_size, quantity,
total_price_cap, authorization_expires_at, claim_nonce, claim_token_sha256,
lease_expires_at, claimed_at, closed_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, NULL)`,
attemptID, candidate.TaskID, candidate.AuthorizationID, deviceID, command.SessionID, generation,
candidate.TaskVersion+1, candidate.Title, candidate.TaskVersion, candidate.GoodsID,
candidate.SKUColor, candidate.SKUSize, candidate.Quantity, candidate.TotalPriceCap,
candidate.AuthorizationExpiresText, nonce, storedTokenHash, leaseText, nowText); err != nil {
return ClaimResponse{}, false, err
}
if err := insertClaimRequest(writeCtx, transaction, command.ClaimRequestID, deviceID, command.SessionID, "CLAIMED", attemptID, leaseText, "", now); err != nil {
return ClaimResponse{}, false, err
}
response := ClaimResponse{
Task: ClaimedTask{ID: candidate.TaskID, Version: candidate.TaskVersion + 1, Title: candidate.Title,
ProductURL: productURL(candidate.GoodsID), GoodsID: candidate.GoodsID, SKUColor: candidate.SKUColor,
SKUSize: candidate.SKUSize, Quantity: candidate.Quantity, MaxTotalPrice: candidate.TotalPriceCap},
Authorization: ClaimedAuthorization{ID: candidate.AuthorizationID, TaskVersion: candidate.TaskVersion, ExpiresAt: candidate.AuthorizationExpiresText},
Attempt: ClaimedAttempt{ID: attemptID, ClaimToken: hex.EncodeToString(token), ClaimGeneration: generation, LeaseExpiresAt: leaseText},
}
if err := transaction.Commit(); err != nil {
return ClaimResponse{}, false, err
}
return response, true, nil
}
func (store *Store) Renew(ctx context.Context, deviceID string, command RenewCommand) (RenewResponse, error) {
providedToken, tokenOK := decodeToken(command.ClaimToken)
if !deviceauth.ValidDeviceID(deviceID) || !validUUID(command.TaskID) || !validUUID(command.RenewRequestID) ||
!validUUID(command.SessionID) || !validUUID(command.AttemptID) || command.ClaimGeneration <= 0 ||
!tokenOK || !validCanonicalTime(command.ExpectedLeaseExpiresAt) {
return RenewResponse{}, ErrInvalid
}
providedHash := tokenHash(providedToken)
writeCtx, cancel := context.WithTimeout(ctx, writeTimeout)
defer cancel()
select {
case store.writeGate <- struct{}{}:
defer func() { <-store.writeGate }()
case <-writeCtx.Done():
return RenewResponse{}, writeCtx.Err()
}
transaction, err := store.database.BeginTx(writeCtx, nil)
if err != nil {
return RenewResponse{}, err
}
defer transaction.Rollback()
// As in ClaimNext, this is deliberately the first database statement in the transaction.
if store.beforeLinearization != nil {
store.beforeLinearization()
}
active, err := transaction.ExecContext(writeCtx, `UPDATE device_credentials SET status = status
WHERE device_id = ? AND status = 'ACTIVE' AND revoked_at IS NULL`, deviceID)
if err != nil {
return RenewResponse{}, err
}
if ok, err := exactlyOne(active); err != nil {
return RenewResponse{}, err
} else if !ok {
return RenewResponse{}, ErrDeviceInactive
}
if store.afterLinearization != nil {
store.afterLinearization()
}
renewal, found, err := findRenewal(writeCtx, transaction, command.RenewRequestID)
if err != nil {
return RenewResponse{}, err
}
if found {
if renewal.TaskID != command.TaskID || renewal.AttemptID != command.AttemptID || renewal.DeviceID != deviceID ||
renewal.SessionID != command.SessionID || renewal.Generation != command.ClaimGeneration ||
renewal.ExpectedLeaseExpiresAt != command.ExpectedLeaseExpiresAt || !matchingHash(renewal.TokenHash, providedHash) {
return RenewResponse{}, ErrIdempotencyConflict
}
response := RenewResponse{TaskID: renewal.TaskID, AttemptID: renewal.AttemptID, ClaimGeneration: renewal.Generation, LeaseExpiresAt: renewal.LeaseExpiresAt}
if err := transaction.Commit(); err != nil {
return RenewResponse{}, err
}
return response, nil
}
now, err := store.serverNow()
if err != nil {
return RenewResponse{}, err
}
record, found, err := store.loadClaimByAttempt(writeCtx, transaction, command.AttemptID)
if err != nil {
return RenewResponse{}, err
}
if !found || record.TaskID != command.TaskID || record.DeviceID != deviceID || record.SessionID != command.SessionID ||
record.Generation != command.ClaimGeneration || !matchingHash(record.TokenHash, providedHash) {
return RenewResponse{}, ErrNotCurrent
}
stateCurrent := record.ClosedAt == "" && record.LeaseExpiresAt.After(now) && record.AuthorizationExpiresAt.After(now) &&
record.CurrentAuthorizationExpiresAt.After(now) && record.AuthorizationStatus == "CLAIMED" &&
record.authorizationConsistent() && record.recoverableBusinessState()
if !stateCurrent || record.LeaseExpiresText != command.ExpectedLeaseExpiresAt {
return RenewResponse{}, ErrNotCurrent
}
leaseExpires := now.Add(store.leaseTTL)
if record.AuthorizationExpiresAt.Before(leaseExpires) {
leaseExpires = record.AuthorizationExpiresAt
}
leaseText := formatTime(leaseExpires)
updated, err := transaction.ExecContext(writeCtx, `UPDATE purchase_attempt_claims SET lease_expires_at = ?
WHERE attempt_id = ? AND lease_expires_at = ? AND closed_at IS NULL`, leaseText, command.AttemptID, command.ExpectedLeaseExpiresAt)
if err != nil {
return RenewResponse{}, err
}
if ok, err := exactlyOne(updated); err != nil || !ok {
if err == nil {
err = ErrNotCurrent
}
return RenewResponse{}, err
}
if _, err := transaction.ExecContext(writeCtx, `INSERT INTO purchase_attempt_lease_renewals
(renew_request_id, task_id, attempt_id, device_id, session_id, claim_generation,
claim_token_sha256, expected_lease_expires_at, lease_expires_at, created_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
command.RenewRequestID, command.TaskID, command.AttemptID, deviceID, command.SessionID,
command.ClaimGeneration, record.TokenHash, command.ExpectedLeaseExpiresAt, leaseText, formatTime(now)); err != nil {
return RenewResponse{}, err
}
response := RenewResponse{TaskID: command.TaskID, AttemptID: command.AttemptID, ClaimGeneration: command.ClaimGeneration, LeaseExpiresAt: leaseText}
if err := transaction.Commit(); err != nil {
return RenewResponse{}, err
}
return response, nil
}
type claimRequestRecord struct {
DeviceID, SessionID, Outcome, AttemptID, ResponseLeaseExpiresAt string
}
func findClaimRequest(ctx context.Context, transaction *sql.Tx, requestID string) (claimRequestRecord, bool, error) {
var record claimRequestRecord
var attemptID, responseLease sql.NullString
err := transaction.QueryRowContext(ctx, `SELECT device_id, session_id, outcome, attempt_id, response_lease_expires_at
FROM task_claim_requests WHERE claim_request_id = ?`, requestID).
Scan(&record.DeviceID, &record.SessionID, &record.Outcome, &attemptID, &responseLease)
if errors.Is(err, sql.ErrNoRows) {
return claimRequestRecord{}, false, nil
}
if err != nil {
return claimRequestRecord{}, false, err
}
record.AttemptID, record.ResponseLeaseExpiresAt = attemptID.String, responseLease.String
return record, true, nil
}
func insertClaimRequest(ctx context.Context, transaction *sql.Tx, requestID, deviceID, sessionID, outcome, attemptID, responseLease, errorCode string, now time.Time) error {
var attempt, lease, code any
if attemptID != "" {
attempt = attemptID
}
if responseLease != "" {
lease = responseLease
}
if errorCode != "" {
code = errorCode
}
_, err := transaction.ExecContext(ctx, `INSERT INTO task_claim_requests
(claim_request_id, device_id, session_id, outcome, attempt_id, response_lease_expires_at, error_code, created_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`, requestID, deviceID, sessionID, outcome, attempt, lease, code, formatTime(now))
return err
}
type renewalRecord struct {
TaskID, AttemptID, DeviceID, SessionID string
Generation int
TokenHash []byte
ExpectedLeaseExpiresAt, LeaseExpiresAt string
}
func findRenewal(ctx context.Context, transaction *sql.Tx, requestID string) (renewalRecord, bool, error) {
var record renewalRecord
err := transaction.QueryRowContext(ctx, `SELECT task_id, attempt_id, device_id, session_id,
claim_generation, claim_token_sha256, expected_lease_expires_at, lease_expires_at
FROM purchase_attempt_lease_renewals WHERE renew_request_id = ?`, requestID).
Scan(&record.TaskID, &record.AttemptID, &record.DeviceID, &record.SessionID, &record.Generation,
&record.TokenHash, &record.ExpectedLeaseExpiresAt, &record.LeaseExpiresAt)
if errors.Is(err, sql.ErrNoRows) {
return renewalRecord{}, false, nil
}
return record, err == nil, err
}
type claimRecord struct {
AttemptID, TaskID, AuthorizationID, DeviceID, SessionID string
Generation, TaskVersion, CurrentTaskVersion int
TaskTitle string
Nonce, TokenHash []byte
LeaseExpiresText, ClaimedAt, ClosedAt string
LeaseExpiresAt time.Time
AuthorizationTaskVersion int
GoodsID, SKUColor, SKUSize, TotalPriceCap string
Quantity int
AuthorizationExpiresText, AuthorizationStatus string
AuthorizationExpiresAt time.Time
CurrentAuthorizationTaskVersion int
CurrentGoodsID, CurrentSKUColor, CurrentSKUSize string
CurrentQuantity int
CurrentTotalPriceCap, CurrentAuthorizationExpiresText string
CurrentAuthorizationExpiresAt time.Time
AttemptStatus, TaskStatus string
CurrentTaskTitle, CurrentTaskGoodsID string
CurrentTaskSKUColor, CurrentTaskSKUSize string
CurrentTaskQuantity int
CurrentTaskMaxTotalPrice string
CurrentAttemptGeneration int
}
const claimSelect = `SELECT claims.attempt_id, claims.task_id, claims.authorization_id,
claims.claimed_by_device_id, claims.session_id, claims.claim_generation, claims.task_version,
claims.task_title, claims.authorization_task_version, claims.goods_id, claims.sku_color,
claims.sku_size, claims.quantity, claims.total_price_cap, claims.authorization_expires_at,
claims.claim_nonce, claims.claim_token_sha256, claims.lease_expires_at,
claims.claimed_at, claims.closed_at, authorizations.task_version, authorizations.goods_id,
authorizations.sku_color, authorizations.sku_size, authorizations.quantity,
authorizations.total_price_cap, authorizations.expires_at, authorizations.status,
attempts.claim_generation, attempts.status, tasks.status, tasks.version, tasks.title, tasks.goods_id,
tasks.sku_color, tasks.sku_size, tasks.quantity, tasks.max_total_price
FROM purchase_attempt_claims AS claims
JOIN order_authorizations AS authorizations
ON authorizations.task_id = claims.task_id AND authorizations.id = claims.authorization_id
JOIN purchase_attempts AS attempts ON attempts.id = claims.attempt_id
JOIN tasks ON tasks.id = claims.task_id `
func (store *Store) loadOpenClaimByDevice(ctx context.Context, transaction *sql.Tx, deviceID string) (claimRecord, bool, error) {
return store.scanClaim(transaction.QueryRowContext(ctx, claimSelect+`WHERE claims.claimed_by_device_id = ? AND claims.closed_at IS NULL`, deviceID))
}
func (store *Store) loadClaimByAttempt(ctx context.Context, transaction *sql.Tx, attemptID string) (claimRecord, bool, error) {
return store.scanClaim(transaction.QueryRowContext(ctx, claimSelect+`WHERE claims.attempt_id = ?`, attemptID))
}
type rowScanner interface{ Scan(...any) error }
func (store *Store) scanClaim(row rowScanner) (claimRecord, bool, error) {
var record claimRecord
var closed sql.NullString
err := row.Scan(&record.AttemptID, &record.TaskID, &record.AuthorizationID, &record.DeviceID,
&record.SessionID, &record.Generation, &record.TaskVersion, &record.TaskTitle,
&record.AuthorizationTaskVersion, &record.GoodsID, &record.SKUColor, &record.SKUSize,
&record.Quantity, &record.TotalPriceCap, &record.AuthorizationExpiresText,
&record.Nonce, &record.TokenHash, &record.LeaseExpiresText, &record.ClaimedAt, &closed,
&record.CurrentAuthorizationTaskVersion, &record.CurrentGoodsID, &record.CurrentSKUColor,
&record.CurrentSKUSize, &record.CurrentQuantity, &record.CurrentTotalPriceCap,
&record.CurrentAuthorizationExpiresText,
&record.AuthorizationStatus, &record.CurrentAttemptGeneration, &record.AttemptStatus,
&record.TaskStatus, &record.CurrentTaskVersion,
&record.CurrentTaskTitle, &record.CurrentTaskGoodsID, &record.CurrentTaskSKUColor,
&record.CurrentTaskSKUSize, &record.CurrentTaskQuantity, &record.CurrentTaskMaxTotalPrice)
if errors.Is(err, sql.ErrNoRows) {
return claimRecord{}, false, nil
}
if err != nil {
return claimRecord{}, false, err
}
record.ClosedAt = closed.String
if !validUUID(record.AttemptID) || !validUUID(record.TaskID) || !validUUID(record.AuthorizationID) ||
!deviceauth.ValidDeviceID(record.DeviceID) || !validUUID(record.SessionID) || record.Generation <= 0 ||
record.CurrentAttemptGeneration != record.Generation ||
record.TaskVersion <= 0 || record.AuthorizationTaskVersion <= 0 ||
!taskmodel.ValidTaskWireFields(record.TaskTitle, record.GoodsID, record.SKUColor, record.SKUSize, record.TotalPriceCap) ||
record.Quantity <= 0 || len(record.Nonce) != sha256.Size || len(record.TokenHash) != sha256.Size {
return claimRecord{}, false, errors.New("stored task claim metadata is invalid")
}
record.LeaseExpiresAt, err = parseCanonicalTime(record.LeaseExpiresText)
if err != nil {
return claimRecord{}, false, errors.New("stored task claim lease is invalid")
}
record.AuthorizationExpiresAt, err = parseCanonicalTime(record.AuthorizationExpiresText)
if err != nil {
return claimRecord{}, false, errors.New("stored authorization expiry is invalid")
}
record.CurrentAuthorizationExpiresAt, err = parseCanonicalTime(record.CurrentAuthorizationExpiresText)
if err != nil {
return claimRecord{}, false, errors.New("current authorization expiry is invalid")
}
derived := deriveToken(store.secret, record.DeviceID, record.TaskID, record.AuthorizationID, record.AttemptID, record.Generation, record.Nonce)
if !matchingHash(tokenHash(derived), record.TokenHash) {
return claimRecord{}, false, errors.New("task claim secret does not match stored claim")
}
return record, true, nil
}
func (record claimRecord) authorizationConsistent() bool {
return taskmodel.ValidAuthorizationFields(record.CurrentGoodsID, record.CurrentSKUColor,
record.CurrentSKUSize, record.CurrentTotalPriceCap) &&
taskmodel.ValidTaskWireFields(record.CurrentTaskTitle, record.CurrentTaskGoodsID,
record.CurrentTaskSKUColor, record.CurrentTaskSKUSize, record.CurrentTaskMaxTotalPrice) &&
record.AuthorizationTaskVersion == record.CurrentAuthorizationTaskVersion &&
record.GoodsID == record.CurrentGoodsID && record.SKUColor == record.CurrentSKUColor &&
record.SKUSize == record.CurrentSKUSize && record.Quantity == record.CurrentQuantity &&
record.TotalPriceCap == record.CurrentTotalPriceCap &&
record.AuthorizationExpiresText == record.CurrentAuthorizationExpiresText &&
record.TaskTitle == record.CurrentTaskTitle && record.GoodsID == record.CurrentTaskGoodsID &&
record.SKUColor == record.CurrentTaskSKUColor && record.SKUSize == record.CurrentTaskSKUSize &&
record.Quantity == record.CurrentTaskQuantity && record.TotalPriceCap == record.CurrentTaskMaxTotalPrice
}
func (record claimRecord) recoverableBusinessState() bool {
if record.TaskStatus == "CLAIMED" && record.AttemptStatus == "CLAIMED" {
return record.CurrentTaskVersion == record.TaskVersion
}
// A later server task may advance this same attempt to ORDERING. A valid lease and identical
// ownership recover that attempt; claim-next still cannot select another task.
return record.TaskStatus == "ORDERING" && record.AttemptStatus == "ORDERING" &&
record.TaskVersion < math.MaxInt && record.CurrentTaskVersion == record.TaskVersion+1
}
func (store *Store) responseFor(record claimRecord, responseLease string) (ClaimResponse, error) {
// Exact idempotent replay is allowed to ignore later source-row drift, but the
// immutable response snapshot itself must still satisfy the current wire bounds.
if !validCanonicalTime(responseLease) ||
!taskmodel.ValidTaskWireFields(record.TaskTitle, record.GoodsID, record.SKUColor, record.SKUSize, record.TotalPriceCap) ||
record.Quantity <= 0 {
return ClaimResponse{}, errors.New("stored claim response snapshot is invalid")
}
token := deriveToken(store.secret, record.DeviceID, record.TaskID, record.AuthorizationID, record.AttemptID, record.Generation, record.Nonce)
return ClaimResponse{
Task: ClaimedTask{ID: record.TaskID, Version: record.TaskVersion, Title: record.TaskTitle,
ProductURL: productURL(record.GoodsID), GoodsID: record.GoodsID, SKUColor: record.SKUColor,
SKUSize: record.SKUSize, Quantity: record.Quantity, MaxTotalPrice: record.TotalPriceCap},
Authorization: ClaimedAuthorization{ID: record.AuthorizationID, TaskVersion: record.AuthorizationTaskVersion, ExpiresAt: record.AuthorizationExpiresText},
Attempt: ClaimedAttempt{ID: record.AttemptID, ClaimToken: hex.EncodeToString(token), ClaimGeneration: record.Generation, LeaseExpiresAt: responseLease},
}, nil
}
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
}
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.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'
ORDER BY authorizations.created_at, authorizations.rowid, authorizations.id`)
if err != nil {
return candidate{}, false, err
}
defer rows.Close()
for rows.Next() {
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
}
item.AuthorizationExpiresAt, err = parseCanonicalTime(item.AuthorizationExpiresText)
if err != nil {
return candidate{}, false, errors.New("stored authorization expiry is invalid")
}
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
}
return item, true, nil
}
}
if err := rows.Err(); err != nil {
return candidate{}, false, err
}
return candidate{}, false, nil
}
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.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 {
return value.Valid && oneOf(value.String, "CLAIMED", "ORDERING", "FAILED", "FENCED", "ABANDONED")
}
func validAuthorizationStatus(value sql.NullString) bool {
return value.Valid && oneOf(value.String, "ACTIVE", "CLAIMED", "FENCED", "CONSUMED", "EXPIRED", "ABANDONED")
}
func validTaskStatus(value sql.NullString) bool {
return value.Valid && oneOf(value.String, "DRAFT", "PENDING", "CLAIMED", "ORDERING", "NEEDS_MANUAL",
"WAITING_PAYMENT", "RECONCILIATION_REQUIRED", "SUCCEEDED", "FAILED", "CANCELED")
}
func oneOf(value string, allowed ...string) bool {
for _, item := range allowed {
if value == item {
return true
}
}
return false
}
func nextGeneration(ctx context.Context, transaction *sql.Tx, taskID string) (int, error) {
var maximum int64
if err := transaction.QueryRowContext(ctx, `SELECT COALESCE(MAX(claim_generation), 0) FROM purchase_attempts WHERE task_id = ?`, taskID).Scan(&maximum); err != nil {
return 0, err
}
if maximum < 0 || maximum >= int64(math.MaxInt) {
return 0, errors.New("task claim generation is exhausted")
}
return int(maximum) + 1, nil
}
func (store *Store) serverNow() (time.Time, error) {
now := store.now().UTC()
if now.IsZero() {
return time.Time{}, errors.New("task claim clock is invalid")
}
return now, nil
}
func (store *Store) randomBytes(size int) ([]byte, error) {
value := make([]byte, size)
store.randomMu.Lock()
_, err := io.ReadFull(store.random, value)
store.randomMu.Unlock()
if err != nil {
return nil, fmt.Errorf("generate task claim randomness: %w", err)
}
return value, nil
}
func (store *Store) newUUID() (string, error) {
value, err := store.randomBytes(16)
if err != nil {
return "", err
}
value[6] = (value[6] & 0x0f) | 0x40
value[8] = (value[8] & 0x3f) | 0x80
encoded := hex.EncodeToString(value)
return encoded[:8] + "-" + encoded[8:12] + "-" + encoded[12:16] + "-" + encoded[16:20] + "-" + encoded[20:], nil
}
func exactlyOne(result sql.Result) (bool, error) {
rows, err := result.RowsAffected()
return rows == 1, err
}
func formatTime(value time.Time) string { return value.UTC().Format(time.RFC3339Nano) }
func parseCanonicalTime(value string) (time.Time, error) {
if !strings.HasSuffix(value, "Z") || strings.TrimSpace(value) != value {
return time.Time{}, ErrInvalid
}
parsed, err := time.Parse(time.RFC3339Nano, value)
if err != nil || parsed.Location() != time.UTC || formatTime(parsed) != value {
return time.Time{}, ErrInvalid
}
return parsed, nil
}
func validCanonicalTime(value string) bool {
_, err := parseCanonicalTime(value)
return err == 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 productURL(goodsID string) string {
return "https://mobile.yangkeduo.com/goods.html?goods_id=" + goodsID
}
File diff suppressed because it is too large Load Diff
+52
View File
@@ -0,0 +1,52 @@
package taskclaim
import (
"crypto/hmac"
"crypto/sha256"
"crypto/subtle"
"encoding/binary"
"encoding/hex"
"hash"
)
const tokenDomain = "cmbuyer/task-claim-token/v1\x00"
func deriveToken(secret []byte, deviceID, taskID, authorizationID, attemptID string, generation int, nonce []byte) []byte {
mac := hmac.New(sha256.New, secret)
_, _ = mac.Write([]byte(tokenDomain))
writeTokenField(mac, deviceID)
writeTokenField(mac, taskID)
writeTokenField(mac, authorizationID)
writeTokenField(mac, attemptID)
var number [8]byte
binary.BigEndian.PutUint64(number[:], uint64(generation))
_, _ = mac.Write(number[:])
writeTokenBytes(mac, nonce)
return mac.Sum(nil)
}
func writeTokenField(writer hash.Hash, value string) { writeTokenBytes(writer, []byte(value)) }
func writeTokenBytes(writer hash.Hash, value []byte) {
var size [4]byte
binary.BigEndian.PutUint32(size[:], uint32(len(value)))
_, _ = writer.Write(size[:])
_, _ = writer.Write(value)
}
func tokenHash(token []byte) []byte {
sum := sha256.Sum256(token)
return sum[:]
}
func matchingHash(left, right []byte) bool {
return len(left) == sha256.Size && len(right) == sha256.Size && subtle.ConstantTimeCompare(left, right) == 1
}
func decodeToken(value string) ([]byte, bool) {
if len(value) != sha256.Size*2 {
return nil, false
}
decoded, err := hex.DecodeString(value)
return decoded, err == nil && hex.EncodeToString(decoded) == value
}
+100
View File
@@ -0,0 +1,100 @@
// Package taskclaim owns the atomic task-claim and lease-renewal boundary.
// A claim token proves only ownership of one attempt; it is never permission to submit an order.
package taskclaim
import (
"context"
"errors"
"math"
taskmodel "cmbuyer/admin/internal/tasks"
)
var (
ErrInvalid = errors.New("invalid task claim request")
ErrIdempotencyConflict = errors.New("task claim idempotency conflict")
ErrRequiresManual = errors.New("task claim requires manual recovery")
ErrNotCurrent = errors.New("task claim is not current")
ErrDeviceInactive = errors.New("task claim device is inactive")
)
type ClaimCommand struct {
SessionID string `json:"session_id"`
ClaimRequestID string `json:"claim_request_id"`
}
type RenewCommand struct {
TaskID string `json:"-"`
RenewRequestID string `json:"renew_request_id"`
SessionID string `json:"session_id"`
AttemptID string `json:"attempt_id"`
ClaimGeneration int `json:"claim_generation"`
ClaimToken string `json:"claim_token"`
ExpectedLeaseExpiresAt string `json:"expected_lease_expires_at"`
}
type ClaimedTask struct {
ID string `json:"id"`
Version int `json:"version"`
Title string `json:"title"`
ProductURL string `json:"product_url"`
GoodsID string `json:"goods_id"`
SKUColor string `json:"sku_color"`
SKUSize string `json:"sku_size"`
Quantity int `json:"quantity"`
MaxTotalPrice string `json:"max_total_price"`
}
type ClaimedAuthorization struct {
ID string `json:"id"`
TaskVersion int `json:"task_version"`
ExpiresAt string `json:"expires_at"`
}
type ClaimedAttempt struct {
ID string `json:"id"`
ClaimToken string `json:"claim_token"`
ClaimGeneration int `json:"claim_generation"`
LeaseExpiresAt string `json:"lease_expires_at"`
}
type ClaimResponse struct {
Task ClaimedTask `json:"task"`
Authorization ClaimedAuthorization `json:"authorization"`
Attempt ClaimedAttempt `json:"attempt"`
}
// ValidClaimResponse closes the service-to-HTTP boundary as well as the SQLite
// boundary. A fake or future Service implementation cannot bypass the same field
// limits enforced while creating and claiming the task.
func ValidClaimResponse(response ClaimResponse) bool {
authorizationExpires, authorizationErr := parseCanonicalTime(response.Authorization.ExpiresAt)
leaseExpires, leaseErr := parseCanonicalTime(response.Attempt.LeaseExpiresAt)
return validUUID(response.Task.ID) && response.Task.Version > 0 &&
response.Authorization.TaskVersion > 0 && response.Authorization.TaskVersion < math.MaxInt &&
response.Task.Version == response.Authorization.TaskVersion+1 &&
taskmodel.ValidTaskWireFields(response.Task.Title, response.Task.GoodsID,
response.Task.SKUColor, response.Task.SKUSize, response.Task.MaxTotalPrice) &&
response.Task.ProductURL == productURL(response.Task.GoodsID) && response.Task.Quantity > 0 &&
validUUID(response.Authorization.ID) && authorizationErr == nil &&
validUUID(response.Attempt.ID) && response.Attempt.ClaimGeneration > 0 &&
len(response.Attempt.ClaimToken) == 64 && tokenTextValid(response.Attempt.ClaimToken) &&
leaseErr == nil && !leaseExpires.After(authorizationExpires)
}
func tokenTextValid(value string) bool {
_, ok := decodeToken(value)
return ok
}
type RenewResponse struct {
TaskID string `json:"task_id"`
AttemptID string `json:"attempt_id"`
ClaimGeneration int `json:"claim_generation"`
LeaseExpiresAt string `json:"lease_expires_at"`
}
type Service interface {
ClaimNext(context.Context, string, ClaimCommand) (ClaimResponse, bool, error)
Renew(context.Context, string, RenewCommand) (RenewResponse, error)
}
+60
View File
@@ -0,0 +1,60 @@
// Package taskdetail provides a read-only audit projection for one task.
package taskdetail
import (
"context"
"errors"
"time"
)
var ErrNotFound = errors.New("task detail not found")
type Store interface {
Get(context.Context, string) (Detail, error)
}
type Detail struct {
Task Task
Authorizations []Authorization
Attempts []Attempt
Submissions []Submission
Evidence []Evidence
}
type Task struct {
ID, Source, Title, GoodsID, SKUColor, SKUSize, MaxTotalPrice, Status string
Quantity, Version int
CreatedAt, UpdatedAt time.Time
}
type Authorization struct {
ID, Status, CreatedBy, TotalPriceCap string
TaskVersion int
CreatedAt, ExpiresAt time.Time
}
type Attempt struct {
ID, AuthorizationID, Status string
ClaimGeneration int
Gate1UnitPrice *string
Gate2UnitPrice *string
QuantityRead *int
ConfirmAmount *string
FailureCode *string
StartedAt time.Time
FinishedAt *time.Time
}
type Submission struct {
ID, AuthorizationID, AttemptID, Status string
Gate1UnitPrice, Gate2UnitPrice, ConfirmAmount string
QuantityRead int
CreatedAt time.Time
ResolvedAt *time.Time
}
type Evidence struct {
ID, AttemptID, Kind, PrivacyTier, SHA256, ContentType string
ByteSize, Width, Height int64
CapturedAt time.Time
}
+204
View File
@@ -0,0 +1,204 @@
package taskdetail
import (
"context"
"database/sql"
"errors"
"fmt"
"time"
)
type SQLiteStore struct{ database *sql.DB }
func NewSQLiteStore(database *sql.DB) (*SQLiteStore, error) {
if database == nil {
return nil, errors.New("task detail database is required")
}
if _, err := database.Exec("SELECT storage_key FROM evidence_assets LIMIT 1"); err != nil {
return nil, fmt.Errorf("task detail migration is not available: %w", err)
}
return &SQLiteStore{database: database}, nil
}
func (store *SQLiteStore) Get(ctx context.Context, id string) (Detail, error) {
if !validUUID(id) {
return Detail{}, ErrNotFound
}
tx, err := store.database.BeginTx(ctx, &sql.TxOptions{ReadOnly: true})
if err != nil {
return Detail{}, err
}
defer tx.Rollback()
var detail Detail
var created, updated string
err = tx.QueryRowContext(ctx, `SELECT id, source, title, goods_id, sku_color, sku_size, quantity, max_total_price, status, version, created_at, updated_at FROM tasks WHERE id = ?`, id).Scan(
&detail.Task.ID, &detail.Task.Source, &detail.Task.Title, &detail.Task.GoodsID, &detail.Task.SKUColor, &detail.Task.SKUSize,
&detail.Task.Quantity, &detail.Task.MaxTotalPrice, &detail.Task.Status, &detail.Task.Version, &created, &updated,
)
if errors.Is(err, sql.ErrNoRows) {
return Detail{}, ErrNotFound
}
if err != nil {
return Detail{}, err
}
if detail.Task.CreatedAt, err = parseTime(created); err != nil {
return Detail{}, err
}
if detail.Task.UpdatedAt, err = parseTime(updated); err != nil {
return Detail{}, err
}
if detail.Authorizations, err = readAuthorizations(ctx, tx, id); err != nil {
return Detail{}, err
}
if detail.Attempts, err = readAttempts(ctx, tx, id); err != nil {
return Detail{}, err
}
if detail.Submissions, err = readSubmissions(ctx, tx, id); err != nil {
return Detail{}, err
}
if detail.Evidence, err = readEvidence(ctx, tx, id); err != nil {
return Detail{}, err
}
if err := tx.Commit(); err != nil {
return Detail{}, err
}
return detail, nil
}
func readAuthorizations(ctx context.Context, tx *sql.Tx, taskID string) ([]Authorization, error) {
rows, err := tx.QueryContext(ctx, `SELECT id, task_version, total_price_cap, status, created_by, created_at, expires_at FROM order_authorizations WHERE task_id = ? ORDER BY created_at DESC, id DESC`, taskID)
if err != nil {
return nil, err
}
defer rows.Close()
result := []Authorization{}
for rows.Next() {
var item Authorization
var created, expires string
if err := rows.Scan(&item.ID, &item.TaskVersion, &item.TotalPriceCap, &item.Status, &item.CreatedBy, &created, &expires); err != nil {
return nil, err
}
if item.CreatedAt, err = parseTime(created); err != nil {
return nil, err
}
if item.ExpiresAt, err = parseTime(expires); err != nil {
return nil, err
}
result = append(result, item)
}
return result, rows.Err()
}
func readAttempts(ctx context.Context, tx *sql.Tx, taskID string) ([]Attempt, error) {
rows, err := tx.QueryContext(ctx, `SELECT id, authorization_id, claim_generation, status, gate1_unit_price, gate2_unit_price, quantity_read, confirm_amount, failure_code, started_at, finished_at FROM purchase_attempts WHERE task_id = ? ORDER BY started_at DESC, id DESC`, taskID)
if err != nil {
return nil, err
}
defer rows.Close()
result := []Attempt{}
for rows.Next() {
var item Attempt
var gate1, gate2, confirm, failure, finished sql.NullString
var quantity sql.NullInt64
var started string
if err := rows.Scan(&item.ID, &item.AuthorizationID, &item.ClaimGeneration, &item.Status, &gate1, &gate2, &quantity, &confirm, &failure, &started, &finished); err != nil {
return nil, err
}
item.Gate1UnitPrice, item.Gate2UnitPrice, item.ConfirmAmount, item.FailureCode = stringPointer(gate1), stringPointer(gate2), stringPointer(confirm), stringPointer(failure)
if quantity.Valid {
value := int(quantity.Int64)
item.QuantityRead = &value
}
if item.StartedAt, err = parseTime(started); err != nil {
return nil, err
}
if finished.Valid {
value, parseErr := parseTime(finished.String)
if parseErr != nil {
return nil, parseErr
}
item.FinishedAt = &value
}
result = append(result, item)
}
return result, rows.Err()
}
func readSubmissions(ctx context.Context, tx *sql.Tx, taskID string) ([]Submission, error) {
rows, err := tx.QueryContext(ctx, `SELECT id, authorization_id, attempt_id, status, gate1_unit_price, gate2_unit_price, quantity_read, confirm_amount, created_at, resolved_at FROM order_submissions WHERE task_id = ? ORDER BY created_at DESC, id DESC`, taskID)
if err != nil {
return nil, err
}
defer rows.Close()
result := []Submission{}
for rows.Next() {
var item Submission
var created string
var resolved sql.NullString
if err := rows.Scan(&item.ID, &item.AuthorizationID, &item.AttemptID, &item.Status, &item.Gate1UnitPrice, &item.Gate2UnitPrice, &item.QuantityRead, &item.ConfirmAmount, &created, &resolved); err != nil {
return nil, err
}
if item.CreatedAt, err = parseTime(created); err != nil {
return nil, err
}
if resolved.Valid {
value, parseErr := parseTime(resolved.String)
if parseErr != nil {
return nil, parseErr
}
item.ResolvedAt = &value
}
result = append(result, item)
}
return result, rows.Err()
}
func readEvidence(ctx context.Context, tx *sql.Tx, taskID string) ([]Evidence, error) {
rows, err := tx.QueryContext(ctx, `SELECT id, attempt_id, kind, privacy_tier, sha256, byte_size, content_type, width_px, height_px, captured_at FROM evidence_assets WHERE task_id = ? ORDER BY captured_at, created_at, id`, taskID)
if err != nil {
return nil, err
}
defer rows.Close()
result := []Evidence{}
for rows.Next() {
var item Evidence
var captured string
if err := rows.Scan(&item.ID, &item.AttemptID, &item.Kind, &item.PrivacyTier, &item.SHA256, &item.ByteSize, &item.ContentType, &item.Width, &item.Height, &captured); err != nil {
return nil, err
}
if item.CapturedAt, err = parseTime(captured); err != nil {
return nil, err
}
result = append(result, item)
}
return result, rows.Err()
}
func parseTime(value string) (time.Time, error) { return time.Parse(time.RFC3339Nano, value) }
func stringPointer(value sql.NullString) *string {
if !value.Valid {
return nil
}
copy := value.String
return &copy
}
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')
}
+97
View File
@@ -0,0 +1,97 @@
package taskdetail
import (
"context"
"database/sql"
"errors"
"path/filepath"
"runtime"
"strings"
"testing"
"cmbuyer/admin/internal/migrations"
"cmbuyer/admin/internal/storage/sqlite"
)
const (
detailTask = "a3c9f507-7473-4fa6-8d71-8786c34c6301"
detailAuth = "b3c9f507-7473-4fa6-8d71-8786c34c6301"
detailTry = "c3c9f507-7473-4fa6-8d71-8786c34c6301"
detailDevice = "e3c9f507-7473-4fa6-8d71-8786c34c6301"
)
func TestSQLiteStoreReturnsOnlyPersistedAuditFacts(t *testing.T) {
database := openDetailDatabase(t)
timestamp := "2026-08-04T00:00:00Z"
if _, err := database.Exec(`INSERT INTO device_credentials
(device_id,display_name,token_sha256,status,created_at,revoked_at)
VALUES (?, 'detail test device', zeroblob(32), 'ACTIVE', ?, NULL)`, detailDevice, timestamp); err != nil {
t.Fatalf("insert device: %v", err)
}
if _, err := database.Exec(`INSERT INTO tasks (id, source, title, goods_id, sku_color, sku_size, quantity, max_total_price, status, version, created_at, updated_at) VALUES (?, 'MANUAL', 'shirt', '123', 'black', 'M', 2, '30.00', 'CLAIMED', 3, ?, ?)`, detailTask, timestamp, timestamp); err != nil {
t.Fatalf("insert task: %v", err)
}
if _, err := database.Exec(`INSERT INTO order_authorizations (id, task_id, task_version, start_key, goods_id, sku_color, sku_size, quantity, total_price_cap, status, created_by, created_at, expires_at) VALUES (?, ?, 2, 'start', '123', 'black', 'M', 2, '30.00', 'CLAIMED', 'admin', ?, ?)`, detailAuth, detailTask, timestamp, timestamp); err != nil {
t.Fatalf("insert authorization: %v", err)
}
if _, err := database.Exec(`INSERT INTO purchase_attempts (id, task_id, authorization_id, claim_generation, status, started_at) VALUES (?, ?, ?, 1, 'CLAIMED', ?)`, detailTry, detailTask, detailAuth, timestamp); err != nil {
t.Fatalf("insert attempt: %v", err)
}
if _, err := database.Exec(`INSERT INTO purchase_attempt_claims
(attempt_id,task_id,authorization_id,claimed_by_device_id,session_id,claim_generation,
task_version,task_title,authorization_task_version,goods_id,sku_color,sku_size,quantity,
total_price_cap,authorization_expires_at,claim_nonce,claim_token_sha256,lease_expires_at,claimed_at,closed_at)
VALUES (?, ?, ?, ?, 'f3c9f507-7473-4fa6-8d71-8786c34c6301', 1, 3, 'shirt',
2, '123', 'black', 'M', 2, '30.00', ?, zeroblob(32), zeroblob(32),
'2026-08-04T00:05:00Z', ?, NULL)`, detailTry, detailTask, detailAuth, detailDevice, timestamp, timestamp); err != nil {
t.Fatalf("insert claim: %v", err)
}
hash := strings.Repeat("a", 64)
if _, err := database.Exec(`INSERT INTO evidence_assets (id, upload_key, task_id, attempt_id, kind, privacy_tier, sha256, byte_size, content_type, width_px, height_px, storage_key, uploaded_by_device_id, captured_at, created_at) VALUES ('d3c9f507-7473-4fa6-8d71-8786c34c6301', 'upload', ?, ?, 'SKU_PANEL_GATE_1', 'INTERNAL_RAW', ?, 100, 'image/png', 10, 20, ?, ?, ?, ?)`, detailTask, detailTry, hash, "aa/"+hash+".png", detailDevice, timestamp, timestamp); err != nil {
t.Fatalf("insert evidence: %v", err)
}
store, err := NewSQLiteStore(database)
if err != nil {
t.Fatalf("NewSQLiteStore: %v", err)
}
detail, err := store.Get(context.Background(), detailTask)
if err != nil {
t.Fatalf("Get: %v", err)
}
if detail.Task.ID != detailTask || detail.Task.Status != "CLAIMED" || len(detail.Authorizations) != 1 || len(detail.Attempts) != 1 || len(detail.Evidence) != 1 || len(detail.Submissions) != 0 {
t.Fatalf("detail = %#v", detail)
}
if detail.Attempts[0].Gate1UnitPrice != nil || detail.Attempts[0].FailureCode != nil {
t.Fatalf("missing attempt facts were fabricated: %#v", detail.Attempts[0])
}
}
func TestSQLiteStoreFailsClosedForMalformedAndMissingIDs(t *testing.T) {
database := openDetailDatabase(t)
store, err := NewSQLiteStore(database)
if err != nil {
t.Fatalf("NewSQLiteStore: %v", err)
}
for _, id := range []string{"../database", "not-a-uuid", "a3c9f507-7473-1fa6-8d71-8786c34c6301"} {
if _, err := store.Get(context.Background(), id); !errors.Is(err, ErrNotFound) {
t.Fatalf("Get(%q) error = %v", id, err)
}
}
}
func openDetailDatabase(t *testing.T) *sql.DB {
t.Helper()
database, err := sqlite.Open(filepath.Join(t.TempDir(), "details.db"))
if err != nil {
t.Fatalf("open database: %v", err)
}
t.Cleanup(func() { _ = database.Close() })
_, file, _, ok := runtime.Caller(0)
if !ok {
t.Fatal("locate migration directory")
}
if err := migrations.Up(context.Background(), database, filepath.Join(filepath.Dir(file), "..", "..", "migrations")); err != nil {
t.Fatalf("migrate database: %v", err)
}
return database
}
+133
View File
@@ -0,0 +1,133 @@
package tasks
import (
"errors"
"math"
"math/big"
"sort"
"strings"
"time"
_ "time/tzdata"
)
const maxStartItems = 100
var (
ErrStartConflict = errors.New("purchase start conflicts with current task state")
ErrInvalidStart = errors.New("invalid purchase start request")
)
type StartPolicy struct {
AuthorizationTTL time.Duration
MaxQuantity int
MaxTotalPrice string
}
type StartItem struct {
TaskID string `json:"task_id"`
ExpectedTaskVersion int `json:"expected_task_version"`
}
type StartCommand struct {
StartKey string `json:"start_key"`
Tasks []StartItem `json:"tasks"`
}
type AuthorizedTask struct {
TaskID string `json:"task_id"`
TaskVersion int `json:"task_version"`
AuthorizationID string `json:"authorization_id"`
ExpiresAt time.Time `json:"expires_at"`
}
type StartResult struct {
StartKey string `json:"start_key"`
AuthorizedCount int `json:"authorized_count"`
Tasks []AuthorizedTask `json:"tasks"`
PaymentAutomated bool `json:"payment_automated"`
}
type TaskFilter struct{ Keyword, Status, CreatedFrom, CreatedTo string }
type TaskRow struct {
ID, Title, GoodsID, SKUColor, SKUSize, MaxTotalPrice, Status string
Quantity, Version int
CreatedAt time.Time
}
func normalizeCents(value string) (string, *big.Int, bool) {
if value == "" || len(value) > MaxMoneyASCIICharacters || strings.TrimSpace(value) != value {
return "", nil, false
}
parts := strings.Split(value, ".")
if len(parts) != 2 || len(parts[0]) == 0 || len(parts[1]) != 2 || (len(parts[0]) > 1 && parts[0][0] == '0') {
return "", nil, false
}
for _, part := range parts {
for _, ch := range part {
if ch < '0' || ch > '9' {
return "", nil, false
}
}
}
cents := new(big.Int)
if _, ok := cents.SetString(parts[0]+parts[1], 10); !ok || cents.Sign() <= 0 {
return "", nil, false
}
return value, cents, true
}
func ValidCanonicalMoney(value string) bool {
canonical, _, ok := normalizeCents(value)
return ok && canonical == value
}
func startItems(command StartCommand) ([]StartItem, error) {
if !validUUID(command.StartKey) || len(command.Tasks) == 0 || len(command.Tasks) > maxStartItems {
return nil, ErrInvalidStart
}
items := append([]StartItem(nil), command.Tasks...)
sort.Slice(items, func(i, j int) bool { return items[i].TaskID < items[j].TaskID })
for i, item := range items {
if !validUUID(item.TaskID) || item.ExpectedTaskVersion <= 0 || item.ExpectedTaskVersion == math.MaxInt || (i > 0 && item.TaskID == items[i-1].TaskID) {
return nil, ErrInvalidStart
}
}
return items, nil
}
func validTaskStatus(value string) bool {
if value == "" {
return true
}
for _, status := range []string{"DRAFT", "PENDING", "CLAIMED", "ORDERING", "NEEDS_MANUAL", "WAITING_PAYMENT", "RECONCILIATION_REQUIRED", "SUCCEEDED", "FAILED", "CANCELED"} {
if value == status {
return true
}
}
return false
}
func ShanghaiRange(from, to string) (time.Time, time.Time, error) {
if from == "" && to == "" {
return time.Time{}, time.Time{}, nil
}
location, err := time.LoadLocation("Asia/Shanghai")
if err != nil {
return time.Time{}, time.Time{}, err
}
parse := func(value string) (time.Time, error) { return time.ParseInLocation("2006-01-02", value, location) }
var start, end time.Time
if from != "" {
start, err = parse(from)
if err != nil {
return time.Time{}, time.Time{}, ErrInvalidStart
}
start = start.UTC()
}
if to != "" {
end, err = parse(to)
if err != nil {
return time.Time{}, time.Time{}, ErrInvalidStart
}
end = end.AddDate(0, 0, 1).UTC()
}
if !start.IsZero() && !end.IsZero() && !start.Before(end) {
return time.Time{}, time.Time{}, ErrInvalidStart
}
return start, end, nil
}
@@ -0,0 +1,480 @@
package tasks
import (
"context"
"database/sql"
"errors"
"fmt"
"math"
"reflect"
"sort"
"strings"
"sync"
"testing"
"time"
"cmbuyer/admin/internal/migrations"
)
var fixedStartTime = time.Date(2026, 8, 4, 9, 2, 3, 456000000, time.FixedZone("UTC+8", 8*60*60))
func TestStartPurchasesPersistsCompleteSnapshotsForOneAndHundredTasks(t *testing.T) {
for _, count := range []int{1, 100} {
t.Run(fmt.Sprintf("%d tasks", count), func(t *testing.T) {
database := migratedDatabase(t)
store := configuredStartStore(t, database)
store.now = func() time.Time { return fixedStartTime }
items := make([]StartItem, 0, count)
wantDrafts := make(map[string]Draft, count)
for index := 1; index <= count; index++ {
id := startTestUUID(index)
draft := Draft{
ID: id,
Title: fmt.Sprintf("task-%03d", index),
GoodsID: fmt.Sprintf("937122%06d", index),
SKUColor: fmt.Sprintf("color-%03d", index),
SKUSize: fmt.Sprintf("size-%03d", index),
Quantity: index%10 + 1,
MaxTotalPrice: fmt.Sprintf("%d.%02d", index+10, index%100),
}
if _, err := store.CreateDraft(context.Background(), draft); err != nil {
t.Fatalf("create draft %d: %v", index, err)
}
items = append(items, StartItem{TaskID: id, ExpectedTaskVersion: 1})
wantDrafts[id] = draft
}
sort.Slice(items, func(i, j int) bool { return items[i].TaskID > items[j].TaskID })
command := StartCommand{StartKey: startTestUUID(1001 + count), Tasks: items}
result, err := store.StartPurchases(context.Background(), command, "authenticated-admin")
if err != nil {
t.Fatalf("StartPurchases: %v", err)
}
if result.StartKey != command.StartKey || result.AuthorizedCount != count || result.PaymentAutomated || len(result.Tasks) != count {
t.Fatalf("result = %#v", result)
}
wantCreated := fixedStartTime.UTC()
wantExpires := wantCreated.Add(15 * time.Minute)
seenAuthorizationIDs := map[string]bool{}
for index, authorized := range result.Tasks {
if index > 0 && result.Tasks[index-1].TaskID >= authorized.TaskID {
t.Fatalf("result is not in canonical task order: %#v", result.Tasks)
}
if authorized.TaskVersion != 2 || !authorized.ExpiresAt.Equal(wantExpires) || !validUUID(authorized.AuthorizationID) || seenAuthorizationIDs[authorized.AuthorizationID] {
t.Fatalf("authorized task = %#v", authorized)
}
seenAuthorizationIDs[authorized.AuthorizationID] = true
want := wantDrafts[authorized.TaskID]
var taskStatus, taskUpdated, authTaskID, authStartKey, goodsID, color, size, priceCap, authStatus, createdBy, createdAt, expiresAt string
var taskVersion, authTaskVersion, quantity int
err := database.QueryRow(`
SELECT t.status,t.version,t.updated_at,
a.task_id,a.task_version,a.start_key,a.goods_id,a.sku_color,a.sku_size,a.quantity,a.total_price_cap,a.status,a.created_by,a.created_at,a.expires_at
FROM tasks t JOIN order_authorizations a ON a.task_id=t.id WHERE a.id=?`, authorized.AuthorizationID).
Scan(&taskStatus, &taskVersion, &taskUpdated, &authTaskID, &authTaskVersion, &authStartKey, &goodsID, &color, &size, &quantity, &priceCap, &authStatus, &createdBy, &createdAt, &expiresAt)
if err != nil {
t.Fatalf("read authorization snapshot: %v", err)
}
if taskStatus != "PENDING" || taskVersion != 2 || taskUpdated != wantCreated.Format(time.RFC3339Nano) ||
authTaskID != want.ID || authTaskVersion != 2 || authStartKey != command.StartKey ||
goodsID != want.GoodsID || color != want.SKUColor || size != want.SKUSize || quantity != want.Quantity || priceCap != want.MaxTotalPrice ||
authStatus != "ACTIVE" || createdBy != "authenticated-admin" || createdAt != wantCreated.Format(time.RFC3339Nano) || expiresAt != wantExpires.Format(time.RFC3339Nano) {
t.Fatalf("stored task/authorization mismatch for %s", want.ID)
}
}
var distinctCreated, distinctExpires int
if err := database.QueryRow(`SELECT COUNT(DISTINCT created_at), COUNT(DISTINCT expires_at) FROM order_authorizations WHERE start_key=?`, command.StartKey).Scan(&distinctCreated, &distinctExpires); err != nil {
t.Fatal(err)
}
if distinctCreated != 1 || distinctExpires != 1 {
t.Fatalf("batch timestamps are not shared: created=%d expires=%d", distinctCreated, distinctExpires)
}
})
}
}
func TestStartPurchasesRejectsInvalidCommandsAndPolicyWithoutWrites(t *testing.T) {
validItem := StartItem{TaskID: startTestUUID(1), ExpectedTaskVersion: 1}
hundredOne := make([]StartItem, 101)
for index := range hundredOne {
hundredOne[index] = StartItem{TaskID: startTestUUID(index + 1), ExpectedTaskVersion: 1}
}
for name, command := range map[string]StartCommand{
"invalid start key": {StartKey: "not-a-uuid", Tasks: []StartItem{validItem}},
"empty tasks": {StartKey: startTestUUID(1001)},
"over batch limit": {StartKey: startTestUUID(1001), Tasks: hundredOne},
"invalid task id": {StartKey: startTestUUID(1001), Tasks: []StartItem{{TaskID: "1", ExpectedTaskVersion: 1}}},
"duplicate task": {StartKey: startTestUUID(1001), Tasks: []StartItem{validItem, validItem}},
"zero version": {StartKey: startTestUUID(1001), Tasks: []StartItem{{TaskID: validItem.TaskID}}},
"overflow version": {StartKey: startTestUUID(1001), Tasks: []StartItem{{TaskID: validItem.TaskID, ExpectedTaskVersion: math.MaxInt}}},
} {
t.Run(name, func(t *testing.T) {
database := migratedDatabase(t)
store := configuredStartStore(t, database)
_, err := store.StartPurchases(context.Background(), command, "admin")
if !errors.Is(err, ErrInvalidStart) {
t.Fatalf("error = %v, want ErrInvalidStart", err)
}
assertAuthorizationCount(t, database, 0)
})
}
for name, mutate := range map[string]func(*SQLiteStore){
"zero ttl": func(store *SQLiteStore) { store.policy.AuthorizationTTL = 0 },
"zero quantity": func(store *SQLiteStore) { store.policy.MaxQuantity = 0 },
"bad max price": func(store *SQLiteStore) { store.policy.MaxTotalPrice = "999" },
} {
t.Run(name, func(t *testing.T) {
database := migratedDatabase(t)
store := configuredStartStore(t, database)
createStartDraft(t, store, validItem.TaskID)
mutate(store)
_, err := store.StartPurchases(context.Background(), StartCommand{StartKey: startTestUUID(1001), Tasks: []StartItem{validItem}}, "admin")
if !errors.Is(err, ErrInvalidStart) {
t.Fatalf("error = %v, want ErrInvalidStart", err)
}
assertDraftUnchanged(t, database, validItem.TaskID)
assertAuthorizationCount(t, database, 0)
})
}
database := migratedDatabase(t)
store := configuredStartStore(t, database)
createStartDraft(t, store, validItem.TaskID)
_, err := store.StartPurchases(context.Background(), StartCommand{StartKey: startTestUUID(1001), Tasks: []StartItem{validItem}}, "")
if !errors.Is(err, ErrInvalidStart) {
t.Fatalf("empty created_by error = %v", err)
}
assertDraftUnchanged(t, database, validItem.TaskID)
}
func TestStartPurchasesRejectsEveryTaskConflictWithoutAuthorization(t *testing.T) {
for name, mutate := range map[string]func(*testing.T, *SQLiteStore, string, *StartItem){
"missing": func(_ *testing.T, _ *SQLiteStore, _ string, item *StartItem) {
item.TaskID = startTestUUID(99)
},
"not draft": func(t *testing.T, store *SQLiteStore, id string, _ *StartItem) {
execTestSQL(t, store.database, `UPDATE tasks SET status='PENDING' WHERE id=?`, id)
},
"version mismatch": func(_ *testing.T, _ *SQLiteStore, _ string, item *StartItem) {
item.ExpectedTaskVersion = 2
},
"empty goods id": func(t *testing.T, store *SQLiteStore, id string, _ *StartItem) {
execTestSQL(t, store.database, `UPDATE tasks SET goods_id='' WHERE id=?`, id)
},
"nondigit goods id": func(t *testing.T, store *SQLiteStore, id string, _ *StartItem) {
execTestSQL(t, store.database, `UPDATE tasks SET goods_id='937x' WHERE id=?`, id)
},
"overlong goods id": func(t *testing.T, store *SQLiteStore, id string, _ *StartItem) {
execTestSQL(t, store.database, `UPDATE tasks SET goods_id=? WHERE id=?`, strings.Repeat("1", MaxGoodsIDCharacters+1), id)
},
"invalid utf8 title": func(t *testing.T, store *SQLiteStore, id string, _ *StartItem) {
execTestSQL(t, store.database, `UPDATE tasks SET title=? WHERE id=?`, string([]byte{0xff}), id)
},
"overlong title": func(t *testing.T, store *SQLiteStore, id string, _ *StartItem) {
execTestSQL(t, store.database, `UPDATE tasks SET title=? WHERE id=?`, strings.Repeat("😀", MaxTitleCodePoints+1), id)
},
"empty color": func(t *testing.T, store *SQLiteStore, id string, _ *StartItem) {
execTestSQL(t, store.database, `UPDATE tasks SET sku_color='' WHERE id=?`, id)
},
"overlong color": func(t *testing.T, store *SQLiteStore, id string, _ *StartItem) {
execTestSQL(t, store.database, `UPDATE tasks SET sku_color=? WHERE id=?`, strings.Repeat("色", MaxSKUTextCodePoints+1), id)
},
"empty size": func(t *testing.T, store *SQLiteStore, id string, _ *StartItem) {
execTestSQL(t, store.database, `UPDATE tasks SET sku_size='' WHERE id=?`, id)
},
"overlong size": func(t *testing.T, store *SQLiteStore, id string, _ *StartItem) {
execTestSQL(t, store.database, `UPDATE tasks SET sku_size=? WHERE id=?`, strings.Repeat("码", MaxSKUTextCodePoints+1), id)
},
"quantity over policy": func(_ *testing.T, store *SQLiteStore, _ string, _ *StartItem) {
store.policy.MaxQuantity = 1
},
"noncanonical price one decimal": func(t *testing.T, store *SQLiteStore, id string, _ *StartItem) {
execTestSQL(t, store.database, `UPDATE tasks SET max_total_price='12.8' WHERE id=?`, id)
},
"noncanonical leading zero": func(t *testing.T, store *SQLiteStore, id string, _ *StartItem) {
execTestSQL(t, store.database, `UPDATE tasks SET max_total_price='012.80' WHERE id=?`, id)
},
"overlong canonical price": func(t *testing.T, store *SQLiteStore, id string, _ *StartItem) {
execTestSQL(t, store.database, `UPDATE tasks SET max_total_price=? WHERE id=?`, strings.Repeat("1", MaxMoneyASCIICharacters-2)+".00", id)
},
"price over policy": func(_ *testing.T, store *SQLiteStore, _ string, _ *StartItem) {
store.policy.MaxTotalPrice = "12.79"
},
} {
t.Run(name, func(t *testing.T) {
database := migratedDatabase(t)
store := configuredStartStore(t, database)
id := startTestUUID(1)
createStartDraft(t, store, id)
item := StartItem{TaskID: id, ExpectedTaskVersion: 1}
mutate(t, store, id, &item)
_, err := store.StartPurchases(context.Background(), StartCommand{StartKey: startTestUUID(1001), Tasks: []StartItem{item}}, "admin")
if !errors.Is(err, ErrStartConflict) {
t.Fatalf("error = %v, want ErrStartConflict", err)
}
assertAuthorizationCount(t, database, 0)
})
}
}
func TestStartPurchasesReplayRejectsMalformedAuthorizationOrTaskSnapshot(t *testing.T) {
mutations := map[string]func(*testing.T, *sql.DB, string){
"authorization goods id": func(t *testing.T, database *sql.DB, id string) {
execTestSQL(t, database, `UPDATE order_authorizations SET goods_id=? WHERE task_id=?`, strings.Repeat("1", MaxGoodsIDCharacters+1), id)
},
"authorization color": func(t *testing.T, database *sql.DB, id string) {
execTestSQL(t, database, `UPDATE order_authorizations SET sku_color=? WHERE task_id=?`, strings.Repeat("色", MaxSKUTextCodePoints+1), id)
},
"authorization size": func(t *testing.T, database *sql.DB, id string) {
execTestSQL(t, database, `UPDATE order_authorizations SET sku_size=? WHERE task_id=?`, strings.Repeat("码", MaxSKUTextCodePoints+1), id)
},
"authorization money": func(t *testing.T, database *sql.DB, id string) {
execTestSQL(t, database, `UPDATE order_authorizations SET total_price_cap=? WHERE task_id=?`, strings.Repeat("1", MaxMoneyASCIICharacters-2)+".00", id)
},
"task title": func(t *testing.T, database *sql.DB, id string) {
execTestSQL(t, database, `UPDATE tasks SET title=? WHERE id=?`, strings.Repeat("😀", MaxTitleCodePoints+1), id)
},
}
for name, mutate := range mutations {
t.Run(name, func(t *testing.T) {
database := migratedDatabase(t)
store := configuredStartStore(t, database)
id := startTestUUID(1)
createStartDraft(t, store, id)
command := StartCommand{StartKey: startTestUUID(1001), Tasks: []StartItem{{TaskID: id, ExpectedTaskVersion: 1}}}
if _, err := store.StartPurchases(context.Background(), command, "admin"); err != nil {
t.Fatal(err)
}
mutate(t, database, id)
if _, err := store.StartPurchases(context.Background(), command, "admin"); !errors.Is(err, ErrStartConflict) {
t.Fatalf("replay error = %v, want ErrStartConflict", err)
}
assertAuthorizationCount(t, database, 1)
})
}
}
func TestStartPurchasesRollsBackWholeBatchForLateConflictAndSQLFailure(t *testing.T) {
for _, test := range []struct {
name string
breakBatch func(*testing.T, *SQLiteStore, string)
}{
{name: "late validation conflict", breakBatch: func(t *testing.T, store *SQLiteStore, secondID string) {
execTestSQL(t, store.database, `UPDATE tasks SET sku_size='' WHERE id=?`, secondID)
}},
{name: "late SQL failure", breakBatch: func(t *testing.T, store *SQLiteStore, secondID string) {
statement := fmt.Sprintf(`CREATE TRIGGER reject_second_authorization BEFORE INSERT ON order_authorizations WHEN NEW.task_id='%s' BEGIN SELECT RAISE(ABORT, 'test failure'); END`, secondID)
execTestSQL(t, store.database, statement)
}},
} {
t.Run(test.name, func(t *testing.T) {
database := migratedDatabase(t)
store := configuredStartStore(t, database)
firstID, secondID := startTestUUID(1), startTestUUID(2)
createStartDraft(t, store, firstID)
createStartDraft(t, store, secondID)
test.breakBatch(t, store, secondID)
_, err := store.StartPurchases(context.Background(), StartCommand{StartKey: startTestUUID(1001), Tasks: []StartItem{{TaskID: firstID, ExpectedTaskVersion: 1}, {TaskID: secondID, ExpectedTaskVersion: 1}}}, "admin")
if err == nil {
t.Fatal("StartPurchases unexpectedly succeeded")
}
assertDraftUnchanged(t, database, firstID)
var secondStatus string
var secondVersion int
if err := database.QueryRow(`SELECT status,version FROM tasks WHERE id=?`, secondID).Scan(&secondStatus, &secondVersion); err != nil {
t.Fatal(err)
}
if secondStatus != "DRAFT" || secondVersion != 1 {
t.Fatalf("second task = %s/v%d, want DRAFT/v1", secondStatus, secondVersion)
}
assertAuthorizationCount(t, database, 0)
})
}
}
func TestStartPurchasesReplayIsStableAndRejectsDifferentOrIncompleteSets(t *testing.T) {
database := migratedDatabase(t)
store := configuredStartStore(t, database)
firstID, secondID, thirdID := startTestUUID(1), startTestUUID(2), startTestUUID(3)
for _, id := range []string{firstID, secondID, thirdID} {
createStartDraft(t, store, id)
}
command := StartCommand{StartKey: startTestUUID(1001), Tasks: []StartItem{{TaskID: secondID, ExpectedTaskVersion: 1}, {TaskID: firstID, ExpectedTaskVersion: 1}}}
first, err := store.StartPurchases(context.Background(), command, "admin")
if err != nil {
t.Fatal(err)
}
command.Tasks[0], command.Tasks[1] = command.Tasks[1], command.Tasks[0]
replay, err := store.StartPurchases(context.Background(), command, "admin")
if err != nil || !reflect.DeepEqual(replay, first) {
t.Fatalf("replay = (%#v, %v), want %#v", replay, err, first)
}
assertAuthorizationCount(t, database, 2)
conflicting := []StartCommand{
{StartKey: command.StartKey, Tasks: command.Tasks[:1]},
{StartKey: command.StartKey, Tasks: []StartItem{{TaskID: firstID, ExpectedTaskVersion: 2}, {TaskID: secondID, ExpectedTaskVersion: 1}}},
{StartKey: command.StartKey, Tasks: []StartItem{{TaskID: firstID, ExpectedTaskVersion: 1}, {TaskID: secondID, ExpectedTaskVersion: 1}, {TaskID: thirdID, ExpectedTaskVersion: 1}}},
}
for _, changed := range conflicting {
if _, err := store.StartPurchases(context.Background(), changed, "admin"); !errors.Is(err, ErrStartConflict) {
t.Fatalf("different payload error = %v", err)
}
}
assertAuthorizationCount(t, database, 2)
assertDraftUnchanged(t, database, thirdID)
execTestSQL(t, database, `DELETE FROM order_authorizations WHERE task_id=?`, secondID)
if _, err := store.StartPurchases(context.Background(), command, "admin"); !errors.Is(err, ErrStartConflict) {
t.Fatalf("incomplete replay error = %v", err)
}
assertAuthorizationCount(t, database, 1)
}
func TestStartPurchasesConcurrentReplayAndVersionRace(t *testing.T) {
t.Run("same key replays one stable result", func(t *testing.T) {
database := migratedDatabase(t)
store := configuredStartStore(t, database)
id := startTestUUID(1)
createStartDraft(t, store, id)
command := StartCommand{StartKey: startTestUUID(1001), Tasks: []StartItem{{TaskID: id, ExpectedTaskVersion: 1}}}
const callers = 16
start := make(chan struct{})
results := make(chan StartResult, callers)
errorsChannel := make(chan error, callers)
var group sync.WaitGroup
for range callers {
group.Add(1)
go func() {
defer group.Done()
<-start
result, err := store.StartPurchases(context.Background(), command, "admin")
if err != nil {
errorsChannel <- err
return
}
results <- result
}()
}
close(start)
group.Wait()
close(results)
close(errorsChannel)
for err := range errorsChannel {
t.Fatalf("concurrent replay: %v", err)
}
var want StartResult
for result := range results {
if want.StartKey == "" {
want = result
} else if !reflect.DeepEqual(result, want) {
t.Fatalf("unstable replay: %#v != %#v", result, want)
}
}
assertAuthorizationCount(t, database, 1)
var version int
if err := database.QueryRow(`SELECT version FROM tasks WHERE id=?`, id).Scan(&version); err != nil || version != 2 {
t.Fatalf("task version = %d, err=%v", version, err)
}
})
t.Run("different keys race one expected version", func(t *testing.T) {
database := migratedDatabase(t)
store := configuredStartStore(t, database)
id := startTestUUID(1)
createStartDraft(t, store, id)
start := make(chan struct{})
errorsChannel := make(chan error, 2)
var group sync.WaitGroup
for _, key := range []string{startTestUUID(1001), startTestUUID(1002)} {
group.Add(1)
go func(startKey string) {
defer group.Done()
<-start
_, err := store.StartPurchases(context.Background(), StartCommand{StartKey: startKey, Tasks: []StartItem{{TaskID: id, ExpectedTaskVersion: 1}}}, "admin")
errorsChannel <- err
}(key)
}
close(start)
group.Wait()
close(errorsChannel)
successes, conflicts := 0, 0
for err := range errorsChannel {
switch {
case err == nil:
successes++
case errors.Is(err, ErrStartConflict):
conflicts++
default:
t.Fatalf("unexpected race error: %v", err)
}
}
if successes != 1 || conflicts != 1 {
t.Fatalf("success/conflict = %d/%d, want 1/1", successes, conflicts)
}
assertAuthorizationCount(t, database, 1)
})
}
func TestSQLiteStoreRejectsV1SchemaAtStartup(t *testing.T) {
database := openDatabase(t)
if err := migrations.Run(context.Background(), database, migrationDirectory(t), "up-by-one"); err != nil {
t.Fatalf("migrate to v1: %v", err)
}
if _, err := NewSQLiteStore(database); err == nil {
t.Fatal("NewSQLiteStore accepted the v1 two-pass schema")
}
}
func configuredStartStore(t *testing.T, database *sql.DB) *SQLiteStore {
t.Helper()
store, err := NewSQLiteStore(database)
if err != nil {
t.Fatalf("NewSQLiteStore: %v", err)
}
store.SetStartPolicy(StartPolicy{AuthorizationTTL: 15 * time.Minute, MaxQuantity: 10, MaxTotalPrice: "999.99"})
return store
}
func createStartDraft(t *testing.T, store *SQLiteStore, id string) {
t.Helper()
draft := Draft{ID: id, Title: "test", GoodsID: "937122477375", SKUColor: "黑色", SKUSize: "M", Quantity: 2, MaxTotalPrice: "12.80"}
if _, err := store.CreateDraft(context.Background(), draft); err != nil {
t.Fatalf("CreateDraft: %v", err)
}
}
func startTestUUID(number int) string {
return fmt.Sprintf("%08x-1234-4abc-a123-%012x", number, number)
}
func assertAuthorizationCount(t *testing.T, database *sql.DB, want int) {
t.Helper()
var got int
if err := database.QueryRow(`SELECT COUNT(*) FROM order_authorizations`).Scan(&got); err != nil {
t.Fatal(err)
}
if got != want {
t.Fatalf("authorization count = %d, want %d", got, want)
}
}
func assertDraftUnchanged(t *testing.T, database *sql.DB, id string) {
t.Helper()
var status string
var version int
if err := database.QueryRow(`SELECT status,version FROM tasks WHERE id=?`, id).Scan(&status, &version); err != nil {
t.Fatal(err)
}
if status != "DRAFT" || version != 1 {
t.Fatalf("task %s = %s/v%d, want DRAFT/v1", id, status, version)
}
}
func execTestSQL(t *testing.T, database *sql.DB, statement string, arguments ...any) {
t.Helper()
if _, err := database.Exec(statement, arguments...); err != nil {
t.Fatalf("execute test SQL: %v", err)
}
}
+239
View File
@@ -0,0 +1,239 @@
package tasks
import (
"context"
"database/sql"
"errors"
"strings"
"time"
"cmbuyer/admin/internal/domain"
)
// ErrInvalidFilter 表示任务筛选值无效,路由应按字段重新渲染而不是泄露内部错误。
var ErrInvalidFilter = errors.New("invalid task filter")
// SetStartPolicy is called during startup; policy is explicit because authorization limits must not be implicit defaults.
func (store *SQLiteStore) SetStartPolicy(policy StartPolicy) { store.policy = policy }
func (store *SQLiteStore) ListTasks(ctx context.Context, filter TaskFilter) ([]TaskRow, error) {
if !ValidateTaskFilter(filter).Valid() {
return nil, ErrInvalidFilter
}
from, to, err := ShanghaiRange(filter.CreatedFrom, filter.CreatedTo)
if err != nil {
return nil, ErrInvalidFilter
}
clauses, args := []string{"1=1"}, []any{}
if filter.Status != "" {
clauses = append(clauses, "status = ?")
args = append(args, filter.Status)
}
if filter.Keyword != "" {
escaped := strings.NewReplacer("\\", "\\\\", "%", "\\%", "_", "\\_").Replace(filter.Keyword)
clauses = append(clauses, "(title LIKE ? ESCAPE '\\' OR goods_id LIKE ? ESCAPE '\\')")
args = append(args, "%"+escaped+"%", "%"+escaped+"%")
}
if !from.IsZero() {
clauses = append(clauses, "julianday(created_at) >= julianday(?)")
args = append(args, from.Format(time.RFC3339Nano))
}
if !to.IsZero() {
clauses = append(clauses, "julianday(created_at) < julianday(?)")
args = append(args, to.Format(time.RFC3339Nano))
}
rows, err := store.database.QueryContext(ctx, "SELECT id,title,goods_id,sku_color,sku_size,quantity,max_total_price,status,version,created_at FROM tasks WHERE "+strings.Join(clauses, " AND ")+" ORDER BY julianday(created_at) DESC,rowid DESC", args...)
if err != nil {
return nil, err
}
defer rows.Close()
result := []TaskRow{}
for rows.Next() {
var item TaskRow
var created string
if err := rows.Scan(&item.ID, &item.Title, &item.GoodsID, &item.SKUColor, &item.SKUSize, &item.Quantity, &item.MaxTotalPrice, &item.Status, &item.Version, &created); err != nil {
return nil, err
}
item.CreatedAt, err = time.Parse(time.RFC3339Nano, created)
if err != nil {
return nil, err
}
result = append(result, item)
}
return result, rows.Err()
}
// ValidateTaskFilter 返回可关联到字段的错误,使服务端页面拒绝篡改参数时仍能保留输入值。
func ValidateTaskFilter(filter TaskFilter) Errors {
validation := Errors{}
if !validTaskStatus(filter.Status) {
validation["status"] = "请选择有效的任务状态。"
}
location, err := time.LoadLocation("Asia/Shanghai")
if err != nil {
validation["created_from"] = "日期筛选暂不可用,请稍后重试。"
validation["created_to"] = "日期筛选暂不可用,请稍后重试。"
return validation
}
parseDate := func(field, value string) (time.Time, bool) {
if value == "" {
return time.Time{}, true
}
parsed, parseErr := time.ParseInLocation("2006-01-02", value, location)
if parseErr != nil {
validation[field] = "请输入有效日期。"
return time.Time{}, false
}
return parsed, true
}
from, fromOK := parseDate("created_from", filter.CreatedFrom)
to, toOK := parseDate("created_to", filter.CreatedTo)
if fromOK && toOK && !from.IsZero() && !to.IsZero() && from.After(to) {
validation["created_to"] = "结束日期不能早于开始日期。"
}
return validation
}
func (store *SQLiteStore) StartPurchases(ctx context.Context, command StartCommand, createdBy string) (StartResult, error) {
items, err := startItems(command)
if err != nil || createdBy == "" {
return StartResult{}, ErrInvalidStart
}
if store.policy.AuthorizationTTL <= 0 || store.policy.MaxQuantity <= 0 {
return StartResult{}, ErrInvalidStart
}
_, ceiling, ok := normalizeCents(store.policy.MaxTotalPrice)
if !ok {
return StartResult{}, ErrInvalidStart
}
writeCtx, cancel := context.WithTimeout(ctx, sqliteWriteTimeout)
defer cancel()
select {
case store.writeGate <- struct{}{}:
defer func() { <-store.writeGate }()
case <-writeCtx.Done():
return StartResult{}, writeCtx.Err()
}
tx, err := store.database.BeginTx(writeCtx, nil)
if err != nil {
return StartResult{}, err
}
defer tx.Rollback()
// Replay precedes any DRAFT check. One service process serializes this check with creation; SQLite uniqueness remains the cross-transaction backstop.
result, found, err := replayStart(writeCtx, tx, command.StartKey, items)
if err != nil {
return StartResult{}, err
}
if found {
if err := tx.Commit(); err != nil {
return StartResult{}, err
}
return result, nil
}
now := store.now().UTC()
expires := now.Add(store.policy.AuthorizationTTL)
result = StartResult{StartKey: command.StartKey, AuthorizedCount: len(items), Tasks: make([]AuthorizedTask, 0, len(items)), PaymentAutomated: false}
for _, item := range items {
var title, goods, color, size, price, status string
var quantity, version int
if err := tx.QueryRowContext(writeCtx, "SELECT title,goods_id,sku_color,sku_size,quantity,max_total_price,status,version FROM tasks WHERE id=?", item.TaskID).Scan(&title, &goods, &color, &size, &quantity, &price, &status, &version); err != nil {
if err == sql.ErrNoRows {
return StartResult{}, ErrStartConflict
}
return StartResult{}, err
}
if status != "DRAFT" || version != item.ExpectedTaskVersion ||
!ValidTaskWireFields(title, goods, color, size, price) ||
quantity < 1 || quantity > store.policy.MaxQuantity {
return StartResult{}, ErrStartConflict
}
canonical, cents, ok := normalizeCents(price)
if !ok || canonical != price || cents.Cmp(ceiling) > 0 {
return StartResult{}, ErrStartConflict
}
if _, err := domain.TransitionTask(domain.TaskStatusDraft, domain.TaskStatusPending); err != nil {
return StartResult{}, err
}
id, err := NewCreateKey()
if err != nil {
return StartResult{}, err
}
next := version + 1
if _, err = tx.ExecContext(writeCtx, "INSERT INTO order_authorizations (id,task_id,task_version,start_key,goods_id,sku_color,sku_size,quantity,total_price_cap,status,created_by,created_at,expires_at) VALUES (?,?,?,?,?,?,?,?,?,'ACTIVE',?,?,?)", id, item.TaskID, next, command.StartKey, goods, color, size, quantity, price, createdBy, now.Format(time.RFC3339Nano), expires.Format(time.RFC3339Nano)); err != nil {
return StartResult{}, err
}
updated, err := tx.ExecContext(writeCtx, "UPDATE tasks SET status='PENDING',version=version+1,updated_at=? WHERE id=? AND status='DRAFT' AND version=?", now.Format(time.RFC3339Nano), item.TaskID, version)
if err != nil {
return StartResult{}, err
}
affected, err := updated.RowsAffected()
if err != nil {
return StartResult{}, err
}
if affected != 1 {
return StartResult{}, ErrStartConflict
}
result.Tasks = append(result.Tasks, AuthorizedTask{TaskID: item.TaskID, TaskVersion: next, AuthorizationID: id, ExpiresAt: expires})
}
if err := tx.Commit(); err != nil {
return StartResult{}, err
}
return result, nil
}
func replayStart(ctx context.Context, tx *sql.Tx, startKey string, items []StartItem) (StartResult, bool, error) {
rows, err := tx.QueryContext(ctx, `SELECT authorizations.id,authorizations.task_id,
authorizations.task_version,authorizations.expires_at,authorizations.goods_id,
authorizations.sku_color,authorizations.sku_size,authorizations.quantity,
authorizations.total_price_cap,tasks.title,tasks.goods_id,tasks.sku_color,
tasks.sku_size,tasks.quantity,tasks.max_total_price
FROM order_authorizations AS authorizations
JOIN tasks ON tasks.id = authorizations.task_id
WHERE authorizations.start_key=? ORDER BY authorizations.task_id`, startKey)
if err != nil {
return StartResult{}, false, err
}
defer rows.Close()
result := StartResult{StartKey: startKey, PaymentAutomated: false}
for rows.Next() {
var item AuthorizedTask
var expires string
var authorizationGoodsID, authorizationColor, authorizationSize, authorizationPrice string
var taskTitle, taskGoodsID, taskColor, taskSize, taskPrice string
var authorizationQuantity, taskQuantity int
if err := rows.Scan(&item.AuthorizationID, &item.TaskID, &item.TaskVersion, &expires,
&authorizationGoodsID, &authorizationColor, &authorizationSize, &authorizationQuantity,
&authorizationPrice, &taskTitle, &taskGoodsID, &taskColor, &taskSize, &taskQuantity,
&taskPrice); err != nil {
return StartResult{}, false, err
}
if !ValidAuthorizationFields(authorizationGoodsID, authorizationColor, authorizationSize, authorizationPrice) ||
authorizationQuantity <= 0 ||
!ValidTaskWireFields(taskTitle, taskGoodsID, taskColor, taskSize, taskPrice) || taskQuantity <= 0 ||
authorizationGoodsID != taskGoodsID || authorizationColor != taskColor ||
authorizationSize != taskSize || authorizationQuantity != taskQuantity || authorizationPrice != taskPrice {
return StartResult{}, false, ErrStartConflict
}
item.ExpiresAt, err = time.Parse(time.RFC3339Nano, expires)
if err != nil {
return StartResult{}, false, err
}
result.Tasks = append(result.Tasks, item)
}
if err := rows.Err(); err != nil {
return StartResult{}, false, err
}
if len(result.Tasks) == 0 {
return StartResult{}, false, nil
}
if len(result.Tasks) != len(items) {
return StartResult{}, false, ErrStartConflict
}
for i := range items {
if result.Tasks[i].TaskID != items[i].TaskID || result.Tasks[i].TaskVersion-1 != items[i].ExpectedTaskVersion {
return StartResult{}, false, ErrStartConflict
}
}
result.AuthorizedCount = len(result.Tasks)
return result, true, nil
}
+195
View File
@@ -0,0 +1,195 @@
package tasks
import (
"context"
"database/sql"
"errors"
"testing"
"time"
)
func TestListTasksTreatsLikeMetacharactersLiterally(t *testing.T) {
database := migratedDatabase(t)
store, err := NewSQLiteStore(database)
if err != nil {
t.Fatal(err)
}
created := "2026-08-04T01:00:00Z"
insertTaskRow(t, database, "percent", "100%纯棉", "100", "DRAFT", created)
insertTaskRow(t, database, "underscore", "尺码_A", "101", "DRAFT", created)
insertTaskRow(t, database, "backslash", `路径\名称`, "102", "DRAFT", created)
insertTaskRow(t, database, "plain", "普通商品", "103", "DRAFT", created)
for _, test := range []struct {
keyword string
wantID string
}{
{keyword: "%", wantID: "percent"},
{keyword: "_", wantID: "underscore"},
{keyword: `\`, wantID: "backslash"},
} {
t.Run(test.wantID, func(t *testing.T) {
rows, err := store.ListTasks(context.Background(), TaskFilter{Keyword: test.keyword})
if err != nil {
t.Fatal(err)
}
if len(rows) != 1 || rows[0].ID != test.wantID {
t.Fatalf("keyword %q rows = %#v, want only %q", test.keyword, rows, test.wantID)
}
})
}
}
func TestListTasksSupportsEveryStatusAndEmptyMeansAll(t *testing.T) {
database := migratedDatabase(t)
store, err := NewSQLiteStore(database)
if err != nil {
t.Fatal(err)
}
statuses := []string{"DRAFT", "PENDING", "CLAIMED", "ORDERING", "NEEDS_MANUAL", "WAITING_PAYMENT", "RECONCILIATION_REQUIRED", "SUCCEEDED", "FAILED", "CANCELED"}
for index, status := range statuses {
insertTaskRow(t, database, status, status, "200", status, time.Date(2026, 8, 4, 1, 0, index, 0, time.UTC).Format(time.RFC3339Nano))
}
all, err := store.ListTasks(context.Background(), TaskFilter{})
if err != nil {
t.Fatal(err)
}
if len(all) != len(statuses) {
t.Fatalf("all-status rows = %d, want %d", len(all), len(statuses))
}
for _, status := range statuses {
rows, err := store.ListTasks(context.Background(), TaskFilter{Status: status})
if err != nil {
t.Fatalf("status %s: %v", status, err)
}
if len(rows) != 1 || rows[0].Status != status {
t.Fatalf("status %s rows = %#v", status, rows)
}
}
}
func TestListTasksUsesShanghaiHalfOpenDateRange(t *testing.T) {
database := migratedDatabase(t)
store, err := NewSQLiteStore(database)
if err != nil {
t.Fatal(err)
}
insertTaskRow(t, database, "before", "before", "300", "DRAFT", "2026-08-03T15:59:59Z")
insertTaskRow(t, database, "at-start", "at-start", "301", "DRAFT", "2026-08-03T16:00:00Z")
insertTaskRow(t, database, "before-end", "before-end", "302", "DRAFT", "2026-08-04T15:59:59Z")
insertTaskRow(t, database, "at-end", "at-end", "303", "DRAFT", "2026-08-04T16:00:00Z")
rows, err := store.ListTasks(context.Background(), TaskFilter{CreatedFrom: "2026-08-04", CreatedTo: "2026-08-04"})
if err != nil {
t.Fatal(err)
}
if len(rows) != 2 || rows[0].ID != "before-end" || rows[1].ID != "at-start" {
t.Fatalf("Shanghai day rows = %#v, want [before-end at-start]", rows)
}
}
func TestListTasksBreaksEqualTimestampsByDescendingRowID(t *testing.T) {
database := migratedDatabase(t)
store, err := NewSQLiteStore(database)
if err != nil {
t.Fatal(err)
}
created := "2026-08-04T01:02:03Z"
insertTaskRow(t, database, "first", "first", "400", "DRAFT", created)
insertTaskRow(t, database, "second", "second", "401", "DRAFT", created)
rows, err := store.ListTasks(context.Background(), TaskFilter{})
if err != nil {
t.Fatal(err)
}
if len(rows) != 2 || rows[0].ID != "second" || rows[1].ID != "first" {
t.Fatalf("equal-time rows = %#v, want descending rowid", rows)
}
}
func TestListTasksRejectsInvalidStatusAndDates(t *testing.T) {
store, err := NewSQLiteStore(migratedDatabase(t))
if err != nil {
t.Fatal(err)
}
for name, filter := range map[string]TaskFilter{
"status": {Status: "UNKNOWN"},
"from date": {CreatedFrom: "2026-02-30"},
"to date": {CreatedTo: "04/08/2026"},
"reverse range": {CreatedFrom: "2026-08-05", CreatedTo: "2026-08-04"},
} {
t.Run(name, func(t *testing.T) {
rows, err := store.ListTasks(context.Background(), filter)
if !errors.Is(err, ErrInvalidFilter) || rows != nil {
t.Fatalf("ListTasks(%#v) = (%#v, %v), want ErrInvalidFilter", filter, rows, err)
}
})
}
}
func TestStartPurchasesIsAtomicAndReplaysSameSet(t *testing.T) {
database := migratedDatabase(t)
store, err := NewSQLiteStore(database)
if err != nil {
t.Fatal(err)
}
store.SetStartPolicy(StartPolicy{AuthorizationTTL: time.Hour, MaxQuantity: 10, MaxTotalPrice: "999.99"})
store.now = func() time.Time { return time.Date(2026, 8, 4, 1, 2, 3, 0, time.UTC) }
for _, draft := range []Draft{testDraft(testKey, "one"), testDraft("b3c9f507-7473-4fa6-8d71-8786c34c6301", "two")} {
if _, err := store.CreateDraft(context.Background(), draft); err != nil {
t.Fatal(err)
}
}
command := StartCommand{StartKey: "c3c9f507-7473-4fa6-8d71-8786c34c6301", Tasks: []StartItem{{TaskID: "b3c9f507-7473-4fa6-8d71-8786c34c6301", ExpectedTaskVersion: 1}, {TaskID: testKey, ExpectedTaskVersion: 1}}}
first, err := store.StartPurchases(context.Background(), command, "admin")
if err != nil {
t.Fatal(err)
}
if first.AuthorizedCount != 2 || first.PaymentAutomated {
t.Fatalf("start result=%#v", first)
}
command.Tasks[0], command.Tasks[1] = command.Tasks[1], command.Tasks[0]
replay, err := store.StartPurchases(context.Background(), command, "admin")
if err != nil {
t.Fatal(err)
}
if replay.Tasks[0].AuthorizationID != first.Tasks[0].AuthorizationID || replay.Tasks[1].AuthorizationID != first.Tasks[1].AuthorizationID {
t.Fatalf("replay=%#v first=%#v", replay, first)
}
var pending, auths int
if err := database.QueryRow(`SELECT COUNT(*) FROM tasks WHERE status='PENDING' AND version=2`).Scan(&pending); err != nil || pending != 2 {
t.Fatalf("pending=%d err=%v", pending, err)
}
if err := database.QueryRow(`SELECT COUNT(*) FROM order_authorizations WHERE status='ACTIVE' AND created_by='admin'`).Scan(&auths); err != nil || auths != 2 {
t.Fatalf("auths=%d err=%v", auths, err)
}
_, err = store.StartPurchases(context.Background(), StartCommand{StartKey: command.StartKey, Tasks: command.Tasks[:1]}, "admin")
if !errors.Is(err, ErrStartConflict) {
t.Fatalf("subset err=%v", err)
}
}
func TestShanghaiRangeAndMoneyAreFailClosed(t *testing.T) {
start, end, err := ShanghaiRange("2026-08-04", "2026-08-04")
if err != nil || start.Format(time.RFC3339) != "2026-08-03T16:00:00Z" || end.Format(time.RFC3339) != "2026-08-04T16:00:00Z" {
t.Fatalf("range=(%s,%s,%v)", start, end, err)
}
for _, value := range []string{"0.01", "12.80", "999999999999999999999999.99"} {
if _, _, ok := normalizeCents(value); !ok {
t.Fatalf("money %q rejected", value)
}
}
for _, value := range []string{"1", "01.20", "0.00", "1.234", "1.", " 1.00", "1e2"} {
if _, _, ok := normalizeCents(value); ok {
t.Fatalf("money %q accepted", value)
}
}
}
func insertTaskRow(t *testing.T, database *sql.DB, id, title, goodsID, status, createdAt string) {
t.Helper()
if _, err := database.Exec(`INSERT INTO tasks (id, source, title, goods_id, sku_color, sku_size, quantity, max_total_price, status, version, created_at, updated_at) VALUES (?, 'MANUAL', ?, ?, '黑色', 'M', 2, '12.80', ?, 1, ?, ?)`, id, title, goodsID, status, createdAt, createdAt); err != nil {
t.Fatalf("insert task %s: %v", id, err)
}
}
+140
View File
@@ -0,0 +1,140 @@
package tasks
import (
"context"
"database/sql"
"errors"
"fmt"
"time"
)
const sqliteWriteTimeout = 2 * time.Second
type Store interface {
CreateDraft(context.Context, Draft) (Draft, error)
ListDrafts(context.Context) ([]Draft, error)
ListTasks(context.Context, TaskFilter) ([]TaskRow, error)
StartPurchases(context.Context, StartCommand, string) (StartResult, error)
}
type SQLiteStore struct {
database *sql.DB
now func() time.Time
writeGate chan struct{}
policy StartPolicy
}
func NewSQLiteStore(database *sql.DB) (*SQLiteStore, error) {
if database == nil {
return nil, errors.New("database is required")
}
if _, err := database.Exec("SELECT task_version, start_key, total_price_cap FROM order_authorizations LIMIT 1"); err != nil {
return nil, fmt.Errorf("tasks migration is not available: %w", err)
}
if _, err := database.Exec("SELECT 1 FROM purchase_attempts LIMIT 1"); err != nil {
return nil, fmt.Errorf("single-pass migration is not available: %w", err)
}
return &SQLiteStore{database: database, now: time.Now, writeGate: make(chan struct{}, 1)}, nil
}
func (store *SQLiteStore) CreateDraft(ctx context.Context, draft Draft) (Draft, error) {
// Validate again at the persistence boundary. HTTP form validation is not the only
// caller, and a malformed row here would later make an authorized claim unencodable.
if !validUUID(draft.ID) || !ValidTaskWireFields(draft.Title, draft.GoodsID, draft.SKUColor, draft.SKUSize, draft.MaxTotalPrice) || draft.Quantity <= 0 {
return Draft{}, ErrInvalidDraft
}
writeContext, cancel := context.WithTimeout(ctx, sqliteWriteTimeout)
defer cancel()
// SQLite permits one writer at a time. Serializing this store's short create
// transaction prevents concurrent retries of one create key from surfacing as busy.
select {
case store.writeGate <- struct{}{}:
defer func() { <-store.writeGate }()
case <-writeContext.Done():
return Draft{}, writeContext.Err()
}
draft.CreatedAt = store.now().UTC()
transaction, err := store.database.BeginTx(writeContext, nil)
if err != nil {
return Draft{}, err
}
defer transaction.Rollback()
_, err = transaction.ExecContext(writeContext, `INSERT INTO tasks (id, source, title, goods_id, sku_color, sku_size, quantity, max_total_price, status, version, created_at, updated_at) VALUES (?, 'MANUAL', ?, ?, ?, ?, ?, ?, 'DRAFT', 1, ?, ?)`, draft.ID, draft.Title, draft.GoodsID, draft.SKUColor, draft.SKUSize, draft.Quantity, draft.MaxTotalPrice, draft.CreatedAt.Format(time.RFC3339Nano), draft.CreatedAt.Format(time.RFC3339Nano))
if err == nil {
if err := transaction.Commit(); err != nil {
return Draft{}, err
}
return draft, nil
}
existing, found, currentPhase, lookupErr := findDraft(writeContext, transaction, draft.ID)
if lookupErr != nil {
return Draft{}, lookupErr
}
if found && currentPhase && samePayload(existing, draft) {
if err := transaction.Commit(); err != nil {
return Draft{}, err
}
return existing, nil
}
if found {
return Draft{}, ErrCreateKeyConflict
}
return Draft{}, err
}
func (store *SQLiteStore) ListDrafts(ctx context.Context) ([]Draft, error) {
// rowid makes equal timestamps deterministic: SQLite assigns it in insertion order,
// whereas UUID v4 is deliberately not time-sortable.
rows, err := store.database.QueryContext(ctx, `SELECT id, title, goods_id, sku_color, sku_size, quantity, max_total_price, created_at FROM tasks WHERE source = 'MANUAL' AND status = 'DRAFT' ORDER BY created_at DESC, rowid DESC`)
if err != nil {
return nil, err
}
defer rows.Close()
result := []Draft{}
for rows.Next() {
draft, err := scanDraft(rows)
if err != nil {
return nil, err
}
result = append(result, draft)
}
return result, rows.Err()
}
func findDraft(ctx context.Context, transaction *sql.Tx, id string) (Draft, bool, bool, error) {
row := transaction.QueryRowContext(ctx, `SELECT id, title, goods_id, sku_color, sku_size, quantity, max_total_price, created_at, source, status, version FROM tasks WHERE id = ?`, id)
var draft Draft
var created, source, status string
var version int
err := row.Scan(&draft.ID, &draft.Title, &draft.GoodsID, &draft.SKUColor, &draft.SKUSize, &draft.Quantity, &draft.MaxTotalPrice, &created, &source, &status, &version)
if errors.Is(err, sql.ErrNoRows) {
return Draft{}, false, false, nil
}
if err != nil {
return Draft{}, false, false, err
}
parsed, err := time.Parse(time.RFC3339Nano, created)
if err != nil {
return Draft{}, false, false, err
}
draft.CreatedAt = parsed
return draft, true, source == "MANUAL" && status == "DRAFT" && version == 1, nil
}
type scanner interface{ Scan(...any) error }
func scanDraft(row scanner) (Draft, error) {
var draft Draft
var created string
if err := row.Scan(&draft.ID, &draft.Title, &draft.GoodsID, &draft.SKUColor, &draft.SKUSize, &draft.Quantity, &draft.MaxTotalPrice, &created); err != nil {
return Draft{}, err
}
parsed, err := time.Parse(time.RFC3339Nano, created)
if err != nil {
return Draft{}, err
}
draft.CreatedAt = parsed
return draft, nil
}
func samePayload(left, right Draft) bool {
return left.ID == right.ID && left.Title == right.Title && left.GoodsID == right.GoodsID && left.SKUColor == right.SKUColor && left.SKUSize == right.SKUSize && left.Quantity == right.Quantity && left.MaxTotalPrice == right.MaxTotalPrice
}
+218
View File
@@ -0,0 +1,218 @@
// 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
}
+379
View File
@@ -0,0 +1,379 @@
package tasks
import (
"context"
"database/sql"
"errors"
"path/filepath"
"regexp"
"runtime"
"strings"
"sync"
"testing"
"time"
"cmbuyer/admin/internal/migrations"
"cmbuyer/admin/internal/storage/sqlite"
)
const testKey = "a3c9f507-7473-4fa6-8d71-8786c34c6301"
func TestValidateNormalizesManualDraft(t *testing.T) {
draft, validation := Validate(Form{
CreateKey: " " + testKey + " ",
Title: " 夏季上衣 ",
ProductURL: "https://mobile.yangkeduo.com/goods.html?goods_id=937122477375&utm_source=untrusted",
SKUColor: " 黑色CHA(纯棉) ",
SKUSize: " M(建议100-115) ",
Quantity: "2",
MaxTotalPrice: "00012.8",
})
if !validation.Valid() {
t.Fatalf("Validate errors = %#v", validation)
}
if draft.ID != testKey || draft.GoodsID != "937122477375" || draft.Title != "夏季上衣" || draft.SKUColor != "黑色CHA(纯棉)" || draft.SKUSize != "M(建议100-115)" || draft.Quantity != 2 || draft.MaxTotalPrice != "12.80" {
t.Fatalf("normalized draft = %#v", draft)
}
}
func TestValidateRejectsInvalidFieldsAndURLs(t *testing.T) {
base := Form{CreateKey: testKey, Title: "title", ProductURL: "https://mobile.yangkeduo.com/goods.html?goods_id=1", SKUColor: "black", SKUSize: "M", Quantity: "1", MaxTotalPrice: "1"}
for name, update := range map[string]func(*Form){
"empty title": func(form *Form) { form.Title = " " },
"invalid utf8 title": func(form *Form) { form.Title = string([]byte{0xff}) },
"long title": func(form *Form) { form.Title = strings.Repeat("😀", MaxTitleCodePoints+1) },
"long color": func(form *Form) { form.SKUColor = string(make([]rune, maxSKUText+1)) },
"invalid utf8 size": func(form *Form) { form.SKUSize = string([]byte{0xff}) },
"fraction quantity": func(form *Form) { form.Quantity = "1.5" },
"zero quantity": func(form *Form) { form.Quantity = "0" },
"too many decimals": func(form *Form) { form.MaxTotalPrice = "1.234" },
"trailing decimal": func(form *Form) { form.MaxTotalPrice = "1." },
"zero money": func(form *Form) { form.MaxTotalPrice = "0.00" },
} {
t.Run(name, func(t *testing.T) {
form := base
update(&form)
if _, validation := Validate(form); validation.Valid() {
t.Fatal("invalid form was accepted")
}
})
}
for _, value := range []string{
"http://mobile.yangkeduo.com/goods.html?goods_id=1",
"https://yangkeduo.com/goods.html?goods_id=1",
"https://mobile.yangkeduo.com:443/goods.html?goods_id=1",
"https://user@mobile.yangkeduo.com/goods.html?goods_id=1",
"https://mobile.yangkeduo.com/goods.html?goods_id=1#fragment",
"https://mobile.yangkeduo.com/goods.html?goods_id=1&goods_id=2",
"https://mobile.yangkeduo.com/goods.html?goods_id=one",
"https://mobile.yangkeduo.com/goods.html?goods_id=%31",
"https://mobile.yangkeduo.com/goods.html?goods_id=1%26goods_id%3D2",
"https://mobile.yangkeduo.com/goods.html?goods_id=1;uin=bad",
"https://mobile.yangkeduo.com/other.html?goods_id=1",
"https://mobile.yangkeduo.com/goods.html?goods_id=" + strings.Repeat("1", MaxGoodsIDCharacters+1),
} {
if _, ok := CanonicalGoodsID(value); ok {
t.Fatalf("CanonicalGoodsID accepted %q", value)
}
}
}
func TestNormalizeMoneyBoundaries(t *testing.T) {
maximum := strings.Repeat("9", MaxMoneyASCIICharacters-3) + ".00"
for value, want := range map[string]string{"1": "1.00", "1.2": "1.20", "000.01": "0.01", "999999999999999999": "999999999999999999.00", maximum: maximum} {
got, ok := normalizeMoney(value)
if !ok || got != want {
t.Fatalf("normalizeMoney(%q) = (%q, %t), want (%q, true)", value, got, ok, want)
}
}
for _, value := range []string{"0", "0.0", "0.00", "1.", ".1", "1.000", "-1", "1e2", " 1", strings.Repeat("9", MaxMoneyASCIICharacters-2) + ".00"} {
if got, ok := normalizeMoney(value); ok {
t.Fatalf("normalizeMoney(%q) = %q, want rejection", value, got)
}
}
}
func TestValidateAcceptsWorstLegalUnicodeFieldBounds(t *testing.T) {
goodsID := strings.Repeat("1", MaxGoodsIDCharacters)
draft, validation := Validate(Form{
CreateKey: testKey, Title: strings.Repeat("😀", MaxTitleCodePoints),
ProductURL: CanonicalURL(goodsID), SKUColor: strings.Repeat("色", MaxSKUTextCodePoints),
SKUSize: strings.Repeat("码", MaxSKUTextCodePoints), Quantity: "1",
MaxTotalPrice: strings.Repeat("9", MaxMoneyASCIICharacters-3) + ".00",
})
if !validation.Valid() || !ValidTaskWireFields(draft.Title, draft.GoodsID, draft.SKUColor, draft.SKUSize, draft.MaxTotalPrice) {
t.Fatalf("worst legal draft = %#v, validation = %#v", draft, validation)
}
}
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 {
t.Fatalf("NewCreateKey: %v", err)
}
if !regexp.MustCompile(`^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$`).MatchString(key) {
t.Fatalf("create key %q is not UUID v4", key)
}
}
func TestSQLiteStoreRequiresMigratedDatabase(t *testing.T) {
database := openDatabase(t)
if _, err := NewSQLiteStore(database); err == nil {
t.Fatal("NewSQLiteStore accepted an unmigrated database")
}
}
func TestSQLiteStoreCreatesListsAndHandlesIdempotency(t *testing.T) {
database := migratedDatabase(t)
store, err := NewSQLiteStore(database)
if err != nil {
t.Fatalf("NewSQLiteStore: %v", err)
}
baseTime := time.Date(2026, 8, 4, 9, 0, 0, 0, time.UTC)
call := 0
store.now = func() time.Time {
result := baseTime.Add(time.Duration(call) * time.Minute)
call++
return result
}
first := testDraft(testKey, "first")
created, err := store.CreateDraft(context.Background(), first)
if err != nil {
t.Fatalf("create first draft: %v", err)
}
replayed, err := store.CreateDraft(context.Background(), first)
if err != nil {
t.Fatalf("replay first draft: %v", err)
}
if replayed.CreatedAt != created.CreatedAt {
t.Fatalf("replayed CreatedAt = %s, want original %s", replayed.CreatedAt, created.CreatedAt)
}
second := testDraft("b3c9f507-7473-4fa6-8d71-8786c34c6301", "second")
if _, err := store.CreateDraft(context.Background(), second); err != nil {
t.Fatalf("create second draft: %v", err)
}
drafts, err := store.ListDrafts(context.Background())
if err != nil {
t.Fatalf("list drafts: %v", err)
}
if len(drafts) != 2 || drafts[0].ID != second.ID || drafts[1].ID != first.ID {
t.Fatalf("draft order = %#v, want second then first", drafts)
}
var source, status string
var version int
if err := database.QueryRow(`SELECT source, status, version FROM tasks WHERE id = ?`, first.ID).Scan(&source, &status, &version); err != nil {
t.Fatalf("read stored task: %v", err)
}
if source != "MANUAL" || status != "DRAFT" || version != 1 {
t.Fatalf("stored metadata = (%q, %q, %d)", source, status, version)
}
conflicting := first
conflicting.Title = "different"
if _, err := store.CreateDraft(context.Background(), conflicting); !errors.Is(err, ErrCreateKeyConflict) {
t.Fatalf("conflicting create error = %v, want ErrCreateKeyConflict", err)
}
}
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) },
"long size": func(draft *Draft) { draft.SKUSize = strings.Repeat("码", MaxSKUTextCodePoints+1) },
"long goods id": func(draft *Draft) { draft.GoodsID = strings.Repeat("1", MaxGoodsIDCharacters+1) },
"long money": func(draft *Draft) { draft.MaxTotalPrice = strings.Repeat("1", MaxMoneyASCIICharacters-2) + ".00" },
}
for name, mutate := range mutations {
t.Run(name, func(t *testing.T) {
database := migratedDatabase(t)
store, err := NewSQLiteStore(database)
if err != nil {
t.Fatal(err)
}
draft := testDraft(testKey, "title")
mutate(&draft)
if _, err := store.CreateDraft(context.Background(), draft); !errors.Is(err, ErrInvalidDraft) {
t.Fatalf("CreateDraft error = %v, want ErrInvalidDraft", err)
}
var count int
if err := database.QueryRow("SELECT COUNT(*) FROM tasks").Scan(&count); err != nil || count != 0 {
t.Fatalf("tasks after invalid create = %d, err %v", count, err)
}
})
}
}
func TestSQLiteStoreRollsBackFailedCreate(t *testing.T) {
database := migratedDatabase(t)
store, err := NewSQLiteStore(database)
if err != nil {
t.Fatalf("NewSQLiteStore: %v", err)
}
if _, err := database.Exec(`CREATE TRIGGER reject_task BEFORE INSERT ON tasks BEGIN SELECT RAISE(ABORT, 'reject test insert'); END`); err != nil {
t.Fatalf("create trigger: %v", err)
}
if _, err := store.CreateDraft(context.Background(), testDraft(testKey, "blocked")); err == nil {
t.Fatal("CreateDraft succeeded despite rejecting trigger")
}
drafts, err := store.ListDrafts(context.Background())
if err != nil {
t.Fatalf("list after failed create: %v", err)
}
if len(drafts) != 0 {
t.Fatalf("failed create persisted drafts: %#v", drafts)
}
}
func TestSQLiteStoreUsesInsertionOrderForEqualTimesAndFiltersPhase(t *testing.T) {
database := migratedDatabase(t)
store, err := NewSQLiteStore(database)
if err != nil {
t.Fatalf("NewSQLiteStore: %v", err)
}
store.now = func() time.Time { return time.Date(2026, 8, 4, 9, 0, 0, 0, time.UTC) }
first := testDraft(testKey, "first")
second := testDraft("b3c9f507-7473-4fa6-8d71-8786c34c6301", "second")
for _, draft := range []Draft{first, second} {
if _, err := store.CreateDraft(context.Background(), draft); err != nil {
t.Fatalf("create %s: %v", draft.Title, err)
}
}
if _, err := database.Exec(`INSERT INTO tasks (id, source, title, goods_id, sku_color, sku_size, quantity, max_total_price, status, version, created_at, updated_at) VALUES ('excel-draft', 'EXCEL', 'other', '1', 'black', 'M', 1, '1.00', 'DRAFT', 1, '2026-08-04T10:00:00Z', '2026-08-04T10:00:00Z'), ('manual-pending', 'MANUAL', 'other', '2', 'black', 'M', 1, '1.00', 'PENDING', 1, '2026-08-04T10:00:00Z', '2026-08-04T10:00:00Z')`); err != nil {
t.Fatalf("insert out-of-scope tasks: %v", err)
}
drafts, err := store.ListDrafts(context.Background())
if err != nil {
t.Fatalf("list drafts: %v", err)
}
if len(drafts) != 2 || drafts[0].ID != second.ID || drafts[1].ID != first.ID {
t.Fatalf("equal-time draft order/filter = %#v, want second then first only", drafts)
}
if _, err := database.Exec(`UPDATE tasks SET status = 'PENDING' WHERE id = ?`, first.ID); err != nil {
t.Fatalf("move draft outside current phase: %v", err)
}
if _, err := store.CreateDraft(context.Background(), first); !errors.Is(err, ErrCreateKeyConflict) {
t.Fatalf("replay of non-DRAFT record error = %v, want conflict", err)
}
third := testDraft("c3c9f507-7473-4fa6-8d71-8786c34c6301", "third")
if _, err := database.Exec(`INSERT INTO tasks (id, source, title, goods_id, sku_color, sku_size, quantity, max_total_price, status, version, created_at, updated_at) VALUES (?, 'EXCEL', ?, ?, ?, ?, ?, ?, 'DRAFT', 1, '2026-08-04T09:00:00Z', '2026-08-04T09:00:00Z')`, third.ID, third.Title, third.GoodsID, third.SKUColor, third.SKUSize, third.Quantity, third.MaxTotalPrice); err != nil {
t.Fatalf("insert same-payload EXCEL record: %v", err)
}
if _, err := store.CreateDraft(context.Background(), third); !errors.Is(err, ErrCreateKeyConflict) {
t.Fatalf("replay of non-MANUAL record error = %v, want conflict", err)
}
if _, err := database.Exec(`UPDATE tasks SET version = 2, source = 'MANUAL' WHERE id = ?`, third.ID); err != nil {
t.Fatalf("change replay record version: %v", err)
}
if _, err := store.CreateDraft(context.Background(), third); !errors.Is(err, ErrCreateKeyConflict) {
t.Fatalf("replay of non-v1 record error = %v, want conflict", err)
}
}
func TestSQLiteStoreConcurrentIdenticalCreateIsOneDraft(t *testing.T) {
store, err := NewSQLiteStore(migratedDatabase(t))
if err != nil {
t.Fatalf("NewSQLiteStore: %v", err)
}
const callers = 20
start := make(chan struct{})
errors := make(chan error, callers)
results := make(chan Draft, callers)
var group sync.WaitGroup
for range callers {
group.Add(1)
go func() {
defer group.Done()
<-start
draft, err := store.CreateDraft(context.Background(), testDraft(testKey, "same"))
if err != nil {
errors <- err
return
}
results <- draft
}()
}
close(start)
group.Wait()
close(errors)
close(results)
for err := range errors {
t.Fatalf("concurrent create: %v", err)
}
for result := range results {
if result.ID != testKey {
t.Fatalf("concurrent result = %#v", result)
}
}
drafts, err := store.ListDrafts(context.Background())
if err != nil {
t.Fatalf("list after concurrent create: %v", err)
}
if len(drafts) != 1 || drafts[0].ID != testKey {
t.Fatalf("concurrent creates persisted %#v, want exactly one", drafts)
}
}
func testDraft(id, title string) Draft {
return Draft{ID: id, Title: title, GoodsID: "937122477375", SKUColor: "black", SKUSize: "M", Quantity: 2, MaxTotalPrice: "12.80"}
}
func openDatabase(t *testing.T) *sql.DB {
t.Helper()
database, err := sqlite.Open(filepath.Join(t.TempDir(), "tasks.db"))
if err != nil {
t.Fatalf("open database: %v", err)
}
t.Cleanup(func() { _ = database.Close() })
return database
}
func migratedDatabase(t *testing.T) *sql.DB {
t.Helper()
database := openDatabase(t)
if err := migrations.Up(context.Background(), database, migrationDirectory(t)); err != nil {
t.Fatalf("migrate database: %v", err)
}
return database
}
func migrationDirectory(t *testing.T) string {
t.Helper()
_, file, _, ok := runtime.Caller(0)
if !ok {
t.Fatal("locate test source")
}
return filepath.Join(filepath.Dir(file), "..", "..", "migrations")
}
@@ -0,0 +1,211 @@
"use strict";
const test = require("node:test");
const assert = require("node:assert/strict");
const fs = require("node:fs");
const path = require("node:path");
const vm = require("node:vm");
const source = fs.readFileSync(path.join(__dirname, "tasks.js"), "utf8");
test("visible button opens the same routed detail and close restores list state", async () => {
const harness = createDrawerHarness();
harness.button.listeners.click();
await harness.flush();
assert.equal(harness.requests.length, 1);
assert.equal(harness.requests[0].url, "/tasks/a3c9f507-7473-4fa6-8d71-8786c34c6301");
assert.equal(harness.requests[0].options.headers["X-CMBuyer-View"], "drawer");
assert.equal(harness.drawer.open, true);
assert.equal(harness.closeButton.focused, true);
assert.equal(harness.history.pushes.length, 1);
assert.equal(harness.history.pushes[0].url, harness.requests[0].url);
assert.equal(harness.history.pushes[0].state.focusTarget, "button");
harness.closeButton.listeners.click();
assert.equal(harness.history.backCalls, 1);
harness.popstate({state: {cmbuyerList: true}});
assert.equal(harness.drawer.open, false);
assert.equal(harness.button.focused, true);
assert.equal(harness.row.focused, false);
assert.equal(harness.scrolls.length, 1);
assert.equal(harness.scrolls[0].top, 275);
assert.equal(harness.scrolls[0].behavior, "auto");
});
test("button focus target survives back, forward, and back again", async () => {
const harness = createDrawerHarness();
harness.button.listeners.click();
await harness.flush();
const drawerState = harness.history.pushes[0].state;
harness.popstate({state: {cmbuyerList: true}});
assert.equal(harness.button.focusCalls, 1);
assert.equal(harness.row.focusCalls, 0);
harness.popstate({state: drawerState});
await harness.flush();
assert.equal(harness.drawer.open, true);
assert.equal(harness.history.pushes.length, 1);
harness.popstate({state: {cmbuyerList: true}});
assert.equal(harness.drawer.open, false);
assert.equal(harness.button.focusCalls, 2);
assert.equal(harness.row.focusCalls, 0);
});
test("failed forward retry reuses history and closes back to button in one step", async () => {
const harness = createDrawerHarness();
harness.button.listeners.click();
await harness.flush();
const drawerState = harness.history.pushes[0].state;
harness.popstate({state: {cmbuyerList: true}});
harness.failNextRequest();
harness.popstate({state: drawerState});
await harness.flush();
const retry = harness.body.children[1].children[0];
retry.listeners.click();
await harness.flush();
assert.equal(harness.history.pushes.length, 1);
assert.equal(harness.drawer.open, true);
harness.closeButton.listeners.click();
assert.equal(harness.history.backCalls, 1);
harness.popstate({state: {cmbuyerList: true}});
assert.equal(harness.drawer.open, false);
assert.equal(harness.button.focusCalls, 2);
assert.equal(harness.row.focusCalls, 0);
});
test("double click and Enter open rows but nested controls never do", async () => {
const harness = createDrawerHarness();
const ignored = {closest: () => ({})};
const rowTarget = {closest: () => null};
harness.row.listeners.dblclick({target: ignored});
harness.row.listeners.dblclick({target: rowTarget});
await harness.flush();
assert.equal(harness.requests.length, 1);
harness.popstate({state: {cmbuyerList: true}});
let prevented = false;
harness.row.listeners.keydown({key: "Enter", target: harness.row, preventDefault: () => { prevented = true; }});
await harness.flush();
assert.equal(prevented, true);
assert.equal(harness.requests.length, 2);
harness.row.listeners.keydown({key: "Enter", target: ignored, preventDefault: () => assert.fail("nested control Enter was intercepted")});
assert.equal(harness.requests.length, 2);
});
test("browser back and forward close and reopen without duplicating history", async () => {
const harness = createDrawerHarness();
harness.row.listeners.keydown({key: "Enter", target: harness.row, preventDefault() {}});
await harness.flush();
assert.equal(harness.history.pushes.length, 1);
harness.popstate({state: {cmbuyerList: true}});
assert.equal(harness.drawer.open, false);
harness.popstate({state: {cmbuyerDrawer: true, detailURL: harness.row.dataset.detailUrl}});
await harness.flush();
assert.equal(harness.drawer.open, true);
assert.equal(harness.requests.length, 2);
assert.equal(harness.history.pushes.length, 1);
});
test("Escape follows browser history and does not mutate list URL", async () => {
const harness = createDrawerHarness();
harness.button.listeners.click();
await harness.flush();
let prevented = false;
harness.drawer.listeners.cancel({preventDefault: () => { prevented = true; }});
assert.equal(prevented, true);
assert.equal(harness.history.backCalls, 1);
assert.equal(harness.history.replaces[0].url, "/tasks?status=DRAFT");
});
function createDrawerHarness() {
class FakeElement {
constructor() {
this.listeners = {};
this.dataset = {};
this.open = false;
this.focused = false;
this.focusCalls = 0;
this.children = [];
this._innerHTML = "";
}
addEventListener(type, listener) { this.listeners[type] = listener; }
focus() { this.focused = true; this.focusCalls++; }
showModal() { this.open = true; }
close() { this.open = false; }
replaceChildren(...children) { this.children = children; this._innerHTML = ""; }
append(...children) { this.children.push(...children); }
setAttribute() {}
closest() { return null; }
set innerHTML(value) { this._innerHTML = value; }
get innerHTML() { return this._innerHTML; }
}
const body = new FakeElement();
const closeButton = new FakeElement();
const button = new FakeElement();
const row = new FakeElement();
row.dataset.detailUrl = "/tasks/a3c9f507-7473-4fa6-8d71-8786c34c6301";
row.querySelector = (selector) => selector === "[data-open-detail]" ? button : null;
const drawer = new FakeElement();
drawer.querySelector = (selector) => ({"[data-detail-body]": body, "[data-close-detail]": closeButton})[selector] || null;
const requests = [];
const popstateListeners = [];
const scrolls = [];
let failNext = false;
const history = {
state: null,
pushes: [],
replaces: [],
backCalls: 0,
pushState(state, _title, url) { this.state = state; this.pushes.push({state, url}); },
replaceState(state, _title, url) { this.state = state; this.replaces.push({state, url}); },
back() { this.backCalls++; },
};
const document = {
querySelector: (selector) => selector === "[data-start-purchases]" ? null : selector === "[data-detail-drawer]" ? drawer : null,
querySelectorAll: (selector) => selector === "[data-task-row]" ? [row] : [],
createElement: () => new FakeElement(),
contains: (element) => element === row || element === button,
};
const window = {
location: {pathname: "/tasks", search: "?status=DRAFT"},
history,
scrollY: 275,
scrollTo: (value) => scrolls.push(value),
addEventListener(type, listener) { if (type === "popstate") popstateListeners.push(listener); },
};
const context = {
AbortController,
document,
window,
fetch: async (url, options) => {
requests.push({url, options});
if (failNext) {
failNext = false;
return {ok: false, headers: {get: () => "text/html"}, text: async () => ""};
}
return {ok: true, headers: {get: () => "text/html; charset=utf-8"}, text: async () => '<article data-task-detail-content>详情</article>'};
},
};
vm.runInNewContext(source, context, {filename: "tasks.js"});
return {
body, button, closeButton, drawer, history, requests, row, scrolls,
failNextRequest: () => { failNext = true; },
popstate: (event) => { history.state = event.state; popstateListeners.forEach((listener) => listener(event)); },
flush: () => new Promise((resolve) => setImmediate(resolve)),
};
}
@@ -0,0 +1,167 @@
(() => {
"use strict";
const form = document.querySelector("[data-start-purchases]");
if (!form) return;
const all = form.querySelector("[data-select-all]");
const summary = form.querySelector("[data-selection-summary]");
const button = form.querySelector("[data-start-button]");
const feedback = form.querySelector("[data-start-feedback]");
const boxes = () => [...form.querySelectorAll("input[data-task-id]")];
let selectionFrozen = false;
const parseCents = (value) => {
const match = /^(0|[1-9]\d*)\.(\d{2})$/.exec(value);
return match ? BigInt(match[1] + match[2]) : null;
};
const refresh = () => {
const available = boxes();
const selected = available.filter((box) => box.checked);
let cents = 0n;
let pricesValid = true;
selected.forEach((box) => {
const price = parseCents(box.dataset.price);
if (price === null) pricesValid = false;
else cents += price;
});
summary.textContent = `已选 ${selected.length} 条,最高总额 ¥${cents / 100n}.${(cents % 100n).toString().padStart(2, "0")}`;
button.disabled = !selected.length || !pricesValid;
if (!pricesValid) feedback.textContent = "所选任务金额无法安全汇总,请刷新后重选。";
if (all) {
all.checked = selected.length > 0 && selected.length === available.length;
all.indeterminate = selected.length > 0 && selected.length < available.length;
all.disabled = selectionFrozen || available.length === 0;
}
};
const freezeSelection = (frozen) => {
selectionFrozen = frozen;
boxes().forEach((box) => { box.disabled = frozen; });
refresh();
};
boxes().forEach((box) => box.addEventListener("change", refresh));
if (all) all.addEventListener("change", () => { boxes().forEach((box) => { box.checked = all.checked; }); refresh(); });
let frozenPayload = null;
let inFlight = false;
form.addEventListener("submit", async (event) => {
event.preventDefault();
const selected = boxes().filter((box) => box.checked);
if (!selected.length || inFlight) return;
const tasks = selected.map((box) => ({task_id: box.dataset.taskId, expected_task_version: Number(box.dataset.taskVersion)}));
if (tasks.some((item) => !Number.isSafeInteger(item.expected_task_version) || item.expected_task_version < 1)) { feedback.textContent = "任务版本无效,请刷新后重选。"; return; }
frozenPayload = frozenPayload || JSON.stringify({start_key: form.dataset.startKey, tasks});
inFlight = true; freezeSelection(true); button.disabled = true; button.textContent = "正在授权…";
try { const response = await fetch("/tasks/start-purchases", {method:"POST", headers:{"Content-Type":"application/json", "X-CSRF-Token":form.dataset.csrf}, body:frozenPayload});
if (response.ok) { window.location.reload(); return; }
if (response.status === 409) { feedback.textContent = "任务已变化,请刷新后重选。"; frozenPayload = null; freezeSelection(false); boxes().forEach((box) => { box.checked = false; }); refresh(); return; }
if (response.status === 400 || response.status === 401 || response.status === 403) { feedback.textContent = "请求未被接受,请刷新页面后重试。"; frozenPayload = null; freezeSelection(false); return; }
feedback.textContent = "结果暂时不明确,只能使用同一按钮原样重放。";
} catch (_) { feedback.textContent = "网络结果不明确,请使用同一按钮原样重试。"; }
finally { inFlight = false; button.textContent = "开始采购(只创建待付款订单)"; if (frozenPayload) button.disabled = false; }
});
refresh();
})();
(() => {
"use strict";
const drawer = document.querySelector("[data-detail-drawer]");
if (!drawer) return;
const body = drawer.querySelector("[data-detail-body]");
const closeButton = drawer.querySelector("[data-close-detail]");
const rows = [...document.querySelectorAll("[data-task-row]")];
const initialURL = window.location.pathname + window.location.search;
let focusTrigger = null;
let scrollPosition = window.scrollY;
let activeRequest = null;
const isInteractive = (target) => Boolean(target && typeof target.closest === "function" && target.closest("a,button,input,select,textarea,label,[contenteditable=true]"));
const showDrawer = () => {
if (!drawer.open) drawer.showModal();
};
const restoreList = () => {
if (activeRequest) {
activeRequest.abort();
activeRequest = null;
}
if (drawer.open) drawer.close();
window.scrollTo({top: scrollPosition, behavior: "auto"});
if (focusTrigger && document.contains(focusTrigger)) focusTrigger.focus({preventScroll: true});
};
const showError = (url, row, requestedFocus, pushHistory) => {
body.replaceChildren();
const message = document.createElement("p");
message.className = "drawer-feedback";
message.setAttribute("role", "alert");
message.textContent = "任务详情加载失败。请重试,或在完整页打开。";
const actions = document.createElement("p");
const retry = document.createElement("button");
retry.className = "button primary";
retry.type = "button";
retry.textContent = "重试";
retry.addEventListener("click", () => loadDetail(url, row, requestedFocus, pushHistory));
const fallback = document.createElement("a");
fallback.className = "button";
fallback.href = url;
fallback.textContent = "在完整页打开";
actions.className = "actions";
actions.append(retry, fallback);
body.append(message, actions);
};
const loadDetail = async (url, row, requestedFocus, pushHistory) => {
if (activeRequest) activeRequest.abort();
const requestController = new AbortController();
activeRequest = requestController;
focusTrigger = requestedFocus || focusTrigger;
if (pushHistory) scrollPosition = window.scrollY;
body.innerHTML = '<p class="drawer-feedback" role="status">正在加载任务详情…</p>';
showDrawer();
try {
const response = await fetch(url, {headers: {"X-CMBuyer-View": "drawer", "Accept": "text/html"}, credentials: "same-origin", signal: requestController.signal});
if (!response.ok || !String(response.headers.get("Content-Type") || "").toLowerCase().startsWith("text/html")) throw new Error("detail request rejected");
const fragment = await response.text();
if (!fragment.includes("data-task-detail-content")) throw new Error("detail fragment missing");
body.innerHTML = fragment;
if (pushHistory) window.history.pushState({cmbuyerDrawer: true, detailURL: url, focusTarget: requestedFocus === row ? "row" : "button"}, "", url);
closeButton.focus();
} catch (error) {
if (error.name !== "AbortError") showError(url, row, requestedFocus, pushHistory);
} finally {
if (activeRequest === requestController) activeRequest = null;
}
};
const requestClose = () => {
if (window.history.state && window.history.state.cmbuyerDrawer) window.history.back();
else restoreList();
};
window.history.replaceState({cmbuyerList: true, listURL: initialURL}, "", initialURL);
rows.forEach((row) => {
const url = row.dataset.detailUrl;
row.addEventListener("dblclick", (event) => {
if (!isInteractive(event.target)) loadDetail(url, row, row, true);
});
row.addEventListener("keydown", (event) => {
if (event.key === "Enter" && event.target === row) {
event.preventDefault();
loadDetail(url, row, row, true);
}
});
const button = row.querySelector("[data-open-detail]");
if (button) button.addEventListener("click", () => loadDetail(url, row, button, true));
});
closeButton.addEventListener("click", requestClose);
drawer.addEventListener("cancel", (event) => {
event.preventDefault();
requestClose();
});
window.addEventListener("popstate", (event) => {
if (event.state && event.state.cmbuyerDrawer) {
const row = rows.find((candidate) => candidate.dataset.detailUrl === event.state.detailURL);
if (!row) {
restoreList();
return;
}
const requestedFocus = event.state.focusTarget === "button" ? row.querySelector("[data-open-detail]") || row : row;
loadDetail(event.state.detailURL, row, requestedFocus, false);
return;
}
restoreList();
});
})();
@@ -0,0 +1,138 @@
"use strict";
const test = require("node:test");
const assert = require("node:assert/strict");
const fs = require("node:fs");
const path = require("node:path");
const vm = require("node:vm");
const source = fs.readFileSync(path.join(__dirname, "tasks.js"), "utf8");
test("successful authorization sends numeric version and reloads", async () => {
const requests = [];
const harness = createHarness(async (_url, options) => {
requests.push(options);
return {ok: true, status: 200};
});
await harness.submit();
assert.equal(requests.length, 1);
assert.equal(requests[0].headers["Content-Type"], "application/json");
assert.equal(requests[0].headers["X-CSRF-Token"], "csrf-token");
const payload = JSON.parse(requests[0].body);
assert.equal(payload.start_key, "start-key");
assert.equal(typeof payload.tasks[0].expected_task_version, "number");
assert.equal(payload.tasks[0].expected_task_version, 7);
assert.equal(harness.reloads(), 1);
});
test("409 clears stale selection and requires a fresh choice", async () => {
const harness = createHarness(async () => ({ok: false, status: 409}));
await harness.submit();
assert.equal(harness.box.checked, false);
assert.equal(harness.box.disabled, false);
assert.equal(harness.button.disabled, true);
assert.match(harness.feedback.textContent, /任务已变化/);
});
for (const status of [400, 401, 403]) {
test(`${status} releases the frozen payload for a page refresh`, async () => {
const harness = createHarness(async () => ({ok: false, status}));
await harness.submit();
assert.equal(harness.box.checked, true);
assert.equal(harness.box.disabled, false);
assert.equal(harness.button.disabled, false);
assert.match(harness.feedback.textContent, /刷新页面后重试/);
});
}
test("5xx retries the byte-identical frozen payload", async () => {
const bodies = [];
const harness = createHarness(async (_url, options) => {
bodies.push(options.body);
return {ok: false, status: 503};
});
await harness.submit();
assert.equal(harness.box.disabled, true);
assert.equal(harness.button.disabled, false);
assert.match(harness.feedback.textContent, /原样重放/);
await harness.submit();
assert.equal(bodies.length, 2);
assert.equal(bodies[1], bodies[0]);
});
test("network ambiguity retries the same payload and can finish", async () => {
const bodies = [];
let call = 0;
const harness = createHarness(async (_url, options) => {
bodies.push(options.body);
call++;
if (call === 1) throw new Error("network result unknown");
return {ok: true, status: 200};
});
await harness.submit();
assert.equal(harness.box.disabled, true);
assert.match(harness.feedback.textContent, /原样重试/);
await harness.submit();
assert.deepEqual(bodies, [bodies[0], bodies[0]]);
assert.equal(harness.reloads(), 1);
});
function createHarness(fetchImplementation) {
class FakeElement {
constructor() {
this.dataset = {};
this.checked = false;
this.disabled = false;
this.indeterminate = false;
this.textContent = "";
this.listeners = {};
}
addEventListener(type, listener) {
this.listeners[type] = listener;
}
}
const box = new FakeElement();
box.checked = true;
box.dataset = {taskId: "task-id", taskVersion: "7", price: "12.80"};
const selectAll = new FakeElement();
const summary = new FakeElement();
const button = new FakeElement();
const feedback = new FakeElement();
const form = new FakeElement();
form.dataset = {startKey: "start-key", csrf: "csrf-token"};
form.querySelector = (selector) => ({
"[data-select-all]": selectAll,
"[data-selection-summary]": summary,
"[data-start-button]": button,
"[data-start-feedback]": feedback,
})[selector] || null;
form.querySelectorAll = (selector) => selector === "input[data-task-id]" ? [box] : [];
let reloadCount = 0;
const context = {
document: {querySelector: (selector) => selector === "[data-start-purchases]" ? form : null},
fetch: fetchImplementation,
window: {location: {reload: () => { reloadCount++; }}},
};
vm.runInNewContext(source, context, {filename: "tasks.js"});
return {
box,
button,
feedback,
reloads: () => reloadCount,
submit: () => form.listeners.submit({preventDefault() {}}),
};
}
@@ -0,0 +1,63 @@
{{define "login.html"}}
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>登录 · 采购服务</title>
<style>
:root { color-scheme: light; --bg:#f4f7fb; --surface:#fff; --text:#172033; --muted:#526079; --border:#cfd8e6; --primary:#155eef; --primary-hover:#0b4ed1; --primary-soft:#eaf1ff; --danger:#b42318; --danger-soft:#fef3f2; --focus:#ffbf47; --shadow:0 12px 30px rgba(23,32,51,.1); font-family:"Segoe UI","Microsoft YaHei UI",system-ui,sans-serif; }
* { box-sizing:border-box; }
html { min-width:320px; background:var(--bg); }
body { min-height:100dvh; margin:0; color:var(--text); background:var(--bg); font-size:16px; line-height:1.55; }
button,input { font:inherit; }
:focus-visible { outline:3px solid var(--focus); outline-offset:3px; }
.skip-link { position:fixed; z-index:10; top:8px; left:8px; padding:10px 14px; color:#fff; background:var(--text); transform:translateY(-160%); }
.skip-link:focus { transform:translateY(0); }
main { display:grid; min-height:100dvh; place-items:center; padding:24px 16px; }
.card { width:min(100%,440px); padding:32px; border:1px solid var(--border); border-radius:14px; background:var(--surface); box-shadow:var(--shadow); }
.brand { display:flex; align-items:center; gap:10px; margin:0 0 24px; font-size:1rem; font-weight:700; }
.brand-mark { display:grid; width:32px; height:32px; place-items:center; border-radius:8px; color:#fff; background:var(--primary); font-size:.82rem; }
h1 { margin:0; font-size:clamp(1.6rem,5vw,2rem); line-height:1.25; }
.intro { margin:8px 0 24px; color:var(--muted); }
.field { margin-top:16px; }
label { display:block; margin-bottom:6px; font-weight:700; }
input { width:100%; min-height:44px; padding:10px 12px; border:1px solid #9ba9bc; border-radius:8px; color:var(--text); background:#fff; }
input[aria-invalid="true"] { border-color:var(--danger); box-shadow:0 0 0 1px var(--danger); }
.hint { margin:5px 0 0; color:var(--muted); font-size:.875rem; }
.error { margin:0 0 18px; padding:12px 14px; border-left:4px solid var(--danger); border-radius:6px; color:var(--danger); background:var(--danger-soft); font-weight:650; }
.submit { width:100%; min-height:44px; margin-top:24px; padding:10px 16px; border:1px solid transparent; border-radius:8px; color:#fff; background:var(--primary); font-weight:700; cursor:pointer; transition:background-color 180ms ease-out; }
.submit:hover { background:var(--primary-hover); }
.notice { margin:20px 0 0; padding:12px 14px; border:1px solid #b9cffc; border-radius:8px; color:#29466f; background:var(--primary-soft); font-size:.9rem; }
@media (max-width:420px) { main { padding-inline:12px; } .card { padding:24px 16px; } }
@media (prefers-reduced-motion:reduce) { *,*::before,*::after { transition-duration:.01ms !important; animation-duration:.01ms !important; } }
</style>
</head>
<body>
<a class="skip-link" href="#main">跳到主要内容</a>
<main id="main">
<section class="card" aria-labelledby="login-title">
<p class="brand"><span class="brand-mark" aria-hidden="true">采</span><span>采购服务</span></p>
<h1 id="login-title">管理端登录</h1>
<p class="intro">登录后进入采购任务工作台。设备身份不能使用此入口。</p>
{{if .Error}}<p class="error" role="alert">{{.Error}}</p>{{end}}
<form method="post" action="/login">
<input type="hidden" name="csrf_token" value="{{.CSRFToken}}">
<input type="hidden" name="return_to" value="{{.ReturnTo}}">
<div class="field">
<label for="username">账号</label>
<input id="username" name="username" type="text" value="{{.Username}}" autocomplete="username" required aria-invalid="{{if .Error}}true{{else}}false{{end}}" aria-describedby="username-hint">
<p class="hint" id="username-hint">使用采购管理员账号登录。</p>
</div>
<div class="field">
<label for="password">密码</label>
<input id="password" name="password" type="password" autocomplete="current-password" required aria-invalid="{{if .Error}}true{{else}}false{{end}}">
</div>
<button class="submit" type="submit">登录并继续</button>
</form>
<p class="notice">系统只创建待付款订单,付款始终由人完成。</p>
</section>
</main>
</body>
</html>
{{end}}
@@ -0,0 +1,38 @@
{{define "task-detail-page.html"}}
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>{{.Detail.Task.Title}} · 任务详情 · 采购服务</title>
<style>
:root{--bg:#f4f7fb;--surface:#fff;--text:#172033;--muted:#526079;--border:#cfd8e6;--primary:#155eef;--danger:#b42318;--success:#067647;--focus:#ffbf47;font-family:"Segoe UI","Microsoft YaHei UI",system-ui,sans-serif}*{box-sizing:border-box}body{margin:0;color:var(--text);background:var(--bg);font-size:16px;line-height:1.55}a{color:#124cc5;text-underline-offset:3px}:focus-visible{outline:3px solid var(--focus);outline-offset:3px}.skip{position:fixed;z-index:100;top:8px;left:8px;padding:10px;color:#fff;background:#172033;transform:translateY(-160%)}.skip:focus{transform:translateY(0)}.topbar{display:flex;align-items:center;justify-content:space-between;gap:16px;min-height:64px;padding:10px clamp(16px,4vw,40px);border-bottom:1px solid var(--border);background:var(--surface)}.brand{color:var(--text);font-weight:700;text-decoration:none}.brand b{display:inline-grid;place-items:center;width:32px;height:32px;margin-right:8px;border-radius:8px;background:var(--primary);color:#fff;font-size:.82rem}.button{display:inline-flex;align-items:center;justify-content:center;min-height:44px;padding:9px 14px;border:1px solid var(--border);border-radius:8px;color:var(--text);background:#fff;font-weight:700;text-decoration:none}.detail-page{width:min(100% - 32px,1120px);margin:28px auto 48px}.detail-shell{display:grid;gap:16px}.detail-head{display:flex;align-items:flex-start;justify-content:space-between;gap:16px}.detail-head h1{margin:0;font-size:clamp(1.45rem,3vw,2rem)}.detail-head p{margin:4px 0;color:var(--muted)}.status{display:inline-block;padding:4px 10px;border-radius:999px;background:#eaf1ff;color:#173d8f;font-size:.88rem;font-weight:700;white-space:nowrap}.safety{margin:0;padding:13px 15px;border:1px solid #a9c3f7;border-left:5px solid var(--primary);border-radius:10px;background:#edf3ff}.detail-grid{display:grid;grid-template-columns:minmax(0,1fr) minmax(250px,320px);gap:16px}.detail-card{overflow:hidden;border:1px solid var(--border);border-radius:12px;background:var(--surface)}.detail-card>header,.detail-card>.detail-body{padding:16px 18px}.detail-card>header{border-bottom:1px solid var(--border)}.detail-card h2,.detail-card h3{margin:0}.detail-card header p,.empty-note{margin:4px 0 0;color:var(--muted)}.facts{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:10px;margin:0}.facts div{min-width:0;padding:11px;border:1px solid var(--border);border-radius:8px;background:#f8fafc}.facts dt{font-size:.82rem;color:var(--muted);font-weight:700}.facts dd{margin:3px 0 0;overflow-wrap:anywhere;font-weight:650}.audit-list{display:grid;gap:10px;margin:0;padding:0;list-style:none}.audit-list li{padding:12px;border:1px solid var(--border);border-radius:8px}.audit-list p{margin:4px 0}.mono{font-family:Consolas,"SFMono-Regular",monospace;overflow-wrap:anywhere}.evidence-grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(220px,1fr));gap:14px}.evidence{margin:0}.evidence img{display:block;width:100%;height:auto;max-height:520px;object-fit:contain;border:1px solid var(--border);border-radius:8px;background:#eef2f7}.evidence figcaption{margin-top:7px;color:var(--muted);font-size:.85rem}.section-stack{display:grid;gap:16px}.privacy-note{margin:12px 0 0;color:var(--muted);font-size:.88rem}@media(max-width:760px){.detail-grid{grid-template-columns:1fr}.detail-head{display:grid}.facts{grid-template-columns:1fr}}@media(prefers-reduced-motion:reduce){*,*::before,*::after{scroll-behavior:auto!important;transition-duration:.01ms!important;animation-duration:.01ms!important}}
</style>
</head>
<body>
<a class="skip" href="#main">跳到主要内容</a>
<header class="topbar"><a class="brand" href="/tasks"><b aria-hidden="true">采</b>采购服务</a><a class="button" href="/tasks">返回任务列表</a></header>
<main class="detail-page" id="main">{{template "task-detail-content" .}}</main>
</body>
</html>
{{end}}
{{define "task-detail-content"}}
<article class="detail-shell" data-task-detail-content data-task-id="{{.Detail.Task.ID}}">
<header class="detail-head"><div><h1>{{.Detail.Task.Title}}</h1><p>任务 <span class="mono">{{.Detail.Task.ID}}</span> · 版本 {{.Detail.Task.Version}}</p></div><span class="status">{{statusLabel .Detail.Task.Status}}</span></header>
<p class="safety"><strong>{{taskSafetyTitle .Detail.Task.Status}}</strong> {{taskSafetyText .Detail.Task.Status}}</p>
<div class="detail-grid">
<div class="section-stack">
<section class="detail-card" aria-labelledby="task-facts-title"><header><h2 id="task-facts-title">任务要求</h2><p>管理员锁定的采购边界;详情页不会触发设备动作。</p></header><div class="detail-body"><dl class="facts"><div><dt>商品</dt><dd><a href="{{canonicalURL .Detail.Task.GoodsID}}" target="_blank" rel="noopener noreferrer">goods_id {{.Detail.Task.GoodsID}}</a></dd></div><div><dt>目标规格</dt><dd>{{.Detail.Task.SKUColor}} / {{.Detail.Task.SKUSize}}</dd></div><div><dt>数量</dt><dd>{{.Detail.Task.Quantity}} 件</dd></div><div><dt>最高总价</dt><dd>¥{{.Detail.Task.MaxTotalPrice}}</dd></div><div><dt>创建时间(上海)</dt><dd><time datetime="{{shanghaiDateTime .Detail.Task.CreatedAt}}">{{shanghaiTime .Detail.Task.CreatedAt}}</time></dd></div><div><dt>更新时间(上海)</dt><dd><time datetime="{{shanghaiDateTime .Detail.Task.UpdatedAt}}">{{shanghaiTime .Detail.Task.UpdatedAt}}</time></dd></div></dl></div></section>
<section class="detail-card" aria-labelledby="execution-title"><header><h2 id="execution-title">设备执行事实</h2><p>只展示数据库中已存在的 attempt;T-204 不创建执行记录。</p></header><div class="detail-body">{{if .Detail.Attempts}}<ol class="audit-list">{{range .Detail.Attempts}}<li><h3>Attempt <span class="mono">{{.ID}}</span></h3><p>状态:{{attemptStatusLabel .Status}} · 领取代次 {{.ClaimGeneration}}</p><p>开始:<time datetime="{{shanghaiDateTime .StartedAt}}">{{shanghaiTime .StartedAt}}</time>{{with .FinishedAt}} · 结束:<time datetime="{{shanghaiDateTime .}}">{{shanghaiTime .}}</time>{{end}}</p>{{with .FailureCode}}<p>失败码:<span class="mono">{{.}}</span></p>{{end}}{{if or .Gate1UnitPrice .Gate2UnitPrice .QuantityRead .ConfirmAmount}}<p>已有读数:{{with .Gate1UnitPrice}}闸门一 ¥{{.}};{{end}}{{with .Gate2UnitPrice}}闸门二 ¥{{.}};{{end}}{{with .QuantityRead}}数量 {{.}};{{end}}{{with .ConfirmAmount}}确认页 ¥{{.}}{{end}}</p>{{else}}<p class="empty-note">暂无规格、价格或数量读数。</p>{{end}}</li>{{end}}</ol>{{else}}<p class="empty-note">暂无设备执行记录。</p>{{end}}</div></section>
<section class="detail-card" aria-labelledby="evidence-title"><header><h2 id="evidence-title">内部截图</h2><p>INTERNAL_RAW 仅供已登录管理员审计,不代表价格闸门通过或人工批准。</p></header><div class="detail-body">{{if .Detail.Evidence}}<div class="evidence-grid">{{range .Detail.Evidence}}<figure class="evidence"><img src="/evidence/{{.ID}}" width="{{.Width}}" height="{{.Height}}" loading="lazy" alt="规格面板内部审计截图,采集于 {{shanghaiTime .CapturedAt}}"><figcaption>{{evidenceKindLabel .Kind}}(<span class="mono">{{.Kind}}</span>)· {{formatBytes .ByteSize}} · <time datetime="{{shanghaiDateTime .CapturedAt}}">{{shanghaiTime .CapturedAt}}</time><br>Attempt <span class="mono">{{.AttemptID}}</span></figcaption></figure>{{end}}</div>{{else}}<p class="empty-note">暂无内部截图。只有已认证设备显式上传的 PNG 会出现在这里。</p>{{end}}<p class="privacy-note">截图可能包含页面已显示的地址或手机号;系统不提取、索引或写入日志。完整 XML、外部支付页和支付凭据不会上传。</p></div></section>
<section class="detail-card" aria-labelledby="submission-title"><header><h2 id="submission-title">提交围栏与结果</h2><p>只读审计;本页没有重试、再次提交或付款动作。</p></header><div class="detail-body">{{if .Detail.Submissions}}<ol class="audit-list">{{range .Detail.Submissions}}<li><h3>Submission <span class="mono">{{.ID}}</span></h3><p>状态:{{submissionStatusLabel .Status}}</p><p>闸门一 ¥{{.Gate1UnitPrice}};闸门二 ¥{{.Gate2UnitPrice}};数量 {{.QuantityRead}};确认页 ¥{{.ConfirmAmount}}</p><p>建立:<time datetime="{{shanghaiDateTime .CreatedAt}}">{{shanghaiTime .CreatedAt}}</time>{{with .ResolvedAt}} · 调和:<time datetime="{{shanghaiDateTime .}}">{{shanghaiTime .}}</time>{{end}}</p></li>{{end}}</ol>{{else}}<p class="empty-note">尚未建立提交围栏;详情页不会创建或释放围栏。</p>{{end}}</div></section>
</div>
<aside class="section-stack" aria-label="任务状态摘要"><section class="detail-card"><header><h2>开始采购授权</h2><p>锁定任务字段和最高总价,不授权付款。</p></header><div class="detail-body">{{if .Detail.Authorizations}}<ol class="audit-list">{{range .Detail.Authorizations}}<li><h3>{{authorizationStatusLabel .Status}}</h3><p class="mono">{{.ID}}</p><p>任务版本 {{.TaskVersion}} · 上限 ¥{{.TotalPriceCap}}</p><p>授权人:{{.CreatedBy}}</p><p><time datetime="{{shanghaiDateTime .CreatedAt}}">{{shanghaiTime .CreatedAt}}</time> 至 <time datetime="{{shanghaiDateTime .ExpiresAt}}">{{shanghaiTime .ExpiresAt}}</time></p></li>{{end}}</ol>{{else}}<p class="empty-note">尚未开始采购,没有授权记录。</p>{{end}}</div></section><section class="detail-card"><header><h2>固定边界</h2></header><div class="detail-body"><ul><li>系统只创建待付款订单,不自动付款。</li><li>截图仅供审计,不替代实时三道价格闸门。</li><li>围栏后只能调和同一提交,禁止再次点击。</li></ul></div></section></aside>
</div>
</article>
{{end}}
File diff suppressed because one or more lines are too long
+162
View File
@@ -0,0 +1,162 @@
// Package webui 渲染采购服务当前可用的服务端页面。
package webui
import (
"embed"
"fmt"
"html/template"
"io"
"time"
"cmbuyer/admin/internal/taskdetail"
"cmbuyer/admin/internal/tasks"
)
//go:embed templates/*.html
var templateFiles embed.FS
//go:embed static/tasks.js
var tasksScript []byte
var shanghaiLocation = time.FixedZone("Asia/Shanghai", 8*60*60)
var templates = template.Must(template.New("webui").Funcs(template.FuncMap{
"list": func(values ...any) []any { return values },
"statusLabel": statusLabel,
"shanghaiDateTime": func(value time.Time) string { return value.In(shanghaiLocation).Format(time.RFC3339) },
"shanghaiTime": func(value time.Time) string { return value.In(shanghaiLocation).Format("2006-01-02 15:04") },
"canonicalURL": tasks.CanonicalURL,
"formatBytes": formatBytes,
"taskSafetyTitle": taskSafetyTitle,
"taskSafetyText": taskSafetyText,
"authorizationStatusLabel": authorizationStatusLabel,
"attemptStatusLabel": attemptStatusLabel,
"submissionStatusLabel": submissionStatusLabel,
"evidenceKindLabel": evidenceKindLabel,
}).ParseFS(templateFiles, "templates/*.html"))
// LoginData 是登录页面所需的非敏感展示数据。
type LoginData struct {
CSRFToken string
ReturnTo string
Username string
Error string
}
// TasksData 是受保护的建单与任务工作台页面所需数据。
type TasksData struct {
CSRFToken string
Tasks []tasks.TaskRow
Filter tasks.TaskFilter
FilterErrors tasks.Errors
HasFilter bool
StartKey string
Form tasks.Form
Errors tasks.Errors
OpenForm bool
FullPage bool
FocusField string
Success bool
}
type TaskDetailData struct{ Detail taskdetail.Detail }
// RenderLogin 写入登录页。
func RenderLogin(writer io.Writer, data LoginData) error {
return templates.ExecuteTemplate(writer, "login.html", data)
}
// RenderTasks 写入登录后的受保护任务页。
func RenderTasks(writer io.Writer, data TasksData) error {
return templates.ExecuteTemplate(writer, "tasks.html", data)
}
func RenderTaskDetailPage(writer io.Writer, data TaskDetailData) error {
return templates.ExecuteTemplate(writer, "task-detail-page.html", data)
}
func RenderTaskDetailFragment(writer io.Writer, data TaskDetailData) error {
return templates.ExecuteTemplate(writer, "task-detail-content", data)
}
func TasksScript() []byte { return tasksScript }
func statusLabel(status string) string {
labels := map[string]string{
"DRAFT": "待开始",
"PENDING": "已授权待领取",
"CLAIMED": "已领取",
"ORDERING": "执行中",
"NEEDS_MANUAL": "待人工处理",
"WAITING_PAYMENT": "待付款",
"RECONCILIATION_REQUIRED": "围栏后待调和",
"SUCCEEDED": "已完成",
"FAILED": "失败",
"CANCELED": "已取消",
}
if label, ok := labels[status]; ok {
return label
}
return "未知状态"
}
func taskSafetyTitle(status string) string {
if status == "WAITING_PAYMENT" {
return "订单已创建,系统尚未付款。"
}
if status == "RECONCILIATION_REQUIRED" {
return "订单可能已创建,只能调和同一提交。"
}
return "系统只创建待付款订单,不会自动付款。"
}
func taskSafetyText(status string) string {
if status == "DRAFT" {
return "创建任务不构成授权;请回到列表勾选后开始采购。"
}
if status == "RECONCILIATION_REQUIRED" {
return "围栏保持占用,禁止重新授权、再次提交或释放。"
}
return "截图只供内部审计,不替代实时价格闸门,也不会触发设备动作。"
}
func authorizationStatusLabel(status string) string {
labels := map[string]string{"ACTIVE": "授权有效", "CLAIMED": "已被领取", "FENCED": "提交围栏已建立", "CONSUMED": "授权已消费", "EXPIRED": "授权已过期", "ABANDONED": "授权已关闭"}
if value, ok := labels[status]; ok {
return value
}
return "未知授权状态"
}
func attemptStatusLabel(status string) string {
labels := map[string]string{"CLAIMED": "已领取", "ORDERING": "执行中", "FAILED": "围栏前失败", "FENCED": "已建立围栏", "ABANDONED": "已安全停止"}
if value, ok := labels[status]; ok {
return value
}
return "未知执行状态"
}
func submissionStatusLabel(status string) string {
labels := map[string]string{"FENCED": "围栏已建立", "SUBMITTED": "已创建待付款订单", "RECONCILIATION_REQUIRED": "结果待调和", "MANUAL_RESOLVED": "已人工调和"}
if value, ok := labels[status]; ok {
return value
}
return "未知提交状态"
}
func evidenceKindLabel(kind string) string {
if kind == "SKU_PANEL_GATE_1" {
return "规格面板 · 闸门一"
}
return "内部截图"
}
func formatBytes(value int64) string {
if value >= 1<<20 {
return fmt.Sprintf("%.1f MiB", float64(value)/(1<<20))
}
if value >= 1<<10 {
return fmt.Sprintf("%.1f KiB", float64(value)/(1<<10))
}
return fmt.Sprintf("%d B", value)
}
+148
View File
@@ -0,0 +1,148 @@
-- +goose Up
CREATE TABLE tasks (
id TEXT PRIMARY KEY,
source TEXT NOT NULL CHECK (source IN ('MANUAL', 'EXCEL', 'ERP')),
source_ref TEXT,
title TEXT NOT NULL,
goods_id TEXT NOT NULL,
sku_color TEXT NOT NULL,
sku_size TEXT NOT NULL,
quantity INTEGER NOT NULL CHECK (quantity > 0 AND typeof(quantity) = 'integer'),
max_total_price TEXT NOT NULL CHECK (
max_total_price <> ''
AND max_total_price NOT GLOB '*[^0-9.]*'
AND length(max_total_price) - length(replace(max_total_price, '.', '')) <= 1
AND max_total_price <> '.'
AND (instr(max_total_price, '.') = 0 OR (
instr(max_total_price, '.') > 1
AND length(max_total_price) > instr(max_total_price, '.')
))
),
reference_asset_id TEXT,
status TEXT NOT NULL CHECK (status IN (
'DRAFT', 'PENDING', 'CLAIMED', 'RUNNING', 'WAITING_CONFIRMATION',
'PENDING_RETRIAL', 'AUTHORIZED', 'ORDERING', 'WAITING_PAYMENT',
'RECONCILIATION_REQUIRED', 'NEEDS_MANUAL', 'SUCCEEDED', 'CANCELED'
)),
version INTEGER NOT NULL DEFAULT 1 CHECK (version > 0 AND typeof(version) = 'integer'),
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL
);
CREATE TABLE spec_trials (
id TEXT PRIMARY KEY,
task_id TEXT NOT NULL REFERENCES tasks(id),
attempt INTEGER NOT NULL CHECK (attempt > 0 AND typeof(attempt) = 'integer'),
product_title TEXT NOT NULL,
selected_color TEXT NOT NULL,
selected_size TEXT NOT NULL,
unit_price TEXT NOT NULL CHECK (
unit_price <> ''
AND unit_price NOT GLOB '*[^0-9.]*'
AND length(unit_price) - length(replace(unit_price, '.', '')) <= 1
AND unit_price <> '.'
AND (instr(unit_price, '.') = 0 OR (
instr(unit_price, '.') > 1
AND length(unit_price) > instr(unit_price, '.')
))
),
total_price TEXT NOT NULL CHECK (
total_price <> ''
AND total_price NOT GLOB '*[^0-9.]*'
AND length(total_price) - length(replace(total_price, '.', '')) <= 1
AND total_price <> '.'
AND (instr(total_price, '.') = 0 OR (
instr(total_price, '.') > 1
AND length(total_price) > instr(total_price, '.')
))
),
evidence_sha256 TEXT NOT NULL,
created_at TEXT NOT NULL,
UNIQUE (task_id, attempt),
UNIQUE (task_id, id)
);
CREATE TABLE order_authorizations (
id TEXT PRIMARY KEY,
task_id TEXT NOT NULL REFERENCES tasks(id),
spec_trial_id TEXT NOT NULL REFERENCES spec_trials(id),
version INTEGER NOT NULL CHECK (version > 0 AND typeof(version) = 'integer'),
goods_id TEXT NOT NULL,
sku_color TEXT NOT NULL,
sku_size TEXT NOT NULL,
quantity INTEGER NOT NULL CHECK (quantity > 0 AND typeof(quantity) = 'integer'),
authorized_unit_price TEXT NOT NULL CHECK (
authorized_unit_price <> ''
AND authorized_unit_price NOT GLOB '*[^0-9.]*'
AND length(authorized_unit_price) - length(replace(authorized_unit_price, '.', '')) <= 1
AND authorized_unit_price <> '.'
AND (instr(authorized_unit_price, '.') = 0 OR (
instr(authorized_unit_price, '.') > 1
AND length(authorized_unit_price) > instr(authorized_unit_price, '.')
))
),
total_price_cap TEXT NOT NULL CHECK (
total_price_cap <> ''
AND total_price_cap NOT GLOB '*[^0-9.]*'
AND length(total_price_cap) - length(replace(total_price_cap, '.', '')) <= 1
AND total_price_cap <> '.'
AND (instr(total_price_cap, '.') = 0 OR (
instr(total_price_cap, '.') > 1
AND length(total_price_cap) > instr(total_price_cap, '.')
))
),
note TEXT,
status TEXT NOT NULL CHECK (status IN (
'PENDING_DELIVERY', 'DELIVERED', 'ACKNOWLEDGED', 'EXECUTING', 'FENCED',
'CONSUMED', 'SUPERSEDED', 'EXPIRED'
)),
created_by TEXT NOT NULL,
created_at TEXT NOT NULL,
expires_at TEXT NOT NULL,
UNIQUE (task_id, version),
UNIQUE (task_id, id),
FOREIGN KEY (task_id, spec_trial_id) REFERENCES spec_trials(task_id, id)
);
CREATE TABLE order_submissions (
id TEXT PRIMARY KEY,
task_id TEXT NOT NULL REFERENCES tasks(id),
authorization_id TEXT NOT NULL REFERENCES order_authorizations(id),
command_id TEXT NOT NULL,
dry_run_id TEXT NOT NULL,
status TEXT NOT NULL CHECK (status IN (
'FENCED', 'SUBMITTED', 'RECONCILIATION_REQUIRED', 'MANUAL_RESOLVED'
)),
verified_unit_price TEXT NOT NULL CHECK (
verified_unit_price <> ''
AND verified_unit_price NOT GLOB '*[^0-9.]*'
AND length(verified_unit_price) - length(replace(verified_unit_price, '.', '')) <= 1
AND verified_unit_price <> '.'
AND (instr(verified_unit_price, '.') = 0 OR (
instr(verified_unit_price, '.') > 1
AND length(verified_unit_price) > instr(verified_unit_price, '.')
))
),
quantity_read INTEGER NOT NULL CHECK (quantity_read > 0 AND typeof(quantity_read) = 'integer'),
confirm_page_amount TEXT NOT NULL CHECK (
confirm_page_amount <> ''
AND confirm_page_amount NOT GLOB '*[^0-9.]*'
AND length(confirm_page_amount) - length(replace(confirm_page_amount, '.', '')) <= 1
AND confirm_page_amount <> '.'
AND (instr(confirm_page_amount, '.') = 0 OR (
instr(confirm_page_amount, '.') > 1
AND length(confirm_page_amount) > instr(confirm_page_amount, '.')
))
),
created_at TEXT NOT NULL,
resolved_at TEXT,
UNIQUE (authorization_id),
UNIQUE (command_id),
FOREIGN KEY (task_id, authorization_id) REFERENCES order_authorizations(task_id, id)
);
-- +goose Down
DROP TABLE order_submissions;
DROP TABLE order_authorizations;
DROP TABLE spec_trials;
DROP TABLE tasks;
@@ -0,0 +1,333 @@
-- +goose Up
-- v1 的试选/锁旧价记录无法安全推断为单趟执行事实。先在同一事务中拒绝它们,
-- 避免删除审计数据后再尝试猜测映射。
CREATE TABLE single_pass_upgrade_guard (
valid INTEGER NOT NULL CHECK (valid = 1)
);
INSERT INTO single_pass_upgrade_guard (valid)
SELECT CASE WHEN
(SELECT COUNT(*) FROM spec_trials) = 0
AND (SELECT COUNT(*) FROM order_authorizations) = 0
AND (SELECT COUNT(*) FROM order_submissions) = 0
AND (SELECT COUNT(*) FROM tasks WHERE source <> 'MANUAL' OR status <> 'DRAFT') = 0
-- v2 的金额边界是严格正数;不把 v1 中不能无损纳入该边界的数据悄悄改写。
AND (SELECT COUNT(*) FROM tasks WHERE
max_total_price = ''
OR max_total_price GLOB '*[^0-9.]*'
OR length(max_total_price) - length(replace(max_total_price, '.', '')) > 1
OR max_total_price = '.'
OR (instr(max_total_price, '.') > 0 AND (
instr(max_total_price, '.') = 1
OR length(max_total_price) = instr(max_total_price, '.')
OR length(max_total_price) - instr(max_total_price, '.') > 2
))
OR replace(replace(max_total_price, '.', ''), '0', '') = ''
) = 0
THEN 1 ELSE 0 END;
DROP TABLE single_pass_upgrade_guard;
ALTER TABLE tasks RENAME TO tasks_v1;
DROP TABLE order_submissions;
DROP TABLE order_authorizations;
DROP TABLE spec_trials;
CREATE TABLE tasks (
id TEXT PRIMARY KEY,
source TEXT NOT NULL CHECK (source IN ('MANUAL', 'EXCEL', 'ERP')),
source_ref TEXT,
title TEXT NOT NULL,
goods_id TEXT NOT NULL,
sku_color TEXT NOT NULL,
sku_size TEXT NOT NULL,
quantity INTEGER NOT NULL CHECK (quantity > 0 AND typeof(quantity) = 'integer'),
max_total_price TEXT NOT NULL CHECK (
max_total_price <> ''
AND max_total_price NOT GLOB '*[^0-9.]*'
AND length(max_total_price) - length(replace(max_total_price, '.', '')) <= 1
AND max_total_price <> '.'
AND (instr(max_total_price, '.') = 0 OR (
instr(max_total_price, '.') > 1
AND length(max_total_price) > instr(max_total_price, '.')
AND length(max_total_price) - instr(max_total_price, '.') <= 2
))
AND replace(replace(max_total_price, '.', ''), '0', '') <> ''
),
reference_asset_id TEXT,
status TEXT NOT NULL CHECK (status IN (
'DRAFT', 'PENDING', 'CLAIMED', 'ORDERING', 'NEEDS_MANUAL', 'WAITING_PAYMENT',
'RECONCILIATION_REQUIRED', 'SUCCEEDED', 'FAILED', 'CANCELED'
)),
version INTEGER NOT NULL DEFAULT 1 CHECK (version > 0 AND typeof(version) = 'integer'),
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL
);
INSERT INTO tasks (
id, source, source_ref, title, goods_id, sku_color, sku_size, quantity, max_total_price,
reference_asset_id, status, version, created_at, updated_at
)
SELECT
id, source, source_ref, title, goods_id, sku_color, sku_size, quantity, max_total_price,
reference_asset_id, status, version, created_at, updated_at
FROM tasks_v1;
DROP TABLE tasks_v1;
CREATE TABLE order_authorizations (
id TEXT PRIMARY KEY,
task_id TEXT NOT NULL REFERENCES tasks(id),
task_version INTEGER NOT NULL CHECK (task_version > 0 AND typeof(task_version) = 'integer'),
start_key TEXT NOT NULL,
goods_id TEXT NOT NULL,
sku_color TEXT NOT NULL,
sku_size TEXT NOT NULL,
quantity INTEGER NOT NULL CHECK (quantity > 0 AND typeof(quantity) = 'integer'),
total_price_cap TEXT NOT NULL CHECK (
total_price_cap <> ''
AND total_price_cap NOT GLOB '*[^0-9.]*'
AND length(total_price_cap) - length(replace(total_price_cap, '.', '')) <= 1
AND total_price_cap <> '.'
AND (instr(total_price_cap, '.') = 0 OR (
instr(total_price_cap, '.') > 1
AND length(total_price_cap) > instr(total_price_cap, '.')
AND length(total_price_cap) - instr(total_price_cap, '.') <= 2
))
AND replace(replace(total_price_cap, '.', ''), '0', '') <> ''
),
status TEXT NOT NULL CHECK (status IN ('ACTIVE', 'CLAIMED', 'FENCED', 'CONSUMED', 'EXPIRED', 'ABANDONED')),
created_by TEXT NOT NULL,
created_at TEXT NOT NULL,
expires_at TEXT NOT NULL,
UNIQUE (task_id, task_version),
UNIQUE (start_key, task_id),
UNIQUE (task_id, id)
);
CREATE TABLE purchase_attempts (
id TEXT PRIMARY KEY,
task_id TEXT NOT NULL,
authorization_id TEXT NOT NULL,
claim_generation INTEGER NOT NULL CHECK (claim_generation > 0 AND typeof(claim_generation) = 'integer'),
status TEXT NOT NULL CHECK (status IN ('CLAIMED', 'ORDERING', 'FAILED', 'FENCED', 'ABANDONED')),
gate1_unit_price TEXT CHECK (
gate1_unit_price IS NULL OR (
gate1_unit_price <> ''
AND gate1_unit_price NOT GLOB '*[^0-9.]*'
AND length(gate1_unit_price) - length(replace(gate1_unit_price, '.', '')) <= 1
AND gate1_unit_price <> '.'
AND (instr(gate1_unit_price, '.') = 0 OR (
instr(gate1_unit_price, '.') > 1
AND length(gate1_unit_price) > instr(gate1_unit_price, '.')
AND length(gate1_unit_price) - instr(gate1_unit_price, '.') <= 2
))
AND replace(replace(gate1_unit_price, '.', ''), '0', '') <> ''
)
),
gate2_unit_price TEXT CHECK (
gate2_unit_price IS NULL OR (
gate2_unit_price <> ''
AND gate2_unit_price NOT GLOB '*[^0-9.]*'
AND length(gate2_unit_price) - length(replace(gate2_unit_price, '.', '')) <= 1
AND gate2_unit_price <> '.'
AND (instr(gate2_unit_price, '.') = 0 OR (
instr(gate2_unit_price, '.') > 1
AND length(gate2_unit_price) > instr(gate2_unit_price, '.')
AND length(gate2_unit_price) - instr(gate2_unit_price, '.') <= 2
))
AND replace(replace(gate2_unit_price, '.', ''), '0', '') <> ''
)
),
quantity_read INTEGER CHECK (quantity_read IS NULL OR (quantity_read > 0 AND typeof(quantity_read) = 'integer')),
confirm_amount TEXT CHECK (
confirm_amount IS NULL OR (
confirm_amount <> ''
AND confirm_amount NOT GLOB '*[^0-9.]*'
AND length(confirm_amount) - length(replace(confirm_amount, '.', '')) <= 1
AND confirm_amount <> '.'
AND (instr(confirm_amount, '.') = 0 OR (
instr(confirm_amount, '.') > 1
AND length(confirm_amount) > instr(confirm_amount, '.')
AND length(confirm_amount) - instr(confirm_amount, '.') <= 2
))
AND replace(replace(confirm_amount, '.', ''), '0', '') <> ''
)
),
failure_code TEXT CHECK (failure_code IS NULL OR failure_code IN (
'AUTHORIZATION_EXPIRED', 'LEASE_LOST', 'GATE_1_REJECTED', 'QUANTITY_MISMATCH',
'GATE_2_REJECTED', 'GATE_3_REJECTED', 'FENCE_REJECTED', 'SAFE_ABORTED'
)),
started_at TEXT NOT NULL,
finished_at TEXT,
UNIQUE (task_id, claim_generation),
UNIQUE (task_id, id),
UNIQUE (task_id, authorization_id, id),
FOREIGN KEY (task_id, authorization_id) REFERENCES order_authorizations(task_id, id)
);
CREATE TABLE order_submissions (
id TEXT PRIMARY KEY,
task_id TEXT NOT NULL,
authorization_id TEXT NOT NULL,
attempt_id TEXT NOT NULL,
status TEXT NOT NULL CHECK (status IN ('FENCED', 'SUBMITTED', 'RECONCILIATION_REQUIRED', 'MANUAL_RESOLVED')),
gate1_unit_price TEXT NOT NULL CHECK (
gate1_unit_price <> ''
AND gate1_unit_price NOT GLOB '*[^0-9.]*'
AND length(gate1_unit_price) - length(replace(gate1_unit_price, '.', '')) <= 1
AND gate1_unit_price <> '.'
AND (instr(gate1_unit_price, '.') = 0 OR (
instr(gate1_unit_price, '.') > 1
AND length(gate1_unit_price) > instr(gate1_unit_price, '.')
AND length(gate1_unit_price) - instr(gate1_unit_price, '.') <= 2
))
AND replace(replace(gate1_unit_price, '.', ''), '0', '') <> ''
),
gate2_unit_price TEXT NOT NULL CHECK (
gate2_unit_price <> ''
AND gate2_unit_price NOT GLOB '*[^0-9.]*'
AND length(gate2_unit_price) - length(replace(gate2_unit_price, '.', '')) <= 1
AND gate2_unit_price <> '.'
AND (instr(gate2_unit_price, '.') = 0 OR (
instr(gate2_unit_price, '.') > 1
AND length(gate2_unit_price) > instr(gate2_unit_price, '.')
AND length(gate2_unit_price) - instr(gate2_unit_price, '.') <= 2
))
AND replace(replace(gate2_unit_price, '.', ''), '0', '') <> ''
),
quantity_read INTEGER NOT NULL CHECK (quantity_read > 0 AND typeof(quantity_read) = 'integer'),
confirm_amount TEXT NOT NULL CHECK (
confirm_amount <> ''
AND confirm_amount NOT GLOB '*[^0-9.]*'
AND length(confirm_amount) - length(replace(confirm_amount, '.', '')) <= 1
AND confirm_amount <> '.'
AND (instr(confirm_amount, '.') = 0 OR (
instr(confirm_amount, '.') > 1
AND length(confirm_amount) > instr(confirm_amount, '.')
AND length(confirm_amount) - instr(confirm_amount, '.') <= 2
))
AND replace(replace(confirm_amount, '.', ''), '0', '') <> ''
),
created_at TEXT NOT NULL,
resolved_at TEXT,
UNIQUE (authorization_id),
UNIQUE (attempt_id),
FOREIGN KEY (task_id, authorization_id, attempt_id) REFERENCES purchase_attempts(task_id, authorization_id, id)
);
-- +goose Down
-- 只有尚未产生任何单趟授权或执行事实的纯 MANUAL/DRAFT 数据才能无损回到 v1。
CREATE TABLE single_pass_downgrade_guard (
valid INTEGER NOT NULL CHECK (valid = 1)
);
INSERT INTO single_pass_downgrade_guard (valid)
SELECT CASE WHEN
(SELECT COUNT(*) FROM order_authorizations) = 0
AND (SELECT COUNT(*) FROM purchase_attempts) = 0
AND (SELECT COUNT(*) FROM order_submissions) = 0
AND (SELECT COUNT(*) FROM tasks WHERE source <> 'MANUAL' OR status <> 'DRAFT') = 0
THEN 1 ELSE 0 END;
DROP TABLE single_pass_downgrade_guard;
ALTER TABLE tasks RENAME TO tasks_v2;
DROP TABLE order_submissions;
DROP TABLE purchase_attempts;
DROP TABLE order_authorizations;
CREATE TABLE tasks (
id TEXT PRIMARY KEY,
source TEXT NOT NULL CHECK (source IN ('MANUAL', 'EXCEL', 'ERP')),
source_ref TEXT,
title TEXT NOT NULL,
goods_id TEXT NOT NULL,
sku_color TEXT NOT NULL,
sku_size TEXT NOT NULL,
quantity INTEGER NOT NULL CHECK (quantity > 0 AND typeof(quantity) = 'integer'),
max_total_price TEXT NOT NULL CHECK (
max_total_price <> ''
AND max_total_price NOT GLOB '*[^0-9.]*'
AND length(max_total_price) - length(replace(max_total_price, '.', '')) <= 1
AND max_total_price <> '.'
AND (instr(max_total_price, '.') = 0 OR (
instr(max_total_price, '.') > 1
AND length(max_total_price) > instr(max_total_price, '.')
AND length(max_total_price) - instr(max_total_price, '.') <= 2
))
),
reference_asset_id TEXT,
status TEXT NOT NULL CHECK (status IN (
'DRAFT', 'PENDING', 'CLAIMED', 'RUNNING', 'WAITING_CONFIRMATION',
'PENDING_RETRIAL', 'AUTHORIZED', 'ORDERING', 'WAITING_PAYMENT',
'RECONCILIATION_REQUIRED', 'NEEDS_MANUAL', 'SUCCEEDED', 'CANCELED'
)),
version INTEGER NOT NULL DEFAULT 1 CHECK (version > 0 AND typeof(version) = 'integer'),
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL
);
INSERT INTO tasks (
id, source, source_ref, title, goods_id, sku_color, sku_size, quantity, max_total_price,
reference_asset_id, status, version, created_at, updated_at
)
SELECT
id, source, source_ref, title, goods_id, sku_color, sku_size, quantity, max_total_price,
reference_asset_id, status, version, created_at, updated_at
FROM tasks_v2;
DROP TABLE tasks_v2;
CREATE TABLE spec_trials (
id TEXT PRIMARY KEY,
task_id TEXT NOT NULL REFERENCES tasks(id),
attempt INTEGER NOT NULL CHECK (attempt > 0 AND typeof(attempt) = 'integer'),
product_title TEXT NOT NULL,
selected_color TEXT NOT NULL,
selected_size TEXT NOT NULL,
unit_price TEXT NOT NULL CHECK (unit_price <> '' AND unit_price NOT GLOB '*[^0-9.]*' AND length(unit_price) - length(replace(unit_price, '.', '')) <= 1 AND unit_price <> '.' AND (instr(unit_price, '.') = 0 OR (instr(unit_price, '.') > 1 AND length(unit_price) > instr(unit_price, '.') AND length(unit_price) - instr(unit_price, '.') <= 2))),
total_price TEXT NOT NULL CHECK (total_price <> '' AND total_price NOT GLOB '*[^0-9.]*' AND length(total_price) - length(replace(total_price, '.', '')) <= 1 AND total_price <> '.' AND (instr(total_price, '.') = 0 OR (instr(total_price, '.') > 1 AND length(total_price) > instr(total_price, '.') AND length(total_price) - instr(total_price, '.') <= 2))),
evidence_sha256 TEXT NOT NULL,
created_at TEXT NOT NULL,
UNIQUE (task_id, attempt),
UNIQUE (task_id, id)
);
CREATE TABLE order_authorizations (
id TEXT PRIMARY KEY,
task_id TEXT NOT NULL REFERENCES tasks(id),
spec_trial_id TEXT NOT NULL REFERENCES spec_trials(id),
version INTEGER NOT NULL CHECK (version > 0 AND typeof(version) = 'integer'),
goods_id TEXT NOT NULL,
sku_color TEXT NOT NULL,
sku_size TEXT NOT NULL,
quantity INTEGER NOT NULL CHECK (quantity > 0 AND typeof(quantity) = 'integer'),
authorized_unit_price TEXT NOT NULL CHECK (authorized_unit_price <> '' AND authorized_unit_price NOT GLOB '*[^0-9.]*' AND length(authorized_unit_price) - length(replace(authorized_unit_price, '.', '')) <= 1 AND authorized_unit_price <> '.' AND (instr(authorized_unit_price, '.') = 0 OR (instr(authorized_unit_price, '.') > 1 AND length(authorized_unit_price) > instr(authorized_unit_price, '.') AND length(authorized_unit_price) - instr(authorized_unit_price, '.') <= 2))),
total_price_cap TEXT NOT NULL CHECK (total_price_cap <> '' AND total_price_cap NOT GLOB '*[^0-9.]*' AND length(total_price_cap) - length(replace(total_price_cap, '.', '')) <= 1 AND total_price_cap <> '.' AND (instr(total_price_cap, '.') = 0 OR (instr(total_price_cap, '.') > 1 AND length(total_price_cap) > instr(total_price_cap, '.') AND length(total_price_cap) - instr(total_price_cap, '.') <= 2))),
note TEXT,
status TEXT NOT NULL CHECK (status IN ('PENDING_DELIVERY', 'DELIVERED', 'ACKNOWLEDGED', 'EXECUTING', 'FENCED', 'CONSUMED', 'SUPERSEDED', 'EXPIRED')),
created_by TEXT NOT NULL,
created_at TEXT NOT NULL,
expires_at TEXT NOT NULL,
UNIQUE (task_id, version),
UNIQUE (task_id, id),
FOREIGN KEY (task_id, spec_trial_id) REFERENCES spec_trials(task_id, id)
);
CREATE TABLE order_submissions (
id TEXT PRIMARY KEY,
task_id TEXT NOT NULL REFERENCES tasks(id),
authorization_id TEXT NOT NULL REFERENCES order_authorizations(id),
command_id TEXT NOT NULL,
dry_run_id TEXT NOT NULL,
status TEXT NOT NULL CHECK (status IN ('FENCED', 'SUBMITTED', 'RECONCILIATION_REQUIRED', 'MANUAL_RESOLVED')),
verified_unit_price TEXT NOT NULL CHECK (verified_unit_price <> '' AND verified_unit_price NOT GLOB '*[^0-9.]*' AND length(verified_unit_price) - length(replace(verified_unit_price, '.', '')) <= 1 AND verified_unit_price <> '.' AND (instr(verified_unit_price, '.') = 0 OR (instr(verified_unit_price, '.') > 1 AND length(verified_unit_price) > instr(verified_unit_price, '.') AND length(verified_unit_price) - instr(verified_unit_price, '.') <= 2))),
quantity_read INTEGER NOT NULL CHECK (quantity_read > 0 AND typeof(quantity_read) = 'integer'),
confirm_page_amount TEXT NOT NULL CHECK (confirm_page_amount <> '' AND confirm_page_amount NOT GLOB '*[^0-9.]*' AND length(confirm_page_amount) - length(replace(confirm_page_amount, '.', '')) <= 1 AND confirm_page_amount <> '.' AND (instr(confirm_page_amount, '.') = 0 OR (instr(confirm_page_amount, '.') > 1 AND length(confirm_page_amount) > instr(confirm_page_amount, '.') AND length(confirm_page_amount) - instr(confirm_page_amount, '.') <= 2))),
created_at TEXT NOT NULL,
resolved_at TEXT,
UNIQUE (authorization_id),
UNIQUE (command_id),
FOREIGN KEY (task_id, authorization_id) REFERENCES order_authorizations(task_id, id)
);
@@ -0,0 +1,53 @@
-- +goose Up
CREATE TABLE evidence_assets (
id TEXT PRIMARY KEY,
upload_key TEXT NOT NULL,
task_id TEXT NOT NULL,
attempt_id TEXT NOT NULL,
kind TEXT NOT NULL CHECK (kind = 'SKU_PANEL_GATE_1'),
privacy_tier TEXT NOT NULL CHECK (privacy_tier = 'INTERNAL_RAW'),
sha256 TEXT NOT NULL CHECK (
length(sha256) = 64
AND sha256 NOT GLOB '*[^0-9a-f]*'
),
byte_size INTEGER NOT NULL CHECK (
typeof(byte_size) = 'integer'
AND byte_size > 0
AND byte_size <= 10485760
),
content_type TEXT NOT NULL CHECK (content_type = 'image/png'),
width_px INTEGER NOT NULL CHECK (
typeof(width_px) = 'integer'
AND width_px > 0
AND width_px <= 8192
),
height_px INTEGER NOT NULL CHECK (
typeof(height_px) = 'integer'
AND height_px > 0
AND height_px <= 8192
),
storage_key TEXT NOT NULL CHECK (
storage_key = substr(sha256, 1, 2) || '/' || sha256 || '.png'
),
uploaded_by_device_id TEXT NOT NULL CHECK (trim(uploaded_by_device_id) <> ''),
captured_at TEXT NOT NULL CHECK (trim(captured_at) <> ''),
created_at TEXT NOT NULL CHECK (trim(created_at) <> ''),
CHECK (width_px * height_px <= 16777216),
UNIQUE (uploaded_by_device_id, upload_key),
FOREIGN KEY (task_id, attempt_id) REFERENCES purchase_attempts(task_id, id)
);
CREATE INDEX evidence_assets_task_time_idx
ON evidence_assets (task_id, captured_at, created_at, id);
-- +goose Down
-- 已写入的内部原图是审计事实,回滚迁移不得静默删除它们。
CREATE TABLE evidence_downgrade_guard (
valid INTEGER NOT NULL CHECK (valid = 1)
);
INSERT INTO evidence_downgrade_guard (valid)
SELECT CASE WHEN (SELECT COUNT(*) FROM evidence_assets) = 0 THEN 1 ELSE 0 END;
DROP TABLE evidence_downgrade_guard;
DROP TABLE evidence_assets;
@@ -0,0 +1,62 @@
-- +goose Up
CREATE TABLE device_credentials (
device_id TEXT PRIMARY KEY CHECK (
length(device_id) = 36
AND substr(device_id, 9, 1) = '-'
AND substr(device_id, 14, 1) = '-'
AND substr(device_id, 19, 1) = '-'
AND substr(device_id, 24, 1) = '-'
AND length(replace(device_id, '-', '')) = 32
AND replace(device_id, '-', '') NOT GLOB '*[^0-9a-f]*'
AND substr(device_id, 15, 1) = '4'
AND substr(device_id, 20, 1) IN ('8', '9', 'a', 'b')
),
display_name TEXT NOT NULL CHECK (
display_name = trim(display_name)
AND length(display_name) BETWEEN 1 AND 128
),
token_sha256 BLOB NOT NULL UNIQUE CHECK (
typeof(token_sha256) = 'blob'
AND length(token_sha256) = 32
),
status TEXT NOT NULL CHECK (status IN ('ACTIVE', 'REVOKED')),
created_at TEXT NOT NULL CHECK (
created_at = trim(created_at)
AND length(created_at) >= 20
AND substr(created_at, 11, 1) = 'T'
AND substr(created_at, -1, 1) = 'Z'
AND julianday(created_at) IS NOT NULL
),
revoked_at TEXT CHECK (
revoked_at IS NULL OR (
revoked_at = trim(revoked_at)
AND length(revoked_at) >= 20
AND substr(revoked_at, 11, 1) = 'T'
AND substr(revoked_at, -1, 1) = 'Z'
AND julianday(revoked_at) IS NOT NULL
)
),
CHECK (
(status = 'ACTIVE' AND revoked_at IS NULL)
OR (
status = 'REVOKED'
AND revoked_at IS NOT NULL
AND julianday(revoked_at) >= julianday(created_at)
)
)
);
CREATE INDEX device_credentials_status_created_idx
ON device_credentials (status, created_at, device_id);
-- +goose Down
-- 已签发凭据是安全配置;回滚不得静默删除并让设备身份审计链消失。
CREATE TABLE device_credentials_downgrade_guard (
valid INTEGER NOT NULL CHECK (valid = 1)
);
INSERT INTO device_credentials_downgrade_guard (valid)
SELECT CASE WHEN (SELECT COUNT(*) FROM device_credentials) = 0 THEN 1 ELSE 0 END;
DROP TABLE device_credentials_downgrade_guard;
DROP TABLE device_credentials;
+272
View File
@@ -0,0 +1,272 @@
-- +goose Up
-- v4 中的 attempt、submission 或证据没有设备/session/租约归属,不能安全猜测成 claim。
-- 在同一迁移事务中拒绝这类数据库,避免补出虚假的所有权审计链。
CREATE TABLE task_claim_upgrade_guard (
valid INTEGER NOT NULL CHECK (valid = 1)
);
INSERT INTO task_claim_upgrade_guard (valid)
SELECT CASE WHEN
(SELECT COUNT(*) FROM purchase_attempts) = 0
AND (SELECT COUNT(*) FROM order_submissions) = 0
AND (SELECT COUNT(*) FROM evidence_assets) = 0
THEN 1 ELSE 0 END;
DROP TABLE task_claim_upgrade_guard;
-- 该唯一索引把“一条授权只能产生一个 attempt”下沉到数据库;应用层检查不能替代它。
CREATE UNIQUE INDEX purchase_attempts_one_per_authorization_idx
ON purchase_attempts (authorization_id);
-- claim_generation 是 attempt lineage 的组成部分,不能只在应用层比较。
CREATE UNIQUE INDEX purchase_attempts_claim_lineage_idx
ON purchase_attempts (task_id, authorization_id, id, claim_generation);
CREATE TABLE purchase_attempt_claims (
attempt_id TEXT PRIMARY KEY,
task_id TEXT NOT NULL,
authorization_id TEXT NOT NULL UNIQUE,
claimed_by_device_id TEXT NOT NULL,
session_id TEXT NOT NULL CHECK (
length(session_id) = 36
AND substr(session_id, 9, 1) = '-'
AND substr(session_id, 14, 1) = '-'
AND substr(session_id, 19, 1) = '-'
AND substr(session_id, 24, 1) = '-'
AND length(replace(session_id, '-', '')) = 32
AND replace(session_id, '-', '') NOT GLOB '*[^0-9a-f]*'
AND substr(session_id, 15, 1) = '4'
AND substr(session_id, 20, 1) IN ('8', '9', 'a', 'b')
),
claim_generation INTEGER NOT NULL CHECK (
typeof(claim_generation) = 'integer' AND claim_generation > 0
),
task_version INTEGER NOT NULL CHECK (
typeof(task_version) = 'integer' AND task_version > 0
),
task_title TEXT NOT NULL CHECK (trim(task_title) <> ''),
authorization_task_version INTEGER NOT NULL CHECK (
typeof(authorization_task_version) = 'integer' AND authorization_task_version > 0
),
goods_id TEXT NOT NULL CHECK (trim(goods_id) <> ''),
sku_color TEXT NOT NULL CHECK (trim(sku_color) <> ''),
sku_size TEXT NOT NULL CHECK (trim(sku_size) <> ''),
quantity INTEGER NOT NULL CHECK (typeof(quantity) = 'integer' AND quantity > 0),
total_price_cap TEXT NOT NULL CHECK (trim(total_price_cap) <> ''),
authorization_expires_at TEXT NOT NULL CHECK (
authorization_expires_at = trim(authorization_expires_at)
AND length(authorization_expires_at) >= 20
AND substr(authorization_expires_at, 11, 1) = 'T'
AND substr(authorization_expires_at, -1, 1) = 'Z'
AND julianday(authorization_expires_at) IS NOT NULL
),
claim_nonce BLOB NOT NULL CHECK (
typeof(claim_nonce) = 'blob' AND length(claim_nonce) = 32
),
claim_token_sha256 BLOB NOT NULL CHECK (
typeof(claim_token_sha256) = 'blob' AND length(claim_token_sha256) = 32
),
lease_expires_at TEXT NOT NULL CHECK (
lease_expires_at = trim(lease_expires_at)
AND length(lease_expires_at) >= 20
AND substr(lease_expires_at, 11, 1) = 'T'
AND substr(lease_expires_at, -1, 1) = 'Z'
AND julianday(lease_expires_at) IS NOT NULL
),
claimed_at TEXT NOT NULL CHECK (
claimed_at = trim(claimed_at)
AND length(claimed_at) >= 20
AND substr(claimed_at, 11, 1) = 'T'
AND substr(claimed_at, -1, 1) = 'Z'
AND julianday(claimed_at) IS NOT NULL
),
closed_at TEXT CHECK (
closed_at IS NULL OR (
closed_at = trim(closed_at)
AND length(closed_at) >= 20
AND substr(closed_at, 11, 1) = 'T'
AND substr(closed_at, -1, 1) = 'Z'
AND julianday(closed_at) IS NOT NULL
AND julianday(closed_at) >= julianday(claimed_at)
)
),
UNIQUE (task_id, attempt_id),
UNIQUE (attempt_id, claimed_by_device_id, session_id),
UNIQUE (
task_id, attempt_id, claimed_by_device_id, session_id,
claim_generation, claim_token_sha256
),
UNIQUE (task_id, authorization_id, attempt_id),
FOREIGN KEY (task_id, authorization_id, attempt_id, claim_generation)
REFERENCES purchase_attempts(task_id, authorization_id, id, claim_generation),
FOREIGN KEY (claimed_by_device_id) REFERENCES device_credentials(device_id)
);
-- 过期、撤销或停轮询都不会自动关闭 claim;partial unique 因而阻止另一条开放归属。
CREATE UNIQUE INDEX purchase_attempt_claims_one_open_per_device_idx
ON purchase_attempt_claims (claimed_by_device_id)
WHERE closed_at IS NULL;
CREATE TABLE task_claim_requests (
claim_request_id TEXT PRIMARY KEY CHECK (
length(claim_request_id) = 36
AND substr(claim_request_id, 9, 1) = '-'
AND substr(claim_request_id, 14, 1) = '-'
AND substr(claim_request_id, 19, 1) = '-'
AND substr(claim_request_id, 24, 1) = '-'
AND length(replace(claim_request_id, '-', '')) = 32
AND replace(claim_request_id, '-', '') NOT GLOB '*[^0-9a-f]*'
AND substr(claim_request_id, 15, 1) = '4'
AND substr(claim_request_id, 20, 1) IN ('8', '9', 'a', 'b')
),
device_id TEXT NOT NULL,
session_id TEXT NOT NULL CHECK (
length(session_id) = 36
AND substr(session_id, 9, 1) = '-'
AND substr(session_id, 14, 1) = '-'
AND substr(session_id, 19, 1) = '-'
AND substr(session_id, 24, 1) = '-'
AND length(replace(session_id, '-', '')) = 32
AND replace(session_id, '-', '') NOT GLOB '*[^0-9a-f]*'
AND substr(session_id, 15, 1) = '4'
AND substr(session_id, 20, 1) IN ('8', '9', 'a', 'b')
),
outcome TEXT NOT NULL CHECK (outcome IN ('CLAIMED', 'EMPTY', 'BLOCKED')),
attempt_id TEXT,
response_lease_expires_at TEXT CHECK (
response_lease_expires_at IS NULL OR (
response_lease_expires_at = trim(response_lease_expires_at)
AND length(response_lease_expires_at) >= 20
AND substr(response_lease_expires_at, 11, 1) = 'T'
AND substr(response_lease_expires_at, -1, 1) = 'Z'
AND julianday(response_lease_expires_at) IS NOT NULL
)
),
error_code TEXT CHECK (error_code IS NULL OR error_code = 'manual_recovery_required'),
created_at TEXT NOT NULL CHECK (
created_at = trim(created_at)
AND length(created_at) >= 20
AND substr(created_at, 11, 1) = 'T'
AND substr(created_at, -1, 1) = 'Z'
AND julianday(created_at) IS NOT NULL
),
CHECK (
(outcome = 'CLAIMED' AND attempt_id IS NOT NULL AND response_lease_expires_at IS NOT NULL AND error_code IS NULL)
OR (outcome = 'EMPTY' AND attempt_id IS NULL AND response_lease_expires_at IS NULL AND error_code IS NULL)
OR (outcome = 'BLOCKED' AND attempt_id IS NULL AND response_lease_expires_at IS NULL AND error_code = 'manual_recovery_required')
),
FOREIGN KEY (device_id) REFERENCES device_credentials(device_id),
-- EMPTY/BLOCKED 行的 attempt_id 为 NULL,SQLite 会跳过复合 FK;CLAIMED 行则必须
-- 同时匹配原 claim 的设备和 session,不能由应用 bug 写成跨设备重放。
FOREIGN KEY (attempt_id, device_id, session_id)
REFERENCES purchase_attempt_claims(attempt_id, claimed_by_device_id, session_id)
);
CREATE TABLE purchase_attempt_lease_renewals (
renew_request_id TEXT PRIMARY KEY CHECK (
length(renew_request_id) = 36
AND substr(renew_request_id, 9, 1) = '-'
AND substr(renew_request_id, 14, 1) = '-'
AND substr(renew_request_id, 19, 1) = '-'
AND substr(renew_request_id, 24, 1) = '-'
AND length(replace(renew_request_id, '-', '')) = 32
AND replace(renew_request_id, '-', '') NOT GLOB '*[^0-9a-f]*'
AND substr(renew_request_id, 15, 1) = '4'
AND substr(renew_request_id, 20, 1) IN ('8', '9', 'a', 'b')
),
task_id TEXT NOT NULL,
attempt_id TEXT NOT NULL,
device_id TEXT NOT NULL,
session_id TEXT NOT NULL CHECK (
length(session_id) = 36
AND substr(session_id, 9, 1) = '-'
AND substr(session_id, 14, 1) = '-'
AND substr(session_id, 19, 1) = '-'
AND substr(session_id, 24, 1) = '-'
AND length(replace(session_id, '-', '')) = 32
AND replace(session_id, '-', '') NOT GLOB '*[^0-9a-f]*'
AND substr(session_id, 15, 1) = '4'
AND substr(session_id, 20, 1) IN ('8', '9', 'a', 'b')
),
claim_generation INTEGER NOT NULL CHECK (
typeof(claim_generation) = 'integer' AND claim_generation > 0
),
claim_token_sha256 BLOB NOT NULL CHECK (
typeof(claim_token_sha256) = 'blob' AND length(claim_token_sha256) = 32
),
expected_lease_expires_at TEXT NOT NULL CHECK (
expected_lease_expires_at = trim(expected_lease_expires_at)
AND length(expected_lease_expires_at) >= 20
AND substr(expected_lease_expires_at, 11, 1) = 'T'
AND substr(expected_lease_expires_at, -1, 1) = 'Z'
AND julianday(expected_lease_expires_at) IS NOT NULL
),
lease_expires_at TEXT NOT NULL CHECK (
lease_expires_at = trim(lease_expires_at)
AND length(lease_expires_at) >= 20
AND substr(lease_expires_at, 11, 1) = 'T'
AND substr(lease_expires_at, -1, 1) = 'Z'
AND julianday(lease_expires_at) IS NOT NULL
),
created_at TEXT NOT NULL CHECK (
created_at = trim(created_at)
AND length(created_at) >= 20
AND substr(created_at, 11, 1) = 'T'
AND substr(created_at, -1, 1) = 'Z'
AND julianday(created_at) IS NOT NULL
),
FOREIGN KEY (
task_id, attempt_id, device_id, session_id,
claim_generation, claim_token_sha256
) REFERENCES purchase_attempt_claims(
task_id, attempt_id, claimed_by_device_id, session_id,
claim_generation, claim_token_sha256
)
);
CREATE INDEX order_authorizations_claim_candidate_idx
ON order_authorizations (status, created_at, id);
-- 首次证据写入必须属于认证设备当前未关闭的 claim。历史资产的幂等重放不触发 INSERT,
-- 因而未来人工关闭 claim 后仍可稳定返回原资产。
-- +goose StatementBegin
CREATE TRIGGER evidence_assets_claim_owner_insert
BEFORE INSERT ON evidence_assets
FOR EACH ROW
WHEN NOT EXISTS (
SELECT 1 FROM purchase_attempt_claims AS claims
WHERE claims.task_id = NEW.task_id
AND claims.attempt_id = NEW.attempt_id
AND claims.claimed_by_device_id = NEW.uploaded_by_device_id
AND claims.closed_at IS NULL
)
BEGIN
SELECT RAISE(ABORT, 'evidence claim ownership required');
END;
-- +goose StatementEnd
-- +goose Down
-- 请求、续租、attempt、submission 和证据都是领取或下游审计事实,回滚不得静默删除。
CREATE TABLE task_claim_downgrade_guard (
valid INTEGER NOT NULL CHECK (valid = 1)
);
INSERT INTO task_claim_downgrade_guard (valid)
SELECT CASE WHEN
(SELECT COUNT(*) FROM task_claim_requests) = 0
AND (SELECT COUNT(*) FROM purchase_attempt_lease_renewals) = 0
AND (SELECT COUNT(*) FROM purchase_attempt_claims) = 0
AND (SELECT COUNT(*) FROM purchase_attempts) = 0
AND (SELECT COUNT(*) FROM order_submissions) = 0
AND (SELECT COUNT(*) FROM evidence_assets) = 0
THEN 1 ELSE 0 END;
DROP TABLE task_claim_downgrade_guard;
DROP TRIGGER evidence_assets_claim_owner_insert;
DROP INDEX order_authorizations_claim_candidate_idx;
DROP TABLE purchase_attempt_lease_renewals;
DROP TABLE task_claim_requests;
DROP INDEX purchase_attempt_claims_one_open_per_device_idx;
DROP TABLE purchase_attempt_claims;
DROP INDEX purchase_attempts_claim_lineage_idx;
DROP INDEX purchase_attempts_one_per_authorization_idx;
+23
View File
@@ -0,0 +1,23 @@
# 本机开发环境与解释器缓存
.venv/
venv/
__pycache__/
*.py[cod]
.pytest_cache/
.mypy_cache/
# 运行时生成的日志、截图和其他证据产物不得进入版本库。
logs/
artifacts/
runtime/
*.log
# 本机凭据或环境覆盖仅可保存在未跟踪文件中。
.env
.env.*
secrets/
# 打包工具生成的本机产物
build/
dist/
*.egg-info/
+19
View File
@@ -0,0 +1,19 @@
[build-system]
requires = ["setuptools>=68"]
build-backend = "setuptools.build_meta"
[project]
name = "cmbuyer-client"
version = "0.1.0"
description = "cmbuyer 采购工具桌面端"
requires-python = ">=3.11"
dynamic = ["dependencies"]
[project.scripts]
cmbuyer-client = "cmbuyer_client.app:main"
[tool.setuptools.packages.find]
where = ["src"]
[tool.setuptools.dynamic]
dependencies = { file = ["requirements.txt"] }
+9
View File
@@ -0,0 +1,9 @@
# -*- coding: utf-8 -*-
# 桌面界面(Qt 官方 Python 绑定)。
PySide6
# T-101 基线取证使用;只连接显式 serial,不打开或操作拼多多页面。
uiautomator2
# T-101 直接使用当前 ADB server 的已列出设备对象交给 uiautomator2,禁止 WiFi 自动重连。
adbutils>=2.11,<3
# 后续截图完整性检查会使用。
Pillow
+76
View File
@@ -0,0 +1,76 @@
"""采集指定 Android 设备的本地基线证据;不打开或操作拼多多页面。"""
from __future__ import annotations
import argparse
from pathlib import Path
import sys
CLIENT_ROOT = Path(__file__).resolve().parents[1]
sys.path.insert(0, str(CLIENT_ROOT / "src"))
from cmbuyer_client.device.adb import AdbClient, DeviceConnectionError, SubprocessAdbRunner
from cmbuyer_client.device.baseline import (
BaselineCaptureError,
DeviceBaselineCapturer,
NoReconnectUiautomatorConnector,
)
def parse_arguments(argv: list[str] | None = None) -> argparse.Namespace:
parser = argparse.ArgumentParser(description="采集显式指定 Android 设备的本地基线证据。")
parser.add_argument("--serial", required=True, help="ADB device serial;禁止自动选择。")
parser.add_argument("--output-dir", required=True, type=Path, help="新建的本地证据目录;不得覆盖已有目录。")
parser.add_argument("--timeout", type=float, default=10.0, help="ADB、uiautomator2 RPC 与 ADB socket 超时(秒)。")
parser.add_argument("--adb", default="adb", help="adb 可执行文件路径。")
return parser.parse_args(argv)
def validate_arguments(arguments: argparse.Namespace) -> None:
"""在导入设备库前拒绝危险或无效输入,便于离线测试。"""
if not arguments.serial.strip():
raise ValueError("必须显式提供非空 --serial。")
if arguments.timeout <= 0:
raise ValueError("--timeout 必须大于 0。")
def main(argv: list[str] | None = None) -> int:
arguments = parse_arguments(argv)
try:
validate_arguments(arguments)
except ValueError as error:
print(f"失败:{error}", file=sys.stderr)
return 2
try:
import adbutils
import uiautomator2 as u2
except ImportError:
print("失败:缺少 uiautomator2;请在采购工具虚拟环境中运行。", file=sys.stderr)
return 2
client = AdbClient(SubprocessAdbRunner(arguments.adb), timeout_seconds=arguments.timeout)
connector = NoReconnectUiautomatorConnector(
adbutils.AdbClient(socket_timeout=arguments.timeout).device_list,
u2.connect,
)
capturer = DeviceBaselineCapturer(client, connector, timeout_seconds=arguments.timeout)
try:
result = capturer.capture(arguments.serial, arguments.output_dir)
except (DeviceConnectionError, BaselineCaptureError) as error:
# 错误类型只表达状态,不打印 ADB 输出、serial、XML 或页面正文。
print(f"基线取证失败:{error}", file=sys.stderr)
return 1
except OSError:
print("基线取证失败:无法创建或发布本地证据目录。", file=sys.stderr)
return 1
print(f"基线取证完成:{result.output_directory}")
print(f"manifest:{result.manifest_path}")
return 0
if __name__ == "__main__":
raise SystemExit(main())
@@ -0,0 +1,109 @@
"""采集 T-106 人工准备的最终面板或返回态证据;不执行页面操作。"""
from __future__ import annotations
import argparse
from math import isfinite
from pathlib import Path
import sys
CLIENT_ROOT = Path(__file__).resolve().parents[1]
sys.path.insert(0, str(CLIENT_ROOT / "src"))
from cmbuyer_client.device.adb import AdbClient, DeviceConnectionError, SubprocessAdbRunner
from cmbuyer_client.device.baseline import NoReconnectUiautomatorConnector
from cmbuyer_client.pdd.order_confirm_spike import (
Android16ForegroundReader,
DECLARED_STATES,
EXPECTED_GOODS_ID,
OrderConfirmEvidenceCapturer,
OrderConfirmEvidenceError,
)
def parse_arguments(argv: list[str] | None = None) -> argparse.Namespace:
parser = argparse.ArgumentParser(description="采集 T-106 人工准备的最终面板或返回态本机证据。")
parser.add_argument("--serial", required=True, help="ADB device serial;禁止自动选择。")
parser.add_argument("--goods-id", required=True, help="T-106 已批准的目标商品标识。")
parser.add_argument(
"--state",
required=True,
choices=DECLARED_STATES,
help="只允许既有提交前来源态,或人工只按一次返回后的安全页状态。",
)
parser.add_argument("--output-dir", required=True, type=Path, help="全新本机证据目录;不得覆盖。")
parser.add_argument("--timeout", type=float, default=10.0, help="ADB 与只读设备 RPC 超时(秒)。")
parser.add_argument("--adb", default="adb", help="adb 可执行文件路径。")
return parser.parse_args(argv)
def validate_arguments(arguments: argparse.Namespace) -> None:
if (
type(arguments.serial) is not str
or not arguments.serial.strip()
or arguments.serial != arguments.serial.strip()
):
raise ValueError("必须显式提供非空 --serial。")
if type(arguments.goods_id) is not str or arguments.goods_id != EXPECTED_GOODS_ID:
raise ValueError("--goods-id 不是 T-106 已批准目标。")
if type(arguments.state) is not str or arguments.state not in DECLARED_STATES:
raise ValueError("--state 必须是批准的人工声明状态。")
if not isinstance(arguments.output_dir, Path) or not arguments.output_dir.name:
raise ValueError("--output-dir 必须是明确的全新目录。")
if (
not isinstance(arguments.timeout, (int, float))
or isinstance(arguments.timeout, bool)
or arguments.timeout <= 0
or not isfinite(arguments.timeout)
):
raise ValueError("--timeout 必须是大于 0 的有限数值。")
def main(argv: list[str] | None = None) -> int:
arguments = parse_arguments(argv)
try:
validate_arguments(arguments)
except ValueError as error:
print(f"失败:{error}", file=sys.stderr)
return 2
try:
import adbutils
import uiautomator2 as u2
except ImportError:
print("失败:缺少 uiautomator2;请在采购工具虚拟环境中运行。", file=sys.stderr)
return 2
adb_runner = SubprocessAdbRunner(arguments.adb)
capturer = OrderConfirmEvidenceCapturer(
AdbClient(adb_runner, timeout_seconds=arguments.timeout),
NoReconnectUiautomatorConnector(
adbutils.AdbClient(socket_timeout=arguments.timeout).device_list,
u2.connect,
),
Android16ForegroundReader(adb_runner, arguments.timeout),
timeout_seconds=arguments.timeout,
)
try:
capturer.capture(
arguments.serial,
arguments.goods_id,
arguments.state,
arguments.output_dir,
)
except (DeviceConnectionError, OrderConfirmEvidenceError):
# 第三方异常可能带设备、页面正文或本机目录,命令行只输出固定摘要。
print("T-106 只读取证失败:已停止,未发布本机证据目录。", file=sys.stderr)
return 1
except OSError:
print("T-106 只读取证失败:无法发布本机证据目录。", file=sys.stderr)
return 1
print("T-106 只读取证完成。")
print("人工复核:请在指定目录检查截图、XML、应用摘要和 manifest。")
return 0
if __name__ == "__main__":
raise SystemExit(main())
+75
View File
@@ -0,0 +1,75 @@
"""打开已验证的拼多多商品直链并采集只读本地证据。"""
from __future__ import annotations
import argparse
from pathlib import Path
import sys
CLIENT_ROOT = Path(__file__).resolve().parents[1]
sys.path.insert(0, str(CLIENT_ROOT / "src"))
from cmbuyer_client.device.adb import AdbClient, DeviceConnectionError, SubprocessAdbRunner
from cmbuyer_client.device.baseline import NoReconnectUiautomatorConnector
from cmbuyer_client.pdd.product_open import ProductOpenCapturer, ProductOpenError
from cmbuyer_client.pdd.product_url import ProductUrlError, parse_product_url
def parse_arguments(argv: list[str] | None = None) -> argparse.Namespace:
parser = argparse.ArgumentParser(description="打开 canonical 拼多多商品链接并采集只读证据。")
parser.add_argument("--serial", required=True, help="ADB device serial;禁止自动选择。")
parser.add_argument("--url", required=True, help="唯一允许的 goods.html?goods_id= 直链。")
parser.add_argument("--output-dir", required=True, type=Path, help="新建的本地证据目录;不得覆盖已有目录。")
parser.add_argument("--timeout", type=float, default=10.0, help="ADB 和只读 RPC 超时(秒)。")
parser.add_argument("--adb", default="adb", help="adb 可执行文件路径。")
return parser.parse_args(argv)
def validate_arguments(arguments: argparse.Namespace) -> None:
if not arguments.serial.strip():
raise ValueError("必须显式提供非空 --serial。")
if arguments.timeout <= 0:
raise ValueError("--timeout 必须大于 0。")
parse_product_url(arguments.url)
def main(argv: list[str] | None = None) -> int:
arguments = parse_arguments(argv)
try:
validate_arguments(arguments)
link = parse_product_url(arguments.url)
except (ValueError, ProductUrlError) as error:
print(f"失败:{error}", file=sys.stderr)
return 2
try:
import adbutils
import uiautomator2 as u2
except ImportError:
print("失败:缺少 uiautomator2;请在采购工具虚拟环境中运行。", file=sys.stderr)
return 2
client = AdbClient(SubprocessAdbRunner(arguments.adb), timeout_seconds=arguments.timeout)
connector = NoReconnectUiautomatorConnector(
adbutils.AdbClient(socket_timeout=arguments.timeout).device_list,
u2.connect,
)
capturer = ProductOpenCapturer(client, connector, timeout_seconds=arguments.timeout)
try:
result = capturer.open_and_capture(arguments.serial, link.canonical_url, arguments.output_dir)
except (DeviceConnectionError, ProductOpenError) as error:
# 不打印 ADB 输出、serial、Activity、XML 或页面正文。
print(f"商品打开取证失败:{error}", file=sys.stderr)
return 1
except OSError:
print("商品打开取证失败:无法创建或发布本地证据目录。", file=sys.stderr)
return 1
print(f"商品打开取证完成:{result.output_directory}")
print(f"manifest:{result.manifest_path}")
return 0
if __name__ == "__main__":
raise SystemExit(main())
@@ -0,0 +1,120 @@
"""采集 T-105 人工准备的数量 1/2 两态证据;不执行页面操作。"""
from __future__ import annotations
import argparse
from math import isfinite
from pathlib import Path
import sys
CLIENT_ROOT = Path(__file__).resolve().parents[1]
sys.path.insert(0, str(CLIENT_ROOT / "src"))
from cmbuyer_client.device.adb import AdbClient, DeviceConnectionError, SubprocessAdbRunner
from cmbuyer_client.device.baseline import NoReconnectUiautomatorConnector
from cmbuyer_client.pdd.quantity_gate2_spike import (
Android16TopResumedForegroundReader,
DECLARED_QUANTITIES,
EXPECTED_GOODS_ID,
QuantityGate2EvidenceCapturer,
QuantityGate2EvidenceError,
)
def parse_arguments(argv: list[str] | None = None) -> argparse.Namespace:
parser = argparse.ArgumentParser(description="采集 T-105 人工准备的数量两态本机证据。")
parser.add_argument("--serial", required=True, help="ADB device serial;禁止自动选择。")
parser.add_argument("--goods-id", required=True, help="T-105 已批准的目标商品标识。")
parser.add_argument(
"--state",
required=True,
choices=sorted(DECLARED_QUANTITIES),
help="人工声明状态:initial=数量1,target=数量2。",
)
parser.add_argument(
"--declared-quantity",
required=True,
type=int,
help="人工看到的数量;必须与状态一致。",
)
parser.add_argument("--output-dir", required=True, type=Path, help="全新本机证据目录;不得覆盖。")
parser.add_argument("--timeout", type=float, default=10.0, help="ADB 与只读设备 RPC 超时(秒)。")
parser.add_argument("--adb", default="adb", help="adb 可执行文件路径。")
return parser.parse_args(argv)
def validate_arguments(arguments: argparse.Namespace) -> None:
if (
type(arguments.serial) is not str
or not arguments.serial.strip()
or arguments.serial != arguments.serial.strip()
):
raise ValueError("必须显式提供非空 --serial。")
if type(arguments.goods_id) is not str or arguments.goods_id != EXPECTED_GOODS_ID:
raise ValueError("--goods-id 不是 T-105 已批准目标。")
if type(arguments.state) is not str or arguments.state not in DECLARED_QUANTITIES:
raise ValueError("--state 必须是批准的人工声明状态。")
if type(arguments.declared_quantity) is not int or (
arguments.declared_quantity != DECLARED_QUANTITIES[arguments.state]
):
raise ValueError("--declared-quantity 必须与人工声明状态一致。")
if not isinstance(arguments.output_dir, Path) or not arguments.output_dir.name:
raise ValueError("--output-dir 必须是明确的全新目录。")
if (
not isinstance(arguments.timeout, (int, float))
or isinstance(arguments.timeout, bool)
or arguments.timeout <= 0
or not isfinite(arguments.timeout)
):
raise ValueError("--timeout 必须是大于 0 的有限数值。")
def main(argv: list[str] | None = None) -> int:
arguments = parse_arguments(argv)
try:
validate_arguments(arguments)
except ValueError as error:
print(f"失败:{error}", file=sys.stderr)
return 2
try:
import adbutils
import uiautomator2 as u2
except ImportError:
print("失败:缺少 uiautomator2;请在采购工具虚拟环境中运行。", file=sys.stderr)
return 2
adb_runner = SubprocessAdbRunner(arguments.adb)
capturer = QuantityGate2EvidenceCapturer(
AdbClient(adb_runner, timeout_seconds=arguments.timeout),
NoReconnectUiautomatorConnector(
adbutils.AdbClient(socket_timeout=arguments.timeout).device_list,
u2.connect,
),
Android16TopResumedForegroundReader(adb_runner, arguments.timeout),
timeout_seconds=arguments.timeout,
)
try:
capturer.capture(
arguments.serial,
arguments.goods_id,
arguments.state,
arguments.declared_quantity,
arguments.output_dir,
)
except (DeviceConnectionError, QuantityGate2EvidenceError):
# 第三方异常可能含 serial、Activity、本机路径或页面正文,CLI 只输出固定摘要。
print("数量两态只读取证失败:已停止,未发布本机证据目录。", file=sys.stderr)
return 1
except OSError:
print("数量两态只读取证失败:无法发布本机证据目录。", file=sys.stderr)
return 1
print("数量两态只读取证完成。")
print("人工复核:请在指定目录检查截图、XML、应用摘要和 manifest。")
return 0
if __name__ == "__main__":
raise SystemExit(main())
+85
View File
@@ -0,0 +1,85 @@
"""采集 T-104 阶段 A 的一次 Back 后本机证据。"""
from __future__ import annotations
import argparse
from math import isfinite
from pathlib import Path
import sys
CLIENT_ROOT = Path(__file__).resolve().parents[1]
sys.path.insert(0, str(CLIENT_ROOT / "src"))
from cmbuyer_client.device.adb import AdbClient, DeviceConnectionError, SubprocessAdbRunner
from cmbuyer_client.device.baseline import NoReconnectUiautomatorConnector
from cmbuyer_client.pdd.sku_selection import SkuSelectionError
from cmbuyer_client.pdd.sku_selection_runner import (
SkuExitSpikeCapturer,
SkuSelectionRunError,
)
def parse_arguments(argv: list[str] | None = None) -> argparse.Namespace:
parser = argparse.ArgumentParser(description="采集 T-104 阶段 A 的一次 Back 后本机证据。")
parser.add_argument("--serial", required=True, help="ADB device serial;禁止自动选择。")
parser.add_argument("--output-dir", required=True, type=Path, help="全新本机证据目录;不得覆盖。")
parser.add_argument("--timeout", type=float, default=10.0, help="ADB 与设备 RPC 超时(秒)。")
parser.add_argument("--adb", default="adb", help="adb 可执行文件路径。")
return parser.parse_args(argv)
def validate_arguments(arguments: argparse.Namespace) -> None:
if type(arguments.serial) is not str or not arguments.serial.strip():
raise ValueError("必须显式提供非空 --serial。")
if not isinstance(arguments.output_dir, Path) or not arguments.output_dir.name:
raise ValueError("--output-dir 必须是明确的全新目录。")
if (
not isinstance(arguments.timeout, (int, float))
or isinstance(arguments.timeout, bool)
or arguments.timeout <= 0
or not isfinite(arguments.timeout)
):
raise ValueError("--timeout 必须是大于 0 的有限数值。")
def main(argv: list[str] | None = None) -> int:
arguments = parse_arguments(argv)
try:
validate_arguments(arguments)
except ValueError as error:
print(f"失败:{error}", file=sys.stderr)
return 2
try:
import adbutils
import uiautomator2 as u2
except ImportError:
print("失败:缺少 uiautomator2;请在采购工具虚拟环境中运行。", file=sys.stderr)
return 2
capturer = SkuExitSpikeCapturer(
AdbClient(SubprocessAdbRunner(arguments.adb), timeout_seconds=arguments.timeout),
NoReconnectUiautomatorConnector(
adbutils.AdbClient(socket_timeout=arguments.timeout).device_list,
u2.connect,
),
timeout_seconds=arguments.timeout,
)
try:
capturer.capture(arguments.serial, arguments.output_dir)
except (DeviceConnectionError, SkuSelectionError, SkuSelectionRunError):
# 不回显第三方异常、serial、页面正文或本机路径。
print("规格安全退出取证失败:已停止,未发布本地证据目录。", file=sys.stderr)
return 1
except OSError:
print("规格安全退出取证失败:无法发布本地证据目录。", file=sys.stderr)
return 1
print("规格安全退出取证完成。")
print("人工复核:请在指定目录检查退出后截图、XML、应用摘要和 manifest。")
return 0
if __name__ == "__main__":
raise SystemExit(main())
+103
View File
@@ -0,0 +1,103 @@
"""采集人工已停在规格面板的三种状态证据;不执行任何页面操作。"""
from __future__ import annotations
import argparse
from math import isfinite
from pathlib import Path
import sys
CLIENT_ROOT = Path(__file__).resolve().parents[1]
sys.path.insert(0, str(CLIENT_ROOT / "src"))
from cmbuyer_client.device.adb import AdbClient, DeviceConnectionError, SubprocessAdbRunner
from cmbuyer_client.device.baseline import NoReconnectUiautomatorConnector
from cmbuyer_client.pdd.product_url import ProductUrl, ProductUrlError, parse_product_url
from cmbuyer_client.pdd.sku_panel_spike import (
HUMAN_DECLARED_STATES,
SkuPanelEvidenceCapturer,
SkuPanelEvidenceError,
)
def parse_arguments(argv: list[str] | None = None) -> argparse.Namespace:
parser = argparse.ArgumentParser(description="采集人工已打开的拼多多规格面板状态证据。")
parser.add_argument("--serial", required=True, help="ADB device serial;禁止自动选择。")
product_source = parser.add_mutually_exclusive_group(required=True)
product_source.add_argument("--url", help="唯一 canonical goods.html?goods_id= 直链。")
product_source.add_argument("--goods-id", help="纯数字商品标识;仅用于记录证据归属。")
parser.add_argument("--state", required=True, choices=sorted(HUMAN_DECLARED_STATES), help="人工声明的面板状态。")
parser.add_argument("--output-dir", required=True, type=Path, help="新建的本地证据目录;不得覆盖已有目录。")
parser.add_argument("--timeout", type=float, default=10.0, help="ADB 和只读 RPC 超时(秒)。")
parser.add_argument("--adb", default="adb", help="adb 可执行文件路径。")
return parser.parse_args(argv)
def resolve_product_url(arguments: argparse.Namespace) -> ProductUrl:
if isinstance(arguments.url, str):
return parse_product_url(arguments.url)
if isinstance(arguments.goods_id, str):
# 仅使用严格 parser 重新验证并构建,不把输入交给 ADB 或页面。
return parse_product_url(f"https://mobile.yangkeduo.com/goods.html?goods_id={arguments.goods_id}")
raise ValueError("必须提供 --url 或 --goods-id。")
def validate_arguments(arguments: argparse.Namespace) -> ProductUrl:
if not isinstance(arguments.serial, str) or not arguments.serial.strip():
raise ValueError("必须显式提供非空 --serial。")
if (
not isinstance(arguments.timeout, (int, float))
or isinstance(arguments.timeout, bool)
or arguments.timeout <= 0
or not isfinite(arguments.timeout)
):
raise ValueError("--timeout 必须是大于 0 的有限数值。")
if arguments.state not in HUMAN_DECLARED_STATES:
raise ValueError("--state 必须是允许的人工声明状态。")
return resolve_product_url(arguments)
def main(argv: list[str] | None = None) -> int:
arguments = parse_arguments(argv)
try:
link = validate_arguments(arguments)
except (ValueError, ProductUrlError) as error:
print(f"失败:{error}", file=sys.stderr)
return 2
try:
import adbutils
import uiautomator2 as u2
except ImportError:
print("失败:缺少 uiautomator2;请在采购工具虚拟环境中运行。", file=sys.stderr)
return 2
client = AdbClient(SubprocessAdbRunner(arguments.adb), timeout_seconds=arguments.timeout)
connector = NoReconnectUiautomatorConnector(
adbutils.AdbClient(socket_timeout=arguments.timeout).device_list,
u2.connect,
)
capturer = SkuPanelEvidenceCapturer(client, connector, timeout_seconds=arguments.timeout)
try:
result = capturer.capture(
arguments.serial,
link.canonical_url,
arguments.state,
arguments.output_dir,
)
except (DeviceConnectionError, SkuPanelEvidenceError) as error:
# 不打印 ADB 输出、serial、Activity、XML 或页面正文。
print(f"规格面板证据采集失败:{error}", file=sys.stderr)
return 1
except OSError:
print("规格面板证据采集失败:无法创建或发布本地证据目录。", file=sys.stderr)
return 1
print(f"规格面板证据采集完成:{result.output_directory}")
print(f"manifest:{result.manifest_path}")
return 0
if __name__ == "__main__":
raise SystemExit(main())
+108
View File
@@ -0,0 +1,108 @@
"""采集 T-103 一次性尺码 reveal 的 before/after 本机证据。"""
from __future__ import annotations
import argparse
from math import isfinite
from pathlib import Path
import sys
CLIENT_ROOT = Path(__file__).resolve().parents[1]
sys.path.insert(0, str(CLIENT_ROOT / "src"))
from cmbuyer_client.device.adb import AdbClient, DeviceConnectionError, SubprocessAdbRunner
from cmbuyer_client.device.baseline import NoReconnectUiautomatorConnector
from cmbuyer_client.pdd.sku_reveal_spike import (
safe_reveal_failure_stage,
SkuRevealSpikeCapturer,
SkuRevealSpikeError,
)
from cmbuyer_client.pdd.sku_selection import (
EXPECTED_GOODS_ID,
_safe_sku_entry_failure_stage,
SkuSelectionError,
)
from cmbuyer_client.pdd.sku_selection_runner import SkuSelectionRunError
def parse_arguments(argv: list[str] | None = None) -> argparse.Namespace:
parser = argparse.ArgumentParser(description="采集 T-103 一次性尺码 reveal 的本机证据。")
parser.add_argument("--serial", required=True, help="ADB device serial;禁止自动选择。")
parser.add_argument("--goods-id", required=True, help="固定 T-103 已取证 goods_id。")
parser.add_argument("--output-dir", required=True, type=Path, help="全新本机证据目录;不得覆盖。")
parser.add_argument("--timeout", type=float, default=30.0, help="整趟动作与只读调和时限(秒)。")
parser.add_argument("--adb", default="adb", help="adb 可执行文件路径。")
return parser.parse_args(argv)
def validate_arguments(arguments: argparse.Namespace) -> None:
if type(arguments.serial) is not str or not arguments.serial.strip():
raise ValueError("必须显式提供非空 --serial。")
if type(arguments.goods_id) is not str or arguments.goods_id != EXPECTED_GOODS_ID:
raise ValueError("--goods-id 不是 T-103 已取证商品。")
if (
not isinstance(arguments.timeout, (int, float))
or isinstance(arguments.timeout, bool)
or arguments.timeout <= 0
or not isfinite(arguments.timeout)
):
raise ValueError("--timeout 必须是大于 0 的有限数值。")
def _safe_capture_failure_stage(error: BaseException) -> str:
# 入口与 reveal 各有不可伪造的正式 marker;入口优先,不能被外层 reveal 标注覆盖。
entry_stage = _safe_sku_entry_failure_stage(error)
if entry_stage is not None:
return entry_stage
return safe_reveal_failure_stage(error) or "unknown"
def main(argv: list[str] | None = None) -> int:
arguments = parse_arguments(argv)
try:
validate_arguments(arguments)
except ValueError as error:
print(f"失败:{error}", file=sys.stderr)
return 2
try:
import adbutils
import uiautomator2 as u2
except ImportError:
print("失败:缺少 uiautomator2;请在采购工具虚拟环境中运行。", file=sys.stderr)
return 2
capturer = SkuRevealSpikeCapturer(
AdbClient(SubprocessAdbRunner(arguments.adb), timeout_seconds=arguments.timeout),
NoReconnectUiautomatorConnector(
adbutils.AdbClient(socket_timeout=arguments.timeout).device_list,
u2.connect,
),
timeout_seconds=arguments.timeout,
)
try:
result = capturer.capture(
arguments.serial,
arguments.goods_id,
arguments.output_dir,
)
except (DeviceConnectionError, SkuSelectionError, SkuSelectionRunError, SkuRevealSpikeError) as error:
# 不回显页面正文、节点、serial、坐标、路径或第三方异常。
print(
f"规格 reveal 取证失败:stage={_safe_capture_failure_stage(error)};已停止,未发布本地证据目录。",
file=sys.stderr,
)
return 1
except OSError:
print("规格 reveal 取证失败:无法创建或发布本地证据目录。", file=sys.stderr)
return 1
print(f"规格 reveal 取证完成:{result.output_directory}")
print(f"manifest:{result.manifest_path}")
print("人工复核:请保持手机不动,确认颜色仍选中、S/M 均未选且未进入提交页。")
return 0
if __name__ == "__main__":
raise SystemExit(main())
+87
View File
@@ -0,0 +1,87 @@
"""恢复 T-103 已取证目标规格、验证现价并保存本地原始截图。"""
from __future__ import annotations
import argparse
from math import isfinite
from pathlib import Path
import sys
CLIENT_ROOT = Path(__file__).resolve().parents[1]
sys.path.insert(0, str(CLIENT_ROOT / "src"))
from cmbuyer_client.device.adb import AdbClient, DeviceConnectionError, SubprocessAdbRunner
from cmbuyer_client.device.baseline import NoReconnectUiautomatorConnector
from cmbuyer_client.pdd.product_url import ProductUrlError, parse_product_url
from cmbuyer_client.pdd.sku_selection import EXPECTED_GOODS_ID, SkuSelectionError, TASK_TO_UI_SELECTION
from cmbuyer_client.pdd.sku_selection_runner import SkuSelectionRunError, SkuSelectionRunner, safe_failure_stage
def parse_arguments(argv: list[str] | None = None) -> argparse.Namespace:
parser = argparse.ArgumentParser(description="恢复 T-103 已取证规格并保存本地原始截图。")
parser.add_argument("--serial", required=True, help="ADB device serial;禁止自动选择。")
parser.add_argument("--url", required=True, help="唯一 canonical goods.html?goods_id= 直链。")
parser.add_argument("--color", required=True, help="T-103 任务颜色值。")
parser.add_argument("--size", required=True, help="T-103 任务尺码值。")
parser.add_argument("--output-dir", required=True, type=Path, help="新建本地目录;不得覆盖已有目录。")
parser.add_argument("--timeout", type=float, default=10.0, help="ADB 与设备 RPC 超时(秒)。")
parser.add_argument("--adb", default="adb", help="adb 可执行文件路径。")
return parser.parse_args(argv)
def validate_arguments(arguments: argparse.Namespace) -> None:
if not isinstance(arguments.serial, str) or not arguments.serial.strip():
raise ValueError("必须显式提供非空 --serial。")
if not isinstance(arguments.timeout, (int, float)) or isinstance(arguments.timeout, bool) or arguments.timeout <= 0 or not isfinite(arguments.timeout):
raise ValueError("--timeout 必须是大于 0 的有限数值。")
link = parse_product_url(arguments.url)
if link.goods_id != EXPECTED_GOODS_ID:
raise ValueError("--url 不是 T-103 已取证商品。")
if (arguments.color, arguments.size) not in TASK_TO_UI_SELECTION:
raise ValueError("--color 与 --size 必须是 T-103 已取证任务值。")
def main(argv: list[str] | None = None) -> int:
arguments = parse_arguments(argv)
try:
validate_arguments(arguments)
except (ValueError, ProductUrlError) as error:
print(f"失败:{error}", file=sys.stderr)
return 2
try:
import adbutils
import uiautomator2 as u2
except ImportError:
print("失败:缺少 uiautomator2;请在采购工具虚拟环境中运行。", file=sys.stderr)
return 2
runner = SkuSelectionRunner(
AdbClient(SubprocessAdbRunner(arguments.adb), timeout_seconds=arguments.timeout),
NoReconnectUiautomatorConnector(adbutils.AdbClient(socket_timeout=arguments.timeout).device_list, u2.connect),
timeout_seconds=arguments.timeout,
)
try:
result = runner.run(arguments.serial, arguments.url, arguments.color, arguments.size, arguments.output_dir)
except (DeviceConnectionError, SkuSelectionRunError, SkuSelectionError) as error:
# Flow 可能来自测试替身或未来实现;CLI 不回显任何异常正文,避免泄露节点树或页面文本。
print(
f"规格恢复失败:stage={safe_failure_stage(error)};已停止,未发布本地证据目录。",
file=sys.stderr,
)
return 1
except OSError:
print("规格恢复失败:无法创建或发布本地证据目录。", file=sys.stderr)
return 1
print(f"规格恢复完成:{result.output_directory}")
print(f"manifest:{result.manifest_path}")
print(f"目标规格:{arguments.color} / {arguments.size}")
print(f"确认单价:{result.unit_price}")
print("页面对应性:请人工核对本地原始截图。")
return 0
if __name__ == "__main__":
raise SystemExit(main())
+132
View File
@@ -0,0 +1,132 @@
"""运行 T-105 数量 1→2、Gate2 截图与一次安全退出。"""
from __future__ import annotations
import argparse
from datetime import datetime
from math import isfinite
from pathlib import Path
import sys
CLIENT_ROOT = Path(__file__).resolve().parents[1]
sys.path.insert(0, str(CLIENT_ROOT / "src"))
from cmbuyer_client.device.adb import AdbClient, DeviceConnectionError, SubprocessAdbRunner
from cmbuyer_client.device.baseline import NoReconnectUiautomatorConnector
from cmbuyer_client.pdd.quantity_gate2 import (
EXPECTED_GATE1_UNIT_PRICE,
EXPECTED_GOODS_ID,
TASK_COLOR,
TASK_SIZE,
Gate1Observation,
QuantityGate2Error,
)
from cmbuyer_client.pdd.quantity_gate2_runner import QuantityGate2Runner
from cmbuyer_client.pdd.quantity_gate2_spike import Android16TopResumedForegroundReader
def parse_arguments(argv: list[str] | None = None) -> argparse.Namespace:
parser = argparse.ArgumentParser(description="运行 T-105 已取证数量与 Gate2 闭环。")
parser.add_argument("--serial", required=True, help="显式 ADB serial;禁止自动选择。")
parser.add_argument("--goods-id", required=True)
parser.add_argument("--color", required=True)
parser.add_argument("--size", required=True)
parser.add_argument("--target-quantity", required=True, type=int)
parser.add_argument("--gate1-unit-price", required=True)
parser.add_argument("--gate1-screenshot", required=True, type=Path)
parser.add_argument("--gate1-captured-at", required=True)
parser.add_argument("--max-total-price", required=True)
parser.add_argument("--output-dir", required=True, type=Path)
parser.add_argument("--timeout", type=float, default=10.0)
parser.add_argument("--adb", default="adb")
return parser.parse_args(argv)
def validate_arguments(arguments: argparse.Namespace) -> datetime:
if type(arguments.serial) is not str or not arguments.serial.strip() or arguments.serial != arguments.serial.strip():
raise ValueError("必须显式提供非空 --serial。")
if arguments.goods_id != EXPECTED_GOODS_ID:
raise ValueError("--goods-id 不是 T-105 已批准目标。")
if arguments.color != TASK_COLOR or arguments.size != TASK_SIZE:
raise ValueError("颜色或尺码不是 T-105 已批准目标。")
if type(arguments.target_quantity) is not int or arguments.target_quantity != 2:
raise ValueError("本次真机验收只批准 --target-quantity 2。")
if arguments.gate1_unit_price != EXPECTED_GATE1_UNIT_PRICE:
raise ValueError("--gate1-unit-price 与已确认 Gate1 不一致。")
if not isinstance(arguments.gate1_screenshot, Path) or not arguments.gate1_screenshot.is_file():
raise ValueError("--gate1-screenshot 必须是现有显式文件。")
try:
captured_at = datetime.fromisoformat(arguments.gate1_captured_at)
except (TypeError, ValueError) as error:
raise ValueError("--gate1-captured-at 必须是带时区 ISO 时间。") from error
if captured_at.utcoffset() is None:
raise ValueError("--gate1-captured-at 必须带时区。")
if arguments.max_total_price != "40.00":
raise ValueError("本次真机验收固定 --max-total-price 40.00。")
if not isinstance(arguments.output_dir, Path) or not arguments.output_dir.name or arguments.output_dir.exists():
raise ValueError("--output-dir 必须是不存在的明确新目录。")
if (
not isinstance(arguments.timeout, (int, float))
or isinstance(arguments.timeout, bool)
or arguments.timeout <= 0
or not isfinite(arguments.timeout)
):
raise ValueError("--timeout 必须是大于 0 的有限数值。")
return captured_at
def main(argv: list[str] | None = None) -> int:
arguments = parse_arguments(argv)
try:
captured_at = validate_arguments(arguments)
except ValueError as error:
print(f"失败:{error}", file=sys.stderr)
return 2
try:
import adbutils
import uiautomator2 as u2
except ImportError:
print("失败:缺少采购工具真机依赖。", file=sys.stderr)
return 2
adb_runner = SubprocessAdbRunner(arguments.adb)
runner = QuantityGate2Runner(
AdbClient(adb_runner, timeout_seconds=arguments.timeout),
NoReconnectUiautomatorConnector(
adbutils.AdbClient(socket_timeout=arguments.timeout).device_list,
u2.connect,
),
Android16TopResumedForegroundReader(adb_runner, arguments.timeout),
timeout_seconds=arguments.timeout,
)
gate1 = Gate1Observation(
color=arguments.color,
size=arguments.size,
quantity=1,
gate1_unit_price=arguments.gate1_unit_price,
screenshot_path=arguments.gate1_screenshot,
captured_at=captured_at,
)
try:
runner.run(
arguments.serial,
arguments.goods_id,
gate1,
arguments.target_quantity,
arguments.max_total_price,
arguments.output_dir,
)
except (DeviceConnectionError, QuantityGate2Error, OSError):
# 不回显第三方异常、serial、本机路径或页面正文。
print("T-105 数量/Gate2 运行失败:已停止,未发布证据目录。", file=sys.stderr)
return 1
print("T-105 数量/Gate2 运行完成。")
print("人工复核:数量 2、目标规格、12.88/32.76、原始截图和一次安全退出。")
return 0
if __name__ == "__main__":
raise SystemExit(main())
@@ -0,0 +1,138 @@
"""运行 T-107 最终面板 Gate3 只读观察与一次安全返回。"""
from __future__ import annotations
import argparse
from datetime import datetime
from math import isfinite
from pathlib import Path
import sys
CLIENT_ROOT = Path(__file__).resolve().parents[1]
sys.path.insert(0, str(CLIENT_ROOT / "src"))
from cmbuyer_client.device.adb import AdbClient, DeviceConnectionError, SubprocessAdbRunner
from cmbuyer_client.device.baseline import NoReconnectUiautomatorConnector
from cmbuyer_client.pdd.final_submit_panel import FinalSubmitPanelError
from cmbuyer_client.pdd.final_submit_panel_runner import FinalSubmitPanelRunner
from cmbuyer_client.pdd.quantity_gate2 import (
EXPECTED_GATE1_UNIT_PRICE,
EXPECTED_GOODS_ID,
TASK_COLOR,
TASK_SIZE,
Gate2Observation,
)
from cmbuyer_client.pdd.quantity_gate2_spike import Android16TopResumedForegroundReader
def parse_arguments(argv: list[str] | None = None) -> argparse.Namespace:
parser = argparse.ArgumentParser(description="运行 T-107 围栏前最终面板只读 dry-run。")
parser.add_argument("--serial", required=True, help="显式 ADB serial;禁止自动选择。")
parser.add_argument("--goods-id", required=True)
parser.add_argument("--color", required=True)
parser.add_argument("--size", required=True)
parser.add_argument("--quantity", required=True, type=int)
parser.add_argument("--gate1-unit-price", required=True)
parser.add_argument("--gate2-panel-total-price", required=True)
parser.add_argument("--gate2-screenshot", required=True, type=Path)
parser.add_argument("--gate2-captured-at", required=True)
parser.add_argument("--max-total-price", required=True)
parser.add_argument("--output-dir", required=True, type=Path)
parser.add_argument("--timeout", type=float, default=10.0)
parser.add_argument("--adb", default="adb")
return parser.parse_args(argv)
def validate_arguments(arguments: argparse.Namespace) -> datetime:
if type(arguments.serial) is not str or not arguments.serial.strip() or arguments.serial != arguments.serial.strip():
raise ValueError("必须显式提供非空 --serial。")
if arguments.goods_id != EXPECTED_GOODS_ID:
raise ValueError("--goods-id 不是 T-107 已批准目标。")
if arguments.color != TASK_COLOR or arguments.size != TASK_SIZE:
raise ValueError("颜色或尺码不是 T-107 已批准目标。")
if type(arguments.quantity) is not int or arguments.quantity != 2:
raise ValueError("本次真机验收只批准 --quantity 2。")
if arguments.gate1_unit_price != EXPECTED_GATE1_UNIT_PRICE:
raise ValueError("--gate1-unit-price 与已确认 Gate1 不一致。")
if arguments.gate2_panel_total_price != "32.76":
raise ValueError("--gate2-panel-total-price 与 T-106 已确认值不一致。")
if not isinstance(arguments.gate2_screenshot, Path) or not arguments.gate2_screenshot.is_file():
raise ValueError("--gate2-screenshot 必须是现有显式文件。")
try:
captured_at = datetime.fromisoformat(arguments.gate2_captured_at)
except (TypeError, ValueError) as error:
raise ValueError("--gate2-captured-at 必须是带时区 ISO 时间。") from error
if captured_at.utcoffset() is None:
raise ValueError("--gate2-captured-at 必须带时区。")
if arguments.max_total_price != "40.00":
raise ValueError("本次真机验收固定 --max-total-price 40.00。")
if not isinstance(arguments.output_dir, Path) or not arguments.output_dir.name or arguments.output_dir.exists():
raise ValueError("--output-dir 必须是不存在的明确新目录。")
if (
not isinstance(arguments.timeout, (int, float))
or isinstance(arguments.timeout, bool)
or arguments.timeout <= 0
or not isfinite(arguments.timeout)
):
raise ValueError("--timeout 必须是大于 0 的有限数值。")
return captured_at
def main(argv: list[str] | None = None) -> int:
arguments = parse_arguments(argv)
try:
captured_at = validate_arguments(arguments)
except ValueError as error:
print(f"失败:{error}", file=sys.stderr)
return 2
try:
import adbutils
import uiautomator2 as u2
except ImportError:
print("失败:缺少采购工具真机依赖。", file=sys.stderr)
return 2
adb_runner = SubprocessAdbRunner(arguments.adb)
runner = FinalSubmitPanelRunner(
AdbClient(adb_runner, timeout_seconds=arguments.timeout),
NoReconnectUiautomatorConnector(
adbutils.AdbClient(socket_timeout=arguments.timeout).device_list,
u2.connect,
),
Android16TopResumedForegroundReader(adb_runner, arguments.timeout),
timeout_seconds=arguments.timeout,
)
gate2 = Gate2Observation(
requested_color=arguments.color,
requested_size=arguments.size,
actual_color=arguments.color,
actual_size=arguments.size,
requested_quantity=arguments.quantity,
quantity_read=arguments.quantity,
gate1_unit_price=arguments.gate1_unit_price,
gate2_panel_total_price=arguments.gate2_panel_total_price,
max_total_price=arguments.max_total_price,
screenshot_path=arguments.gate2_screenshot,
captured_at=captured_at,
)
try:
runner.run(
arguments.serial,
arguments.goods_id,
gate2,
arguments.output_dir,
)
except (DeviceConnectionError, FinalSubmitPanelError, OSError):
# 不回显第三方异常、serial、本机路径或页面正文。
print("T-107 最终面板 dry-run 失败:已停止,未发布证据目录。", file=sys.stderr)
return 1
print("T-107 最终面板 dry-run 完成。")
print("人工复核:Gate2/Gate3、最终控件唯一、一次安全返回,且未创建订单或进入付款。")
return 0
if __name__ == "__main__":
raise SystemExit(main())
@@ -0,0 +1,41 @@
"""本机脱敏 T-103 raw 证据到同级 derived;不连接设备或解析页面语义。"""
from __future__ import annotations
import argparse
from pathlib import Path
import sys
CLIENT_ROOT = Path(__file__).resolve().parents[1]
sys.path.insert(0, str(CLIENT_ROOT / "src"))
from cmbuyer_client.device.sku_evidence_sanitizer import (
SkuEvidenceSanitizationError,
sanitize_sku_panel_evidence,
)
def parse_arguments(argv: list[str] | None = None) -> argparse.Namespace:
parser = argparse.ArgumentParser(description="将本机 raw 规格面板证据确定性脱敏到同级 derived。")
parser.add_argument("--raw-dir", required=True, type=Path, help="仅允许名为 raw 的本机原始证据目录。")
parser.add_argument("--output-dir", required=True, type=Path, help="仅允许 raw 同级且名为 derived 的新目录。")
return parser.parse_args(argv)
def main(argv: list[str] | None = None) -> int:
arguments = parse_arguments(argv)
try:
result = sanitize_sku_panel_evidence(arguments.raw_dir, arguments.output_dir)
except SkuEvidenceSanitizationError as error:
# 错误不回显 raw 路径、manifest/XML、地址、手机号或 serial。
print(f"证据脱敏失败:{error}", file=sys.stderr)
return 1
print(f"派生证据脱敏完成:{result.output_directory}")
print(f"manifest:{result.manifest_path}")
return 0
if __name__ == "__main__":
raise SystemExit(main())
+80
View File
@@ -0,0 +1,80 @@
"""验证 wheel 元数据从 requirements.txt 声明了全部运行时依赖。"""
from __future__ import annotations
import argparse
from email import policy
from email.parser import BytesParser
from pathlib import Path
import re
import sys
import zipfile
def normalize_project_name(name: str) -> str:
"""使用足以比较 requirements 与 Core Metadata 的项目名规范化规则。"""
return re.sub(r"[-_.]+", "-", name).lower()
def dependencies_from_requirements(requirements_file: Path) -> set[str]:
"""从唯一依赖来源读取项目名;当前 requirements 不允许间接或可编辑依赖。"""
dependencies: set[str] = set()
for line in requirements_file.read_text(encoding="utf-8").splitlines():
requirement = line.partition("#")[0].strip()
if not requirement:
continue
match = re.match(r"[A-Za-z0-9][A-Za-z0-9._-]*", requirement)
if match is None:
raise ValueError(f"requirements.txt 包含不支持的依赖声明:{requirement}")
dependencies.add(normalize_project_name(match.group()))
return dependencies
def dependencies_from_wheel(wheel_file: Path) -> set[str]:
"""读取 wheel 的 Core Metadata 中声明的 Requires-Dist 项目名。"""
with zipfile.ZipFile(wheel_file) as wheel:
metadata_members = [name for name in wheel.namelist() if name.endswith(".dist-info/METADATA")]
if len(metadata_members) != 1:
raise ValueError("wheel 中必须恰有一个 .dist-info/METADATA 文件")
metadata = BytesParser(policy=policy.default).parsebytes(wheel.read(metadata_members[0]))
dependencies = set()
for requirement in metadata.get_all("Requires-Dist", []):
match = re.match(r"[A-Za-z0-9][A-Za-z0-9._-]*", requirement)
if match is None:
raise ValueError(f"wheel METADATA 包含无效的 Requires-Dist:{requirement}")
dependencies.add(normalize_project_name(match.group()))
return dependencies
def verify_wheel_metadata(wheel_file: Path, requirements_file: Path) -> set[str]:
"""返回没有被 wheel 元数据声明的 requirements 项目名。"""
return dependencies_from_requirements(requirements_file) - dependencies_from_wheel(wheel_file)
def main(argv: list[str] | None = None) -> int:
parser = argparse.ArgumentParser(description="检查 wheel 是否包含 requirements.txt 的依赖元数据")
parser.add_argument("wheel", type=Path, help="待检查的 wheel 文件")
parser.add_argument(
"--requirements",
type=Path,
default=Path(__file__).resolve().parents[1] / "requirements.txt",
help="唯一依赖来源 requirements.txt 的路径",
)
arguments = parser.parse_args(argv)
missing = verify_wheel_metadata(arguments.wheel, arguments.requirements)
if missing:
print(f"wheel METADATA 缺少依赖:{', '.join(sorted(missing))}", file=sys.stderr)
return 1
print("wheel METADATA 已声明 requirements.txt 中的全部依赖。")
return 0
if __name__ == "__main__":
raise SystemExit(main())
+6
View File
@@ -0,0 +1,6 @@
"""采购工具桌面端包。
本包当前只提供应用骨架和安全的本地运行基础设施;不包含真机操作或采购流程。
"""
__version__ = "0.1.0"
+7
View File
@@ -0,0 +1,7 @@
"""支持通过 ``python -m cmbuyer_client`` 启动应用。"""
from .app import main
if __name__ == "__main__":
raise SystemExit(main())
+85
View File
@@ -0,0 +1,85 @@
"""采购工具的最小桌面应用入口。"""
from __future__ import annotations
import sys
from collections.abc import Sequence
from .core.errors import StateError
from .logging_policy import configure_application_logger
from .runtime import LocalStateRuntime, RuntimePaths
def select_application_argv(argv: Sequence[str] | None) -> list[str]:
"""保留调用方明确给出的空参数列表,避免改变测试或打包入口的语义。"""
return list(sys.argv if argv is None else argv)
def main(argv: Sequence[str] | None = None) -> int:
"""启动只表达当前工程状态的桌面外壳。
真机控制和采购执行必须在完成取证并实现后才可接入,因此此入口不导入
uiautomator2,也不提供任何会影响采购或支付状态的命令。
"""
try:
paths = RuntimePaths.default()
logger = configure_application_logger(paths)
except OSError as error:
print(f"无法创建采购工具运行目录:{error}", file=sys.stderr)
return 1
try:
from PySide6.QtWidgets import QApplication
except ImportError:
logger.error("缺少 PySide6,无法启动桌面界面。")
print("无法启动采购工具:缺少 PySide6。请先安装 requirements.txt 中的依赖。", file=sys.stderr)
return 2
application = QApplication.instance() or QApplication(select_application_argv(argv))
application.setApplicationName("采购工具")
runtime: LocalStateRuntime | None = None
coordinator = None
try:
runtime = LocalStateRuntime.open(paths)
try:
summary = runtime.store.load_profile_summary("default")
except StateError as error:
if error.reason != "profile_not_found":
raise
summary = None
from .polling.coordinator import PollingCoordinator
from .ui.main_window import PurchaseToolWindow
settings = None if summary is None else summary.settings
has_token = False if summary is None else summary.has_stored_device_token
coordinator = PollingCoordinator(
profile_id="default",
store=runtime.store,
gateway_factory=None,
consumer=None,
profile_settings=settings,
poll_interval_seconds=15 if settings is None else settings.poll_interval_seconds,
failure_threshold=3 if settings is None else settings.failure_threshold,
)
window = PurchaseToolWindow(
store=runtime.store,
coordinator=coordinator,
profile_settings=settings,
has_stored_device_token=has_token,
)
logger.info("应用已启动;单趟执行能力尚未接入,真实领取保持禁用。")
window.show()
return application.exec()
except (OSError, RuntimeError, StateError):
logger.error("无法打开采购工具本地安全状态。")
print("无法启动采购工具:本地安全状态不可用。", file=sys.stderr)
return 3
finally:
worker_stopped = True
if coordinator is not None:
worker_stopped = coordinator.shutdown()
if runtime is not None and worker_stopped:
runtime.close()
@@ -0,0 +1,44 @@
"""与 UI、HTTP 和拼多多页面实现无关的客户端核心契约。"""
from .errors import (
AmbiguousRemoteError,
CredentialRemoteError,
ManualRemoteError,
ProtocolRemoteError,
StateError,
ValidationError,
)
from .models import (
AssetReceipt,
AuthorizationSnapshot,
ClaimRequest,
ClaimedTask,
DeviceCredentials,
EvidenceUpload,
PurchaseTask,
RenewRequest,
RenewResult,
SecretToken,
)
from .ports import EvidenceSink, TaskSource
__all__ = [
"AmbiguousRemoteError",
"AssetReceipt",
"AuthorizationSnapshot",
"ClaimRequest",
"ClaimedTask",
"CredentialRemoteError",
"DeviceCredentials",
"EvidenceSink",
"EvidenceUpload",
"ManualRemoteError",
"ProtocolRemoteError",
"PurchaseTask",
"RenewRequest",
"RenewResult",
"SecretToken",
"StateError",
"TaskSource",
"ValidationError",
]
+47
View File
@@ -0,0 +1,47 @@
"""可安全呈现的客户端错误分类。"""
from __future__ import annotations
class ClientError(RuntimeError):
"""错误文本只使用固定 reason code,不携带凭据、响应或本机路径。"""
def __init__(self, reason: str) -> None:
self.reason = reason
super().__init__(reason)
class ValidationError(ClientError):
"""本地输入或 wire schema 不满足固定契约。"""
class StateError(ClientError):
"""本地状态无法安全推进;调用方必须停止而不是绕过。"""
class ProtectionError(ClientError):
"""秘密保护失败。"""
class SingleInstanceError(ClientError):
"""同一配置已经由另一个采购工具进程持有。"""
class RemoteError(ClientError):
"""服务端调用的稳定错误分类。"""
class AmbiguousRemoteError(RemoteError):
"""请求结果不明;只允许以原幂等键、原载荷显式恢复。"""
class CredentialRemoteError(RemoteError):
"""设备凭据无效或已撤销。"""
class ProtocolRemoteError(RemoteError):
"""请求/响应与固定协议不兼容,不得自动重试。"""
class ManualRemoteError(RemoteError):
"""服务端要求人工处理的确定性冲突。"""
+328
View File
@@ -0,0 +1,328 @@
"""任务领取、续租和单张证据上传的不可变值对象。"""
from __future__ import annotations
from dataclasses import dataclass, field
import hashlib
from pathlib import Path
from .errors import ValidationError
from .validation import (
MAX_SKU_TEXT_CODE_POINTS,
MAX_TITLE_CODE_POINTS,
canonical_product_url,
require_exact_fields,
require_goods_id,
require_lower_hex_64,
require_money,
require_persisted_text,
require_positive_int,
require_rfc3339_z,
rfc3339_z_nanoseconds,
require_string,
require_uuid4,
)
EVIDENCE_KIND = "SKU_PANEL_GATE_1"
PRIVACY_TIER = "INTERNAL_RAW"
@dataclass(frozen=True, repr=False)
class SecretToken:
"""64 位小写 token;repr 永不暴露明文。"""
value: str
def __post_init__(self) -> None:
require_lower_hex_64(self.value, "invalid_token")
def __repr__(self) -> str:
return "SecretToken([已隐藏])"
def __str__(self) -> str:
return "[已隐藏]"
@dataclass(frozen=True, repr=False)
class DeviceCredentials:
device_id: str
token: SecretToken
def __post_init__(self) -> None:
require_uuid4(self.device_id, "invalid_device_id")
if not isinstance(self.token, SecretToken):
raise ValidationError("invalid_device_token")
def __repr__(self) -> str:
return f"DeviceCredentials(device_id={self.device_id!r}, token=[已隐藏])"
@dataclass(frozen=True)
class ClaimRequest:
session_id: str
claim_request_id: str
def __post_init__(self) -> None:
require_uuid4(self.session_id, "invalid_session_id")
require_uuid4(self.claim_request_id, "invalid_claim_request_id")
def to_wire(self) -> dict[str, object]:
return {"session_id": self.session_id, "claim_request_id": self.claim_request_id}
@dataclass(frozen=True)
class PurchaseTask:
id: str
version: int
title: str
product_url: str
goods_id: str
sku_color: str
sku_size: str
quantity: int
max_total_price: str
def __post_init__(self) -> None:
require_uuid4(self.id, "invalid_task_id")
require_positive_int(self.version, "invalid_task_version")
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_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")
@classmethod
def from_wire(cls, value: object) -> "PurchaseTask":
data = require_exact_fields(
value,
("id", "version", "title", "product_url", "goods_id", "sku_color", "sku_size", "quantity", "max_total_price"),
)
return cls(**data) # type: ignore[arg-type]
@dataclass(frozen=True)
class AuthorizationSnapshot:
id: str
task_version: int
expires_at: str
def __post_init__(self) -> None:
require_uuid4(self.id, "invalid_authorization_id")
require_positive_int(self.task_version, "invalid_authorization_task_version")
require_rfc3339_z(self.expires_at, "invalid_authorization_expiry")
@classmethod
def from_wire(cls, value: object) -> "AuthorizationSnapshot":
data = require_exact_fields(value, ("id", "task_version", "expires_at"))
return cls(**data) # type: ignore[arg-type]
@dataclass(frozen=True)
class AttemptSnapshot:
id: str
claim_token: SecretToken
claim_generation: int
lease_expires_at: str
def __post_init__(self) -> None:
require_uuid4(self.id, "invalid_attempt_id")
if not isinstance(self.claim_token, SecretToken):
object.__setattr__(self, "claim_token", SecretToken(self.claim_token))
require_positive_int(self.claim_generation, "invalid_claim_generation")
require_rfc3339_z(self.lease_expires_at, "invalid_lease_expiry")
@classmethod
def from_wire(cls, value: object) -> "AttemptSnapshot":
data = require_exact_fields(value, ("id", "claim_token", "claim_generation", "lease_expires_at"))
return cls(
id=data["id"], # type: ignore[arg-type]
claim_token=SecretToken(data["claim_token"]), # type: ignore[arg-type]
claim_generation=data["claim_generation"], # type: ignore[arg-type]
lease_expires_at=data["lease_expires_at"], # type: ignore[arg-type]
)
@dataclass(frozen=True)
class ClaimedTask:
task: PurchaseTask
authorization: AuthorizationSnapshot
attempt: AttemptSnapshot = field(repr=False)
def __post_init__(self) -> None:
if self.task.version != self.authorization.task_version + 1:
raise ValidationError("task_authorization_version_mismatch")
if rfc3339_z_nanoseconds(self.attempt.lease_expires_at) > rfc3339_z_nanoseconds(self.authorization.expires_at):
raise ValidationError("claim_lease_exceeds_authorization")
@classmethod
def from_wire(cls, value: object) -> "ClaimedTask":
data = require_exact_fields(value, ("task", "authorization", "attempt"))
return cls(
task=PurchaseTask.from_wire(data["task"]),
authorization=AuthorizationSnapshot.from_wire(data["authorization"]),
attempt=AttemptSnapshot.from_wire(data["attempt"]),
)
@dataclass(frozen=True, repr=False)
class RenewRequest:
task_id: str
renew_request_id: str
session_id: str
attempt_id: str
claim_generation: int
claim_token: SecretToken
expected_lease_expires_at: str
authorization_expires_at: str
def __post_init__(self) -> None:
require_uuid4(self.task_id, "invalid_task_id")
require_uuid4(self.renew_request_id, "invalid_renew_request_id")
require_uuid4(self.session_id, "invalid_session_id")
require_uuid4(self.attempt_id, "invalid_attempt_id")
require_positive_int(self.claim_generation, "invalid_claim_generation")
if not isinstance(self.claim_token, SecretToken):
object.__setattr__(self, "claim_token", SecretToken(self.claim_token))
require_rfc3339_z(self.expected_lease_expires_at, "invalid_expected_lease_expiry")
require_rfc3339_z(self.authorization_expires_at, "invalid_authorization_expiry")
def __repr__(self) -> str:
return (
f"RenewRequest(task_id={self.task_id!r}, renew_request_id={self.renew_request_id!r}, "
"claim_token=[已隐藏])"
)
def to_wire(self) -> dict[str, object]:
return {
"renew_request_id": self.renew_request_id,
"session_id": self.session_id,
"attempt_id": self.attempt_id,
"claim_generation": self.claim_generation,
"claim_token": self.claim_token.value,
"expected_lease_expires_at": self.expected_lease_expires_at,
}
@dataclass(frozen=True)
class RenewResult:
task_id: str
attempt_id: str
claim_generation: int
lease_expires_at: str
def __post_init__(self) -> None:
require_uuid4(self.task_id, "invalid_task_id")
require_uuid4(self.attempt_id, "invalid_attempt_id")
require_positive_int(self.claim_generation, "invalid_claim_generation")
require_rfc3339_z(self.lease_expires_at, "invalid_lease_expiry")
@classmethod
def from_wire(cls, value: object) -> "RenewResult":
data = require_exact_fields(value, ("task_id", "attempt_id", "claim_generation", "lease_expires_at"))
return cls(**data) # type: ignore[arg-type]
@dataclass(frozen=True, repr=False)
class EvidenceUpload:
task_id: str
upload_key: str
attempt_id: str
sha256: str
captured_at: str
content: bytes = field(repr=False)
kind: str = EVIDENCE_KIND
privacy_tier: str = PRIVACY_TIER
width_px: int = field(init=False)
height_px: int = field(init=False)
def __post_init__(self) -> None:
require_uuid4(self.task_id, "invalid_task_id")
require_uuid4(self.upload_key, "invalid_upload_key")
require_uuid4(self.attempt_id, "invalid_attempt_id")
require_lower_hex_64(self.sha256, "invalid_evidence_sha256")
require_rfc3339_z(self.captured_at, "invalid_captured_at")
if self.kind != EVIDENCE_KIND or self.privacy_tier != PRIVACY_TIER:
raise ValidationError("invalid_evidence_metadata")
if not isinstance(self.content, bytes) or not self.content or len(self.content) > 10 * 1024 * 1024:
raise ValidationError("invalid_evidence_size")
if len(self.content) < 24 or not self.content.startswith(b"\x89PNG\r\n\x1a\n") or self.content[12:16] != b"IHDR":
raise ValidationError("invalid_evidence_png")
width = int.from_bytes(self.content[16:20], "big")
height = int.from_bytes(self.content[20:24], "big")
if width <= 0 or height <= 0 or width > 8192 or height > 8192 or width * height > 16_777_216:
raise ValidationError("invalid_evidence_dimensions")
object.__setattr__(self, "width_px", width)
object.__setattr__(self, "height_px", height)
if hashlib.sha256(self.content).hexdigest() != self.sha256:
raise ValidationError("evidence_hash_mismatch")
def __repr__(self) -> str:
return (
f"EvidenceUpload(task_id={self.task_id!r}, upload_key={self.upload_key!r}, "
f"attempt_id={self.attempt_id!r}, byte_size={len(self.content)})"
)
@dataclass(frozen=True)
class AssetReceipt:
asset_id: str
task_id: str
attempt_id: str
kind: str
privacy_tier: str
sha256: str
byte_size: int
content_type: str
width_px: int
height_px: int
captured_at: str
def __post_init__(self) -> None:
require_uuid4(self.asset_id, "invalid_asset_id")
require_uuid4(self.task_id, "invalid_task_id")
require_uuid4(self.attempt_id, "invalid_attempt_id")
if self.kind != EVIDENCE_KIND or self.privacy_tier != PRIVACY_TIER:
raise ValidationError("invalid_asset_metadata")
require_lower_hex_64(self.sha256, "invalid_asset_sha256")
require_positive_int(self.byte_size, "invalid_asset_byte_size")
if self.byte_size > 10 * 1024 * 1024 or self.content_type != "image/png":
raise ValidationError("invalid_asset_content")
width = require_positive_int(self.width_px, "invalid_asset_width")
height = require_positive_int(self.height_px, "invalid_asset_height")
if width > 8192 or height > 8192 or width * height > 16_777_216:
raise ValidationError("invalid_asset_dimensions")
require_rfc3339_z(self.captured_at, "invalid_captured_at")
@classmethod
def from_wire(cls, value: object) -> "AssetReceipt":
data = require_exact_fields(
value,
("asset_id", "task_id", "attempt_id", "kind", "privacy_tier", "sha256", "byte_size", "content_type", "width_px", "height_px", "captured_at"),
)
return cls(**data) # type: ignore[arg-type]
@dataclass(frozen=True, repr=False)
class ScreenshotAsset:
"""调用方显式选择的唯一 PNG;路径不会进入 repr 或 HTTP。"""
path: Path = field(repr=False)
task_id: str
attempt_id: str
captured_at: str
kind: str = EVIDENCE_KIND
privacy_tier: str = PRIVACY_TIER
def __post_init__(self) -> None:
require_uuid4(self.task_id, "invalid_task_id")
require_uuid4(self.attempt_id, "invalid_attempt_id")
require_rfc3339_z(self.captured_at, "invalid_captured_at")
if self.kind != EVIDENCE_KIND or self.privacy_tier != PRIVACY_TIER:
raise ValidationError("invalid_evidence_metadata")
def __repr__(self) -> str:
return f"ScreenshotAsset(task_id={self.task_id!r}, attempt_id={self.attempt_id!r}, path=[已隐藏])"
+17
View File
@@ -0,0 +1,17 @@
"""由 HTTP 适配器实现的窄端口。"""
from __future__ import annotations
from typing import Protocol
from .models import AssetReceipt, ClaimRequest, ClaimedTask, DeviceCredentials, EvidenceUpload, RenewRequest, RenewResult
class TaskSource(Protocol):
def claim_next(self, credentials: DeviceCredentials, request: ClaimRequest) -> ClaimedTask | None: ...
def renew(self, credentials: DeviceCredentials, request: RenewRequest) -> RenewResult: ...
class EvidenceSink(Protocol):
def upload(self, credentials: DeviceCredentials, evidence: EvidenceUpload) -> AssetReceipt: ...
@@ -0,0 +1,197 @@
"""客户端与服务端共享 wire 的严格值校验。"""
from __future__ import annotations
import calendar
from datetime import datetime, timezone
import json
import re
from typing import Any, Iterable, Mapping
from urllib.parse import quote
from .errors import ValidationError
UUID4_RE = re.compile(
r"[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}"
)
LOWER_HEX_64_RE = re.compile(r"[0-9a-f]{64}")
RFC3339_Z_RE = re.compile(
r"\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d{1,9})?Z"
)
MONEY_RE = re.compile(r"(?:0|[1-9][0-9]*)\.[0-9]{2}")
GOODS_ID_RE = re.compile(r"[0-9]+")
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:
if not isinstance(value, str) or not value or len(value) > maximum:
raise ValidationError(reason)
if any(0xD800 <= ord(character) <= 0xDFFF for character in value):
raise ValidationError(reason)
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:
raise ValidationError(reason)
return text
def require_lower_hex_64(value: object, reason: str = "invalid_hex") -> str:
text = require_string(value, reason, maximum=64)
if LOWER_HEX_64_RE.fullmatch(text) is None:
raise ValidationError(reason)
return text
def require_rfc3339_z(value: object, reason: str = "invalid_timestamp") -> str:
text = require_string(value, reason, maximum=40)
if RFC3339_Z_RE.fullmatch(text) is None:
raise ValidationError(reason)
parsed: datetime | None = None
try:
parsed = datetime.fromisoformat(text[:-1] + "+00:00")
except ValueError:
pass
if parsed is None:
raise ValidationError(reason)
if parsed.utcoffset() is None or parsed.utcoffset().total_seconds() != 0:
raise ValidationError(reason)
return text
def rfc3339_z_nanoseconds(value: object, reason: str = "invalid_timestamp") -> int:
"""无浮点、无微秒截断地把 UTC RFC3339Nano 转成纳秒时间轴。"""
text = require_rfc3339_z(value, reason)
base: datetime | None = None
try:
base = datetime.strptime(text[:19], "%Y-%m-%dT%H:%M:%S").replace(tzinfo=timezone.utc)
except ValueError:
pass
if base is None:
raise ValidationError(reason)
fraction = "" if len(text) == 20 else text[20:-1]
nanoseconds = int(fraction.ljust(9, "0")) if fraction else 0
return calendar.timegm(base.utctimetuple()) * 1_000_000_000 + nanoseconds
def datetime_nanoseconds(value: datetime, reason: str = "invalid_timestamp") -> int:
if not isinstance(value, datetime) or value.utcoffset() is None:
raise ValidationError(reason)
utc = value.astimezone(timezone.utc)
return calendar.timegm(utc.utctimetuple()) * 1_000_000_000 + utc.microsecond * 1_000
def require_positive_int(value: object, reason: str = "invalid_integer") -> int:
# bool 是 int 的子类;wire 中必须显式拒绝 true/false。
if type(value) is not int or value <= 0 or value > 9_223_372_036_854_775_807:
raise ValidationError(reason)
return value
def require_money(value: object, reason: str = "invalid_money") -> str:
text = require_string(value, reason, maximum=MAX_MONEY_ASCII_CHARACTERS)
if MONEY_RE.fullmatch(text) is None or text == "0.00":
raise ValidationError(reason)
return text
def require_goods_id(value: object) -> str:
text = require_string(value, "invalid_goods_id", maximum=MAX_GOODS_ID_ASCII_CHARACTERS)
if GOODS_ID_RE.fullmatch(text) is None:
raise ValidationError("invalid_goods_id")
return text
def canonical_product_url(goods_id: str) -> str:
require_goods_id(goods_id)
return "https://mobile.yangkeduo.com/goods.html?goods_id=" + quote(goods_id, safe="")
def require_exact_fields(
value: object,
required: Iterable[str],
reason: str = "invalid_schema",
) -> Mapping[str, Any]:
if not isinstance(value, dict):
raise ValidationError(reason)
expected = frozenset(required)
if frozenset(value) != expected:
raise ValidationError(reason)
return value
def strict_json_loads(raw: bytes, *, maximum: int) -> object:
if not isinstance(raw, bytes) or len(raw) == 0 or len(raw) > maximum:
raise ValidationError("invalid_json_size")
text: str | None = None
try:
text = raw.decode("utf-8")
except UnicodeDecodeError:
pass
if text is None:
raise ValidationError("invalid_json_utf8")
if text.startswith("\ufeff"):
raise ValidationError("invalid_json_bom")
def pairs_hook(pairs: list[tuple[str, Any]]) -> dict[str, Any]:
result: dict[str, Any] = {}
for key, value in pairs:
if key in result:
raise ValidationError("duplicate_json_key")
result[key] = value
return result
def reject_number(_: str) -> object:
raise ValidationError("invalid_json_number")
def parse_integer(value: str) -> int:
digits = value[1:] if value.startswith("-") else value
if len(digits) > 19:
raise ValidationError("invalid_json_integer")
parsed = int(value)
if parsed < -9_223_372_036_854_775_808 or parsed > 9_223_372_036_854_775_807:
raise ValidationError("invalid_json_integer")
return parsed
parsed_json: object | None = None
failed = False
try:
parsed_json = json.loads(
text,
object_pairs_hook=pairs_hook,
parse_int=parse_integer,
parse_float=reject_number,
parse_constant=reject_number,
)
except ValidationError:
raise
except (json.JSONDecodeError, UnicodeError, ValueError, RecursionError):
failed = True
if failed:
raise ValidationError("invalid_json")
return parsed_json
@@ -0,0 +1,15 @@
"""设备连接与基线取证边界。
本包只提供显式设备选择、非敏感身份核验和本地基线采集;不包含任何采购页面或订单操作。
"""
from .adb import AdbClient, AdbDevice, CommandResult
from .baseline import BaselineCaptureResult, DeviceBaselineCapturer
__all__ = [
"AdbClient",
"AdbDevice",
"BaselineCaptureResult",
"CommandResult",
"DeviceBaselineCapturer",
]
+282
View File
@@ -0,0 +1,282 @@
"""ADB 设备清单与物理设备冲突的 fail-closed 边界。"""
from __future__ import annotations
from dataclasses import dataclass
from pathlib import Path
import subprocess
from typing import Protocol, Sequence
class DeviceConnectionError(RuntimeError):
"""显式设备连接边界的基础错误,不携带命令输出或设备敏感内容。"""
class SerialRequiredError(DeviceConnectionError):
"""调用方没有明确指定设备 serial。"""
class DeviceNotFoundError(DeviceConnectionError):
"""指定 serial 不在 ADB 当前清单中。"""
class DeviceOfflineError(DeviceConnectionError):
"""指定设备处于 offline 状态。"""
class DeviceUnauthorizedError(DeviceConnectionError):
"""指定设备尚未授权此电脑。"""
class DeviceStateError(DeviceConnectionError):
"""指定设备处于其他不可用状态。"""
class DeviceCommandTimeoutError(DeviceConnectionError):
"""ADB 命令超过调用方指定的超时。"""
class DeviceCommandError(DeviceConnectionError):
"""ADB 命令失败;错误文本刻意不回显设备输出。"""
class DeviceIdentityUnconfirmedError(DeviceConnectionError):
"""多条在线通道无法完成同机身份判断,必须由人处理。"""
class DuplicatePhysicalDeviceError(DeviceConnectionError):
"""同一物理手机通过多个 ADB 通道同时在线。"""
class IntentLaunchUnconfirmedError(DeviceConnectionError):
"""`am start -W` 没有给出可确认的启动成功结果。"""
@dataclass(frozen=True)
class CommandResult:
"""可注入命令执行器的最小、可离线构造结果。"""
stdout: str
stderr: str = ""
returncode: int = 0
@dataclass(frozen=True)
class IntentLaunchSummary:
"""不含 Activity、页面内容或 ADB 输出的受限启动摘要。"""
status: str
returncode: int
class CommandRunner(Protocol):
"""运行 ADB 子命令的可替换边界。"""
def run(self, arguments: Sequence[str], timeout_seconds: float) -> CommandResult:
"""运行参数,不得通过 shell 拼接。"""
class SubprocessAdbRunner:
"""使用 subprocess 的生产执行器,所有调用必须带超时。"""
def __init__(self, executable: str | Path = "adb") -> None:
self._executable = str(executable)
def run(self, arguments: Sequence[str], timeout_seconds: float) -> CommandResult:
try:
completed = subprocess.run(
[self._executable, *arguments],
check=False,
capture_output=True,
text=True,
encoding="utf-8",
errors="replace",
timeout=timeout_seconds,
)
except subprocess.TimeoutExpired as error:
raise DeviceCommandTimeoutError("ADB 命令超时,请检查设备连接后由人工重试。") from error
except OSError as error:
raise DeviceCommandError("无法启动 ADB,请检查 adb 路径与本机工具链。") from error
return CommandResult(
stdout=completed.stdout,
stderr=completed.stderr,
returncode=completed.returncode,
)
@dataclass(frozen=True)
class AdbDevice:
"""`adb devices -l` 的单行非敏感传输元数据。"""
serial: str
state: str
product: str | None = None
model: str | None = None
device: str | None = None
@dataclass(frozen=True)
class DeviceInspection:
"""选定通道的只读身份结果,原始硬件标识只在内存中参与比较。"""
device: AdbDevice
model: str
android_version: str
def parse_adb_devices(output: str) -> list[AdbDevice]:
"""解析 `adb devices -l`,忽略标题、空行和 adb 附加提示。"""
devices: list[AdbDevice] = []
for raw_line in output.splitlines():
line = raw_line.strip()
if not line or line.startswith("List of devices attached") or line.startswith("*"):
continue
fields = line.split()
if len(fields) < 2:
continue
details = {
key: value
for field in fields[2:]
if ":" in field
for key, value in [field.split(":", 1)]
}
devices.append(
AdbDevice(
serial=fields[0],
state=fields[1],
product=details.get("product"),
model=details.get("model"),
device=details.get("device"),
)
)
return devices
class AdbClient:
"""显式 serial 的 ADB 只读查询。
多个在线通道必须完成硬件身份比对。比对失败时不能用相同 model/product 猜测同一台手机,
因为那会把不确定性隐藏成错误的安全结论。
"""
def __init__(self, runner: CommandRunner, timeout_seconds: float = 10.0) -> None:
if timeout_seconds <= 0:
raise ValueError("timeout_seconds 必须大于 0")
self._runner = runner
self._timeout_seconds = timeout_seconds
def inspect(self, serial: str) -> DeviceInspection:
"""确认指定通道在线且不与另一在线通道指向同一物理设备。"""
selected_serial = _require_serial(serial)
devices = self.devices()
selected = next((device for device in devices if device.serial == selected_serial), None)
if selected is None:
raise DeviceNotFoundError("指定设备不在 ADB 清单中,请显式检查 serial。")
_raise_for_state(selected.state)
online_devices = [device for device in devices if device.state == "device"]
if len(online_devices) > 1:
identities: dict[str, frozenset[str]] = {}
for candidate in online_devices:
try:
identities[candidate.serial] = self._physical_identity(candidate)
except DeviceConnectionError as error:
raise DeviceIdentityUnconfirmedError(
"存在多个在线 ADB 通道且身份无法确认,已拒绝选择设备。"
) from error
selected_identity = identities[selected.serial]
if any(
candidate_serial != selected.serial and selected_identity.intersection(candidate_identity)
for candidate_serial, candidate_identity in identities.items()
):
raise DuplicatePhysicalDeviceError(
"同一物理手机的多个 ADB 通道同时在线,已拒绝继续;请仅保留一个通道。"
)
model = self._getprop(selected.serial, "ro.product.model") or selected.model or "unknown"
android_version = self._getprop(selected.serial, "ro.build.version.release") or "unknown"
return DeviceInspection(device=selected, model=model, android_version=android_version)
def devices(self) -> list[AdbDevice]:
"""读取并解析 ADB 设备清单。"""
result = self._run_checked(("devices", "-l"))
return parse_adb_devices(result.stdout)
def start_pdd_view_intent(self, serial: str, goods_id: str) -> IntentLaunchSummary:
"""以参数数组启动唯一允许的拼多多 ACTION_VIEW Intent。
这里刻意不提供任意 shell 或任意 package 的执行接口。调用方必须先完成
``inspect`` 和应用版本核验;本方法在本层从纯数字 ``goods_id`` 重建 URL,调用方不能
把另一个 URL 直接交给 ADB。本方法既不点击控件,也不解析 Activity 或页面文本。
"""
selected_serial = _require_serial(serial)
if (
not isinstance(goods_id, str)
or not goods_id
or any(character < "0" or character > "9" for character in goods_id)
):
raise ValueError("goods_id 必须是纯数字")
canonical_url = f"https://mobile.yangkeduo.com/goods.html?goods_id={goods_id}"
result = self._run_checked(
(
"-s",
selected_serial,
"shell",
"am",
"start",
"-W",
"-a",
"android.intent.action.VIEW",
"-d",
canonical_url,
"-p",
"com.xunmeng.pinduoduo",
)
)
if not any(line.strip() == "Status: ok" for line in result.stdout.splitlines()):
raise IntentLaunchUnconfirmedError("商品链接启动结果无法确认,已停止后续取证。")
return IntentLaunchSummary(status="ok", returncode=result.returncode)
def _physical_identity(self, device: AdbDevice) -> frozenset[str]:
serialno = self._getprop(device.serial, "ro.serialno")
boot_serialno = self._getprop(device.serial, "ro.boot.serialno")
identifiers = frozenset(value for value in (serialno, boot_serialno) if value)
if identifiers:
return identifiers
# model/product/device 只能作为展示元数据,不能证明两台同型号设备是同一物理机。
raise DeviceIdentityUnconfirmedError("无法读取设备硬件身份摘要。")
def _getprop(self, serial: str, property_name: str) -> str:
result = self._run_checked(("-s", serial, "shell", "getprop", property_name))
return result.stdout.strip()
def _run_checked(self, arguments: Sequence[str]) -> CommandResult:
try:
result = self._runner.run(arguments, self._timeout_seconds)
except subprocess.TimeoutExpired as error:
raise DeviceCommandTimeoutError("ADB 命令超时,请检查设备连接后由人工重试。") from error
if result.returncode != 0:
raise DeviceCommandError("ADB 命令失败,请检查设备连接或授权状态。")
return result
def _require_serial(serial: str) -> str:
if not isinstance(serial, str) or not serial.strip():
raise SerialRequiredError("必须显式提供设备 serial,禁止自动选择设备。")
return serial.strip()
def _raise_for_state(state: str) -> None:
if state == "device":
return
if state == "offline":
raise DeviceOfflineError("指定设备处于 offline 状态。")
if state == "unauthorized":
raise DeviceUnauthorizedError("指定设备尚未授权此电脑。")
raise DeviceStateError("指定设备不处于可用状态。")
@@ -0,0 +1,238 @@
"""只读设备基线取证,严格限制在元数据、截图和完整节点树。"""
from __future__ import annotations
from collections.abc import Callable
from dataclasses import dataclass
from datetime import UTC, datetime
from hashlib import sha256
import base64
import binascii
from io import BytesIO
import json
import os
from pathlib import Path
import shutil
from typing import Any, Protocol
from uuid import uuid4
from xml.etree import ElementTree
from adbutils.errors import AdbTimeout
from PIL import Image, UnidentifiedImageError
from uiautomator2.exceptions import HTTPTimeoutError
from .adb import AdbClient, DeviceInspection
PDD_PACKAGE = "com.xunmeng.pinduoduo"
SCREENSHOT_PARAMS = [1, 80]
HIERARCHY_PARAMS = [False, 50]
_BASE64_ASCII_WHITESPACE = " \t\r\n"
class BaselineCaptureError(RuntimeError):
"""基线取证无法完整落盘时的失败,不会伪造成功产物。"""
class BaselineCaptureTimeoutError(BaselineCaptureError):
"""设备基线取证超时;底层异常文本不向 CLI 或日志泄露。"""
class UiAutomatorDevice(Protocol):
"""本任务唯一需要的 uiautomator2 只读能力。"""
def app_info(self, package_name: str) -> dict[str, Any]:
"""读取已安装应用元数据。"""
def jsonrpc_call(self, method: str, params: Any = None, timeout: float = 10) -> Any:
"""调用公开 uiautomator2 JSON-RPC 接口。"""
class NoReconnectUiautomatorConnector:
"""只把当前 ADB server 已列出的设备对象交给 uiautomator2。
uiautomator2 直接接收 IP serial 时会在内部尝试 adb disconnect/connect。这里先从已列出设备中
取对象再调用 ``u2.connect(device_object)``,避免连接阶段隐式重连已经掉线的 WiFi 通道。
"""
def __init__(self, list_devices: Callable[[], list[Any]], connect: Callable[[Any], UiAutomatorDevice]) -> None:
self._list_devices = list_devices
self._connect = connect
def __call__(self, serial: str) -> UiAutomatorDevice:
device = next((item for item in self._list_devices() if item.serial == serial), None)
if device is None:
raise BaselineCaptureError("设备在连接前已从 ADB 清单消失,已拒绝自动重连。")
return self._connect(device)
@dataclass(frozen=True)
class BaselineCaptureResult:
"""已原子发布的基线取证摘要,不包含页面正文或原始 serial。"""
output_directory: Path
manifest_path: Path
screenshot_path: Path
hierarchy_path: Path
class DeviceBaselineCapturer:
"""以先校验通道、后连接、最后原子发布的顺序采集基线。
截图和 XML 可能包含页面敏感内容,因此仅落在调用方明确指定的本地目录;manifest 只写
哈希、设备非敏感元数据和脱敏后的 serial 摘要,绝不嵌入 XML 或页面文本。
"""
def __init__(
self,
adb_client: AdbClient,
connector: Callable[[str], UiAutomatorDevice],
timeout_seconds: float,
) -> None:
if timeout_seconds <= 0:
raise ValueError("timeout_seconds 必须大于 0")
self._adb_client = adb_client
self._connector = connector
self._timeout_seconds = timeout_seconds
def capture(self, serial: str, output_directory: Path) -> BaselineCaptureResult:
"""采集完整基线,任何一步失败均不发布 output_directory。"""
inspection = self._adb_client.inspect(serial)
target = Path(output_directory)
if target.exists():
raise BaselineCaptureError("输出目录已存在;为防止混入旧证据,拒绝覆盖。")
if not target.name:
raise BaselineCaptureError("输出目录必须是明确的新目录。")
target.parent.mkdir(parents=True, exist_ok=True)
staging = target.parent / f".{target.name}.staging-{uuid4().hex}"
staging.mkdir()
try:
device = self._connector(serial)
app_info = device.app_info(PDD_PACKAGE)
version = _extract_version(app_info)
screenshot_path = staging / "screenshot.png"
screenshot_base64 = device.jsonrpc_call(
"takeScreenshot",
SCREENSHOT_PARAMS,
timeout=self._timeout_seconds,
)
_save_base64_screenshot(screenshot_base64, screenshot_path)
hierarchy = device.jsonrpc_call(
"dumpWindowHierarchy",
HIERARCHY_PARAMS,
timeout=self._timeout_seconds,
)
_validate_hierarchy(hierarchy)
hierarchy_path = staging / "hierarchy.xml"
hierarchy_path.write_text(hierarchy, encoding="utf-8")
manifest_path = staging / "manifest.json"
manifest_path.write_text(
json.dumps(
_manifest(inspection, serial, version, screenshot_path, hierarchy_path),
ensure_ascii=False,
indent=2,
sort_keys=True,
)
+ "\n",
encoding="utf-8",
)
os.replace(staging, target)
except BaselineCaptureError:
# 仅删除本次创建、名称带随机标识的暂存目录,绝不触碰调用方原有输出目录。
if staging.exists():
shutil.rmtree(staging)
raise
except (AdbTimeout, HTTPTimeoutError, TimeoutError) as error:
if staging.exists():
shutil.rmtree(staging)
raise BaselineCaptureTimeoutError("设备基线取证超时,未发布任何证据产物。") from error
except Exception as error:
if staging.exists():
shutil.rmtree(staging)
# uiautomator2/adbutils 可能把 serial、路径或远端响应放入异常文本,不能直接传播到 CLI。
raise BaselineCaptureError("设备基线取证未完成,未发布任何证据产物。") from error
return BaselineCaptureResult(
output_directory=target,
manifest_path=target / "manifest.json",
screenshot_path=target / "screenshot.png",
hierarchy_path=target / "hierarchy.xml",
)
def _extract_version(app_info: dict[str, Any]) -> str:
version = app_info.get("versionName") or app_info.get("version_name")
if not isinstance(version, str) or not version.strip():
raise BaselineCaptureError("无法读取拼多多版本,拒绝发布不完整取证。")
return version.strip()
def _save_base64_screenshot(value: Any, target: Path) -> None:
"""规范化常见 ASCII Base64 空白后严格解码;没有 adb screenshot fallback。"""
if not isinstance(value, str) or not value:
raise BaselineCaptureError("截图 RPC 未返回 base64 数据,拒绝发布不完整取证。")
try:
normalized = value.translate({ord(character): None for character in _BASE64_ASCII_WHITESPACE})
raw_image = base64.b64decode(normalized.encode("ascii"), validate=True)
except (UnicodeEncodeError, ValueError, binascii.Error) as error:
raise BaselineCaptureError("截图 RPC Base64 语法无效,拒绝发布不完整取证。") from error
try:
with Image.open(BytesIO(raw_image)) as image:
image.load()
image.save(target, format="PNG")
except (UnidentifiedImageError, OSError) as error:
raise BaselineCaptureError("截图 RPC 图像数据无效,拒绝发布不完整取证。") from error
def _validate_hierarchy(value: Any) -> None:
"""确认 RPC 返回的是完整节点树,不把原始 XML 放进错误或日志。"""
if not isinstance(value, str) or not value:
raise BaselineCaptureError("节点树导出为空,拒绝发布不完整取证。")
try:
root = ElementTree.fromstring(value)
except ElementTree.ParseError as error:
raise BaselineCaptureError("节点树不是有效 XML,拒绝发布不完整取证。") from error
if root.tag != "hierarchy":
raise BaselineCaptureError("节点树根节点无效,拒绝发布不完整取证。")
def _manifest(
inspection: DeviceInspection,
serial: str,
pdd_version: str,
screenshot_path: Path,
hierarchy_path: Path,
) -> dict[str, Any]:
"""只序列化审计摘要;页面内容留在 XML 文件,不进入日志或 manifest。"""
return {
"schema_version": 1,
"captured_at": datetime.now(UTC).isoformat(),
"channel": "wifi" if ":" in serial else "usb",
"serial_sha256": sha256(serial.encode("utf-8")).hexdigest(),
"device": {
"model": inspection.model,
"android_version": inspection.android_version,
"pdd_package": PDD_PACKAGE,
"pdd_version": pdd_version,
},
"artifacts": [
{"path": screenshot_path.name, "sha256": _sha256_file(screenshot_path)},
{"path": hierarchy_path.name, "sha256": _sha256_file(hierarchy_path)},
],
}
def _sha256_file(path: Path) -> str:
digest = sha256()
with path.open("rb") as source:
for chunk in iter(lambda: source.read(1024 * 1024), b""):
digest.update(chunk)
return digest.hexdigest()
@@ -0,0 +1,580 @@
"""T-103 原始规格面板证据的本机确定性隐私脱敏。
此模块只处理人工采集的本地文件:不连接设备、不识别规格;仅可按已取证的固定
几何和严格格式,将跨隐私边界的价格叶节点投影到派生 XML。
"""
from __future__ import annotations
from dataclasses import dataclass
from hashlib import sha256
import json
from pathlib import Path
import re
import shutil
from typing import Any
from uuid import uuid4
from xml.etree import ElementTree
from PIL import Image, ImageDraw, UnidentifiedImageError
from ..pdd.product_url import ProductUrl, ProductUrlError, parse_product_url
from ..pdd.sku_panel_state import HUMAN_DECLARED_STATES
SANITIZER_VERSION = "t103-privacy-v5"
EXPECTED_GOODS_ID = "937122477375"
EXPECTED_PDD_VERSION = "8.17.0"
EXPECTED_DEVICE_MODEL = "PKG110"
EXPECTED_ANDROID_VERSION = "16"
EXPECTED_SCREENSHOT_WIDTH = 1080
EXPECTED_SCREENSHOT_HEIGHT = 2376
EXPECTED_XML_WIDTH = 1080
EXPECTED_XML_HEIGHT = 2376
_ARTIFACT_FILES = ("screenshot.png", "hierarchy.xml")
_SHA256_RE = re.compile(r"[0-9a-f]{64}\Z")
_BOUNDS_RE = re.compile(r"\[(-?\d+),(-?\d+)\]\[(-?\d+),(-?\d+)\]\Z")
_FULL_PHONE_RE = re.compile(r"(?:\+?86)?1[3-9]\d{9}")
_MASKED_PHONE_RE = re.compile(r"1[3-9]\d\*{4}\d{4}")
_MASK_TRANSLATION = str.maketrans({"*": "*", "•": "*", "·": "*", "×": "*", "x": "*", "X": "*"})
_SEPARATOR_RE = re.compile(r"[\s\-‐‑‒–—―()()]+")
# 这两个槽位来自 T-103 当前第一态、1080×2376 XML 坐标的人工审查。它们不是通用
# 页面判据;坐标、文本或结构任何变化都停止发布,交由人重新取证。
_CROSSING_PRICE_SLOTS = {
(396, 503, 712, 570): "[396,503][712,570]",
(730, 503, 895, 570): "[730,503][895,570]",
}
_CROSSING_PRICE_BOUNDS = frozenset(_CROSSING_PRICE_SLOTS)
_PRICE_PROJECTION_ATTRIBUTES = (
"bounds",
"text",
"package",
"class",
"clickable",
"enabled",
"visible-to-user",
)
# 仅接受普通 ASCII 空格,且每个可分隔位置最多一个;禁止换行、折扣、支付/提交文案和
# 任何其它字符。前缀捕获组用于区分当前价与至多一个划线/原价候选。
# T-103 人工在 live 规格面板确认当前价槽的完整非敏感前缀仅为“快卖完”;不得兼容
# 未取证的“快卖光”或其它相近文案。
_CROSSING_PRICE_TEXT_RE = re.compile(r" {0,1}(?:(快卖完) {0,1})?[¥¥] {0,1}[1-9]\d*\.\d{2} {0,1}\Z")
_CROSSING_PRICE_PREFIX_RE = re.compile(r" {0,1}(?:快卖完 {0,1})?[¥¥] {0,1}[1-9]\d*\.\d{2} {0,1}")
_CROSSING_PRICE_ALLOWED_CHARACTERS = frozenset(" 快卖完¥¥0123456789.")
class SkuEvidenceSanitizationError(RuntimeError):
"""原始证据不能被安全地发布为派生证据。"""
@dataclass
class _CleanupStats:
"""仅记录节点数量,供派生 manifest 审计;不记录任何页面文本。"""
removed_nodes: int = 0
cleared_crossing_nodes: int = 0
preserved_crossing_price_nodes: int = 0
retained_below_nodes: int = 0
max_right: int = 0
max_bottom: int = 0
current_price_candidates: int = 0
original_price_candidates: int = 0
@dataclass(frozen=True)
class PrivacyMaskConfig:
"""仅描述已人工确认的隐私几何区域,绝不承担页面或规格判据。"""
version: str
screenshot_width: int
screenshot_height: int
xml_width: int
xml_height: int
privacy_top: int
PRIVACY_MASK_CONFIG = PrivacyMaskConfig(
version=SANITIZER_VERSION,
screenshot_width=EXPECTED_SCREENSHOT_WIDTH,
screenshot_height=EXPECTED_SCREENSHOT_HEIGHT,
xml_width=EXPECTED_XML_WIDTH,
xml_height=EXPECTED_XML_HEIGHT,
# 主审在原始截图确认 y < 540 为收货/手机号区域;整宽遮罩优先保护隐私而非保留版面。
privacy_top=540,
)
@dataclass(frozen=True)
class SkuEvidenceSanitizationResult:
"""已经原子发布的派生证据位置。"""
output_directory: Path
manifest_path: Path
screenshot_path: Path
hierarchy_path: Path
def sanitize_sku_panel_evidence(raw_directory: Path, output_directory: Path) -> SkuEvidenceSanitizationResult:
"""校验 raw 三文件,并发布同级 ``derived`` 的脱敏副本。
目标已存在时在读取任何输入前拒绝,避免混入旧派生物或覆盖人工保留文件。
"""
raw = Path(raw_directory)
target = Path(output_directory)
_validate_directories(raw, target)
if target.exists():
raise SkuEvidenceSanitizationError("派生证据目录已存在,拒绝覆盖。")
staging: Path | None = None
try:
source_manifest_path = _required_file(raw, "manifest.json")
source_screenshot_path = _required_file(raw, "screenshot.png")
source_hierarchy_path = _required_file(raw, "hierarchy.xml")
manifest = _read_source_manifest(source_manifest_path)
link, state, source_hashes = _validate_source_manifest(manifest)
_verify_source_hashes(source_screenshot_path, source_hierarchy_path, source_hashes)
staging = raw.parent / f".derived.staging-{uuid4().hex}"
staging.mkdir()
derived_screenshot_path = staging / "screenshot.png"
_sanitize_screenshot(source_screenshot_path, derived_screenshot_path)
derived_hierarchy_path = staging / "hierarchy.xml"
cleanup_stats = _sanitize_hierarchy(source_hierarchy_path, derived_hierarchy_path)
derived_manifest_path = staging / "manifest.json"
derived_manifest_path.write_text(
json.dumps(
_derived_manifest(
manifest,
link,
state,
source_manifest_path,
source_screenshot_path,
source_hierarchy_path,
derived_screenshot_path,
derived_hierarchy_path,
cleanup_stats,
),
ensure_ascii=False,
indent=2,
sort_keys=True,
)
+ "\n",
encoding="utf-8",
)
_publish_staging(staging, target)
except SkuEvidenceSanitizationError:
_clean_staging(staging)
raise
except (OSError, ValueError, ElementTree.ParseError, UnidentifiedImageError) as error:
_clean_staging(staging)
# 原始异常可能含文件路径、JSON/XML 文本或其他敏感内容,不能向 CLI/日志传播。
raise SkuEvidenceSanitizationError("原始证据无法安全脱敏,未发布任何派生产物。") from error
except Exception as error:
_clean_staging(staging)
raise SkuEvidenceSanitizationError("原始证据脱敏未完成,未发布任何派生产物。") from error
return SkuEvidenceSanitizationResult(
output_directory=target,
manifest_path=target / "manifest.json",
screenshot_path=target / "screenshot.png",
hierarchy_path=target / "hierarchy.xml",
)
def _validate_directories(raw: Path, target: Path) -> None:
if raw.name != "raw" or not raw.is_dir():
raise SkuEvidenceSanitizationError("原始证据目录必须是存在的 raw 目录。")
if target.name != "derived" or target.parent != raw.parent:
raise SkuEvidenceSanitizationError("派生证据目录必须是 raw 同级的 derived 目录。")
def _required_file(raw: Path, filename: str) -> Path:
candidate = raw / filename
if not candidate.is_file():
raise SkuEvidenceSanitizationError("原始证据文件集合不完整。")
return candidate
def _read_source_manifest(path: Path) -> dict[str, Any]:
try:
value = json.loads(path.read_text(encoding="utf-8"))
except (OSError, UnicodeDecodeError, json.JSONDecodeError) as error:
raise SkuEvidenceSanitizationError("原始证据 manifest 无效。") from error
if not isinstance(value, dict):
raise SkuEvidenceSanitizationError("原始证据 manifest 结构无效。")
return value
def _validate_source_manifest(manifest: dict[str, Any]) -> tuple[ProductUrl, str, dict[str, str]]:
product = manifest.get("product")
device = manifest.get("device")
state = manifest.get("human_declared_state")
if manifest.get("schema_version") != 1 or not isinstance(product, dict) or not isinstance(device, dict):
raise SkuEvidenceSanitizationError("原始证据 manifest 缺少必要元数据。")
canonical_url = product.get("canonical_url")
goods_id = product.get("goods_id")
try:
link = parse_product_url(canonical_url)
except ProductUrlError as error:
raise SkuEvidenceSanitizationError("原始证据商品元数据不匹配。") from error
if (
link.goods_id != EXPECTED_GOODS_ID
or goods_id != EXPECTED_GOODS_ID
or device.get("model") != EXPECTED_DEVICE_MODEL
or device.get("android_version") != EXPECTED_ANDROID_VERSION
or device.get("pdd_version") != EXPECTED_PDD_VERSION
or device.get("pdd_package") != "com.xunmeng.pinduoduo"
or not isinstance(state, str)
or state not in HUMAN_DECLARED_STATES
):
raise SkuEvidenceSanitizationError("原始证据元数据与脱敏配置不匹配。")
return link, state, _artifact_hashes(manifest)
def _artifact_hashes(manifest: dict[str, Any]) -> dict[str, str]:
artifacts = manifest.get("artifacts")
if not isinstance(artifacts, list):
raise SkuEvidenceSanitizationError("原始证据 manifest 缺少文件校验信息。")
hashes: dict[str, str] = {}
for artifact in artifacts:
if not isinstance(artifact, dict):
raise SkuEvidenceSanitizationError("原始证据 manifest 文件校验信息无效。")
path = artifact.get("path")
digest = artifact.get("sha256")
if path not in _ARTIFACT_FILES or path in hashes or not isinstance(digest, str) or not _SHA256_RE.fullmatch(digest):
raise SkuEvidenceSanitizationError("原始证据 manifest 文件校验信息无效。")
hashes[path] = digest
if set(hashes) != set(_ARTIFACT_FILES):
raise SkuEvidenceSanitizationError("原始证据 manifest 文件校验信息不完整。")
return hashes
def _verify_source_hashes(screenshot_path: Path, hierarchy_path: Path, expected: dict[str, str]) -> None:
if (
_sha256_file(screenshot_path) != expected["screenshot.png"]
or _sha256_file(hierarchy_path) != expected["hierarchy.xml"]
):
raise SkuEvidenceSanitizationError("原始证据文件校验失败。")
def _sanitize_screenshot(source: Path, target: Path) -> None:
try:
with Image.open(source) as image:
image.load()
if image.format != "PNG" or image.size != (
PRIVACY_MASK_CONFIG.screenshot_width,
PRIVACY_MASK_CONFIG.screenshot_height,
):
raise SkuEvidenceSanitizationError("原始截图分辨率或格式与脱敏配置不匹配。")
sanitized = image.convert("RGBA")
except SkuEvidenceSanitizationError:
raise
except (OSError, UnidentifiedImageError) as error:
raise SkuEvidenceSanitizationError("原始截图无效。") from error
# 用不透明黑色覆盖 y < 540,保证截图与 XML 使用相同的隐私几何边界。
ImageDraw.Draw(sanitized).rectangle(
(0, 0, PRIVACY_MASK_CONFIG.screenshot_width - 1, PRIVACY_MASK_CONFIG.privacy_top - 1),
fill=(0, 0, 0, 255),
)
sanitized.save(target, format="PNG", optimize=False, compress_level=9)
def _sanitize_hierarchy(source: Path, target: Path) -> _CleanupStats:
try:
root = ElementTree.parse(source).getroot()
except (OSError, ElementTree.ParseError) as error:
raise SkuEvidenceSanitizationError("原始节点树无效。") from error
if root.tag != "hierarchy":
raise SkuEvidenceSanitizationError("原始节点树结构不匹配。")
if not list(root):
raise SkuEvidenceSanitizationError("原始节点树结构不匹配。")
stats = _CleanupStats()
_clear_node_text(root)
for child in list(root):
_sanitize_node(root, child, stats)
_require_expected_xml_coordinate_space(stats)
if stats.removed_nodes < 1 or stats.retained_below_nodes < 1:
raise SkuEvidenceSanitizationError("原始节点树未满足隐私几何结构。")
_require_safe_crossing_price_projection(stats)
if _contains_phone(root):
raise SkuEvidenceSanitizationError("派生节点树仍包含手机号,拒绝发布。")
ElementTree.ElementTree(root).write(target, encoding="utf-8", xml_declaration=True)
return stats
def _sanitize_node(parent: ElementTree.Element, node: ElementTree.Element, stats: _CleanupStats) -> None:
if node.tag != "node":
raise SkuEvidenceSanitizationError("原始节点树结构不匹配。")
bounds = _parse_bounds(node.get("bounds"))
_observe_bounds(stats, bounds)
position = _vertical_position(bounds)
if position == "private":
# 私有带内的父节点不可以悄然包含下方子节点,否则会把仍需审计的下方内容一起丢失。
for descendant in node.iter("node"):
descendant_bounds = _parse_bounds(descendant.get("bounds"))
_observe_bounds(stats, descendant_bounds)
if _vertical_position(descendant_bounds) != "private":
raise SkuEvidenceSanitizationError("原始节点树 bounds 结构不匹配。")
stats.removed_nodes += sum(1 for _ in node.iter("node"))
parent.remove(node)
return
if position == "crossing":
if bounds in _CROSSING_PRICE_BOUNDS and node.get("text"):
_project_crossing_price_node(node, bounds, stats)
else:
# 全屏/跨界容器可保留其下方子节点,但自身所有属性和文本都可能含地址或手机号。
_clear_node_text(node)
stats.cleared_crossing_nodes += 1
else:
stats.retained_below_nodes += 1
for child in list(node):
_sanitize_node(node, child, stats)
def _parse_bounds(value: object) -> tuple[int, int, int, int]:
if not isinstance(value, str):
raise SkuEvidenceSanitizationError("原始节点树 bounds 缺失或无效。")
match = _BOUNDS_RE.fullmatch(value)
if match is None:
raise SkuEvidenceSanitizationError("原始节点树 bounds 缺失或无效。")
left, top, right, bottom = (int(group) for group in match.groups())
if not (0 <= left < right and 0 <= top < bottom):
raise SkuEvidenceSanitizationError("原始节点树 bounds 缺失或无效。")
return left, top, right, bottom
def _observe_bounds(stats: _CleanupStats, bounds: tuple[int, int, int, int]) -> None:
_, _, right, bottom = bounds
stats.max_right = max(stats.max_right, right)
stats.max_bottom = max(stats.max_bottom, bottom)
def _require_expected_xml_coordinate_space(stats: _CleanupStats) -> None:
if (
stats.max_right != PRIVACY_MASK_CONFIG.xml_width
or stats.max_bottom != PRIVACY_MASK_CONFIG.xml_height
):
raise SkuEvidenceSanitizationError(
f"原始节点树坐标范围不匹配(observed {stats.max_right}x{stats.max_bottom})。"
)
def _vertical_position(bounds: tuple[int, int, int, int]) -> str:
_, top, _, bottom = bounds
if bottom <= PRIVACY_MASK_CONFIG.privacy_top:
return "private"
if top >= PRIVACY_MASK_CONFIG.privacy_top:
return "below"
return "crossing"
def _project_crossing_price_node(
node: ElementTree.Element,
bounds: tuple[int, int, int, int],
stats: _CleanupStats,
) -> None:
"""投影唯一允许的跨界价格叶节点;任何结构漂移一律拒绝发布。"""
if (
len(node) != 0
or node.get("package") != "com.xunmeng.pinduoduo"
or node.get("class") != "android.widget.TextView"
or node.get("clickable") != "false"
or node.get("enabled") != "true"
or node.get("visible-to-user") != "true"
):
raise SkuEvidenceSanitizationError("跨界价格节点结构不匹配,拒绝发布。")
text = node.get("text")
if text is None:
raise SkuEvidenceSanitizationError("跨界价格节点文本不匹配,拒绝发布。")
match = _CROSSING_PRICE_TEXT_RE.fullmatch(text)
if match is None:
raise _crossing_price_text_mismatch_error(bounds, text)
# 只有这七项经上述检查后可进入派生 XML;尤其不复制 content-desc、resource-id 等原始属性。
node.attrib = {attribute: node.attrib[attribute] for attribute in _PRICE_PROJECTION_ATTRIBUTES}
node.text = None
node.tail = None
stats.preserved_crossing_price_nodes += 1
if match.group(1) is not None:
stats.current_price_candidates += 1
else:
stats.original_price_candidates += 1
def _crossing_price_text_mismatch_error(
bounds: tuple[int, int, int, int],
text: str,
) -> SkuEvidenceSanitizationError:
"""仅输出固定槽位与 reason,避免将任意 raw 正文带入 CLI 或日志。"""
reason = _crossing_price_text_mismatch_reason(text)
slot = _CROSSING_PRICE_SLOTS[bounds]
return SkuEvidenceSanitizationError(f"跨界价格节点文本不匹配:slot={slot};reason={reason}。")
def _crossing_price_text_mismatch_reason(text: str) -> str:
"""将未匹配文本归类为受控枚举;返回值绝不包含原始片段。"""
if "\r" in text or "\n" in text:
return "newline"
if any(character.isspace() and character != " " for character in text):
return "non_ascii_whitespace"
if any(marker in text for marker in ("提交订单", "支付", "下单", "优惠")):
return "extra_or_order"
without_one_leading_space = text[1:] if text.startswith(" ") else text
if without_one_leading_space.startswith("快要抢光"):
return "observed_prefix_kuaiyaoqiangguang"
if without_one_leading_space.startswith("快") and not without_one_leading_space.startswith("快卖完"):
return "known_prefix_missing"
if "¥" not in text and "¥" not in text:
return "currency_missing"
if _CROSSING_PRICE_PREFIX_RE.match(text) is not None:
return "extra_or_order"
if any(character not in _CROSSING_PRICE_ALLOWED_CHARACTERS for character in text):
return "forbidden_characters"
return "amount_shape"
def _require_safe_crossing_price_projection(stats: _CleanupStats) -> None:
"""当前价必须唯一;原价仅可选且唯一,避免把任意金额释放为价格证据。"""
if (
stats.current_price_candidates != 1
or stats.original_price_candidates > 1
or stats.preserved_crossing_price_nodes != stats.current_price_candidates + stats.original_price_candidates
):
raise SkuEvidenceSanitizationError("跨界价格候选不唯一或缺失,拒绝发布。")
def _clear_node_text(node: ElementTree.Element) -> None:
node.attrib = {"bounds": node.attrib["bounds"]} if "bounds" in node.attrib else {}
node.text = None
node.tail = None
def _contains_phone(root: ElementTree.Element) -> bool:
"""逐项与跨节点复检电话,避免分隔符、遮罩字符或节点切分绕过。"""
all_values: list[str] = []
content_values: list[str] = []
for element in root.iter():
if element.text:
all_values.append(element.text)
content_values.append(element.text)
for attribute, value in element.attrib.items():
all_values.append(value)
if attribute != "bounds":
content_values.append(value)
if element.tail:
all_values.append(element.tail)
content_values.append(element.tail)
normalized_values = [_normalize_phone_value(value) for value in all_values]
normalized_all_document = "".join(normalized_values)
normalized_document = "".join(_normalize_phone_value(value) for value in content_values)
return (
any(_matches_phone(value) for value in normalized_values)
or _matches_phone(normalized_all_document)
or _matches_phone(normalized_document)
)
def _normalize_phone_value(value: str) -> str:
return _SEPARATOR_RE.sub("", value.translate(_MASK_TRANSLATION))
def _matches_phone(value: str) -> bool:
return _FULL_PHONE_RE.search(value) is not None or _MASKED_PHONE_RE.search(value) is not None
def _derived_manifest(
source_manifest: dict[str, Any],
link: ProductUrl,
state: str,
source_manifest_path: Path,
source_screenshot_path: Path,
source_hierarchy_path: Path,
derived_screenshot_path: Path,
derived_hierarchy_path: Path,
cleanup_stats: _CleanupStats,
) -> dict[str, Any]:
device = source_manifest["device"]
return {
"schema_version": 1,
"privacy_tier": "SANITIZED",
"sanitizer_version": PRIVACY_MASK_CONFIG.version,
"screenshot_space": {
"width": PRIVACY_MASK_CONFIG.screenshot_width,
"height": PRIVACY_MASK_CONFIG.screenshot_height,
"privacy_mask_rectangle": [
0,
0,
PRIVACY_MASK_CONFIG.screenshot_width,
PRIVACY_MASK_CONFIG.privacy_top,
],
},
"xml_coordinate_space": {
"width": PRIVACY_MASK_CONFIG.xml_width,
"height": PRIVACY_MASK_CONFIG.xml_height,
"privacy_mask_rectangle": [0, 0, PRIVACY_MASK_CONFIG.xml_width, PRIVACY_MASK_CONFIG.privacy_top],
"observed_max": {"right": cleanup_stats.max_right, "bottom": cleanup_stats.max_bottom},
},
"privacy_cleanup": {
"removed_nodes": cleanup_stats.removed_nodes,
"cleared_crossing_nodes": cleanup_stats.cleared_crossing_nodes,
"preserved_crossing_price_nodes": cleanup_stats.preserved_crossing_price_nodes,
"retained_below_nodes": cleanup_stats.retained_below_nodes,
"max_right": cleanup_stats.max_right,
"max_bottom": cleanup_stats.max_bottom,
},
"product": {"goods_id": link.goods_id},
"human_declared_state": state,
"device": {
"model": device["model"],
"android_version": device.get("android_version"),
"pdd_package": device["pdd_package"],
"pdd_version": device["pdd_version"],
},
# source hashes stay only in the local derived manifest; no raw path, serial or body is retained.
"source": {
"manifest_sha256": _sha256_file(source_manifest_path),
"artifacts": [
{"path": "screenshot.png", "sha256": _sha256_file(source_screenshot_path)},
{"path": "hierarchy.xml", "sha256": _sha256_file(source_hierarchy_path)},
],
},
"derived": {
"artifacts": [
{"path": "screenshot.png", "sha256": _sha256_file(derived_screenshot_path)},
{"path": "hierarchy.xml", "sha256": _sha256_file(derived_hierarchy_path)},
]
},
}
def _sha256_file(path: Path) -> str:
digest = sha256()
with path.open("rb") as source:
for chunk in iter(lambda: source.read(1024 * 1024), b""):
digest.update(chunk)
return digest.hexdigest()
def _clean_staging(staging: Path | None) -> None:
if staging is not None and staging.exists():
shutil.rmtree(staging)
def _publish_staging(staging: Path, target: Path) -> None:
"""发布前二次检查,并使用目录 rename 而不是会覆盖目标的 replace。"""
if target.exists():
raise SkuEvidenceSanitizationError("派生证据目录已存在,拒绝覆盖。")
try:
staging.rename(target)
except OSError as error:
# 竞态中新目标出现或文件系统拒绝 rename 时一律不尝试覆盖或重试。
raise SkuEvidenceSanitizationError("派生证据目录发布失败,未覆盖已有目录。") from error
@@ -0,0 +1,18 @@
"""Windows 本地恢复、凭据保护和单实例底座。"""
from .models import PollingSession, ProfileSettings, RecoverySnapshot
from .facade import DurableClientGateway
from .protection import DpapiProtector, SecretProtector
from .single_instance import NamedMutex
from .store import LocalStateStore
__all__ = [
"DpapiProtector",
"DurableClientGateway",
"LocalStateStore",
"NamedMutex",
"PollingSession",
"ProfileSettings",
"RecoverySnapshot",
"SecretProtector",
]
@@ -0,0 +1,82 @@
"""把“先持久化,再发一次 HTTP”固化成 T-304/T-306 的唯一集成入口。"""
from __future__ import annotations
from cmbuyer_client.core.errors import (
AmbiguousRemoteError,
CredentialRemoteError,
ManualRemoteError,
ProtocolRemoteError,
)
from cmbuyer_client.core.models import AssetReceipt, ClaimedTask, ScreenshotAsset
from cmbuyer_client.core.ports import EvidenceSink, TaskSource
from .store import LocalStateStore
class DurableClientGateway:
"""不隐藏重试;每次方法调用最多发一次请求,结果不明保留原槽。"""
def __init__(self, store: LocalStateStore, task_source: TaskSource, evidence_sink: EvidenceSink) -> None:
self._store = store
self._task_source = task_source
self._evidence_sink = evidence_sink
def claim_next(self, profile_id: str) -> ClaimedTask | None:
request = self._store.prepare_claim(profile_id)
credentials = self._store.load_profile(profile_id).credentials
try:
claimed = self._task_source.claim_next(credentials, request)
except AmbiguousRemoteError:
raise
except CredentialRemoteError:
raise
except ProtocolRemoteError:
self._store.mark_claim_terminal(profile_id, request, "PROTOCOL")
raise
except ManualRemoteError:
self._store.mark_claim_terminal(profile_id, request, "MANUAL")
raise
if claimed is None:
self._store.commit_claim_empty(profile_id, request)
return None
self._store.commit_claim_success(profile_id, request, claimed)
return claimed
def renew(self, profile_id: str):
request = self._store.prepare_renew(profile_id)
credentials = self._store.load_profile(profile_id).credentials
try:
result = self._task_source.renew(credentials, request)
except AmbiguousRemoteError:
raise
except CredentialRemoteError:
raise
except ProtocolRemoteError:
self._store.mark_renew_terminal(profile_id, request, "PROTOCOL")
raise
except ManualRemoteError:
self._store.mark_renew_terminal(profile_id, request, "MANUAL")
raise
self._store.commit_renew_success(profile_id, request, result)
return result
def upload_evidence(self, profile_id: str, asset: ScreenshotAsset) -> AssetReceipt:
prepared = self._store.prepare_or_resume_evidence(profile_id, asset)
if isinstance(prepared, AssetReceipt):
return prepared
credentials = self._store.load_profile(profile_id).credentials
try:
receipt = self._evidence_sink.upload(credentials, prepared)
except AmbiguousRemoteError:
raise
except CredentialRemoteError:
raise
except ProtocolRemoteError:
self._store.mark_evidence_terminal(profile_id, prepared, "PROTOCOL")
raise
except ManualRemoteError:
self._store.mark_evidence_terminal(profile_id, prepared, "MANUAL")
raise
self._store.commit_evidence_success(profile_id, prepared, receipt)
return receipt
@@ -0,0 +1,98 @@
"""供 T-304 使用的稳定本地配置与恢复快照。"""
from __future__ import annotations
from dataclasses import dataclass, field
import re
from cmbuyer_client.core.models import ClaimRequest, ClaimedTask, DeviceCredentials, RenewRequest
from cmbuyer_client.core.validation import require_string, require_uuid4
LOOPBACK_SERVICE_URL = "http://127.0.0.1:8080"
PROFILE_ID_RE = re.compile(r"[a-z0-9][a-z0-9_-]{0,63}")
@dataclass(frozen=True)
class ProfileSettings:
profile_id: str
service_url: str
device_id: str
adb_path: str
adb_serial: str
transport: str
poll_interval_seconds: int = 15
failure_threshold: int = 3
http_timeout_seconds: int = 10
step_timeout_seconds: int = 45
def __post_init__(self) -> None:
if not isinstance(self.profile_id, str) or PROFILE_ID_RE.fullmatch(self.profile_id) is None:
raise ValueError("invalid_profile_id")
if self.service_url != LOOPBACK_SERVICE_URL:
raise ValueError("service_url_not_allowed")
require_uuid4(self.device_id, "invalid_device_id")
require_string(self.adb_path, "invalid_adb_path", maximum=1024)
require_string(self.adb_serial, "invalid_adb_serial", maximum=200)
if self.transport not in ("usb", "wifi"):
raise ValueError("invalid_transport")
_range(self.poll_interval_seconds, 5, 300, "invalid_poll_interval")
_range(self.failure_threshold, 1, 10, "invalid_failure_threshold")
_range(self.http_timeout_seconds, 1, 120, "invalid_http_timeout")
_range(self.step_timeout_seconds, 5, 300, "invalid_step_timeout")
@dataclass(frozen=True, repr=False)
class LoadedProfile:
settings: ProfileSettings
credentials: DeviceCredentials = field(repr=False)
def __repr__(self) -> str:
return f"LoadedProfile(settings={self.settings!r}, credentials=[已隐藏])"
@dataclass(frozen=True)
class ProfileSummary:
"""不解密、不返回任何 token 数据的配置页只读摘要。"""
settings: ProfileSettings
has_stored_device_token: bool
def __post_init__(self) -> None:
if type(self.has_stored_device_token) is not bool:
raise ValueError("invalid_token_presence")
@dataclass(frozen=True)
class PollingSession:
profile_id: str
session_id: str
accept_new: bool
def __post_init__(self) -> None:
require_uuid4(self.session_id, "invalid_session_id")
if type(self.accept_new) is not bool:
raise ValueError("invalid_accept_new")
@dataclass(frozen=True)
class PendingEvidence:
task_id: str
attempt_id: str
kind: str
upload_key: str
status: str
@dataclass(frozen=True)
class RecoverySnapshot:
session: PollingSession | None
pending_claim: ClaimRequest | None
active_claim: ClaimedTask | None = field(repr=False)
pending_renew: RenewRequest | None = field(repr=False)
pending_evidence: tuple[PendingEvidence, ...]
def _range(value: object, minimum: int, maximum: int, reason: str) -> None:
if type(value) is not int or not minimum <= value <= maximum:
raise ValueError(reason)

Some files were not shown because too many files have changed in this diff Show More