Compare commits
633 Commits
57de9a8df6
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
| 6984af782c | |||
| 9ab87956f8 | |||
| 2dcdd90c37 | |||
| 15a527d7f4 | |||
| 264453a714 | |||
| 767da0164a | |||
| a51e63415f | |||
|
|
bdf99468e7 | ||
| 5a1c1c88dd | |||
| 38babc592d | |||
| 660abff773 | |||
| cd49cb0106 | |||
| b7fd36747d | |||
| 951caac32d | |||
| 3bccfd1a83 | |||
| fe30396544 | |||
| 93e4522b65 | |||
| 2251fa2f8e | |||
| 4a6c126366 | |||
| 69815d918a | |||
| f87ae3f915 | |||
| cfc0c0eadf | |||
|
|
21add3dcbc | ||
|
|
1dd58a3d66 | ||
| 9d1dd493a6 | |||
| f7404a07ef | |||
| b54cbafc54 | |||
|
|
d78c11d5b7 | ||
| ed2c386603 | |||
| c5763fdf25 | |||
| 59facaf14b | |||
| 291ab06a5b | |||
| 9b40f6b453 | |||
| a20d7b1655 | |||
| ffcffef6d2 | |||
| f8a80cd795 | |||
|
|
86ea69f79d | ||
|
|
077b139e80 | ||
| 7c47ad3358 | |||
| d08842b576 | |||
| ef6a2b7200 | |||
| ebd958b5a0 | |||
| 193e4f0e96 | |||
| ce13bdbb02 | |||
| 0d9259b762 | |||
| 1b89611c9a | |||
| a8012109ae | |||
| 7f8400fd3a | |||
| 7431b1b9a4 | |||
| fe951a8819 | |||
| 58a3c6eb97 | |||
| 051fd6156c | |||
| 22c6edb7c2 | |||
| de0f0c1399 | |||
| 54ff839b0b | |||
| ae1a15cee0 | |||
| 84ded19f11 | |||
| ab5a0efe50 | |||
| 60ef4ead71 | |||
| 02f4982cfe | |||
| 01c0028b87 | |||
| 402b847c7f | |||
| 06bf03a28c | |||
| 0a4ccdf45c | |||
| 94122583ac | |||
| edf37d97a7 | |||
| 6163350f5c | |||
| 7005792580 | |||
| 4ef805282d | |||
| 40ff21f493 | |||
| 7dd53e95d8 | |||
| dbfdb9b99b | |||
| a33914aa5a | |||
| 31ad55b0cf | |||
| a076faf652 | |||
| 547e771f75 | |||
| f54c3ee936 | |||
| 914b74ce8e | |||
| 4e3378be65 | |||
| 08339b410b | |||
| c975d41d46 | |||
| 17cf65a1a3 | |||
| 1841eac0e1 | |||
| 2af0916c04 | |||
| fdc3b5f440 | |||
| f5536a14e6 | |||
| 488c69aecc | |||
| 701fd42777 | |||
| ba5f84d963 | |||
| 9a04ea55dc | |||
| 9141540c37 | |||
| cb01d33944 | |||
| f336352d37 | |||
| eb73ffb34d | |||
| 7411b9a435 | |||
| ebf181d53b | |||
| 53abf94635 | |||
| 1e6ecf2ea9 | |||
| fface53745 | |||
| e612b13b88 | |||
| e29992cf01 | |||
| 694b2fa209 | |||
| 9b36903021 | |||
| e019ece907 | |||
| 876fd37ce4 | |||
| eff95886ad | |||
| b9ddc07e0a | |||
| 71690c3aaa | |||
| 448b0697ee | |||
| 11193112b6 | |||
| 0db4f5a6df | |||
| 3e194c647e | |||
| 7977a19d24 | |||
| 345ace1842 | |||
| da8f2e3624 | |||
| e22cb1d06b | |||
| d5b3133c8d | |||
| 25a30a1111 | |||
| d7002929c3 | |||
| 6841b686d9 | |||
| 2bc76f0558 | |||
| 3f7748983c | |||
| 979ab10ea2 | |||
| 8157e656fa | |||
| d59654f277 | |||
| 1df16377ce | |||
| 2a9514a975 | |||
| 66f31db968 | |||
| 66b73f5a1d | |||
| c1905d52ac | |||
| 0f5e0114a9 | |||
| 930f43661b | |||
| b507302fdb | |||
| 076970828a | |||
| 7fcaf346c7 | |||
| 9389f9401f | |||
|
|
9db467d23f | ||
| b601b3b57e | |||
|
|
7aafb37f04 | ||
| 0baad9e022 | |||
| 43c66d31a2 | |||
| 1ad25e30f8 | |||
| 727aa8d353 | |||
|
|
6abb30c2ac | ||
| c3fb8f364c | |||
| 6e107200bb | |||
| 2d30fd808d | |||
|
|
585a5638db | ||
| 1e0577468e | |||
|
|
cd55eff12c | ||
|
|
68dd48be42 | ||
|
|
4ed9711b76 | ||
| 4f86c1a75b | |||
| 7eae91d7d3 | |||
|
|
facbb2074c | ||
|
|
568509027c | ||
| a0473771f1 | |||
| c9c66f046b | |||
| f69affec95 | |||
| 1dcba515b2 | |||
|
|
088470a315 | ||
|
|
11c5e3edf4 | ||
| a4ee6ff698 | |||
| 6f242a290a | |||
| 52c6f4282f | |||
| f4eee2d585 | |||
|
|
ccb5023197 | ||
| 5ea9f0a120 | |||
|
|
3ca5a460f1 | ||
| 8f991a4ac2 | |||
|
|
59b5bd1f83 | ||
|
|
3a918687c5 | ||
|
|
38d9c292ae | ||
| 49e4d085b3 | |||
| ff2ed5a59d | |||
| 498f7c9a3d | |||
| 17662916cd | |||
| e4b13d73b5 | |||
| 2a6da01307 | |||
| 7719c7e5a8 | |||
| d3a3238028 | |||
| decded991e | |||
| 48c7cce1ba | |||
| cc84656a1f | |||
| a5143fa0cb | |||
| 665f09f047 | |||
| 56a9075582 | |||
| ea4706daa6 | |||
| 78791af424 | |||
| 8dca8a6443 | |||
| c810e255a5 | |||
| 3965f34b02 | |||
| 63444d047f | |||
| 8131894bb5 | |||
| 8f460feb41 | |||
| e56a25243c | |||
| 9e1549151d | |||
| c344daba19 | |||
| f5cefc8d5f | |||
| 48ef19d518 | |||
| 4bb6d0bd1e | |||
|
|
e3ecac85f3 | ||
| ce930ee5c3 | |||
| 79d0d7a305 | |||
| b74440373f | |||
| d0be3f36aa | |||
|
|
ff7a2f6284 | ||
| d5b51a4242 | |||
| 18908609fc | |||
| 50e335ba47 | |||
| 95f17cd920 | |||
| dfa59aaf31 | |||
| 683a9115b3 | |||
| caa65bf15f | |||
|
|
b2543ba8a2 | ||
| ceb1e4b505 | |||
| 2db3a6e185 | |||
| 7e6ed91149 | |||
|
|
f4a8cc80c2 | ||
| fb3eede781 | |||
| 601c6772b7 | |||
| 7140ac72b5 | |||
|
|
36969726b4 | ||
| 3849c3ccbe | |||
|
|
8e1a62d130 | ||
| cd8088d1fd | |||
| 60709395d0 | |||
| dcbf02bbda | |||
| a215852381 | |||
| c98c3de96d | |||
|
|
ed6a59e641 | ||
| 6a03575d68 | |||
|
|
5a6d69bebe | ||
| 853d1db618 | |||
| 8d54ea3374 | |||
| adfc421c9e | |||
|
|
5150925947 | ||
| d489488ca2 | |||
| 524ad430ab | |||
| 89f596ea64 | |||
| 0edcb1b9f1 | |||
| e5592304a5 | |||
|
|
9ab353926e | ||
| 0b07161034 | |||
|
|
cb08c9ad20 | ||
|
|
fdafe8d747 | ||
|
|
2a271876ac | ||
| 1b39c0c5d7 | |||
|
|
b19a3e4231 | ||
|
|
dd5861d5f5 | ||
| 27b30f974b | |||
| 0041b95f72 | |||
| 0c7fc0b26f | |||
| b60382a752 | |||
| c442c3c3f0 | |||
| b9de2f2a43 | |||
| bbb9269bab | |||
| c93b8fb570 | |||
| e570d50e9f | |||
| 217cc881e6 | |||
| b037ce1e32 | |||
| 991efb2eed | |||
| 4069fd5859 | |||
| d44560f330 | |||
| 9c96535073 | |||
| 20a21ee78b | |||
| 0dc326b79e | |||
| df5e20d550 | |||
| 05713e1d3b | |||
| 4e23995347 | |||
| f9f22e5663 | |||
| df17f51edf | |||
| c31676a0e1 | |||
| bced46ad92 | |||
| 4b4af11dbc | |||
| 46a36222cb | |||
| cd959b4095 | |||
| 5114a230ae | |||
| 23314e62aa | |||
| 671f5da86a | |||
| 5dd73186b0 | |||
| 80dab35646 | |||
| 4e8cac3856 | |||
| 83ae363670 | |||
| 5ba5ca6bf8 | |||
| e6e0f93102 | |||
| a504da1e32 | |||
| dbc00be2cc | |||
| 0d2d391cb2 | |||
| 75593b8860 | |||
|
|
2678954627 | ||
| 7301043afb | |||
| 8d3e14020f | |||
| 7349c6df4f | |||
| e1134cc9ec | |||
| 37a35daddb | |||
| 872d741fdc | |||
| 8c54d40b9c | |||
| 39522f3b96 | |||
| dd52848e9c | |||
| 00820e6571 | |||
| 7d2d67a3f5 | |||
| 1b5e098225 | |||
| caac418e0e | |||
| 3efbb6882c | |||
| ef236fc3a7 | |||
| c238ef9b40 | |||
| 30ead590e2 | |||
| deadce9cf1 | |||
| 01a68bcf3d | |||
| fe2f8a66e6 | |||
| 69167da8d0 | |||
| 0b71b79e7a | |||
|
|
84e5740e6d | ||
| a0efb14e84 | |||
| 685560ec07 | |||
| 5fecceef4f | |||
| f67f57b415 | |||
| d67abecc9e | |||
| a178942033 | |||
| 4f59a0e791 | |||
| 06fabd3eab | |||
| 3783f0d2af | |||
| ab49c32e33 | |||
| e9534baace | |||
| 4a185ac8c2 | |||
| 5783bfeea6 | |||
| cf0840d9e9 | |||
| 1eeb14c50f | |||
| 545ffa4b2c | |||
| 3f742fbaca | |||
| b0865cfa19 | |||
| 70ff18ad90 | |||
| 08577b66c5 | |||
| 67ba40c678 | |||
|
|
dbe4c902b4 | ||
|
|
2fdeb34567 | ||
| 1cb11bc1dd | |||
|
|
fae4db6a09 | ||
|
|
fc4b04a812 | ||
| 5354268529 | |||
|
|
4796cff56f | ||
| 8713a5119b | |||
|
|
3ffa9e0bb1 | ||
| 751c742b0f | |||
| 6cd554473b | |||
|
|
42a9f3cce1 | ||
|
|
dd2f301771 | ||
| 23dec91bd6 | |||
|
|
3db956ec81 | ||
| 724d8be405 | |||
| c193a352df | |||
| 9b3616fd42 | |||
|
|
78448d2a7b | ||
|
|
aaaba77c3a | ||
|
|
e941ac4539 | ||
|
|
c02dcd8aaf | ||
| 551d436919 | |||
| 26975644b5 | |||
| 40ef89aeb5 | |||
| 6e74cf5add | |||
| 3a87b2d966 | |||
|
|
ca2a4c0295 | ||
|
|
aff18bc017 | ||
|
|
1a56bb1e8a | ||
|
|
d2f838582f | ||
|
|
31afb2b18a | ||
|
|
f1fb92aa29 | ||
|
|
2cd2b9704b | ||
|
|
771b0411a3 | ||
|
|
c8b36cf799 | ||
|
|
41568099c4 | ||
| 03840099ec | |||
|
|
82f24ded1b | ||
|
|
a0134ce966 | ||
|
|
d276faf5ca | ||
|
|
d13970a731 | ||
|
|
36af9e9c1b | ||
|
|
3042c3e32e | ||
|
|
de8b82c575 | ||
|
|
f62d152d8c | ||
|
|
23f6317c8b | ||
|
|
0eee0dd53e | ||
|
|
5c591768a0 | ||
| d808b6718c | |||
|
|
7beaf68893 | ||
|
|
dbbd48083a | ||
|
|
e14061a0fe | ||
|
|
0edfc21a46 | ||
|
|
9c6fa10301 | ||
|
|
6c494bfd4e | ||
| f74493e43f | |||
| 00c18d2319 | |||
|
|
48dd96d5cd | ||
| a93e2d7536 | |||
| 83f73289dc | |||
| c674450fc1 | |||
|
|
5289d81baf | ||
| 9cc36ea99c | |||
| 95e702d670 | |||
| 232114495f | |||
|
|
418fcb0548 | ||
| 0cbba1c628 | |||
| df905aec60 | |||
| 278d897640 | |||
|
|
b43c3cd823 | ||
|
|
4004fcf5f2 | ||
|
|
5a8a856265 | ||
|
|
8e96c8a67c | ||
| a7bba70ca5 | |||
|
|
46f8a1e613 | ||
| aca23fc850 | |||
| 36a413167d | |||
|
|
948d5a698c | ||
| 7fd49cd8be | |||
| dae46eab52 | |||
|
|
2411eb6513 | ||
| 927dcf5664 | |||
|
|
f36b90ebdb | ||
| 6b9c0fb3db | |||
|
|
54683cb3ac | ||
|
|
17a184b0a7 | ||
| a2972839cc | |||
|
|
4edf76488c | ||
|
|
296a7fced9 | ||
|
|
4001ee0a5c | ||
|
|
2d31e19791 | ||
|
|
b388b124da | ||
| ba7abe01cc | |||
|
|
564fc01b37 | ||
|
|
5fb51f4430 | ||
|
|
4e14301b33 | ||
| 3909a1ef10 | |||
|
|
d50e69560b | ||
|
|
aeee782fe0 | ||
|
|
abea7cec1d | ||
|
|
2a031bcd89 | ||
|
|
fbda614156 | ||
|
|
44c65df5c9 | ||
| ed0257ee2d | |||
| 288215285e | |||
|
|
2d34004e64 | ||
| f79a6ea81e | |||
|
|
50f44489cd | ||
| e0655db01e | |||
| 19319df426 | |||
| 545f315cbc | |||
| 15f1d134b0 | |||
| 5a6e68b6dc | |||
| 4b8e7cf4a5 | |||
| eb6ab404e2 | |||
| f4b84f0d54 | |||
| 62a24bf0e5 | |||
| 21d95a18d3 | |||
| abf24c0b9b | |||
| cab5a5366d | |||
| 485e0fe5d5 | |||
| e6e2d821a8 | |||
| ae66776fa9 | |||
| 6d7c688fe3 | |||
| aa60c4a89c | |||
|
|
220119ae54 | ||
|
|
42065e0f4f | ||
| 080694fb46 | |||
| 0ffbea67fd | |||
| bdb81466ce | |||
| 3ddf603829 | |||
|
|
5859d738a0 | ||
|
|
c1dcf074bb | ||
| 30cf8abbf7 | |||
| 50a0d6f982 | |||
| 5d3e2ac111 | |||
| 838c74d8fe | |||
|
|
9a0bc6b129 | ||
|
|
42037860d5 | ||
| cf77192837 | |||
|
|
7e878f32d1 | ||
|
|
2c6fb2e81a | ||
| 8638397faa | |||
|
|
2ba4691bc0 | ||
|
|
b89d4940a9 | ||
|
|
d46c5f9383 | ||
|
|
ac8ca6ecb1 | ||
|
|
1756b83789 | ||
| 7b8cb82bf0 | |||
|
|
61b850c2c2 | ||
|
|
bf82f04b64 | ||
| 0faabb27c7 | |||
| 5f1128540e | |||
| ea9d3aafbf | |||
|
|
c501f78e36 | ||
|
|
d74457faa2 | ||
| ae014ac881 | |||
| d81cc49549 | |||
| 5b0f9f3763 | |||
|
|
321e1ea33a | ||
|
|
a9d23a8a44 | ||
|
|
fda916ac63 | ||
|
|
5834a99107 | ||
|
|
224a26d318 | ||
|
|
41075e41a2 | ||
|
|
cc23b6020d | ||
|
|
487efff9c4 | ||
|
|
8d716e84ad | ||
|
|
da3badb802 | ||
|
|
01da85a577 | ||
|
|
9cd1bd6241 | ||
|
|
0eed942ce5 | ||
| ef09a23c35 | |||
| 83e92fc3c4 | |||
| 3931442249 | |||
| 49f3831d95 | |||
| 02cca7bd79 | |||
|
|
8b6d43e91e | ||
| c84f7b5483 | |||
| 8919f37b2c | |||
| 84eccfe8cb | |||
| b4a6cb2465 | |||
| a9c54b0e1a | |||
|
|
1997de0f1a | ||
| f370539a6f | |||
| 79af97dedd | |||
| 23fb895e82 | |||
|
|
f7edc8b1ec | ||
|
|
bf31425053 | ||
|
|
ec0eeaac62 | ||
|
|
6fdf4ad65a | ||
|
|
34d16b7f0a | ||
| 5c9d39bde8 | |||
|
|
356d54a034 | ||
|
|
c9b3468b29 | ||
| 013496e341 | |||
| 7b37271f17 | |||
|
|
f557bc3f06 | ||
|
|
f6292c3ad5 | ||
|
|
e0086a6e61 | ||
|
|
a0979138b1 | ||
|
|
fa43410c8c | ||
|
|
05a0f34722 | ||
|
|
8038b6a6ee | ||
| f16e657c3c | |||
|
|
5e03b3d2f2 | ||
| 184e9f7ba9 | |||
| d1adfa3406 | |||
| 4c10c181e3 | |||
| 774b46b868 | |||
|
|
269f35cecf | ||
|
|
472a47eae7 | ||
|
|
3eb292b403 | ||
| ddc061bb6f | |||
| 9cd685c3eb | |||
| 1fe34a2233 | |||
|
|
4f6c97ae92 | ||
|
|
5a4a8a4892 | ||
|
|
73f937d78a | ||
|
|
7b74d129a5 | ||
|
|
02271e6c73 | ||
|
|
fb23ee79d8 | ||
|
|
81fe3dcf28 | ||
|
|
d9c8473504 | ||
|
|
99f539a601 | ||
|
|
05f2661056 | ||
| 5789e4b547 | |||
| a45e358e83 | |||
|
|
01af298c07 | ||
|
|
0305b79440 | ||
| aa78ea9adc | |||
| c3ad28577c | |||
|
|
381e6aa287 | ||
| 804f1e32be | |||
|
|
7f16e88e57 | ||
| 45daca3647 | |||
| 3b7e1615c2 | |||
| 49ffa6b901 | |||
| 34dca5fccc | |||
|
|
7a3b137565 | ||
| 0152f9bd67 | |||
| bb60ca91ef | |||
| c94f22e26c | |||
| 5b70ec6af7 | |||
| 0f36beee91 | |||
| 3cb3efb4d0 | |||
| c7fe793a9e | |||
| 9c33cc565c | |||
| 5633059f3d | |||
| 7972661d1e | |||
| b97a3ccebd | |||
| 6672867c6f | |||
| 2eded08bc7 | |||
| 3288b6aafb | |||
| c9a4176a41 | |||
| 74fd9a33ac | |||
| a30f296cdc | |||
| 73424f958a | |||
| f31bb7e7e5 | |||
| ed8c93fb5d | |||
| 8ade75390c | |||
| 2801b55d2f | |||
| 1b54db4f92 | |||
| b24af5a279 | |||
| 4642855fd0 | |||
| cf3dcc6195 | |||
| bca439726d | |||
| 548db78ca7 | |||
| 5c5a8d4a40 | |||
| 514365fdec | |||
| de25324991 | |||
| 1c35662ed5 | |||
| 379ce60839 | |||
| 4f36235f60 | |||
| e55c12b68b | |||
| 502811a103 | |||
| 1d7ef7e4b6 | |||
| cb794601be | |||
| 4ba1ebbbdf | |||
| 332f887c66 | |||
| e444266e1e | |||
| 10e8beea80 | |||
| d33c937ebc | |||
| 7a75f5d612 | |||
| 2dc9d752e4 | |||
| 166544fae6 | |||
| e8d832c1ea | |||
| e8648e45fc | |||
| b08f878841 | |||
| 5a55180b78 | |||
| ae58a443a3 | |||
| bf4423e53b | |||
| ad1b91498d | |||
| 71c7dd2558 | |||
| a6de4d8a32 | |||
| 8ddaf72eb7 | |||
| ad970797e9 |
1
.cloudbase/container/debug.json
Normal file
1
.cloudbase/container/debug.json
Normal file
@@ -0,0 +1 @@
|
||||
{"containers":[],"config":{}}
|
||||
16
.codegraph/.gitignore
vendored
Normal file
16
.codegraph/.gitignore
vendored
Normal file
@@ -0,0 +1,16 @@
|
||||
# CodeGraph data files
|
||||
# These are local to each machine and should not be committed
|
||||
|
||||
# Database
|
||||
*.db
|
||||
*.db-wal
|
||||
*.db-shm
|
||||
|
||||
# Cache
|
||||
cache/
|
||||
|
||||
# Logs
|
||||
*.log
|
||||
|
||||
# Hook markers
|
||||
.dirty
|
||||
143
.codegraph/config.json
Normal file
143
.codegraph/config.json
Normal file
@@ -0,0 +1,143 @@
|
||||
{
|
||||
"version": 1,
|
||||
"include": [
|
||||
"**/*.ts",
|
||||
"**/*.tsx",
|
||||
"**/*.js",
|
||||
"**/*.jsx",
|
||||
"**/*.py",
|
||||
"**/*.go",
|
||||
"**/*.rs",
|
||||
"**/*.java",
|
||||
"**/*.c",
|
||||
"**/*.h",
|
||||
"**/*.cpp",
|
||||
"**/*.hpp",
|
||||
"**/*.cc",
|
||||
"**/*.cxx",
|
||||
"**/*.cs",
|
||||
"**/*.php",
|
||||
"**/*.rb",
|
||||
"**/*.swift",
|
||||
"**/*.kt",
|
||||
"**/*.kts",
|
||||
"**/*.dart",
|
||||
"**/*.svelte",
|
||||
"**/*.vue",
|
||||
"**/*.liquid",
|
||||
"**/*.pas",
|
||||
"**/*.dpr",
|
||||
"**/*.dpk",
|
||||
"**/*.lpr",
|
||||
"**/*.dfm",
|
||||
"**/*.fmx",
|
||||
"**/*.scala",
|
||||
"**/*.sc"
|
||||
],
|
||||
"exclude": [
|
||||
"**/.git/**",
|
||||
"**/node_modules/**",
|
||||
"**/vendor/**",
|
||||
"**/Pods/**",
|
||||
"**/dist/**",
|
||||
"**/build/**",
|
||||
"**/out/**",
|
||||
"**/bin/**",
|
||||
"**/obj/**",
|
||||
"**/target/**",
|
||||
"**/*.min.js",
|
||||
"**/*.bundle.js",
|
||||
"**/.next/**",
|
||||
"**/.nuxt/**",
|
||||
"**/.svelte-kit/**",
|
||||
"**/.output/**",
|
||||
"**/.turbo/**",
|
||||
"**/.cache/**",
|
||||
"**/.parcel-cache/**",
|
||||
"**/.vite/**",
|
||||
"**/.astro/**",
|
||||
"**/.docusaurus/**",
|
||||
"**/.gatsby/**",
|
||||
"**/.webpack/**",
|
||||
"**/.nx/**",
|
||||
"**/.yarn/cache/**",
|
||||
"**/.pnpm-store/**",
|
||||
"**/storybook-static/**",
|
||||
"**/.expo/**",
|
||||
"**/web-build/**",
|
||||
"**/ios/Pods/**",
|
||||
"**/ios/build/**",
|
||||
"**/android/build/**",
|
||||
"**/android/.gradle/**",
|
||||
"**/__pycache__/**",
|
||||
"**/.venv/**",
|
||||
"**/venv/**",
|
||||
"**/site-packages/**",
|
||||
"**/dist-packages/**",
|
||||
"**/.pytest_cache/**",
|
||||
"**/.mypy_cache/**",
|
||||
"**/.ruff_cache/**",
|
||||
"**/.tox/**",
|
||||
"**/.nox/**",
|
||||
"**/*.egg-info/**",
|
||||
"**/.eggs/**",
|
||||
"**/go/pkg/mod/**",
|
||||
"**/target/debug/**",
|
||||
"**/target/release/**",
|
||||
"**/.gradle/**",
|
||||
"**/.m2/**",
|
||||
"**/generated-sources/**",
|
||||
"**/.kotlin/**",
|
||||
"**/.dart_tool/**",
|
||||
"**/.vs/**",
|
||||
"**/.nuget/**",
|
||||
"**/artifacts/**",
|
||||
"**/publish/**",
|
||||
"**/cmake-build-*/**",
|
||||
"**/CMakeFiles/**",
|
||||
"**/bazel-*/**",
|
||||
"**/vcpkg_installed/**",
|
||||
"**/.conan/**",
|
||||
"**/Debug/**",
|
||||
"**/Release/**",
|
||||
"**/x64/**",
|
||||
"**/.pio/**",
|
||||
"**/release/**",
|
||||
"**/*.app/**",
|
||||
"**/*.asar",
|
||||
"**/DerivedData/**",
|
||||
"**/.build/**",
|
||||
"**/.swiftpm/**",
|
||||
"**/xcuserdata/**",
|
||||
"**/Carthage/Build/**",
|
||||
"**/SourcePackages/**",
|
||||
"**/__history/**",
|
||||
"**/__recovery/**",
|
||||
"**/*.dcu",
|
||||
"**/.composer/**",
|
||||
"**/storage/framework/**",
|
||||
"**/bootstrap/cache/**",
|
||||
"**/.bundle/**",
|
||||
"**/tmp/cache/**",
|
||||
"**/public/assets/**",
|
||||
"**/public/packs/**",
|
||||
"**/.yardoc/**",
|
||||
"**/coverage/**",
|
||||
"**/htmlcov/**",
|
||||
"**/.nyc_output/**",
|
||||
"**/test-results/**",
|
||||
"**/.coverage/**",
|
||||
"**/.idea/**",
|
||||
"**/logs/**",
|
||||
"**/tmp/**",
|
||||
"**/temp/**",
|
||||
"**/_build/**",
|
||||
"**/docs/_build/**",
|
||||
"**/site/**"
|
||||
],
|
||||
"languages": [],
|
||||
"frameworks": [],
|
||||
"maxFileSize": 1048576,
|
||||
"extractDocstrings": true,
|
||||
"trackCallSites": true
|
||||
}
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 514 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 510 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 53 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 6.8 KiB |
23
.codex/config.toml
Normal file
23
.codex/config.toml
Normal file
@@ -0,0 +1,23 @@
|
||||
# Genarrative 项目级 Codex 配置。
|
||||
# 这里仅保存可进入仓库的 hook 配置与脚本;个人 token、MCP server、模型路由仍放在个人 ~/.codex/config.toml。
|
||||
|
||||
[features]
|
||||
hooks = true
|
||||
|
||||
# Codex 准备执行 git commit 前检查 TypeScript / admin-web / api-server 编译错误。
|
||||
# 脚本也可手动运行:
|
||||
# node .codex/hooks/pre-submit-compile-check.mjs
|
||||
[[hooks.PreToolUse]]
|
||||
matcher = "Bash|shell_command|functions.shell_command"
|
||||
command = "node .codex/hooks/pre-submit-compile-check.mjs"
|
||||
timeout = 180
|
||||
statusMessage = "提交前检查编译错误"
|
||||
|
||||
# Codex 每次工具修改文件后执行:同步 CodeGraph 索引。
|
||||
# 脚本也可手动运行:
|
||||
# node .codex/hooks/post-edit-codegraph-sync.mjs
|
||||
[[hooks.PostToolUse]]
|
||||
matcher = "Bash|Edit|MultiEdit|Write|apply_patch|shell_command|functions.shell_command|functions.apply_patch"
|
||||
command = "node .codex/hooks/post-edit-codegraph-sync.mjs"
|
||||
timeout = 60
|
||||
statusMessage = "更新 CodeGraph 索引"
|
||||
@@ -3,4 +3,22 @@ version = 1
|
||||
name = "Genarrative"
|
||||
|
||||
[setup]
|
||||
script = ""
|
||||
script = '''
|
||||
cp "$CODEX_SOURCE_TREE_PATH/.env.secrets.local" "$CODEX_WORKTREE_PATH/.env.secrets.local"
|
||||
npm install
|
||||
npm run codegraph:init
|
||||
npm run codegraph:index
|
||||
'''
|
||||
|
||||
[setup.win32]
|
||||
script = '''
|
||||
cp "$env:CODEX_SOURCE_TREE_PATH\.env.secrets.local" "$env:CODEX_WORKTREE_PATH\.env.secrets.local"
|
||||
npm install
|
||||
npm run codegraph:init
|
||||
npm run codegraph:index
|
||||
'''
|
||||
|
||||
[[actions]]
|
||||
name = "运行"
|
||||
icon = "run"
|
||||
command = "npm run dev"
|
||||
|
||||
51
.codex/hooks/post-edit-codegraph-sync.mjs
Normal file
51
.codex/hooks/post-edit-codegraph-sync.mjs
Normal file
@@ -0,0 +1,51 @@
|
||||
#!/usr/bin/env node
|
||||
import { spawnSync } from 'node:child_process';
|
||||
import { existsSync, mkdirSync } from 'node:fs';
|
||||
import { dirname, resolve } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const scriptDir = dirname(fileURLToPath(import.meta.url));
|
||||
const repoRoot = resolve(scriptDir, '..', '..');
|
||||
const logDir = resolve(repoRoot, '.codex', 'logs');
|
||||
const hasCodegraphConfig = existsSync(resolve(repoRoot, '.codegraph', 'config.json'));
|
||||
const npmCommand = process.platform === 'win32' ? 'cmd' : 'npm';
|
||||
|
||||
if (!hasCodegraphConfig) {
|
||||
console.log('[codex-hook] 未发现 .codegraph/config.json,跳过 CodeGraph 同步。');
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
const result = spawnSync(npmCommand, process.platform === 'win32' ? ['/d', '/s', '/c', 'npm run codegraph:sync'] : ['run', 'codegraph:sync'], {
|
||||
cwd: repoRoot,
|
||||
shell: false,
|
||||
encoding: 'utf8',
|
||||
env: {
|
||||
...process.env,
|
||||
NO_COLOR: process.env.NO_COLOR ?? '1',
|
||||
},
|
||||
});
|
||||
|
||||
mkdirSync(logDir, { recursive: true });
|
||||
if (result.stdout) {
|
||||
process.stdout.write(result.stdout);
|
||||
}
|
||||
if (result.stderr) {
|
||||
process.stderr.write(result.stderr);
|
||||
}
|
||||
|
||||
if (result.error) {
|
||||
console.error(`[codex-hook] CodeGraph 同步启动失败:${result.error.message}`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
if (result.signal) {
|
||||
console.error(`[codex-hook] CodeGraph 同步被信号终止:${result.signal}`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
if ((result.status ?? 0) !== 0) {
|
||||
console.error('[codex-hook] CodeGraph 同步失败,请手动运行 npm run codegraph:sync 查看详情。');
|
||||
process.exit(result.status ?? 1);
|
||||
}
|
||||
|
||||
console.log('[codex-hook] CodeGraph 已同步。');
|
||||
122
.codex/hooks/pre-submit-compile-check.mjs
Normal file
122
.codex/hooks/pre-submit-compile-check.mjs
Normal file
@@ -0,0 +1,122 @@
|
||||
#!/usr/bin/env node
|
||||
import { spawnSync } from 'node:child_process';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { dirname, resolve } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const scriptDir = dirname(fileURLToPath(import.meta.url));
|
||||
const repoRoot = resolve(scriptDir, '..', '..');
|
||||
const npmCommand = process.platform === 'win32' ? 'cmd' : 'npm';
|
||||
const hookInput = readHookInput();
|
||||
|
||||
if (hookInput && !isGitCommitCommand(extractShellCommand(hookInput))) {
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
const validationSteps = [
|
||||
{
|
||||
label: 'TypeScript typecheck',
|
||||
command: npmCommand,
|
||||
args: process.platform === 'win32' ? ['/d', '/s', '/c', 'npm run typecheck'] : ['run', 'typecheck'],
|
||||
},
|
||||
{
|
||||
label: 'Admin web typecheck',
|
||||
command: npmCommand,
|
||||
args: process.platform === 'win32' ? ['/d', '/s', '/c', 'npm run admin-web:typecheck'] : ['run', 'admin-web:typecheck'],
|
||||
},
|
||||
{
|
||||
label: 'Rust api-server compile check',
|
||||
command: 'cargo',
|
||||
args: ['check', '-p', 'api-server', '--manifest-path', 'server-rs/Cargo.toml'],
|
||||
},
|
||||
];
|
||||
|
||||
for (const step of validationSteps) {
|
||||
const result = runStep(step);
|
||||
if (!result.ok) {
|
||||
const reason = `[codex-hook] 提交前编译检查失败:${step.label}。请修复编译错误后再提交。`;
|
||||
console.error(reason);
|
||||
if (hookInput) {
|
||||
console.log(JSON.stringify({ decision: 'block', reason }));
|
||||
process.exit(0);
|
||||
}
|
||||
process.exit(result.status ?? 1);
|
||||
}
|
||||
}
|
||||
|
||||
console.error('[codex-hook] 提交前编译检查通过。');
|
||||
|
||||
function runStep(step) {
|
||||
console.error(`[codex-hook] ${step.label}`);
|
||||
const result = spawnSync(step.command, step.args, {
|
||||
cwd: repoRoot,
|
||||
shell: false,
|
||||
encoding: 'utf8',
|
||||
env: {
|
||||
...process.env,
|
||||
NO_COLOR: process.env.NO_COLOR ?? '1',
|
||||
},
|
||||
});
|
||||
|
||||
if (result.stdout) {
|
||||
process.stderr.write(result.stdout);
|
||||
}
|
||||
if (result.stderr) {
|
||||
process.stderr.write(result.stderr);
|
||||
}
|
||||
|
||||
if (result.error) {
|
||||
console.error(`[codex-hook] ${step.label} 启动失败:${result.error.message}`);
|
||||
return { ok: false, status: 1 };
|
||||
}
|
||||
|
||||
if (result.signal) {
|
||||
console.error(`[codex-hook] ${step.label} 被信号终止:${result.signal}`);
|
||||
return { ok: false, status: 1 };
|
||||
}
|
||||
|
||||
return {
|
||||
ok: (result.status ?? 0) === 0,
|
||||
status: result.status ?? 1,
|
||||
};
|
||||
}
|
||||
|
||||
function readHookInput() {
|
||||
try {
|
||||
const rawInput = readFileSync(0, 'utf8').trim();
|
||||
if (!rawInput) {
|
||||
return null;
|
||||
}
|
||||
return JSON.parse(rawInput);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function extractShellCommand(input) {
|
||||
const candidates = [
|
||||
input?.tool_input?.command,
|
||||
input?.toolInput?.command,
|
||||
input?.tool_args?.command,
|
||||
input?.toolArgs?.command,
|
||||
input?.arguments?.command,
|
||||
input?.params?.command,
|
||||
input?.command,
|
||||
];
|
||||
|
||||
const command = candidates.find(value => typeof value === 'string' && value.trim().length > 0);
|
||||
if (command) {
|
||||
return command;
|
||||
}
|
||||
|
||||
const shellCommand = input?.tool_input?.cmd ?? input?.toolInput?.cmd ?? input?.arguments?.cmd;
|
||||
if (Array.isArray(shellCommand)) {
|
||||
return shellCommand.join(' ');
|
||||
}
|
||||
|
||||
return '';
|
||||
}
|
||||
|
||||
function isGitCommitCommand(command) {
|
||||
return /(^|[;&|]\s*)git(?:\.exe)?\b[\s\S]{0,200}\bcommit\b/iu.test(command);
|
||||
}
|
||||
@@ -1 +0,0 @@
|
||||
C:/proj/Genarrative/.hermes/skills/behavior-driven-development
|
||||
@@ -1,389 +1,296 @@
|
||||
---
|
||||
name: genarrative-play-type-integration
|
||||
description: 在 Genarrative 中新增一个创作入口/玩法类型时,按入口配置、前端分流、契约、后端接口、工作台、结果页、可选 runtime 与作品架的顺序接入。
|
||||
description: 在 Genarrative 中新增或补齐一个创作入口/玩法类型时,按入口配置、前端分流、契约、后端接口、工作台、独立生成页、结果页、发布、统一作品详情、正式 runtime、公开 read model、基础统计与作品架/广场的顺序接入。
|
||||
license: MIT
|
||||
metadata:
|
||||
author: Hermes Agent
|
||||
version: "1.0"
|
||||
---
|
||||
|
||||
# Genarrative 新增玩法类型接入流程
|
||||
# Genarrative 新增玩法创作工具平台 SOP
|
||||
|
||||
用于在 Genarrative 中新增一个创作入口/玩法类型,而不是单纯说明用户如何从入口创建作品。
|
||||
把新增玩法当成平台能力接入,不把任何既有玩法当作默认模板。先确定通用模式和契约,再写具体玩法代码。
|
||||
|
||||
## 适用场景
|
||||
## 硬性禁区
|
||||
|
||||
- 新增一个游戏玩法入口
|
||||
- 让某个玩法从“敬请期待”变为可创建
|
||||
- 为新玩法补齐创作工作台、结果页、发布与试玩链路
|
||||
- 将新玩法接入创作中心作品架与广场
|
||||
- 不恢复前端硬编码入口配置;创作入口事实源必须来自 SpacetimeDB 和 `/api/creation-entry/config`。
|
||||
- 不把聊天输入区、流式消息或轻输入 Agent 作为新增玩法默认工作台。
|
||||
- 不在新页面内手写图片上传、参考图、AI 重绘、历史图选择、预览或删除确认逻辑。
|
||||
- 不把通用系列素材建模成任一玩法专属 DTO;玩法只能追加自己的运行态字段。
|
||||
- 不让前端承接正式业务真相;发布、试玩、通关、失败、计分、资产持久化和作品状态以后端投影为准。
|
||||
- 不新建平行入口系统、平行作品架或平行公开列表;优先扩展现有平台壳、现有阶段和现有聚合。
|
||||
- 不在 UI 面板内默认写功能说明、规则说明或开发解释文案。
|
||||
|
||||
## 先判断接入级别
|
||||
## 接入前输入
|
||||
|
||||
### 1. 只做入口占位
|
||||
开始编码前,PRD 或当前玩法文档必须已经明确:
|
||||
|
||||
只需要新增入口配置,不接 session/workspace/result/runtime。
|
||||
- `playId`、对外名称、工程域名、入口 `visible/open` 状态。
|
||||
- 创作链路:入口 -> 工作台 -> 生成页 -> 结果页 -> 试玩 -> 发布 -> 运行态。
|
||||
- 表单字段:字段名、默认值、校验、后端落库位置、生成提示词来源。
|
||||
- 单图资产槽位:`slotId`、`slotType`、`slotName`、提示词来源、读取字段、写回字段、是否允许历史图和 AI 重绘。
|
||||
- 系列素材槽位:`batchId` 语义、`sheetSpec`、`slotSpecs`、切图规则、透明化规则、失败回写、局部重生成策略。
|
||||
- API 命名空间:`/api/creation/<play>/sessions`、`actions`、`works`、`runtime`。
|
||||
- 草稿恢复、生成中恢复、失败重试、登录切换、发布后回读和移动端行为。
|
||||
- 验证命令和例外声明;没有例外时写明“无创作工具模式例外”。
|
||||
|
||||
适合:
|
||||
## 默认模式
|
||||
|
||||
- 敬请期待
|
||||
- 灰度占位
|
||||
新增玩法默认采用表单/图片输入创作工作台:
|
||||
|
||||
### 2. 可进入创作工作台
|
||||
```text
|
||||
创作入口 -> 表单/图片输入工作台 -> 生成页 -> 结果页 -> 试玩 -> 发布 -> 运行态
|
||||
```
|
||||
|
||||
需要补齐前端分流、session、工作台、结果页,至少能生成草稿。
|
||||
工作台只提交结构化表单、图片槽位和配置 payload。确需自然语言对话时,先走“例外流程”,不能把聊天区直接加进默认工作台。
|
||||
|
||||
### 3. 完整玩法闭环
|
||||
## SOP
|
||||
|
||||
需要补齐:
|
||||
### 1. 文档和领域词先行
|
||||
|
||||
- 创作入口
|
||||
- 工作台
|
||||
- 草稿生成
|
||||
- 独立生成页(如果存在自动资产生成)
|
||||
- 结果页
|
||||
- 发布
|
||||
- 统一作品详情页
|
||||
- 试玩 runtime
|
||||
- 作品架 / 广场 / 分享
|
||||
- 公开作品卡 / 作品架 / 广场 / 分享
|
||||
- 正式 runtime 统计
|
||||
|
||||
## 推荐接入顺序
|
||||
## 公开闭环决策点
|
||||
|
||||
### Step 1: 先定玩法 ID 和能力边界
|
||||
新增玩法如果要作为公开作品交付,先按这些决策点对齐,不要直接套某个玩法的具体字段或 UI:
|
||||
|
||||
先明确:
|
||||
```text
|
||||
创作入口 / 工作台
|
||||
-> 草稿保存 / 编译
|
||||
-> 独立生成页(自动素材,可选但推荐)
|
||||
-> 结果页(确认、单槽重试、上传、发布)
|
||||
-> 统一作品详情页 /works/detail?work=<公开作品码>
|
||||
-> 正式 runtime
|
||||
-> 基础统计 / 公开 read model
|
||||
-> 作品架 / 发现流 / 分类推荐 / 今日卡片
|
||||
```
|
||||
|
||||
- `id` 是什么
|
||||
- 入口是否可见
|
||||
- 是否可点击创建
|
||||
- 是否需要对话式创作
|
||||
- 是否需要生成中页面
|
||||
- 是否需要 result/runtime/gallery/share
|
||||
必须先做这些决策:
|
||||
|
||||
不要先随便起临时 ID 再改名。
|
||||
1. **公开作品身份**:是否需要公开作品码;前缀、解析入口、分享 URL 和统一作品详情页如何映射。
|
||||
2. **编辑契约边界**:哪些字段是 v1 公开编辑语义;旧字段是兼容、迁移、只读展示,还是明确不兼容。
|
||||
3. **生成阶段归属**:是否有自动素材生成;生成动作放在工作台、独立 `*-generating` 页,还是结果页手动触发。
|
||||
4. **失败承接策略**:全部失败、部分失败、单槽失败分别进入哪个页面;错误态由生成页还是结果页承接。
|
||||
5. **结果页能力边界**:结果页只做确认 / 单槽重试 / 重新生成 / 上传,还是还允许批量生成、规则编辑或资源配置。
|
||||
6. **发布后去向**:发布成功后默认进入统一作品详情页;只有明确需要时才新增专属详情页。
|
||||
7. **公开卡片资产来源**:封面是复用已有素材合成、使用首图、还是新增独立封面资产。
|
||||
8. **公开读取路径**:广场 / 发现流读取 SpacetimeDB view 或 public read model;api-server 是否需要订阅缓存,避免每请求 procedure 热路径。
|
||||
9. **runtime 模式差异**:`draft` 和 `published` 的输入能力、mock/debug 开关、鉴权、开始条件是否不同。
|
||||
10. **正式统计口径**:哪些 runtime 事件写正式统计;草稿试玩、mock、debug 是否必须排除。
|
||||
11. **规则参数归属**:哪些配置是创作者可编辑;哪些阈值、时长、冷却、计分、反作弊、裁决规则必须留在后端规则集。
|
||||
12. **旧数据策略**:旧草稿、旧发布配置、旧分享码是迁移、降级展示、重新生成,还是明确不兼容。
|
||||
|
||||
### Step 2: 新增入口配置
|
||||
- `AGENTS.md`
|
||||
- `.hermes/shared-memory/`
|
||||
- `CONTEXT.md`
|
||||
- `docs/README.md`
|
||||
- `docs/【玩法创作】平台入口与玩法链路-2026-05-15.md`
|
||||
- 相关玩法 PRD 或设计文档
|
||||
|
||||
文件:
|
||||
如果文档不能精确指导字段、契约、资产槽位、生成流程和恢复语义,先补文档再编码。新增长期约定时同步 `.hermes/shared-memory/`。
|
||||
|
||||
- `src/config/newWorkEntryConfig.ts`
|
||||
### 2. 定玩法边界
|
||||
|
||||
在 `creationTypes` 中新增:
|
||||
固定 `playId`、对外名称、工程域、入口状态、是否支持结果页、试玩、发布、作品架、广场、分享和 runtime。不要先用临时 ID 接线后再批量改名。
|
||||
|
||||
- `id`
|
||||
- `title`
|
||||
- `subtitle`
|
||||
- `badge`
|
||||
- `visible`
|
||||
- `open`
|
||||
### 3. 接入口配置
|
||||
|
||||
如果只是占位:
|
||||
入口配置事实源是 SpacetimeDB `creation_entry_type_config`。后台通过 `/admin/api/creation-entry/config` 管理,前台通过 `/api/creation-entry/config` 读取。
|
||||
|
||||
- `visible: true`
|
||||
- `open: false`
|
||||
前端只允许在展示层派生:
|
||||
|
||||
### Step 3: 确认类型过滤逻辑
|
||||
- 可见入口卡片。
|
||||
- 锁定或开放状态。
|
||||
- 排序、图标、短标题等展示信息。
|
||||
|
||||
文件:
|
||||
`api-server` 路由熔断必须使用同一份入口配置。禁止新增或恢复前端本地默认入口配置作为事实源。
|
||||
|
||||
- `src/components/platform-entry/platformEntryCreationTypes.ts`
|
||||
### 4. 前端阶段
|
||||
|
||||
检查:
|
||||
按需要扩展 `SelectionStage`:
|
||||
|
||||
- `getVisiblePlatformCreationTypes()` 是否能展示新类型
|
||||
- `isPlatformCreationTypeVisible()` 是否能识别新类型
|
||||
- `locked` / `hidden` 是否正确映射
|
||||
- `<play>-workspace`
|
||||
- `<play>-generating`
|
||||
- `<play>-result`
|
||||
- `<play>-runtime`
|
||||
- `<play>-gallery-detail`
|
||||
|
||||
### Step 4: 扩展页面阶段
|
||||
阶段名可以按玩法命名,UI 形态必须仍是表单/图片创作工作台。进入工作台时只初始化结构化草稿状态,不启动默认聊天会话。
|
||||
|
||||
文件:
|
||||
### 5. 工作台实现
|
||||
|
||||
- `src/components/platform-entry/platformEntryTypes.ts`
|
||||
工作台必须满足:
|
||||
|
||||
为新玩法补充 `SelectionStage`:
|
||||
- 使用表单控件、图片槽位、风格选项、难度选项、开关和提交按钮组织输入。
|
||||
- 单图槽位统一使用 `CreativeImageInputPanel`。
|
||||
- 组件缺少能力时先扩展 `CreativeImageInputPanel` 的受控 props,不在玩法页面复制上传、参考图、AI 重绘、历史图、预览或删除确认。
|
||||
- 主图读取、裁剪、历史素材弹层、计费确认、自动保存和后端请求由外层页面持有;通用面板只表达输入 UI 和短生命周期 UI 状态。
|
||||
- 提交 payload 必须是表单字段与图片槽位结构,不是用户消息文本。
|
||||
|
||||
- `*-agent-workspace`
|
||||
- `*-generating`(可选)
|
||||
- `*-result`
|
||||
- `*-runtime`(可选)
|
||||
- `*-gallery-detail`(可选)
|
||||
### 6. 单图资产槽位
|
||||
|
||||
### Step 5: 在总流程中加类型分流
|
||||
角色形象、UI 背景、容器、封面、分享图、图标等单张图都按单图资产槽位处理。
|
||||
|
||||
文件:
|
||||
统一约定:
|
||||
|
||||
- `src/components/platform-entry/PlatformEntryFlowShellImpl.tsx`
|
||||
- 槽位用 `slotId` 稳定标识,`slotType` 表达用途,`slotName` 用于 UI 标签。
|
||||
- 上传图、参考图、AI 重绘、历史图选择和删除确认都通过 `CreativeImageInputPanel` 入口表达。
|
||||
- 后端写回 `imageSrc`、`imageObjectKey`、`assetObjectId` 中可用字段;前端展示前通过平台资产读取能力换签。
|
||||
- 单个槽位重生成只禁用该槽位动作,不阻塞结果页其它槽位、系列素材槽位或导航。
|
||||
|
||||
在 `handleCreationHubCreateType(type)` 中新增分支,确保:
|
||||
### 7. 系列素材图集生成
|
||||
|
||||
- 能进入对应工作台
|
||||
- 能设置对应 `selectionStage`
|
||||
- 能关闭类型弹层
|
||||
地块、物品、障碍、装饰、UI 部件等一组同类素材都走通用系列素材图集生成流程:
|
||||
|
||||
同时按玩法补齐:
|
||||
```text
|
||||
批量规划 -> sheet 生图 -> 后端切图 -> 去背景/透明化 -> PNG 输出 -> OSS 持久化 -> 状态回写 -> 局部重生成
|
||||
```
|
||||
|
||||
- `open<Play>AgentWorkspace()`
|
||||
- `leave<Play>Flow()`
|
||||
- `submit<Play>Message()`(对话式玩法)
|
||||
- `execute<Play>Action()`
|
||||
玩法只提供:
|
||||
|
||||
### Step 6: 接入通用 Agent flow controller
|
||||
- `sheetSpec`:画布比例、行列、单格尺寸、输出格式、背景处理策略。
|
||||
- `slotSpecs`:每个素材槽位的 `slotId`、`slotType`、`slotName`、提示词、sheet 单元格映射。
|
||||
- 玩法字段映射:把通用素材结果映射回玩法自己的 draft/profile/runtime 字段。
|
||||
|
||||
文件:
|
||||
通用系列素材结果建议字段:
|
||||
|
||||
- `src/components/platform-entry/usePlatformCreationAgentFlowController.ts`
|
||||
|
||||
如果是 Agent 型玩法,复用通用控制器:
|
||||
|
||||
- `createSession`
|
||||
- `getSession`
|
||||
- `streamMessage`
|
||||
- `executeAction`
|
||||
- `isBusy`
|
||||
- `batchId`
|
||||
- `slotId`
|
||||
- `slotType`
|
||||
- `slotName`
|
||||
- `prompt`
|
||||
- `imageSrc`
|
||||
- `imageObjectKey`
|
||||
- `assetObjectId`
|
||||
- `sourceSheetCell`
|
||||
- `status`
|
||||
- `error`
|
||||
- `streamingReplyText`
|
||||
- `selectionStage` 切换
|
||||
|
||||
### Step 7: 定义 shared contracts
|
||||
玩法可追加运行态字段,例如半径、宽度、视图索引或碰撞参数,但不能依赖任何玩法专属字段作为平台通用模型。新增玩法 compile action 内部调用通用系列素材服务;如果通用服务还缺能力,先补通用服务再接玩法。
|
||||
|
||||
前端:
|
||||
### 8. 契约与 API
|
||||
|
||||
前后端必须同步补契约:
|
||||
|
||||
- `packages/shared/src/contracts/`
|
||||
|
||||
后端:
|
||||
|
||||
- `server-rs/crates/shared-contracts/src/`
|
||||
|
||||
至少补齐:
|
||||
玩法 API 保留独立命名空间:
|
||||
|
||||
- session snapshot
|
||||
- create session request/response
|
||||
- message request/response
|
||||
- action request/response
|
||||
- draft/result 结构
|
||||
- work summary / gallery 结构(如果需要)
|
||||
- runtime 结构(如果需要)
|
||||
- `POST /api/creation/<play>/sessions`
|
||||
- `GET /api/creation/<play>/sessions/{sessionId}`
|
||||
- `POST /api/creation/<play>/sessions/{sessionId}/actions`
|
||||
- `/api/creation/<play>/works`
|
||||
- `/api/creation/<play>/runtime`
|
||||
|
||||
### Step 8: 实现前端 service client
|
||||
契约需要区分:
|
||||
|
||||
目录参考:
|
||||
- 工作台输入。
|
||||
- 草稿 snapshot。
|
||||
- 单图资产槽位。
|
||||
- 系列素材批次与槽位。
|
||||
- 结果页操作。
|
||||
- 发布作品摘要。
|
||||
- runtime snapshot。
|
||||
|
||||
- `src/services/`
|
||||
### 9. 后端分层
|
||||
|
||||
按玩法补:
|
||||
按 DDD 边界落地:
|
||||
|
||||
- creation client
|
||||
- runtime client(可选)
|
||||
- works client(可选)
|
||||
- gallery client(可选)
|
||||
- `module-<play>`:纯领域规则、状态机、draft/runtime 校验。
|
||||
- `shared-contracts`:前后端 DTO。
|
||||
- `spacetime-module`:表、reducer、procedure、事务编排、migration。
|
||||
- `spacetime-client`:typed facade 和 row mapper。
|
||||
- `api-server`:Axum 路由、鉴权、BFF、SSE、生成编排。
|
||||
- `platform-*`:LLM、图片生成、OSS、认证等外部副作用。
|
||||
|
||||
建议保持和现有玩法一致的 API base 与命名风格。
|
||||
涉及 SpacetimeDB schema 时同步 `migration.rs`、表目录和绑定,并运行 `npm run check:spacetime-schema`。
|
||||
|
||||
### Step 9: 接后端 API
|
||||
|
||||
文件参考:
|
||||
|
||||
- `server-rs/crates/api-server/src/puzzle.rs`
|
||||
- `server-rs/crates/api-server/src/puzzle_agent_turn.rs`
|
||||
- `server-rs/crates/api-server/src/match3d.rs`
|
||||
|
||||
通常需要:
|
||||
|
||||
- create session
|
||||
- get session
|
||||
- send message
|
||||
- stream message
|
||||
- execute action
|
||||
- publish / save / delete
|
||||
- runtime start / action(可选)
|
||||
- gallery / detail(可选)
|
||||
|
||||
后端设计优先按 Genarrative 的 DDD 分层拆开,不要把玩法规则、数据库事务、LLM 调用和 HTTP handler 混在一个文件里:
|
||||
|
||||
- `module-<play>`:纯领域规则、状态机、draft/runtime 校验,不依赖 Axum、SpacetimeDB 或外部平台。
|
||||
- `shared-contracts`:前后端 DTO、请求/响应、session snapshot、draft/result/runtime 结构。
|
||||
- `spacetime-module`:表定义、reducer/procedure、事务编排、migration;表结构变化要同步生成绑定。
|
||||
- `spacetime-client`:api-server 到 SpacetimeDB 的 facade,隐藏 reducer 调用细节。
|
||||
- `api-server`:Axum 路由、鉴权、SSE/stream、应用层编排。
|
||||
- `platform-*`:LLM、资产上传、鉴权、第三方服务等副作用。
|
||||
|
||||
建议按四条线设计后端能力:
|
||||
|
||||
- Agent 创作线:session、turn、stream、compile action。
|
||||
- Works 作品线:保存、发布、删除、草稿恢复。
|
||||
- Gallery 广场线:公开列表、详情、like/remix/share。
|
||||
- Runtime 运行态线:开始试玩、提交动作、读取状态。
|
||||
|
||||
### Step 10: 新增工作台组件
|
||||
|
||||
目录建议:
|
||||
|
||||
- `src/components/<play>-creation/<Play>AgentWorkspace.tsx`
|
||||
|
||||
两种形态:
|
||||
|
||||
#### 对话式
|
||||
|
||||
适合设定逐轮补齐。
|
||||
|
||||
参考:
|
||||
|
||||
- `BigFishAgentWorkspace.tsx`
|
||||
- `Match3DAgentWorkspace.tsx`
|
||||
|
||||
#### 表单式
|
||||
|
||||
适合输入结构明确的玩法。
|
||||
|
||||
参考:
|
||||
|
||||
- `PuzzleAgentWorkspace.tsx`
|
||||
|
||||
### Step 11: 在渲染树中挂载新页面
|
||||
|
||||
文件:
|
||||
|
||||
- `src/components/platform-entry/PlatformEntryFlowShellImpl.tsx`
|
||||
|
||||
补齐:
|
||||
|
||||
- workspace 分支
|
||||
- generating 分支(如需要)
|
||||
- result 分支
|
||||
- runtime 分支(如需要)
|
||||
|
||||
### Step 12: 新增结果页
|
||||
|
||||
目录建议:
|
||||
|
||||
- `src/components/<play>-result/<Play>ResultView.tsx`
|
||||
### 10. 结果页
|
||||
|
||||
结果页至少支持:
|
||||
|
||||
- 展示 draft
|
||||
- 返回编辑
|
||||
- 发布
|
||||
- 试玩
|
||||
- 错误展示
|
||||
- 展示草稿和生成状态。
|
||||
- 返回工作台编辑。
|
||||
- 单图槽位重生成。
|
||||
- 系列素材追加、替换、局部重生成。
|
||||
- 发布。
|
||||
- 试玩。
|
||||
- 错误展示和失败重试。
|
||||
|
||||
### Step 13: 需要试玩就补 runtime
|
||||
单图槽位和系列素材槽位的生成状态互不阻塞。已有可查看结果时,局部重生成不能把作品架草稿重新变成不可打开的全局生成中。
|
||||
|
||||
目录建议:
|
||||
### 11. 运行态、作品架和广场
|
||||
|
||||
- `src/components/<play>-runtime/<Play>RuntimeShell.tsx`
|
||||
需要试玩或发布时补齐:
|
||||
|
||||
如果玩法是游戏类,建议补完整 runtime 闭环。
|
||||
- runtime start/action/finish API。
|
||||
- 作品保存、发布、删除、回读。
|
||||
- 作品架摘要。
|
||||
- 公开列表、详情、分享码。
|
||||
- 公开列表优先消费后端投影或 BFF 缓存,不让前端直接拼源表事实。
|
||||
|
||||
### Step 14: 接入作品架 / 广场 / 分享
|
||||
运行态可以做低延迟表现,但正式胜负、分数、奖励、排行榜和发布状态以后端裁决为准。
|
||||
|
||||
需要改:
|
||||
### 12. 恢复与登录态
|
||||
|
||||
- `src/components/custom-world-home/creationWorkShelf.ts`
|
||||
- `src/components/custom-world-home/CustomWorldCreationHub.tsx`
|
||||
- `src/services/publicWorkCode.ts`
|
||||
必须处理:
|
||||
|
||||
如果玩法支持发布,还要补:
|
||||
- 刷新恢复生成中草稿。
|
||||
- 生成页计时从后端摘要时间恢复。
|
||||
- 失败后回读 session/work detail 再决定是否展示失败。
|
||||
- 退出登录清空私有玩法状态。
|
||||
- 私有生成图展示前换签。
|
||||
- result/runtime 缺必要 draft 时回到可恢复入口,不停在空白页。
|
||||
|
||||
- public work code
|
||||
- public detail
|
||||
- publish share modal
|
||||
- like/remix(可选)
|
||||
### 13. 例外流程
|
||||
|
||||
### Step 15: 处理登录态与草稿恢复
|
||||
任何非表单/图片工作台、对话式 Agent、独立创作系统或特殊资产模型都必须先更新 PRD 和平台文档。例外声明至少写清:
|
||||
|
||||
要考虑:
|
||||
- 为什么默认表单/图片工作台不能满足。
|
||||
- 例外影响哪些输入、契约、后端流程和测试。
|
||||
- 如何保留单图资产槽位和系列素材槽位的通用能力。
|
||||
- 如何回退到平台默认链路。
|
||||
|
||||
- 刷新恢复草稿
|
||||
- 退出登录清空私有状态
|
||||
- result/draft 缺失时回退
|
||||
- busy / generating / runtime 中断恢复
|
||||
没有文档例外,不进入编码。
|
||||
|
||||
### Step 16: 补测试
|
||||
## PRD 检查块
|
||||
|
||||
至少覆盖:
|
||||
在新增玩法 PRD 中保留这一段:
|
||||
|
||||
- 入口展示
|
||||
- 类型分流
|
||||
- 工作台打开
|
||||
- session 创建
|
||||
- compile action
|
||||
- result 页切换
|
||||
- 发布后刷新作品架
|
||||
- runtime 进入与退出
|
||||
```md
|
||||
## 创作工具平台接入声明
|
||||
|
||||
## 最小改动清单
|
||||
|
||||
### 只做占位
|
||||
|
||||
只改:
|
||||
|
||||
- `src/config/newWorkEntryConfig.ts`
|
||||
|
||||
### 做到可进入工作台
|
||||
|
||||
至少改:
|
||||
|
||||
- `newWorkEntryConfig.ts`
|
||||
- `platformEntryTypes.ts`
|
||||
- `PlatformEntryFlowShellImpl.tsx`
|
||||
- 新玩法 service client
|
||||
- 新玩法工作台组件
|
||||
- shared contracts
|
||||
- 后端 API
|
||||
|
||||
### 做到完整闭环
|
||||
|
||||
还要补:
|
||||
|
||||
- result 页
|
||||
- runtime
|
||||
- works / gallery
|
||||
- public code
|
||||
- share
|
||||
- 作品架聚合
|
||||
- 测试
|
||||
|
||||
## 常见坑
|
||||
|
||||
1. 只加入口配置不够,类型分流和页面阶段也要补。
|
||||
2. `SelectionStage` 不扩展,前端无法安全切页。
|
||||
3. 新玩法如果要出现在作品架,必须改聚合逻辑,不只是加入口。
|
||||
4. 发布后不刷新 works/gallery,用户会看不到新作品。
|
||||
5. 如果走 SpacetimeDB,表结构变化要同步 migration 和绑定;`spacetime-client/src/module_bindings/` 通常是生成物,不要为了修编译或格式化而手改,优先改 module 源 schema/reducer/procedure 后重新生成。
|
||||
6. 做 analytics/tracking 这类 runtime 能力时,不要只补 API DTO;先在 `module-runtime` 写纯函数测试(例如 day/week/month/quarter/year bucket 聚合、scope/event 过滤),RED 后再补领域类型与聚合函数。
|
||||
7. 时间粒度聚合建议复用已有 date dimension 逻辑,把 daily stat 映射到 day/week/month/quarter/year bucket;bucket 输出要有稳定排序,并显式携带 `bucketKey`、`bucketStartDateKey`、`bucketEndDateKey`、`value`。
|
||||
8. 后端 shared-contracts 与前端 `packages/shared/src/contracts/runtime.ts` 要同步补 request/response/type union;admin-web 若有独立 `api/adminApiTypes.ts`,也要同步,避免共享包已更新但管理端本地类型缺失。
|
||||
9. 退出登录时要清空新玩法私有状态,避免串用户。
|
||||
10. 移动端入口卡片增多后要检查布局和滚动体验。
|
||||
|
||||
## 验证标准
|
||||
|
||||
一个玩法算真正接入成功,至少要满足:
|
||||
|
||||
- 入口能展示
|
||||
- 能进入对应工作台
|
||||
- 能创建 session
|
||||
- 能生成草稿
|
||||
- 能进入结果页
|
||||
- 能返回编辑
|
||||
- 如果需要,可试玩
|
||||
- 如果需要,可发布
|
||||
- 发布后能回到作品架 / 广场 / 分享链路
|
||||
|
||||
## 建议验证命令
|
||||
|
||||
按改动范围选择:
|
||||
|
||||
```bash
|
||||
# 后端 contracts / module-runtime / api-server
|
||||
cd server-rs
|
||||
cargo test -p shared-contracts
|
||||
cargo test -p module-runtime
|
||||
cargo check -p api-server
|
||||
|
||||
# SpacetimeDB schema/reducer/procedure 改动后,优先在有 CLI 的机器重新生成 bindings
|
||||
npm run spacetime:generate -- --rust-only
|
||||
|
||||
# 前端类型
|
||||
npm run admin-web:typecheck
|
||||
- 工作台模式:表单/图片输入创作工作台
|
||||
- 创作链路:入口 -> 工作台 -> 生成页 -> 结果页 -> 试玩 -> 发布 -> 运行态
|
||||
- 单图资产槽位:
|
||||
- slotId / slotType / slotName / 提示词来源 / 写回字段 / 是否允许历史图 / 是否允许 AI 重绘
|
||||
- 系列素材槽位:
|
||||
- batchId / sheetSpec / slotSpecs / 切图规则 / 透明化规则 / 失败回写 / 局部重生成
|
||||
- API 命名空间:/api/creation/<play>/...
|
||||
- 业务真相:后端裁决字段和前端表现字段边界
|
||||
- 创作工具模式例外:无;如有,先写明例外原因和回退方式
|
||||
- 验证命令:
|
||||
```
|
||||
|
||||
如果新增完整前端玩法闭环,还要按项目实际脚本补充 web typecheck、lint 或 Playwright/单元测试。
|
||||
## 验证门禁
|
||||
|
||||
按改动范围运行:
|
||||
|
||||
- `npm run check:encoding`
|
||||
- `npm run typecheck`
|
||||
- 前端工作台测试:确认没有聊天式 Agent 输入,提交的是表单/图片 payload。
|
||||
- `CreativeImageInputPanel` 测试:覆盖多玩法标签、上传、AI 重绘、参考图上限、历史图入口和删除确认。
|
||||
- 系列素材测试:覆盖 sheet layout、切图、透明化、OSS 持久化、追加、替换、局部重生成和失败回写。
|
||||
- 结果页测试:覆盖单图槽位重生成和系列素材槽位重生成互不阻塞。
|
||||
- 后端定向测试:覆盖 compile action、资产持久化、失败回写、发布和 runtime start。
|
||||
- 涉及 SpacetimeDB schema 时运行 `npm run check:spacetime-schema`。
|
||||
|
||||
@@ -5,7 +5,7 @@ description: Generate or inspect project image assets through this repository's
|
||||
|
||||
# gpt-image-2 VectorEngine
|
||||
|
||||
Use this skill for project-local image asset generation that must match the repository's `server-rs` VectorEngine `gpt-image-2-all` path. The folder still contains `apimart` in its name for compatibility with existing local plugin references.
|
||||
Use this skill for project-local image asset generation that must match the repository's `server-rs` VectorEngine `gpt-image-2` path. The folder still contains `apimart` in its name for compatibility with existing local plugin references.
|
||||
|
||||
## Workflow
|
||||
|
||||
@@ -40,22 +40,34 @@ Default body:
|
||||
|
||||
```json
|
||||
{
|
||||
"model": "gpt-image-2-all",
|
||||
"model": "gpt-image-2",
|
||||
"prompt": "<prompt>",
|
||||
"n": 1,
|
||||
"size": "1024x1024"
|
||||
}
|
||||
```
|
||||
|
||||
For a reference image, add:
|
||||
For visual references, use the edit endpoint instead of the create endpoint:
|
||||
|
||||
```json
|
||||
{
|
||||
"image": ["data:image/png;base64,..."]
|
||||
}
|
||||
```text
|
||||
POST {VECTOR_ENGINE_BASE_URL}/v1/images/edits
|
||||
Authorization: Bearer {VECTOR_ENGINE_API_KEY}
|
||||
Content-Type: multipart/form-data
|
||||
```
|
||||
|
||||
Accept image output from `data[].url`, `data[].b64_json`, or direct nested `url` fields. VectorEngine GPT-image-2-all currently returns synchronously; do not poll APIMart task endpoints.
|
||||
Multipart fields:
|
||||
|
||||
```text
|
||||
model=gpt-image-2
|
||||
prompt=<prompt>
|
||||
n=1
|
||||
size=1024x1024
|
||||
image=@reference.png
|
||||
```
|
||||
|
||||
In this repository, calls with no reference images use `POST /v1/images/generations`; calls with any reference image use `POST /v1/images/edits` and pass references as one or more `image` form parts. Match3D container UI generation embeds `public/match3d-background-references/pot-fused-reference.png` into the edit request as an `image` part.
|
||||
|
||||
Accept image output from `data[].url`, `data[].b64_json`, or direct nested `url` fields. VectorEngine GPT-image-2 currently returns synchronously; do not poll APIMart task endpoints.
|
||||
|
||||
## Environment
|
||||
|
||||
|
||||
@@ -0,0 +1,351 @@
|
||||
import { Buffer } from 'node:buffer';
|
||||
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = path.dirname(__filename);
|
||||
const skillRoot = path.resolve(__dirname, '..');
|
||||
const repoRoot = path.resolve(skillRoot, '..', '..', '..');
|
||||
const defaultOutDir = path.join(repoRoot, 'public', 'anthro-cat-illustrations');
|
||||
const defaultTimeoutMs = 1000000;
|
||||
|
||||
const prompts = [
|
||||
{
|
||||
id: 'cat-barista',
|
||||
title: '咖啡师猫咪',
|
||||
subject:
|
||||
'一只奶油色猫咪像人一样双足站立,穿深绿色围裙,在温暖咖啡馆吧台前专注拉花,爪子扶着咖啡杯,蓬松尾巴自然弯起,童书级精致插画,柔和自然光,主体清晰。',
|
||||
},
|
||||
{
|
||||
id: 'cat-detective',
|
||||
title: '侦探猫咪',
|
||||
subject:
|
||||
'一只黑白猫咪像侦探一样双足站在雨后街角,穿短风衣和小帽子,单爪拿放大镜,另一只爪插兜,路灯和湿润石板路反光,电影感但可爱,插画风格。',
|
||||
},
|
||||
{
|
||||
id: 'cat-dancer',
|
||||
title: '舞者猫咪',
|
||||
subject:
|
||||
'一只橘猫以拟人舞者姿态单脚旋转,穿轻盈舞台披肩,前爪展开,尾巴形成优雅弧线,背景是暖色小剧场灯光,动作灵动,精致插画。',
|
||||
},
|
||||
{
|
||||
id: 'cat-knight',
|
||||
title: '骑士猫咪',
|
||||
subject:
|
||||
'一只银灰猫咪像小骑士一样站在苔藓石台上,披短斗篷,双爪握着细剑指向地面,姿态勇敢但可亲,远处森林微光,奇幻插画风格。',
|
||||
},
|
||||
{
|
||||
id: 'cat-painter',
|
||||
title: '画家猫咪',
|
||||
subject:
|
||||
'一只三花猫咪双足站在画架前,穿宽松蓝色工作衫,一爪拿画笔一爪托调色盘,鼻尖有颜料点,窗边画室阳光明亮,温柔手绘插画。',
|
||||
},
|
||||
{
|
||||
id: 'cat-astronaut',
|
||||
title: '宇航员猫咪',
|
||||
subject:
|
||||
'一只白猫咪以拟人宇航员姿态站在月面,透明头盔内露出猫脸,尾巴在宇航服后轻轻翘起,爪子向远处蓝色星球敬礼,梦幻插画风格。',
|
||||
},
|
||||
];
|
||||
|
||||
const args = new Map();
|
||||
for (let index = 2; index < process.argv.length; index += 1) {
|
||||
const raw = process.argv[index];
|
||||
if (raw.startsWith('--')) {
|
||||
const next = process.argv[index + 1];
|
||||
if (next && !next.startsWith('--')) {
|
||||
args.set(raw, next);
|
||||
index += 1;
|
||||
} else {
|
||||
args.set(raw, true);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function readDotenv(fileName) {
|
||||
const filePath = path.join(repoRoot, fileName);
|
||||
if (!existsSync(filePath)) {
|
||||
return {};
|
||||
}
|
||||
|
||||
const values = {};
|
||||
for (const line of readFileSync(filePath, 'utf8').split(/\r?\n/u)) {
|
||||
const trimmed = line.trim();
|
||||
if (!trimmed || trimmed.startsWith('#')) {
|
||||
continue;
|
||||
}
|
||||
const match = /^([A-Za-z_][A-Za-z0-9_]*)=(.*)$/u.exec(trimmed);
|
||||
if (!match) {
|
||||
continue;
|
||||
}
|
||||
let value = match[2].trim();
|
||||
if (
|
||||
(value.startsWith('"') && value.endsWith('"')) ||
|
||||
(value.startsWith("'") && value.endsWith("'"))
|
||||
) {
|
||||
value = value.slice(1, -1);
|
||||
}
|
||||
values[match[1]] = value;
|
||||
}
|
||||
return values;
|
||||
}
|
||||
|
||||
function resolveEnv() {
|
||||
const loaded = {
|
||||
...readDotenv('.env.example'),
|
||||
...readDotenv('.env.local'),
|
||||
...readDotenv('.env.secrets.local'),
|
||||
...process.env,
|
||||
};
|
||||
return {
|
||||
baseUrl: String(loaded.VECTOR_ENGINE_BASE_URL || '')
|
||||
.trim()
|
||||
.replace(/\/+$/u, ''),
|
||||
apiKey: String(loaded.VECTOR_ENGINE_API_KEY || '').trim(),
|
||||
timeoutMs: Number.parseInt(
|
||||
String(loaded.VECTOR_ENGINE_IMAGE_REQUEST_TIMEOUT_MS || defaultTimeoutMs),
|
||||
10,
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
function buildVectorEngineImagesGenerationUrl(baseUrl) {
|
||||
return baseUrl.endsWith('/v1')
|
||||
? `${baseUrl}/images/generations`
|
||||
: `${baseUrl}/v1/images/generations`;
|
||||
}
|
||||
|
||||
function buildPrompt(entry) {
|
||||
return [
|
||||
'请生成一张高清 1:1 方形插画。',
|
||||
`画面主体:${entry.subject}`,
|
||||
'要求:猫咪保留清晰猫脸、猫耳、猫尾和毛发质感,但身体姿态像人一样自然;构图完整,角色占画面主体,适合作为项目插画素材。',
|
||||
'避免:文字、水印、边框、按钮、UI 元素、低清晰度、过度写实恐怖感、畸形肢体、多余手指。',
|
||||
].join('');
|
||||
}
|
||||
|
||||
function collectStringsByKey(value, targetKey, output) {
|
||||
if (Array.isArray(value)) {
|
||||
value.forEach((entry) => collectStringsByKey(entry, targetKey, output));
|
||||
return;
|
||||
}
|
||||
if (!value || typeof value !== 'object') {
|
||||
return;
|
||||
}
|
||||
|
||||
for (const [key, nested] of Object.entries(value)) {
|
||||
if (key === targetKey) {
|
||||
if (typeof nested === 'string' && nested.trim()) {
|
||||
output.push(nested.trim());
|
||||
}
|
||||
if (Array.isArray(nested)) {
|
||||
nested.forEach((entry) => {
|
||||
if (typeof entry === 'string' && entry.trim()) {
|
||||
output.push(entry.trim());
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
collectStringsByKey(nested, targetKey, output);
|
||||
}
|
||||
}
|
||||
|
||||
function extractImageUrls(payload) {
|
||||
const urls = [];
|
||||
collectStringsByKey(payload, 'url', urls);
|
||||
collectStringsByKey(payload, 'image', urls);
|
||||
collectStringsByKey(payload, 'image_url', urls);
|
||||
return [...new Set(urls)].filter((url) => /^https?:\/\//u.test(url));
|
||||
}
|
||||
|
||||
function extractBase64Images(payload) {
|
||||
const values = [];
|
||||
collectStringsByKey(payload, 'b64_json', values);
|
||||
return values;
|
||||
}
|
||||
|
||||
function inferExtensionFromContentType(contentType) {
|
||||
const normalized = contentType.split(';')[0]?.trim().toLowerCase();
|
||||
if (normalized === 'image/png') {
|
||||
return 'png';
|
||||
}
|
||||
if (normalized === 'image/webp') {
|
||||
return 'webp';
|
||||
}
|
||||
if (normalized === 'image/gif') {
|
||||
return 'gif';
|
||||
}
|
||||
return 'jpg';
|
||||
}
|
||||
|
||||
function inferExtensionFromBytes(bytes) {
|
||||
if (bytes.subarray(0, 8).equals(Buffer.from('\x89PNG\r\n\x1A\n', 'binary'))) {
|
||||
return 'png';
|
||||
}
|
||||
if (bytes.subarray(0, 3).equals(Buffer.from([0xff, 0xd8, 0xff]))) {
|
||||
return 'jpg';
|
||||
}
|
||||
if (
|
||||
bytes.subarray(0, 4).toString('ascii') === 'RIFF' &&
|
||||
bytes.subarray(8, 12).toString('ascii') === 'WEBP'
|
||||
) {
|
||||
return 'webp';
|
||||
}
|
||||
return 'png';
|
||||
}
|
||||
|
||||
async function fetchJson(url, options, timeoutMs) {
|
||||
const abortController = new AbortController();
|
||||
const timer = setTimeout(() => abortController.abort(), timeoutMs);
|
||||
try {
|
||||
const response = await fetch(url, {
|
||||
...options,
|
||||
signal: abortController.signal,
|
||||
});
|
||||
const text = await response.text();
|
||||
if (!response.ok) {
|
||||
throw new Error(`VectorEngine ${response.status}: ${text.slice(0, 600)}`);
|
||||
}
|
||||
return JSON.parse(text);
|
||||
} catch (error) {
|
||||
if (error?.name === 'AbortError') {
|
||||
throw new Error(`VectorEngine request timed out after ${timeoutMs}ms`);
|
||||
}
|
||||
throw error;
|
||||
} finally {
|
||||
clearTimeout(timer);
|
||||
}
|
||||
}
|
||||
|
||||
async function downloadUrl(url, timeoutMs) {
|
||||
const abortController = new AbortController();
|
||||
const timer = setTimeout(() => abortController.abort(), timeoutMs);
|
||||
try {
|
||||
const response = await fetch(url, { signal: abortController.signal });
|
||||
if (!response.ok) {
|
||||
throw new Error(`download ${response.status}`);
|
||||
}
|
||||
const bytes = Buffer.from(await response.arrayBuffer());
|
||||
return {
|
||||
bytes,
|
||||
extension: inferExtensionFromContentType(
|
||||
response.headers.get('content-type') || 'image/jpeg',
|
||||
),
|
||||
};
|
||||
} catch (error) {
|
||||
if (error?.name === 'AbortError') {
|
||||
throw new Error(`Generated image download timed out after ${timeoutMs}ms`);
|
||||
}
|
||||
throw error;
|
||||
} finally {
|
||||
clearTimeout(timer);
|
||||
}
|
||||
}
|
||||
|
||||
async function generateOne(env, entry, outDir) {
|
||||
const requestBody = {
|
||||
model: 'gpt-image-2',
|
||||
prompt: buildPrompt(entry),
|
||||
n: 1,
|
||||
size: '1024x1024',
|
||||
};
|
||||
const payload = await fetchJson(
|
||||
buildVectorEngineImagesGenerationUrl(env.baseUrl),
|
||||
{
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Authorization: `Bearer ${env.apiKey}`,
|
||||
Accept: 'application/json',
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify(requestBody),
|
||||
},
|
||||
env.timeoutMs,
|
||||
);
|
||||
|
||||
const urls = extractImageUrls(payload);
|
||||
const b64Images = extractBase64Images(payload);
|
||||
|
||||
let image;
|
||||
if (urls[0]) {
|
||||
image = await downloadUrl(urls[0], env.timeoutMs);
|
||||
} else if (b64Images[0]) {
|
||||
const bytes = Buffer.from(b64Images[0], 'base64');
|
||||
image = {
|
||||
bytes,
|
||||
extension: inferExtensionFromBytes(bytes),
|
||||
};
|
||||
} else {
|
||||
throw new Error(`VectorEngine returned no image for ${entry.id}`);
|
||||
}
|
||||
|
||||
mkdirSync(outDir, { recursive: true });
|
||||
const outputPath = path.join(outDir, `${entry.id}.${image.extension}`);
|
||||
writeFileSync(outputPath, image.bytes);
|
||||
return outputPath;
|
||||
}
|
||||
|
||||
const dryRun = args.has('--dry-run') || !args.has('--live');
|
||||
const outDir = path.resolve(String(args.get('--out-dir') || defaultOutDir));
|
||||
const limit = Number.parseInt(String(args.get('--limit') || '0'), 10);
|
||||
const selectedPrompts = limit > 0 ? prompts.slice(0, limit) : prompts;
|
||||
|
||||
if (dryRun) {
|
||||
const env = resolveEnv();
|
||||
console.log(
|
||||
JSON.stringify(
|
||||
{
|
||||
mode: 'dry-run',
|
||||
outDir,
|
||||
count: selectedPrompts.length,
|
||||
hasBaseUrl: Boolean(env.baseUrl),
|
||||
hasApiKey: Boolean(env.apiKey),
|
||||
requests: selectedPrompts.map((entry) => ({
|
||||
id: entry.id,
|
||||
title: entry.title,
|
||||
body: {
|
||||
model: 'gpt-image-2',
|
||||
prompt: buildPrompt(entry),
|
||||
n: 1,
|
||||
size: '1024x1024',
|
||||
},
|
||||
})),
|
||||
},
|
||||
null,
|
||||
2,
|
||||
),
|
||||
);
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
const env = resolveEnv();
|
||||
if (!env.baseUrl || !env.apiKey) {
|
||||
console.error(
|
||||
JSON.stringify({
|
||||
ok: false,
|
||||
error: 'Missing VECTOR_ENGINE_BASE_URL or VECTOR_ENGINE_API_KEY',
|
||||
hasBaseUrl: Boolean(env.baseUrl),
|
||||
hasApiKey: Boolean(env.apiKey),
|
||||
}),
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const generated = [];
|
||||
for (const entry of selectedPrompts) {
|
||||
console.log(`Generating ${entry.id}...`);
|
||||
generated.push(await generateOne(env, entry, outDir));
|
||||
}
|
||||
|
||||
console.log(
|
||||
JSON.stringify(
|
||||
{
|
||||
ok: true,
|
||||
count: generated.length,
|
||||
files: generated,
|
||||
},
|
||||
null,
|
||||
2,
|
||||
),
|
||||
);
|
||||
@@ -13,7 +13,7 @@ const promptsPath = path.join(
|
||||
'puzzle-template-prompts.json',
|
||||
);
|
||||
const defaultOutDir = path.join(repoRoot, 'public', 'puzzle-creation-templates');
|
||||
const defaultTimeoutMs = 180000;
|
||||
const defaultTimeoutMs = 1000000;
|
||||
|
||||
const args = new Map();
|
||||
for (let index = 2; index < process.argv.length; index += 1) {
|
||||
@@ -211,7 +211,7 @@ async function downloadUrl(url, timeoutMs) {
|
||||
|
||||
async function generateOne(env, template, outDir) {
|
||||
const requestBody = {
|
||||
model: 'gpt-image-2-all',
|
||||
model: 'gpt-image-2',
|
||||
prompt: buildPrompt(template),
|
||||
n: 1,
|
||||
size: '1024x1024',
|
||||
@@ -275,7 +275,7 @@ if (dryRun) {
|
||||
id: template.id,
|
||||
title: template.title,
|
||||
body: {
|
||||
model: 'gpt-image-2-all',
|
||||
model: 'gpt-image-2',
|
||||
prompt: buildPrompt(template),
|
||||
n: 1,
|
||||
size: '1024x1024',
|
||||
|
||||
@@ -1,229 +1,151 @@
|
||||
---
|
||||
name: spacetimedb-cli
|
||||
description: SpacetimeDB CLI reference for initializing projects, building modules, publishing databases, querying data, and managing servers
|
||||
triggers:
|
||||
- spacetime init
|
||||
- spacetime build
|
||||
- spacetime publish
|
||||
- spacetime dev
|
||||
- spacetime sql
|
||||
- spacetime call
|
||||
- spacetime logs
|
||||
- spacetime server
|
||||
- spacetime login
|
||||
- spacetime generate
|
||||
- how do I use the CLI
|
||||
- CLI command
|
||||
description: SpacetimeDB 2.5 CLI reference for Genarrative. Use for spacetime build, publish, generate, call, sql, logs, server management, local dev, explicit server targeting, version checks, and remote runtime verification.
|
||||
---
|
||||
|
||||
# SpacetimeDB CLI
|
||||
|
||||
Use this skill when the user needs help with the `spacetime` CLI tool - initializing projects, building modules, publishing databases, querying data, managing servers, or troubleshooting CLI issues.
|
||||
Use this skill when working with the `spacetime` CLI in Genarrative. Prefer repository scripts when they exist, and keep every operation pinned to an explicit target server or local process.
|
||||
|
||||
## Quick Reference
|
||||
## Genarrative Rules
|
||||
|
||||
### Project Initialization & Development
|
||||
- Do not rely on the default SpacetimeDB cloud target. Pass `--server` or `--server-url` explicitly in scripts, docs, smoke tests, and manual troubleshooting.
|
||||
- Do not introduce `maincloud` / `MAINCLOUD` commands, env vars, or docs. Treat old references as historical residue.
|
||||
- Do not use `spacetime --root-dir` in manual commands or docs. Use project scripts, `--data-dir`, explicit `--server`, or the configured running service.
|
||||
- For repository version upgrades, update `server-rs/Cargo.toml` exact pins, regenerate bindings, and verify the actual CLI/runtime version. Do not treat a local CLI reinstall as a repo upgrade.
|
||||
- For host upgrades, verify the running service binary, not just shell PATH: `systemctl show ... MainPID` -> `/proc/$pid/exe --version` -> `/v1/ping`.
|
||||
|
||||
## Core Commands
|
||||
|
||||
```bash
|
||||
# Initialize new project
|
||||
spacetime init my-project --lang rust|csharp|typescript|cpp
|
||||
spacetime init my-project --template <template-id>
|
||||
|
||||
# Build module
|
||||
spacetime build # release build
|
||||
spacetime build --debug # faster iteration, slower runtime
|
||||
spacetime build
|
||||
spacetime build --debug
|
||||
|
||||
# Dev mode (auto-rebuild, auto-publish, generates bindings)
|
||||
spacetime dev
|
||||
spacetime dev --client-lang typescript --module-bindings-path ./client/src/module_bindings
|
||||
# Publish to an explicit server
|
||||
spacetime publish my-database --server http://127.0.0.1:3101 --yes=migrate,break-clients
|
||||
|
||||
# Generate client bindings
|
||||
# Destructive publish only when explicitly intended
|
||||
spacetime publish my-database --server http://127.0.0.1:3101 --delete-data=always --yes=delete-data,migrate
|
||||
|
||||
# Delete data only for breaking schema conflicts
|
||||
spacetime publish my-database --server http://127.0.0.1:3101 --delete-data=on-conflict --yes=migrate
|
||||
|
||||
# Generate bindings
|
||||
spacetime generate --lang typescript|csharp|rust|unrealcpp --out-dir ./bindings --module-path ./server
|
||||
```
|
||||
|
||||
### Publishing & Deployment
|
||||
## Genarrative Local Workflow
|
||||
|
||||
```bash
|
||||
# Publish to an explicit server
|
||||
spacetime publish my-database --server http://127.0.0.1:3101 --yes
|
||||
# Prefer project wrappers
|
||||
npm run dev:spacetime
|
||||
npm run dev:api-server
|
||||
npm run spacetime:generate
|
||||
|
||||
# Publish to local server
|
||||
spacetime publish my-database --server local --yes
|
||||
# Query local database
|
||||
spacetime sql my-db --server http://127.0.0.1:3101 "SELECT * FROM players"
|
||||
|
||||
# Clear database and republish
|
||||
spacetime publish my-database --clear-database --yes
|
||||
# Logs
|
||||
spacetime logs my-db --server http://127.0.0.1:3101 -f
|
||||
```
|
||||
|
||||
### Database Interaction
|
||||
## Database Interaction
|
||||
|
||||
```bash
|
||||
# SQL queries
|
||||
spacetime sql my-database "SELECT * FROM users"
|
||||
spacetime sql my-database --interactive # REPL mode
|
||||
# SQL / describe
|
||||
spacetime sql my-db --server http://127.0.0.1:3101 "SELECT * FROM users"
|
||||
spacetime describe my-db --server http://127.0.0.1:3101 --json
|
||||
spacetime describe my-db table users --server http://127.0.0.1:3101 --json
|
||||
|
||||
# Call reducers
|
||||
spacetime call my-database my_reducer '{"arg1": "value", "arg2": 123}'
|
||||
# Reducer/procedure calls. Arguments are positional JSON values.
|
||||
spacetime call --server http://127.0.0.1:3101 my-db my_reducer '"value"' '123'
|
||||
|
||||
# Subscribe to changes
|
||||
spacetime subscribe my-database "SELECT * FROM users" --num-updates 10
|
||||
# 2.5 accepts hex strings for Identity arguments without full JSON tuple syntax.
|
||||
spacetime call --server http://127.0.0.1:3101 my-db reducer_needing_identity 0xabc123...
|
||||
|
||||
# View logs
|
||||
spacetime logs my-database -f # follow logs
|
||||
spacetime logs my-database -n 100 # up to 100 log lines
|
||||
|
||||
# Describe schema
|
||||
spacetime describe my-database --json
|
||||
spacetime describe my-database table users --json
|
||||
spacetime describe my-database reducer my_reducer --json
|
||||
# Subscribe from CLI
|
||||
spacetime subscribe my-db "SELECT * FROM users" --num-updates 10 --server http://127.0.0.1:3101
|
||||
```
|
||||
|
||||
### Database Management
|
||||
## Server & Auth
|
||||
|
||||
```bash
|
||||
# List databases
|
||||
spacetime list
|
||||
|
||||
# Delete database
|
||||
spacetime delete my-database
|
||||
|
||||
# Rename database
|
||||
spacetime rename <database-identity> --to new-name
|
||||
```
|
||||
|
||||
### Server Management
|
||||
|
||||
```bash
|
||||
# List configured servers
|
||||
spacetime server list
|
||||
|
||||
# Add server
|
||||
spacetime server add local --url http://localhost:3000 --default
|
||||
spacetime server add myserver --url https://my-spacetime.example.com
|
||||
spacetime server add genarrative-dev --url http://127.0.0.1:3101
|
||||
spacetime server ping genarrative-dev
|
||||
|
||||
# Set default server
|
||||
spacetime server set-default local
|
||||
|
||||
# Test connectivity
|
||||
spacetime server ping local
|
||||
|
||||
# Start local instance
|
||||
spacetime start
|
||||
|
||||
# Clear local data
|
||||
spacetime server clear
|
||||
```
|
||||
|
||||
### Authentication
|
||||
|
||||
```bash
|
||||
# Login (opens browser)
|
||||
spacetime login
|
||||
|
||||
# Login with token
|
||||
spacetime login --token <token>
|
||||
|
||||
# Show login status
|
||||
spacetime login show
|
||||
|
||||
# Logout
|
||||
spacetime logout
|
||||
```
|
||||
|
||||
## Default Servers
|
||||
|
||||
| Name | URL | Description |
|
||||
|------|-----|-------------|
|
||||
| `local` | `http://127.0.0.1:3000` | Local development server |
|
||||
| `dev` | `http://127.0.0.1:3101` | Genarrative local development server |
|
||||
|
||||
## Common Workflows
|
||||
|
||||
### New Project Setup
|
||||
## Version & Runtime Verification
|
||||
|
||||
```bash
|
||||
# 1. Login
|
||||
spacetime login
|
||||
# CLI resolution can be misleading; compare all candidates when diagnosing.
|
||||
type -a spacetime
|
||||
spacetime --version
|
||||
spacetime version list
|
||||
|
||||
# 2. Create project
|
||||
spacetime init my-game --lang rust
|
||||
cd my-game
|
||||
|
||||
# 3. Start dev mode (auto-rebuilds and publishes)
|
||||
spacetime dev
|
||||
# Verify a systemd service binary actually changed.
|
||||
pid="$(systemctl show spacetimedb.service -p MainPID --value)"
|
||||
readlink -f "/proc/${pid}/exe"
|
||||
"/proc/${pid}/exe" --version
|
||||
curl -fsS http://127.0.0.1:3101/v1/ping
|
||||
```
|
||||
|
||||
### Local Development
|
||||
## Flags
|
||||
|
||||
```bash
|
||||
# Start local server (in separate terminal)
|
||||
spacetime start
|
||||
|
||||
# Publish to local
|
||||
spacetime publish my-db --server local --clear-database --yes
|
||||
|
||||
# Query local database
|
||||
spacetime sql my-db --server local "SELECT * FROM players"
|
||||
```
|
||||
|
||||
### Generate Client Bindings
|
||||
|
||||
```bash
|
||||
# After building module
|
||||
spacetime build
|
||||
spacetime generate --lang typescript --out-dir ./client/src/bindings --module-path .
|
||||
|
||||
# Or use dev mode which auto-generates
|
||||
spacetime dev --client-lang typescript --module-bindings-path ./client/src/bindings
|
||||
```
|
||||
|
||||
## Common Flags
|
||||
|
||||
| Flag | Short | Description |
|
||||
|------|-------|-------------|
|
||||
| `--server` | `-s` | Target server (nickname, hostname, or URL) |
|
||||
| `--yes` | `-y` | Non-interactive mode (skip confirmations) |
|
||||
| `--anonymous` | | Use anonymous identity |
|
||||
| `--module-path` | `-p` | Path to module project |
|
||||
| Flag | Description |
|
||||
|------|-------------|
|
||||
| `--server`, `-s` | Target server nickname, host, or URL |
|
||||
| `--yes`, `-y` | Non-interactive prompt skipping; in 2.5 prefer scoped values |
|
||||
| `--delete-data`, `-c` | Publish data policy: `always`, `on-conflict`, or `never` |
|
||||
| `--module-path`, `-p` | Module project path |
|
||||
| `--bin-path`, `-b` | Publish/generate from compiled wasm |
|
||||
| `--no-config` | Ignore `spacetime.json` |
|
||||
| `--env` | Select config file layering environment |
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### "Not logged in"
|
||||
### Not Logged In
|
||||
|
||||
```bash
|
||||
spacetime login
|
||||
# Or use --anonymous for public operations
|
||||
```
|
||||
|
||||
### "Server not responding"
|
||||
### Server Not Responding
|
||||
|
||||
```bash
|
||||
spacetime server ping <server>
|
||||
# For local: ensure spacetime start is running
|
||||
curl -fsS http://127.0.0.1:3101/v1/ping
|
||||
```
|
||||
|
||||
### "Schema conflict"
|
||||
For local Genarrative work, start SpacetimeDB first with `npm run dev:spacetime`, then start `npm run dev:api-server`.
|
||||
|
||||
### Schema Conflict
|
||||
|
||||
```bash
|
||||
# Clear data and republish
|
||||
spacetime publish my-db --clear-database --yes
|
||||
# Clear data and republish only when conflict
|
||||
spacetime publish my-db --clear-database=on-conflict --yes
|
||||
spacetime publish my-db --server http://127.0.0.1:3101 --delete-data=on-conflict --yes=migrate
|
||||
```
|
||||
|
||||
### "Build failed"
|
||||
Use `--delete-data=always` only with explicit approval.
|
||||
|
||||
### Version Mismatch
|
||||
|
||||
```bash
|
||||
# Check Rust/C# toolchain
|
||||
rustup show
|
||||
# For Rust modules, ensure wasm32-unknown-unknown target
|
||||
rustup target add wasm32-unknown-unknown
|
||||
rg -n 'spacetimedb' server-rs/Cargo.toml
|
||||
spacetime --version
|
||||
spacetime version list
|
||||
pid="$(systemctl show spacetimedb.service -p MainPID --value)"
|
||||
"/proc/${pid}/exe" --version
|
||||
```
|
||||
|
||||
## Module Languages
|
||||
|
||||
**Server-side (modules):** Rust, C#, TypeScript, C++
|
||||
**Client SDKs:** TypeScript, C#, Rust, Python, Unreal Engine
|
||||
**CLI `generate` targets:** TypeScript, C#, Rust, Unreal C++
|
||||
|
||||
## Notes
|
||||
|
||||
- Many commands are marked UNSTABLE and may change
|
||||
- Genarrative scripts should pass `--server` or `--server-url` explicitly instead of relying on the CLI default
|
||||
- Use `--yes` flag in scripts to avoid interactive prompts
|
||||
- Dev mode watches files and auto-rebuilds on changes
|
||||
- Procedure calls are stable in 2.5; module HTTP handlers/webhooks, unstable view features, and RLS remain behind unstable gates per release notes.
|
||||
- 2.5 fixes `publish --delete-data` config fallback so `spacetime.json` can provide the database name.
|
||||
- Genarrative scripts should pass `--server` or `--server-url` explicitly instead of relying on CLI defaults.
|
||||
|
||||
@@ -1,345 +1,105 @@
|
||||
---
|
||||
name: spacetimedb-concepts
|
||||
description: Understand SpacetimeDB architecture and core concepts. Use when learning SpacetimeDB or making architectural decisions.
|
||||
license: Apache-2.0
|
||||
metadata:
|
||||
author: clockworklabs
|
||||
version: "2.0"
|
||||
description: Understand SpacetimeDB 2.5 architecture, reducer/procedure/table/view semantics, schema evolution, subscriptions, identity, and Genarrative-specific backend boundaries. Use when designing or reviewing SpacetimeDB-backed features.
|
||||
---
|
||||
|
||||
# SpacetimeDB Core Concepts
|
||||
|
||||
SpacetimeDB is a relational database that is also a server. It lets you upload application logic directly into the database via WebAssembly modules, eliminating the traditional web/game server layer entirely.
|
||||
SpacetimeDB is a relational database that also executes application logic in uploaded modules. In Genarrative, it is the data and transaction layer behind `server-rs + Axum + SpacetimeDB`, not a replacement for the `api-server` BFF or external platform adapters.
|
||||
|
||||
---
|
||||
## Genarrative Boundaries
|
||||
|
||||
## Critical Rules (Read First)
|
||||
- Domain rules live in `module-*`.
|
||||
- SpacetimeDB tables, reducers, procedures, migrations, row mappers, and read models live in `spacetime-module`.
|
||||
- Backend access goes through `spacetime-client` facades.
|
||||
- HTTP/SSE/BFF and external orchestration stay in `api-server`.
|
||||
- External side effects stay in `platform-*`.
|
||||
- Frontend renders backend truth and must not bypass BFF/projections to invent formal business state.
|
||||
|
||||
These five rules prevent the most common SpacetimeDB mistakes:
|
||||
## Critical Rules
|
||||
|
||||
1. **Reducers are transactional** — they do not return data to callers. Use subscriptions to read data.
|
||||
2. **Reducers must be deterministic** — no filesystem, network, timers, or random. All state must come from tables.
|
||||
3. **Read data via tables/subscriptions** — not reducer return values. Clients get data through subscribed queries.
|
||||
4. **Auto-increment IDs are not sequential** — gaps are normal, do not use for ordering. Use timestamps or explicit sequence columns.
|
||||
5. **`ctx.sender()` is the authenticated principal** — never trust identity passed as arguments. Always use `ctx.sender()` for authorization.
|
||||
|
||||
---
|
||||
|
||||
## Feature Implementation Checklist
|
||||
|
||||
When implementing a feature that spans backend and client:
|
||||
|
||||
1. **Backend:** Define table(s) to store the data
|
||||
2. **Backend:** Define reducer(s) to mutate the data
|
||||
3. **Client:** Subscribe to the table(s)
|
||||
4. **Client:** Call the reducer(s) from UI — **do not skip this step**
|
||||
5. **Client:** Render the data from the table(s)
|
||||
|
||||
**Common mistake:** Building backend tables/reducers but forgetting to wire up the client to call them.
|
||||
|
||||
---
|
||||
|
||||
## Debugging Checklist
|
||||
|
||||
When things are not working:
|
||||
|
||||
1. Is SpacetimeDB server running? (`spacetime start`)
|
||||
2. Is the module published? (`spacetime publish`)
|
||||
3. Are client bindings generated? (`spacetime generate`)
|
||||
4. Check server logs for errors (`spacetime logs <db-name>`)
|
||||
5. **Is the reducer actually being called from the client?**
|
||||
|
||||
---
|
||||
|
||||
## CLI Commands
|
||||
|
||||
```bash
|
||||
spacetime start
|
||||
spacetime publish <db-name> --module-path <module-path>
|
||||
spacetime publish <db-name> --clear-database -y --module-path <module-path>
|
||||
spacetime generate --lang <lang> --out-dir <out> --module-path <module-path>
|
||||
spacetime logs <db-name>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## What SpacetimeDB Is
|
||||
|
||||
SpacetimeDB combines a database and application server into a single deployable unit. Clients connect directly to the database and execute application logic inside it. The system is optimized for real-time applications requiring maximum speed and minimum latency.
|
||||
|
||||
Key characteristics:
|
||||
|
||||
- **In-memory execution**: Application state is served from memory for very low-latency access
|
||||
- **Persistent storage**: Data is automatically persisted to a write-ahead log (WAL) for durability
|
||||
- **Real-time synchronization**: Changes are automatically pushed to subscribed clients
|
||||
- **Single deployment**: No separate servers, containers, or infrastructure to manage
|
||||
|
||||
## The Five Zen Principles
|
||||
|
||||
1. **Everything is a Table**: Your entire application state lives in tables. No separate cache layer, no Redis, no in-memory state to synchronize.
|
||||
2. **Everything is Persistent**: SpacetimeDB persists state by default (for example via WAL-backed durability).
|
||||
3. **Everything is Real-Time**: Clients are replicas of server state. Subscribe to data and it flows automatically.
|
||||
4. **Everything is Transactional**: Every reducer runs atomically. Either all changes succeed or all roll back.
|
||||
5. **Everything is Programmable**: Modules are real code (Rust, C#, TypeScript) running inside the database.
|
||||
1. **Reducers are transactional**: they do not return data to callers. Read through subscriptions, read models, views, or BFF endpoints.
|
||||
2. **Reducers are deterministic**: no filesystem, network, wall-clock, or external RNG. Use `ctx.timestamp`, `ctx.rng()` / `ctx.random()`, and tables.
|
||||
3. **Procedures are stable in 2.5**: they can use explicit transactions and outgoing HTTP via `ctx.http`.
|
||||
4. **Identity comes from context**: use `ctx.sender()` or language equivalent for authorization. Never trust identity passed as an argument.
|
||||
5. **Auto-increment IDs are not ordering guarantees**: gaps are normal. Use timestamps or explicit sequence columns for ordering.
|
||||
6. **Schema changes need migration discipline**: existing Genarrative table fields must be appended with defaults; update migration code, table catalog, generated bindings, and run `npm run check:spacetime-schema`.
|
||||
|
||||
## Tables
|
||||
|
||||
Tables store all data in SpacetimeDB. They use the relational model and support SQL queries for subscriptions.
|
||||
|
||||
### Defining Tables
|
||||
|
||||
Tables are defined using language-specific attributes. In 2.0, use `accessor` (not `name`) for the API name:
|
||||
|
||||
**Rust:**
|
||||
```rust
|
||||
#[spacetimedb::table(accessor = player, public)]
|
||||
pub struct Player {
|
||||
#[primary_key]
|
||||
#[auto_inc]
|
||||
id: u32,
|
||||
#[index(btree)]
|
||||
name: String,
|
||||
#[unique]
|
||||
email: String,
|
||||
}
|
||||
```
|
||||
|
||||
**C#:**
|
||||
```csharp
|
||||
[SpacetimeDB.Table(Accessor = "Player", Public = true)]
|
||||
public partial struct Player
|
||||
{
|
||||
[SpacetimeDB.PrimaryKey]
|
||||
[SpacetimeDB.AutoInc]
|
||||
public uint Id;
|
||||
[SpacetimeDB.Index.BTree]
|
||||
public string Name;
|
||||
[SpacetimeDB.Unique]
|
||||
public string Email;
|
||||
}
|
||||
```
|
||||
|
||||
**TypeScript:**
|
||||
```typescript
|
||||
const players = table(
|
||||
{ name: 'players', public: true },
|
||||
{
|
||||
id: t.u32().primaryKey().autoInc(),
|
||||
name: t.string().index('btree'),
|
||||
email: t.string().unique(),
|
||||
}
|
||||
);
|
||||
```
|
||||
|
||||
### Table Visibility
|
||||
|
||||
- **Private tables** (default): Only accessible by reducers and the database owner
|
||||
- **Public tables**: Exposed for client read access through subscriptions. Writes still require reducers.
|
||||
|
||||
### Table Design Principles
|
||||
|
||||
Organize data by access pattern, not by entity:
|
||||
|
||||
**Decomposed approach (recommended):**
|
||||
```
|
||||
Player PlayerState PlayerStats
|
||||
id <-- player_id player_id
|
||||
name position_x total_kills
|
||||
position_y total_deaths
|
||||
velocity_x play_time
|
||||
```
|
||||
|
||||
Benefits: reduced bandwidth, cache efficiency, schema evolution, semantic clarity.
|
||||
- Private tables are the default; only reducers/procedures and database owners can access them.
|
||||
- Public tables are exposed to clients through subscriptions. Writes still go through reducers/procedures.
|
||||
- Organize data by access pattern when bandwidth or update frequency differs.
|
||||
- Existing persistent tables in Genarrative are conservative: no rename, delete, reorder, or type changes without a user-approved migration plan.
|
||||
|
||||
## Reducers
|
||||
|
||||
Reducers are transactional functions that modify database state. They are the primary client-invoked mutation path; procedures can also mutate tables by running explicit transactions.
|
||||
Reducers are deterministic transactional functions. They are the primary client-invoked mutation path.
|
||||
|
||||
### Key Properties
|
||||
- No global mutable state.
|
||||
- No filesystem, network, timers, or non-deterministic RNG.
|
||||
- Return `Result<(), String>` for expected sender-visible errors.
|
||||
- Use `ctx.sender()` for authorization.
|
||||
- Store persistent state in tables.
|
||||
|
||||
- **Transactional**: Run in isolated database transactions
|
||||
- **Atomic**: Either all changes succeed or all roll back
|
||||
- **Isolated**: Cannot interact with the outside world (no network, no filesystem)
|
||||
- **Callable**: Clients invoke reducers as remote procedure calls
|
||||
## Procedures
|
||||
|
||||
### Critical Reducer Rules
|
||||
Procedures are stable in 2.5. They can be scheduled, can open explicit transactions with `with_tx` / `try_with_tx`, and can use outgoing HTTP (`ctx.http`).
|
||||
|
||||
1. **No global state**: Relying on static variables is undefined behavior
|
||||
2. **No side effects**: Reducers cannot make network requests or access files
|
||||
3. **Store state in tables**: All persistent state must be in tables
|
||||
4. **No return data**: Reducers do not return data to callers — use subscriptions
|
||||
5. **Must be deterministic**: No random, no timers, no external I/O
|
||||
Genarrative default: keep external provider protocols in `platform-*` and orchestration in `api-server` unless a task explicitly moves a workflow into a module procedure.
|
||||
|
||||
### Defining Reducers
|
||||
Module HTTP handlers/webhooks, unstable view features, and RLS `client_visibility_filter` remain gated behind unstable according to the 2.5 release notes.
|
||||
|
||||
**Rust:**
|
||||
```rust
|
||||
#[spacetimedb::reducer]
|
||||
pub fn create_user(ctx: &ReducerContext, name: String, email: String) -> Result<(), String> {
|
||||
if name.is_empty() {
|
||||
return Err("Name cannot be empty".to_string());
|
||||
}
|
||||
ctx.db.user().insert(User { id: 0, name, email });
|
||||
Ok(())
|
||||
}
|
||||
```
|
||||
## Views
|
||||
|
||||
**C#:**
|
||||
```csharp
|
||||
[SpacetimeDB.Reducer]
|
||||
public static void CreateUser(ReducerContext ctx, string name, string email)
|
||||
{
|
||||
if (string.IsNullOrEmpty(name))
|
||||
throw new ArgumentException("Name cannot be empty");
|
||||
ctx.Db.User.Insert(new User { Id = 0, Name = name, Email = email });
|
||||
}
|
||||
```
|
||||
Views expose computed read-only data. In 2.4.1 Rust and TypeScript gained primary key support for procedural views; in 2.5 C# gained the same. Clients can receive `OnUpdate` events when subscribed to such views with primary keys. Ensure the view never returns duplicate primary keys, because that can fail view refresh and roll back the triggering transaction.
|
||||
|
||||
### ReducerContext
|
||||
## Event Tables
|
||||
|
||||
Every reducer receives a `ReducerContext` providing:
|
||||
- **Database**: `ctx.db` (Rust field, TS property) / `ctx.Db` (C# property)
|
||||
- **Sender**: `ctx.sender()` (Rust method) / `ctx.Sender` (C# property) / `ctx.sender` (TS property)
|
||||
- **Connection ID**: `ctx.connection_id()` (Rust method) / `ctx.ConnectionId` (C# property) / `ctx.connectionId` (TS property)
|
||||
- **Timestamp**: `ctx.timestamp` (Rust field, TS property) / `ctx.Timestamp` (C# property)
|
||||
Event tables broadcast reducer/procedure-specific facts to subscribers and must be subscribed explicitly. They are excluded from `subscribe_to_all_tables()`.
|
||||
|
||||
## Event Tables (2.0)
|
||||
2.5 adds broader layout-altering automigrations for event tables, including column removal, reordering, and type changes that regular tables reject. This relaxed migration behavior is for event-only tables, not persistent tables.
|
||||
|
||||
Event tables are the preferred way to broadcast reducer-specific data to clients.
|
||||
Event-table primary keys and constraints are transaction-scoped. They can reject duplicate event rows within one transaction, but event rows are not retained in client cache, so clients observe event tables through insert callbacks only. Do not design Genarrative event tables around `OnUpdate` / `on_update` / `onUpdate`; use a persistent table or a primary-keyed procedural view when update callbacks are required.
|
||||
|
||||
```rust
|
||||
#[table(accessor = damage_event, public, event)]
|
||||
pub struct DamageEvent {
|
||||
pub target: Identity,
|
||||
pub amount: u32,
|
||||
}
|
||||
|
||||
#[reducer]
|
||||
fn deal_damage(ctx: &ReducerContext, target: Identity, amount: u32) {
|
||||
ctx.db.damage_event().insert(DamageEvent { target, amount });
|
||||
}
|
||||
```
|
||||
|
||||
Clients subscribe to event tables and use `on_insert` callbacks. Event tables must be subscribed explicitly and are excluded from `subscribe_to_all_tables()`.
|
||||
Official 2.4.1/2.5 release notes document primary-key-backed update callbacks for procedural views, not event tables.
|
||||
|
||||
## Subscriptions
|
||||
|
||||
Subscriptions replicate database rows to clients in real-time.
|
||||
1. Subscribe to SQL queries or generated table/query builders.
|
||||
2. Receive initial matching rows.
|
||||
3. Receive updates when subscribed rows change.
|
||||
4. Render from subscribed data, not reducer return values.
|
||||
|
||||
### How Subscriptions Work
|
||||
Best practices:
|
||||
|
||||
1. **Subscribe**: Register SQL queries describing needed data
|
||||
2. **Receive initial data**: All matching rows are sent immediately
|
||||
3. **Receive updates**: Real-time updates when subscribed rows change
|
||||
4. **React to changes**: Use callbacks (`onInsert`, `onDelete`, `onUpdate`)
|
||||
- Group subscriptions by lifetime.
|
||||
- Subscribe to new data before unsubscribing old data during transitions.
|
||||
- Avoid overlapping queries that duplicate row delivery.
|
||||
- Use indexes for subscribed filters.
|
||||
|
||||
### Subscription Best Practices
|
||||
## 2.2.0 to 2.5.0 Delta
|
||||
|
||||
1. **Group subscriptions by lifetime**: Keep always-needed data separate from temporary subscriptions
|
||||
2. **Subscribe before unsubscribing**: When updating subscriptions, subscribe to new data first
|
||||
3. **Avoid overlapping queries**: Distinct queries returning overlapping data cause redundant processing
|
||||
4. **Use indexes**: Queries on indexed columns are efficient; full table scans are expensive
|
||||
Genarrative introduced SpacetimeDB around 2.2.0. Important changes since then:
|
||||
|
||||
## Modules
|
||||
- **2.2.0**: v3 WebSocket transport and TS SDK default, safer production operations (`lock`/`unlock`, safer `delete`, better `publish --yes`), TS React `useProcedure`, table clearing APIs, empty-table drop automigration, primary-key migration fixes, bytes-key B-tree support, durability hardening.
|
||||
- **2.3.0**: first-party Godot SDK, more WebSocket pipelining/batching, HTTP/2 backend support, Vue `useProcedure`, Unity 6 WebGL support, commitlog compression/throughput improvements, Rust `DbContext` generics, `ReducerContext::identity` deprecated in favor of `database_identity`, connection lifecycle and unsubscribe fixes.
|
||||
- **2.4.0**: unstable module HTTP handlers/webhooks, faster synchronous WASM reducer runtime, commitlog resume truncation fix for silent data loss risk, better commitlog decode context, V8 heap metrics for procedure workers, JS execution-time billing regression reverted.
|
||||
- **2.4.1**: Rust and TypeScript procedural views can declare primary keys, enabling `OnUpdate` events for subscribed views; fixed index schema from ST tables.
|
||||
- **2.5.0**: procedures are stable, C# procedural views gain primary keys, event tables allow broader layout-altering automigrations, BTreeSet storage makes row insertion deterministic and avoids accidentally quadratic bulk insert behavior, `wasm_memory_bytes` billing metric semantics changed, template version constraints unified, `publish --delete-data` config fallback fixed, CLI `call` accepts hex Identity arguments.
|
||||
|
||||
Modules are WebAssembly bundles containing application logic that runs inside the database.
|
||||
## Debugging Checklist
|
||||
|
||||
### Module Components
|
||||
|
||||
- **Tables**: Define the data schema
|
||||
- **Reducers**: Define callable functions that modify state
|
||||
- **Views**: Define read-only computed queries
|
||||
- **Event Tables**: Broadcast reducer-specific data to clients (2.0)
|
||||
- **Procedures**: (Beta) Functions that can have side effects (HTTP requests)
|
||||
|
||||
### Module Languages
|
||||
|
||||
Server-side modules can be written in: Rust, C#, TypeScript (beta)
|
||||
|
||||
### Module Lifecycle
|
||||
|
||||
1. **Write**: Define tables and reducers in your chosen language
|
||||
2. **Compile**: Build to WebAssembly using the SpacetimeDB CLI
|
||||
3. **Publish**: Upload to a SpacetimeDB host with `spacetime publish`
|
||||
4. **Hot-swap**: Republish to update code without disconnecting clients
|
||||
|
||||
## Identity
|
||||
|
||||
Identity is SpacetimeDB's authentication system based on OpenID Connect (OIDC).
|
||||
|
||||
- **Identity**: A long-lived, globally unique identifier for a user.
|
||||
- **ConnectionId**: Identifies a specific client connection.
|
||||
|
||||
```rust
|
||||
#[spacetimedb::reducer]
|
||||
pub fn do_something(ctx: &ReducerContext) {
|
||||
let caller_identity = ctx.sender(); // Who is calling?
|
||||
// NEVER trust identity passed as a reducer argument
|
||||
}
|
||||
```
|
||||
|
||||
### Authentication Providers
|
||||
|
||||
SpacetimeDB works with many OIDC providers, including SpacetimeAuth (built-in), Auth0, Clerk, Keycloak, Google, and GitHub.
|
||||
|
||||
## When to Use SpacetimeDB
|
||||
|
||||
### Ideal Use Cases
|
||||
|
||||
- **Real-time games**: MMOs, multiplayer games, turn-based games
|
||||
- **Collaborative applications**: Document editing, whiteboards, design tools
|
||||
- **Chat and messaging**: Real-time communication with presence
|
||||
- **Live dashboards**: Streaming analytics and monitoring
|
||||
|
||||
### Key Decision Factors
|
||||
|
||||
Choose SpacetimeDB when you need:
|
||||
- Sub-10ms latency for reads and writes
|
||||
- Automatic real-time synchronization
|
||||
- Transactional guarantees for all operations
|
||||
- Simplified architecture (no separate cache, queue, or server)
|
||||
|
||||
### Less Suitable For
|
||||
|
||||
- **Batch analytics**: Optimized for OLTP, not OLAP
|
||||
- **Large blob storage**: Better suited for structured relational data
|
||||
- **Stateless APIs**: Traditional REST APIs do not need real-time sync
|
||||
|
||||
## Common Patterns
|
||||
|
||||
**Authentication check in reducer:**
|
||||
```rust
|
||||
#[spacetimedb::reducer]
|
||||
fn admin_action(ctx: &ReducerContext) -> Result<(), String> {
|
||||
let admin = ctx.db.admin().identity().find(&ctx.sender())
|
||||
.ok_or("Not an admin")?;
|
||||
Ok(())
|
||||
}
|
||||
```
|
||||
|
||||
**Scheduled reducer:**
|
||||
```rust
|
||||
#[spacetimedb::table(accessor = reminder, scheduled(send_reminder))]
|
||||
pub struct Reminder {
|
||||
#[primary_key]
|
||||
#[auto_inc]
|
||||
id: u64,
|
||||
scheduled_at: ScheduleAt,
|
||||
message: String,
|
||||
}
|
||||
|
||||
#[spacetimedb::reducer]
|
||||
fn send_reminder(ctx: &ReducerContext, reminder: Reminder) {
|
||||
log::info!("Reminder: {}", reminder.message);
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
1. Is the Genarrative SpacetimeDB server running? Use `npm run dev:spacetime` locally or host-local `systemctl`.
|
||||
2. Is the module published to the same server the API uses?
|
||||
3. Are generated bindings current? Use `npm run spacetime:generate`.
|
||||
4. Is `api-server` using the same database and token?
|
||||
5. Is the reducer/procedure actually called?
|
||||
6. Did `/healthz` / `/readyz` pass while business SpacetimeDB calls still timeout? Inspect API logs and public route behavior.
|
||||
|
||||
## Editing Behavior
|
||||
|
||||
When modifying SpacetimeDB code:
|
||||
|
||||
- Make the smallest change necessary
|
||||
- Do NOT touch unrelated files, configs, or dependencies
|
||||
- Do NOT invent new SpacetimeDB APIs — use only what exists in docs or this repo
|
||||
- Make the smallest change necessary.
|
||||
- Do not invent SpacetimeDB APIs; verify against current docs, generated bindings, or source.
|
||||
- For Genarrative schema edits, update migration code, table catalog/docs, generated bindings, and relevant tests.
|
||||
- After schema edits, run `npm run spacetime:generate` and `npm run check:spacetime-schema`.
|
||||
|
||||
@@ -1,646 +0,0 @@
|
||||
---
|
||||
name: spacetimedb-csharp
|
||||
description: Build C# modules and clients for SpacetimeDB. Covers server-side module development and client SDK integration.
|
||||
license: Apache-2.0
|
||||
metadata:
|
||||
author: clockworklabs
|
||||
version: "2.0"
|
||||
tested_with: "SpacetimeDB 2.0, .NET 8 SDK"
|
||||
---
|
||||
|
||||
# SpacetimeDB C# SDK
|
||||
|
||||
This skill provides guidance for building C# server-side modules and C# clients that connect to SpacetimeDB 2.0.
|
||||
|
||||
---
|
||||
|
||||
## HALLUCINATED APIs — DO NOT USE
|
||||
|
||||
**These APIs DO NOT EXIST. LLMs frequently hallucinate them.**
|
||||
|
||||
```csharp
|
||||
// WRONG — these table access patterns do not exist
|
||||
ctx.db.tableName // Wrong casing — use ctx.Db
|
||||
ctx.Db.tableName // Wrong casing — accessor must match exactly
|
||||
ctx.Db.TableName.Get(id) // Use Find, not Get
|
||||
ctx.Db.TableName.FindById(id) // Use index accessor: ctx.Db.TableName.Id.Find(id)
|
||||
ctx.Db.table.field_name.Find(x) // Wrong! Use PascalCase: ctx.Db.Table.FieldName.Find(x)
|
||||
Optional<string> field; // Use C# nullable: string? field
|
||||
|
||||
// WRONG — missing partial keyword
|
||||
public struct MyTable { } // Must be "partial struct"
|
||||
public class Module { } // Must be "static partial class"
|
||||
|
||||
// WRONG — non-partial types
|
||||
[SpacetimeDB.Table(Accessor = "Player")]
|
||||
public struct Player { } // WRONG — missing partial!
|
||||
|
||||
// WRONG — sum type syntax (VERY COMMON MISTAKE)
|
||||
public partial struct Shape : TaggedEnum<(Circle, Rectangle)> { } // WRONG: struct, missing names
|
||||
public partial record Shape : TaggedEnum<(Circle, Rectangle)> { } // WRONG: missing variant names
|
||||
public partial class Shape : TaggedEnum<(Circle Circle, Rectangle Rectangle)> { } // WRONG: class
|
||||
|
||||
// WRONG — Index attribute without full qualification
|
||||
[Index.BTree(Accessor = "idx", Columns = new[] { "Col" })] // Ambiguous with System.Index!
|
||||
[SpacetimeDB.Index.BTree(Accessor = "idx", Columns = ["Col"])] // Valid with modern C# collection expressions
|
||||
|
||||
// WRONG — old 1.0 patterns
|
||||
[SpacetimeDB.Table(Name = "Player")] // Use Accessor, not Name (2.0)
|
||||
<PackageReference Include="SpacetimeDB.ServerSdk" /> // Use SpacetimeDB.Runtime
|
||||
.WithModuleName("my-db") // Use .WithDatabaseName() (2.0)
|
||||
ScheduleAt.Time(futureTime) // Use new ScheduleAt.Time(futureTime)
|
||||
|
||||
// WRONG — lifecycle hooks starting with "On"
|
||||
[SpacetimeDB.Reducer(ReducerKind.ClientConnected)]
|
||||
public static void OnClientConnected(ReducerContext ctx) { } // STDB0010 error!
|
||||
|
||||
// WRONG — non-deterministic code in reducers
|
||||
var random = new Random(); // Use ctx.Rng
|
||||
var guid = Guid.NewGuid(); // Not allowed
|
||||
var now = DateTime.Now; // Use ctx.Timestamp
|
||||
|
||||
// WRONG — collection parameters
|
||||
int[] itemIds = { 1, 2, 3 };
|
||||
_conn.Reducers.ProcessItems(itemIds); // Generated code expects List<T>!
|
||||
```
|
||||
|
||||
### CORRECT PATTERNS
|
||||
|
||||
```csharp
|
||||
using SpacetimeDB;
|
||||
|
||||
// CORRECT TABLE — must be partial struct, use Accessor
|
||||
[SpacetimeDB.Table(Accessor = "Player", Public = true)]
|
||||
public partial struct Player
|
||||
{
|
||||
[SpacetimeDB.PrimaryKey]
|
||||
[SpacetimeDB.AutoInc]
|
||||
public ulong Id;
|
||||
[SpacetimeDB.Index.BTree]
|
||||
public Identity OwnerId;
|
||||
public string Name;
|
||||
}
|
||||
|
||||
// CORRECT MODULE — must be static partial class
|
||||
public static partial class Module
|
||||
{
|
||||
[SpacetimeDB.Reducer]
|
||||
public static void CreatePlayer(ReducerContext ctx, string name)
|
||||
{
|
||||
ctx.Db.Player.Insert(new Player { Id = 0, OwnerId = ctx.Sender, Name = name });
|
||||
}
|
||||
}
|
||||
|
||||
// CORRECT DATABASE ACCESS — PascalCase, index-based lookups
|
||||
var player = ctx.Db.Player.Id.Find(playerId); // Unique/PK: returns nullable
|
||||
foreach (var p in ctx.Db.Player.OwnerId.Filter(ctx.Sender)) { } // BTree: returns IEnumerable
|
||||
|
||||
// CORRECT SUM TYPE — partial record with named tuple elements
|
||||
[SpacetimeDB.Type]
|
||||
public partial record Shape : TaggedEnum<(Circle Circle, Rectangle Rectangle)> { }
|
||||
|
||||
// CORRECT — collection parameters use List<T>
|
||||
_conn.Reducers.ProcessItems(new List<int> { 1, 2, 3 });
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Common Mistakes Table
|
||||
|
||||
| Wrong | Right | Error |
|
||||
|-------|-------|-------|
|
||||
| Wrong .csproj name | `StdbModule.csproj` | Publish fails silently |
|
||||
| .NET 9 SDK | .NET 8 SDK only | WASI compilation fails |
|
||||
| Missing WASI workload | `dotnet workload install wasi-experimental` | Build fails |
|
||||
| async/await in reducers | Synchronous only | Not supported |
|
||||
| `table.Name.Update(...)` | `table.Id.Update(...)` | Update only via primary key (2.0) |
|
||||
| Not calling `FrameTick()` | `conn.FrameTick()` in Update loop | No callbacks fire |
|
||||
| Accessing `conn.Db` from background thread | Copy data in callback | Data races |
|
||||
|
||||
---
|
||||
|
||||
## Hard Requirements
|
||||
|
||||
1. **Tables and Module MUST be `partial`** — required for code generation
|
||||
2. **Use `Accessor =` in table attributes** — `Name =` is only for SQL compatibility (2.0)
|
||||
3. **Project file MUST be named `StdbModule.csproj`** — CLI requirement
|
||||
4. **Requires .NET 8 SDK** — .NET 9 and newer not yet supported
|
||||
5. **Install WASI workload** — `dotnet workload install wasi-experimental`
|
||||
6. **Procedures are supported** — use `[SpacetimeDB.Procedure]` with `ProcedureContext` when needed
|
||||
7. **Reducers must be deterministic** — no filesystem, network, timers, or `Random`
|
||||
8. **Add `Public = true`** — if clients need to subscribe to a table
|
||||
9. **Use `T?` for nullable fields** — not `Optional<T>`
|
||||
10. **Pass `0` for auto-increment** — to trigger ID generation on insert
|
||||
11. **Sum types must be `partial record`** — not struct or class
|
||||
12. **Fully qualify Index attribute** — `[SpacetimeDB.Index.BTree]` to avoid System.Index ambiguity
|
||||
13. **Update only via primary key** — use delete+insert for non-PK changes (2.0)
|
||||
14. **Use `SpacetimeDB.Runtime` package** — not `ServerSdk` (2.0)
|
||||
15. **Use `List<T>` for collection parameters** — not arrays
|
||||
16. **`Identity` is in `SpacetimeDB` namespace** — not `SpacetimeDB.Types`
|
||||
|
||||
---
|
||||
|
||||
## Server-Side Module Development
|
||||
|
||||
### Table Definition
|
||||
|
||||
```csharp
|
||||
using SpacetimeDB;
|
||||
|
||||
[SpacetimeDB.Table(Accessor = "Player", Public = true)]
|
||||
public partial struct Player
|
||||
{
|
||||
[SpacetimeDB.PrimaryKey]
|
||||
[SpacetimeDB.AutoInc]
|
||||
public ulong Id;
|
||||
|
||||
[SpacetimeDB.Index.BTree]
|
||||
public Identity OwnerId;
|
||||
|
||||
public string Name;
|
||||
public Timestamp CreatedAt;
|
||||
}
|
||||
|
||||
// Multi-column index (use fully-qualified attribute!)
|
||||
[SpacetimeDB.Table(Accessor = "Score", Public = true)]
|
||||
[SpacetimeDB.Index.BTree(Accessor = "by_player_game", Columns = new[] { "PlayerId", "GameId" })]
|
||||
public partial struct Score
|
||||
{
|
||||
[SpacetimeDB.PrimaryKey]
|
||||
[SpacetimeDB.AutoInc]
|
||||
public ulong Id;
|
||||
public Identity PlayerId;
|
||||
public string GameId;
|
||||
public int Points;
|
||||
}
|
||||
```
|
||||
|
||||
### Field Attributes
|
||||
|
||||
```csharp
|
||||
[SpacetimeDB.PrimaryKey] // Exactly one per table (required)
|
||||
[SpacetimeDB.AutoInc] // Auto-increment (integer fields only)
|
||||
[SpacetimeDB.Unique] // Unique constraint
|
||||
[SpacetimeDB.Index.BTree] // Single-column B-tree index
|
||||
[SpacetimeDB.Default(value)] // Default value for new columns
|
||||
```
|
||||
|
||||
### SpacetimeDB Column Types
|
||||
|
||||
```csharp
|
||||
Identity // User identity (SpacetimeDB namespace, not SpacetimeDB.Types)
|
||||
Timestamp // Timestamp (use ctx.Timestamp server-side, never DateTime.Now)
|
||||
ScheduleAt // For scheduled tables
|
||||
T? // Nullable (e.g., string?)
|
||||
List<T> // Collections (use List, not arrays)
|
||||
```
|
||||
|
||||
Standard C# primitives (`bool`, `byte`..`ulong`, `float`, `double`, `string`) are all supported.
|
||||
|
||||
### Insert with Auto-Increment
|
||||
|
||||
```csharp
|
||||
var player = ctx.Db.Player.Insert(new Player
|
||||
{
|
||||
Id = 0, // Pass 0 to trigger auto-increment
|
||||
OwnerId = ctx.Sender,
|
||||
Name = name,
|
||||
CreatedAt = ctx.Timestamp
|
||||
});
|
||||
ulong newId = player.Id; // Insert returns the row with generated ID
|
||||
```
|
||||
|
||||
### Module and Reducers
|
||||
|
||||
```csharp
|
||||
using SpacetimeDB;
|
||||
|
||||
public static partial class Module
|
||||
{
|
||||
[SpacetimeDB.Reducer]
|
||||
public static void CreateTask(ReducerContext ctx, string title)
|
||||
{
|
||||
if (string.IsNullOrEmpty(title))
|
||||
throw new Exception("Title cannot be empty");
|
||||
|
||||
ctx.Db.Task.Insert(new Task
|
||||
{
|
||||
Id = 0,
|
||||
OwnerId = ctx.Sender,
|
||||
Title = title,
|
||||
Completed = false
|
||||
});
|
||||
}
|
||||
|
||||
[SpacetimeDB.Reducer]
|
||||
public static void CompleteTask(ReducerContext ctx, ulong taskId)
|
||||
{
|
||||
if (ctx.Db.Task.Id.Find(taskId) is not Task task)
|
||||
throw new Exception("Task not found");
|
||||
if (task.OwnerId != ctx.Sender)
|
||||
throw new Exception("Not authorized");
|
||||
|
||||
ctx.Db.Task.Id.Update(task with { Completed = true });
|
||||
}
|
||||
|
||||
[SpacetimeDB.Reducer]
|
||||
public static void DeleteTask(ReducerContext ctx, ulong taskId)
|
||||
{
|
||||
ctx.Db.Task.Id.Delete(taskId);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Lifecycle Reducers
|
||||
|
||||
```csharp
|
||||
public static partial class Module
|
||||
{
|
||||
[SpacetimeDB.Reducer(ReducerKind.Init)]
|
||||
public static void Init(ReducerContext ctx)
|
||||
{
|
||||
Log.Info("Module initialized");
|
||||
}
|
||||
|
||||
// CRITICAL: no "On" prefix!
|
||||
[SpacetimeDB.Reducer(ReducerKind.ClientConnected)]
|
||||
public static void ClientConnected(ReducerContext ctx)
|
||||
{
|
||||
Log.Info($"Client connected: {ctx.Sender}");
|
||||
if (ctx.Db.User.Identity.Find(ctx.Sender) is User user)
|
||||
{
|
||||
ctx.Db.User.Identity.Update(user with { Online = true });
|
||||
}
|
||||
else
|
||||
{
|
||||
ctx.Db.User.Insert(new User { Identity = ctx.Sender, Online = true });
|
||||
}
|
||||
}
|
||||
|
||||
[SpacetimeDB.Reducer(ReducerKind.ClientDisconnected)]
|
||||
public static void ClientDisconnected(ReducerContext ctx)
|
||||
{
|
||||
if (ctx.Db.User.Identity.Find(ctx.Sender) is User user)
|
||||
{
|
||||
ctx.Db.User.Identity.Update(user with { Online = false });
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Event Tables (2.0)
|
||||
|
||||
Reducer callbacks are removed in 2.0. Use event tables + `OnInsert` instead.
|
||||
|
||||
```csharp
|
||||
[SpacetimeDB.Table(Accessor = "DamageEvent", Public = true, Event = true)]
|
||||
public partial struct DamageEvent
|
||||
{
|
||||
public Identity Target;
|
||||
public uint Amount;
|
||||
}
|
||||
|
||||
[SpacetimeDB.Reducer]
|
||||
public static void DealDamage(ReducerContext ctx, Identity target, uint amount)
|
||||
{
|
||||
ctx.Db.DamageEvent.Insert(new DamageEvent { Target = target, Amount = amount });
|
||||
}
|
||||
```
|
||||
|
||||
Client subscribes and uses `OnInsert`:
|
||||
```csharp
|
||||
conn.Db.DamageEvent.OnInsert += (ctx, evt) => {
|
||||
PlayDamageAnimation(evt.Target, evt.Amount);
|
||||
};
|
||||
```
|
||||
|
||||
Event tables must be subscribed explicitly — they are excluded from `SubscribeToAllTables()`.
|
||||
|
||||
### Database Access
|
||||
|
||||
```csharp
|
||||
// Find by primary key — returns nullable, use pattern matching
|
||||
if (ctx.Db.Task.Id.Find(taskId) is Task task) { /* use task */ }
|
||||
|
||||
// Update by primary key (2.0: only primary key has .Update)
|
||||
ctx.Db.Task.Id.Update(task with { Title = newTitle });
|
||||
|
||||
// Delete by primary key
|
||||
ctx.Db.Task.Id.Delete(taskId);
|
||||
|
||||
// Find by unique index — returns nullable
|
||||
if (ctx.Db.Player.Username.Find("alice") is Player player) { }
|
||||
|
||||
// Filter by B-tree index — returns iterator
|
||||
foreach (var task in ctx.Db.Task.OwnerId.Filter(ctx.Sender)) { }
|
||||
|
||||
// Full table scan — avoid for large tables
|
||||
foreach (var task in ctx.Db.Task.Iter()) { }
|
||||
var count = ctx.Db.Task.Count;
|
||||
```
|
||||
|
||||
### Custom Types and Sum Types
|
||||
|
||||
```csharp
|
||||
[SpacetimeDB.Type]
|
||||
public partial struct Position { public int X; public int Y; }
|
||||
|
||||
// Sum types MUST be partial record with named tuple
|
||||
[SpacetimeDB.Type]
|
||||
public partial struct Circle { public int Radius; }
|
||||
[SpacetimeDB.Type]
|
||||
public partial struct Rectangle { public int Width; public int Height; }
|
||||
[SpacetimeDB.Type]
|
||||
public partial record Shape : TaggedEnum<(Circle Circle, Rectangle Rectangle)> { }
|
||||
|
||||
// Creating sum type values
|
||||
var circle = new Shape.Circle(new Circle { Radius = 10 });
|
||||
```
|
||||
|
||||
### Scheduled Tables
|
||||
|
||||
```csharp
|
||||
[SpacetimeDB.Table(Accessor = "Reminder", Scheduled = nameof(Module.SendReminder))]
|
||||
public partial struct Reminder
|
||||
{
|
||||
[SpacetimeDB.PrimaryKey]
|
||||
[SpacetimeDB.AutoInc]
|
||||
public ulong Id;
|
||||
public string Message;
|
||||
public ScheduleAt ScheduledAt;
|
||||
}
|
||||
|
||||
public static partial class Module
|
||||
{
|
||||
[SpacetimeDB.Reducer]
|
||||
public static void SendReminder(ReducerContext ctx, Reminder reminder)
|
||||
{
|
||||
Log.Info($"Reminder: {reminder.Message}");
|
||||
}
|
||||
|
||||
[SpacetimeDB.Reducer]
|
||||
public static void CreateReminder(ReducerContext ctx, string message, ulong delaySecs)
|
||||
{
|
||||
ctx.Db.Reminder.Insert(new Reminder
|
||||
{
|
||||
Id = 0,
|
||||
Message = message,
|
||||
ScheduledAt = new ScheduleAt.Time(ctx.Timestamp + TimeSpan.FromSeconds(delaySecs))
|
||||
});
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Logging
|
||||
|
||||
```csharp
|
||||
Log.Debug("Debug message");
|
||||
Log.Info("Information");
|
||||
Log.Warn("Warning");
|
||||
Log.Error("Error occurred");
|
||||
Log.Exception("Critical failure"); // Logs at error level
|
||||
```
|
||||
|
||||
### ReducerContext API
|
||||
|
||||
```csharp
|
||||
ctx.Sender // Identity of the caller
|
||||
ctx.Timestamp // Current timestamp
|
||||
ctx.Db // Database access
|
||||
ctx.Identity // Module's own identity
|
||||
ctx.ConnectionId // Connection ID (nullable)
|
||||
ctx.SenderAuth // Authorization context (JWT claims, internal call detection)
|
||||
ctx.Rng // Deterministic random number generator
|
||||
```
|
||||
|
||||
### Error Handling
|
||||
|
||||
Throwing an exception in a reducer rolls back the entire transaction:
|
||||
|
||||
```csharp
|
||||
[SpacetimeDB.Reducer]
|
||||
public static void TransferCredits(ReducerContext ctx, Identity toUser, uint amount)
|
||||
{
|
||||
if (ctx.Db.User.Identity.Find(ctx.Sender) is not User sender)
|
||||
throw new Exception("Sender not found");
|
||||
|
||||
if (sender.Credits < amount)
|
||||
throw new Exception("Insufficient credits");
|
||||
|
||||
ctx.Db.User.Identity.Update(sender with { Credits = sender.Credits - amount });
|
||||
|
||||
if (ctx.Db.User.Identity.Find(toUser) is User receiver)
|
||||
ctx.Db.User.Identity.Update(receiver with { Credits = receiver.Credits + amount });
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Project Setup
|
||||
|
||||
### Required .csproj (MUST be named `StdbModule.csproj`)
|
||||
|
||||
```xml
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net8.0</TargetFramework>
|
||||
<RuntimeIdentifier>wasi-wasm</RuntimeIdentifier>
|
||||
<OutputType>Exe</OutputType>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
</PropertyGroup>
|
||||
<ItemGroup>
|
||||
<PackageReference Include="SpacetimeDB.Runtime" Version="1.*" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
```
|
||||
|
||||
### Prerequisites
|
||||
|
||||
```bash
|
||||
# Install .NET 8 SDK (required, not .NET 9)
|
||||
# Install WASI workload
|
||||
dotnet workload install wasi-experimental
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Client SDK
|
||||
|
||||
### Installation
|
||||
|
||||
```bash
|
||||
dotnet add package SpacetimeDB.ClientSDK
|
||||
```
|
||||
|
||||
### Generate Module Bindings
|
||||
|
||||
```bash
|
||||
spacetime generate --lang csharp --out-dir module_bindings --module-path PATH_TO_MODULE
|
||||
```
|
||||
|
||||
This creates `SpacetimeDBClient.g.cs`, `Tables/*.g.cs`, `Reducers/*.g.cs`, and `Types/*.g.cs`.
|
||||
|
||||
### Connection Setup
|
||||
|
||||
```csharp
|
||||
using SpacetimeDB;
|
||||
using SpacetimeDB.Types;
|
||||
|
||||
var conn = DbConnection.Builder()
|
||||
.WithUri("http://localhost:3000")
|
||||
.WithDatabaseName("my-database")
|
||||
.WithToken(savedToken)
|
||||
.OnConnect(OnConnected)
|
||||
.OnConnectError(err => Console.Error.WriteLine($"Failed: {err}"))
|
||||
.OnDisconnect((conn, err) => { if (err != null) Console.Error.WriteLine(err); })
|
||||
.Build();
|
||||
|
||||
void OnConnected(DbConnection conn, Identity identity, string authToken)
|
||||
{
|
||||
// Save authToken to persistent storage for reconnection
|
||||
Console.WriteLine($"Connected: {identity}");
|
||||
conn.SubscriptionBuilder()
|
||||
.OnApplied(OnSubscriptionApplied)
|
||||
.SubscribeToAllTables();
|
||||
}
|
||||
```
|
||||
|
||||
### Critical: FrameTick
|
||||
|
||||
**The SDK does NOT automatically process messages.** You must call `FrameTick()` regularly.
|
||||
|
||||
```csharp
|
||||
// Console application
|
||||
while (running) { conn.FrameTick(); Thread.Sleep(16); }
|
||||
|
||||
// Unity: call conn?.FrameTick() in Update()
|
||||
```
|
||||
|
||||
**Warning**: Do NOT call `FrameTick()` from a background thread. It modifies `conn.Db` and can cause data races.
|
||||
|
||||
### Subscribing to Tables
|
||||
|
||||
```csharp
|
||||
// SQL queries
|
||||
conn.SubscriptionBuilder()
|
||||
.OnApplied(OnSubscriptionApplied)
|
||||
.OnError((ctx, err) => Console.Error.WriteLine($"Subscription failed: {err}"))
|
||||
.Subscribe(new[] {
|
||||
"SELECT * FROM player",
|
||||
"SELECT * FROM message WHERE sender = :sender"
|
||||
});
|
||||
|
||||
// Subscribe to all tables (development only)
|
||||
conn.SubscriptionBuilder()
|
||||
.OnApplied(OnSubscriptionApplied)
|
||||
.SubscribeToAllTables();
|
||||
|
||||
// Subscription handle for later unsubscribe
|
||||
SubscriptionHandle handle = conn.SubscriptionBuilder()
|
||||
.OnApplied(ctx => Console.WriteLine("Applied"))
|
||||
.Subscribe(new[] { "SELECT * FROM player" });
|
||||
|
||||
handle.UnsubscribeThen(ctx => Console.WriteLine("Unsubscribed"));
|
||||
```
|
||||
|
||||
**Warning**: `SubscribeToAllTables()` cannot be mixed with `Subscribe()` on the same connection.
|
||||
|
||||
### Accessing the Client Cache
|
||||
|
||||
```csharp
|
||||
// Iterate all rows
|
||||
foreach (var player in ctx.Db.Player.Iter()) { Console.WriteLine(player.Name); }
|
||||
|
||||
// Count rows
|
||||
int playerCount = ctx.Db.Player.Count;
|
||||
|
||||
// Find by unique/primary key — returns nullable
|
||||
Player? player = ctx.Db.Player.Identity.Find(someIdentity);
|
||||
if (player != null) { Console.WriteLine(player.Name); }
|
||||
|
||||
// Filter by BTree index — returns IEnumerable
|
||||
foreach (var p in ctx.Db.Player.Level.Filter(1)) { }
|
||||
```
|
||||
|
||||
### Row Event Callbacks
|
||||
|
||||
```csharp
|
||||
ctx.Db.Player.OnInsert += (EventContext ctx, Player player) => {
|
||||
Console.WriteLine($"Player joined: {player.Name}");
|
||||
};
|
||||
|
||||
ctx.Db.Player.OnDelete += (EventContext ctx, Player player) => {
|
||||
Console.WriteLine($"Player left: {player.Name}");
|
||||
};
|
||||
|
||||
ctx.Db.Player.OnUpdate += (EventContext ctx, Player oldRow, Player newRow) => {
|
||||
Console.WriteLine($"Player {oldRow.Name} renamed to {newRow.Name}");
|
||||
};
|
||||
|
||||
// Checking event source
|
||||
ctx.Db.Player.OnInsert += (EventContext ctx, Player player) => {
|
||||
switch (ctx.Event)
|
||||
{
|
||||
case Event<Reducer>.SubscribeApplied:
|
||||
break; // Initial subscription data
|
||||
case Event<Reducer>.Reducer(var reducerEvent):
|
||||
Console.WriteLine($"Reducer: {reducerEvent.Reducer}");
|
||||
break;
|
||||
}
|
||||
};
|
||||
```
|
||||
|
||||
### Calling Reducers
|
||||
|
||||
```csharp
|
||||
ctx.Reducers.SendMessage("Hello, world!");
|
||||
ctx.Reducers.CreatePlayer("NewPlayer");
|
||||
|
||||
// Reducer completion callbacks
|
||||
conn.Reducers.OnSendMessage += (ReducerEventContext ctx, string text) => {
|
||||
if (ctx.Event.Status is Status.Committed)
|
||||
Console.WriteLine($"Message sent: {text}");
|
||||
else if (ctx.Event.Status is Status.Failed(var reason))
|
||||
Console.Error.WriteLine($"Send failed: {reason}");
|
||||
};
|
||||
|
||||
// Unhandled reducer errors
|
||||
conn.OnUnhandledReducerError += (ReducerEventContext ctx, Exception ex) => {
|
||||
Console.Error.WriteLine($"Reducer error: {ex.Message}");
|
||||
};
|
||||
```
|
||||
|
||||
### Identity and Authentication
|
||||
|
||||
```csharp
|
||||
// In OnConnect callback — save token for reconnection
|
||||
void OnConnected(DbConnection conn, Identity identity, string authToken)
|
||||
{
|
||||
// Save authToken to persistent storage (file, config, PlayerPrefs, etc.)
|
||||
SaveToken(authToken);
|
||||
}
|
||||
|
||||
// Reconnect with saved token
|
||||
string savedToken = LoadToken();
|
||||
DbConnection.Builder()
|
||||
.WithUri("http://localhost:3000")
|
||||
.WithDatabaseName("my-database")
|
||||
.WithToken(savedToken)
|
||||
.OnConnect(OnConnected)
|
||||
.Build();
|
||||
|
||||
// Pass null or omit WithToken for anonymous connection
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Commands
|
||||
|
||||
```bash
|
||||
spacetime start
|
||||
spacetime publish <module-name> --module-path <backend-dir>
|
||||
spacetime publish <module-name> --clear-database -y --module-path <backend-dir>
|
||||
spacetime generate --lang csharp --out-dir <client>/SpacetimeDB --module-path <backend-dir>
|
||||
spacetime logs <module-name>
|
||||
```
|
||||
@@ -1,312 +1,170 @@
|
||||
---
|
||||
name: spacetimedb-rust
|
||||
description: Develop SpacetimeDB server modules in Rust. Use when writing reducers, tables, or module logic.
|
||||
license: Apache-2.0
|
||||
metadata:
|
||||
author: clockworklabs
|
||||
version: "2.0"
|
||||
description: Develop SpacetimeDB 2.5 server modules in Rust for Genarrative. Use when writing or reviewing tables, reducers, procedures, views, migrations, row mappers, schema changes, and module logic.
|
||||
---
|
||||
|
||||
# SpacetimeDB Rust Module Development
|
||||
|
||||
SpacetimeDB modules are WebAssembly applications that run inside the database. They define tables to store data and reducers to modify data. Clients connect directly to the database and execute application logic inside it.
|
||||
Use this skill for Rust code in `server-rs/crates/spacetime-module` and related Genarrative schema/migration work.
|
||||
|
||||
> **Tested with:** SpacetimeDB 2.0+ APIs
|
||||
## Genarrative Rules
|
||||
|
||||
---
|
||||
- Keep domain rules in `module-*`; keep SpacetimeDB tables, reducers, procedures, views, mappers, and transaction adapters in `spacetime-module`.
|
||||
- Existing table fields must be appended at the end with explicit defaults. Do not rename, remove, reorder, or change field types without a user-confirmed migration plan.
|
||||
- After schema changes, update `migration.rs`, table catalog/docs, generated bindings, and run `npm run spacetime:generate` plus `npm run check:spacetime-schema`.
|
||||
- Private tables are backend facts. Expose user-visible state through BFF endpoints/read models rather than direct client SQL.
|
||||
|
||||
## HALLUCINATED APIs — DO NOT USE
|
||||
|
||||
**These APIs/patterns are incorrect. LLMs frequently hallucinate them.**
|
||||
|
||||
Both macro forms are valid in 2.0: `#[spacetimedb::table(...)]` / `#[table(...)]` and `#[spacetimedb::reducer]` / `#[reducer]`.
|
||||
## Hallucinated APIs: Do Not Use
|
||||
|
||||
```rust
|
||||
#[derive(Table)] // Tables use #[table] attribute, not derive
|
||||
#[derive(Reducer)] // Reducers use #[reducer] attribute
|
||||
#[derive(Table)] // Tables use #[table], not derive
|
||||
#[derive(Reducer)] // Reducers use #[reducer], not derive
|
||||
#[derive(SpacetimeType)] // Do not derive this on #[table] structs
|
||||
|
||||
// WRONG — SpacetimeType on tables
|
||||
#[derive(SpacetimeType)] // DO NOT use on #[table] structs!
|
||||
#[table(accessor = my_table)]
|
||||
pub struct MyTable { ... }
|
||||
pub fn reducer(ctx: &mut ReducerContext) {} // Use &ReducerContext
|
||||
|
||||
// WRONG — mutable context
|
||||
pub fn my_reducer(ctx: &mut ReducerContext, ...) { } // Should be &ReducerContext
|
||||
ctx.db.player // Use ctx.db.player()
|
||||
ctx.db.player.find(id) // Use ctx.db.player().id().find(&id)
|
||||
ctx.sender // Use ctx.sender()
|
||||
ctx.db.user().name().update(..) // Update by primary key only
|
||||
|
||||
// WRONG — table access without parentheses
|
||||
ctx.db.player // Should be ctx.db.player()
|
||||
ctx.db.player.find(id) // Should be ctx.db.player().id().find(&id)
|
||||
|
||||
// WRONG — old 1.0 patterns
|
||||
ctx.sender // Use ctx.sender() — method, not field (2.0)
|
||||
.with_module_name("db") // Use .with_database_name() (2.0)
|
||||
ctx.db.user().name().update(..) // Update only via primary key (2.0)
|
||||
spacetimedb = { version = "...", features = ["unstable"] } // Not needed for procedures in 2.5
|
||||
```
|
||||
|
||||
### CORRECT PATTERNS:
|
||||
## Required Patterns
|
||||
|
||||
```rust
|
||||
use spacetimedb::{table, reducer, Table, ReducerContext, Identity, Timestamp};
|
||||
use spacetimedb::SpacetimeType; // Only for custom types, NOT tables
|
||||
use spacetimedb::{reducer, table, Identity, ReducerContext, Table, Timestamp};
|
||||
use spacetimedb::SpacetimeType; // Custom types only, not tables
|
||||
|
||||
// CORRECT TABLE — accessor, not name; no SpacetimeType derive!
|
||||
#[table(accessor = player, public)]
|
||||
pub struct Player {
|
||||
#[primary_key]
|
||||
pub id: u64,
|
||||
pub name: String,
|
||||
}
|
||||
|
||||
// CORRECT REDUCER — immutable context, sender() is a method
|
||||
#[reducer]
|
||||
pub fn create_player(ctx: &ReducerContext, name: String) {
|
||||
ctx.db.player().insert(Player { id: 0, name });
|
||||
}
|
||||
|
||||
// CORRECT TABLE ACCESS — methods with parentheses, sender() method
|
||||
let player = ctx.db.player().id().find(&player_id);
|
||||
let caller = ctx.sender();
|
||||
```
|
||||
|
||||
### DO NOT:
|
||||
- **Derive `SpacetimeType` on `#[table]` structs** — the macro handles this
|
||||
- **Use mutable context** — `&ReducerContext`, not `&mut ReducerContext`
|
||||
- **Forget `Table` trait import** — required for table operations
|
||||
- **Use field access for tables** — `ctx.db.player()` not `ctx.db.player`
|
||||
- **Use `ctx.sender`** — it's `ctx.sender()` (method) in 2.0
|
||||
|
||||
---
|
||||
|
||||
## Common Mistakes Table
|
||||
|
||||
| Wrong | Right | Error |
|
||||
|-------|-------|-------|
|
||||
| `#[table(accessor = "my_table")]` | `#[table(accessor = my_table)]` | String literals not allowed |
|
||||
| Missing `public` on table | Add `public` flag | Clients can't subscribe |
|
||||
| Network/filesystem in reducer | Use procedures instead | Sandbox violation |
|
||||
| Panic for expected errors | Return `Result<(), String>` | WASM instance destroyed |
|
||||
|
||||
---
|
||||
|
||||
## Hard Requirements
|
||||
|
||||
1. **DO NOT derive `SpacetimeType` on `#[table]` structs** — the macro handles this
|
||||
2. **Import `Table` trait** — required for all table operations
|
||||
3. **Use `&ReducerContext`** — not `&mut ReducerContext`
|
||||
4. **Tables are methods** — `ctx.db.table()` not `ctx.db.table`
|
||||
5. **Use `ctx.sender()`** — method call, not field access (2.0)
|
||||
6. **Use `accessor =` for API handles** — `name = "..."` is optional canonical naming in table/index attributes
|
||||
7. **Reducers must be deterministic** — no filesystem, network, timers, or external RNG
|
||||
8. **Use `ctx.rng()`** — not `rand` crate for random numbers
|
||||
9. **Add `public` flag** — if clients need to subscribe to a table
|
||||
10. **Update only via primary key** — use delete+insert for non-PK changes (2.0)
|
||||
|
||||
---
|
||||
|
||||
## Project Setup
|
||||
|
||||
```toml
|
||||
[package]
|
||||
name = "my-module"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
|
||||
[lib]
|
||||
crate-type = ["cdylib"]
|
||||
|
||||
[dependencies]
|
||||
spacetimedb = { workspace = true }
|
||||
log = "0.4"
|
||||
```
|
||||
|
||||
### Essential Imports
|
||||
|
||||
```rust
|
||||
use spacetimedb::{ReducerContext, Table};
|
||||
use spacetimedb::{Identity, Timestamp, ConnectionId, ScheduleAt};
|
||||
```
|
||||
|
||||
## Table Definitions
|
||||
|
||||
```rust
|
||||
#[spacetimedb::table(accessor = player, public)]
|
||||
pub struct Player {
|
||||
#[primary_key]
|
||||
#[auto_inc]
|
||||
id: u64,
|
||||
name: String,
|
||||
score: u32,
|
||||
pub id: u64,
|
||||
pub owner: Identity,
|
||||
pub name: String,
|
||||
pub created_at: Timestamp,
|
||||
}
|
||||
```
|
||||
|
||||
### Table Attributes
|
||||
|
||||
| Attribute | Description |
|
||||
|-----------|-------------|
|
||||
| `accessor = identifier` | Required. The API name used in `ctx.db.{accessor}()` |
|
||||
| `public` | Makes table visible to clients via subscriptions |
|
||||
| `scheduled(function_name)` | Creates a schedule table that triggers the named reducer or procedure |
|
||||
| `index(accessor = idx, btree(columns = [a, b]))` | Multi-column index |
|
||||
|
||||
### Column Attributes
|
||||
|
||||
| Attribute | Description |
|
||||
|-----------|-------------|
|
||||
| `#[primary_key]` | Unique identifier for the row (one per table max) |
|
||||
| `#[unique]` | Enforces uniqueness, enables `find()` method |
|
||||
| `#[auto_inc]` | Auto-generates unique integer values when inserting 0 |
|
||||
| `#[index(btree)]` | Creates a B-tree index for efficient lookups |
|
||||
|
||||
### Supported Column Types
|
||||
|
||||
**Primitives**: `u8`-`u256`, `i8`-`i256`, `f32`, `f64`, `bool`, `String`
|
||||
|
||||
**SpacetimeDB Types**: `Identity`, `ConnectionId`, `Timestamp`, `Uuid`, `ScheduleAt`
|
||||
|
||||
**Collections**: `Vec<T>`, `Option<T>`, `Result<T, E>`
|
||||
|
||||
**Custom Types**: Any struct/enum with `#[derive(SpacetimeType)]`
|
||||
|
||||
---
|
||||
|
||||
## Reducers
|
||||
|
||||
```rust
|
||||
#[spacetimedb::reducer]
|
||||
#[reducer]
|
||||
pub fn create_player(ctx: &ReducerContext, name: String) -> Result<(), String> {
|
||||
if name.is_empty() {
|
||||
return Err("Name cannot be empty".to_string());
|
||||
if name.trim().is_empty() {
|
||||
return Err("name required".to_string());
|
||||
}
|
||||
ctx.db.player().insert(Player { id: 0, name, score: 0 });
|
||||
ctx.db.player().try_insert(Player {
|
||||
id: 0,
|
||||
owner: ctx.sender(),
|
||||
name,
|
||||
created_at: ctx.timestamp,
|
||||
})?;
|
||||
Ok(())
|
||||
}
|
||||
```
|
||||
|
||||
### Reducer Rules
|
||||
Hard requirements:
|
||||
|
||||
1. First parameter must be `&ReducerContext`
|
||||
2. Return `()`, `Result<(), String>`, or `Result<(), E>` where `E: Display`
|
||||
3. All changes roll back on panic or `Err` return
|
||||
4. Must import `Table` trait: `use spacetimedb::Table;`
|
||||
- Import `Table` for table operations.
|
||||
- Use `accessor = identifier`, not string literals.
|
||||
- Use `ctx.sender()` for authorization.
|
||||
- Use `ctx.rng()` / `ctx.random()` / `ctx.new_uuid_*()` for deterministic randomness and UUIDs.
|
||||
- Use `Result<(), String>` for expected sender errors; avoid panics except impossible states.
|
||||
- Use `try_insert()` in `Result` reducers when constraint violations should be reported cleanly.
|
||||
|
||||
### ReducerContext
|
||||
## Tables
|
||||
|
||||
```rust
|
||||
ctx.db // Database access
|
||||
ctx.sender() // Identity of the caller (method, not field!)
|
||||
ctx.connection_id() // Option<ConnectionId> (None for scheduled/system reducers)
|
||||
ctx.timestamp // Invocation timestamp
|
||||
ctx.identity() // Module's own identity
|
||||
ctx.rng() // Deterministic RNG (method, not field!)
|
||||
#[spacetimedb::table(accessor = game_tick_schedule, scheduled(game_tick))]
|
||||
pub struct GameTickSchedule {
|
||||
#[primary_key]
|
||||
#[auto_inc]
|
||||
pub scheduled_id: u64,
|
||||
pub scheduled_at: ScheduleAt,
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
Table attributes:
|
||||
|
||||
| Attribute | Description |
|
||||
|-----------|-------------|
|
||||
| `accessor = identifier` | API name used in `ctx.db.{accessor}()` |
|
||||
| `public` | Visible to clients via subscriptions |
|
||||
| `event` | Transient event table |
|
||||
| `scheduled(function_name)` | Schedule table that triggers a reducer/procedure |
|
||||
| `index(accessor = idx, btree(columns = [a, b]))` | Multi-column index |
|
||||
|
||||
Column attributes:
|
||||
|
||||
| Attribute | Description |
|
||||
|-----------|-------------|
|
||||
| `#[primary_key]` | One primary key per table |
|
||||
| `#[auto_inc]` | Auto-generates integer values when inserting `0` |
|
||||
| `#[unique]` | Unique constraint and `find()` accessor |
|
||||
| `#[index(btree)]` | B-tree index and `filter()` accessor |
|
||||
| `#[default(...)]` | Required for new fields on existing Genarrative tables |
|
||||
|
||||
## Genarrative Schema Change Pattern
|
||||
|
||||
```rust
|
||||
#[spacetimedb::table(accessor = creation_entry_config, public)]
|
||||
pub struct CreationEntryConfig {
|
||||
#[primary_key]
|
||||
pub id: u64,
|
||||
pub existing_field: String,
|
||||
|
||||
// Append new fields at the end and provide a default.
|
||||
#[default(false)]
|
||||
pub new_flag: bool,
|
||||
}
|
||||
```
|
||||
|
||||
Then update `migration.rs`, table catalog/docs, generated bindings, and run:
|
||||
|
||||
```bash
|
||||
npm run spacetime:generate
|
||||
npm run check:spacetime-schema
|
||||
```
|
||||
|
||||
## Table Operations
|
||||
|
||||
### Insert
|
||||
|
||||
```rust
|
||||
// Insert returns the row with auto_inc values populated
|
||||
let player = ctx.db.player().insert(Player { id: 0, name: "Alice".into(), score: 100 });
|
||||
log::info!("Created player with id: {}", player.id);
|
||||
```
|
||||
let row = ctx.db.player().insert(Player { id: 0, owner, name, created_at });
|
||||
ctx.db.player().try_insert(row)?;
|
||||
|
||||
### Find and Filter
|
||||
|
||||
```rust
|
||||
// Find by unique/primary key — returns Option
|
||||
if let Some(player) = ctx.db.player().id().find(&123) {
|
||||
log::info!("Found: {}", player.name);
|
||||
}
|
||||
|
||||
// Optional clarity: typed literals can avoid inference ambiguity
|
||||
if let Some(player) = ctx.db.player().id().find(&123u64) {
|
||||
log::info!("Found: {}", player.name);
|
||||
}
|
||||
|
||||
// Filter by indexed column — returns iterator
|
||||
for player in ctx.db.player().name().filter(&"Alice".to_string()) {
|
||||
log::info!("Player: {}", player.name);
|
||||
}
|
||||
|
||||
// Full table scan
|
||||
let by_id = ctx.db.player().id().find(&123u64);
|
||||
for player in ctx.db.player().owner().filter(&ctx.sender()) {}
|
||||
for player in ctx.db.player().level().filter(&(18u32..=65u32)) {}
|
||||
for player in ctx.db.player().iter() {}
|
||||
let total = ctx.db.player().count();
|
||||
```
|
||||
let count = ctx.db.player().count();
|
||||
|
||||
### Update
|
||||
|
||||
```rust
|
||||
// Update via primary key (2.0: only primary key has update)
|
||||
if let Some(player) = ctx.db.player().id().find(&123) {
|
||||
ctx.db.player().id().update(Player { score: player.score + 10, ..player });
|
||||
if let Some(player) = ctx.db.player().id().find(&id) {
|
||||
ctx.db.player().id().update(Player { name: new_name, ..player });
|
||||
}
|
||||
|
||||
// For non-PK changes: delete + insert
|
||||
if let Some(old) = ctx.db.player().id().find(&id) {
|
||||
ctx.db.player().id().delete(&id);
|
||||
ctx.db.player().insert(Player { name: new_name, ..old });
|
||||
}
|
||||
```
|
||||
|
||||
### Delete
|
||||
|
||||
```rust
|
||||
// Delete by primary key
|
||||
ctx.db.player().id().delete(&123);
|
||||
|
||||
// Delete by indexed column (collect first to avoid iterator invalidation)
|
||||
let to_remove: Vec<u64> = ctx.db.player().name().filter(&"Alice".to_string())
|
||||
.map(|p| p.id)
|
||||
.collect();
|
||||
for id in to_remove {
|
||||
ctx.db.player().id().delete(&id);
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
For delete/update based on non-PK filters, collect keys first to avoid iterator invalidation.
|
||||
|
||||
## Indexes
|
||||
|
||||
```rust
|
||||
// Single-column index
|
||||
#[spacetimedb::table(accessor = player, public)]
|
||||
pub struct Player {
|
||||
#[primary_key]
|
||||
id: u64,
|
||||
#[index(btree)]
|
||||
level: u32,
|
||||
name: String,
|
||||
}
|
||||
|
||||
// Multi-column index
|
||||
#[spacetimedb::table(
|
||||
accessor = score, public,
|
||||
accessor = score,
|
||||
public,
|
||||
index(accessor = by_player_level, btree(columns = [player_id, level]))
|
||||
)]
|
||||
pub struct Score {
|
||||
player_id: u32,
|
||||
level: u32,
|
||||
points: i64,
|
||||
pub player_id: u32,
|
||||
pub level: u32,
|
||||
pub points: i64,
|
||||
}
|
||||
|
||||
// Multi-column index querying: prefix match (first column only)
|
||||
for s in ctx.db.score().by_player_level().filter(&(42,)) {
|
||||
log::info!("Player 42, any level: {} pts", s.points);
|
||||
}
|
||||
|
||||
// Full match (both columns)
|
||||
for s in ctx.db.score().by_player_level().filter(&(42, 5)) {
|
||||
log::info!("Player 42, level 5: {} pts", s.points);
|
||||
}
|
||||
for row in ctx.db.score().by_player_level().filter(&(42,)) {}
|
||||
for row in ctx.db.score().by_player_level().filter(&(42, 5)) {}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Event Tables (2.0)
|
||||
|
||||
Reducer callbacks are removed in 2.0. Use event tables + `on_insert` instead.
|
||||
## Event Tables
|
||||
|
||||
```rust
|
||||
#[table(accessor = damage_event, public, event)]
|
||||
@@ -321,182 +179,65 @@ fn deal_damage(ctx: &ReducerContext, target: Identity, amount: u32) {
|
||||
}
|
||||
```
|
||||
|
||||
Client subscribes and uses `on_insert`:
|
||||
Event tables must be subscribed explicitly and are excluded from `subscribe_to_all_tables()`.
|
||||
|
||||
In 2.5, event tables support broader layout-altering automigrations than regular tables, including column removal, reordering, and type changes. This relaxed migration policy does not apply to persistent tables.
|
||||
|
||||
Event-table primary keys and constraints are enforced only within the current transaction. They do not make event rows persistent, and client SDKs expose event tables as insert-only event streams. Do not rely on `OnUpdate` / `on_update` / `onUpdate` for event tables; use a persistent table or a primary-keyed procedural view when update callbacks are required.
|
||||
|
||||
Official 2.4.1/2.5 release notes tie primary-key-backed update callbacks to procedural views, not event tables.
|
||||
|
||||
## Views
|
||||
|
||||
```rust
|
||||
conn.db.damage_event().on_insert(|ctx, event| {
|
||||
play_damage_animation(event.target, event.amount);
|
||||
});
|
||||
#[spacetimedb::view(accessor = my_players, public, primary_key = id)]
|
||||
pub fn my_players(ctx: &spacetimedb::ViewContext) -> Vec<Player> {
|
||||
ctx.db.player().owner().filter(&ctx.sender()).collect()
|
||||
}
|
||||
```
|
||||
|
||||
Event tables must be subscribed explicitly — they are excluded from `subscribe_to_all_tables()`.
|
||||
Rust and TypeScript gained primary key support for procedural views in 2.4.1. With primary keys, clients can receive update events when subscribed to such views. Avoid duplicate primary keys in view results.
|
||||
|
||||
---
|
||||
|
||||
## Lifecycle Reducers
|
||||
## Lifecycle & Scheduled Reducers
|
||||
|
||||
```rust
|
||||
#[spacetimedb::reducer(init)]
|
||||
pub fn init(ctx: &ReducerContext) -> Result<(), String> {
|
||||
log::info!("Database initializing...");
|
||||
ctx.db.config().insert(Config {
|
||||
id: 0,
|
||||
max_players: 100,
|
||||
game_mode: "default".to_string(),
|
||||
});
|
||||
Ok(())
|
||||
}
|
||||
pub fn init(ctx: &ReducerContext) -> Result<(), String> { Ok(()) }
|
||||
|
||||
#[spacetimedb::reducer(client_connected)]
|
||||
pub fn on_connect(ctx: &ReducerContext) -> Result<(), String> {
|
||||
let caller = ctx.sender();
|
||||
log::info!("Client connected: {}", caller);
|
||||
|
||||
if let Some(user) = ctx.db.user().identity().find(&caller) {
|
||||
ctx.db.user().identity().update(User { online: true, ..user });
|
||||
} else {
|
||||
ctx.db.user().insert(User {
|
||||
identity: caller,
|
||||
name: format!("User-{}", &caller.to_hex()[..8]),
|
||||
online: true,
|
||||
});
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
pub fn on_connect(ctx: &ReducerContext) -> Result<(), String> { Ok(()) }
|
||||
|
||||
#[spacetimedb::reducer(client_disconnected)]
|
||||
pub fn on_disconnect(ctx: &ReducerContext) -> Result<(), String> {
|
||||
let caller = ctx.sender();
|
||||
if let Some(user) = ctx.db.user().identity().find(&caller) {
|
||||
ctx.db.user().identity().update(User { online: false, ..user });
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
```
|
||||
pub fn on_disconnect(ctx: &ReducerContext) -> Result<(), String> { Ok(()) }
|
||||
|
||||
---
|
||||
use spacetimedb::{ScheduleAt, TimeDuration};
|
||||
|
||||
## Scheduled Reducers
|
||||
|
||||
```rust
|
||||
use spacetimedb::ScheduleAt;
|
||||
use std::time::Duration;
|
||||
|
||||
#[spacetimedb::table(accessor = game_tick_schedule, scheduled(game_tick))]
|
||||
pub struct GameTickSchedule {
|
||||
#[primary_key]
|
||||
#[auto_inc]
|
||||
scheduled_id: u64,
|
||||
scheduled_at: ScheduleAt,
|
||||
}
|
||||
|
||||
#[spacetimedb::reducer]
|
||||
fn game_tick(ctx: &ReducerContext, schedule: GameTickSchedule) {
|
||||
if !ctx.sender_auth().is_internal() { return; }
|
||||
log::info!("Game tick at {:?}", ctx.timestamp);
|
||||
}
|
||||
|
||||
// Schedule at interval (e.g., in init reducer)
|
||||
ctx.db.game_tick_schedule().insert(GameTickSchedule {
|
||||
scheduled_id: 0,
|
||||
scheduled_at: ScheduleAt::Interval(Duration::from_millis(100).into()),
|
||||
scheduled_at: ScheduleAt::Interval(std::time::Duration::from_millis(100).into()),
|
||||
});
|
||||
|
||||
// Schedule at specific time
|
||||
let run_at = ctx.timestamp + Duration::from_secs(delay_secs);
|
||||
ctx.db.reminder_schedule().insert(ReminderSchedule {
|
||||
let run_at = ctx.timestamp + std::time::Duration::from_secs(60);
|
||||
ctx.db.game_tick_schedule().insert(GameTickSchedule {
|
||||
scheduled_id: 0,
|
||||
scheduled_at: ScheduleAt::Time(run_at),
|
||||
});
|
||||
```
|
||||
|
||||
---
|
||||
For scheduled reducers, check `ctx.sender_auth().is_internal()` when the reducer should only be system-triggered.
|
||||
|
||||
## Identity and Authentication
|
||||
## Procedures
|
||||
|
||||
```rust
|
||||
#[spacetimedb::table(accessor = user, public)]
|
||||
pub struct User {
|
||||
#[primary_key]
|
||||
identity: Identity,
|
||||
name: String,
|
||||
online: bool,
|
||||
}
|
||||
|
||||
#[spacetimedb::reducer]
|
||||
pub fn set_name(ctx: &ReducerContext, new_name: String) -> Result<(), String> {
|
||||
let caller = ctx.sender();
|
||||
let user = ctx.db.user().identity().find(&caller)
|
||||
.ok_or("User not found — connect first")?;
|
||||
ctx.db.user().identity().update(User { name: new_name, ..user });
|
||||
Ok(())
|
||||
}
|
||||
```
|
||||
|
||||
### Owner-Only Reducer Pattern
|
||||
|
||||
```rust
|
||||
fn require_owner(ctx: &ReducerContext, entity_owner: &Identity) -> Result<(), String> {
|
||||
if ctx.sender() != *entity_owner {
|
||||
Err("Not authorized: you don't own this entity".to_string())
|
||||
} else {
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[spacetimedb::reducer]
|
||||
pub fn rename_character(ctx: &ReducerContext, char_id: u64, new_name: String) -> Result<(), String> {
|
||||
let character = ctx.db.character().id().find(&char_id)
|
||||
.ok_or("Character not found")?;
|
||||
require_owner(ctx, &character.owner)?;
|
||||
ctx.db.character().id().update(Character { name: new_name, ..character });
|
||||
Ok(())
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Error Handling
|
||||
|
||||
```rust
|
||||
// Sender error — return Err (user sees message, transaction rolls back cleanly)
|
||||
#[spacetimedb::reducer]
|
||||
pub fn transfer(ctx: &ReducerContext, to: Identity, amount: u64) -> Result<(), String> {
|
||||
let sender = ctx.db.wallet().identity().find(&ctx.sender())
|
||||
.ok_or("Wallet not found")?;
|
||||
if sender.balance < amount {
|
||||
return Err("Insufficient balance".to_string());
|
||||
}
|
||||
// ... proceed with transfer
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// Programmer error — panic (destroys the WASM instance, expensive!)
|
||||
// Only use for truly impossible states
|
||||
#[spacetimedb::reducer]
|
||||
pub fn process(ctx: &ReducerContext, id: u64) {
|
||||
let item = ctx.db.item().id().find(&id)
|
||||
.expect("BUG: item should exist at this point");
|
||||
// ...
|
||||
}
|
||||
```
|
||||
|
||||
Prefer `Result<(), String>` for all expected failure cases. Panics destroy and recreate the WASM instance.
|
||||
|
||||
---
|
||||
|
||||
## Procedures (Beta)
|
||||
|
||||
> Procedures are behind the `unstable` feature in `spacetimedb`.
|
||||
> In `Cargo.toml`: `spacetimedb = { version = "...", features = ["unstable"] }`
|
||||
Procedures are stable in 2.5 and no longer require the `unstable` feature.
|
||||
|
||||
```rust
|
||||
use spacetimedb::{procedure, ProcedureContext};
|
||||
|
||||
#[procedure]
|
||||
fn save_external_data(ctx: &mut ProcedureContext, url: String) -> Result<(), String> {
|
||||
let data = fetch_from_url(&url)?;
|
||||
let body = ctx.http.get(url).send()?.text()?;
|
||||
ctx.try_with_tx(|tx| {
|
||||
tx.db.external_data().insert(ExternalData { id: 0, content: data });
|
||||
tx.db.external_data().insert(ExternalData { id: 0, content: body });
|
||||
Ok(())
|
||||
})?;
|
||||
Ok(())
|
||||
@@ -505,52 +246,35 @@ fn save_external_data(ctx: &mut ProcedureContext, url: String) -> Result<(), Str
|
||||
|
||||
| Reducers | Procedures |
|
||||
|----------|------------|
|
||||
| `&ReducerContext` (immutable) | `&mut ProcedureContext` (mutable) |
|
||||
| Direct `ctx.db` access | Must use `ctx.with_tx()` |
|
||||
| No HTTP/network | HTTP allowed |
|
||||
| No return values | Can return data |
|
||||
| `&ReducerContext` | `&mut ProcedureContext` |
|
||||
| Direct `ctx.db` access | Use `with_tx()` / `try_with_tx()` |
|
||||
| No HTTP/network | Outgoing HTTP via `ctx.http` |
|
||||
| Deterministic transaction path | Side-effect-capable workflow path |
|
||||
|
||||
---
|
||||
In Genarrative, keep external provider protocols in `platform-*` by default unless the architecture explicitly moves that workflow into the module.
|
||||
|
||||
## Custom Types
|
||||
## Identity & Auth
|
||||
|
||||
```rust
|
||||
use spacetimedb::SpacetimeType;
|
||||
|
||||
#[derive(SpacetimeType)]
|
||||
pub enum PlayerStatus { Active, Idle, Away }
|
||||
|
||||
#[derive(SpacetimeType)]
|
||||
pub struct Position { x: f32, y: f32, z: f32 }
|
||||
|
||||
// Use in table (DO NOT derive SpacetimeType on the table!)
|
||||
#[spacetimedb::table(accessor = player, public)]
|
||||
pub struct Player {
|
||||
#[primary_key]
|
||||
id: u64,
|
||||
status: PlayerStatus,
|
||||
position: Position,
|
||||
fn require_owner(ctx: &ReducerContext, owner: &Identity) -> Result<(), String> {
|
||||
if ctx.sender() != *owner {
|
||||
return Err("Not authorized".to_string());
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
`ReducerContext::identity` is deprecated since 2.3; use the current database/module identity API when needed, and use `ctx.sender()` for caller identity.
|
||||
|
||||
## Commands
|
||||
|
||||
```bash
|
||||
spacetime build
|
||||
spacetime publish my_database --module-path .
|
||||
spacetime publish my_database --clear-database --module-path .
|
||||
spacetime logs my_database
|
||||
spacetime call my_database create_player "Alice"
|
||||
spacetime sql my_database "SELECT * FROM player"
|
||||
spacetime generate --lang rust --out-dir <client>/src/module_bindings --module-path <backend-dir>
|
||||
spacetime publish my_database --server http://127.0.0.1:3101 --module-path . --yes=migrate
|
||||
spacetime publish my_database --server http://127.0.0.1:3101 --delete-data=on-conflict --module-path . --yes=migrate
|
||||
spacetime logs my_database --server http://127.0.0.1:3101
|
||||
spacetime call --server http://127.0.0.1:3101 my_database create_player '"Alice"'
|
||||
spacetime sql my_database --server http://127.0.0.1:3101 "SELECT * FROM player"
|
||||
npm run spacetime:generate
|
||||
npm run check:spacetime-schema
|
||||
```
|
||||
|
||||
## Important Constraints
|
||||
|
||||
1. **No Global State**: Static/global variables are undefined behavior across reducer calls
|
||||
2. **No Side Effects**: Reducers cannot make network requests or file I/O
|
||||
3. **Deterministic Execution**: Use `ctx.rng()` and `ctx.new_uuid_*()` for randomness
|
||||
4. **Transactional**: All reducer changes roll back on failure
|
||||
5. **Isolated**: Reducers don't see concurrent changes until commit
|
||||
|
||||
@@ -1,489 +0,0 @@
|
||||
---
|
||||
name: spacetimedb-typescript
|
||||
description: Build TypeScript clients for SpacetimeDB. Use when connecting to SpacetimeDB from web apps, Node.js, Deno, Bun, or other JavaScript runtimes.
|
||||
license: Apache-2.0
|
||||
metadata:
|
||||
author: clockworklabs
|
||||
version: "2.0"
|
||||
---
|
||||
|
||||
# SpacetimeDB TypeScript SDK
|
||||
|
||||
Build real-time TypeScript clients that connect directly to SpacetimeDB modules. The SDK provides type-safe database access, automatic synchronization, and reactive updates for web apps, Node.js, Deno, Bun, and other JavaScript runtimes.
|
||||
|
||||
---
|
||||
|
||||
## HALLUCINATED APIs — DO NOT USE
|
||||
|
||||
**These APIs DO NOT EXIST. LLMs frequently hallucinate them.**
|
||||
|
||||
```typescript
|
||||
// WRONG PACKAGE — does not exist
|
||||
import { SpacetimeDBClient } from "@clockworklabs/spacetimedb-sdk";
|
||||
|
||||
// WRONG — these methods don't exist
|
||||
SpacetimeDBClient.connect(...);
|
||||
SpacetimeDBClient.call("reducer_name", [...]);
|
||||
connection.call("reducer_name", [arg1, arg2]);
|
||||
|
||||
// WRONG — positional reducer arguments
|
||||
conn.reducers.doSomething("value"); // WRONG!
|
||||
|
||||
// WRONG — old 1.0 patterns
|
||||
spacetimedb.reducer('reducer_name', params, fn); // Use export const name = spacetimedb.reducer(params, fn)
|
||||
schema(myTable); // Use schema({ myTable })
|
||||
schema(t1, t2, t3); // Use schema({ t1, t2, t3 })
|
||||
scheduled: 'run_cleanup' // Use scheduled: () => run_cleanup
|
||||
.withModuleName('db') // Use .withDatabaseName('db') (2.0)
|
||||
setReducerFlags.x('NoSuccessNotify') // Removed in 2.0
|
||||
```
|
||||
|
||||
### CORRECT PATTERNS:
|
||||
|
||||
```typescript
|
||||
// CORRECT IMPORTS
|
||||
import { DbConnection, tables } from './module_bindings'; // Generated!
|
||||
import { SpacetimeDBProvider, useTable } from 'spacetimedb/react';
|
||||
import { Identity } from 'spacetimedb';
|
||||
|
||||
// CORRECT REDUCER CALLS — object syntax, not positional!
|
||||
conn.reducers.doSomething({ value: 'test' });
|
||||
conn.reducers.updateItem({ itemId: 1n, newValue: 42 });
|
||||
|
||||
// CORRECT DATA ACCESS — useTable returns [rows, isReady]
|
||||
const [items, isReady] = useTable(tables.item);
|
||||
```
|
||||
|
||||
### DO NOT:
|
||||
- **Invent hooks** like `useItems()`, `useData()` — use `useTable(tables.tableName)`
|
||||
- **Import from fake packages** — only `spacetimedb`, `spacetimedb/react`, `./module_bindings`
|
||||
|
||||
---
|
||||
|
||||
## Common Mistakes Table
|
||||
|
||||
### Server-side errors
|
||||
|
||||
| Wrong | Right | Error |
|
||||
|-------|-------|-------|
|
||||
| Missing `package.json` | Create `package.json` | "could not detect language" |
|
||||
| Missing `tsconfig.json` | Create `tsconfig.json` | "TsconfigNotFound" |
|
||||
| Entrypoint not at `src/index.ts` | Use `src/index.ts` | Module won't bundle |
|
||||
| `indexes` in COLUMNS (2nd arg) | `indexes` in OPTIONS (1st arg) of `table()` | "reading 'tag'" error |
|
||||
| Index without `algorithm` | `algorithm: 'btree'` | "reading 'tag'" error |
|
||||
| `filter({ ownerId })` | `filter(ownerId)` | "does not exist in type 'Range'" |
|
||||
| `.filter()` on unique column | `.find()` on unique column | TypeError |
|
||||
| `insert({ ...without id })` | `insert({ id: 0n, ... })` | "Property 'id' is missing" |
|
||||
| `const id = table.insert(...)` | `const row = table.insert(...)` | `.insert()` returns ROW, not ID |
|
||||
| `.unique()` + explicit index | Just use `.unique()` | "name is used for multiple entities" |
|
||||
| Import spacetimedb from index.ts | Import from schema.ts | "Cannot access before initialization" |
|
||||
| Incorrect multi-column `.filter()` range shape | Match index prefix/tuple shape | Empty results or range/type errors |
|
||||
| `.iter()` in views | Use index lookups only | Views can't scan tables |
|
||||
| `ctx.db` in procedures | `ctx.withTx(tx => tx.db...)` | Procedures need explicit transactions |
|
||||
|
||||
### Client-side errors
|
||||
|
||||
| Wrong | Right | Error |
|
||||
|-------|-------|-------|
|
||||
| Inline `connectionBuilder` | `useMemo(() => ..., [])` | Reconnects every render |
|
||||
| `const rows = useTable(table)` | `const [rows, isReady] = useTable(table)` | Tuple destructuring |
|
||||
| Optimistic UI updates | Let subscriptions drive state | Desync issues |
|
||||
| `<SpacetimeDBProvider builder={...}>` | `connectionBuilder={...}` | Wrong prop name |
|
||||
|
||||
---
|
||||
|
||||
## Hard Requirements
|
||||
|
||||
1. **`schema({ table })`** — use a single tables object; optional module settings are allowed as a second argument
|
||||
2. **Reducer/procedure names from exports** — `export const name = spacetimedb.reducer(params, fn)`; never `reducer('name', ...)`
|
||||
3. **Reducer calls use object syntax** — `{ param: 'value' }` not positional args
|
||||
4. **Import `DbConnection` from `./module_bindings`** — not from `spacetimedb`
|
||||
5. **DO NOT edit generated bindings** — regenerate with `spacetime generate`
|
||||
6. **Indexes go in OPTIONS (1st arg)** — not in COLUMNS (2nd arg) of `table()`
|
||||
7. **Use BigInt for u64/i64 fields** — `0n`, `1n`, not `0`, `1`
|
||||
8. **Reducers are transactional** — they do not return data
|
||||
9. **Reducers must be deterministic** — no filesystem, network, timers, random
|
||||
10. **Views should use index lookups** — `.iter()` causes severe performance issues
|
||||
11. **Procedures need `ctx.withTx()`** — `ctx.db` doesn't exist in procedures
|
||||
12. **Sum type values** — use `{ tag: 'variant', value: payload }` not `{ variant: payload }`
|
||||
13. **Use `.withDatabaseName()`** — not `.withModuleName()` (2.0)
|
||||
|
||||
---
|
||||
|
||||
## Installation
|
||||
|
||||
```bash
|
||||
npm install spacetimedb
|
||||
```
|
||||
|
||||
For Node.js environments without native fetch/WebSocket support, install `undici`.
|
||||
|
||||
## Generating Type Bindings
|
||||
|
||||
```bash
|
||||
spacetime generate --lang typescript --out-dir ./src/module_bindings --module-path ./server
|
||||
```
|
||||
|
||||
## Client Connection
|
||||
|
||||
```typescript
|
||||
import { DbConnection } from './module_bindings';
|
||||
|
||||
const connection = DbConnection.builder()
|
||||
.withUri('ws://localhost:3000')
|
||||
.withDatabaseName('my_database')
|
||||
.withToken(localStorage.getItem('spacetimedb_token') ?? undefined)
|
||||
.onConnect((conn, identity, token) => {
|
||||
// identity: your unique Identity for this database
|
||||
console.log('Connected as:', identity.toHexString());
|
||||
|
||||
// Save token for reconnection (preserves identity across sessions)
|
||||
localStorage.setItem('spacetimedb_token', token);
|
||||
|
||||
conn.subscriptionBuilder()
|
||||
.onApplied(() => console.log('Cache ready'))
|
||||
.subscribe('SELECT * FROM player');
|
||||
})
|
||||
.onDisconnect((ctx) => console.log('Disconnected'))
|
||||
.onConnectError((ctx, error) => console.error('Connection failed:', error))
|
||||
.build();
|
||||
```
|
||||
|
||||
## Subscribing to Tables
|
||||
|
||||
```typescript
|
||||
// Basic subscription
|
||||
connection.subscriptionBuilder()
|
||||
.onApplied((ctx) => console.log('Cache ready'))
|
||||
.subscribe('SELECT * FROM player');
|
||||
|
||||
// Multiple queries
|
||||
connection.subscriptionBuilder()
|
||||
.subscribe(['SELECT * FROM player', 'SELECT * FROM game_state']);
|
||||
|
||||
// Subscribe to all tables (development only — cannot mix with Subscribe)
|
||||
connection.subscriptionBuilder().subscribeToAllTables();
|
||||
|
||||
// Subscription handle for later unsubscribe
|
||||
const handle = connection.subscriptionBuilder()
|
||||
.onApplied(() => console.log('Subscribed'))
|
||||
.subscribe('SELECT * FROM player');
|
||||
|
||||
handle.unsubscribeThen(() => console.log('Unsubscribed'));
|
||||
```
|
||||
|
||||
## Accessing Table Data
|
||||
|
||||
```typescript
|
||||
for (const player of connection.db.player.iter()) { console.log(player.name); }
|
||||
const players = Array.from(connection.db.player.iter());
|
||||
const count = connection.db.player.count();
|
||||
const player = connection.db.player.id.find(42n);
|
||||
```
|
||||
|
||||
## Table Event Callbacks
|
||||
|
||||
```typescript
|
||||
connection.db.player.onInsert((ctx, player) => console.log('New:', player.name));
|
||||
connection.db.player.onDelete((ctx, player) => console.log('Left:', player.name));
|
||||
connection.db.player.onUpdate((ctx, old, new_) => console.log(`${old.score} -> ${new_.score}`));
|
||||
```
|
||||
|
||||
## Calling Reducers
|
||||
|
||||
**CRITICAL: Use object syntax, not positional arguments.**
|
||||
|
||||
```typescript
|
||||
connection.reducers.createPlayer({ name: 'Alice', location: { x: 0, y: 0 } });
|
||||
```
|
||||
|
||||
### Snake_case to camelCase conversion
|
||||
- Server: `export const do_something = spacetimedb.reducer(...)`
|
||||
- Client: `conn.reducers.doSomething({ ... })`
|
||||
|
||||
---
|
||||
|
||||
## Identity and Authentication
|
||||
|
||||
- `identity` and `token` are provided in the `onConnect` callback (see Client Connection above)
|
||||
- `identity.toHexString()` for display or logging
|
||||
- Omit `.withToken()` for anonymous connection — server assigns a new identity
|
||||
- Pass a stale/invalid token: server issues a new identity and token in `onConnect`
|
||||
|
||||
---
|
||||
|
||||
## Error Handling
|
||||
|
||||
Connection-level errors (`.onConnectError`, `.onDisconnect`) are shown in the Client Connection example above.
|
||||
|
||||
```typescript
|
||||
// Subscription error
|
||||
connection.subscriptionBuilder()
|
||||
.onApplied(() => console.log('Subscribed'))
|
||||
.onError((ctx) => console.error('Subscription error:', ctx.event))
|
||||
.subscribe('SELECT * FROM player');
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Server-Side Module Development
|
||||
|
||||
### Table Definition
|
||||
|
||||
```typescript
|
||||
import { schema, table, t } from 'spacetimedb/server';
|
||||
|
||||
export const Task = table({
|
||||
name: 'task',
|
||||
public: true,
|
||||
indexes: [{ name: 'task_owner_id', algorithm: 'btree', columns: ['ownerId'] }]
|
||||
}, {
|
||||
id: t.u64().primaryKey().autoInc(),
|
||||
ownerId: t.identity(),
|
||||
title: t.string(),
|
||||
createdAt: t.timestamp(),
|
||||
});
|
||||
```
|
||||
|
||||
### Column types
|
||||
|
||||
```typescript
|
||||
t.identity() // User identity
|
||||
t.u64() // Unsigned 64-bit integer (use for IDs)
|
||||
t.string() // Text
|
||||
t.bool() // Boolean
|
||||
t.timestamp() // Timestamp
|
||||
t.scheduleAt() // For scheduled tables only
|
||||
t.object('Name', {}) // Product types (nested objects)
|
||||
t.enum('Name', {}) // Sum types (tagged unions)
|
||||
t.string().optional() // Nullable
|
||||
```
|
||||
|
||||
> BigInt syntax: All `u64`/`i64` fields use `0n`, `1n`, not `0`, `1`.
|
||||
|
||||
### Schema export
|
||||
|
||||
```typescript
|
||||
const spacetimedb = schema({ Task, Player });
|
||||
export default spacetimedb;
|
||||
```
|
||||
|
||||
### Reducer Definition (2.0)
|
||||
|
||||
**Name comes from the export — NOT from a string argument.**
|
||||
|
||||
```typescript
|
||||
import spacetimedb from './schema';
|
||||
import { t, SenderError } from 'spacetimedb/server';
|
||||
|
||||
export const create_task = spacetimedb.reducer(
|
||||
{ title: t.string() },
|
||||
(ctx, { title }) => {
|
||||
if (!title) throw new SenderError('title required');
|
||||
ctx.db.task.insert({ id: 0n, ownerId: ctx.sender, title, createdAt: ctx.timestamp });
|
||||
}
|
||||
);
|
||||
```
|
||||
|
||||
### Update Pattern
|
||||
|
||||
```typescript
|
||||
const existing = ctx.db.task.id.find(taskId);
|
||||
if (!existing) throw new SenderError('Task not found');
|
||||
ctx.db.task.id.update({ ...existing, title: newTitle, updatedAt: ctx.timestamp });
|
||||
```
|
||||
|
||||
### Lifecycle Hooks
|
||||
|
||||
```typescript
|
||||
spacetimedb.clientConnected((ctx) => { /* ctx.sender is the connecting identity */ });
|
||||
spacetimedb.clientDisconnected((ctx) => { /* clean up */ });
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Event Tables (2.0)
|
||||
|
||||
Reducer callbacks are removed in 2.0. Use event tables + `onInsert` instead.
|
||||
|
||||
```typescript
|
||||
export const DamageEvent = table(
|
||||
{ name: 'damage_event', public: true, event: true },
|
||||
{ target: t.identity(), amount: t.u32() }
|
||||
);
|
||||
|
||||
export const deal_damage = spacetimedb.reducer(
|
||||
{ target: t.identity(), amount: t.u32() },
|
||||
(ctx, { target, amount }) => {
|
||||
ctx.db.damageEvent.insert({ target, amount });
|
||||
}
|
||||
);
|
||||
```
|
||||
|
||||
Client subscribes and uses `onInsert`:
|
||||
```typescript
|
||||
conn.db.damageEvent.onInsert((ctx, evt) => {
|
||||
playDamageAnimation(evt.target, evt.amount);
|
||||
});
|
||||
```
|
||||
|
||||
Event tables must be subscribed explicitly — they are excluded from `subscribeToAllTables()`.
|
||||
|
||||
---
|
||||
|
||||
## Views
|
||||
|
||||
### ViewContext vs AnonymousViewContext
|
||||
|
||||
```typescript
|
||||
// ViewContext — has ctx.sender, result varies per user
|
||||
spacetimedb.view({ name: 'my_items', public: true }, t.array(Item.rowType), (ctx) => {
|
||||
return [...ctx.db.item.by_owner.filter(ctx.sender)];
|
||||
});
|
||||
|
||||
// AnonymousViewContext — no ctx.sender, same result for everyone (better perf)
|
||||
spacetimedb.anonymousView({ name: 'leaderboard', public: true }, t.array(Player.rowType), (ctx) => {
|
||||
return ctx.from.player.where(p => p.score.gt(1000));
|
||||
});
|
||||
```
|
||||
|
||||
Views can only use index lookups — `.iter()` is NOT allowed.
|
||||
|
||||
---
|
||||
|
||||
## Scheduled Tables
|
||||
|
||||
```typescript
|
||||
export const CleanupJob = table({
|
||||
name: 'cleanup_job',
|
||||
scheduled: () => run_cleanup // function returning the exported reducer
|
||||
}, {
|
||||
scheduledId: t.u64().primaryKey().autoInc(),
|
||||
scheduledAt: t.scheduleAt(),
|
||||
targetId: t.u64(),
|
||||
});
|
||||
|
||||
export const run_cleanup = spacetimedb.reducer(
|
||||
{ arg: CleanupJob.rowType },
|
||||
(ctx, { arg }) => { /* arg.scheduledId, arg.targetId available */ }
|
||||
);
|
||||
|
||||
// Schedule a job
|
||||
import { ScheduleAt } from 'spacetimedb';
|
||||
ctx.db.cleanupJob.insert({
|
||||
scheduledId: 0n,
|
||||
scheduledAt: ScheduleAt.time(ctx.timestamp.microsSinceUnixEpoch + 60_000_000n),
|
||||
targetId: someId
|
||||
});
|
||||
```
|
||||
|
||||
### ScheduleAt on Client
|
||||
|
||||
```typescript
|
||||
// ScheduleAt is a tagged union on the client
|
||||
// { tag: 'Time', value: Timestamp } or { tag: 'Interval', value: TimeDuration }
|
||||
const schedule = row.scheduledAt;
|
||||
if (schedule.tag === 'Time') {
|
||||
const date = new Date(Number(schedule.value.microsSinceUnixEpoch / 1000n));
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Timestamps
|
||||
|
||||
### Server-side
|
||||
```typescript
|
||||
ctx.db.item.insert({ id: 0n, createdAt: ctx.timestamp });
|
||||
const future = ctx.timestamp.microsSinceUnixEpoch + 300_000_000n;
|
||||
```
|
||||
|
||||
### Client-side
|
||||
```typescript
|
||||
// Timestamps are objects with BigInt, not numbers
|
||||
const date = new Date(Number(row.createdAt.microsSinceUnixEpoch / 1000n));
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Procedures (Beta)
|
||||
|
||||
```typescript
|
||||
export const fetch_data = spacetimedb.procedure(
|
||||
{ url: t.string() }, t.string(),
|
||||
(ctx, { url }) => {
|
||||
const response = ctx.http.fetch(url);
|
||||
ctx.withTx(tx => { tx.db.myTable.insert({ id: 0n, content: response.text() }); });
|
||||
return response.text();
|
||||
}
|
||||
);
|
||||
```
|
||||
|
||||
Procedures don't have `ctx.db` — use `ctx.withTx(tx => tx.db...)`.
|
||||
|
||||
---
|
||||
|
||||
## React Integration
|
||||
|
||||
```tsx
|
||||
import { useMemo } from 'react';
|
||||
import { SpacetimeDBProvider, useTable } from 'spacetimedb/react';
|
||||
import { DbConnection, tables } from './module_bindings';
|
||||
|
||||
function Root() {
|
||||
const connectionBuilder = useMemo(() =>
|
||||
DbConnection.builder()
|
||||
.withUri('ws://localhost:3000')
|
||||
.withDatabaseName('my_game')
|
||||
.withToken(localStorage.getItem('auth_token') || undefined)
|
||||
.onConnect((conn, identity, token) => {
|
||||
localStorage.setItem('auth_token', token);
|
||||
conn.subscriptionBuilder().subscribe(tables.player);
|
||||
}),
|
||||
[]
|
||||
);
|
||||
|
||||
return (
|
||||
<SpacetimeDBProvider connectionBuilder={connectionBuilder}>
|
||||
<App />
|
||||
</SpacetimeDBProvider>
|
||||
);
|
||||
}
|
||||
|
||||
function PlayerList() {
|
||||
const [players, isReady] = useTable(tables.player);
|
||||
if (!isReady) return <div>Loading...</div>;
|
||||
return <ul>{players.map(p => <li key={p.id}>{p.name}</li>)}</ul>;
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Project Structure
|
||||
|
||||
### Server (`backend/spacetimedb/`)
|
||||
```
|
||||
src/schema.ts -> Tables, export spacetimedb
|
||||
src/index.ts -> Reducers, lifecycle, import schema
|
||||
package.json -> { "type": "module", "dependencies": { "spacetimedb": "^2.0.0" } }
|
||||
tsconfig.json -> Standard config
|
||||
```
|
||||
|
||||
### Client (`client/`)
|
||||
```
|
||||
src/module_bindings/ -> Generated (spacetime generate)
|
||||
src/main.tsx -> Provider, connection setup
|
||||
src/App.tsx -> UI components
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Commands
|
||||
|
||||
```bash
|
||||
spacetime start
|
||||
spacetime publish <module-name> --module-path <backend-dir>
|
||||
spacetime publish <module-name> --clear-database -y --module-path <backend-dir>
|
||||
spacetime generate --lang typescript --out-dir <client>/src/module_bindings --module-path <backend-dir>
|
||||
spacetime logs <module-name>
|
||||
```
|
||||
@@ -1,292 +0,0 @@
|
||||
---
|
||||
name: spacetimedb-unity
|
||||
description: Integrate SpacetimeDB with Unity game projects. Use when building Unity clients with MonoBehaviour lifecycle, FrameTick, and PlayerPrefs token persistence.
|
||||
license: Apache-2.0
|
||||
metadata:
|
||||
author: clockworklabs
|
||||
version: "2.0"
|
||||
tested_with: "SpacetimeDB 2.0, Unity 2022.3+"
|
||||
---
|
||||
|
||||
# SpacetimeDB Unity Integration
|
||||
|
||||
This skill covers Unity-specific patterns for connecting to SpacetimeDB. For server-side module development and general C# SDK usage, see the `spacetimedb-csharp` skill.
|
||||
|
||||
---
|
||||
|
||||
## HALLUCINATED APIs — DO NOT USE
|
||||
|
||||
```csharp
|
||||
// WRONG — these do not exist in Unity SDK
|
||||
SpacetimeDBClient.instance.Connect(...); // Use DbConnection.Builder()
|
||||
SpacetimeDBClient.instance.Subscribe(...); // Use conn.SubscriptionBuilder()
|
||||
NetworkManager.RegisterReducer(...); // SpacetimeDB is not a Unity networking plugin
|
||||
|
||||
// WRONG — old 1.0 patterns
|
||||
.WithModuleName("my-db") // Use .WithDatabaseName() (2.0)
|
||||
ScheduleAt.Time(futureTime) // Use new ScheduleAt.Time(futureTime)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Common Mistakes
|
||||
|
||||
| Wrong | Right | Error |
|
||||
|-------|-------|-------|
|
||||
| Not calling `FrameTick()` | `conn?.FrameTick()` in `Update()` | No callbacks fire |
|
||||
| Accessing `conn.Db` from background thread | Copy data in callback, use on main thread | Data races / crashes |
|
||||
| Forgetting `DontDestroyOnLoad` | Add to manager `Awake()` | Connection lost on scene load |
|
||||
| Connecting in `Update()` | Connect in `Start()` or on user action | Reconnects every frame |
|
||||
| Not saving auth token | `PlayerPrefs.SetString(...)` in `OnConnect` | New identity every session |
|
||||
| Missing generated bindings | Run `spacetime generate --lang csharp` | Compile errors |
|
||||
|
||||
---
|
||||
|
||||
## Installation
|
||||
|
||||
Add via Unity Package Manager using the git URL:
|
||||
|
||||
```
|
||||
https://github.com/clockworklabs/com.clockworklabs.spacetimedbsdk.git
|
||||
```
|
||||
|
||||
**Window > Package Manager > + > Add package from git URL**
|
||||
|
||||
---
|
||||
|
||||
## Generate Module Bindings
|
||||
|
||||
```bash
|
||||
spacetime generate --lang csharp --out-dir Assets/SpacetimeDB/module_bindings --module-path PATH_TO_MODULE
|
||||
```
|
||||
|
||||
Place generated files in your Assets folder so Unity compiles them.
|
||||
|
||||
---
|
||||
|
||||
## SpacetimeManager Singleton
|
||||
|
||||
The core pattern for Unity integration. This MonoBehaviour manages the connection lifecycle.
|
||||
|
||||
```csharp
|
||||
using UnityEngine;
|
||||
using SpacetimeDB;
|
||||
using SpacetimeDB.Types;
|
||||
|
||||
public class SpacetimeManager : MonoBehaviour
|
||||
{
|
||||
private const string TOKEN_KEY = "SpacetimeAuthToken";
|
||||
private const string SERVER_URI = "http://localhost:3000";
|
||||
private const string DATABASE_NAME = "my-game";
|
||||
|
||||
public static SpacetimeManager Instance { get; private set; }
|
||||
public DbConnection Connection { get; private set; }
|
||||
public Identity LocalIdentity { get; private set; }
|
||||
|
||||
void Awake()
|
||||
{
|
||||
if (Instance != null && Instance != this) { Destroy(gameObject); return; }
|
||||
Instance = this;
|
||||
DontDestroyOnLoad(gameObject);
|
||||
}
|
||||
|
||||
void Start()
|
||||
{
|
||||
string savedToken = PlayerPrefs.GetString(TOKEN_KEY, null);
|
||||
|
||||
Connection = DbConnection.Builder()
|
||||
.WithUri(SERVER_URI)
|
||||
.WithDatabaseName(DATABASE_NAME)
|
||||
.WithToken(savedToken)
|
||||
.OnConnect(OnConnected)
|
||||
.OnConnectError(err => Debug.LogError($"Connection failed: {err}"))
|
||||
.OnDisconnect((conn, err) => {
|
||||
if (err != null) Debug.LogError($"Disconnected: {err}");
|
||||
})
|
||||
.Build();
|
||||
}
|
||||
|
||||
void Update()
|
||||
{
|
||||
Connection?.FrameTick();
|
||||
}
|
||||
|
||||
void OnDestroy()
|
||||
{
|
||||
Connection?.Disconnect();
|
||||
}
|
||||
|
||||
private void OnConnected(DbConnection conn, Identity identity, string authToken)
|
||||
{
|
||||
LocalIdentity = identity;
|
||||
PlayerPrefs.SetString(TOKEN_KEY, authToken);
|
||||
PlayerPrefs.Save();
|
||||
|
||||
Debug.Log($"Connected as: {identity}");
|
||||
|
||||
conn.SubscriptionBuilder()
|
||||
.OnApplied(OnSubscriptionApplied)
|
||||
.SubscribeToAllTables();
|
||||
}
|
||||
|
||||
private void OnSubscriptionApplied(SubscriptionEventContext ctx)
|
||||
{
|
||||
Debug.Log("Subscription applied — game state loaded");
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## FrameTick — Critical
|
||||
|
||||
**`FrameTick()` must be called every frame in `Update()`.** The SDK queues all network messages and only processes them when you call `FrameTick()`. Without it:
|
||||
- No callbacks fire (OnInsert, OnUpdate, OnDelete, reducer callbacks)
|
||||
- The client appears frozen
|
||||
|
||||
```csharp
|
||||
void Update()
|
||||
{
|
||||
Connection?.FrameTick();
|
||||
}
|
||||
```
|
||||
|
||||
**Thread safety**: `FrameTick()` processes messages on the calling thread (the main thread in Unity). Do NOT call it from a background thread. Do NOT access `conn.Db` from background threads.
|
||||
|
||||
---
|
||||
|
||||
## Subscribing to Tables
|
||||
|
||||
Subscribe in the `OnConnected` callback:
|
||||
|
||||
```csharp
|
||||
private void OnConnected(DbConnection conn, Identity identity, string authToken)
|
||||
{
|
||||
// ...save token...
|
||||
|
||||
// Development: subscribe to all
|
||||
conn.SubscriptionBuilder()
|
||||
.OnApplied(OnSubscriptionApplied)
|
||||
.SubscribeToAllTables();
|
||||
|
||||
// Production: subscribe to specific tables
|
||||
conn.SubscriptionBuilder()
|
||||
.OnApplied(OnSubscriptionApplied)
|
||||
.Subscribe(new[] {
|
||||
"SELECT * FROM player",
|
||||
"SELECT * FROM game_state"
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Row Callbacks for Game State
|
||||
|
||||
Register callbacks to update Unity GameObjects when table data changes.
|
||||
|
||||
```csharp
|
||||
void RegisterCallbacks()
|
||||
{
|
||||
Connection.Db.Player.OnInsert += (EventContext ctx, Player player) => {
|
||||
SpawnPlayerObject(player);
|
||||
};
|
||||
|
||||
Connection.Db.Player.OnDelete += (EventContext ctx, Player player) => {
|
||||
DestroyPlayerObject(player.Id);
|
||||
};
|
||||
|
||||
Connection.Db.Player.OnUpdate += (EventContext ctx, Player oldPlayer, Player newPlayer) => {
|
||||
UpdatePlayerObject(newPlayer);
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
Register these in `OnSubscriptionApplied` (after initial data is loaded) or in `Start()` before connecting.
|
||||
|
||||
---
|
||||
|
||||
## Calling Reducers from UI
|
||||
|
||||
```csharp
|
||||
public class GameUI : MonoBehaviour
|
||||
{
|
||||
public void OnMoveButtonClicked(Vector2 direction)
|
||||
{
|
||||
SpacetimeManager.Instance.Connection.Reducers.MovePlayer(direction.x, direction.y);
|
||||
}
|
||||
|
||||
public void OnSendChat(string message)
|
||||
{
|
||||
SpacetimeManager.Instance.Connection.Reducers.SendMessage(message);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Reducer Callbacks
|
||||
|
||||
```csharp
|
||||
SpacetimeManager.Instance.Connection.Reducers.OnSendMessage += (ReducerEventContext ctx, string text) => {
|
||||
if (ctx.Event.Status is Status.Committed)
|
||||
Debug.Log($"Message sent: {text}");
|
||||
else if (ctx.Event.Status is Status.Failed(var reason))
|
||||
Debug.LogError($"Send failed: {reason}");
|
||||
};
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Reading the Client Cache
|
||||
|
||||
```csharp
|
||||
// Find by primary key
|
||||
if (Connection.Db.Player.Id.Find(playerId) is Player player)
|
||||
{
|
||||
Debug.Log($"Player: {player.Name}");
|
||||
}
|
||||
|
||||
// Iterate all
|
||||
foreach (var p in Connection.Db.Player.Iter())
|
||||
{
|
||||
Debug.Log(p.Name);
|
||||
}
|
||||
|
||||
// Filter by index
|
||||
foreach (var p in Connection.Db.Player.Level.Filter(5))
|
||||
{
|
||||
Debug.Log($"Level 5: {p.Name}");
|
||||
}
|
||||
|
||||
// Count
|
||||
int total = Connection.Db.Player.Count;
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Unity-Specific Considerations
|
||||
|
||||
### Main Thread Only
|
||||
All SpacetimeDB SDK calls (`FrameTick`, `conn.Db` access, reducer calls) must happen on the main thread. If you need to pass data to a background thread, copy it first in the callback.
|
||||
|
||||
### Scene Loading
|
||||
Use `DontDestroyOnLoad(gameObject)` on the SpacetimeManager to prevent the connection from being destroyed during scene transitions. Without it, the connection drops every time you load a new scene.
|
||||
|
||||
### IL2CPP / AOT
|
||||
The SpacetimeDB SDK uses code generation. If you encounter issues with IL2CPP builds:
|
||||
- Ensure generated bindings are up to date
|
||||
- Check that `link.xml` preserves SpacetimeDB types if you use assembly stripping
|
||||
|
||||
### Token Persistence
|
||||
Token save/load via `PlayerPrefs` is demonstrated in the SpacetimeManager singleton above. If the token is stale or invalid, the server issues a new identity and token in the `OnConnect` callback.
|
||||
|
||||
---
|
||||
|
||||
## Commands
|
||||
|
||||
```bash
|
||||
spacetime start
|
||||
spacetime publish <module-name> --module-path <backend-dir>
|
||||
spacetime publish <module-name> --clear-database -y --module-path <backend-dir>
|
||||
spacetime generate --lang csharp --out-dir Assets/SpacetimeDB/module_bindings --module-path <backend-dir>
|
||||
spacetime logs <module-name>
|
||||
```
|
||||
121
.codex/skills/wechatpay-basic-payment/SKILL.md
Normal file
121
.codex/skills/wechatpay-basic-payment/SKILL.md
Normal file
@@ -0,0 +1,121 @@
|
||||
---
|
||||
name: wechatpay-basic-payment
|
||||
description: 微信支付基础支付解决方案,涵盖支付、退款账单、分账、商户进件、开户意愿确认,提供选型/代码示例/业务速查/质量评估/排障五大能力。Use when user mentions "JSAPI支付", "APP支付", "H5支付", "Native支付", "小程序支付", "付款码支付", "合单支付", "特约商户进件", "开户意愿确认", or asks to "推荐支付方式", "要支付接口代码示例", "排查支付或退款问题".
|
||||
author: wechatpay
|
||||
version: "1.0"
|
||||
---
|
||||
|
||||
# 微信支付基础支付 & 合单支付接入指引
|
||||
|
||||
## 全局交互规范
|
||||
|
||||
> ‼️ 以下规则适用于本技能所有能力、所有对话轮次,优先级高于各能力的局部规则。
|
||||
|
||||
1. **所有问题必须得到用户明确回答后才能继续。** 如果一次提出了多个问题,必须逐一检查每个问题是否都已获得用户的明确答复。对于未回答的问题,必须再次追问,**严禁对未回答的问题自行假设、推断或使用默认值**。
|
||||
2. **接入模式前置确认**:任何能力使用前须先确认**商户模式**或**服务商模式**,已明确则无需重复。两种模式的核心差异见 → [📄 接入模式说明.md](./references/3-商户与服务商通用/接入指南/接入模式说明.md)。
|
||||
3. **分步确认协议**(简单知识问答除外,需要帮用户排查、分析或执行操作时必须遵守):
|
||||
- **① 明确需求**:先理解用户问题,给出初步判断或原因分析,不要一上来就堆参数清单。
|
||||
- **② 征得同意**:主动提出下一步能做什么,**等用户明确同意后**才继续,严禁用户没表态就开始收集参数或执行操作。
|
||||
- **③ 收集信息**:用户同意后再告知需要哪些信息并逐项收集,收齐才能执行。
|
||||
- **④ 执行前确认**:准备执行操作前,简要说明即将做什么,确认用户同意后再执行;涉及线上环境须额外提示风险。
|
||||
|
||||
## 能力概览
|
||||
|
||||
1. **产品选型** — 根据场景推荐支付方式(JSAPI/APP/H5/Native/小程序/付款码),判断是否需要合单支付
|
||||
2. **示例代码** — 各接口的下单、调起、回调、退款、账单等代码结构示例(只展示不写入)
|
||||
3. **业务知识速查** — 订单状态、退款规则、账单对账、APPID绑定、特约商户进件、开户意愿确认等
|
||||
4. **接入质量评估** — 签名验签、业务逻辑完整性、回调处理规范性检查(含合单/分账/进件/开户意愿确认专项)
|
||||
5. **问题排查** — 下单失败、调起异常、回调收不到、退款失败等(含合单支付专项常见问题)
|
||||
|
||||
> 未明确支付方式时先通过能力1引导选型。退款和账单无需确认支付方式,但仍需确认接入模式。合单支付需先确认是否涉及多商户/多APPID场景。特约商户进件和商户开户意愿确认仅适用于服务商/渠道商模式。
|
||||
|
||||
## 能力1:产品选型
|
||||
|
||||
> 用户问「该用哪种支付方式」或比较各方式区别时 → 加载 `支付产品对比.md`,确定支付方式后再按需加载示例代码。
|
||||
|
||||
- 产品对比 + 选型决策树 + 准入条件 + 调起支付差异 → [📄 支付产品对比.md](./references/3-商户与服务商通用/产品选型/支付产品对比.md)
|
||||
|
||||
## 能力2:示例代码
|
||||
|
||||
> 用户要某个接口的代码示例时 → 确认接入模式和语言,加载对应模式的 `接口索引.md` 定位代码文件。
|
||||
>
|
||||
> ‼️ **只检索、不生成。** 严禁从零编写任何代码,必须从代码示例文件中检索获取。
|
||||
>
|
||||
> ‼️ **只展示、不写入。** 代码示例仅用于讲解 API 调用结构和签名流程,严禁直接写入用户项目(禁止调用 write_to_file、replace_in_file 等工具创建或修改项目文件)。在对话中展示代码,让用户自行复制适配。
|
||||
>
|
||||
> ‼️ **先交互、后输出。** 提供代码前必须先确认接入模式、开发语言和具体接口,每次只输出一个接口;提供完代码后主动推荐接入质量评估。
|
||||
>
|
||||
> ‼️ **支付方式仅「下单」和「调起支付」接口需确认,其他接口无需询问支付方式。** 用户请求查单、关单、退款、回调处理、账单等通用接口时,只需确认接入模式和开发语言,无需询问支付方式——这些接口各支付方式完全相同。**但合单支付的查单、关单、回调使用专用接口,需确认用户是基础支付还是合单支付。**
|
||||
>
|
||||
> ‼️ **用户语言非 Java/Go 时**(本 skill 仅维护 Java/Go 示例):**禁止**直接生成跨语言代码。流程:
|
||||
> 1. 用 `AskQuestion` 获明确同意(文案需明示「参考实现 / 非官方维护 / 须自行 review 与测试」),未同意只发官方 Java/Go 原文。
|
||||
> 2. 同意后以官方 Java 示例为基准翻译生成业务代码「参考实现」;再用纯文字问是否翻 Java 公库(SDK 工具类 + HTTP 客户端),未明确要不贴。每段代码前附下方免责块。
|
||||
>
|
||||
> > ⚠️ 以下代码为**跨语言参考实现**,由 AI 参考官方 Java 示例翻译生成,并非微信支付官方维护。
|
||||
> > - 请**逐行 review** 签名构造、HTTP 调用、字段命名、回调解密等关键逻辑。
|
||||
> > - 上线前必须在测试环境完整验证,建议先以官方 Java/Go 示例打通主链路作为对照。
|
||||
> > - 出现接入问题时以官方 Java/Go 示例为准。
|
||||
|
||||
- 涉及提供示例代码时,按接入模式查阅对应接口索引,定位目标代码文件:
|
||||
- 商户模式 → [📄 接口索引.md](./references/1-商户/示例代码/接口索引.md)
|
||||
- 服务商模式 → [📄 接口索引.md](./references/2-服务商/示例代码/接口索引.md)
|
||||
|
||||
> **加载策略**:先确认接入模式,读对应的 `接口索引.md` 定位用户需要的接口对应的文件路径,再按需加载具体文件。不要一次性加载所有文件。
|
||||
|
||||
## 能力3:业务知识速查
|
||||
|
||||
> 用户问参数获取、APPID绑定、订单状态、退款规则、分账等业务知识时 → 按接入模式加载对应文档。
|
||||
|
||||
- 开发必要参数 / APPID类型 / APPID绑定流程:
|
||||
- 商户模式 → [📄 开发必要参数说明.md](./references/1-商户/接入指南/开发必要参数说明.md)
|
||||
- 服务商模式 → [📄 开发必要参数说明.md](./references/2-服务商/接入指南/开发必要参数说明.md)
|
||||
- 点金计划(服务商 JSAPI 必接) → [📄 点金计划.md](./references/2-服务商/接入指南/点金计划.md)
|
||||
- 订单状态 / 关单 / 终态 → [📄 订单状态流转.md](./references/3-商户与服务商通用/接入指南/订单状态流转.md)
|
||||
- 分账 → [📄 分账接入指南.md](./references/3-商户与服务商通用/接入指南/分账接入指南.md)
|
||||
- 特约商户进件(仅服务商) → [📄 特约商户进件.md](./references/2-服务商/接入指南/特约商户进件.md)
|
||||
- 商户开户意愿确认(仅服务商/渠道商) → [📄 商户开户意愿确认.md](./references/2-服务商/接入指南/商户开户意愿确认.md)
|
||||
- 退款规则 / 账单对账 → 已整合到示例代码注释中,通过能力2加载
|
||||
|
||||
> **加载策略**:按关键词匹配文档,区分接入模式。特约商户进件和商户开户意愿确认为服务商/渠道商专属,商户模式无需加载。
|
||||
|
||||
## 能力4:接入质量评估
|
||||
|
||||
> 用户准备上线或想检查代码隐患时 → 加载以下文档。
|
||||
>
|
||||
> ‼️ **只检查用户实际使用的功能模块。** 合单支付、分账、进件、开户意愿确认等模块须先确认用户是否涉及,**未使用的不检查、不提及**。
|
||||
|
||||
- 签名验签 → [📄 签名与验签规则.md](./references/3-商户与服务商通用/接入指南/签名与验签规则.md)
|
||||
- 业务逻辑完整性(含质检人设 + 检查清单) → [📄 接入质量检查清单.md](./references/3-商户与服务商通用/接入指南/接入质量检查清单.md)
|
||||
- 回调处理规范 → [📄 回调通知处理.md](./references/3-商户与服务商通用/接入指南/回调通知处理.md)
|
||||
|
||||
## 能力5:问题排查
|
||||
|
||||
> 用户遇到报错或接口调用异常时 → 按下方路径分流加载。
|
||||
>
|
||||
> ‼️ **排障推荐示例代码时,必须先确认开发语言,只推荐对应的示例。** 排障手册中每个错误码的「示例代码推荐」可能涉及 Java/Go 两种语言示例,但输出时**只输出匹配的示例**。开发语言尚未确认时,先在推荐示例代码时自然地询问用户。
|
||||
>
|
||||
> ‼️ **用户语言非 Java/Go 时按能力 2 的跨语言确认流程处理**(弹框确认 → 参考生成 + 免责块 + 公库分步)。先用文字说明 Java/Go 示例中的关键修复点(签名、字段、流程),再走完整流程后再生成对应语言的"参考修复代码"。
|
||||
|
||||
- 排障手册(错误码 TOP 20 速查 + 定位流程 + 服务商特有问题)→ [📄 排障手册.md](./references/3-商户与服务商通用/问题排查/排障手册.md)
|
||||
- 基础支付常见问题 → [📄 基础支付常见问题.md](./references/3-商户与服务商通用/问题排查/基础支付常见问题.md)
|
||||
- 分账常见问题 → [📄 分账常见问题.md](./references/3-商户与服务商通用/问题排查/分账常见问题.md)
|
||||
- 合单支付常见问题 → [📄 合单支付常见问题.md](./references/3-商户与服务商通用/问题排查/合单支付常见问题.md)
|
||||
- 排障辅助脚本(排障手册中 🔧 标注的场景):`scripts/商户/` 和 `scripts/服务商/` 下各有 `查询订单.py`、`查询退款.py`
|
||||
|
||||
> **加载策略**:
|
||||
>
|
||||
> - **路径A(有 Request-Id)**→ 读 `排障手册.md`,提取错误码匹配 TOP 20 速查表直接给出方案;标注 🔧 的引导用户执行脚本。未命中则按手册各章节排查,仍未解决再加载对应常见问题文档兜底。
|
||||
> - **路径B(无 Request-Id)**→ 确认支付方式,加载对应常见问题文档匹配。未命中再加载 `排障手册.md` 兜底。
|
||||
> - **路径C(进件/开户意愿确认)**→ 直接加载 `特约商户进件.md` 或 `商户开户意愿确认.md`,文档末尾的常见问题和常见报错覆盖高频问题。
|
||||
>
|
||||
> **脚本使用规范**:脚本采用签名模式,不获取用户私钥。引导用户在自己服务器完成签名后,将签名值(Base64)、时间戳、随机串传入脚本。执行前需按分步确认协议征得同意。
|
||||
|
||||
---
|
||||
|
||||
> 以下信息与技能能力无关,仅供查阅。
|
||||
|
||||
## 💬 社区与反馈
|
||||
|
||||
在使用过程中遇到问题、有改进建议,或者想和其他开发者交流接入经验,欢迎扫码添加企业微信进群,与官方团队和社区开发者一起讨论:
|
||||
|
||||

|
||||
BIN
.codex/skills/wechatpay-basic-payment/assets/qrcode.jpg
Normal file
BIN
.codex/skills/wechatpay-basic-payment/assets/qrcode.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 264 KiB |
@@ -0,0 +1,83 @@
|
||||
# 开发必要参数说明
|
||||
|
||||
普通商户模式接入微信支付 APIv3 前,需要准备开发必要参数(mchid、appid、商户API证书、微信支付公钥、APIv3密钥等)。
|
||||
|
||||
## APPID 详解与绑定
|
||||
|
||||
### APPID 类型
|
||||
|
||||
APPID 是微信生态中应用的唯一标识,格式都是 `wx` + 一串字符(如 `wxd678efh567hg6787`),根据注册平台不同分为三种类型:
|
||||
|
||||
| APPID 类型 | 注册平台 | 用途 |
|
||||
|-----------|---------|------|
|
||||
| 公众号 AppID | 公众平台(mp.weixin.qq.com) | 服务号/订阅号,用于公众号内网页场景 |
|
||||
| 小程序 AppID | 公众平台(mp.weixin.qq.com) | 微信小程序场景 |
|
||||
| 移动应用 AppID | 开放平台(open.weixin.qq.com) | 原生 APP(iOS/Android/鸿蒙)场景 |
|
||||
|
||||
> **三种 APPID 格式相同但不能混用**。拿小程序 AppID 做 JSAPI 支付会报错,拿公众号 AppID 做 APP 支付也不行。
|
||||
|
||||
### 为什么需要绑定 APPID
|
||||
|
||||
微信支付的所有支付方式都要求商户号与 APPID 建立绑定关系,未绑定时下单接口会报错。
|
||||
|
||||
### 如何查询 APPID
|
||||
|
||||
| APPID 类型 | 查询路径 |
|
||||
|-----------|---------|
|
||||
| 服务号/公众号 | 登录公众平台 → 设置与开发 → 开发接口管理 → 基本配置 → 开发者ID(AppID) |
|
||||
| 小程序 | 登录公众平台 → 开发与服务 → 开发管理 → 开发设置 → AppID(小程序ID) |
|
||||
| 移动应用 | 登录开放平台 → 管理中心 → 移动应用 → 查看 → 详情页面 → APPID |
|
||||
|
||||
### 如何绑定
|
||||
|
||||
**第一步:在商户平台发起绑定申请**
|
||||
|
||||
登录商户平台 → 产品中心 → APPID授权管理 → +关联AppID → 新增授权 → 填写 APPID → 提交
|
||||
|
||||
- 主体一致:直接填写 APPID 提交
|
||||
- 主体不一致:还需填写 APPID 认证主体,并勾选《微信支付联合营运承诺函》
|
||||
|
||||
**第二步:在对应平台确认授权**
|
||||
|
||||
| APPID 类型 | 确认路径 |
|
||||
|-----------|---------|
|
||||
| 服务号/公众号 | 登录公众平台 → 微信支付 → 商户号管理 → 待关联商户号 → 确认 |
|
||||
| 小程序 | 登录公众平台 → 微信支付 → 商户号管理 → 待关联商户号 → 确认 |
|
||||
| 移动应用 | 登录开放平台 → 移动应用 → 详情 → 能力专区 → 微信支付 → 查询详情 → 待关联商户号 → 确认 |
|
||||
|
||||
> 委托第三方创建的小程序,需先设置邮箱密码后登录 PC 端确认。
|
||||
|
||||
**第三步:查看绑定结果**
|
||||
|
||||
登录商户平台 → 产品中心 → APPID账号管理 → 我关联的APPID账号
|
||||
|
||||
### 绑定限制
|
||||
|
||||
| 限制项 | 说明 |
|
||||
|-------|------|
|
||||
| 数量上限 | 一个商户号最多关联 50 个 APPID |
|
||||
| 解绑 | 绑定后不支持解绑,每条关系相互独立 |
|
||||
| 跨主体 | 需补充 APPID 主体信息 |
|
||||
| 特殊费率 | 享有特殊行业费率的商户号,提交后有额外审核(1-3个工作日) |
|
||||
| 费率一致性 | APPID 已绑定其他商户号时,新商户号的费率需与已绑定的一致 |
|
||||
| 风控 | 商户号或 APPID 存在风险时(资料不全、有未处理处罚等),可能增加审核或被驳回 |
|
||||
|
||||
### APPID 相关常见报错
|
||||
|
||||
| 报错信息 | 原因 | 处理方式 |
|
||||
|---------|------|---------|
|
||||
| `appid and mchid not match` | 下单时传入的 appid 与商户号未建立绑定关系 | 按上述流程绑定 |
|
||||
| `appid is invalid` | appid 格式不对,或使用了错误类型的 appid | 检查是否用了正确类型的 APPID(如 JSAPI 需要公众号 AppID,不能用小程序 AppID) |
|
||||
| JSAPI 支付报权限错误 | 商户号绑定的是小程序 APPID,但用 JSAPI 调起 | JSAPI 需要绑定服务号 APPID |
|
||||
|
||||
## 参数与代码示例的对应关系
|
||||
|
||||
示例代码中构造函数所需的参数与上述开发必要参数的对应:
|
||||
|
||||
```
|
||||
mchid → 商户号
|
||||
certificateSerialNo → 商户API证书序列号
|
||||
privateKeyFilePath → 商户API证书私钥文件路径(apiclient_key.pem)
|
||||
wechatPayPublicKeyId → 微信支付公钥ID
|
||||
wechatPayPublicKeyFilePath → 微信支付公钥文件路径(wxp_pub.pem)
|
||||
```
|
||||
@@ -0,0 +1,203 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"demo/wxpay_utility" // 引用微信支付工具库,参考 https://pay.weixin.qq.com/doc/v3/merchant/4015119334
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"time"
|
||||
)
|
||||
|
||||
func main() {
|
||||
// TODO: 请准备商户开发必要参数,参考:https://pay.weixin.qq.com/doc/v3/merchant/4013070756
|
||||
config, err := wxpay_utility.CreateMchConfig(
|
||||
"19xxxxxxxx", // 商户号,是由微信支付系统生成并分配给每个商户的唯一标识符,商户号获取方式参考 https://pay.weixin.qq.com/doc/v3/merchant/4013070756
|
||||
"1DDE55AD98Exxxxxxxxxx", // 商户API证书序列号,如何获取请参考 https://pay.weixin.qq.com/doc/v3/merchant/4013053053
|
||||
"/path/to/apiclient_key.pem", // 商户API证书私钥文件路径,本地文件路径
|
||||
"PUB_KEY_ID_xxxxxxxxxxxxx", // 微信支付公钥ID,如何获取请参考 https://pay.weixin.qq.com/doc/v3/merchant/4013038816
|
||||
"/path/to/wxp_pub.pem", // 微信支付公钥文件路径,本地文件路径
|
||||
)
|
||||
if err != nil {
|
||||
fmt.Println(err)
|
||||
return
|
||||
}
|
||||
|
||||
request := &DirectApiv3JsapiPrepayRequest{
|
||||
Appid: wxpay_utility.String("wxd678efh567hg6787"),
|
||||
Mchid: wxpay_utility.String("1230000109"),
|
||||
Description: wxpay_utility.String("Image形象店-深圳腾大-QQ公仔"),
|
||||
OutTradeNo: wxpay_utility.String("1217752501201407033233368018"),
|
||||
TimeExpire: wxpay_utility.Time(time.Now()),
|
||||
Attach: wxpay_utility.String("自定义数据说明"),
|
||||
NotifyUrl: wxpay_utility.String(" https://www.weixin.qq.com/wxpay/pay.php"),
|
||||
GoodsTag: wxpay_utility.String("WXG"),
|
||||
SupportFapiao: wxpay_utility.Bool(false),
|
||||
Amount: &CommonAmountInfo{
|
||||
Total: wxpay_utility.Int64(100),
|
||||
Currency: wxpay_utility.String("CNY"),
|
||||
},
|
||||
Payer: &JsapiReqPayerInfo{
|
||||
Openid: wxpay_utility.String("oUpF8uMuAJO_M2pxb1Q9zNjWeS6o"),
|
||||
},
|
||||
Detail: &CouponInfo{
|
||||
CostPrice: wxpay_utility.Int64(608800),
|
||||
InvoiceId: wxpay_utility.String("微信123"),
|
||||
GoodsDetail: []GoodsDetail{GoodsDetail{
|
||||
MerchantGoodsId: wxpay_utility.String("1246464644"),
|
||||
WechatpayGoodsId: wxpay_utility.String("1001"),
|
||||
GoodsName: wxpay_utility.String("iPhoneX 256G"),
|
||||
Quantity: wxpay_utility.Int64(1),
|
||||
UnitPrice: wxpay_utility.Int64(528800),
|
||||
}},
|
||||
},
|
||||
SceneInfo: &CommonSceneInfo{
|
||||
PayerClientIp: wxpay_utility.String("14.23.150.211"),
|
||||
DeviceId: wxpay_utility.String("013467007045764"),
|
||||
StoreInfo: &StoreInfo{
|
||||
Id: wxpay_utility.String("0001"),
|
||||
Name: wxpay_utility.String("腾讯大厦分店"),
|
||||
AreaCode: wxpay_utility.String("440305"),
|
||||
Address: wxpay_utility.String("广东省深圳市南山区科技中一道10000号"),
|
||||
},
|
||||
},
|
||||
SettleInfo: &SettleInfo{
|
||||
ProfitSharing: wxpay_utility.Bool(false),
|
||||
},
|
||||
}
|
||||
|
||||
response, err := JsapiPrepay(config, request)
|
||||
if err != nil {
|
||||
fmt.Printf("请求失败: %+v\n", err)
|
||||
// TODO: 请求失败,根据状态码执行不同的处理
|
||||
return
|
||||
}
|
||||
|
||||
// TODO: 请求成功,继续业务逻辑
|
||||
fmt.Printf("请求成功: %+v\n", response)
|
||||
}
|
||||
|
||||
func JsapiPrepay(config *wxpay_utility.MchConfig, request *DirectApiv3JsapiPrepayRequest) (response *DirectApiv3JsapiPrepayResponse, err error) {
|
||||
const (
|
||||
host = "https://api.mch.weixin.qq.com"
|
||||
method = "POST"
|
||||
path = "/v3/pay/transactions/jsapi"
|
||||
)
|
||||
|
||||
reqUrl, err := url.Parse(fmt.Sprintf("%s%s", host, path))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
reqBody, err := json.Marshal(request)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
httpRequest, err := http.NewRequest(method, reqUrl.String(), bytes.NewReader(reqBody))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
httpRequest.Header.Set("Accept", "application/json")
|
||||
httpRequest.Header.Set("Wechatpay-Serial", config.WechatPayPublicKeyId())
|
||||
httpRequest.Header.Set("Content-Type", "application/json")
|
||||
authorization, err := wxpay_utility.BuildAuthorization(config.MchId(), config.CertificateSerialNo(), config.PrivateKey(), method, reqUrl.RequestURI(), reqBody)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
httpRequest.Header.Set("Authorization", authorization)
|
||||
|
||||
client := &http.Client{}
|
||||
httpResponse, err := client.Do(httpRequest)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
respBody, err := wxpay_utility.ExtractResponseBody(httpResponse)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if httpResponse.StatusCode >= 200 && httpResponse.StatusCode < 300 {
|
||||
// 2XX 成功,验证应答签名
|
||||
err = wxpay_utility.ValidateResponse(
|
||||
config.WechatPayPublicKeyId(),
|
||||
config.WechatPayPublicKey(),
|
||||
&httpResponse.Header,
|
||||
respBody,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
response := &DirectApiv3JsapiPrepayResponse{}
|
||||
if err := json.Unmarshal(respBody, response); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return response, nil
|
||||
} else {
|
||||
return nil, wxpay_utility.NewApiException(
|
||||
httpResponse.StatusCode,
|
||||
httpResponse.Header,
|
||||
respBody,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
type DirectApiv3JsapiPrepayRequest struct {
|
||||
Appid *string `json:"appid,omitempty"`
|
||||
Mchid *string `json:"mchid,omitempty"`
|
||||
Description *string `json:"description,omitempty"`
|
||||
OutTradeNo *string `json:"out_trade_no,omitempty"`
|
||||
TimeExpire *time.Time `json:"time_expire,omitempty"`
|
||||
Attach *string `json:"attach,omitempty"`
|
||||
NotifyUrl *string `json:"notify_url,omitempty"`
|
||||
GoodsTag *string `json:"goods_tag,omitempty"`
|
||||
SupportFapiao *bool `json:"support_fapiao,omitempty"`
|
||||
Amount *CommonAmountInfo `json:"amount,omitempty"`
|
||||
Payer *JsapiReqPayerInfo `json:"payer,omitempty"`
|
||||
Detail *CouponInfo `json:"detail,omitempty"`
|
||||
SceneInfo *CommonSceneInfo `json:"scene_info,omitempty"`
|
||||
SettleInfo *SettleInfo `json:"settle_info,omitempty"`
|
||||
}
|
||||
|
||||
type DirectApiv3JsapiPrepayResponse struct {
|
||||
PrepayId *string `json:"prepay_id,omitempty"`
|
||||
}
|
||||
|
||||
type CommonAmountInfo struct {
|
||||
Total *int64 `json:"total,omitempty"`
|
||||
Currency *string `json:"currency,omitempty"`
|
||||
}
|
||||
|
||||
type JsapiReqPayerInfo struct {
|
||||
Openid *string `json:"openid,omitempty"`
|
||||
}
|
||||
|
||||
type CouponInfo struct {
|
||||
CostPrice *int64 `json:"cost_price,omitempty"`
|
||||
InvoiceId *string `json:"invoice_id,omitempty"`
|
||||
GoodsDetail []GoodsDetail `json:"goods_detail,omitempty"`
|
||||
}
|
||||
|
||||
type CommonSceneInfo struct {
|
||||
PayerClientIp *string `json:"payer_client_ip,omitempty"`
|
||||
DeviceId *string `json:"device_id,omitempty"`
|
||||
StoreInfo *StoreInfo `json:"store_info,omitempty"`
|
||||
}
|
||||
|
||||
type SettleInfo struct {
|
||||
ProfitSharing *bool `json:"profit_sharing,omitempty"`
|
||||
}
|
||||
|
||||
type GoodsDetail struct {
|
||||
MerchantGoodsId *string `json:"merchant_goods_id,omitempty"`
|
||||
WechatpayGoodsId *string `json:"wechatpay_goods_id,omitempty"`
|
||||
GoodsName *string `json:"goods_name,omitempty"`
|
||||
Quantity *int64 `json:"quantity,omitempty"`
|
||||
UnitPrice *int64 `json:"unit_price,omitempty"`
|
||||
}
|
||||
|
||||
type StoreInfo struct {
|
||||
Id *string `json:"id,omitempty"`
|
||||
Name *string `json:"name,omitempty"`
|
||||
AreaCode *string `json:"area_code,omitempty"`
|
||||
Address *string `json:"address,omitempty"`
|
||||
}
|
||||
@@ -0,0 +1,177 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"demo/wxpay_utility" // 引用微信支付工具库,参考 https://pay.weixin.qq.com/doc/v3/merchant/4015119334
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/url"
|
||||
)
|
||||
|
||||
// 申请交易账单API
|
||||
//
|
||||
// 关键注意:
|
||||
// 1. 次日10点后拉取,API仅支持3个月内单日账单,更早的需在商户平台下载。
|
||||
// 2. 返回的是下载链接(download_url),需二次请求下载(gzip压缩CSV)。
|
||||
// 3. 账单金额单位为"元",与下单API的"分"不同,对账时注意转换。
|
||||
func main() {
|
||||
// TODO: 请准备商户开发必要参数,参考:https://pay.weixin.qq.com/doc/v3/merchant/4013070756
|
||||
config, err := wxpay_utility.CreateMchConfig(
|
||||
"19xxxxxxxx", // 商户号,是由微信支付系统生成并分配给每个商户的唯一标识符,商户号获取方式参考 https://pay.weixin.qq.com/doc/v3/merchant/4013070756
|
||||
"1DDE55AD98Exxxxxxxxxx", // 商户API证书序列号,如何获取请参考 https://pay.weixin.qq.com/doc/v3/merchant/4013053053
|
||||
"/path/to/apiclient_key.pem", // 商户API证书私钥文件路径,本地文件路径
|
||||
"PUB_KEY_ID_xxxxxxxxxxxxx", // 微信支付公钥ID,如何获取请参考 https://pay.weixin.qq.com/doc/v3/merchant/4013038816
|
||||
"/path/to/wxp_pub.pem", // 微信支付公钥文件路径,本地文件路径
|
||||
)
|
||||
if err != nil {
|
||||
fmt.Println(err)
|
||||
return
|
||||
}
|
||||
|
||||
request := &GetTradeBillRequest{
|
||||
BillDate: wxpay_utility.String("2019-06-11"),
|
||||
BillType: BILLTYPE_ALL.Ptr(),
|
||||
TarType: TARTYPE_GZIP.Ptr(),
|
||||
}
|
||||
|
||||
response, err := GetTradeBill(config, request)
|
||||
if err != nil {
|
||||
fmt.Printf("请求失败: %+v\n", err)
|
||||
// TODO: 请求失败,根据状态码执行不同的处理
|
||||
return
|
||||
}
|
||||
|
||||
// TODO: 请求成功,继续业务逻辑
|
||||
fmt.Printf("请求成功: %+v\n", response)
|
||||
}
|
||||
|
||||
// GetTradeBill 申请交易账单API
|
||||
func GetTradeBill(config *wxpay_utility.MchConfig, request *GetTradeBillRequest) (response *QueryBillEntity, err error) {
|
||||
const (
|
||||
host = "https://api.mch.weixin.qq.com"
|
||||
method = "GET"
|
||||
path = "/v3/bill/tradebill"
|
||||
)
|
||||
|
||||
reqUrl, err := url.Parse(fmt.Sprintf("%s%s", host, path))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
query := reqUrl.Query()
|
||||
if request.BillDate != nil {
|
||||
query.Add("bill_date", *request.BillDate)
|
||||
}
|
||||
if request.BillType != nil {
|
||||
query.Add("bill_type", fmt.Sprintf("%v", *request.BillType))
|
||||
}
|
||||
if request.TarType != nil {
|
||||
query.Add("tar_type", fmt.Sprintf("%v", *request.TarType))
|
||||
}
|
||||
reqUrl.RawQuery = query.Encode()
|
||||
httpRequest, err := http.NewRequest(method, reqUrl.String(), nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
httpRequest.Header.Set("Accept", "application/json")
|
||||
httpRequest.Header.Set("Wechatpay-Serial", config.WechatPayPublicKeyId())
|
||||
authorization, err := wxpay_utility.BuildAuthorization(config.MchId(), config.CertificateSerialNo(), config.PrivateKey(), method, reqUrl.RequestURI(), nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
httpRequest.Header.Set("Authorization", authorization)
|
||||
|
||||
client := &http.Client{}
|
||||
httpResponse, err := client.Do(httpRequest)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
respBody, err := wxpay_utility.ExtractResponseBody(httpResponse)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if httpResponse.StatusCode >= 200 && httpResponse.StatusCode < 300 {
|
||||
// 2XX 成功,验证应答签名
|
||||
err = wxpay_utility.ValidateResponse(
|
||||
config.WechatPayPublicKeyId(),
|
||||
config.WechatPayPublicKey(),
|
||||
&httpResponse.Header,
|
||||
respBody,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
response := &QueryBillEntity{}
|
||||
if err := json.Unmarshal(respBody, response); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return response, nil
|
||||
} else {
|
||||
return nil, wxpay_utility.NewApiException(
|
||||
httpResponse.StatusCode,
|
||||
httpResponse.Header,
|
||||
respBody,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
type GetTradeBillRequest struct {
|
||||
BillDate *string `json:"bill_date,omitempty"`
|
||||
BillType *BillType `json:"bill_type,omitempty"`
|
||||
TarType *TarType `json:"tar_type,omitempty"`
|
||||
}
|
||||
|
||||
func (o *GetTradeBillRequest) MarshalJSON() ([]byte, error) {
|
||||
type Alias GetTradeBillRequest
|
||||
a := &struct {
|
||||
BillDate *string `json:"bill_date,omitempty"`
|
||||
BillType *BillType `json:"bill_type,omitempty"`
|
||||
TarType *TarType `json:"tar_type,omitempty"`
|
||||
*Alias
|
||||
}{
|
||||
// 序列化时移除非 Body 字段
|
||||
BillDate: nil,
|
||||
BillType: nil,
|
||||
TarType: nil,
|
||||
Alias: (*Alias)(o),
|
||||
}
|
||||
return json.Marshal(a)
|
||||
}
|
||||
|
||||
type QueryBillEntity struct {
|
||||
HashType *HashType `json:"hash_type,omitempty"`
|
||||
HashValue *string `json:"hash_value,omitempty"`
|
||||
DownloadUrl *string `json:"download_url,omitempty"`
|
||||
}
|
||||
|
||||
type BillType string
|
||||
|
||||
func (e BillType) Ptr() *BillType {
|
||||
return &e
|
||||
}
|
||||
|
||||
const (
|
||||
BILLTYPE_ALL BillType = "ALL"
|
||||
BILLTYPE_SUCCESS BillType = "SUCCESS"
|
||||
BILLTYPE_REFUND BillType = "REFUND"
|
||||
)
|
||||
|
||||
type TarType string
|
||||
|
||||
func (e TarType) Ptr() *TarType {
|
||||
return &e
|
||||
}
|
||||
|
||||
const (
|
||||
TARTYPE_GZIP TarType = "GZIP"
|
||||
)
|
||||
|
||||
type HashType string
|
||||
|
||||
func (e HashType) Ptr() *HashType {
|
||||
return &e
|
||||
}
|
||||
|
||||
const (
|
||||
HASHTYPE_SHA1 HashType = "SHA1"
|
||||
)
|
||||
@@ -0,0 +1,171 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"demo/wxpay_utility" // 引用微信支付工具库,参考 https://pay.weixin.qq.com/doc/v3/merchant/4015119334
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/url"
|
||||
)
|
||||
|
||||
func main() {
|
||||
// TODO: 请准备商户开发必要参数,参考:https://pay.weixin.qq.com/doc/v3/merchant/4013070756
|
||||
config, err := wxpay_utility.CreateMchConfig(
|
||||
"19xxxxxxxx", // 商户号,是由微信支付系统生成并分配给每个商户的唯一标识符,商户号获取方式参考 https://pay.weixin.qq.com/doc/v3/merchant/4013070756
|
||||
"1DDE55AD98Exxxxxxxxxx", // 商户API证书序列号,如何获取请参考 https://pay.weixin.qq.com/doc/v3/merchant/4013053053
|
||||
"/path/to/apiclient_key.pem", // 商户API证书私钥文件路径,本地文件路径
|
||||
"PUB_KEY_ID_xxxxxxxxxxxxx", // 微信支付公钥ID,如何获取请参考 https://pay.weixin.qq.com/doc/v3/merchant/4013038816
|
||||
"/path/to/wxp_pub.pem", // 微信支付公钥文件路径,本地文件路径
|
||||
)
|
||||
if err != nil {
|
||||
fmt.Println(err)
|
||||
return
|
||||
}
|
||||
|
||||
request := &GetFundFlowBillRequest{
|
||||
BillDate: wxpay_utility.String("2019-06-11"),
|
||||
AccountType: FUNDFLOWBILLACCOUNTTYPE_BASIC.Ptr(),
|
||||
TarType: TARTYPE_GZIP.Ptr(),
|
||||
}
|
||||
|
||||
response, err := GetFundFlowBill(config, request)
|
||||
if err != nil {
|
||||
fmt.Printf("请求失败: %+v\n", err)
|
||||
// TODO: 请求失败,根据状态码执行不同的处理
|
||||
return
|
||||
}
|
||||
|
||||
// TODO: 请求成功,继续业务逻辑
|
||||
fmt.Printf("请求成功: %+v\n", response)
|
||||
}
|
||||
|
||||
// GetFundFlowBill 申请资金账单API
|
||||
func GetFundFlowBill(config *wxpay_utility.MchConfig, request *GetFundFlowBillRequest) (response *QueryBillEntity, err error) {
|
||||
const (
|
||||
host = "https://api.mch.weixin.qq.com"
|
||||
method = "GET"
|
||||
path = "/v3/bill/fundflowbill"
|
||||
)
|
||||
|
||||
reqUrl, err := url.Parse(fmt.Sprintf("%s%s", host, path))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
query := reqUrl.Query()
|
||||
if request.BillDate != nil {
|
||||
query.Add("bill_date", *request.BillDate)
|
||||
}
|
||||
if request.AccountType != nil {
|
||||
query.Add("account_type", fmt.Sprintf("%v", *request.AccountType))
|
||||
}
|
||||
if request.TarType != nil {
|
||||
query.Add("tar_type", fmt.Sprintf("%v", *request.TarType))
|
||||
}
|
||||
reqUrl.RawQuery = query.Encode()
|
||||
httpRequest, err := http.NewRequest(method, reqUrl.String(), nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
httpRequest.Header.Set("Accept", "application/json")
|
||||
httpRequest.Header.Set("Wechatpay-Serial", config.WechatPayPublicKeyId())
|
||||
authorization, err := wxpay_utility.BuildAuthorization(config.MchId(), config.CertificateSerialNo(), config.PrivateKey(), method, reqUrl.RequestURI(), nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
httpRequest.Header.Set("Authorization", authorization)
|
||||
|
||||
client := &http.Client{}
|
||||
httpResponse, err := client.Do(httpRequest)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
respBody, err := wxpay_utility.ExtractResponseBody(httpResponse)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if httpResponse.StatusCode >= 200 && httpResponse.StatusCode < 300 {
|
||||
// 2XX 成功,验证应答签名
|
||||
err = wxpay_utility.ValidateResponse(
|
||||
config.WechatPayPublicKeyId(),
|
||||
config.WechatPayPublicKey(),
|
||||
&httpResponse.Header,
|
||||
respBody,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
response := &QueryBillEntity{}
|
||||
if err := json.Unmarshal(respBody, response); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return response, nil
|
||||
} else {
|
||||
return nil, wxpay_utility.NewApiException(
|
||||
httpResponse.StatusCode,
|
||||
httpResponse.Header,
|
||||
respBody,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
type GetFundFlowBillRequest struct {
|
||||
BillDate *string `json:"bill_date,omitempty"`
|
||||
AccountType *FundFlowBillAccountType `json:"account_type,omitempty"`
|
||||
TarType *TarType `json:"tar_type,omitempty"`
|
||||
}
|
||||
|
||||
func (o *GetFundFlowBillRequest) MarshalJSON() ([]byte, error) {
|
||||
type Alias GetFundFlowBillRequest
|
||||
a := &struct {
|
||||
BillDate *string `json:"bill_date,omitempty"`
|
||||
AccountType *FundFlowBillAccountType `json:"account_type,omitempty"`
|
||||
TarType *TarType `json:"tar_type,omitempty"`
|
||||
*Alias
|
||||
}{
|
||||
// 序列化时移除非 Body 字段
|
||||
BillDate: nil,
|
||||
AccountType: nil,
|
||||
TarType: nil,
|
||||
Alias: (*Alias)(o),
|
||||
}
|
||||
return json.Marshal(a)
|
||||
}
|
||||
|
||||
type QueryBillEntity struct {
|
||||
HashType *HashType `json:"hash_type,omitempty"`
|
||||
HashValue *string `json:"hash_value,omitempty"`
|
||||
DownloadUrl *string `json:"download_url,omitempty"`
|
||||
}
|
||||
|
||||
type FundFlowBillAccountType string
|
||||
|
||||
func (e FundFlowBillAccountType) Ptr() *FundFlowBillAccountType {
|
||||
return &e
|
||||
}
|
||||
|
||||
const (
|
||||
FUNDFLOWBILLACCOUNTTYPE_BASIC FundFlowBillAccountType = "BASIC"
|
||||
FUNDFLOWBILLACCOUNTTYPE_OPERATION FundFlowBillAccountType = "OPERATION"
|
||||
FUNDFLOWBILLACCOUNTTYPE_FEES FundFlowBillAccountType = "FEES"
|
||||
)
|
||||
|
||||
type TarType string
|
||||
|
||||
func (e TarType) Ptr() *TarType {
|
||||
return &e
|
||||
}
|
||||
|
||||
const (
|
||||
TARTYPE_GZIP TarType = "GZIP"
|
||||
)
|
||||
|
||||
type HashType string
|
||||
|
||||
func (e HashType) Ptr() *HashType {
|
||||
return &e
|
||||
}
|
||||
|
||||
const (
|
||||
HASHTYPE_SHA1 HashType = "SHA1"
|
||||
)
|
||||
@@ -0,0 +1,71 @@
|
||||
package wxpay_utility
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"io"
|
||||
"net/http"
|
||||
)
|
||||
|
||||
const Host = "https://api.mch.weixin.qq.com"
|
||||
|
||||
// SendGet 发送 GET 请求并返回已验签的应答 Body
|
||||
func SendGet(config *MchConfig, uri string) ([]byte, error) {
|
||||
return sendRequest(config, "GET", uri, nil)
|
||||
}
|
||||
|
||||
// SendPost 发送 POST 请求并返回已验签的应答 Body
|
||||
func SendPost(config *MchConfig, uri string, reqBody []byte) ([]byte, error) {
|
||||
return sendRequest(config, "POST", uri, reqBody)
|
||||
}
|
||||
|
||||
func sendRequest(config *MchConfig, method string, uri string, reqBody []byte) ([]byte, error) {
|
||||
var bodyReader io.Reader
|
||||
if reqBody != nil {
|
||||
bodyReader = bytes.NewReader(reqBody)
|
||||
}
|
||||
|
||||
httpRequest, err := http.NewRequest(method, Host+uri, bodyReader)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
httpRequest.Header.Set("Accept", "application/json")
|
||||
httpRequest.Header.Set("Wechatpay-Serial", config.WechatPayPublicKeyId())
|
||||
|
||||
authorization, err := BuildAuthorization(config.MchId(), config.CertificateSerialNo(),
|
||||
config.PrivateKey(), method, uri, reqBody)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
httpRequest.Header.Set("Authorization", authorization)
|
||||
|
||||
if reqBody != nil {
|
||||
httpRequest.Header.Set("Content-Type", "application/json")
|
||||
}
|
||||
|
||||
client := &http.Client{}
|
||||
httpResponse, err := client.Do(httpRequest)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
respBody, err := ExtractResponseBody(httpResponse)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if httpResponse.StatusCode >= 200 && httpResponse.StatusCode < 300 {
|
||||
err = ValidateResponse(
|
||||
config.WechatPayPublicKeyId(),
|
||||
config.WechatPayPublicKey(),
|
||||
&httpResponse.Header,
|
||||
respBody,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return respBody, nil
|
||||
}
|
||||
|
||||
return nil, NewApiException(httpResponse.StatusCode, httpResponse.Header, respBody)
|
||||
}
|
||||
@@ -0,0 +1,539 @@
|
||||
package wxpay_utility
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"crypto"
|
||||
"crypto/aes"
|
||||
"crypto/cipher"
|
||||
"crypto/rand"
|
||||
"crypto/rsa"
|
||||
"crypto/sha1"
|
||||
"crypto/sha256"
|
||||
"crypto/x509"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"encoding/pem"
|
||||
"errors"
|
||||
"fmt"
|
||||
"hash"
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"github.com/tjfoc/gmsm/sm3"
|
||||
)
|
||||
|
||||
type MchConfig struct {
|
||||
mchId string
|
||||
certificateSerialNo string
|
||||
privateKeyFilePath string
|
||||
wechatPayPublicKeyId string
|
||||
wechatPayPublicKeyFilePath string
|
||||
privateKey *rsa.PrivateKey
|
||||
wechatPayPublicKey *rsa.PublicKey
|
||||
}
|
||||
|
||||
func (c *MchConfig) MchId() string {
|
||||
return c.mchId
|
||||
}
|
||||
|
||||
func (c *MchConfig) CertificateSerialNo() string {
|
||||
return c.certificateSerialNo
|
||||
}
|
||||
|
||||
func (c *MchConfig) PrivateKey() *rsa.PrivateKey {
|
||||
return c.privateKey
|
||||
}
|
||||
|
||||
func (c *MchConfig) WechatPayPublicKeyId() string {
|
||||
return c.wechatPayPublicKeyId
|
||||
}
|
||||
|
||||
func (c *MchConfig) WechatPayPublicKey() *rsa.PublicKey {
|
||||
return c.wechatPayPublicKey
|
||||
}
|
||||
|
||||
func CreateMchConfig(
|
||||
mchId string,
|
||||
certificateSerialNo string,
|
||||
privateKeyFilePath string,
|
||||
wechatPayPublicKeyId string,
|
||||
wechatPayPublicKeyFilePath string,
|
||||
) (*MchConfig, error) {
|
||||
mchConfig := &MchConfig{
|
||||
mchId: mchId,
|
||||
certificateSerialNo: certificateSerialNo,
|
||||
privateKeyFilePath: privateKeyFilePath,
|
||||
wechatPayPublicKeyId: wechatPayPublicKeyId,
|
||||
wechatPayPublicKeyFilePath: wechatPayPublicKeyFilePath,
|
||||
}
|
||||
privateKey, err := LoadPrivateKeyWithPath(mchConfig.privateKeyFilePath)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
mchConfig.privateKey = privateKey
|
||||
wechatPayPublicKey, err := LoadPublicKeyWithPath(mchConfig.wechatPayPublicKeyFilePath)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
mchConfig.wechatPayPublicKey = wechatPayPublicKey
|
||||
return mchConfig, nil
|
||||
}
|
||||
|
||||
func LoadPrivateKey(privateKeyStr string) (privateKey *rsa.PrivateKey, err error) {
|
||||
block, _ := pem.Decode([]byte(privateKeyStr))
|
||||
if block == nil {
|
||||
return nil, fmt.Errorf("decode private key err")
|
||||
}
|
||||
if block.Type != "PRIVATE KEY" {
|
||||
return nil, fmt.Errorf("the kind of PEM should be PRVATE KEY")
|
||||
}
|
||||
key, err := x509.ParsePKCS8PrivateKey(block.Bytes)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("parse private key err:%s", err.Error())
|
||||
}
|
||||
privateKey, ok := key.(*rsa.PrivateKey)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("not a RSA private key")
|
||||
}
|
||||
return privateKey, nil
|
||||
}
|
||||
|
||||
func LoadPublicKey(publicKeyStr string) (publicKey *rsa.PublicKey, err error) {
|
||||
block, _ := pem.Decode([]byte(publicKeyStr))
|
||||
if block == nil {
|
||||
return nil, errors.New("decode public key error")
|
||||
}
|
||||
if block.Type != "PUBLIC KEY" {
|
||||
return nil, fmt.Errorf("the kind of PEM should be PUBLIC KEY")
|
||||
}
|
||||
key, err := x509.ParsePKIXPublicKey(block.Bytes)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("parse public key err:%s", err.Error())
|
||||
}
|
||||
publicKey, ok := key.(*rsa.PublicKey)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("%s is not rsa public key", publicKeyStr)
|
||||
}
|
||||
return publicKey, nil
|
||||
}
|
||||
|
||||
func LoadPrivateKeyWithPath(path string) (privateKey *rsa.PrivateKey, err error) {
|
||||
privateKeyBytes, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("read private pem file err:%s", err.Error())
|
||||
}
|
||||
return LoadPrivateKey(string(privateKeyBytes))
|
||||
}
|
||||
|
||||
func LoadPublicKeyWithPath(path string) (publicKey *rsa.PublicKey, err error) {
|
||||
publicKeyBytes, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("read certificate pem file err:%s", err.Error())
|
||||
}
|
||||
return LoadPublicKey(string(publicKeyBytes))
|
||||
}
|
||||
|
||||
func EncryptOAEPWithPublicKey(message string, publicKey *rsa.PublicKey) (ciphertext string, err error) {
|
||||
if publicKey == nil {
|
||||
return "", fmt.Errorf("you should input *rsa.PublicKey")
|
||||
}
|
||||
ciphertextByte, err := rsa.EncryptOAEP(sha1.New(), rand.Reader, publicKey, []byte(message), nil)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("encrypt message with public key err:%s", err.Error())
|
||||
}
|
||||
ciphertext = base64.StdEncoding.EncodeToString(ciphertextByte)
|
||||
return ciphertext, nil
|
||||
}
|
||||
|
||||
func DecryptAES256GCM(aesKey, associatedData, nonce, ciphertext string) (plaintext string, err error) {
|
||||
decodedCiphertext, err := base64.StdEncoding.DecodeString(ciphertext)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
c, err := aes.NewCipher([]byte(aesKey))
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
gcm, err := cipher.NewGCM(c)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
dataBytes, err := gcm.Open(nil, []byte(nonce), decodedCiphertext, []byte(associatedData))
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return string(dataBytes), nil
|
||||
}
|
||||
|
||||
func SignSHA256WithRSA(source string, privateKey *rsa.PrivateKey) (signature string, err error) {
|
||||
if privateKey == nil {
|
||||
return "", fmt.Errorf("private key should not be nil")
|
||||
}
|
||||
h := crypto.Hash.New(crypto.SHA256)
|
||||
_, err = h.Write([]byte(source))
|
||||
if err != nil {
|
||||
return "", nil
|
||||
}
|
||||
hashed := h.Sum(nil)
|
||||
signatureByte, err := rsa.SignPKCS1v15(rand.Reader, privateKey, crypto.SHA256, hashed)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return base64.StdEncoding.EncodeToString(signatureByte), nil
|
||||
}
|
||||
|
||||
func VerifySHA256WithRSA(source string, signature string, publicKey *rsa.PublicKey) error {
|
||||
if publicKey == nil {
|
||||
return fmt.Errorf("public key should not be nil")
|
||||
}
|
||||
|
||||
sigBytes, err := base64.StdEncoding.DecodeString(signature)
|
||||
if err != nil {
|
||||
return fmt.Errorf("verify failed: signature is not base64 encoded")
|
||||
}
|
||||
hashed := sha256.Sum256([]byte(source))
|
||||
err = rsa.VerifyPKCS1v15(publicKey, crypto.SHA256, hashed[:], sigBytes)
|
||||
if err != nil {
|
||||
return fmt.Errorf("verify signature with public key error:%s", err.Error())
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func GenerateNonce() (string, error) {
|
||||
const (
|
||||
NonceSymbols = "0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ"
|
||||
NonceLength = 32
|
||||
)
|
||||
|
||||
bytes := make([]byte, NonceLength)
|
||||
_, err := rand.Read(bytes)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
symbolsByteLength := byte(len(NonceSymbols))
|
||||
for i, b := range bytes {
|
||||
bytes[i] = NonceSymbols[b%symbolsByteLength]
|
||||
}
|
||||
return string(bytes), nil
|
||||
}
|
||||
|
||||
func BuildAuthorization(
|
||||
mchid string,
|
||||
certificateSerialNo string,
|
||||
privateKey *rsa.PrivateKey,
|
||||
method string,
|
||||
canonicalURL string,
|
||||
body []byte,
|
||||
) (string, error) {
|
||||
const (
|
||||
SignatureMessageFormat = "%s\n%s\n%d\n%s\n%s\n"
|
||||
HeaderAuthorizationFormat = "WECHATPAY2-SHA256-RSA2048 mchid=\"%s\",nonce_str=\"%s\",timestamp=\"%d\",serial_no=\"%s\",signature=\"%s\""
|
||||
)
|
||||
|
||||
nonce, err := GenerateNonce()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
timestamp := time.Now().Unix()
|
||||
message := fmt.Sprintf(SignatureMessageFormat, method, canonicalURL, timestamp, nonce, body)
|
||||
signature, err := SignSHA256WithRSA(message, privateKey)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
authorization := fmt.Sprintf(
|
||||
HeaderAuthorizationFormat,
|
||||
mchid, nonce, timestamp, certificateSerialNo, signature,
|
||||
)
|
||||
return authorization, nil
|
||||
}
|
||||
|
||||
func ExtractResponseBody(response *http.Response) ([]byte, error) {
|
||||
if response.Body == nil {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
body, err := io.ReadAll(response.Body)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("read response body err:[%s]", err.Error())
|
||||
}
|
||||
response.Body = io.NopCloser(bytes.NewBuffer(body))
|
||||
return body, nil
|
||||
}
|
||||
|
||||
const (
|
||||
WechatPayTimestamp = "Wechatpay-Timestamp"
|
||||
WechatPayNonce = "Wechatpay-Nonce"
|
||||
WechatPaySignature = "Wechatpay-Signature"
|
||||
WechatPaySerial = "Wechatpay-Serial"
|
||||
RequestID = "Request-Id"
|
||||
)
|
||||
|
||||
func validateWechatPaySignature(
|
||||
wechatpayPublicKeyId string,
|
||||
wechatpayPublicKey *rsa.PublicKey,
|
||||
headers *http.Header,
|
||||
body []byte,
|
||||
) error {
|
||||
timestampStr := headers.Get(WechatPayTimestamp)
|
||||
serialNo := headers.Get(WechatPaySerial)
|
||||
signature := headers.Get(WechatPaySignature)
|
||||
nonce := headers.Get(WechatPayNonce)
|
||||
|
||||
timestamp, err := strconv.ParseInt(timestampStr, 10, 64)
|
||||
if err != nil {
|
||||
return fmt.Errorf("invalid timestamp: %w", err)
|
||||
}
|
||||
if time.Now().Sub(time.Unix(timestamp, 0)) > 5*time.Minute {
|
||||
return fmt.Errorf("timestamp expired: %d", timestamp)
|
||||
}
|
||||
|
||||
if serialNo != wechatpayPublicKeyId {
|
||||
return fmt.Errorf(
|
||||
"serial-no mismatch: got %s, expected %s",
|
||||
serialNo,
|
||||
wechatpayPublicKeyId,
|
||||
)
|
||||
}
|
||||
|
||||
message := fmt.Sprintf("%s\n%s\n%s\n", timestampStr, nonce, body)
|
||||
if err := VerifySHA256WithRSA(message, signature, wechatpayPublicKey); err != nil {
|
||||
return fmt.Errorf("invalid signature: %v", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func ValidateResponse(
|
||||
wechatpayPublicKeyId string,
|
||||
wechatpayPublicKey *rsa.PublicKey,
|
||||
headers *http.Header,
|
||||
body []byte,
|
||||
) error {
|
||||
if err := validateWechatPaySignature(wechatpayPublicKeyId, wechatpayPublicKey, headers, body); err != nil {
|
||||
return fmt.Errorf("validate response err: %w, RequestID: %s", err, headers.Get(RequestID))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func validateNotification(
|
||||
wechatpayPublicKeyId string,
|
||||
wechatpayPublicKey *rsa.PublicKey,
|
||||
headers *http.Header,
|
||||
body []byte,
|
||||
) error {
|
||||
if err := validateWechatPaySignature(wechatpayPublicKeyId, wechatpayPublicKey, headers, body); err != nil {
|
||||
return fmt.Errorf("validate notification err: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
type Resource struct {
|
||||
Algorithm string `json:"algorithm"`
|
||||
Ciphertext string `json:"ciphertext"`
|
||||
AssociatedData string `json:"associated_data"`
|
||||
Nonce string `json:"nonce"`
|
||||
OriginalType string `json:"original_type"`
|
||||
}
|
||||
|
||||
type Notification struct {
|
||||
ID string `json:"id"`
|
||||
CreateTime *time.Time `json:"create_time"`
|
||||
EventType string `json:"event_type"`
|
||||
ResourceType string `json:"resource_type"`
|
||||
Resource *Resource `json:"resource"`
|
||||
Summary string `json:"summary"`
|
||||
|
||||
Plaintext string
|
||||
}
|
||||
|
||||
func (c *Notification) validate() error {
|
||||
if c.Resource == nil {
|
||||
return errors.New("resource is nil")
|
||||
}
|
||||
|
||||
if c.Resource.Algorithm != "AEAD_AES_256_GCM" {
|
||||
return fmt.Errorf("unsupported algorithm: %s", c.Resource.Algorithm)
|
||||
}
|
||||
|
||||
if c.Resource.Ciphertext == "" {
|
||||
return errors.New("ciphertext is empty")
|
||||
}
|
||||
|
||||
if c.Resource.AssociatedData == "" {
|
||||
return errors.New("associated_data is empty")
|
||||
}
|
||||
|
||||
if c.Resource.Nonce == "" {
|
||||
return errors.New("nonce is empty")
|
||||
}
|
||||
|
||||
if c.Resource.OriginalType == "" {
|
||||
return fmt.Errorf("original_type is empty")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *Notification) decrypt(apiv3Key string) error {
|
||||
if err := c.validate(); err != nil {
|
||||
return fmt.Errorf("notification format err: %w", err)
|
||||
}
|
||||
|
||||
plaintext, err := DecryptAES256GCM(
|
||||
apiv3Key,
|
||||
c.Resource.AssociatedData,
|
||||
c.Resource.Nonce,
|
||||
c.Resource.Ciphertext,
|
||||
)
|
||||
if err != nil {
|
||||
return fmt.Errorf("notification decrypt err: %w", err)
|
||||
}
|
||||
|
||||
c.Plaintext = plaintext
|
||||
return nil
|
||||
}
|
||||
|
||||
func ParseNotification(
|
||||
wechatpayPublicKeyId string,
|
||||
wechatpayPublicKey *rsa.PublicKey,
|
||||
apiv3Key string,
|
||||
headers *http.Header,
|
||||
body []byte,
|
||||
) (*Notification, error) {
|
||||
if err := validateNotification(wechatpayPublicKeyId, wechatpayPublicKey, headers, body); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
notification := &Notification{}
|
||||
if err := json.Unmarshal(body, notification); err != nil {
|
||||
return nil, fmt.Errorf("parse notification err: %w", err)
|
||||
}
|
||||
|
||||
if err := notification.decrypt(apiv3Key); err != nil {
|
||||
return nil, fmt.Errorf("notification decrypt err: %w", err)
|
||||
}
|
||||
|
||||
return notification, nil
|
||||
}
|
||||
|
||||
type ApiException struct {
|
||||
statusCode int
|
||||
header http.Header
|
||||
body []byte
|
||||
errorCode string
|
||||
errorMessage string
|
||||
}
|
||||
|
||||
func (c *ApiException) Error() string {
|
||||
buf := bytes.NewBuffer(nil)
|
||||
buf.WriteString(fmt.Sprintf("api error:[StatusCode: %d, Body: %s", c.statusCode, string(c.body)))
|
||||
if len(c.header) > 0 {
|
||||
buf.WriteString(" Header: ")
|
||||
for key, value := range c.header {
|
||||
buf.WriteString(fmt.Sprintf("\n - %v=%v", key, value))
|
||||
}
|
||||
buf.WriteString("\n")
|
||||
}
|
||||
buf.WriteString("]")
|
||||
return buf.String()
|
||||
}
|
||||
|
||||
func (c *ApiException) StatusCode() int {
|
||||
return c.statusCode
|
||||
}
|
||||
|
||||
func (c *ApiException) Header() http.Header {
|
||||
return c.header
|
||||
}
|
||||
|
||||
func (c *ApiException) Body() []byte {
|
||||
return c.body
|
||||
}
|
||||
|
||||
func (c *ApiException) ErrorCode() string {
|
||||
return c.errorCode
|
||||
}
|
||||
|
||||
func (c *ApiException) ErrorMessage() string {
|
||||
return c.errorMessage
|
||||
}
|
||||
|
||||
func NewApiException(statusCode int, header http.Header, body []byte) error {
|
||||
ret := &ApiException{
|
||||
statusCode: statusCode,
|
||||
header: header,
|
||||
body: body,
|
||||
}
|
||||
|
||||
bodyObject := map[string]interface{}{}
|
||||
if err := json.Unmarshal(body, &bodyObject); err == nil {
|
||||
if val, ok := bodyObject["code"]; ok {
|
||||
ret.errorCode = val.(string)
|
||||
}
|
||||
if val, ok := bodyObject["message"]; ok {
|
||||
ret.errorMessage = val.(string)
|
||||
}
|
||||
}
|
||||
|
||||
return ret
|
||||
}
|
||||
|
||||
func Time(t time.Time) *time.Time {
|
||||
return &t
|
||||
}
|
||||
|
||||
func String(s string) *string {
|
||||
return &s
|
||||
}
|
||||
|
||||
func Bytes(b []byte) *[]byte {
|
||||
return &b
|
||||
}
|
||||
|
||||
func Bool(b bool) *bool {
|
||||
return &b
|
||||
}
|
||||
|
||||
func Float64(f float64) *float64 {
|
||||
return &f
|
||||
}
|
||||
|
||||
func Float32(f float32) *float32 {
|
||||
return &f
|
||||
}
|
||||
|
||||
func Int64(i int64) *int64 {
|
||||
return &i
|
||||
}
|
||||
|
||||
func Int32(i int32) *int32 {
|
||||
return &i
|
||||
}
|
||||
|
||||
func generateHashFromStream(reader io.Reader, hashFunc func() hash.Hash, algorithmName string) (string, error) {
|
||||
hash := hashFunc()
|
||||
if _, err := io.Copy(hash, reader); err != nil {
|
||||
return "", fmt.Errorf("failed to read stream for %s: %w", algorithmName, err)
|
||||
}
|
||||
return fmt.Sprintf("%x", hash.Sum(nil)), nil
|
||||
}
|
||||
|
||||
func GenerateSHA256FromStream(reader io.Reader) (string, error) {
|
||||
return generateHashFromStream(reader, sha256.New, "SHA256")
|
||||
}
|
||||
|
||||
func GenerateSHA1FromStream(reader io.Reader) (string, error) {
|
||||
return generateHashFromStream(reader, sha1.New, "SHA1")
|
||||
}
|
||||
|
||||
func GenerateSM3FromStream(reader io.Reader) (string, error) {
|
||||
h := sm3.New()
|
||||
if _, err := io.Copy(h, reader); err != nil {
|
||||
return "", fmt.Errorf("failed to read stream for SM3: %w", err)
|
||||
}
|
||||
return fmt.Sprintf("%x", h.Sum(nil)), nil
|
||||
}
|
||||
|
||||
@@ -0,0 +1,191 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"demo/wxpay_utility" // 引用微信支付工具库,参考 https://pay.weixin.qq.com/doc/v3/merchant/4015119334
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"time"
|
||||
)
|
||||
|
||||
func main() {
|
||||
// TODO: 请准备商户开发必要参数,参考:https://pay.weixin.qq.com/doc/v3/merchant/4013070756
|
||||
config, err := wxpay_utility.CreateMchConfig(
|
||||
"19xxxxxxxx", // 商户号,是由微信支付系统生成并分配给每个商户的唯一标识符,商户号获取方式参考 https://pay.weixin.qq.com/doc/v3/merchant/4013070756
|
||||
"1DDE55AD98Exxxxxxxxxx", // 商户API证书序列号,如何获取请参考 https://pay.weixin.qq.com/doc/v3/merchant/4013053053
|
||||
"/path/to/apiclient_key.pem", // 商户API证书私钥文件路径,本地文件路径
|
||||
"PUB_KEY_ID_xxxxxxxxxxxxx", // 微信支付公钥ID,如何获取请参考 https://pay.weixin.qq.com/doc/v3/merchant/4013038816
|
||||
"/path/to/wxp_pub.pem", // 微信支付公钥文件路径,本地文件路径
|
||||
)
|
||||
if err != nil {
|
||||
fmt.Println(err)
|
||||
return
|
||||
}
|
||||
|
||||
request := &UnionApiv3AppPrepayRequest{
|
||||
CombineAppid: wxpay_utility.String("wxd678efh567hg6787"),
|
||||
CombineOutTradeNo: wxpay_utility.String("20150806125345"),
|
||||
CombineMchid: wxpay_utility.String("1900000109"),
|
||||
SceneInfo: &UnionSceneInfo{
|
||||
DeviceId: wxpay_utility.String("POS1:1"),
|
||||
PayerClientIp: wxpay_utility.String("14.17.22.32"),
|
||||
},
|
||||
SubOrders: []UnionSubOrder{
|
||||
UnionSubOrder{
|
||||
Mchid: wxpay_utility.String("1230000109"),
|
||||
OutTradeNo: wxpay_utility.String("20150806125346"),
|
||||
Amount: &UnionAmountInfo{
|
||||
TotalAmount: wxpay_utility.Int64(10),
|
||||
Currency: wxpay_utility.String("CNY"),
|
||||
},
|
||||
Attach: wxpay_utility.String("深圳分店"),
|
||||
Description: wxpay_utility.String("腾讯充值中心-QQ会员充值"),
|
||||
Detail: wxpay_utility.String("买单费用"),
|
||||
GoodsTag: wxpay_utility.String("WXG"),
|
||||
SettleInfo: &UnionSettleInfo{
|
||||
ProfitSharing: wxpay_utility.Bool(false),
|
||||
},
|
||||
},
|
||||
UnionSubOrder{
|
||||
Mchid: wxpay_utility.String("1230000119"),
|
||||
OutTradeNo: wxpay_utility.String("20150806125347"),
|
||||
Amount: &UnionAmountInfo{
|
||||
TotalAmount: wxpay_utility.Int64(10),
|
||||
Currency: wxpay_utility.String("CNY"),
|
||||
},
|
||||
Attach: wxpay_utility.String("广州分店"),
|
||||
Description: wxpay_utility.String("腾讯充值中心-微信充值"),
|
||||
Detail: wxpay_utility.String("买单费用"),
|
||||
GoodsTag: wxpay_utility.String("WXG"),
|
||||
SettleInfo: &UnionSettleInfo{
|
||||
ProfitSharing: wxpay_utility.Bool(false),
|
||||
},
|
||||
},
|
||||
},
|
||||
CombinePayerInfo: &UnionAppPayerInfo{
|
||||
Openid: wxpay_utility.String("oUpF8uMuAJO_M2pxb1Q9zNjWeS6o"),
|
||||
},
|
||||
TimeExpire: wxpay_utility.Time(time.Now()),
|
||||
NotifyUrl: wxpay_utility.String("https://yourapp.com/notify"),
|
||||
}
|
||||
|
||||
response, err := UnionAppPrepay(config, request)
|
||||
if err != nil {
|
||||
fmt.Printf("请求失败: %+v\n", err)
|
||||
// TODO: 请求失败,根据状态码执行不同的处理
|
||||
return
|
||||
}
|
||||
|
||||
// TODO: 请求成功,继续业务逻辑
|
||||
fmt.Printf("请求成功: %+v\n", response)
|
||||
}
|
||||
|
||||
func UnionAppPrepay(config *wxpay_utility.MchConfig, request *UnionApiv3AppPrepayRequest) (response *UnionApiv3AppPrepayResponse, err error) {
|
||||
const (
|
||||
host = "https://api.mch.weixin.qq.com"
|
||||
method = "POST"
|
||||
path = "/v3/combine-transactions/app"
|
||||
)
|
||||
|
||||
reqUrl, err := url.Parse(fmt.Sprintf("%s%s", host, path))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
reqBody, err := json.Marshal(request)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
httpRequest, err := http.NewRequest(method, reqUrl.String(), bytes.NewReader(reqBody))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
httpRequest.Header.Set("Accept", "application/json")
|
||||
httpRequest.Header.Set("Wechatpay-Serial", config.WechatPayPublicKeyId())
|
||||
httpRequest.Header.Set("Content-Type", "application/json")
|
||||
authorization, err := wxpay_utility.BuildAuthorization(config.MchId(), config.CertificateSerialNo(), config.PrivateKey(), method, reqUrl.RequestURI(), reqBody)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
httpRequest.Header.Set("Authorization", authorization)
|
||||
|
||||
client := &http.Client{}
|
||||
httpResponse, err := client.Do(httpRequest)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
respBody, err := wxpay_utility.ExtractResponseBody(httpResponse)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if httpResponse.StatusCode >= 200 && httpResponse.StatusCode < 300 {
|
||||
// 2XX 成功,验证应答签名
|
||||
err = wxpay_utility.ValidateResponse(
|
||||
config.WechatPayPublicKeyId(),
|
||||
config.WechatPayPublicKey(),
|
||||
&httpResponse.Header,
|
||||
respBody,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
response := &UnionApiv3AppPrepayResponse{}
|
||||
if err := json.Unmarshal(respBody, response); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return response, nil
|
||||
} else {
|
||||
return nil, wxpay_utility.NewApiException(
|
||||
httpResponse.StatusCode,
|
||||
httpResponse.Header,
|
||||
respBody,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
type UnionApiv3AppPrepayRequest struct {
|
||||
CombineAppid *string `json:"combine_appid,omitempty"`
|
||||
CombineOutTradeNo *string `json:"combine_out_trade_no,omitempty"`
|
||||
CombineMchid *string `json:"combine_mchid,omitempty"`
|
||||
SceneInfo *UnionSceneInfo `json:"scene_info,omitempty"`
|
||||
SubOrders []UnionSubOrder `json:"sub_orders,omitempty"`
|
||||
CombinePayerInfo *UnionAppPayerInfo `json:"combine_payer_info,omitempty"`
|
||||
TimeExpire *time.Time `json:"time_expire,omitempty"`
|
||||
NotifyUrl *string `json:"notify_url,omitempty"`
|
||||
TradeScenario *string `json:"trade_scenario,omitempty"`
|
||||
}
|
||||
|
||||
type UnionApiv3AppPrepayResponse struct {
|
||||
PrepayId *string `json:"prepay_id,omitempty"`
|
||||
}
|
||||
|
||||
type UnionSceneInfo struct {
|
||||
DeviceId *string `json:"device_id,omitempty"`
|
||||
PayerClientIp *string `json:"payer_client_ip,omitempty"`
|
||||
}
|
||||
|
||||
type UnionSubOrder struct {
|
||||
Mchid *string `json:"mchid,omitempty"`
|
||||
OutTradeNo *string `json:"out_trade_no,omitempty"`
|
||||
Amount *UnionAmountInfo `json:"amount,omitempty"`
|
||||
Attach *string `json:"attach,omitempty"`
|
||||
Description *string `json:"description,omitempty"`
|
||||
Detail *string `json:"detail,omitempty"`
|
||||
GoodsTag *string `json:"goods_tag,omitempty"`
|
||||
SettleInfo *UnionSettleInfo `json:"settle_info,omitempty"`
|
||||
}
|
||||
|
||||
type UnionAppPayerInfo struct {
|
||||
Openid *string `json:"openid,omitempty"`
|
||||
}
|
||||
|
||||
type UnionAmountInfo struct {
|
||||
TotalAmount *int64 `json:"total_amount,omitempty"`
|
||||
Currency *string `json:"currency,omitempty"`
|
||||
}
|
||||
|
||||
type UnionSettleInfo struct {
|
||||
ProfitSharing *bool `json:"profit_sharing,omitempty"`
|
||||
}
|
||||
@@ -0,0 +1,128 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"demo/wxpay_utility" // 引用微信支付工具库,参考 https://pay.weixin.qq.com/doc/v3/merchant/4015119334
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
)
|
||||
|
||||
func main() {
|
||||
// TODO: 请准备商户开发必要参数,参考:https://pay.weixin.qq.com/doc/v3/merchant/4013070756
|
||||
config, err := wxpay_utility.CreateMchConfig(
|
||||
"19xxxxxxxx", // 商户号,是由微信支付系统生成并分配给每个商户的唯一标识符,商户号获取方式参考 https://pay.weixin.qq.com/doc/v3/merchant/4013070756
|
||||
"1DDE55AD98Exxxxxxxxxx", // 商户API证书序列号,如何获取请参考 https://pay.weixin.qq.com/doc/v3/merchant/4013053053
|
||||
"/path/to/apiclient_key.pem", // 商户API证书私钥文件路径,本地文件路径
|
||||
"PUB_KEY_ID_xxxxxxxxxxxxx", // 微信支付公钥ID,如何获取请参考 https://pay.weixin.qq.com/doc/v3/merchant/4013038816
|
||||
"/path/to/wxp_pub.pem", // 微信支付公钥文件路径,本地文件路径
|
||||
)
|
||||
if err != nil {
|
||||
fmt.Println(err)
|
||||
return
|
||||
}
|
||||
|
||||
request := &UnionCloseRequest{
|
||||
CombineOutTradeNo: wxpay_utility.String("1217752501201407033233368018"),
|
||||
CombineAppid: wxpay_utility.String("wxd678efh567hg6787"),
|
||||
SubOrders: []UnionCloseSubOrder{UnionCloseSubOrder{
|
||||
Mchid: wxpay_utility.String("1900000109"),
|
||||
OutTradeNo: wxpay_utility.String("20150806125346"),
|
||||
}},
|
||||
}
|
||||
|
||||
err = UnionClose(config, request)
|
||||
if err != nil {
|
||||
fmt.Printf("请求失败: %+v\n", err)
|
||||
// TODO: 请求失败,根据状态码执行不同的处理
|
||||
return
|
||||
}
|
||||
|
||||
// TODO: 请求成功,继续业务逻辑
|
||||
fmt.Println("请求成功")
|
||||
}
|
||||
|
||||
func UnionClose(config *wxpay_utility.MchConfig, request *UnionCloseRequest) (err error) {
|
||||
const (
|
||||
host = "https://api.mch.weixin.qq.com"
|
||||
method = "POST"
|
||||
path = "/v3/combine-transactions/out-trade-no/{combine_out_trade_no}/close"
|
||||
)
|
||||
|
||||
reqUrl, err := url.Parse(fmt.Sprintf("%s%s", host, path))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
reqUrl.Path = strings.Replace(reqUrl.Path, "{combine_out_trade_no}", url.PathEscape(*request.CombineOutTradeNo), -1)
|
||||
reqBody, err := json.Marshal(request)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
httpRequest, err := http.NewRequest(method, reqUrl.String(), bytes.NewReader(reqBody))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
httpRequest.Header.Set("Accept", "application/json")
|
||||
httpRequest.Header.Set("Wechatpay-Serial", config.WechatPayPublicKeyId())
|
||||
httpRequest.Header.Set("Content-Type", "application/json")
|
||||
authorization, err := wxpay_utility.BuildAuthorization(config.MchId(), config.CertificateSerialNo(), config.PrivateKey(), method, reqUrl.RequestURI(), reqBody)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
httpRequest.Header.Set("Authorization", authorization)
|
||||
|
||||
client := &http.Client{}
|
||||
httpResponse, err := client.Do(httpRequest)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
respBody, err := wxpay_utility.ExtractResponseBody(httpResponse)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if httpResponse.StatusCode >= 200 && httpResponse.StatusCode < 300 {
|
||||
// 2XX 成功,验证应答签名
|
||||
err = wxpay_utility.ValidateResponse(
|
||||
config.WechatPayPublicKeyId(),
|
||||
config.WechatPayPublicKey(),
|
||||
&httpResponse.Header,
|
||||
respBody,
|
||||
)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
} else {
|
||||
return wxpay_utility.NewApiException(
|
||||
httpResponse.StatusCode,
|
||||
httpResponse.Header,
|
||||
respBody,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
type UnionCloseRequest struct {
|
||||
CombineAppid *string `json:"combine_appid,omitempty"`
|
||||
CombineOutTradeNo *string `json:"combine_out_trade_no,omitempty"`
|
||||
SubOrders []UnionCloseSubOrder `json:"sub_orders,omitempty"`
|
||||
}
|
||||
|
||||
func (o *UnionCloseRequest) MarshalJSON() ([]byte, error) {
|
||||
type Alias UnionCloseRequest
|
||||
a := &struct {
|
||||
CombineOutTradeNo *string `json:"combine_out_trade_no,omitempty"`
|
||||
*Alias
|
||||
}{
|
||||
// 序列化时移除非 Body 字段
|
||||
CombineOutTradeNo: nil,
|
||||
Alias: (*Alias)(o),
|
||||
}
|
||||
return json.Marshal(a)
|
||||
}
|
||||
|
||||
type UnionCloseSubOrder struct {
|
||||
Mchid *string `json:"mchid,omitempty"`
|
||||
OutTradeNo *string `json:"out_trade_no,omitempty"`
|
||||
}
|
||||
@@ -0,0 +1,195 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"demo/wxpay_utility" // 引用微信支付工具库,参考 https://pay.weixin.qq.com/doc/v3/merchant/4015119334
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"time"
|
||||
)
|
||||
|
||||
func main() {
|
||||
// TODO: 请准备商户开发必要参数,参考:https://pay.weixin.qq.com/doc/v3/merchant/4013070756
|
||||
config, err := wxpay_utility.CreateMchConfig(
|
||||
"19xxxxxxxx", // 商户号,是由微信支付系统生成并分配给每个商户的唯一标识符,商户号获取方式参考 https://pay.weixin.qq.com/doc/v3/merchant/4013070756
|
||||
"1DDE55AD98Exxxxxxxxxx", // 商户API证书序列号,如何获取请参考 https://pay.weixin.qq.com/doc/v3/merchant/4013053053
|
||||
"/path/to/apiclient_key.pem", // 商户API证书私钥文件路径,本地文件路径
|
||||
"PUB_KEY_ID_xxxxxxxxxxxxx", // 微信支付公钥ID,如何获取请参考 https://pay.weixin.qq.com/doc/v3/merchant/4013038816
|
||||
"/path/to/wxp_pub.pem", // 微信支付公钥文件路径,本地文件路径
|
||||
)
|
||||
if err != nil {
|
||||
fmt.Println(err)
|
||||
return
|
||||
}
|
||||
|
||||
request := &UnionApiv3H5PrepayRequest{
|
||||
CombineAppid: wxpay_utility.String("wxd678efh567hg6787"),
|
||||
CombineOutTradeNo: wxpay_utility.String("1217752501201407033233368018"),
|
||||
CombineMchid: wxpay_utility.String("1230000109"),
|
||||
SceneInfo: &UnionH5SceneInfo{
|
||||
PayerClientIp: wxpay_utility.String("14.23.150.211"),
|
||||
DeviceId: wxpay_utility.String("013467007045764"),
|
||||
H5Info: &UnionH5Info{
|
||||
Type: wxpay_utility.String("iOS"),
|
||||
AppName: wxpay_utility.String("王者荣耀"),
|
||||
AppUrl: wxpay_utility.String("https://pay.qq.com"),
|
||||
BundleId: wxpay_utility.String("com.tencent.wzryiOS"),
|
||||
PackageName: wxpay_utility.String("com.tencent.tmgp.sgame"),
|
||||
},
|
||||
},
|
||||
SubOrders: []UnionCommonSubOrder{
|
||||
UnionCommonSubOrder{
|
||||
Mchid: wxpay_utility.String("1230000109"),
|
||||
OutTradeNo: wxpay_utility.String("20150806125346"),
|
||||
Amount: &UnionAmountInfo{
|
||||
TotalAmount: wxpay_utility.Int64(10),
|
||||
Currency: wxpay_utility.String("CNY"),
|
||||
},
|
||||
Attach: wxpay_utility.String("深圳分店"),
|
||||
Description: wxpay_utility.String("腾讯充值中心-QQ会员充值"),
|
||||
GoodsTag: wxpay_utility.String("WXG"),
|
||||
SettleInfo: &UnionSettleInfo{
|
||||
ProfitSharing: wxpay_utility.Bool(false),
|
||||
},
|
||||
},
|
||||
UnionCommonSubOrder{
|
||||
Mchid: wxpay_utility.String("1230000119"),
|
||||
OutTradeNo: wxpay_utility.String("20150806125347"),
|
||||
Amount: &UnionAmountInfo{
|
||||
TotalAmount: wxpay_utility.Int64(10),
|
||||
Currency: wxpay_utility.String("CNY"),
|
||||
},
|
||||
Attach: wxpay_utility.String("广州分店"),
|
||||
Description: wxpay_utility.String("腾讯充值中心-微信充值"),
|
||||
GoodsTag: wxpay_utility.String("WXG"),
|
||||
SettleInfo: &UnionSettleInfo{
|
||||
ProfitSharing: wxpay_utility.Bool(false),
|
||||
},
|
||||
},
|
||||
},
|
||||
TimeExpire: wxpay_utility.Time(time.Now()),
|
||||
NotifyUrl: wxpay_utility.String("https://yourapp.com/notify"),
|
||||
}
|
||||
|
||||
response, err := UnionH5Prepay(config, request)
|
||||
if err != nil {
|
||||
fmt.Printf("请求失败: %+v\n", err)
|
||||
// TODO: 请求失败,根据状态码执行不同的处理
|
||||
return
|
||||
}
|
||||
|
||||
// TODO: 请求成功,继续业务逻辑
|
||||
fmt.Printf("请求成功: %+v\n", response)
|
||||
}
|
||||
|
||||
func UnionH5Prepay(config *wxpay_utility.MchConfig, request *UnionApiv3H5PrepayRequest) (response *UnionApiv3H5PrepayResponse, err error) {
|
||||
const (
|
||||
host = "https://api.mch.weixin.qq.com"
|
||||
method = "POST"
|
||||
path = "/v3/combine-transactions/h5"
|
||||
)
|
||||
|
||||
reqUrl, err := url.Parse(fmt.Sprintf("%s%s", host, path))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
reqBody, err := json.Marshal(request)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
httpRequest, err := http.NewRequest(method, reqUrl.String(), bytes.NewReader(reqBody))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
httpRequest.Header.Set("Accept", "application/json")
|
||||
httpRequest.Header.Set("Wechatpay-Serial", config.WechatPayPublicKeyId())
|
||||
httpRequest.Header.Set("Content-Type", "application/json")
|
||||
authorization, err := wxpay_utility.BuildAuthorization(config.MchId(), config.CertificateSerialNo(), config.PrivateKey(), method, reqUrl.RequestURI(), reqBody)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
httpRequest.Header.Set("Authorization", authorization)
|
||||
|
||||
client := &http.Client{}
|
||||
httpResponse, err := client.Do(httpRequest)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
respBody, err := wxpay_utility.ExtractResponseBody(httpResponse)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if httpResponse.StatusCode >= 200 && httpResponse.StatusCode < 300 {
|
||||
// 2XX 成功,验证应答签名
|
||||
err = wxpay_utility.ValidateResponse(
|
||||
config.WechatPayPublicKeyId(),
|
||||
config.WechatPayPublicKey(),
|
||||
&httpResponse.Header,
|
||||
respBody,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
response := &UnionApiv3H5PrepayResponse{}
|
||||
if err := json.Unmarshal(respBody, response); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return response, nil
|
||||
} else {
|
||||
return nil, wxpay_utility.NewApiException(
|
||||
httpResponse.StatusCode,
|
||||
httpResponse.Header,
|
||||
respBody,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
type UnionApiv3H5PrepayRequest struct {
|
||||
CombineAppid *string `json:"combine_appid,omitempty"`
|
||||
CombineOutTradeNo *string `json:"combine_out_trade_no,omitempty"`
|
||||
CombineMchid *string `json:"combine_mchid,omitempty"`
|
||||
SceneInfo *UnionH5SceneInfo `json:"scene_info,omitempty"`
|
||||
SubOrders []UnionCommonSubOrder `json:"sub_orders,omitempty"`
|
||||
TimeExpire *time.Time `json:"time_expire,omitempty"`
|
||||
NotifyUrl *string `json:"notify_url,omitempty"`
|
||||
}
|
||||
|
||||
type UnionApiv3H5PrepayResponse struct {
|
||||
H5Url *string `json:"h5_url,omitempty"`
|
||||
}
|
||||
|
||||
type UnionH5SceneInfo struct {
|
||||
PayerClientIp *string `json:"payer_client_ip,omitempty"`
|
||||
DeviceId *string `json:"device_id,omitempty"`
|
||||
H5Info *UnionH5Info `json:"h5_info,omitempty"`
|
||||
}
|
||||
|
||||
type UnionCommonSubOrder struct {
|
||||
Mchid *string `json:"mchid,omitempty"`
|
||||
OutTradeNo *string `json:"out_trade_no,omitempty"`
|
||||
Amount *UnionAmountInfo `json:"amount,omitempty"`
|
||||
Attach *string `json:"attach,omitempty"`
|
||||
Description *string `json:"description,omitempty"`
|
||||
GoodsTag *string `json:"goods_tag,omitempty"`
|
||||
SettleInfo *UnionSettleInfo `json:"settle_info,omitempty"`
|
||||
}
|
||||
|
||||
type UnionH5Info struct {
|
||||
Type *string `json:"type,omitempty"`
|
||||
AppName *string `json:"app_name,omitempty"`
|
||||
AppUrl *string `json:"app_url,omitempty"`
|
||||
BundleId *string `json:"bundle_id,omitempty"`
|
||||
PackageName *string `json:"package_name,omitempty"`
|
||||
}
|
||||
|
||||
type UnionAmountInfo struct {
|
||||
TotalAmount *int64 `json:"total_amount,omitempty"`
|
||||
Currency *string `json:"currency,omitempty"`
|
||||
}
|
||||
|
||||
type UnionSettleInfo struct {
|
||||
ProfitSharing *bool `json:"profit_sharing,omitempty"`
|
||||
}
|
||||
@@ -0,0 +1,191 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"demo/wxpay_utility" // 引用微信支付工具库,参考 https://pay.weixin.qq.com/doc/v3/merchant/4015119334
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"time"
|
||||
)
|
||||
|
||||
func main() {
|
||||
// TODO: 请准备商户开发必要参数,参考:https://pay.weixin.qq.com/doc/v3/merchant/4013070756
|
||||
config, err := wxpay_utility.CreateMchConfig(
|
||||
"19xxxxxxxx", // 商户号,是由微信支付系统生成并分配给每个商户的唯一标识符,商户号获取方式参考 https://pay.weixin.qq.com/doc/v3/merchant/4013070756
|
||||
"1DDE55AD98Exxxxxxxxxx", // 商户API证书序列号,如何获取请参考 https://pay.weixin.qq.com/doc/v3/merchant/4013053053
|
||||
"/path/to/apiclient_key.pem", // 商户API证书私钥文件路径,本地文件路径
|
||||
"PUB_KEY_ID_xxxxxxxxxxxxx", // 微信支付公钥ID,如何获取请参考 https://pay.weixin.qq.com/doc/v3/merchant/4013038816
|
||||
"/path/to/wxp_pub.pem", // 微信支付公钥文件路径,本地文件路径
|
||||
)
|
||||
if err != nil {
|
||||
fmt.Println(err)
|
||||
return
|
||||
}
|
||||
|
||||
request := &UnionApiv3JsapiPrepayRequest{
|
||||
CombineAppid: wxpay_utility.String("wxd678efh567hg6787"),
|
||||
CombineMchid: wxpay_utility.String("1230000109"),
|
||||
CombineOutTradeNo: wxpay_utility.String("1217752501201407033233368018"),
|
||||
CombinePayerInfo: &UnionPayerInfo{
|
||||
Openid: wxpay_utility.String("oUpF8uMuAJO_M2pxb1Q9zNjWeS6o"),
|
||||
},
|
||||
SceneInfo: &UnionSceneInfo{
|
||||
DeviceId: wxpay_utility.String("POS1:1"),
|
||||
PayerClientIp: wxpay_utility.String("14.17.22.32"),
|
||||
},
|
||||
SubOrders: []UnionSubOrder{
|
||||
UnionSubOrder{
|
||||
Mchid: wxpay_utility.String("1230000109"),
|
||||
OutTradeNo: wxpay_utility.String("20150806125346"),
|
||||
Amount: &UnionAmountInfo{
|
||||
TotalAmount: wxpay_utility.Int64(10),
|
||||
Currency: wxpay_utility.String("CNY"),
|
||||
},
|
||||
Attach: wxpay_utility.String("深圳分店"),
|
||||
Description: wxpay_utility.String("腾讯充值中心-QQ会员充值"),
|
||||
Detail: wxpay_utility.String("买单费用"),
|
||||
GoodsTag: wxpay_utility.String("WXG"),
|
||||
SettleInfo: &UnionSettleInfo{
|
||||
ProfitSharing: wxpay_utility.Bool(false),
|
||||
},
|
||||
},
|
||||
UnionSubOrder{
|
||||
Mchid: wxpay_utility.String("1230000119"),
|
||||
OutTradeNo: wxpay_utility.String("20150806125347"),
|
||||
Amount: &UnionAmountInfo{
|
||||
TotalAmount: wxpay_utility.Int64(10),
|
||||
Currency: wxpay_utility.String("CNY"),
|
||||
},
|
||||
Attach: wxpay_utility.String("广州分店"),
|
||||
Description: wxpay_utility.String("腾讯充值中心-微信充值"),
|
||||
Detail: wxpay_utility.String("买单费用"),
|
||||
GoodsTag: wxpay_utility.String("WXG"),
|
||||
SettleInfo: &UnionSettleInfo{
|
||||
ProfitSharing: wxpay_utility.Bool(false),
|
||||
},
|
||||
},
|
||||
},
|
||||
TimeExpire: wxpay_utility.Time(time.Now()),
|
||||
NotifyUrl: wxpay_utility.String("https://yourapp.com/notify"),
|
||||
}
|
||||
|
||||
response, err := UnionJsapiPrepay(config, request)
|
||||
if err != nil {
|
||||
fmt.Printf("请求失败: %+v\n", err)
|
||||
// TODO: 请求失败,根据状态码执行不同的处理
|
||||
return
|
||||
}
|
||||
|
||||
// TODO: 请求成功,继续业务逻辑
|
||||
fmt.Printf("请求成功: %+v\n", response)
|
||||
}
|
||||
|
||||
func UnionJsapiPrepay(config *wxpay_utility.MchConfig, request *UnionApiv3JsapiPrepayRequest) (response *UnionApiv3JsapiPrepayResponse, err error) {
|
||||
const (
|
||||
host = "https://api.mch.weixin.qq.com"
|
||||
method = "POST"
|
||||
path = "/v3/combine-transactions/jsapi"
|
||||
)
|
||||
|
||||
reqUrl, err := url.Parse(fmt.Sprintf("%s%s", host, path))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
reqBody, err := json.Marshal(request)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
httpRequest, err := http.NewRequest(method, reqUrl.String(), bytes.NewReader(reqBody))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
httpRequest.Header.Set("Accept", "application/json")
|
||||
httpRequest.Header.Set("Wechatpay-Serial", config.WechatPayPublicKeyId())
|
||||
httpRequest.Header.Set("Content-Type", "application/json")
|
||||
authorization, err := wxpay_utility.BuildAuthorization(config.MchId(), config.CertificateSerialNo(), config.PrivateKey(), method, reqUrl.RequestURI(), reqBody)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
httpRequest.Header.Set("Authorization", authorization)
|
||||
|
||||
client := &http.Client{}
|
||||
httpResponse, err := client.Do(httpRequest)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
respBody, err := wxpay_utility.ExtractResponseBody(httpResponse)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if httpResponse.StatusCode >= 200 && httpResponse.StatusCode < 300 {
|
||||
// 2XX 成功,验证应答签名
|
||||
err = wxpay_utility.ValidateResponse(
|
||||
config.WechatPayPublicKeyId(),
|
||||
config.WechatPayPublicKey(),
|
||||
&httpResponse.Header,
|
||||
respBody,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
response := &UnionApiv3JsapiPrepayResponse{}
|
||||
if err := json.Unmarshal(respBody, response); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return response, nil
|
||||
} else {
|
||||
return nil, wxpay_utility.NewApiException(
|
||||
httpResponse.StatusCode,
|
||||
httpResponse.Header,
|
||||
respBody,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
type UnionApiv3JsapiPrepayRequest struct {
|
||||
CombineAppid *string `json:"combine_appid,omitempty"`
|
||||
CombineMchid *string `json:"combine_mchid,omitempty"`
|
||||
CombineOutTradeNo *string `json:"combine_out_trade_no,omitempty"`
|
||||
CombinePayerInfo *UnionPayerInfo `json:"combine_payer_info,omitempty"`
|
||||
SceneInfo *UnionSceneInfo `json:"scene_info,omitempty"`
|
||||
SubOrders []UnionSubOrder `json:"sub_orders,omitempty"`
|
||||
TimeExpire *time.Time `json:"time_expire,omitempty"`
|
||||
NotifyUrl *string `json:"notify_url,omitempty"`
|
||||
}
|
||||
|
||||
type UnionApiv3JsapiPrepayResponse struct {
|
||||
PrepayId *string `json:"prepay_id,omitempty"`
|
||||
}
|
||||
|
||||
type UnionPayerInfo struct {
|
||||
Openid *string `json:"openid,omitempty"`
|
||||
SubOpenid *string `json:"sub_openid,omitempty"`
|
||||
}
|
||||
|
||||
type UnionSceneInfo struct {
|
||||
DeviceId *string `json:"device_id,omitempty"`
|
||||
PayerClientIp *string `json:"payer_client_ip,omitempty"`
|
||||
}
|
||||
|
||||
type UnionSubOrder struct {
|
||||
Mchid *string `json:"mchid,omitempty"`
|
||||
OutTradeNo *string `json:"out_trade_no,omitempty"`
|
||||
Amount *UnionAmountInfo `json:"amount,omitempty"`
|
||||
Attach *string `json:"attach,omitempty"`
|
||||
Description *string `json:"description,omitempty"`
|
||||
Detail *string `json:"detail,omitempty"`
|
||||
GoodsTag *string `json:"goods_tag,omitempty"`
|
||||
SettleInfo *UnionSettleInfo `json:"settle_info,omitempty"`
|
||||
}
|
||||
|
||||
type UnionAmountInfo struct {
|
||||
TotalAmount *int64 `json:"total_amount,omitempty"`
|
||||
Currency *string `json:"currency,omitempty"`
|
||||
}
|
||||
|
||||
type UnionSettleInfo struct {
|
||||
ProfitSharing *bool `json:"profit_sharing,omitempty"`
|
||||
}
|
||||
@@ -0,0 +1,179 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"demo/wxpay_utility" // 引用微信支付工具库,参考 https://pay.weixin.qq.com/doc/v3/merchant/4015119334
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"time"
|
||||
)
|
||||
|
||||
func main() {
|
||||
// TODO: 请准备商户开发必要参数,参考:https://pay.weixin.qq.com/doc/v3/merchant/4013070756
|
||||
config, err := wxpay_utility.CreateMchConfig(
|
||||
"19xxxxxxxx", // 商户号,是由微信支付系统生成并分配给每个商户的唯一标识符,商户号获取方式参考 https://pay.weixin.qq.com/doc/v3/merchant/4013070756
|
||||
"1DDE55AD98Exxxxxxxxxx", // 商户API证书序列号,如何获取请参考 https://pay.weixin.qq.com/doc/v3/merchant/4013053053
|
||||
"/path/to/apiclient_key.pem", // 商户API证书私钥文件路径,本地文件路径
|
||||
"PUB_KEY_ID_xxxxxxxxxxxxx", // 微信支付公钥ID,如何获取请参考 https://pay.weixin.qq.com/doc/v3/merchant/4013038816
|
||||
"/path/to/wxp_pub.pem", // 微信支付公钥文件路径,本地文件路径
|
||||
)
|
||||
if err != nil {
|
||||
fmt.Println(err)
|
||||
return
|
||||
}
|
||||
|
||||
request := &UnionApiv3NativePrepayRequest{
|
||||
CombineAppid: wxpay_utility.String("wxd678efh567hg6787"),
|
||||
CombineOutTradeNo: wxpay_utility.String("20150806125346"),
|
||||
CombineMchid: wxpay_utility.String("1900000109"),
|
||||
SceneInfo: &UnionSceneInfo{
|
||||
DeviceId: wxpay_utility.String("POS1:1"),
|
||||
PayerClientIp: wxpay_utility.String("14.17.22.32"),
|
||||
},
|
||||
SubOrders: []UnionCommonSubOrder{
|
||||
UnionCommonSubOrder{
|
||||
Mchid: wxpay_utility.String("1230000109"),
|
||||
OutTradeNo: wxpay_utility.String("20150806125346"),
|
||||
Amount: &UnionAmountInfo{
|
||||
TotalAmount: wxpay_utility.Int64(10),
|
||||
Currency: wxpay_utility.String("CNY"),
|
||||
},
|
||||
Attach: wxpay_utility.String("深圳分店"),
|
||||
Description: wxpay_utility.String("腾讯充值中心-QQ会员充值"),
|
||||
GoodsTag: wxpay_utility.String("WXG"),
|
||||
SettleInfo: &UnionSettleInfo{
|
||||
ProfitSharing: wxpay_utility.Bool(false),
|
||||
},
|
||||
},
|
||||
UnionCommonSubOrder{
|
||||
Mchid: wxpay_utility.String("1230000119"),
|
||||
OutTradeNo: wxpay_utility.String("20150806125347"),
|
||||
Amount: &UnionAmountInfo{
|
||||
TotalAmount: wxpay_utility.Int64(10),
|
||||
Currency: wxpay_utility.String("CNY"),
|
||||
},
|
||||
Attach: wxpay_utility.String("广州分店"),
|
||||
Description: wxpay_utility.String("腾讯充值中心-微信充值"),
|
||||
GoodsTag: wxpay_utility.String("WXG"),
|
||||
SettleInfo: &UnionSettleInfo{
|
||||
ProfitSharing: wxpay_utility.Bool(false),
|
||||
},
|
||||
},
|
||||
},
|
||||
TimeExpire: wxpay_utility.Time(time.Now()),
|
||||
NotifyUrl: wxpay_utility.String("https://yourapp.com/notify"),
|
||||
}
|
||||
|
||||
response, err := UnionNativePrepay(config, request)
|
||||
if err != nil {
|
||||
fmt.Printf("请求失败: %+v\n", err)
|
||||
// TODO: 请求失败,根据状态码执行不同的处理
|
||||
return
|
||||
}
|
||||
|
||||
// TODO: 请求成功,继续业务逻辑
|
||||
fmt.Printf("请求成功: %+v\n", response)
|
||||
}
|
||||
|
||||
func UnionNativePrepay(config *wxpay_utility.MchConfig, request *UnionApiv3NativePrepayRequest) (response *UnionApiv3NativePrepayResponse, err error) {
|
||||
const (
|
||||
host = "https://api.mch.weixin.qq.com"
|
||||
method = "POST"
|
||||
path = "/v3/combine-transactions/native"
|
||||
)
|
||||
|
||||
reqUrl, err := url.Parse(fmt.Sprintf("%s%s", host, path))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
reqBody, err := json.Marshal(request)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
httpRequest, err := http.NewRequest(method, reqUrl.String(), bytes.NewReader(reqBody))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
httpRequest.Header.Set("Accept", "application/json")
|
||||
httpRequest.Header.Set("Wechatpay-Serial", config.WechatPayPublicKeyId())
|
||||
httpRequest.Header.Set("Content-Type", "application/json")
|
||||
authorization, err := wxpay_utility.BuildAuthorization(config.MchId(), config.CertificateSerialNo(), config.PrivateKey(), method, reqUrl.RequestURI(), reqBody)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
httpRequest.Header.Set("Authorization", authorization)
|
||||
|
||||
client := &http.Client{}
|
||||
httpResponse, err := client.Do(httpRequest)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
respBody, err := wxpay_utility.ExtractResponseBody(httpResponse)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if httpResponse.StatusCode >= 200 && httpResponse.StatusCode < 300 {
|
||||
// 2XX 成功,验证应答签名
|
||||
err = wxpay_utility.ValidateResponse(
|
||||
config.WechatPayPublicKeyId(),
|
||||
config.WechatPayPublicKey(),
|
||||
&httpResponse.Header,
|
||||
respBody,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
response := &UnionApiv3NativePrepayResponse{}
|
||||
if err := json.Unmarshal(respBody, response); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return response, nil
|
||||
} else {
|
||||
return nil, wxpay_utility.NewApiException(
|
||||
httpResponse.StatusCode,
|
||||
httpResponse.Header,
|
||||
respBody,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
type UnionApiv3NativePrepayRequest struct {
|
||||
CombineAppid *string `json:"combine_appid,omitempty"`
|
||||
CombineOutTradeNo *string `json:"combine_out_trade_no,omitempty"`
|
||||
CombineMchid *string `json:"combine_mchid,omitempty"`
|
||||
SceneInfo *UnionSceneInfo `json:"scene_info,omitempty"`
|
||||
SubOrders []UnionCommonSubOrder `json:"sub_orders,omitempty"`
|
||||
TimeExpire *time.Time `json:"time_expire,omitempty"`
|
||||
NotifyUrl *string `json:"notify_url,omitempty"`
|
||||
}
|
||||
|
||||
type UnionApiv3NativePrepayResponse struct {
|
||||
CodeUrl *string `json:"code_url,omitempty"`
|
||||
}
|
||||
|
||||
type UnionSceneInfo struct {
|
||||
DeviceId *string `json:"device_id,omitempty"`
|
||||
PayerClientIp *string `json:"payer_client_ip,omitempty"`
|
||||
}
|
||||
|
||||
type UnionCommonSubOrder struct {
|
||||
Mchid *string `json:"mchid,omitempty"`
|
||||
OutTradeNo *string `json:"out_trade_no,omitempty"`
|
||||
Amount *UnionAmountInfo `json:"amount,omitempty"`
|
||||
Attach *string `json:"attach,omitempty"`
|
||||
Description *string `json:"description,omitempty"`
|
||||
GoodsTag *string `json:"goods_tag,omitempty"`
|
||||
SettleInfo *UnionSettleInfo `json:"settle_info,omitempty"`
|
||||
}
|
||||
|
||||
type UnionAmountInfo struct {
|
||||
TotalAmount *int64 `json:"total_amount,omitempty"`
|
||||
Currency *string `json:"currency,omitempty"`
|
||||
}
|
||||
|
||||
type UnionSettleInfo struct {
|
||||
ProfitSharing *bool `json:"profit_sharing,omitempty"`
|
||||
}
|
||||
@@ -0,0 +1,178 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"demo/wxpay_utility" // 引用微信支付工具库,参考 https://pay.weixin.qq.com/doc/v3/merchant/4015119334
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
)
|
||||
|
||||
func main() {
|
||||
// TODO: 请准备商户开发必要参数,参考:https://pay.weixin.qq.com/doc/v3/merchant/4013070756
|
||||
config, err := wxpay_utility.CreateMchConfig(
|
||||
"19xxxxxxxx", // 商户号,是由微信支付系统生成并分配给每个商户的唯一标识符,商户号获取方式参考 https://pay.weixin.qq.com/doc/v3/merchant/4013070756
|
||||
"1DDE55AD98Exxxxxxxxxx", // 商户API证书序列号,如何获取请参考 https://pay.weixin.qq.com/doc/v3/merchant/4013053053
|
||||
"/path/to/apiclient_key.pem", // 商户API证书私钥文件路径,本地文件路径
|
||||
"PUB_KEY_ID_xxxxxxxxxxxxx", // 微信支付公钥ID,如何获取请参考 https://pay.weixin.qq.com/doc/v3/merchant/4013038816
|
||||
"/path/to/wxp_pub.pem", // 微信支付公钥文件路径,本地文件路径
|
||||
)
|
||||
if err != nil {
|
||||
fmt.Println(err)
|
||||
return
|
||||
}
|
||||
|
||||
request := &UnionQueryByOutTradeNoRequest{
|
||||
CombineOutTradeNo: wxpay_utility.String("P20150806125346"),
|
||||
}
|
||||
|
||||
response, err := UnionQueryByOutTradeNo(config, request)
|
||||
if err != nil {
|
||||
fmt.Printf("请求失败: %+v\n", err)
|
||||
// TODO: 请求失败,根据状态码执行不同的处理
|
||||
return
|
||||
}
|
||||
|
||||
// TODO: 请求成功,继续业务逻辑
|
||||
fmt.Printf("请求成功: %+v\n", response)
|
||||
}
|
||||
|
||||
func UnionQueryByOutTradeNo(config *wxpay_utility.MchConfig, request *UnionQueryByOutTradeNoRequest) (response *UnionApiv3UnionQueryResponse, err error) {
|
||||
const (
|
||||
host = "https://api.mch.weixin.qq.com"
|
||||
method = "GET"
|
||||
path = "/v3/combine-transactions/out-trade-no/{combine_out_trade_no}"
|
||||
)
|
||||
|
||||
reqUrl, err := url.Parse(fmt.Sprintf("%s%s", host, path))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
reqUrl.Path = strings.Replace(reqUrl.Path, "{combine_out_trade_no}", url.PathEscape(*request.CombineOutTradeNo), -1)
|
||||
httpRequest, err := http.NewRequest(method, reqUrl.String(), nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
httpRequest.Header.Set("Accept", "application/json")
|
||||
httpRequest.Header.Set("Wechatpay-Serial", config.WechatPayPublicKeyId())
|
||||
authorization, err := wxpay_utility.BuildAuthorization(config.MchId(), config.CertificateSerialNo(), config.PrivateKey(), method, reqUrl.RequestURI(), nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
httpRequest.Header.Set("Authorization", authorization)
|
||||
|
||||
client := &http.Client{}
|
||||
httpResponse, err := client.Do(httpRequest)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
respBody, err := wxpay_utility.ExtractResponseBody(httpResponse)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if httpResponse.StatusCode >= 200 && httpResponse.StatusCode < 300 {
|
||||
// 2XX 成功,验证应答签名
|
||||
err = wxpay_utility.ValidateResponse(
|
||||
config.WechatPayPublicKeyId(),
|
||||
config.WechatPayPublicKey(),
|
||||
&httpResponse.Header,
|
||||
respBody,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
response := &UnionApiv3UnionQueryResponse{}
|
||||
if err := json.Unmarshal(respBody, response); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return response, nil
|
||||
} else {
|
||||
return nil, wxpay_utility.NewApiException(
|
||||
httpResponse.StatusCode,
|
||||
httpResponse.Header,
|
||||
respBody,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
type UnionQueryByOutTradeNoRequest struct {
|
||||
CombineOutTradeNo *string `json:"combine_out_trade_no,omitempty"`
|
||||
}
|
||||
|
||||
func (o *UnionQueryByOutTradeNoRequest) MarshalJSON() ([]byte, error) {
|
||||
type Alias UnionQueryByOutTradeNoRequest
|
||||
a := &struct {
|
||||
CombineOutTradeNo *string `json:"combine_out_trade_no,omitempty"`
|
||||
*Alias
|
||||
}{
|
||||
// 序列化时移除非 Body 字段
|
||||
CombineOutTradeNo: nil,
|
||||
Alias: (*Alias)(o),
|
||||
}
|
||||
return json.Marshal(a)
|
||||
}
|
||||
|
||||
type UnionApiv3UnionQueryResponse struct {
|
||||
CombineAppid *string `json:"combine_appid,omitempty"`
|
||||
CombineMchid *string `json:"combine_mchid,omitempty"`
|
||||
CombineOutTradeNo *string `json:"combine_out_trade_no,omitempty"`
|
||||
CombinePayerInfo *UnionCommRespPayerInfo `json:"combine_payer_info,omitempty"`
|
||||
SceneInfo *UnionCommRespSceneInfo `json:"scene_info,omitempty"`
|
||||
SubOrders []UnionSubOrder `json:"sub_orders,omitempty"`
|
||||
}
|
||||
|
||||
type UnionCommRespPayerInfo struct {
|
||||
Openid *string `json:"openid,omitempty"`
|
||||
}
|
||||
|
||||
type UnionCommRespSceneInfo struct {
|
||||
DeviceId *string `json:"device_id,omitempty"`
|
||||
}
|
||||
|
||||
type UnionSubOrder struct {
|
||||
Mchid *string `json:"mchid,omitempty"`
|
||||
SubMchid *string `json:"sub_mchid,omitempty"`
|
||||
SubAppid *string `json:"sub_appid,omitempty"`
|
||||
SubOpenid *string `json:"sub_openid,omitempty"`
|
||||
OutTradeNo *string `json:"out_trade_no,omitempty"`
|
||||
TransactionId *string `json:"transaction_id,omitempty"`
|
||||
TradeType *string `json:"trade_type,omitempty"`
|
||||
TradeState *string `json:"trade_state,omitempty"`
|
||||
BankType *string `json:"bank_type,omitempty"`
|
||||
Attach *string `json:"attach,omitempty"`
|
||||
SuccessTime *string `json:"success_time,omitempty"`
|
||||
Amount *UnionCommRespAmountInfo `json:"amount,omitempty"`
|
||||
PromotionDetail []UnionPromotionDetail `json:"promotion_detail,omitempty"`
|
||||
}
|
||||
|
||||
type UnionCommRespAmountInfo struct {
|
||||
TotalAmount *int64 `json:"total_amount,omitempty"`
|
||||
PayerAmount *int64 `json:"payer_amount,omitempty"`
|
||||
Currency *string `json:"currency,omitempty"`
|
||||
PayerCurrency *string `json:"payer_currency,omitempty"`
|
||||
SettlementRate *int64 `json:"settlement_rate,omitempty"`
|
||||
}
|
||||
|
||||
type UnionPromotionDetail struct {
|
||||
CouponId *string `json:"coupon_id,omitempty"`
|
||||
Name *string `json:"name,omitempty"`
|
||||
Scope *string `json:"scope,omitempty"`
|
||||
Type *string `json:"type,omitempty"`
|
||||
Amount *int64 `json:"amount,omitempty"`
|
||||
StockId *string `json:"stock_id,omitempty"`
|
||||
WechatpayContribute *int64 `json:"wechatpay_contribute,omitempty"`
|
||||
MerchantContribute *int64 `json:"merchant_contribute,omitempty"`
|
||||
OtherContribute *int64 `json:"other_contribute,omitempty"`
|
||||
Currency *string `json:"currency,omitempty"`
|
||||
GoodsDetail []GoodsDetailInPromotion `json:"goods_detail,omitempty"`
|
||||
}
|
||||
|
||||
type GoodsDetailInPromotion struct {
|
||||
GoodsId *string `json:"goods_id,omitempty"`
|
||||
Quantity *int64 `json:"quantity,omitempty"`
|
||||
UnitPrice *int64 `json:"unit_price,omitempty"`
|
||||
DiscountAmount *int64 `json:"discount_amount,omitempty"`
|
||||
GoodsRemark *string `json:"goods_remark,omitempty"`
|
||||
}
|
||||
@@ -0,0 +1,156 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"demo/wxpay_utility" // 引用微信支付工具库,参考 https://pay.weixin.qq.com/doc/v3/merchant/4015119334
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/url"
|
||||
)
|
||||
|
||||
func main() {
|
||||
// TODO: 请准备商户开发必要参数,参考:https://pay.weixin.qq.com/doc/v3/merchant/4013070756
|
||||
config, err := wxpay_utility.CreateMchConfig(
|
||||
"19xxxxxxxx", // 商户号,是由微信支付系统生成并分配给每个商户的唯一标识符,商户号获取方式参考 https://pay.weixin.qq.com/doc/v3/merchant/4013070756
|
||||
"1DDE55AD98Exxxxxxxxxx", // 商户API证书序列号,如何获取请参考 https://pay.weixin.qq.com/doc/v3/merchant/4013053053
|
||||
"/path/to/apiclient_key.pem", // 商户API证书私钥文件路径,本地文件路径
|
||||
"PUB_KEY_ID_xxxxxxxxxxxxx", // 微信支付公钥ID,如何获取请参考 https://pay.weixin.qq.com/doc/v3/merchant/4013038816
|
||||
"/path/to/wxp_pub.pem", // 微信支付公钥文件路径,本地文件路径
|
||||
)
|
||||
if err != nil {
|
||||
fmt.Println(err)
|
||||
return
|
||||
}
|
||||
|
||||
request := &AddReceiverRequest{
|
||||
Appid: wxpay_utility.String("wx8888888888888888"),
|
||||
Type: RECEIVERTYPE_MERCHANT_ID.Ptr(),
|
||||
Account: wxpay_utility.String("86693852"),
|
||||
Name: wxpay_utility.String("hu89ohu89ohu89o"), /*请传入wxpay_utility.EncryptOAEPWithPublicKey 加密结果*/
|
||||
RelationType: RECEIVERRELATIONTYPE_STORE.Ptr(),
|
||||
CustomRelation: wxpay_utility.String("代理商"),
|
||||
}
|
||||
|
||||
response, err := AddReceiver(config, request)
|
||||
if err != nil {
|
||||
fmt.Printf("请求失败: %+v\n", err)
|
||||
// TODO: 请求失败,根据状态码执行不同的处理
|
||||
return
|
||||
}
|
||||
|
||||
// TODO: 请求成功,继续业务逻辑
|
||||
fmt.Printf("请求成功: %+v\n", response)
|
||||
}
|
||||
|
||||
func AddReceiver(config *wxpay_utility.MchConfig, request *AddReceiverRequest) (response *AddReceiverResponse, err error) {
|
||||
const (
|
||||
host = "https://api.mch.weixin.qq.com"
|
||||
method = "POST"
|
||||
path = "/v3/profitsharing/receivers/add"
|
||||
)
|
||||
|
||||
reqUrl, err := url.Parse(fmt.Sprintf("%s%s", host, path))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
reqBody, err := json.Marshal(request)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
httpRequest, err := http.NewRequest(method, reqUrl.String(), bytes.NewReader(reqBody))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
httpRequest.Header.Set("Accept", "application/json")
|
||||
httpRequest.Header.Set("Wechatpay-Serial", config.WechatPayPublicKeyId())
|
||||
httpRequest.Header.Set("Content-Type", "application/json")
|
||||
authorization, err := wxpay_utility.BuildAuthorization(config.MchId(), config.CertificateSerialNo(), config.PrivateKey(), method, reqUrl.RequestURI(), reqBody)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
httpRequest.Header.Set("Authorization", authorization)
|
||||
|
||||
client := &http.Client{}
|
||||
httpResponse, err := client.Do(httpRequest)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
respBody, err := wxpay_utility.ExtractResponseBody(httpResponse)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if httpResponse.StatusCode >= 200 && httpResponse.StatusCode < 300 {
|
||||
// 2XX 成功,验证应答签名
|
||||
err = wxpay_utility.ValidateResponse(
|
||||
config.WechatPayPublicKeyId(),
|
||||
config.WechatPayPublicKey(),
|
||||
&httpResponse.Header,
|
||||
respBody,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
response := &AddReceiverResponse{}
|
||||
if err := json.Unmarshal(respBody, response); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return response, nil
|
||||
} else {
|
||||
return nil, wxpay_utility.NewApiException(
|
||||
httpResponse.StatusCode,
|
||||
httpResponse.Header,
|
||||
respBody,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
type AddReceiverRequest struct {
|
||||
Appid *string `json:"appid,omitempty"`
|
||||
Type *ReceiverType `json:"type,omitempty"`
|
||||
Account *string `json:"account,omitempty"`
|
||||
Name *string `json:"name,omitempty"`
|
||||
RelationType *ReceiverRelationType `json:"relation_type,omitempty"`
|
||||
CustomRelation *string `json:"custom_relation,omitempty"`
|
||||
}
|
||||
|
||||
type AddReceiverResponse struct {
|
||||
Type *ReceiverType `json:"type,omitempty"`
|
||||
Account *string `json:"account,omitempty"`
|
||||
Name *string `json:"name,omitempty"`
|
||||
RelationType *ReceiverRelationType `json:"relation_type,omitempty"`
|
||||
CustomRelation *string `json:"custom_relation,omitempty"`
|
||||
}
|
||||
|
||||
type ReceiverType string
|
||||
|
||||
func (e ReceiverType) Ptr() *ReceiverType {
|
||||
return &e
|
||||
}
|
||||
|
||||
const (
|
||||
RECEIVERTYPE_MERCHANT_ID ReceiverType = "MERCHANT_ID"
|
||||
RECEIVERTYPE_PERSONAL_OPENID ReceiverType = "PERSONAL_OPENID"
|
||||
)
|
||||
|
||||
type ReceiverRelationType string
|
||||
|
||||
func (e ReceiverRelationType) Ptr() *ReceiverRelationType {
|
||||
return &e
|
||||
}
|
||||
|
||||
const (
|
||||
RECEIVERRELATIONTYPE_STORE ReceiverRelationType = "STORE"
|
||||
RECEIVERRELATIONTYPE_STAFF ReceiverRelationType = "STAFF"
|
||||
RECEIVERRELATIONTYPE_STORE_OWNER ReceiverRelationType = "STORE_OWNER"
|
||||
RECEIVERRELATIONTYPE_PARTNER ReceiverRelationType = "PARTNER"
|
||||
RECEIVERRELATIONTYPE_HEADQUARTER ReceiverRelationType = "HEADQUARTER"
|
||||
RECEIVERRELATIONTYPE_BRAND ReceiverRelationType = "BRAND"
|
||||
RECEIVERRELATIONTYPE_DISTRIBUTOR ReceiverRelationType = "DISTRIBUTOR"
|
||||
RECEIVERRELATIONTYPE_USER ReceiverRelationType = "USER"
|
||||
RECEIVERRELATIONTYPE_SUPPLIER ReceiverRelationType = "SUPPLIER"
|
||||
RECEIVERRELATIONTYPE_CUSTOM ReceiverRelationType = "CUSTOM"
|
||||
)
|
||||
|
||||
删除分账接收方
|
||||
@@ -0,0 +1,178 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"demo/wxpay_utility" // 引用微信支付工具库,参考 https://pay.weixin.qq.com/doc/v3/merchant/4015119334
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"time"
|
||||
)
|
||||
|
||||
func main() {
|
||||
config, err := wxpay_utility.CreateMchConfig(
|
||||
"19xxxxxxxx",
|
||||
"1DDE55AD98Exxxxxxxxxx",
|
||||
"/path/to/apiclient_key.pem",
|
||||
"PUB_KEY_ID_xxxxxxxxxxxxx",
|
||||
"/path/to/wxp_pub.pem",
|
||||
)
|
||||
if err != nil {
|
||||
fmt.Println(err)
|
||||
return
|
||||
}
|
||||
|
||||
request := &CreateOrderRequest{
|
||||
Appid: wxpay_utility.String("wx8888888888888888"),
|
||||
TransactionId: wxpay_utility.String("4208450740201411110007820472"),
|
||||
OutOrderNo: wxpay_utility.String("P20150806125346"),
|
||||
Receivers: []CreateOrderReceiver{CreateOrderReceiver{
|
||||
Type: wxpay_utility.String("MERCHANT_ID"),
|
||||
Account: wxpay_utility.String("86693852"),
|
||||
Name: wxpay_utility.String("hu89ohu89ohu89o"), /*请传入wxpay_utility.EncryptOAEPWithPublicKey 加密结果*/
|
||||
Amount: wxpay_utility.Int64(888),
|
||||
Description: wxpay_utility.String("分给商户A"),
|
||||
}},
|
||||
UnfreezeUnsplit: wxpay_utility.Bool(true),
|
||||
}
|
||||
|
||||
response, err := CreateOrder(config, request)
|
||||
if err != nil {
|
||||
fmt.Printf("请求失败: %+v\n", err)
|
||||
return
|
||||
}
|
||||
|
||||
fmt.Printf("请求成功: %+v\n", response)
|
||||
}
|
||||
|
||||
func CreateOrder(config *wxpay_utility.MchConfig, request *CreateOrderRequest) (response *OrdersEntity, err error) {
|
||||
const (
|
||||
host = "https://api.mch.weixin.qq.com"
|
||||
method = "POST"
|
||||
path = "/v3/profitsharing/orders"
|
||||
)
|
||||
|
||||
reqUrl, err := url.Parse(fmt.Sprintf("%s%s", host, path))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
reqBody, err := json.Marshal(request)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
httpRequest, err := http.NewRequest(method, reqUrl.String(), bytes.NewReader(reqBody))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
httpRequest.Header.Set("Accept", "application/json")
|
||||
httpRequest.Header.Set("Wechatpay-Serial", config.WechatPayPublicKeyId())
|
||||
httpRequest.Header.Set("Content-Type", "application/json")
|
||||
authorization, err := wxpay_utility.BuildAuthorization(config.MchId(), config.CertificateSerialNo(), config.PrivateKey(), method, reqUrl.RequestURI(), reqBody)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
httpRequest.Header.Set("Authorization", authorization)
|
||||
|
||||
client := &http.Client{}
|
||||
httpResponse, err := client.Do(httpRequest)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
respBody, err := wxpay_utility.ExtractResponseBody(httpResponse)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if httpResponse.StatusCode >= 200 && httpResponse.StatusCode < 300 {
|
||||
err = wxpay_utility.ValidateResponse(config.WechatPayPublicKeyId(), config.WechatPayPublicKey(), &httpResponse.Header, respBody)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
response := &OrdersEntity{}
|
||||
if err := json.Unmarshal(respBody, response); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return response, nil
|
||||
} else {
|
||||
return nil, wxpay_utility.NewApiException(httpResponse.StatusCode, httpResponse.Header, respBody)
|
||||
}
|
||||
}
|
||||
|
||||
type CreateOrderRequest struct {
|
||||
Appid *string `json:"appid,omitempty"`
|
||||
TransactionId *string `json:"transaction_id,omitempty"`
|
||||
OutOrderNo *string `json:"out_order_no,omitempty"`
|
||||
Receivers []CreateOrderReceiver `json:"receivers,omitempty"`
|
||||
UnfreezeUnsplit *bool `json:"unfreeze_unsplit,omitempty"`
|
||||
}
|
||||
|
||||
type OrdersEntity struct {
|
||||
TransactionId *string `json:"transaction_id,omitempty"`
|
||||
OutOrderNo *string `json:"out_order_no,omitempty"`
|
||||
OrderId *string `json:"order_id,omitempty"`
|
||||
State *OrderStatus `json:"state,omitempty"`
|
||||
Receivers []OrderReceiverDetail `json:"receivers,omitempty"`
|
||||
}
|
||||
|
||||
type CreateOrderReceiver struct {
|
||||
Type *string `json:"type,omitempty"`
|
||||
Account *string `json:"account,omitempty"`
|
||||
Name *string `json:"name,omitempty"`
|
||||
Amount *int64 `json:"amount,omitempty"`
|
||||
Description *string `json:"description,omitempty"`
|
||||
}
|
||||
|
||||
type OrderStatus string
|
||||
|
||||
func (e OrderStatus) Ptr() *OrderStatus { return &e }
|
||||
|
||||
const (
|
||||
ORDERSTATUS_PROCESSING OrderStatus = "PROCESSING"
|
||||
ORDERSTATUS_FINISHED OrderStatus = "FINISHED"
|
||||
)
|
||||
|
||||
type OrderReceiverDetail struct {
|
||||
Amount *int64 `json:"amount,omitempty"`
|
||||
Description *string `json:"description,omitempty"`
|
||||
Type *ReceiverType `json:"type,omitempty"`
|
||||
Account *string `json:"account,omitempty"`
|
||||
Result *DetailStatus `json:"result,omitempty"`
|
||||
FailReason *DetailFailReason `json:"fail_reason,omitempty"`
|
||||
CreateTime *time.Time `json:"create_time,omitempty"`
|
||||
FinishTime *time.Time `json:"finish_time,omitempty"`
|
||||
DetailId *string `json:"detail_id,omitempty"`
|
||||
}
|
||||
|
||||
type ReceiverType string
|
||||
|
||||
func (e ReceiverType) Ptr() *ReceiverType { return &e }
|
||||
|
||||
const (
|
||||
RECEIVERTYPE_MERCHANT_ID ReceiverType = "MERCHANT_ID"
|
||||
RECEIVERTYPE_PERSONAL_OPENID ReceiverType = "PERSONAL_OPENID"
|
||||
)
|
||||
|
||||
type DetailStatus string
|
||||
|
||||
func (e DetailStatus) Ptr() *DetailStatus { return &e }
|
||||
|
||||
const (
|
||||
DETAILSTATUS_PENDING DetailStatus = "PENDING"
|
||||
DETAILSTATUS_SUCCESS DetailStatus = "SUCCESS"
|
||||
DETAILSTATUS_CLOSED DetailStatus = "CLOSED"
|
||||
)
|
||||
|
||||
type DetailFailReason string
|
||||
|
||||
func (e DetailFailReason) Ptr() *DetailFailReason { return &e }
|
||||
|
||||
const (
|
||||
DETAILFAILREASON_ACCOUNT_ABNORMAL DetailFailReason = "ACCOUNT_ABNORMAL"
|
||||
DETAILFAILREASON_NO_RELATION DetailFailReason = "NO_RELATION"
|
||||
DETAILFAILREASON_RECEIVER_HIGH_RISK DetailFailReason = "RECEIVER_HIGH_RISK"
|
||||
DETAILFAILREASON_RECEIVER_REAL_NAME_NOT_VERIFIED DetailFailReason = "RECEIVER_REAL_NAME_NOT_VERIFIED"
|
||||
DETAILFAILREASON_NO_AUTH DetailFailReason = "NO_AUTH"
|
||||
DETAILFAILREASON_RECEIVER_RECEIPT_LIMIT DetailFailReason = "RECEIVER_RECEIPT_LIMIT"
|
||||
DETAILFAILREASON_PAYER_ACCOUNT_ABNORMAL DetailFailReason = "PAYER_ACCOUNT_ABNORMAL"
|
||||
DETAILFAILREASON_INVALID_REQUEST DetailFailReason = "INVALID_REQUEST"
|
||||
)
|
||||
@@ -0,0 +1,159 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"demo/wxpay_utility" // 引用微信支付工具库,参考 https://pay.weixin.qq.com/doc/v3/merchant/4015119334
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"time"
|
||||
)
|
||||
|
||||
func main() {
|
||||
// TODO: 请准备商户开发必要参数,参考:https://pay.weixin.qq.com/doc/v3/merchant/4013070756
|
||||
config, err := wxpay_utility.CreateMchConfig(
|
||||
"19xxxxxxxx", // 商户号,是由微信支付系统生成并分配给每个商户的唯一标识符,商户号获取方式参考 https://pay.weixin.qq.com/doc/v3/merchant/4013070756
|
||||
"1DDE55AD98Exxxxxxxxxx", // 商户API证书序列号,如何获取请参考 https://pay.weixin.qq.com/doc/v3/merchant/4013053053
|
||||
"/path/to/apiclient_key.pem", // 商户API证书私钥文件路径,本地文件路径
|
||||
"PUB_KEY_ID_xxxxxxxxxxxxx", // 微信支付公钥ID,如何获取请参考 https://pay.weixin.qq.com/doc/v3/merchant/4013038816
|
||||
"/path/to/wxp_pub.pem", // 微信支付公钥文件路径,本地文件路径
|
||||
)
|
||||
if err != nil {
|
||||
fmt.Println(err)
|
||||
return
|
||||
}
|
||||
|
||||
request := &CreateReturnOrderRequest{
|
||||
OrderId: wxpay_utility.String("3008450740201411110007820472"),
|
||||
OutOrderNo: wxpay_utility.String("P20150806125346"),
|
||||
OutReturnNo: wxpay_utility.String("R20190516001"),
|
||||
ReturnMchid: wxpay_utility.String("86693852"),
|
||||
Amount: wxpay_utility.Int64(10),
|
||||
Description: wxpay_utility.String("用户退款"),
|
||||
}
|
||||
|
||||
response, err := CreateReturnOrder(config, request)
|
||||
if err != nil {
|
||||
fmt.Printf("请求失败: %+v\n", err)
|
||||
// TODO: 请求失败,根据状态码执行不同的处理
|
||||
return
|
||||
}
|
||||
|
||||
// TODO: 请求成功,继续业务逻辑
|
||||
fmt.Printf("请求成功: %+v\n", response)
|
||||
}
|
||||
|
||||
func CreateReturnOrder(config *wxpay_utility.MchConfig, request *CreateReturnOrderRequest) (response *ReturnOrdersEntity, err error) {
|
||||
const (
|
||||
host = "https://api.mch.weixin.qq.com"
|
||||
method = "POST"
|
||||
path = "/v3/profitsharing/return-orders"
|
||||
)
|
||||
|
||||
reqUrl, err := url.Parse(fmt.Sprintf("%s%s", host, path))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
reqBody, err := json.Marshal(request)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
httpRequest, err := http.NewRequest(method, reqUrl.String(), bytes.NewReader(reqBody))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
httpRequest.Header.Set("Accept", "application/json")
|
||||
httpRequest.Header.Set("Wechatpay-Serial", config.WechatPayPublicKeyId())
|
||||
httpRequest.Header.Set("Content-Type", "application/json")
|
||||
authorization, err := wxpay_utility.BuildAuthorization(config.MchId(), config.CertificateSerialNo(), config.PrivateKey(), method, reqUrl.RequestURI(), reqBody)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
httpRequest.Header.Set("Authorization", authorization)
|
||||
|
||||
client := &http.Client{}
|
||||
httpResponse, err := client.Do(httpRequest)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
respBody, err := wxpay_utility.ExtractResponseBody(httpResponse)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if httpResponse.StatusCode >= 200 && httpResponse.StatusCode < 300 {
|
||||
// 2XX 成功,验证应答签名
|
||||
err = wxpay_utility.ValidateResponse(
|
||||
config.WechatPayPublicKeyId(),
|
||||
config.WechatPayPublicKey(),
|
||||
&httpResponse.Header,
|
||||
respBody,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
response := &ReturnOrdersEntity{}
|
||||
if err := json.Unmarshal(respBody, response); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return response, nil
|
||||
} else {
|
||||
return nil, wxpay_utility.NewApiException(
|
||||
httpResponse.StatusCode,
|
||||
httpResponse.Header,
|
||||
respBody,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
type CreateReturnOrderRequest struct {
|
||||
OrderId *string `json:"order_id,omitempty"`
|
||||
OutOrderNo *string `json:"out_order_no,omitempty"`
|
||||
OutReturnNo *string `json:"out_return_no,omitempty"`
|
||||
ReturnMchid *string `json:"return_mchid,omitempty"`
|
||||
Amount *int64 `json:"amount,omitempty"`
|
||||
Description *string `json:"description,omitempty"`
|
||||
}
|
||||
|
||||
type ReturnOrdersEntity struct {
|
||||
OrderId *string `json:"order_id,omitempty"`
|
||||
OutOrderNo *string `json:"out_order_no,omitempty"`
|
||||
OutReturnNo *string `json:"out_return_no,omitempty"`
|
||||
ReturnId *string `json:"return_id,omitempty"`
|
||||
ReturnMchid *string `json:"return_mchid,omitempty"`
|
||||
Amount *int64 `json:"amount,omitempty"`
|
||||
Description *string `json:"description,omitempty"`
|
||||
Result *ReturnOrderStatus `json:"result,omitempty"`
|
||||
FailReason *ReturnOrderFailReason `json:"fail_reason,omitempty"`
|
||||
CreateTime *time.Time `json:"create_time,omitempty"`
|
||||
FinishTime *time.Time `json:"finish_time,omitempty"`
|
||||
}
|
||||
|
||||
type ReturnOrderStatus string
|
||||
|
||||
func (e ReturnOrderStatus) Ptr() *ReturnOrderStatus {
|
||||
return &e
|
||||
}
|
||||
|
||||
const (
|
||||
RETURNORDERSTATUS_PROCESSING ReturnOrderStatus = "PROCESSING"
|
||||
RETURNORDERSTATUS_SUCCESS ReturnOrderStatus = "SUCCESS"
|
||||
RETURNORDERSTATUS_FAILED ReturnOrderStatus = "FAILED"
|
||||
)
|
||||
|
||||
type ReturnOrderFailReason string
|
||||
|
||||
func (e ReturnOrderFailReason) Ptr() *ReturnOrderFailReason {
|
||||
return &e
|
||||
}
|
||||
|
||||
const (
|
||||
RETURNORDERFAILREASON_ACCOUNT_ABNORMAL ReturnOrderFailReason = "ACCOUNT_ABNORMAL"
|
||||
RETURNORDERFAILREASON_BALANCE_NOT_ENOUGH ReturnOrderFailReason = "BALANCE_NOT_ENOUGH"
|
||||
RETURNORDERFAILREASON_TIME_OUT_CLOSED ReturnOrderFailReason = "TIME_OUT_CLOSED"
|
||||
RETURNORDERFAILREASON_PAYER_ACCOUNT_ABNORMAL ReturnOrderFailReason = "PAYER_ACCOUNT_ABNORMAL"
|
||||
RETURNORDERFAILREASON_INVALID_REQUEST ReturnOrderFailReason = "INVALID_REQUEST"
|
||||
)
|
||||
|
||||
查询分账回退结果
|
||||
@@ -0,0 +1,128 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"demo/wxpay_utility" // 引用微信支付工具库,参考 https://pay.weixin.qq.com/doc/v3/merchant/4015119334
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/url"
|
||||
)
|
||||
|
||||
func main() {
|
||||
// TODO: 请准备商户开发必要参数,参考:https://pay.weixin.qq.com/doc/v3/merchant/4013070756
|
||||
config, err := wxpay_utility.CreateMchConfig(
|
||||
"19xxxxxxxx", // 商户号,是由微信支付系统生成并分配给每个商户的唯一标识符,商户号获取方式参考 https://pay.weixin.qq.com/doc/v3/merchant/4013070756
|
||||
"1DDE55AD98Exxxxxxxxxx", // 商户API证书序列号,如何获取请参考 https://pay.weixin.qq.com/doc/v3/merchant/4013053053
|
||||
"/path/to/apiclient_key.pem", // 商户API证书私钥文件路径,本地文件路径
|
||||
"PUB_KEY_ID_xxxxxxxxxxxxx", // 微信支付公钥ID,如何获取请参考 https://pay.weixin.qq.com/doc/v3/merchant/4013038816
|
||||
"/path/to/wxp_pub.pem", // 微信支付公钥文件路径,本地文件路径
|
||||
)
|
||||
if err != nil {
|
||||
fmt.Println(err)
|
||||
return
|
||||
}
|
||||
|
||||
request := &DeleteReceiverRequest{
|
||||
Appid: wxpay_utility.String("wx8888888888888888"),
|
||||
Type: RECEIVERTYPE_MERCHANT_ID.Ptr(),
|
||||
Account: wxpay_utility.String("1900000109"),
|
||||
}
|
||||
|
||||
response, err := DeleteReceiver(config, request)
|
||||
if err != nil {
|
||||
fmt.Printf("请求失败: %+v\n", err)
|
||||
// TODO: 请求失败,根据状态码执行不同的处理
|
||||
return
|
||||
}
|
||||
|
||||
// TODO: 请求成功,继续业务逻辑
|
||||
fmt.Printf("请求成功: %+v\n", response)
|
||||
}
|
||||
|
||||
func DeleteReceiver(config *wxpay_utility.MchConfig, request *DeleteReceiverRequest) (response *DeleteReceiverResponse, err error) {
|
||||
const (
|
||||
host = "https://api.mch.weixin.qq.com"
|
||||
method = "POST"
|
||||
path = "/v3/profitsharing/receivers/delete"
|
||||
)
|
||||
|
||||
reqUrl, err := url.Parse(fmt.Sprintf("%s%s", host, path))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
reqBody, err := json.Marshal(request)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
httpRequest, err := http.NewRequest(method, reqUrl.String(), bytes.NewReader(reqBody))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
httpRequest.Header.Set("Accept", "application/json")
|
||||
httpRequest.Header.Set("Wechatpay-Serial", config.WechatPayPublicKeyId())
|
||||
httpRequest.Header.Set("Content-Type", "application/json")
|
||||
authorization, err := wxpay_utility.BuildAuthorization(config.MchId(), config.CertificateSerialNo(), config.PrivateKey(), method, reqUrl.RequestURI(), reqBody)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
httpRequest.Header.Set("Authorization", authorization)
|
||||
|
||||
client := &http.Client{}
|
||||
httpResponse, err := client.Do(httpRequest)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
respBody, err := wxpay_utility.ExtractResponseBody(httpResponse)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if httpResponse.StatusCode >= 200 && httpResponse.StatusCode < 300 {
|
||||
// 2XX 成功,验证应答签名
|
||||
err = wxpay_utility.ValidateResponse(
|
||||
config.WechatPayPublicKeyId(),
|
||||
config.WechatPayPublicKey(),
|
||||
&httpResponse.Header,
|
||||
respBody,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
response := &DeleteReceiverResponse{}
|
||||
if err := json.Unmarshal(respBody, response); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return response, nil
|
||||
} else {
|
||||
return nil, wxpay_utility.NewApiException(
|
||||
httpResponse.StatusCode,
|
||||
httpResponse.Header,
|
||||
respBody,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
type DeleteReceiverRequest struct {
|
||||
Appid *string `json:"appid,omitempty"`
|
||||
Type *ReceiverType `json:"type,omitempty"`
|
||||
Account *string `json:"account,omitempty"`
|
||||
}
|
||||
|
||||
type DeleteReceiverResponse struct {
|
||||
Type *ReceiverType `json:"type,omitempty"`
|
||||
Account *string `json:"account,omitempty"`
|
||||
}
|
||||
|
||||
type ReceiverType string
|
||||
|
||||
func (e ReceiverType) Ptr() *ReceiverType {
|
||||
return &e
|
||||
}
|
||||
|
||||
const (
|
||||
RECEIVERTYPE_MERCHANT_ID ReceiverType = "MERCHANT_ID"
|
||||
RECEIVERTYPE_PERSONAL_OPENID ReceiverType = "PERSONAL_OPENID"
|
||||
)
|
||||
|
||||
申请分账账单
|
||||
@@ -0,0 +1,198 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"demo/wxpay_utility" // 引用微信支付工具库,参考 https://pay.weixin.qq.com/doc/v3/merchant/4015119334
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
func main() {
|
||||
// TODO: 请准备商户开发必要参数,参考:https://pay.weixin.qq.com/doc/v3/merchant/4013070756
|
||||
config, err := wxpay_utility.CreateMchConfig(
|
||||
"19xxxxxxxx", // 商户号,是由微信支付系统生成并分配给每个商户的唯一标识符,商户号获取方式参考 https://pay.weixin.qq.com/doc/v3/merchant/4013070756
|
||||
"1DDE55AD98Exxxxxxxxxx", // 商户API证书序列号,如何获取请参考 https://pay.weixin.qq.com/doc/v3/merchant/4013053053
|
||||
"/path/to/apiclient_key.pem", // 商户API证书私钥文件路径,本地文件路径
|
||||
"PUB_KEY_ID_xxxxxxxxxxxxx", // 微信支付公钥ID,如何获取请参考 https://pay.weixin.qq.com/doc/v3/merchant/4013038816
|
||||
"/path/to/wxp_pub.pem", // 微信支付公钥文件路径,本地文件路径
|
||||
)
|
||||
if err != nil {
|
||||
fmt.Println(err)
|
||||
return
|
||||
}
|
||||
|
||||
request := &QueryOrderRequest{
|
||||
TransactionId: wxpay_utility.String("4208450740201411110007820472"),
|
||||
OutOrderNo: wxpay_utility.String("P20150806125346"),
|
||||
}
|
||||
|
||||
response, err := QueryOrder(config, request)
|
||||
if err != nil {
|
||||
fmt.Printf("请求失败: %+v\n", err)
|
||||
// TODO: 请求失败,根据状态码执行不同的处理
|
||||
return
|
||||
}
|
||||
|
||||
// TODO: 请求成功,继续业务逻辑
|
||||
fmt.Printf("请求成功: %+v\n", response)
|
||||
}
|
||||
|
||||
func QueryOrder(config *wxpay_utility.MchConfig, request *QueryOrderRequest) (response *OrdersEntity, err error) {
|
||||
const (
|
||||
host = "https://api.mch.weixin.qq.com"
|
||||
method = "GET"
|
||||
path = "/v3/profitsharing/orders/{out_order_no}"
|
||||
)
|
||||
|
||||
reqUrl, err := url.Parse(fmt.Sprintf("%s%s", host, path))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
reqUrl.Path = strings.Replace(reqUrl.Path, "{out_order_no}", url.PathEscape(*request.OutOrderNo), -1)
|
||||
query := reqUrl.Query()
|
||||
if request.TransactionId != nil {
|
||||
query.Add("transaction_id", *request.TransactionId)
|
||||
}
|
||||
reqUrl.RawQuery = query.Encode()
|
||||
httpRequest, err := http.NewRequest(method, reqUrl.String(), nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
httpRequest.Header.Set("Accept", "application/json")
|
||||
httpRequest.Header.Set("Wechatpay-Serial", config.WechatPayPublicKeyId())
|
||||
authorization, err := wxpay_utility.BuildAuthorization(config.MchId(), config.CertificateSerialNo(), config.PrivateKey(), method, reqUrl.RequestURI(), nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
httpRequest.Header.Set("Authorization", authorization)
|
||||
|
||||
client := &http.Client{}
|
||||
httpResponse, err := client.Do(httpRequest)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
respBody, err := wxpay_utility.ExtractResponseBody(httpResponse)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if httpResponse.StatusCode >= 200 && httpResponse.StatusCode < 300 {
|
||||
// 2XX 成功,验证应答签名
|
||||
err = wxpay_utility.ValidateResponse(
|
||||
config.WechatPayPublicKeyId(),
|
||||
config.WechatPayPublicKey(),
|
||||
&httpResponse.Header,
|
||||
respBody,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
response := &OrdersEntity{}
|
||||
if err := json.Unmarshal(respBody, response); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return response, nil
|
||||
} else {
|
||||
return nil, wxpay_utility.NewApiException(
|
||||
httpResponse.StatusCode,
|
||||
httpResponse.Header,
|
||||
respBody,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
type QueryOrderRequest struct {
|
||||
TransactionId *string `json:"transaction_id,omitempty"`
|
||||
OutOrderNo *string `json:"out_order_no,omitempty"`
|
||||
}
|
||||
|
||||
func (o *QueryOrderRequest) MarshalJSON() ([]byte, error) {
|
||||
type Alias QueryOrderRequest
|
||||
a := &struct {
|
||||
TransactionId *string `json:"transaction_id,omitempty"`
|
||||
OutOrderNo *string `json:"out_order_no,omitempty"`
|
||||
*Alias
|
||||
}{
|
||||
// 序列化时移除非 Body 字段
|
||||
TransactionId: nil,
|
||||
OutOrderNo: nil,
|
||||
Alias: (*Alias)(o),
|
||||
}
|
||||
return json.Marshal(a)
|
||||
}
|
||||
|
||||
type OrdersEntity struct {
|
||||
TransactionId *string `json:"transaction_id,omitempty"`
|
||||
OutOrderNo *string `json:"out_order_no,omitempty"`
|
||||
OrderId *string `json:"order_id,omitempty"`
|
||||
State *OrderStatus `json:"state,omitempty"`
|
||||
Receivers []OrderReceiverDetail `json:"receivers,omitempty"`
|
||||
}
|
||||
|
||||
type OrderStatus string
|
||||
|
||||
func (e OrderStatus) Ptr() *OrderStatus {
|
||||
return &e
|
||||
}
|
||||
|
||||
const (
|
||||
ORDERSTATUS_PROCESSING OrderStatus = "PROCESSING"
|
||||
ORDERSTATUS_FINISHED OrderStatus = "FINISHED"
|
||||
)
|
||||
|
||||
type OrderReceiverDetail struct {
|
||||
Amount *int64 `json:"amount,omitempty"`
|
||||
Description *string `json:"description,omitempty"`
|
||||
Type *ReceiverType `json:"type,omitempty"`
|
||||
Account *string `json:"account,omitempty"`
|
||||
Result *DetailStatus `json:"result,omitempty"`
|
||||
FailReason *DetailFailReason `json:"fail_reason,omitempty"`
|
||||
CreateTime *time.Time `json:"create_time,omitempty"`
|
||||
FinishTime *time.Time `json:"finish_time,omitempty"`
|
||||
DetailId *string `json:"detail_id,omitempty"`
|
||||
}
|
||||
|
||||
type ReceiverType string
|
||||
|
||||
func (e ReceiverType) Ptr() *ReceiverType {
|
||||
return &e
|
||||
}
|
||||
|
||||
const (
|
||||
RECEIVERTYPE_MERCHANT_ID ReceiverType = "MERCHANT_ID"
|
||||
RECEIVERTYPE_PERSONAL_OPENID ReceiverType = "PERSONAL_OPENID"
|
||||
)
|
||||
|
||||
type DetailStatus string
|
||||
|
||||
func (e DetailStatus) Ptr() *DetailStatus {
|
||||
return &e
|
||||
}
|
||||
|
||||
const (
|
||||
DETAILSTATUS_PENDING DetailStatus = "PENDING"
|
||||
DETAILSTATUS_SUCCESS DetailStatus = "SUCCESS"
|
||||
DETAILSTATUS_CLOSED DetailStatus = "CLOSED"
|
||||
)
|
||||
|
||||
type DetailFailReason string
|
||||
|
||||
func (e DetailFailReason) Ptr() *DetailFailReason {
|
||||
return &e
|
||||
}
|
||||
|
||||
const (
|
||||
DETAILFAILREASON_ACCOUNT_ABNORMAL DetailFailReason = "ACCOUNT_ABNORMAL"
|
||||
DETAILFAILREASON_NO_RELATION DetailFailReason = "NO_RELATION"
|
||||
DETAILFAILREASON_RECEIVER_HIGH_RISK DetailFailReason = "RECEIVER_HIGH_RISK"
|
||||
DETAILFAILREASON_RECEIVER_REAL_NAME_NOT_VERIFIED DetailFailReason = "RECEIVER_REAL_NAME_NOT_VERIFIED"
|
||||
DETAILFAILREASON_NO_AUTH DetailFailReason = "NO_AUTH"
|
||||
DETAILFAILREASON_RECEIVER_RECEIPT_LIMIT DetailFailReason = "RECEIVER_RECEIPT_LIMIT"
|
||||
DETAILFAILREASON_PAYER_ACCOUNT_ABNORMAL DetailFailReason = "PAYER_ACCOUNT_ABNORMAL"
|
||||
DETAILFAILREASON_INVALID_REQUEST DetailFailReason = "INVALID_REQUEST"
|
||||
)
|
||||
|
||||
请求分账回退
|
||||
@@ -0,0 +1,122 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"demo/wxpay_utility" // 引用微信支付工具库,参考 https://pay.weixin.qq.com/doc/v3/merchant/4015119334
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
)
|
||||
|
||||
func main() {
|
||||
// TODO: 请准备商户开发必要参数,参考:https://pay.weixin.qq.com/doc/v3/merchant/4013070756
|
||||
config, err := wxpay_utility.CreateMchConfig(
|
||||
"19xxxxxxxx", // 商户号,是由微信支付系统生成并分配给每个商户的唯一标识符,商户号获取方式参考 https://pay.weixin.qq.com/doc/v3/merchant/4013070756
|
||||
"1DDE55AD98Exxxxxxxxxx", // 商户API证书序列号,如何获取请参考 https://pay.weixin.qq.com/doc/v3/merchant/4013053053
|
||||
"/path/to/apiclient_key.pem", // 商户API证书私钥文件路径,本地文件路径
|
||||
"PUB_KEY_ID_xxxxxxxxxxxxx", // 微信支付公钥ID,如何获取请参考 https://pay.weixin.qq.com/doc/v3/merchant/4013038816
|
||||
"/path/to/wxp_pub.pem", // 微信支付公钥文件路径,本地文件路径
|
||||
)
|
||||
if err != nil {
|
||||
fmt.Println(err)
|
||||
return
|
||||
}
|
||||
|
||||
request := &QueryOrderAmountRequest{
|
||||
TransactionId: wxpay_utility.String("4208450740201411110007820472"),
|
||||
}
|
||||
|
||||
response, err := QueryOrderAmount(config, request)
|
||||
if err != nil {
|
||||
fmt.Printf("请求失败: %+v\n", err)
|
||||
// TODO: 请求失败,根据状态码执行不同的处理
|
||||
return
|
||||
}
|
||||
|
||||
// TODO: 请求成功,继续业务逻辑
|
||||
fmt.Printf("请求成功: %+v\n", response)
|
||||
}
|
||||
|
||||
func QueryOrderAmount(config *wxpay_utility.MchConfig, request *QueryOrderAmountRequest) (response *QueryOrderAmountResponse, err error) {
|
||||
const (
|
||||
host = "https://api.mch.weixin.qq.com"
|
||||
method = "GET"
|
||||
path = "/v3/profitsharing/transactions/{transaction_id}/amounts"
|
||||
)
|
||||
|
||||
reqUrl, err := url.Parse(fmt.Sprintf("%s%s", host, path))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
reqUrl.Path = strings.Replace(reqUrl.Path, "{transaction_id}", url.PathEscape(*request.TransactionId), -1)
|
||||
httpRequest, err := http.NewRequest(method, reqUrl.String(), nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
httpRequest.Header.Set("Accept", "application/json")
|
||||
httpRequest.Header.Set("Wechatpay-Serial", config.WechatPayPublicKeyId())
|
||||
authorization, err := wxpay_utility.BuildAuthorization(config.MchId(), config.CertificateSerialNo(), config.PrivateKey(), method, reqUrl.RequestURI(), nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
httpRequest.Header.Set("Authorization", authorization)
|
||||
|
||||
client := &http.Client{}
|
||||
httpResponse, err := client.Do(httpRequest)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
respBody, err := wxpay_utility.ExtractResponseBody(httpResponse)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if httpResponse.StatusCode >= 200 && httpResponse.StatusCode < 300 {
|
||||
// 2XX 成功,验证应答签名
|
||||
err = wxpay_utility.ValidateResponse(
|
||||
config.WechatPayPublicKeyId(),
|
||||
config.WechatPayPublicKey(),
|
||||
&httpResponse.Header,
|
||||
respBody,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
response := &QueryOrderAmountResponse{}
|
||||
if err := json.Unmarshal(respBody, response); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return response, nil
|
||||
} else {
|
||||
return nil, wxpay_utility.NewApiException(
|
||||
httpResponse.StatusCode,
|
||||
httpResponse.Header,
|
||||
respBody,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
type QueryOrderAmountRequest struct {
|
||||
TransactionId *string `json:"transaction_id,omitempty"`
|
||||
}
|
||||
|
||||
func (o *QueryOrderAmountRequest) MarshalJSON() ([]byte, error) {
|
||||
type Alias QueryOrderAmountRequest
|
||||
a := &struct {
|
||||
TransactionId *string `json:"transaction_id,omitempty"`
|
||||
*Alias
|
||||
}{
|
||||
// 序列化时移除非 Body 字段
|
||||
TransactionId: nil,
|
||||
Alias: (*Alias)(o),
|
||||
}
|
||||
return json.Marshal(a)
|
||||
}
|
||||
|
||||
type QueryOrderAmountResponse struct {
|
||||
TransactionId *string `json:"transaction_id,omitempty"`
|
||||
UnsplitAmount *int64 `json:"unsplit_amount,omitempty"`
|
||||
}
|
||||
|
||||
添加分账接收方
|
||||
@@ -0,0 +1,167 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"demo/wxpay_utility" // 引用微信支付工具库,参考 https://pay.weixin.qq.com/doc/v3/merchant/4015119334
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
func main() {
|
||||
// TODO: 请准备商户开发必要参数,参考:https://pay.weixin.qq.com/doc/v3/merchant/4013070756
|
||||
config, err := wxpay_utility.CreateMchConfig(
|
||||
"19xxxxxxxx", // 商户号,是由微信支付系统生成并分配给每个商户的唯一标识符,商户号获取方式参考 https://pay.weixin.qq.com/doc/v3/merchant/4013070756
|
||||
"1DDE55AD98Exxxxxxxxxx", // 商户API证书序列号,如何获取请参考 https://pay.weixin.qq.com/doc/v3/merchant/4013053053
|
||||
"/path/to/apiclient_key.pem", // 商户API证书私钥文件路径,本地文件路径
|
||||
"PUB_KEY_ID_xxxxxxxxxxxxx", // 微信支付公钥ID,如何获取请参考 https://pay.weixin.qq.com/doc/v3/merchant/4013038816
|
||||
"/path/to/wxp_pub.pem", // 微信支付公钥文件路径,本地文件路径
|
||||
)
|
||||
if err != nil {
|
||||
fmt.Println(err)
|
||||
return
|
||||
}
|
||||
|
||||
request := &QueryReturnOrderRequest{
|
||||
OutReturnNo: wxpay_utility.String("R20190516001"),
|
||||
OutOrderNo: wxpay_utility.String("P20190806125346"),
|
||||
}
|
||||
|
||||
response, err := QueryReturnOrder(config, request)
|
||||
if err != nil {
|
||||
fmt.Printf("请求失败: %+v\n", err)
|
||||
// TODO: 请求失败,根据状态码执行不同的处理
|
||||
return
|
||||
}
|
||||
|
||||
// TODO: 请求成功,继续业务逻辑
|
||||
fmt.Printf("请求成功: %+v\n", response)
|
||||
}
|
||||
|
||||
func QueryReturnOrder(config *wxpay_utility.MchConfig, request *QueryReturnOrderRequest) (response *ReturnOrdersEntity, err error) {
|
||||
const (
|
||||
host = "https://api.mch.weixin.qq.com"
|
||||
method = "GET"
|
||||
path = "/v3/profitsharing/return-orders/{out_return_no}"
|
||||
)
|
||||
|
||||
reqUrl, err := url.Parse(fmt.Sprintf("%s%s", host, path))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
reqUrl.Path = strings.Replace(reqUrl.Path, "{out_return_no}", url.PathEscape(*request.OutReturnNo), -1)
|
||||
query := reqUrl.Query()
|
||||
if request.OutOrderNo != nil {
|
||||
query.Add("out_order_no", *request.OutOrderNo)
|
||||
}
|
||||
reqUrl.RawQuery = query.Encode()
|
||||
httpRequest, err := http.NewRequest(method, reqUrl.String(), nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
httpRequest.Header.Set("Accept", "application/json")
|
||||
httpRequest.Header.Set("Wechatpay-Serial", config.WechatPayPublicKeyId())
|
||||
authorization, err := wxpay_utility.BuildAuthorization(config.MchId(), config.CertificateSerialNo(), config.PrivateKey(), method, reqUrl.RequestURI(), nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
httpRequest.Header.Set("Authorization", authorization)
|
||||
|
||||
client := &http.Client{}
|
||||
httpResponse, err := client.Do(httpRequest)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
respBody, err := wxpay_utility.ExtractResponseBody(httpResponse)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if httpResponse.StatusCode >= 200 && httpResponse.StatusCode < 300 {
|
||||
// 2XX 成功,验证应答签名
|
||||
err = wxpay_utility.ValidateResponse(
|
||||
config.WechatPayPublicKeyId(),
|
||||
config.WechatPayPublicKey(),
|
||||
&httpResponse.Header,
|
||||
respBody,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
response := &ReturnOrdersEntity{}
|
||||
if err := json.Unmarshal(respBody, response); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return response, nil
|
||||
} else {
|
||||
return nil, wxpay_utility.NewApiException(
|
||||
httpResponse.StatusCode,
|
||||
httpResponse.Header,
|
||||
respBody,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
type QueryReturnOrderRequest struct {
|
||||
OutReturnNo *string `json:"out_return_no,omitempty"`
|
||||
OutOrderNo *string `json:"out_order_no,omitempty"`
|
||||
}
|
||||
|
||||
func (o *QueryReturnOrderRequest) MarshalJSON() ([]byte, error) {
|
||||
type Alias QueryReturnOrderRequest
|
||||
a := &struct {
|
||||
OutReturnNo *string `json:"out_return_no,omitempty"`
|
||||
OutOrderNo *string `json:"out_order_no,omitempty"`
|
||||
*Alias
|
||||
}{
|
||||
// 序列化时移除非 Body 字段
|
||||
OutReturnNo: nil,
|
||||
OutOrderNo: nil,
|
||||
Alias: (*Alias)(o),
|
||||
}
|
||||
return json.Marshal(a)
|
||||
}
|
||||
|
||||
type ReturnOrdersEntity struct {
|
||||
OrderId *string `json:"order_id,omitempty"`
|
||||
OutOrderNo *string `json:"out_order_no,omitempty"`
|
||||
OutReturnNo *string `json:"out_return_no,omitempty"`
|
||||
ReturnId *string `json:"return_id,omitempty"`
|
||||
ReturnMchid *string `json:"return_mchid,omitempty"`
|
||||
Amount *int64 `json:"amount,omitempty"`
|
||||
Description *string `json:"description,omitempty"`
|
||||
Result *ReturnOrderStatus `json:"result,omitempty"`
|
||||
FailReason *ReturnOrderFailReason `json:"fail_reason,omitempty"`
|
||||
CreateTime *time.Time `json:"create_time,omitempty"`
|
||||
FinishTime *time.Time `json:"finish_time,omitempty"`
|
||||
}
|
||||
|
||||
type ReturnOrderStatus string
|
||||
|
||||
func (e ReturnOrderStatus) Ptr() *ReturnOrderStatus {
|
||||
return &e
|
||||
}
|
||||
|
||||
const (
|
||||
RETURNORDERSTATUS_PROCESSING ReturnOrderStatus = "PROCESSING"
|
||||
RETURNORDERSTATUS_SUCCESS ReturnOrderStatus = "SUCCESS"
|
||||
RETURNORDERSTATUS_FAILED ReturnOrderStatus = "FAILED"
|
||||
)
|
||||
|
||||
type ReturnOrderFailReason string
|
||||
|
||||
func (e ReturnOrderFailReason) Ptr() *ReturnOrderFailReason {
|
||||
return &e
|
||||
}
|
||||
|
||||
const (
|
||||
RETURNORDERFAILREASON_ACCOUNT_ABNORMAL ReturnOrderFailReason = "ACCOUNT_ABNORMAL"
|
||||
RETURNORDERFAILREASON_BALANCE_NOT_ENOUGH ReturnOrderFailReason = "BALANCE_NOT_ENOUGH"
|
||||
RETURNORDERFAILREASON_TIME_OUT_CLOSED ReturnOrderFailReason = "TIME_OUT_CLOSED"
|
||||
RETURNORDERFAILREASON_PAYER_ACCOUNT_ABNORMAL ReturnOrderFailReason = "PAYER_ACCOUNT_ABNORMAL"
|
||||
RETURNORDERFAILREASON_INVALID_REQUEST ReturnOrderFailReason = "INVALID_REQUEST"
|
||||
)
|
||||
|
||||
解冻剩余资金
|
||||
@@ -0,0 +1,151 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"demo/wxpay_utility" // 引用微信支付工具库,参考 https://pay.weixin.qq.com/doc/v3/merchant/4015119334
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/url"
|
||||
)
|
||||
|
||||
func main() {
|
||||
// TODO: 请准备商户开发必要参数,参考:https://pay.weixin.qq.com/doc/v3/merchant/4013070756
|
||||
config, err := wxpay_utility.CreateMchConfig(
|
||||
"19xxxxxxxx", // 商户号,是由微信支付系统生成并分配给每个商户的唯一标识符,商户号获取方式参考 https://pay.weixin.qq.com/doc/v3/merchant/4013070756
|
||||
"1DDE55AD98Exxxxxxxxxx", // 商户API证书序列号,如何获取请参考 https://pay.weixin.qq.com/doc/v3/merchant/4013053053
|
||||
"/path/to/apiclient_key.pem", // 商户API证书私钥文件路径,本地文件路径
|
||||
"PUB_KEY_ID_xxxxxxxxxxxxx", // 微信支付公钥ID,如何获取请参考 https://pay.weixin.qq.com/doc/v3/merchant/4013038816
|
||||
"/path/to/wxp_pub.pem", // 微信支付公钥文件路径,本地文件路径
|
||||
)
|
||||
if err != nil {
|
||||
fmt.Println(err)
|
||||
return
|
||||
}
|
||||
|
||||
request := &SplitBillRequest{
|
||||
BillDate: wxpay_utility.String("2019-06-11"),
|
||||
TarType: SPLITBILLTARTYPE_GZIP.Ptr(),
|
||||
}
|
||||
|
||||
response, err := SplitBill(config, request)
|
||||
if err != nil {
|
||||
fmt.Printf("请求失败: %+v\n", err)
|
||||
// TODO: 请求失败,根据状态码执行不同的处理
|
||||
return
|
||||
}
|
||||
|
||||
// TODO: 请求成功,继续业务逻辑
|
||||
fmt.Printf("请求成功: %+v\n", response)
|
||||
}
|
||||
|
||||
func SplitBill(config *wxpay_utility.MchConfig, request *SplitBillRequest) (response *SplitBillResponse, err error) {
|
||||
const (
|
||||
host = "https://api.mch.weixin.qq.com"
|
||||
method = "GET"
|
||||
path = "/v3/profitsharing/bills"
|
||||
)
|
||||
|
||||
reqUrl, err := url.Parse(fmt.Sprintf("%s%s", host, path))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
query := reqUrl.Query()
|
||||
if request.BillDate != nil {
|
||||
query.Add("bill_date", *request.BillDate)
|
||||
}
|
||||
if request.TarType != nil {
|
||||
query.Add("tar_type", fmt.Sprintf("%v", *request.TarType))
|
||||
}
|
||||
reqUrl.RawQuery = query.Encode()
|
||||
httpRequest, err := http.NewRequest(method, reqUrl.String(), nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
httpRequest.Header.Set("Accept", "application/json")
|
||||
httpRequest.Header.Set("Wechatpay-Serial", config.WechatPayPublicKeyId())
|
||||
authorization, err := wxpay_utility.BuildAuthorization(config.MchId(), config.CertificateSerialNo(), config.PrivateKey(), method, reqUrl.RequestURI(), nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
httpRequest.Header.Set("Authorization", authorization)
|
||||
|
||||
client := &http.Client{}
|
||||
httpResponse, err := client.Do(httpRequest)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
respBody, err := wxpay_utility.ExtractResponseBody(httpResponse)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if httpResponse.StatusCode >= 200 && httpResponse.StatusCode < 300 {
|
||||
// 2XX 成功,验证应答签名
|
||||
err = wxpay_utility.ValidateResponse(
|
||||
config.WechatPayPublicKeyId(),
|
||||
config.WechatPayPublicKey(),
|
||||
&httpResponse.Header,
|
||||
respBody,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
response := &SplitBillResponse{}
|
||||
if err := json.Unmarshal(respBody, response); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return response, nil
|
||||
} else {
|
||||
return nil, wxpay_utility.NewApiException(
|
||||
httpResponse.StatusCode,
|
||||
httpResponse.Header,
|
||||
respBody,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
type SplitBillRequest struct {
|
||||
BillDate *string `json:"bill_date,omitempty"`
|
||||
TarType *SplitBillTarType `json:"tar_type,omitempty"`
|
||||
}
|
||||
|
||||
func (o *SplitBillRequest) MarshalJSON() ([]byte, error) {
|
||||
type Alias SplitBillRequest
|
||||
a := &struct {
|
||||
BillDate *string `json:"bill_date,omitempty"`
|
||||
TarType *SplitBillTarType `json:"tar_type,omitempty"`
|
||||
*Alias
|
||||
}{
|
||||
// 序列化时移除非 Body 字段
|
||||
BillDate: nil,
|
||||
TarType: nil,
|
||||
Alias: (*Alias)(o),
|
||||
}
|
||||
return json.Marshal(a)
|
||||
}
|
||||
|
||||
type SplitBillResponse struct {
|
||||
HashType *SplitBillHashType `json:"hash_type,omitempty"`
|
||||
HashValue *string `json:"hash_value,omitempty"`
|
||||
DownloadUrl *string `json:"download_url,omitempty"`
|
||||
}
|
||||
|
||||
type SplitBillTarType string
|
||||
|
||||
func (e SplitBillTarType) Ptr() *SplitBillTarType {
|
||||
return &e
|
||||
}
|
||||
|
||||
const (
|
||||
SPLITBILLTARTYPE_GZIP SplitBillTarType = "GZIP"
|
||||
)
|
||||
|
||||
type SplitBillHashType string
|
||||
|
||||
func (e SplitBillHashType) Ptr() *SplitBillHashType {
|
||||
return &e
|
||||
}
|
||||
|
||||
const (
|
||||
SPLITBILLHASHTYPE_SHA1 SplitBillHashType = "SHA1"
|
||||
)
|
||||
@@ -0,0 +1,184 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"demo/wxpay_utility" // 引用微信支付工具库,参考 https://pay.weixin.qq.com/doc/v3/merchant/4015119334
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"time"
|
||||
)
|
||||
|
||||
func main() {
|
||||
// TODO: 请准备商户开发必要参数,参考:https://pay.weixin.qq.com/doc/v3/merchant/4013070756
|
||||
config, err := wxpay_utility.CreateMchConfig(
|
||||
"19xxxxxxxx", // 商户号,是由微信支付系统生成并分配给每个商户的唯一标识符,商户号获取方式参考 https://pay.weixin.qq.com/doc/v3/merchant/4013070756
|
||||
"1DDE55AD98Exxxxxxxxxx", // 商户API证书序列号,如何获取请参考 https://pay.weixin.qq.com/doc/v3/merchant/4013053053
|
||||
"/path/to/apiclient_key.pem", // 商户API证书私钥文件路径,本地文件路径
|
||||
"PUB_KEY_ID_xxxxxxxxxxxxx", // 微信支付公钥ID,如何获取请参考 https://pay.weixin.qq.com/doc/v3/merchant/4013038816
|
||||
"/path/to/wxp_pub.pem", // 微信支付公钥文件路径,本地文件路径
|
||||
)
|
||||
if err != nil {
|
||||
fmt.Println(err)
|
||||
return
|
||||
}
|
||||
|
||||
request := &UnfreezeOrderRequest{
|
||||
TransactionId: wxpay_utility.String("4208450740201411110007820472"),
|
||||
OutOrderNo: wxpay_utility.String("P20150806125346"),
|
||||
Description: wxpay_utility.String("解冻全部剩余资金"),
|
||||
}
|
||||
|
||||
response, err := UnfreezeOrder(config, request)
|
||||
if err != nil {
|
||||
fmt.Printf("请求失败: %+v\n", err)
|
||||
// TODO: 请求失败,根据状态码执行不同的处理
|
||||
return
|
||||
}
|
||||
|
||||
// TODO: 请求成功,继续业务逻辑
|
||||
fmt.Printf("请求成功: %+v\n", response)
|
||||
}
|
||||
|
||||
func UnfreezeOrder(config *wxpay_utility.MchConfig, request *UnfreezeOrderRequest) (response *OrdersEntity, err error) {
|
||||
const (
|
||||
host = "https://api.mch.weixin.qq.com"
|
||||
method = "POST"
|
||||
path = "/v3/profitsharing/orders/unfreeze"
|
||||
)
|
||||
|
||||
reqUrl, err := url.Parse(fmt.Sprintf("%s%s", host, path))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
reqBody, err := json.Marshal(request)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
httpRequest, err := http.NewRequest(method, reqUrl.String(), bytes.NewReader(reqBody))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
httpRequest.Header.Set("Accept", "application/json")
|
||||
httpRequest.Header.Set("Wechatpay-Serial", config.WechatPayPublicKeyId())
|
||||
httpRequest.Header.Set("Content-Type", "application/json")
|
||||
authorization, err := wxpay_utility.BuildAuthorization(config.MchId(), config.CertificateSerialNo(), config.PrivateKey(), method, reqUrl.RequestURI(), reqBody)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
httpRequest.Header.Set("Authorization", authorization)
|
||||
|
||||
client := &http.Client{}
|
||||
httpResponse, err := client.Do(httpRequest)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
respBody, err := wxpay_utility.ExtractResponseBody(httpResponse)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if httpResponse.StatusCode >= 200 && httpResponse.StatusCode < 300 {
|
||||
// 2XX 成功,验证应答签名
|
||||
err = wxpay_utility.ValidateResponse(
|
||||
config.WechatPayPublicKeyId(),
|
||||
config.WechatPayPublicKey(),
|
||||
&httpResponse.Header,
|
||||
respBody,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
response := &OrdersEntity{}
|
||||
if err := json.Unmarshal(respBody, response); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return response, nil
|
||||
} else {
|
||||
return nil, wxpay_utility.NewApiException(
|
||||
httpResponse.StatusCode,
|
||||
httpResponse.Header,
|
||||
respBody,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
type UnfreezeOrderRequest struct {
|
||||
TransactionId *string `json:"transaction_id,omitempty"`
|
||||
OutOrderNo *string `json:"out_order_no,omitempty"`
|
||||
Description *string `json:"description,omitempty"`
|
||||
}
|
||||
|
||||
type OrdersEntity struct {
|
||||
TransactionId *string `json:"transaction_id,omitempty"`
|
||||
OutOrderNo *string `json:"out_order_no,omitempty"`
|
||||
OrderId *string `json:"order_id,omitempty"`
|
||||
State *OrderStatus `json:"state,omitempty"`
|
||||
Receivers []OrderReceiverDetail `json:"receivers,omitempty"`
|
||||
}
|
||||
|
||||
type OrderStatus string
|
||||
|
||||
func (e OrderStatus) Ptr() *OrderStatus {
|
||||
return &e
|
||||
}
|
||||
|
||||
const (
|
||||
ORDERSTATUS_PROCESSING OrderStatus = "PROCESSING"
|
||||
ORDERSTATUS_FINISHED OrderStatus = "FINISHED"
|
||||
)
|
||||
|
||||
type OrderReceiverDetail struct {
|
||||
Amount *int64 `json:"amount,omitempty"`
|
||||
Description *string `json:"description,omitempty"`
|
||||
Type *ReceiverType `json:"type,omitempty"`
|
||||
Account *string `json:"account,omitempty"`
|
||||
Result *DetailStatus `json:"result,omitempty"`
|
||||
FailReason *DetailFailReason `json:"fail_reason,omitempty"`
|
||||
CreateTime *time.Time `json:"create_time,omitempty"`
|
||||
FinishTime *time.Time `json:"finish_time,omitempty"`
|
||||
DetailId *string `json:"detail_id,omitempty"`
|
||||
}
|
||||
|
||||
type ReceiverType string
|
||||
|
||||
func (e ReceiverType) Ptr() *ReceiverType {
|
||||
return &e
|
||||
}
|
||||
|
||||
const (
|
||||
RECEIVERTYPE_MERCHANT_ID ReceiverType = "MERCHANT_ID"
|
||||
RECEIVERTYPE_PERSONAL_OPENID ReceiverType = "PERSONAL_OPENID"
|
||||
)
|
||||
|
||||
type DetailStatus string
|
||||
|
||||
func (e DetailStatus) Ptr() *DetailStatus {
|
||||
return &e
|
||||
}
|
||||
|
||||
const (
|
||||
DETAILSTATUS_PENDING DetailStatus = "PENDING"
|
||||
DETAILSTATUS_SUCCESS DetailStatus = "SUCCESS"
|
||||
DETAILSTATUS_CLOSED DetailStatus = "CLOSED"
|
||||
)
|
||||
|
||||
type DetailFailReason string
|
||||
|
||||
func (e DetailFailReason) Ptr() *DetailFailReason {
|
||||
return &e
|
||||
}
|
||||
|
||||
const (
|
||||
DETAILFAILREASON_ACCOUNT_ABNORMAL DetailFailReason = "ACCOUNT_ABNORMAL"
|
||||
DETAILFAILREASON_NO_RELATION DetailFailReason = "NO_RELATION"
|
||||
DETAILFAILREASON_RECEIVER_HIGH_RISK DetailFailReason = "RECEIVER_HIGH_RISK"
|
||||
DETAILFAILREASON_RECEIVER_REAL_NAME_NOT_VERIFIED DetailFailReason = "RECEIVER_REAL_NAME_NOT_VERIFIED"
|
||||
DETAILFAILREASON_NO_AUTH DetailFailReason = "NO_AUTH"
|
||||
DETAILFAILREASON_RECEIVER_RECEIPT_LIMIT DetailFailReason = "RECEIVER_RECEIPT_LIMIT"
|
||||
DETAILFAILREASON_PAYER_ACCOUNT_ABNORMAL DetailFailReason = "PAYER_ACCOUNT_ABNORMAL"
|
||||
DETAILFAILREASON_INVALID_REQUEST DetailFailReason = "INVALID_REQUEST"
|
||||
)
|
||||
|
||||
查询剩余待分金额
|
||||
@@ -0,0 +1,196 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"demo/wxpay_utility" // 引用微信支付工具库,参考 https://pay.weixin.qq.com/doc/v3/merchant/4015119334
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"time"
|
||||
)
|
||||
|
||||
func main() {
|
||||
// TODO: 请准备商户开发必要参数,参考:https://pay.weixin.qq.com/doc/v3/merchant/4013070756
|
||||
config, err := wxpay_utility.CreateMchConfig(
|
||||
"19xxxxxxxx", // 商户号,是由微信支付系统生成并分配给每个商户的唯一标识符,商户号获取方式参考 https://pay.weixin.qq.com/doc/v3/merchant/4013070756
|
||||
"1DDE55AD98Exxxxxxxxxx", // 商户API证书序列号,如何获取请参考 https://pay.weixin.qq.com/doc/v3/merchant/4013053053
|
||||
"/path/to/apiclient_key.pem", // 商户API证书私钥文件路径,本地文件路径
|
||||
"PUB_KEY_ID_xxxxxxxxxxxxx", // 微信支付公钥ID,如何获取请参考 https://pay.weixin.qq.com/doc/v3/merchant/4013038816
|
||||
"/path/to/wxp_pub.pem", // 微信支付公钥文件路径,本地文件路径
|
||||
)
|
||||
if err != nil {
|
||||
fmt.Println(err)
|
||||
return
|
||||
}
|
||||
|
||||
request := &CommonPrepayRequest{
|
||||
Appid: wxpay_utility.String("wxd678efh567hg6787"),
|
||||
Mchid: wxpay_utility.String("1230000109"),
|
||||
Description: wxpay_utility.String("Image形象店-深圳腾大-QQ公仔"),
|
||||
OutTradeNo: wxpay_utility.String("1217752501201407033233368018"),
|
||||
TimeExpire: wxpay_utility.Time(time.Now()),
|
||||
Attach: wxpay_utility.String("自定义数据说明"),
|
||||
NotifyUrl: wxpay_utility.String(" https://www.weixin.qq.com/wxpay/pay.php"),
|
||||
GoodsTag: wxpay_utility.String("WXG"),
|
||||
SupportFapiao: wxpay_utility.Bool(false),
|
||||
Amount: &CommonAmountInfo{
|
||||
Total: wxpay_utility.Int64(100),
|
||||
Currency: wxpay_utility.String("CNY"),
|
||||
},
|
||||
Detail: &CouponInfo{
|
||||
CostPrice: wxpay_utility.Int64(608800),
|
||||
InvoiceId: wxpay_utility.String("微信123"),
|
||||
GoodsDetail: []GoodsDetail{GoodsDetail{
|
||||
MerchantGoodsId: wxpay_utility.String("1246464644"),
|
||||
WechatpayGoodsId: wxpay_utility.String("1001"),
|
||||
GoodsName: wxpay_utility.String("iPhoneX 256G"),
|
||||
Quantity: wxpay_utility.Int64(1),
|
||||
UnitPrice: wxpay_utility.Int64(528800),
|
||||
}},
|
||||
},
|
||||
SceneInfo: &CommonSceneInfo{
|
||||
PayerClientIp: wxpay_utility.String("14.23.150.211"),
|
||||
DeviceId: wxpay_utility.String("013467007045764"),
|
||||
StoreInfo: &StoreInfo{
|
||||
Id: wxpay_utility.String("0001"),
|
||||
Name: wxpay_utility.String("腾讯大厦分店"),
|
||||
AreaCode: wxpay_utility.String("440305"),
|
||||
Address: wxpay_utility.String("广东省深圳市南山区科技中一道10000号"),
|
||||
},
|
||||
},
|
||||
SettleInfo: &SettleInfo{
|
||||
ProfitSharing: wxpay_utility.Bool(false),
|
||||
},
|
||||
}
|
||||
|
||||
response, err := AppPrepay(config, request)
|
||||
if err != nil {
|
||||
fmt.Printf("请求失败: %+v\n", err)
|
||||
// TODO: 请求失败,根据状态码执行不同的处理
|
||||
return
|
||||
}
|
||||
|
||||
// TODO: 请求成功,继续业务逻辑
|
||||
fmt.Printf("请求成功: %+v\n", response)
|
||||
}
|
||||
|
||||
// AppPrepay App下单
|
||||
func AppPrepay(config *wxpay_utility.MchConfig, request *CommonPrepayRequest) (response *DirectApiv3AppPrepayResponse, err error) {
|
||||
const (
|
||||
host = "https://api.mch.weixin.qq.com"
|
||||
method = "POST"
|
||||
path = "/v3/pay/transactions/app"
|
||||
)
|
||||
|
||||
reqUrl, err := url.Parse(fmt.Sprintf("%s%s", host, path))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
reqBody, err := json.Marshal(request)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
httpRequest, err := http.NewRequest(method, reqUrl.String(), bytes.NewReader(reqBody))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
httpRequest.Header.Set("Accept", "application/json")
|
||||
httpRequest.Header.Set("Wechatpay-Serial", config.WechatPayPublicKeyId())
|
||||
httpRequest.Header.Set("Content-Type", "application/json")
|
||||
authorization, err := wxpay_utility.BuildAuthorization(config.MchId(), config.CertificateSerialNo(), config.PrivateKey(), method, reqUrl.RequestURI(), reqBody)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
httpRequest.Header.Set("Authorization", authorization)
|
||||
|
||||
client := &http.Client{}
|
||||
httpResponse, err := client.Do(httpRequest)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
respBody, err := wxpay_utility.ExtractResponseBody(httpResponse)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if httpResponse.StatusCode >= 200 && httpResponse.StatusCode < 300 {
|
||||
// 2XX 成功,验证应答签名
|
||||
err = wxpay_utility.ValidateResponse(
|
||||
config.WechatPayPublicKeyId(),
|
||||
config.WechatPayPublicKey(),
|
||||
&httpResponse.Header,
|
||||
respBody,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
response := &DirectApiv3AppPrepayResponse{}
|
||||
if err := json.Unmarshal(respBody, response); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return response, nil
|
||||
} else {
|
||||
return nil, wxpay_utility.NewApiException(
|
||||
httpResponse.StatusCode,
|
||||
httpResponse.Header,
|
||||
respBody,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
type CommonPrepayRequest struct {
|
||||
Appid *string `json:"appid,omitempty"`
|
||||
Mchid *string `json:"mchid,omitempty"`
|
||||
Description *string `json:"description,omitempty"`
|
||||
OutTradeNo *string `json:"out_trade_no,omitempty"`
|
||||
TimeExpire *time.Time `json:"time_expire,omitempty"`
|
||||
Attach *string `json:"attach,omitempty"`
|
||||
NotifyUrl *string `json:"notify_url,omitempty"`
|
||||
GoodsTag *string `json:"goods_tag,omitempty"`
|
||||
SupportFapiao *bool `json:"support_fapiao,omitempty"`
|
||||
Amount *CommonAmountInfo `json:"amount,omitempty"`
|
||||
Detail *CouponInfo `json:"detail,omitempty"`
|
||||
SceneInfo *CommonSceneInfo `json:"scene_info,omitempty"`
|
||||
SettleInfo *SettleInfo `json:"settle_info,omitempty"`
|
||||
}
|
||||
|
||||
type DirectApiv3AppPrepayResponse struct {
|
||||
PrepayId *string `json:"prepay_id,omitempty"`
|
||||
}
|
||||
|
||||
type CommonAmountInfo struct {
|
||||
Total *int64 `json:"total,omitempty"`
|
||||
Currency *string `json:"currency,omitempty"`
|
||||
}
|
||||
|
||||
type CouponInfo struct {
|
||||
CostPrice *int64 `json:"cost_price,omitempty"`
|
||||
InvoiceId *string `json:"invoice_id,omitempty"`
|
||||
GoodsDetail []GoodsDetail `json:"goods_detail,omitempty"`
|
||||
}
|
||||
|
||||
type CommonSceneInfo struct {
|
||||
PayerClientIp *string `json:"payer_client_ip,omitempty"`
|
||||
DeviceId *string `json:"device_id,omitempty"`
|
||||
StoreInfo *StoreInfo `json:"store_info,omitempty"`
|
||||
}
|
||||
|
||||
type SettleInfo struct {
|
||||
ProfitSharing *bool `json:"profit_sharing,omitempty"`
|
||||
}
|
||||
|
||||
type GoodsDetail struct {
|
||||
MerchantGoodsId *string `json:"merchant_goods_id,omitempty"`
|
||||
WechatpayGoodsId *string `json:"wechatpay_goods_id,omitempty"`
|
||||
GoodsName *string `json:"goods_name,omitempty"`
|
||||
Quantity *int64 `json:"quantity,omitempty"`
|
||||
UnitPrice *int64 `json:"unit_price,omitempty"`
|
||||
}
|
||||
|
||||
type StoreInfo struct {
|
||||
Id *string `json:"id,omitempty"`
|
||||
Name *string `json:"name,omitempty"`
|
||||
AreaCode *string `json:"area_code,omitempty"`
|
||||
Address *string `json:"address,omitempty"`
|
||||
}
|
||||
@@ -0,0 +1,212 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"demo/wxpay_utility" // 引用微信支付工具库,参考 https://pay.weixin.qq.com/doc/v3/merchant/4015119334
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"time"
|
||||
)
|
||||
|
||||
func main() {
|
||||
// TODO: 请准备商户开发必要参数,参考:https://pay.weixin.qq.com/doc/v3/merchant/4013070756
|
||||
config, err := wxpay_utility.CreateMchConfig(
|
||||
"19xxxxxxxx", // 商户号,是由微信支付系统生成并分配给每个商户的唯一标识符,商户号获取方式参考 https://pay.weixin.qq.com/doc/v3/merchant/4013070756
|
||||
"1DDE55AD98Exxxxxxxxxx", // 商户API证书序列号,如何获取请参考 https://pay.weixin.qq.com/doc/v3/merchant/4013053053
|
||||
"/path/to/apiclient_key.pem", // 商户API证书私钥文件路径,本地文件路径
|
||||
"PUB_KEY_ID_xxxxxxxxxxxxx", // 微信支付公钥ID,如何获取请参考 https://pay.weixin.qq.com/doc/v3/merchant/4013038816
|
||||
"/path/to/wxp_pub.pem", // 微信支付公钥文件路径,本地文件路径
|
||||
)
|
||||
if err != nil {
|
||||
fmt.Println(err)
|
||||
return
|
||||
}
|
||||
|
||||
request := &DirectApiv3H5PrepayRequest{
|
||||
Appid: wxpay_utility.String("wxd678efh567hg6787"),
|
||||
Mchid: wxpay_utility.String("1230000109"),
|
||||
Description: wxpay_utility.String("Image形象店-深圳腾大-QQ公仔"),
|
||||
OutTradeNo: wxpay_utility.String("1217752501201407033233368018"),
|
||||
TimeExpire: wxpay_utility.Time(time.Now()),
|
||||
Attach: wxpay_utility.String("自定义数据说明"),
|
||||
NotifyUrl: wxpay_utility.String(" https://www.weixin.qq.com/wxpay/pay.php"),
|
||||
GoodsTag: wxpay_utility.String("WXG"),
|
||||
SupportFapiao: wxpay_utility.Bool(false),
|
||||
Amount: &CommonAmountInfo{
|
||||
Total: wxpay_utility.Int64(100),
|
||||
Currency: wxpay_utility.String("CNY"),
|
||||
},
|
||||
Detail: &CouponInfo{
|
||||
CostPrice: wxpay_utility.Int64(608800),
|
||||
InvoiceId: wxpay_utility.String("微信123"),
|
||||
GoodsDetail: []GoodsDetail{GoodsDetail{
|
||||
MerchantGoodsId: wxpay_utility.String("1246464644"),
|
||||
WechatpayGoodsId: wxpay_utility.String("1001"),
|
||||
GoodsName: wxpay_utility.String("iPhoneX 256G"),
|
||||
Quantity: wxpay_utility.Int64(1),
|
||||
UnitPrice: wxpay_utility.Int64(528800),
|
||||
}},
|
||||
},
|
||||
SceneInfo: &H5ReqSceneInfo{
|
||||
PayerClientIp: wxpay_utility.String("14.23.150.211"),
|
||||
DeviceId: wxpay_utility.String("013467007045764"),
|
||||
StoreInfo: &StoreInfo{
|
||||
Id: wxpay_utility.String("0001"),
|
||||
Name: wxpay_utility.String("腾讯大厦分店"),
|
||||
AreaCode: wxpay_utility.String("440305"),
|
||||
Address: wxpay_utility.String("广东省深圳市南山区科技中一道10000号"),
|
||||
},
|
||||
H5Info: &H5Info{
|
||||
Type: wxpay_utility.String("iOS"),
|
||||
AppName: wxpay_utility.String("王者荣耀"),
|
||||
AppUrl: wxpay_utility.String("https://pay.qq.com"),
|
||||
BundleId: wxpay_utility.String("com.tencent.wzryiOS"),
|
||||
PackageName: wxpay_utility.String("com.tencent.tmgp.sgame"),
|
||||
},
|
||||
},
|
||||
SettleInfo: &SettleInfo{
|
||||
ProfitSharing: wxpay_utility.Bool(false),
|
||||
},
|
||||
}
|
||||
|
||||
response, err := H5Prepay(config, request)
|
||||
if err != nil {
|
||||
fmt.Printf("请求失败: %+v\n", err)
|
||||
// TODO: 请求失败,根据状态码执行不同的处理
|
||||
return
|
||||
}
|
||||
|
||||
// TODO: 请求成功,继续业务逻辑
|
||||
fmt.Printf("请求成功: %+v\n", response)
|
||||
}
|
||||
|
||||
// H5Prepay H5下单
|
||||
func H5Prepay(config *wxpay_utility.MchConfig, request *DirectApiv3H5PrepayRequest) (response *DirectApiv3H5PrepayResponse, err error) {
|
||||
const (
|
||||
host = "https://api.mch.weixin.qq.com"
|
||||
method = "POST"
|
||||
path = "/v3/pay/transactions/h5"
|
||||
)
|
||||
|
||||
reqUrl, err := url.Parse(fmt.Sprintf("%s%s", host, path))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
reqBody, err := json.Marshal(request)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
httpRequest, err := http.NewRequest(method, reqUrl.String(), bytes.NewReader(reqBody))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
httpRequest.Header.Set("Accept", "application/json")
|
||||
httpRequest.Header.Set("Wechatpay-Serial", config.WechatPayPublicKeyId())
|
||||
httpRequest.Header.Set("Content-Type", "application/json")
|
||||
authorization, err := wxpay_utility.BuildAuthorization(config.MchId(), config.CertificateSerialNo(), config.PrivateKey(), method, reqUrl.RequestURI(), reqBody)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
httpRequest.Header.Set("Authorization", authorization)
|
||||
|
||||
client := &http.Client{}
|
||||
httpResponse, err := client.Do(httpRequest)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
respBody, err := wxpay_utility.ExtractResponseBody(httpResponse)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if httpResponse.StatusCode >= 200 && httpResponse.StatusCode < 300 {
|
||||
// 2XX 成功,验证应答签名
|
||||
err = wxpay_utility.ValidateResponse(
|
||||
config.WechatPayPublicKeyId(),
|
||||
config.WechatPayPublicKey(),
|
||||
&httpResponse.Header,
|
||||
respBody,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
response := &DirectApiv3H5PrepayResponse{}
|
||||
if err := json.Unmarshal(respBody, response); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return response, nil
|
||||
} else {
|
||||
return nil, wxpay_utility.NewApiException(
|
||||
httpResponse.StatusCode,
|
||||
httpResponse.Header,
|
||||
respBody,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
type DirectApiv3H5PrepayRequest struct {
|
||||
Appid *string `json:"appid,omitempty"`
|
||||
Mchid *string `json:"mchid,omitempty"`
|
||||
Description *string `json:"description,omitempty"`
|
||||
OutTradeNo *string `json:"out_trade_no,omitempty"`
|
||||
TimeExpire *time.Time `json:"time_expire,omitempty"`
|
||||
Attach *string `json:"attach,omitempty"`
|
||||
NotifyUrl *string `json:"notify_url,omitempty"`
|
||||
GoodsTag *string `json:"goods_tag,omitempty"`
|
||||
SupportFapiao *bool `json:"support_fapiao,omitempty"`
|
||||
Amount *CommonAmountInfo `json:"amount,omitempty"`
|
||||
Detail *CouponInfo `json:"detail,omitempty"`
|
||||
SceneInfo *H5ReqSceneInfo `json:"scene_info,omitempty"`
|
||||
SettleInfo *SettleInfo `json:"settle_info,omitempty"`
|
||||
}
|
||||
|
||||
type DirectApiv3H5PrepayResponse struct {
|
||||
H5Url *string `json:"h5_url,omitempty"`
|
||||
}
|
||||
|
||||
type CommonAmountInfo struct {
|
||||
Total *int64 `json:"total,omitempty"`
|
||||
Currency *string `json:"currency,omitempty"`
|
||||
}
|
||||
|
||||
type CouponInfo struct {
|
||||
CostPrice *int64 `json:"cost_price,omitempty"`
|
||||
InvoiceId *string `json:"invoice_id,omitempty"`
|
||||
GoodsDetail []GoodsDetail `json:"goods_detail,omitempty"`
|
||||
}
|
||||
|
||||
type H5ReqSceneInfo struct {
|
||||
PayerClientIp *string `json:"payer_client_ip,omitempty"`
|
||||
DeviceId *string `json:"device_id,omitempty"`
|
||||
StoreInfo *StoreInfo `json:"store_info,omitempty"`
|
||||
H5Info *H5Info `json:"h5_info,omitempty"`
|
||||
}
|
||||
|
||||
type SettleInfo struct {
|
||||
ProfitSharing *bool `json:"profit_sharing,omitempty"`
|
||||
}
|
||||
|
||||
type GoodsDetail struct {
|
||||
MerchantGoodsId *string `json:"merchant_goods_id,omitempty"`
|
||||
WechatpayGoodsId *string `json:"wechatpay_goods_id,omitempty"`
|
||||
GoodsName *string `json:"goods_name,omitempty"`
|
||||
Quantity *int64 `json:"quantity,omitempty"`
|
||||
UnitPrice *int64 `json:"unit_price,omitempty"`
|
||||
}
|
||||
|
||||
type StoreInfo struct {
|
||||
Id *string `json:"id,omitempty"`
|
||||
Name *string `json:"name,omitempty"`
|
||||
AreaCode *string `json:"area_code,omitempty"`
|
||||
Address *string `json:"address,omitempty"`
|
||||
}
|
||||
|
||||
type H5Info struct {
|
||||
Type *string `json:"type,omitempty"`
|
||||
AppName *string `json:"app_name,omitempty"`
|
||||
AppUrl *string `json:"app_url,omitempty"`
|
||||
BundleId *string `json:"bundle_id,omitempty"`
|
||||
PackageName *string `json:"package_name,omitempty"`
|
||||
}
|
||||
@@ -0,0 +1,196 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"demo/wxpay_utility" // 引用微信支付工具库,参考 https://pay.weixin.qq.com/doc/v3/merchant/4015119334
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"time"
|
||||
)
|
||||
|
||||
func main() {
|
||||
// TODO: 请准备商户开发必要参数,参考:https://pay.weixin.qq.com/doc/v3/merchant/4013070756
|
||||
config, err := wxpay_utility.CreateMchConfig(
|
||||
"19xxxxxxxx", // 商户号,是由微信支付系统生成并分配给每个商户的唯一标识符,商户号获取方式参考 https://pay.weixin.qq.com/doc/v3/merchant/4013070756
|
||||
"1DDE55AD98Exxxxxxxxxx", // 商户API证书序列号,如何获取请参考 https://pay.weixin.qq.com/doc/v3/merchant/4013053053
|
||||
"/path/to/apiclient_key.pem", // 商户API证书私钥文件路径,本地文件路径
|
||||
"PUB_KEY_ID_xxxxxxxxxxxxx", // 微信支付公钥ID,如何获取请参考 https://pay.weixin.qq.com/doc/v3/merchant/4013038816
|
||||
"/path/to/wxp_pub.pem", // 微信支付公钥文件路径,本地文件路径
|
||||
)
|
||||
if err != nil {
|
||||
fmt.Println(err)
|
||||
return
|
||||
}
|
||||
|
||||
request := &CommonPrepayRequest{
|
||||
Appid: wxpay_utility.String("wxd678efh567hg6787"),
|
||||
Mchid: wxpay_utility.String("1230000109"),
|
||||
Description: wxpay_utility.String("Image形象店-深圳腾大-QQ公仔"),
|
||||
OutTradeNo: wxpay_utility.String("1217752501201407033233368018"),
|
||||
TimeExpire: wxpay_utility.Time(time.Now()),
|
||||
Attach: wxpay_utility.String("自定义数据说明"),
|
||||
NotifyUrl: wxpay_utility.String(" https://www.weixin.qq.com/wxpay/pay.php"),
|
||||
GoodsTag: wxpay_utility.String("WXG"),
|
||||
SupportFapiao: wxpay_utility.Bool(false),
|
||||
Amount: &CommonAmountInfo{
|
||||
Total: wxpay_utility.Int64(100),
|
||||
Currency: wxpay_utility.String("CNY"),
|
||||
},
|
||||
Detail: &CouponInfo{
|
||||
CostPrice: wxpay_utility.Int64(608800),
|
||||
InvoiceId: wxpay_utility.String("微信123"),
|
||||
GoodsDetail: []GoodsDetail{GoodsDetail{
|
||||
MerchantGoodsId: wxpay_utility.String("1246464644"),
|
||||
WechatpayGoodsId: wxpay_utility.String("1001"),
|
||||
GoodsName: wxpay_utility.String("iPhoneX 256G"),
|
||||
Quantity: wxpay_utility.Int64(1),
|
||||
UnitPrice: wxpay_utility.Int64(528800),
|
||||
}},
|
||||
},
|
||||
SceneInfo: &CommonSceneInfo{
|
||||
PayerClientIp: wxpay_utility.String("14.23.150.211"),
|
||||
DeviceId: wxpay_utility.String("013467007045764"),
|
||||
StoreInfo: &StoreInfo{
|
||||
Id: wxpay_utility.String("0001"),
|
||||
Name: wxpay_utility.String("腾讯大厦分店"),
|
||||
AreaCode: wxpay_utility.String("440305"),
|
||||
Address: wxpay_utility.String("广东省深圳市南山区科技中一道10000号"),
|
||||
},
|
||||
},
|
||||
SettleInfo: &SettleInfo{
|
||||
ProfitSharing: wxpay_utility.Bool(false),
|
||||
},
|
||||
}
|
||||
|
||||
response, err := NativePrepay(config, request)
|
||||
if err != nil {
|
||||
fmt.Printf("请求失败: %+v\n", err)
|
||||
// TODO: 请求失败,根据状态码执行不同的处理
|
||||
return
|
||||
}
|
||||
|
||||
// TODO: 请求成功,继续业务逻辑
|
||||
fmt.Printf("请求成功: %+v\n", response)
|
||||
}
|
||||
|
||||
// NativePrepay Native下单
|
||||
func NativePrepay(config *wxpay_utility.MchConfig, request *CommonPrepayRequest) (response *DirectApiv3DirectNativePrepayResponse, err error) {
|
||||
const (
|
||||
host = "https://api.mch.weixin.qq.com"
|
||||
method = "POST"
|
||||
path = "/v3/pay/transactions/native"
|
||||
)
|
||||
|
||||
reqUrl, err := url.Parse(fmt.Sprintf("%s%s", host, path))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
reqBody, err := json.Marshal(request)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
httpRequest, err := http.NewRequest(method, reqUrl.String(), bytes.NewReader(reqBody))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
httpRequest.Header.Set("Accept", "application/json")
|
||||
httpRequest.Header.Set("Wechatpay-Serial", config.WechatPayPublicKeyId())
|
||||
httpRequest.Header.Set("Content-Type", "application/json")
|
||||
authorization, err := wxpay_utility.BuildAuthorization(config.MchId(), config.CertificateSerialNo(), config.PrivateKey(), method, reqUrl.RequestURI(), reqBody)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
httpRequest.Header.Set("Authorization", authorization)
|
||||
|
||||
client := &http.Client{}
|
||||
httpResponse, err := client.Do(httpRequest)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
respBody, err := wxpay_utility.ExtractResponseBody(httpResponse)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if httpResponse.StatusCode >= 200 && httpResponse.StatusCode < 300 {
|
||||
// 2XX 成功,验证应答签名
|
||||
err = wxpay_utility.ValidateResponse(
|
||||
config.WechatPayPublicKeyId(),
|
||||
config.WechatPayPublicKey(),
|
||||
&httpResponse.Header,
|
||||
respBody,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
response := &DirectApiv3DirectNativePrepayResponse{}
|
||||
if err := json.Unmarshal(respBody, response); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return response, nil
|
||||
} else {
|
||||
return nil, wxpay_utility.NewApiException(
|
||||
httpResponse.StatusCode,
|
||||
httpResponse.Header,
|
||||
respBody,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
type CommonPrepayRequest struct {
|
||||
Appid *string `json:"appid,omitempty"`
|
||||
Mchid *string `json:"mchid,omitempty"`
|
||||
Description *string `json:"description,omitempty"`
|
||||
OutTradeNo *string `json:"out_trade_no,omitempty"`
|
||||
TimeExpire *time.Time `json:"time_expire,omitempty"`
|
||||
Attach *string `json:"attach,omitempty"`
|
||||
NotifyUrl *string `json:"notify_url,omitempty"`
|
||||
GoodsTag *string `json:"goods_tag,omitempty"`
|
||||
SupportFapiao *bool `json:"support_fapiao,omitempty"`
|
||||
Amount *CommonAmountInfo `json:"amount,omitempty"`
|
||||
Detail *CouponInfo `json:"detail,omitempty"`
|
||||
SceneInfo *CommonSceneInfo `json:"scene_info,omitempty"`
|
||||
SettleInfo *SettleInfo `json:"settle_info,omitempty"`
|
||||
}
|
||||
|
||||
type DirectApiv3DirectNativePrepayResponse struct {
|
||||
CodeUrl *string `json:"code_url,omitempty"`
|
||||
}
|
||||
|
||||
type CommonAmountInfo struct {
|
||||
Total *int64 `json:"total,omitempty"`
|
||||
Currency *string `json:"currency,omitempty"`
|
||||
}
|
||||
|
||||
type CouponInfo struct {
|
||||
CostPrice *int64 `json:"cost_price,omitempty"`
|
||||
InvoiceId *string `json:"invoice_id,omitempty"`
|
||||
GoodsDetail []GoodsDetail `json:"goods_detail,omitempty"`
|
||||
}
|
||||
|
||||
type CommonSceneInfo struct {
|
||||
PayerClientIp *string `json:"payer_client_ip,omitempty"`
|
||||
DeviceId *string `json:"device_id,omitempty"`
|
||||
StoreInfo *StoreInfo `json:"store_info,omitempty"`
|
||||
}
|
||||
|
||||
type SettleInfo struct {
|
||||
ProfitSharing *bool `json:"profit_sharing,omitempty"`
|
||||
}
|
||||
|
||||
type GoodsDetail struct {
|
||||
MerchantGoodsId *string `json:"merchant_goods_id,omitempty"`
|
||||
WechatpayGoodsId *string `json:"wechatpay_goods_id,omitempty"`
|
||||
GoodsName *string `json:"goods_name,omitempty"`
|
||||
Quantity *int64 `json:"quantity,omitempty"`
|
||||
UnitPrice *int64 `json:"unit_price,omitempty"`
|
||||
}
|
||||
|
||||
type StoreInfo struct {
|
||||
Id *string `json:"id,omitempty"`
|
||||
Name *string `json:"name,omitempty"`
|
||||
AreaCode *string `json:"area_code,omitempty"`
|
||||
Address *string `json:"address,omitempty"`
|
||||
}
|
||||
@@ -0,0 +1,178 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"demo/wxpay_utility" // 引用微信支付工具库,参考 https://pay.weixin.qq.com/doc/v3/merchant/4015119334
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
)
|
||||
|
||||
func main() {
|
||||
// TODO: 请准备商户开发必要参数,参考:https://pay.weixin.qq.com/doc/v3/merchant/4013070756
|
||||
config, err := wxpay_utility.CreateMchConfig(
|
||||
"19xxxxxxxx", // 商户号,是由微信支付系统生成并分配给每个商户的唯一标识符,商户号获取方式参考 https://pay.weixin.qq.com/doc/v3/merchant/4013070756
|
||||
"1DDE55AD98Exxxxxxxxxx", // 商户API证书序列号,如何获取请参考 https://pay.weixin.qq.com/doc/v3/merchant/4013053053
|
||||
"/path/to/apiclient_key.pem", // 商户API证书私钥文件路径,本地文件路径
|
||||
"PUB_KEY_ID_xxxxxxxxxxxxx", // 微信支付公钥ID,如何获取请参考 https://pay.weixin.qq.com/doc/v3/merchant/4013038816
|
||||
"/path/to/wxp_pub.pem", // 微信支付公钥文件路径,本地文件路径
|
||||
)
|
||||
if err != nil {
|
||||
fmt.Println(err)
|
||||
return
|
||||
}
|
||||
|
||||
request := &QueryByOutTradeNoRequest{
|
||||
Mchid: wxpay_utility.String("1230000109"),
|
||||
OutTradeNo: wxpay_utility.String("1217752501201407033233368018"),
|
||||
}
|
||||
|
||||
response, err := QueryByOutTradeNo(config, request)
|
||||
if err != nil {
|
||||
fmt.Printf("请求失败: %+v\n", err)
|
||||
// TODO: 请求失败,根据状态码执行不同的处理
|
||||
return
|
||||
}
|
||||
|
||||
// TODO: 请求成功,继续业务逻辑
|
||||
fmt.Printf("请求成功: %+v\n", response)
|
||||
}
|
||||
|
||||
func QueryByOutTradeNo(config *wxpay_utility.MchConfig, request *QueryByOutTradeNoRequest) (response *DirectApiv3QueryResponse, err error) {
|
||||
const (
|
||||
host = "https://api.mch.weixin.qq.com"
|
||||
method = "GET"
|
||||
path = "/v3/pay/transactions/out-trade-no/{out_trade_no}"
|
||||
)
|
||||
|
||||
reqUrl, err := url.Parse(fmt.Sprintf("%s%s", host, path))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
reqUrl.Path = strings.Replace(reqUrl.Path, "{out_trade_no}", url.PathEscape(*request.OutTradeNo), -1)
|
||||
query := reqUrl.Query()
|
||||
if request.Mchid != nil {
|
||||
query.Add("mchid", *request.Mchid)
|
||||
}
|
||||
reqUrl.RawQuery = query.Encode()
|
||||
httpRequest, err := http.NewRequest(method, reqUrl.String(), nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
httpRequest.Header.Set("Accept", "application/json")
|
||||
httpRequest.Header.Set("Wechatpay-Serial", config.WechatPayPublicKeyId())
|
||||
authorization, err := wxpay_utility.BuildAuthorization(config.MchId(), config.CertificateSerialNo(), config.PrivateKey(), method, reqUrl.RequestURI(), nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
httpRequest.Header.Set("Authorization", authorization)
|
||||
|
||||
client := &http.Client{}
|
||||
httpResponse, err := client.Do(httpRequest)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
respBody, err := wxpay_utility.ExtractResponseBody(httpResponse)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if httpResponse.StatusCode >= 200 && httpResponse.StatusCode < 300 {
|
||||
// 2XX 成功,验证应答签名
|
||||
err = wxpay_utility.ValidateResponse(
|
||||
config.WechatPayPublicKeyId(),
|
||||
config.WechatPayPublicKey(),
|
||||
&httpResponse.Header,
|
||||
respBody,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
response := &DirectApiv3QueryResponse{}
|
||||
if err := json.Unmarshal(respBody, response); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return response, nil
|
||||
} else {
|
||||
return nil, wxpay_utility.NewApiException(
|
||||
httpResponse.StatusCode,
|
||||
httpResponse.Header,
|
||||
respBody,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
type QueryByOutTradeNoRequest struct {
|
||||
Mchid *string `json:"mchid,omitempty"`
|
||||
OutTradeNo *string `json:"out_trade_no,omitempty"`
|
||||
}
|
||||
|
||||
func (o *QueryByOutTradeNoRequest) MarshalJSON() ([]byte, error) {
|
||||
type Alias QueryByOutTradeNoRequest
|
||||
a := &struct {
|
||||
Mchid *string `json:"mchid,omitempty"`
|
||||
OutTradeNo *string `json:"out_trade_no,omitempty"`
|
||||
*Alias
|
||||
}{
|
||||
// 序列化时移除非 Body 字段
|
||||
Mchid: nil,
|
||||
OutTradeNo: nil,
|
||||
Alias: (*Alias)(o),
|
||||
}
|
||||
return json.Marshal(a)
|
||||
}
|
||||
|
||||
type DirectApiv3QueryResponse struct {
|
||||
Appid *string `json:"appid,omitempty"`
|
||||
Mchid *string `json:"mchid,omitempty"`
|
||||
OutTradeNo *string `json:"out_trade_no,omitempty"`
|
||||
TransactionId *string `json:"transaction_id,omitempty"`
|
||||
TradeType *string `json:"trade_type,omitempty"`
|
||||
TradeState *string `json:"trade_state,omitempty"`
|
||||
TradeStateDesc *string `json:"trade_state_desc,omitempty"`
|
||||
BankType *string `json:"bank_type,omitempty"`
|
||||
Attach *string `json:"attach,omitempty"`
|
||||
SuccessTime *string `json:"success_time,omitempty"`
|
||||
Payer *CommRespPayerInfo `json:"payer,omitempty"`
|
||||
Amount *CommRespAmountInfo `json:"amount,omitempty"`
|
||||
SceneInfo *CommRespSceneInfo `json:"scene_info,omitempty"`
|
||||
PromotionDetail []PromotionDetail `json:"promotion_detail,omitempty"`
|
||||
}
|
||||
|
||||
type CommRespPayerInfo struct {
|
||||
Openid *string `json:"openid,omitempty"`
|
||||
}
|
||||
|
||||
type CommRespAmountInfo struct {
|
||||
Total *int64 `json:"total,omitempty"`
|
||||
PayerTotal *int64 `json:"payer_total,omitempty"`
|
||||
Currency *string `json:"currency,omitempty"`
|
||||
PayerCurrency *string `json:"payer_currency,omitempty"`
|
||||
}
|
||||
|
||||
type CommRespSceneInfo struct {
|
||||
DeviceId *string `json:"device_id,omitempty"`
|
||||
}
|
||||
|
||||
type PromotionDetail struct {
|
||||
CouponId *string `json:"coupon_id,omitempty"`
|
||||
Name *string `json:"name,omitempty"`
|
||||
Scope *string `json:"scope,omitempty"`
|
||||
Type *string `json:"type,omitempty"`
|
||||
Amount *int64 `json:"amount,omitempty"`
|
||||
StockId *string `json:"stock_id,omitempty"`
|
||||
WechatpayContribute *int64 `json:"wechatpay_contribute,omitempty"`
|
||||
MerchantContribute *int64 `json:"merchant_contribute,omitempty"`
|
||||
OtherContribute *int64 `json:"other_contribute,omitempty"`
|
||||
Currency *string `json:"currency,omitempty"`
|
||||
GoodsDetail []GoodsDetailInPromotion `json:"goods_detail,omitempty"`
|
||||
}
|
||||
|
||||
type GoodsDetailInPromotion struct {
|
||||
GoodsId *string `json:"goods_id,omitempty"`
|
||||
Quantity *int64 `json:"quantity,omitempty"`
|
||||
UnitPrice *int64 `json:"unit_price,omitempty"`
|
||||
DiscountAmount *int64 `json:"discount_amount,omitempty"`
|
||||
GoodsRemark *string `json:"goods_remark,omitempty"`
|
||||
}
|
||||
@@ -0,0 +1,178 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"demo/wxpay_utility" // 引用微信支付工具库,参考 https://pay.weixin.qq.com/doc/v3/merchant/4015119334
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
)
|
||||
|
||||
func main() {
|
||||
// TODO: 请准备商户开发必要参数,参考:https://pay.weixin.qq.com/doc/v3/merchant/4013070756
|
||||
config, err := wxpay_utility.CreateMchConfig(
|
||||
"19xxxxxxxx", // 商户号,是由微信支付系统生成并分配给每个商户的唯一标识符,商户号获取方式参考 https://pay.weixin.qq.com/doc/v3/merchant/4013070756
|
||||
"1DDE55AD98Exxxxxxxxxx", // 商户API证书序列号,如何获取请参考 https://pay.weixin.qq.com/doc/v3/merchant/4013053053
|
||||
"/path/to/apiclient_key.pem", // 商户API证书私钥文件路径,本地文件路径
|
||||
"PUB_KEY_ID_xxxxxxxxxxxxx", // 微信支付公钥ID,如何获取请参考 https://pay.weixin.qq.com/doc/v3/merchant/4013038816
|
||||
"/path/to/wxp_pub.pem", // 微信支付公钥文件路径,本地文件路径
|
||||
)
|
||||
if err != nil {
|
||||
fmt.Println(err)
|
||||
return
|
||||
}
|
||||
|
||||
request := &QueryByWxTradeNoRequest{
|
||||
Mchid: wxpay_utility.String("1230000109"),
|
||||
TransactionId: wxpay_utility.String("1217752501201407033233368018"),
|
||||
}
|
||||
|
||||
response, err := QueryByWxTradeNo(config, request)
|
||||
if err != nil {
|
||||
fmt.Printf("请求失败: %+v\n", err)
|
||||
// TODO: 请求失败,根据状态码执行不同的处理
|
||||
return
|
||||
}
|
||||
|
||||
// TODO: 请求成功,继续业务逻辑
|
||||
fmt.Printf("请求成功: %+v\n", response)
|
||||
}
|
||||
|
||||
func QueryByWxTradeNo(config *wxpay_utility.MchConfig, request *QueryByWxTradeNoRequest) (response *DirectApiv3QueryResponse, err error) {
|
||||
const (
|
||||
host = "https://api.mch.weixin.qq.com"
|
||||
method = "GET"
|
||||
path = "/v3/pay/transactions/id/{transaction_id}"
|
||||
)
|
||||
|
||||
reqUrl, err := url.Parse(fmt.Sprintf("%s%s", host, path))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
reqUrl.Path = strings.Replace(reqUrl.Path, "{transaction_id}", url.PathEscape(*request.TransactionId), -1)
|
||||
query := reqUrl.Query()
|
||||
if request.Mchid != nil {
|
||||
query.Add("mchid", *request.Mchid)
|
||||
}
|
||||
reqUrl.RawQuery = query.Encode()
|
||||
httpRequest, err := http.NewRequest(method, reqUrl.String(), nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
httpRequest.Header.Set("Accept", "application/json")
|
||||
httpRequest.Header.Set("Wechatpay-Serial", config.WechatPayPublicKeyId())
|
||||
authorization, err := wxpay_utility.BuildAuthorization(config.MchId(), config.CertificateSerialNo(), config.PrivateKey(), method, reqUrl.RequestURI(), nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
httpRequest.Header.Set("Authorization", authorization)
|
||||
|
||||
client := &http.Client{}
|
||||
httpResponse, err := client.Do(httpRequest)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
respBody, err := wxpay_utility.ExtractResponseBody(httpResponse)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if httpResponse.StatusCode >= 200 && httpResponse.StatusCode < 300 {
|
||||
// 2XX 成功,验证应答签名
|
||||
err = wxpay_utility.ValidateResponse(
|
||||
config.WechatPayPublicKeyId(),
|
||||
config.WechatPayPublicKey(),
|
||||
&httpResponse.Header,
|
||||
respBody,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
response := &DirectApiv3QueryResponse{}
|
||||
if err := json.Unmarshal(respBody, response); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return response, nil
|
||||
} else {
|
||||
return nil, wxpay_utility.NewApiException(
|
||||
httpResponse.StatusCode,
|
||||
httpResponse.Header,
|
||||
respBody,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
type QueryByWxTradeNoRequest struct {
|
||||
Mchid *string `json:"mchid,omitempty"`
|
||||
TransactionId *string `json:"transaction_id,omitempty"`
|
||||
}
|
||||
|
||||
func (o *QueryByWxTradeNoRequest) MarshalJSON() ([]byte, error) {
|
||||
type Alias QueryByWxTradeNoRequest
|
||||
a := &struct {
|
||||
Mchid *string `json:"mchid,omitempty"`
|
||||
TransactionId *string `json:"transaction_id,omitempty"`
|
||||
*Alias
|
||||
}{
|
||||
// 序列化时移除非 Body 字段
|
||||
Mchid: nil,
|
||||
TransactionId: nil,
|
||||
Alias: (*Alias)(o),
|
||||
}
|
||||
return json.Marshal(a)
|
||||
}
|
||||
|
||||
type DirectApiv3QueryResponse struct {
|
||||
Appid *string `json:"appid,omitempty"`
|
||||
Mchid *string `json:"mchid,omitempty"`
|
||||
OutTradeNo *string `json:"out_trade_no,omitempty"`
|
||||
TransactionId *string `json:"transaction_id,omitempty"`
|
||||
TradeType *string `json:"trade_type,omitempty"`
|
||||
TradeState *string `json:"trade_state,omitempty"`
|
||||
TradeStateDesc *string `json:"trade_state_desc,omitempty"`
|
||||
BankType *string `json:"bank_type,omitempty"`
|
||||
Attach *string `json:"attach,omitempty"`
|
||||
SuccessTime *string `json:"success_time,omitempty"`
|
||||
Payer *CommRespPayerInfo `json:"payer,omitempty"`
|
||||
Amount *CommRespAmountInfo `json:"amount,omitempty"`
|
||||
SceneInfo *CommRespSceneInfo `json:"scene_info,omitempty"`
|
||||
PromotionDetail []PromotionDetail `json:"promotion_detail,omitempty"`
|
||||
}
|
||||
|
||||
type CommRespPayerInfo struct {
|
||||
Openid *string `json:"openid,omitempty"`
|
||||
}
|
||||
|
||||
type CommRespAmountInfo struct {
|
||||
Total *int64 `json:"total,omitempty"`
|
||||
PayerTotal *int64 `json:"payer_total,omitempty"`
|
||||
Currency *string `json:"currency,omitempty"`
|
||||
PayerCurrency *string `json:"payer_currency,omitempty"`
|
||||
}
|
||||
|
||||
type CommRespSceneInfo struct {
|
||||
DeviceId *string `json:"device_id,omitempty"`
|
||||
}
|
||||
|
||||
type PromotionDetail struct {
|
||||
CouponId *string `json:"coupon_id,omitempty"`
|
||||
Name *string `json:"name,omitempty"`
|
||||
Scope *string `json:"scope,omitempty"`
|
||||
Type *string `json:"type,omitempty"`
|
||||
Amount *int64 `json:"amount,omitempty"`
|
||||
StockId *string `json:"stock_id,omitempty"`
|
||||
WechatpayContribute *int64 `json:"wechatpay_contribute,omitempty"`
|
||||
MerchantContribute *int64 `json:"merchant_contribute,omitempty"`
|
||||
OtherContribute *int64 `json:"other_contribute,omitempty"`
|
||||
Currency *string `json:"currency,omitempty"`
|
||||
GoodsDetail []GoodsDetailInPromotion `json:"goods_detail,omitempty"`
|
||||
}
|
||||
|
||||
type GoodsDetailInPromotion struct {
|
||||
GoodsId *string `json:"goods_id,omitempty"`
|
||||
Quantity *int64 `json:"quantity,omitempty"`
|
||||
UnitPrice *int64 `json:"unit_price,omitempty"`
|
||||
DiscountAmount *int64 `json:"discount_amount,omitempty"`
|
||||
GoodsRemark *string `json:"goods_remark,omitempty"`
|
||||
}
|
||||
@@ -0,0 +1,129 @@
|
||||
// 关闭订单
|
||||
//
|
||||
// 未支付状态的订单,可在无需支付时调用此接口关闭订单。常见关单情况包括:
|
||||
// 1. 用户在商户系统提交取消订单请求,商户需执行关单操作。
|
||||
// 2. 订单超时未支付(超出商户系统设定的可支付时间或下单时的time_expire支付截止时间),商户需进行关单处理。
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"demo/wxpay_utility" // 引用微信支付工具库,参考 https://pay.weixin.qq.com/doc/v3/merchant/4015119334
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
)
|
||||
|
||||
func main() {
|
||||
// TODO: 请准备商户开发必要参数,参考:https://pay.weixin.qq.com/doc/v3/merchant/4013070756
|
||||
config, err := wxpay_utility.CreateMchConfig(
|
||||
"19xxxxxxxx", // 商户号,是由微信支付系统生成并分配给每个商户的唯一标识符,商户号获取方式参考 https://pay.weixin.qq.com/doc/v3/merchant/4013070756
|
||||
"1DDE55AD98Exxxxxxxxxx", // 商户API证书序列号,如何获取请参考 https://pay.weixin.qq.com/doc/v3/merchant/4013053053
|
||||
"/path/to/apiclient_key.pem", // 商户API证书私钥文件路径,本地文件路径
|
||||
"PUB_KEY_ID_xxxxxxxxxxxxx", // 微信支付公钥ID,如何获取请参考 https://pay.weixin.qq.com/doc/v3/merchant/4013038816
|
||||
"/path/to/wxp_pub.pem", // 微信支付公钥文件路径,本地文件路径
|
||||
)
|
||||
if err != nil {
|
||||
fmt.Println(err)
|
||||
return
|
||||
}
|
||||
|
||||
request := &CloseOrderRequest{
|
||||
OutTradeNo: wxpay_utility.String("1217752501201407033233368018"),
|
||||
Mchid: wxpay_utility.String("1230000109"),
|
||||
}
|
||||
|
||||
err = CloseOrder(config, request)
|
||||
if err != nil {
|
||||
fmt.Printf("请求失败: %+v\n", err)
|
||||
// TODO: 请求失败,根据状态码执行不同的处理
|
||||
return
|
||||
}
|
||||
|
||||
// TODO: 请求成功,继续业务逻辑
|
||||
fmt.Println("请求成功")
|
||||
}
|
||||
|
||||
// CloseOrder 关闭订单
|
||||
//
|
||||
// 未支付状态的订单,可在无需支付时调用此接口关闭订单。常见关单情况包括:
|
||||
// 1. 用户在商户系统提交取消订单请求,商户需执行关单操作。
|
||||
// 2. 订单超时未支付(超出商户系统设定的可支付时间或下单时的time_expire支付截止时间),商户需进行关单处理。
|
||||
func CloseOrder(config *wxpay_utility.MchConfig, request *CloseOrderRequest) (err error) {
|
||||
const (
|
||||
host = "https://api.mch.weixin.qq.com"
|
||||
method = "POST"
|
||||
path = "/v3/pay/transactions/out-trade-no/{out_trade_no}/close"
|
||||
)
|
||||
|
||||
reqUrl, err := url.Parse(fmt.Sprintf("%s%s", host, path))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
reqUrl.Path = strings.Replace(reqUrl.Path, "{out_trade_no}", url.PathEscape(*request.OutTradeNo), -1)
|
||||
reqBody, err := json.Marshal(request)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
httpRequest, err := http.NewRequest(method, reqUrl.String(), bytes.NewReader(reqBody))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
httpRequest.Header.Set("Accept", "application/json")
|
||||
httpRequest.Header.Set("Wechatpay-Serial", config.WechatPayPublicKeyId())
|
||||
httpRequest.Header.Set("Content-Type", "application/json")
|
||||
authorization, err := wxpay_utility.BuildAuthorization(config.MchId(), config.CertificateSerialNo(), config.PrivateKey(), method, reqUrl.RequestURI(), reqBody)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
httpRequest.Header.Set("Authorization", authorization)
|
||||
|
||||
client := &http.Client{}
|
||||
httpResponse, err := client.Do(httpRequest)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
respBody, err := wxpay_utility.ExtractResponseBody(httpResponse)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if httpResponse.StatusCode >= 200 && httpResponse.StatusCode < 300 {
|
||||
// 2XX 成功,验证应答签名
|
||||
err = wxpay_utility.ValidateResponse(
|
||||
config.WechatPayPublicKeyId(),
|
||||
config.WechatPayPublicKey(),
|
||||
&httpResponse.Header,
|
||||
respBody,
|
||||
)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
} else {
|
||||
return wxpay_utility.NewApiException(
|
||||
httpResponse.StatusCode,
|
||||
httpResponse.Header,
|
||||
respBody,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
type CloseOrderRequest struct {
|
||||
Mchid *string `json:"mchid,omitempty"`
|
||||
OutTradeNo *string `json:"out_trade_no,omitempty"`
|
||||
}
|
||||
|
||||
func (o *CloseOrderRequest) MarshalJSON() ([]byte, error) {
|
||||
type Alias CloseOrderRequest
|
||||
a := &struct {
|
||||
OutTradeNo *string `json:"out_trade_no,omitempty"`
|
||||
*Alias
|
||||
}{
|
||||
// 序列化时移除非 Body 字段
|
||||
OutTradeNo: nil,
|
||||
Alias: (*Alias)(o),
|
||||
}
|
||||
return json.Marshal(a)
|
||||
}
|
||||
@@ -0,0 +1,269 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"demo/wxpay_utility" // 引用微信支付工具库,参考 https://pay.weixin.qq.com/doc/v3/merchant/4015119334
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
func main() {
|
||||
// TODO: 请准备商户开发必要参数,参考:https://pay.weixin.qq.com/doc/v3/merchant/4013070756
|
||||
config, err := wxpay_utility.CreateMchConfig(
|
||||
"19xxxxxxxx", // 商户号,是由微信支付系统生成并分配给每个商户的唯一标识符,商户号获取方式参考 https://pay.weixin.qq.com/doc/v3/merchant/4013070756
|
||||
"1DDE55AD98Exxxxxxxxxx", // 商户API证书序列号,如何获取请参考 https://pay.weixin.qq.com/doc/v3/merchant/4013053053
|
||||
"/path/to/apiclient_key.pem", // 商户API证书私钥文件路径,本地文件路径
|
||||
"PUB_KEY_ID_xxxxxxxxxxxxx", // 微信支付公钥ID,如何获取请参考 https://pay.weixin.qq.com/doc/v3/merchant/4013038816
|
||||
"/path/to/wxp_pub.pem", // 微信支付公钥文件路径,本地文件路径
|
||||
)
|
||||
if err != nil {
|
||||
fmt.Println(err)
|
||||
return
|
||||
}
|
||||
|
||||
request := &CreateAbnormalRefundRequest{
|
||||
RefundId: wxpay_utility.String("50000000382019052709732678859"),
|
||||
OutRefundNo: wxpay_utility.String("1217752501201407033233368018"),
|
||||
Type: ABNORMALRECEIVETYPE_MERCHANT_BANK_CARD.Ptr(),
|
||||
BankType: wxpay_utility.String("ICBC_DEBIT"),
|
||||
BankAccount: wxpay_utility.String("d+xT+MQCvrLHUVDWv/8MR/dB7TkXLVfSrUxMPZy6jWWYzpRrEEaYQE8ZRGYoeorwC+w=="), /*请传入wxpay_utility.EncryptOAEPWithPublicKey 加密结果*/
|
||||
RealName: wxpay_utility.String("UPgQcZSdq3zOayJwZ5XLrHY2dZU1W2Cd"), /*请传入wxpay_utility.EncryptOAEPWithPublicKey 加密结果*/
|
||||
}
|
||||
|
||||
response, err := CreateAbnormalRefund(config, request)
|
||||
if err != nil {
|
||||
fmt.Printf("请求失败: %+v\n", err)
|
||||
// TODO: 请求失败,根据状态码执行不同的处理
|
||||
return
|
||||
}
|
||||
|
||||
// TODO: 请求成功,继续业务逻辑
|
||||
fmt.Printf("请求成功: %+v\n", response)
|
||||
}
|
||||
|
||||
// CreateAbnormalRefund 发起异常退款
|
||||
func CreateAbnormalRefund(config *wxpay_utility.MchConfig, request *CreateAbnormalRefundRequest) (response *Refund, err error) {
|
||||
const (
|
||||
host = "https://api.mch.weixin.qq.com"
|
||||
method = "POST"
|
||||
path = "/v3/refund/domestic/refunds/{refund_id}/apply-abnormal-refund"
|
||||
)
|
||||
|
||||
reqUrl, err := url.Parse(fmt.Sprintf("%s%s", host, path))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
reqUrl.Path = strings.Replace(reqUrl.Path, "{refund_id}", url.PathEscape(*request.RefundId), -1)
|
||||
reqBody, err := json.Marshal(request)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
httpRequest, err := http.NewRequest(method, reqUrl.String(), bytes.NewReader(reqBody))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
httpRequest.Header.Set("Accept", "application/json")
|
||||
httpRequest.Header.Set("Wechatpay-Serial", config.WechatPayPublicKeyId())
|
||||
httpRequest.Header.Set("Content-Type", "application/json")
|
||||
authorization, err := wxpay_utility.BuildAuthorization(config.MchId(), config.CertificateSerialNo(), config.PrivateKey(), method, reqUrl.RequestURI(), reqBody)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
httpRequest.Header.Set("Authorization", authorization)
|
||||
|
||||
client := &http.Client{}
|
||||
httpResponse, err := client.Do(httpRequest)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
respBody, err := wxpay_utility.ExtractResponseBody(httpResponse)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if httpResponse.StatusCode >= 200 && httpResponse.StatusCode < 300 {
|
||||
// 2XX 成功,验证应答签名
|
||||
err = wxpay_utility.ValidateResponse(
|
||||
config.WechatPayPublicKeyId(),
|
||||
config.WechatPayPublicKey(),
|
||||
&httpResponse.Header,
|
||||
respBody,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
response := &Refund{}
|
||||
if err := json.Unmarshal(respBody, response); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return response, nil
|
||||
} else {
|
||||
return nil, wxpay_utility.NewApiException(
|
||||
httpResponse.StatusCode,
|
||||
httpResponse.Header,
|
||||
respBody,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
type CreateAbnormalRefundRequest struct {
|
||||
RefundId *string `json:"refund_id,omitempty"`
|
||||
OutRefundNo *string `json:"out_refund_no,omitempty"`
|
||||
Type *AbnormalReceiveType `json:"type,omitempty"`
|
||||
BankType *string `json:"bank_type,omitempty"`
|
||||
BankAccount *string `json:"bank_account,omitempty"`
|
||||
RealName *string `json:"real_name,omitempty"`
|
||||
}
|
||||
|
||||
func (o *CreateAbnormalRefundRequest) MarshalJSON() ([]byte, error) {
|
||||
type Alias CreateAbnormalRefundRequest
|
||||
a := &struct {
|
||||
RefundId *string `json:"refund_id,omitempty"`
|
||||
*Alias
|
||||
}{
|
||||
// 序列化时移除非 Body 字段
|
||||
RefundId: nil,
|
||||
Alias: (*Alias)(o),
|
||||
}
|
||||
return json.Marshal(a)
|
||||
}
|
||||
|
||||
type Refund struct {
|
||||
RefundId *string `json:"refund_id,omitempty"`
|
||||
OutRefundNo *string `json:"out_refund_no,omitempty"`
|
||||
TransactionId *string `json:"transaction_id,omitempty"`
|
||||
OutTradeNo *string `json:"out_trade_no,omitempty"`
|
||||
Channel *Channel `json:"channel,omitempty"`
|
||||
UserReceivedAccount *string `json:"user_received_account,omitempty"`
|
||||
SuccessTime *time.Time `json:"success_time,omitempty"`
|
||||
CreateTime *time.Time `json:"create_time,omitempty"`
|
||||
Status *Status `json:"status,omitempty"`
|
||||
FundsAccount *FundsAccount `json:"funds_account,omitempty"`
|
||||
Amount *Amount `json:"amount,omitempty"`
|
||||
PromotionDetail []Promotion `json:"promotion_detail,omitempty"`
|
||||
}
|
||||
|
||||
type AbnormalReceiveType string
|
||||
|
||||
func (e AbnormalReceiveType) Ptr() *AbnormalReceiveType {
|
||||
return &e
|
||||
}
|
||||
|
||||
const (
|
||||
ABNORMALRECEIVETYPE_USER_BANK_CARD AbnormalReceiveType = "USER_BANK_CARD"
|
||||
ABNORMALRECEIVETYPE_MERCHANT_BANK_CARD AbnormalReceiveType = "MERCHANT_BANK_CARD"
|
||||
)
|
||||
|
||||
type Channel string
|
||||
|
||||
func (e Channel) Ptr() *Channel {
|
||||
return &e
|
||||
}
|
||||
|
||||
const (
|
||||
CHANNEL_ORIGINAL Channel = "ORIGINAL"
|
||||
CHANNEL_BALANCE Channel = "BALANCE"
|
||||
CHANNEL_OTHER_BALANCE Channel = "OTHER_BALANCE"
|
||||
CHANNEL_OTHER_BANKCARD Channel = "OTHER_BANKCARD"
|
||||
)
|
||||
|
||||
type Status string
|
||||
|
||||
func (e Status) Ptr() *Status {
|
||||
return &e
|
||||
}
|
||||
|
||||
const (
|
||||
STATUS_SUCCESS Status = "SUCCESS"
|
||||
STATUS_CLOSED Status = "CLOSED"
|
||||
STATUS_PROCESSING Status = "PROCESSING"
|
||||
STATUS_ABNORMAL Status = "ABNORMAL"
|
||||
)
|
||||
|
||||
type FundsAccount string
|
||||
|
||||
func (e FundsAccount) Ptr() *FundsAccount {
|
||||
return &e
|
||||
}
|
||||
|
||||
const (
|
||||
FUNDSACCOUNT_UNSETTLED FundsAccount = "UNSETTLED"
|
||||
FUNDSACCOUNT_AVAILABLE FundsAccount = "AVAILABLE"
|
||||
FUNDSACCOUNT_UNAVAILABLE FundsAccount = "UNAVAILABLE"
|
||||
FUNDSACCOUNT_OPERATION FundsAccount = "OPERATION"
|
||||
FUNDSACCOUNT_BASIC FundsAccount = "BASIC"
|
||||
FUNDSACCOUNT_ECNY_BASIC FundsAccount = "ECNY_BASIC"
|
||||
)
|
||||
|
||||
type Amount struct {
|
||||
Total *int64 `json:"total,omitempty"`
|
||||
Refund *int64 `json:"refund,omitempty"`
|
||||
From []FundsFromItem `json:"from,omitempty"`
|
||||
PayerTotal *int64 `json:"payer_total,omitempty"`
|
||||
PayerRefund *int64 `json:"payer_refund,omitempty"`
|
||||
SettlementRefund *int64 `json:"settlement_refund,omitempty"`
|
||||
SettlementTotal *int64 `json:"settlement_total,omitempty"`
|
||||
DiscountRefund *int64 `json:"discount_refund,omitempty"`
|
||||
Currency *string `json:"currency,omitempty"`
|
||||
RefundFee *int64 `json:"refund_fee,omitempty"`
|
||||
}
|
||||
|
||||
type Promotion struct {
|
||||
PromotionId *string `json:"promotion_id,omitempty"`
|
||||
Scope *PromotionScope `json:"scope,omitempty"`
|
||||
Type *PromotionType `json:"type,omitempty"`
|
||||
Amount *int64 `json:"amount,omitempty"`
|
||||
RefundAmount *int64 `json:"refund_amount,omitempty"`
|
||||
GoodsDetail []GoodsDetail `json:"goods_detail,omitempty"`
|
||||
}
|
||||
|
||||
type FundsFromItem struct {
|
||||
Account *Account `json:"account,omitempty"`
|
||||
Amount *int64 `json:"amount,omitempty"`
|
||||
}
|
||||
|
||||
type PromotionScope string
|
||||
|
||||
func (e PromotionScope) Ptr() *PromotionScope {
|
||||
return &e
|
||||
}
|
||||
|
||||
const (
|
||||
PROMOTIONSCOPE_GLOBAL PromotionScope = "GLOBAL"
|
||||
PROMOTIONSCOPE_SINGLE PromotionScope = "SINGLE"
|
||||
)
|
||||
|
||||
type PromotionType string
|
||||
|
||||
func (e PromotionType) Ptr() *PromotionType {
|
||||
return &e
|
||||
}
|
||||
|
||||
const (
|
||||
PROMOTIONTYPE_CASH PromotionType = "CASH"
|
||||
PROMOTIONTYPE_NOCASH PromotionType = "NOCASH"
|
||||
)
|
||||
|
||||
type GoodsDetail struct {
|
||||
MerchantGoodsId *string `json:"merchant_goods_id,omitempty"`
|
||||
WechatpayGoodsId *string `json:"wechatpay_goods_id,omitempty"`
|
||||
GoodsName *string `json:"goods_name,omitempty"`
|
||||
UnitPrice *int64 `json:"unit_price,omitempty"`
|
||||
RefundAmount *int64 `json:"refund_amount,omitempty"`
|
||||
RefundQuantity *int64 `json:"refund_quantity,omitempty"`
|
||||
}
|
||||
|
||||
type Account string
|
||||
|
||||
func (e Account) Ptr() *Account {
|
||||
return &e
|
||||
}
|
||||
|
||||
const (
|
||||
ACCOUNT_AVAILABLE Account = "AVAILABLE"
|
||||
ACCOUNT_UNAVAILABLE Account = "UNAVAILABLE"
|
||||
)
|
||||
@@ -0,0 +1,300 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"demo/wxpay_utility" // 引用微信支付工具库,参考 https://pay.weixin.qq.com/doc/v3/merchant/4015119334
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"time"
|
||||
)
|
||||
|
||||
// 退款申请
|
||||
//
|
||||
// 支付成功后1年内,可通过此接口将款项全部或部分原路退还给用户(也可在商户平台手动操作)。
|
||||
//
|
||||
// 关键注意:
|
||||
// 1. 一笔订单最多50次部分退款,重试必须用原 out_refund_no,否则会重复退款。
|
||||
// 2. 接口返回成功仅表示受理成功,实际结果以退款回调通知或查询退款接口为准。
|
||||
// 3. 原路退还:银行卡1-3个工作日到账,零钱即时到账。
|
||||
// 4. 有代金券的订单部分退款时,退给用户 = 退款金额 × (实付 ÷ 总额),四舍五入。
|
||||
// 5. 有分账的订单,需确保可用余额充足;部分分账未解冻时需先调"完结分账"。
|
||||
func main() {
|
||||
// TODO: 请准备商户开发必要参数,参考:https://pay.weixin.qq.com/doc/v3/merchant/4013070756
|
||||
config, err := wxpay_utility.CreateMchConfig(
|
||||
"19xxxxxxxx", // 商户号,是由微信支付系统生成并分配给每个商户的唯一标识符,商户号获取方式参考 https://pay.weixin.qq.com/doc/v3/merchant/4013070756
|
||||
"1DDE55AD98Exxxxxxxxxx", // 商户API证书序列号,如何获取请参考 https://pay.weixin.qq.com/doc/v3/merchant/4013053053
|
||||
"/path/to/apiclient_key.pem", // 商户API证书私钥文件路径,本地文件路径
|
||||
"PUB_KEY_ID_xxxxxxxxxxxxx", // 微信支付公钥ID,如何获取请参考 https://pay.weixin.qq.com/doc/v3/merchant/4013038816
|
||||
"/path/to/wxp_pub.pem", // 微信支付公钥文件路径,本地文件路径
|
||||
)
|
||||
if err != nil {
|
||||
fmt.Println(err)
|
||||
return
|
||||
}
|
||||
|
||||
request := &CreateRequest{
|
||||
TransactionId: wxpay_utility.String("1217752501201407033233368018"),
|
||||
OutTradeNo: wxpay_utility.String("1217752501201407033233368018"),
|
||||
OutRefundNo: wxpay_utility.String("1217752501201407033233368018"),
|
||||
Reason: wxpay_utility.String("商品已售完"),
|
||||
NotifyUrl: wxpay_utility.String("https://weixin.qq.com"),
|
||||
FundsAccount: REQFUNDSACCOUNT_AVAILABLE.Ptr(),
|
||||
Amount: &AmountReq{
|
||||
Refund: wxpay_utility.Int64(888),
|
||||
From: []FundsFromItem{FundsFromItem{
|
||||
Account: ACCOUNT_AVAILABLE.Ptr(),
|
||||
Amount: wxpay_utility.Int64(444),
|
||||
}},
|
||||
Total: wxpay_utility.Int64(888),
|
||||
Currency: wxpay_utility.String("CNY"),
|
||||
},
|
||||
GoodsDetail: []GoodsDetail{GoodsDetail{
|
||||
MerchantGoodsId: wxpay_utility.String("1217752501201407033233368018"),
|
||||
WechatpayGoodsId: wxpay_utility.String("1001"),
|
||||
GoodsName: wxpay_utility.String("iPhone6s 16G"),
|
||||
UnitPrice: wxpay_utility.Int64(528800),
|
||||
RefundAmount: wxpay_utility.Int64(528800),
|
||||
RefundQuantity: wxpay_utility.Int64(1),
|
||||
}},
|
||||
}
|
||||
|
||||
response, err := CreateRefund(config, request)
|
||||
if err != nil {
|
||||
fmt.Printf("请求失败: %+v\n", err)
|
||||
// TODO: 请求失败,根据状态码执行不同的处理
|
||||
return
|
||||
}
|
||||
|
||||
// TODO: 请求成功,继续业务逻辑
|
||||
fmt.Printf("请求成功: %+v\n", response)
|
||||
}
|
||||
|
||||
// CreateRefund 退款申请
|
||||
//
|
||||
// 在交易完成后的一年内(以支付成功时间为起点+365天计算),若因用户或商户方面导致需进行订单退款,
|
||||
// 商户可通过此接口将支付金额的全部或部分原路退还至用户。
|
||||
//
|
||||
// 注意:
|
||||
// 1. 一笔订单最多支持50次部分退款(若需多次部分退款,请更换商户退款单号并间隔1分钟后再次调用)。
|
||||
// 2. 在申请退款失败后进行重试时,请务必使用原商户退款单号,以避免因重复退款而导致的资金损失。
|
||||
// 3. 同一商户号下,此接口调用成功的频率限制为150QPS,而调用失败报错时的频率限制为6QPS。
|
||||
// 4. 申请退款接口返回成功仅表示退款单已受理成功,具体的退款结果需依据退款结果通知及查询退款的返回信息为准。
|
||||
// 5. 若一个月前的订单申请退款时返回报错"频率限制,1个月之前的订单请降低申请频率再重试",请调整退款时间,再使用原参数进行重试。
|
||||
func CreateRefund(config *wxpay_utility.MchConfig, request *CreateRequest) (response *Refund, err error) {
|
||||
const (
|
||||
host = "https://api.mch.weixin.qq.com"
|
||||
method = "POST"
|
||||
path = "/v3/refund/domestic/refunds"
|
||||
)
|
||||
|
||||
reqUrl, err := url.Parse(fmt.Sprintf("%s%s", host, path))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
reqBody, err := json.Marshal(request)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
httpRequest, err := http.NewRequest(method, reqUrl.String(), bytes.NewReader(reqBody))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
httpRequest.Header.Set("Accept", "application/json")
|
||||
httpRequest.Header.Set("Wechatpay-Serial", config.WechatPayPublicKeyId())
|
||||
httpRequest.Header.Set("Content-Type", "application/json")
|
||||
authorization, err := wxpay_utility.BuildAuthorization(config.MchId(), config.CertificateSerialNo(), config.PrivateKey(), method, reqUrl.RequestURI(), reqBody)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
httpRequest.Header.Set("Authorization", authorization)
|
||||
|
||||
client := &http.Client{}
|
||||
httpResponse, err := client.Do(httpRequest)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
respBody, err := wxpay_utility.ExtractResponseBody(httpResponse)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if httpResponse.StatusCode >= 200 && httpResponse.StatusCode < 300 {
|
||||
// 2XX 成功,验证应答签名
|
||||
err = wxpay_utility.ValidateResponse(
|
||||
config.WechatPayPublicKeyId(),
|
||||
config.WechatPayPublicKey(),
|
||||
&httpResponse.Header,
|
||||
respBody,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
response := &Refund{}
|
||||
if err := json.Unmarshal(respBody, response); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return response, nil
|
||||
} else {
|
||||
return nil, wxpay_utility.NewApiException(
|
||||
httpResponse.StatusCode,
|
||||
httpResponse.Header,
|
||||
respBody,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
type CreateRequest struct {
|
||||
TransactionId *string `json:"transaction_id,omitempty"`
|
||||
OutTradeNo *string `json:"out_trade_no,omitempty"`
|
||||
OutRefundNo *string `json:"out_refund_no,omitempty"`
|
||||
Reason *string `json:"reason,omitempty"`
|
||||
NotifyUrl *string `json:"notify_url,omitempty"`
|
||||
FundsAccount *ReqFundsAccount `json:"funds_account,omitempty"`
|
||||
Amount *AmountReq `json:"amount,omitempty"`
|
||||
GoodsDetail []GoodsDetail `json:"goods_detail,omitempty"`
|
||||
}
|
||||
|
||||
type Refund struct {
|
||||
RefundId *string `json:"refund_id,omitempty"`
|
||||
OutRefundNo *string `json:"out_refund_no,omitempty"`
|
||||
TransactionId *string `json:"transaction_id,omitempty"`
|
||||
OutTradeNo *string `json:"out_trade_no,omitempty"`
|
||||
Channel *Channel `json:"channel,omitempty"`
|
||||
UserReceivedAccount *string `json:"user_received_account,omitempty"`
|
||||
SuccessTime *time.Time `json:"success_time,omitempty"`
|
||||
CreateTime *time.Time `json:"create_time,omitempty"`
|
||||
Status *Status `json:"status,omitempty"`
|
||||
FundsAccount *FundsAccount `json:"funds_account,omitempty"`
|
||||
Amount *Amount `json:"amount,omitempty"`
|
||||
PromotionDetail []Promotion `json:"promotion_detail,omitempty"`
|
||||
}
|
||||
|
||||
type ReqFundsAccount string
|
||||
|
||||
func (e ReqFundsAccount) Ptr() *ReqFundsAccount {
|
||||
return &e
|
||||
}
|
||||
|
||||
const (
|
||||
REQFUNDSACCOUNT_AVAILABLE ReqFundsAccount = "AVAILABLE"
|
||||
REQFUNDSACCOUNT_UNSETTLED ReqFundsAccount = "UNSETTLED"
|
||||
)
|
||||
|
||||
type AmountReq struct {
|
||||
Refund *int64 `json:"refund,omitempty"`
|
||||
From []FundsFromItem `json:"from,omitempty"`
|
||||
Total *int64 `json:"total,omitempty"`
|
||||
Currency *string `json:"currency,omitempty"`
|
||||
}
|
||||
|
||||
type GoodsDetail struct {
|
||||
MerchantGoodsId *string `json:"merchant_goods_id,omitempty"`
|
||||
WechatpayGoodsId *string `json:"wechatpay_goods_id,omitempty"`
|
||||
GoodsName *string `json:"goods_name,omitempty"`
|
||||
UnitPrice *int64 `json:"unit_price,omitempty"`
|
||||
RefundAmount *int64 `json:"refund_amount,omitempty"`
|
||||
RefundQuantity *int64 `json:"refund_quantity,omitempty"`
|
||||
}
|
||||
|
||||
type Channel string
|
||||
|
||||
func (e Channel) Ptr() *Channel {
|
||||
return &e
|
||||
}
|
||||
|
||||
const (
|
||||
CHANNEL_ORIGINAL Channel = "ORIGINAL"
|
||||
CHANNEL_BALANCE Channel = "BALANCE"
|
||||
CHANNEL_OTHER_BALANCE Channel = "OTHER_BALANCE"
|
||||
CHANNEL_OTHER_BANKCARD Channel = "OTHER_BANKCARD"
|
||||
)
|
||||
|
||||
type Status string
|
||||
|
||||
func (e Status) Ptr() *Status {
|
||||
return &e
|
||||
}
|
||||
|
||||
const (
|
||||
STATUS_SUCCESS Status = "SUCCESS"
|
||||
STATUS_CLOSED Status = "CLOSED"
|
||||
STATUS_PROCESSING Status = "PROCESSING"
|
||||
STATUS_ABNORMAL Status = "ABNORMAL"
|
||||
)
|
||||
|
||||
type FundsAccount string
|
||||
|
||||
func (e FundsAccount) Ptr() *FundsAccount {
|
||||
return &e
|
||||
}
|
||||
|
||||
const (
|
||||
FUNDSACCOUNT_UNSETTLED FundsAccount = "UNSETTLED"
|
||||
FUNDSACCOUNT_AVAILABLE FundsAccount = "AVAILABLE"
|
||||
FUNDSACCOUNT_UNAVAILABLE FundsAccount = "UNAVAILABLE"
|
||||
FUNDSACCOUNT_OPERATION FundsAccount = "OPERATION"
|
||||
FUNDSACCOUNT_BASIC FundsAccount = "BASIC"
|
||||
FUNDSACCOUNT_ECNY_BASIC FundsAccount = "ECNY_BASIC"
|
||||
)
|
||||
|
||||
type Amount struct {
|
||||
Total *int64 `json:"total,omitempty"`
|
||||
Refund *int64 `json:"refund,omitempty"`
|
||||
From []FundsFromItem `json:"from,omitempty"`
|
||||
PayerTotal *int64 `json:"payer_total,omitempty"`
|
||||
PayerRefund *int64 `json:"payer_refund,omitempty"`
|
||||
SettlementRefund *int64 `json:"settlement_refund,omitempty"`
|
||||
SettlementTotal *int64 `json:"settlement_total,omitempty"`
|
||||
DiscountRefund *int64 `json:"discount_refund,omitempty"`
|
||||
Currency *string `json:"currency,omitempty"`
|
||||
RefundFee *int64 `json:"refund_fee,omitempty"`
|
||||
}
|
||||
|
||||
type Promotion struct {
|
||||
PromotionId *string `json:"promotion_id,omitempty"`
|
||||
Scope *PromotionScope `json:"scope,omitempty"`
|
||||
Type *PromotionType `json:"type,omitempty"`
|
||||
Amount *int64 `json:"amount,omitempty"`
|
||||
RefundAmount *int64 `json:"refund_amount,omitempty"`
|
||||
GoodsDetail []GoodsDetail `json:"goods_detail,omitempty"`
|
||||
}
|
||||
|
||||
type FundsFromItem struct {
|
||||
Account *Account `json:"account,omitempty"`
|
||||
Amount *int64 `json:"amount,omitempty"`
|
||||
}
|
||||
|
||||
type PromotionScope string
|
||||
|
||||
func (e PromotionScope) Ptr() *PromotionScope {
|
||||
return &e
|
||||
}
|
||||
|
||||
const (
|
||||
PROMOTIONSCOPE_GLOBAL PromotionScope = "GLOBAL"
|
||||
PROMOTIONSCOPE_SINGLE PromotionScope = "SINGLE"
|
||||
)
|
||||
|
||||
type PromotionType string
|
||||
|
||||
func (e PromotionType) Ptr() *PromotionType {
|
||||
return &e
|
||||
}
|
||||
|
||||
const (
|
||||
PROMOTIONTYPE_CASH PromotionType = "CASH"
|
||||
PROMOTIONTYPE_NOCASH PromotionType = "NOCASH"
|
||||
)
|
||||
|
||||
type Account string
|
||||
|
||||
func (e Account) Ptr() *Account {
|
||||
return &e
|
||||
}
|
||||
|
||||
const (
|
||||
ACCOUNT_AVAILABLE Account = "AVAILABLE"
|
||||
ACCOUNT_UNAVAILABLE Account = "UNAVAILABLE"
|
||||
)
|
||||
@@ -0,0 +1,251 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"demo/wxpay_utility" // 引用微信支付工具库,参考 https://pay.weixin.qq.com/doc/v3/merchant/4015119334
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
func main() {
|
||||
// TODO: 请准备商户开发必要参数,参考:https://pay.weixin.qq.com/doc/v3/merchant/4013070756
|
||||
config, err := wxpay_utility.CreateMchConfig(
|
||||
"19xxxxxxxx", // 商户号,是由微信支付系统生成并分配给每个商户的唯一标识符,商户号获取方式参考 https://pay.weixin.qq.com/doc/v3/merchant/4013070756
|
||||
"1DDE55AD98Exxxxxxxxxx", // 商户API证书序列号,如何获取请参考 https://pay.weixin.qq.com/doc/v3/merchant/4013053053
|
||||
"/path/to/apiclient_key.pem", // 商户API证书私钥文件路径,本地文件路径
|
||||
"PUB_KEY_ID_xxxxxxxxxxxxx", // 微信支付公钥ID,如何获取请参考 https://pay.weixin.qq.com/doc/v3/merchant/4013038816
|
||||
"/path/to/wxp_pub.pem", // 微信支付公钥文件路径,本地文件路径
|
||||
)
|
||||
if err != nil {
|
||||
fmt.Println(err)
|
||||
return
|
||||
}
|
||||
|
||||
request := &QueryByOutRefundNoRequest{
|
||||
OutRefundNo: wxpay_utility.String("1217752501201407033233368018"),
|
||||
}
|
||||
|
||||
response, err := QueryByOutRefundNo(config, request)
|
||||
if err != nil {
|
||||
fmt.Printf("请求失败: %+v\n", err)
|
||||
// TODO: 请求失败,根据状态码执行不同的处理
|
||||
return
|
||||
}
|
||||
|
||||
// TODO: 请求成功,继续业务逻辑
|
||||
fmt.Printf("请求成功: %+v\n", response)
|
||||
}
|
||||
|
||||
// QueryByOutRefundNo 查询单笔退款(通过商户退款单号)
|
||||
//
|
||||
// 提交退款申请后,推荐每间隔1分钟调用该接口查询一次退款状态,若超过5分钟仍是退款处理中状态,
|
||||
// 建议开始逐步衰减查询频率(比如之后间隔5分钟、10分钟、20分钟、30分钟……查询一次)。
|
||||
//
|
||||
// 退款有一定延时,零钱支付的订单退款一般5分钟内到账,银行卡支付的订单退款一般1-3个工作日到账。
|
||||
//
|
||||
// 同一商户号查询退款频率限制为300qps,如返回FREQUENCY_LIMITED频率限制报错可间隔1分钟再重试查询。
|
||||
func QueryByOutRefundNo(config *wxpay_utility.MchConfig, request *QueryByOutRefundNoRequest) (response *Refund, err error) {
|
||||
const (
|
||||
host = "https://api.mch.weixin.qq.com"
|
||||
method = "GET"
|
||||
path = "/v3/refund/domestic/refunds/{out_refund_no}"
|
||||
)
|
||||
|
||||
reqUrl, err := url.Parse(fmt.Sprintf("%s%s", host, path))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
reqUrl.Path = strings.Replace(reqUrl.Path, "{out_refund_no}", url.PathEscape(*request.OutRefundNo), -1)
|
||||
query := reqUrl.Query()
|
||||
reqUrl.RawQuery = query.Encode()
|
||||
httpRequest, err := http.NewRequest(method, reqUrl.String(), nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
httpRequest.Header.Set("Accept", "application/json")
|
||||
httpRequest.Header.Set("Wechatpay-Serial", config.WechatPayPublicKeyId())
|
||||
authorization, err := wxpay_utility.BuildAuthorization(config.MchId(), config.CertificateSerialNo(), config.PrivateKey(), method, reqUrl.RequestURI(), nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
httpRequest.Header.Set("Authorization", authorization)
|
||||
|
||||
client := &http.Client{}
|
||||
httpResponse, err := client.Do(httpRequest)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
respBody, err := wxpay_utility.ExtractResponseBody(httpResponse)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if httpResponse.StatusCode >= 200 && httpResponse.StatusCode < 300 {
|
||||
// 2XX 成功,验证应答签名
|
||||
err = wxpay_utility.ValidateResponse(
|
||||
config.WechatPayPublicKeyId(),
|
||||
config.WechatPayPublicKey(),
|
||||
&httpResponse.Header,
|
||||
respBody,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
response := &Refund{}
|
||||
if err := json.Unmarshal(respBody, response); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return response, nil
|
||||
} else {
|
||||
return nil, wxpay_utility.NewApiException(
|
||||
httpResponse.StatusCode,
|
||||
httpResponse.Header,
|
||||
respBody,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
type QueryByOutRefundNoRequest struct {
|
||||
OutRefundNo *string `json:"out_refund_no,omitempty"`
|
||||
}
|
||||
|
||||
func (o *QueryByOutRefundNoRequest) MarshalJSON() ([]byte, error) {
|
||||
type Alias QueryByOutRefundNoRequest
|
||||
a := &struct {
|
||||
OutRefundNo *string `json:"out_refund_no,omitempty"`
|
||||
*Alias
|
||||
}{
|
||||
// 序列化时移除非 Body 字段
|
||||
OutRefundNo: nil,
|
||||
Alias: (*Alias)(o),
|
||||
}
|
||||
return json.Marshal(a)
|
||||
}
|
||||
|
||||
type Refund struct {
|
||||
RefundId *string `json:"refund_id,omitempty"`
|
||||
OutRefundNo *string `json:"out_refund_no,omitempty"`
|
||||
TransactionId *string `json:"transaction_id,omitempty"`
|
||||
OutTradeNo *string `json:"out_trade_no,omitempty"`
|
||||
Channel *Channel `json:"channel,omitempty"`
|
||||
UserReceivedAccount *string `json:"user_received_account,omitempty"`
|
||||
SuccessTime *time.Time `json:"success_time,omitempty"`
|
||||
CreateTime *time.Time `json:"create_time,omitempty"`
|
||||
Status *Status `json:"status,omitempty"`
|
||||
FundsAccount *FundsAccount `json:"funds_account,omitempty"`
|
||||
Amount *Amount `json:"amount,omitempty"`
|
||||
PromotionDetail []Promotion `json:"promotion_detail,omitempty"`
|
||||
}
|
||||
|
||||
type Channel string
|
||||
|
||||
func (e Channel) Ptr() *Channel {
|
||||
return &e
|
||||
}
|
||||
|
||||
const (
|
||||
CHANNEL_ORIGINAL Channel = "ORIGINAL"
|
||||
CHANNEL_BALANCE Channel = "BALANCE"
|
||||
CHANNEL_OTHER_BALANCE Channel = "OTHER_BALANCE"
|
||||
CHANNEL_OTHER_BANKCARD Channel = "OTHER_BANKCARD"
|
||||
)
|
||||
|
||||
type Status string
|
||||
|
||||
func (e Status) Ptr() *Status {
|
||||
return &e
|
||||
}
|
||||
|
||||
const (
|
||||
STATUS_SUCCESS Status = "SUCCESS"
|
||||
STATUS_CLOSED Status = "CLOSED"
|
||||
STATUS_PROCESSING Status = "PROCESSING"
|
||||
STATUS_ABNORMAL Status = "ABNORMAL"
|
||||
)
|
||||
|
||||
type FundsAccount string
|
||||
|
||||
func (e FundsAccount) Ptr() *FundsAccount {
|
||||
return &e
|
||||
}
|
||||
|
||||
const (
|
||||
FUNDSACCOUNT_UNSETTLED FundsAccount = "UNSETTLED"
|
||||
FUNDSACCOUNT_AVAILABLE FundsAccount = "AVAILABLE"
|
||||
FUNDSACCOUNT_UNAVAILABLE FundsAccount = "UNAVAILABLE"
|
||||
FUNDSACCOUNT_OPERATION FundsAccount = "OPERATION"
|
||||
FUNDSACCOUNT_BASIC FundsAccount = "BASIC"
|
||||
FUNDSACCOUNT_ECNY_BASIC FundsAccount = "ECNY_BASIC"
|
||||
)
|
||||
|
||||
type Amount struct {
|
||||
Total *int64 `json:"total,omitempty"`
|
||||
Refund *int64 `json:"refund,omitempty"`
|
||||
From []FundsFromItem `json:"from,omitempty"`
|
||||
PayerTotal *int64 `json:"payer_total,omitempty"`
|
||||
PayerRefund *int64 `json:"payer_refund,omitempty"`
|
||||
SettlementRefund *int64 `json:"settlement_refund,omitempty"`
|
||||
SettlementTotal *int64 `json:"settlement_total,omitempty"`
|
||||
DiscountRefund *int64 `json:"discount_refund,omitempty"`
|
||||
Currency *string `json:"currency,omitempty"`
|
||||
RefundFee *int64 `json:"refund_fee,omitempty"`
|
||||
}
|
||||
|
||||
type Promotion struct {
|
||||
PromotionId *string `json:"promotion_id,omitempty"`
|
||||
Scope *PromotionScope `json:"scope,omitempty"`
|
||||
Type *PromotionType `json:"type,omitempty"`
|
||||
Amount *int64 `json:"amount,omitempty"`
|
||||
RefundAmount *int64 `json:"refund_amount,omitempty"`
|
||||
GoodsDetail []GoodsDetail `json:"goods_detail,omitempty"`
|
||||
}
|
||||
|
||||
type FundsFromItem struct {
|
||||
Account *Account `json:"account,omitempty"`
|
||||
Amount *int64 `json:"amount,omitempty"`
|
||||
}
|
||||
|
||||
type PromotionScope string
|
||||
|
||||
func (e PromotionScope) Ptr() *PromotionScope {
|
||||
return &e
|
||||
}
|
||||
|
||||
const (
|
||||
PROMOTIONSCOPE_GLOBAL PromotionScope = "GLOBAL"
|
||||
PROMOTIONSCOPE_SINGLE PromotionScope = "SINGLE"
|
||||
)
|
||||
|
||||
type PromotionType string
|
||||
|
||||
func (e PromotionType) Ptr() *PromotionType {
|
||||
return &e
|
||||
}
|
||||
|
||||
const (
|
||||
PROMOTIONTYPE_CASH PromotionType = "CASH"
|
||||
PROMOTIONTYPE_NOCASH PromotionType = "NOCASH"
|
||||
)
|
||||
|
||||
type GoodsDetail struct {
|
||||
MerchantGoodsId *string `json:"merchant_goods_id,omitempty"`
|
||||
WechatpayGoodsId *string `json:"wechatpay_goods_id,omitempty"`
|
||||
GoodsName *string `json:"goods_name,omitempty"`
|
||||
UnitPrice *int64 `json:"unit_price,omitempty"`
|
||||
RefundAmount *int64 `json:"refund_amount,omitempty"`
|
||||
RefundQuantity *int64 `json:"refund_quantity,omitempty"`
|
||||
}
|
||||
|
||||
type Account string
|
||||
|
||||
func (e Account) Ptr() *Account {
|
||||
return &e
|
||||
}
|
||||
|
||||
const (
|
||||
ACCOUNT_AVAILABLE Account = "AVAILABLE"
|
||||
ACCOUNT_UNAVAILABLE Account = "UNAVAILABLE"
|
||||
)
|
||||
@@ -0,0 +1,39 @@
|
||||
# JSAPI调起支付
|
||||
|
||||
## 说明
|
||||
|
||||
商户通过JSAPI/小程序下单接口获取到发起支付的必要参数 `prepay_id` 后,再通过微信浏览器内置对象方法(WeixinJSBridge)调起微信支付收银台。
|
||||
|
||||
## 示例代码
|
||||
|
||||
```javascript
|
||||
function onBridgeReady() {
|
||||
WeixinJSBridge.invoke('getBrandWCPayRequest', {
|
||||
"appId": "wx2421b1c4370ec43b", //公众号ID,由商户传入
|
||||
"timeStamp": "1395712654", //时间戳,自1970年以来的秒数
|
||||
"nonceStr": "e61463f8efa94090b1f366cccfbbb444", //随机串
|
||||
"package": "prepay_id=wx21201855730335ac86f8c43d1889123400",
|
||||
"signType": "RSA", //微信签名方式
|
||||
"paySign": "oR9d8PuhnIc+YZ8cBHFCwfgpaK9gd7vaRvkYD7rthRAZ\/X+QBhcCYL21N7cHCTUxbQ+EAt6Uy+lwSN22f5YZvI45MLko8Pfso0jm46v5hqcVwrk6uddkGuT+Cdvu4WBqDzaDjnNa5UK3GfE1Wfl2gHxIIY5lLdUgWFts17D4WuolLLkiFZV+JSHMvH7eaLdT9N5GBovBwu5yYKUR7skR8Fu+LozcSqQixnlEZUfyE55feLOQTUYzLmR9pNtPbPsu6WVhbNHMS3Ss2+AehHvz+n64GDmXxbX++IOBvm2olHu3PsOUGRwhudhVf7UcGcunXt8cqNjKNqZLhLw4jq\/xDg=="
|
||||
},
|
||||
function(res) {
|
||||
if (res.err_msg == "get_brand_wcpay_request:ok") {
|
||||
// 使用以上方式判断前端返回,微信团队郑重提示:
|
||||
// res.err_msg将在用户支付成功后返回ok,但并不保证它绝对可靠,
|
||||
// 商户需进一步调用后端查单确认支付结果。
|
||||
}
|
||||
});
|
||||
}
|
||||
if (typeof WeixinJSBridge == "undefined") {
|
||||
if (document.addEventListener) {
|
||||
document.addEventListener('WeixinJSBridgeReady', onBridgeReady, false);
|
||||
} else if (document.attachEvent) {
|
||||
document.attachEvent('WeixinJSBridgeReady', onBridgeReady);
|
||||
document.attachEvent('onWeixinJSBridgeReady', onBridgeReady);
|
||||
}
|
||||
} else {
|
||||
onBridgeReady();
|
||||
}
|
||||
```
|
||||
|
||||
> **重要**:`res.err_msg` 将在用户支付成功后返回 ok,但并不保证它绝对可靠,商户需进一步调用后端查单确认支付结果。
|
||||
@@ -0,0 +1,254 @@
|
||||
package com.java.demo;
|
||||
|
||||
import com.java.utils.WXPayUtility; // 引用微信支付工具库,参考:https://pay.weixin.qq.com/doc/v3/merchant/4014931831
|
||||
|
||||
import com.google.gson.annotations.SerializedName;
|
||||
import com.google.gson.annotations.Expose;
|
||||
import okhttp3.MediaType;
|
||||
import okhttp3.OkHttpClient;
|
||||
import okhttp3.Request;
|
||||
import okhttp3.RequestBody;
|
||||
import okhttp3.Response;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.io.UncheckedIOException;
|
||||
import java.security.PrivateKey;
|
||||
import java.security.PublicKey;
|
||||
import java.util.ArrayList;
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* JSAPI下单
|
||||
*/
|
||||
public class JsapiPrepay {
|
||||
private static String HOST = "https://api.mch.weixin.qq.com";
|
||||
private static String METHOD = "POST";
|
||||
private static String PATH = "/v3/pay/transactions/jsapi";
|
||||
|
||||
public static void main(String[] args) {
|
||||
// TODO: 请准备商户开发必要参数,参考:https://pay.weixin.qq.com/doc/v3/merchant/4013070756
|
||||
JsapiPrepay client = new JsapiPrepay(
|
||||
"19xxxxxxxx", // 商户号,是由微信支付系统生成并分配给每个商户的唯一标识符,商户号获取方式参考 https://pay.weixin.qq.com/doc/v3/merchant/4013070756
|
||||
"1DDE55AD98Exxxxxxxxxx", // 商户API证书序列号,如何获取请参考 https://pay.weixin.qq.com/doc/v3/merchant/4013053053
|
||||
"/path/to/apiclient_key.pem", // 商户API证书私钥文件路径,本地文件路径
|
||||
"PUB_KEY_ID_xxxxxxxxxxxxx", // 微信支付公钥ID,如何获取请参考 https://pay.weixin.qq.com/doc/v3/merchant/4013038816
|
||||
"/path/to/wxp_pub.pem" // 微信支付公钥文件路径,本地文件路径
|
||||
);
|
||||
|
||||
DirectAPIv3JsapiPrepayRequest request = new DirectAPIv3JsapiPrepayRequest();
|
||||
request.appid = "wxd678efh567hg6787";
|
||||
request.mchid = "1230000109";
|
||||
request.description = "Image形象店-深圳腾大-QQ公仔";
|
||||
request.outTradeNo = "1217752501201407033233368018";
|
||||
request.timeExpire = "2018-06-08T10:34:56+08:00";
|
||||
request.attach = "自定义数据说明";
|
||||
request.notifyUrl = " https://www.weixin.qq.com/wxpay/pay.php";
|
||||
request.goodsTag = "WXG";
|
||||
request.supportFapiao = false;
|
||||
request.amount = new CommonAmountInfo();
|
||||
request.amount.total = 100L;
|
||||
request.amount.currency = "CNY";
|
||||
request.payer = new JsapiReqPayerInfo();
|
||||
request.payer.openid = "oUpF8uMuAJO_M2pxb1Q9zNjWeS6o";
|
||||
request.detail = new CouponInfo();
|
||||
request.detail.costPrice = 608800L;
|
||||
request.detail.invoiceId = "微信123";
|
||||
request.detail.goodsDetail = new ArrayList<>();
|
||||
{
|
||||
GoodsDetail goodsDetailItem = new GoodsDetail();
|
||||
goodsDetailItem.merchantGoodsId = "1246464644";
|
||||
goodsDetailItem.wechatpayGoodsId = "1001";
|
||||
goodsDetailItem.goodsName = "iPhoneX 256G";
|
||||
goodsDetailItem.quantity = 1L;
|
||||
goodsDetailItem.unitPrice = 528800L;
|
||||
request.detail.goodsDetail.add(goodsDetailItem);
|
||||
};
|
||||
request.sceneInfo = new CommonSceneInfo();
|
||||
request.sceneInfo.payerClientIp = "14.23.150.211";
|
||||
request.sceneInfo.deviceId = "013467007045764";
|
||||
request.sceneInfo.storeInfo = new StoreInfo();
|
||||
request.sceneInfo.storeInfo.id = "0001";
|
||||
request.sceneInfo.storeInfo.name = "腾讯大厦分店";
|
||||
request.sceneInfo.storeInfo.areaCode = "440305";
|
||||
request.sceneInfo.storeInfo.address = "广东省深圳市南山区科技中一道10000号";
|
||||
request.settleInfo = new SettleInfo();
|
||||
request.settleInfo.profitSharing = false;
|
||||
try {
|
||||
DirectAPIv3JsapiPrepayResponse response = client.run(request);
|
||||
// TODO: 请求成功,继续业务逻辑
|
||||
System.out.println(response);
|
||||
} catch (WXPayUtility.ApiException e) {
|
||||
// TODO: 请求失败,根据状态码执行不同的逻辑
|
||||
e.printStackTrace();
|
||||
}
|
||||
}
|
||||
|
||||
public DirectAPIv3JsapiPrepayResponse run(DirectAPIv3JsapiPrepayRequest request) {
|
||||
String uri = PATH;
|
||||
String reqBody = WXPayUtility.toJson(request);
|
||||
|
||||
Request.Builder reqBuilder = new Request.Builder().url(HOST + uri);
|
||||
reqBuilder.addHeader("Accept", "application/json");
|
||||
reqBuilder.addHeader("Wechatpay-Serial", wechatPayPublicKeyId);
|
||||
reqBuilder.addHeader("Authorization", WXPayUtility.buildAuthorization(mchid, certificateSerialNo,privateKey, METHOD, uri, reqBody));
|
||||
reqBuilder.addHeader("Content-Type", "application/json");
|
||||
RequestBody requestBody = RequestBody.create(MediaType.parse("application/json; charset=utf-8"), reqBody);
|
||||
reqBuilder.method(METHOD, requestBody);
|
||||
Request httpRequest = reqBuilder.build();
|
||||
|
||||
// 发送HTTP请求
|
||||
OkHttpClient client = new OkHttpClient.Builder().build();
|
||||
try (Response httpResponse = client.newCall(httpRequest).execute()) {
|
||||
String respBody = WXPayUtility.extractBody(httpResponse);
|
||||
if (httpResponse.code() >= 200 && httpResponse.code() < 300) {
|
||||
// 2XX 成功,验证应答签名
|
||||
WXPayUtility.validateResponse(this.wechatPayPublicKeyId, this.wechatPayPublicKey,
|
||||
httpResponse.headers(), respBody);
|
||||
|
||||
// 从HTTP应答报文构建返回数据
|
||||
return WXPayUtility.fromJson(respBody, DirectAPIv3JsapiPrepayResponse.class);
|
||||
} else {
|
||||
throw new WXPayUtility.ApiException(httpResponse.code(), respBody, httpResponse.headers());
|
||||
}
|
||||
} catch (IOException e) {
|
||||
throw new UncheckedIOException("Sending request to " + uri + " failed.", e);
|
||||
}
|
||||
}
|
||||
|
||||
private final String mchid;
|
||||
private final String certificateSerialNo;
|
||||
private final PrivateKey privateKey;
|
||||
private final String wechatPayPublicKeyId;
|
||||
private final PublicKey wechatPayPublicKey;
|
||||
|
||||
public JsapiPrepay(String mchid, String certificateSerialNo, String privateKeyFilePath, String wechatPayPublicKeyId, String wechatPayPublicKeyFilePath) {
|
||||
this.mchid = mchid;
|
||||
this.certificateSerialNo = certificateSerialNo;
|
||||
this.privateKey = WXPayUtility.loadPrivateKeyFromPath(privateKeyFilePath);
|
||||
this.wechatPayPublicKeyId = wechatPayPublicKeyId;
|
||||
this.wechatPayPublicKey = WXPayUtility.loadPublicKeyFromPath(wechatPayPublicKeyFilePath);
|
||||
}
|
||||
|
||||
public static class DirectAPIv3JsapiPrepayRequest {
|
||||
@SerializedName("appid")
|
||||
public String appid;
|
||||
|
||||
@SerializedName("mchid")
|
||||
public String mchid;
|
||||
|
||||
@SerializedName("description")
|
||||
public String description;
|
||||
|
||||
@SerializedName("out_trade_no")
|
||||
public String outTradeNo;
|
||||
|
||||
@SerializedName("time_expire")
|
||||
public String timeExpire;
|
||||
|
||||
@SerializedName("attach")
|
||||
public String attach;
|
||||
|
||||
@SerializedName("notify_url")
|
||||
public String notifyUrl;
|
||||
|
||||
@SerializedName("goods_tag")
|
||||
public String goodsTag;
|
||||
|
||||
@SerializedName("support_fapiao")
|
||||
public Boolean supportFapiao;
|
||||
|
||||
@SerializedName("amount")
|
||||
public CommonAmountInfo amount;
|
||||
|
||||
@SerializedName("payer")
|
||||
public JsapiReqPayerInfo payer;
|
||||
|
||||
@SerializedName("detail")
|
||||
public CouponInfo detail;
|
||||
|
||||
@SerializedName("scene_info")
|
||||
public CommonSceneInfo sceneInfo;
|
||||
|
||||
@SerializedName("settle_info")
|
||||
public SettleInfo settleInfo;
|
||||
}
|
||||
|
||||
public static class DirectAPIv3JsapiPrepayResponse {
|
||||
@SerializedName("prepay_id")
|
||||
public String prepayId;
|
||||
}
|
||||
|
||||
public static class CommonAmountInfo {
|
||||
@SerializedName("total")
|
||||
public Long total;
|
||||
|
||||
@SerializedName("currency")
|
||||
public String currency;
|
||||
}
|
||||
|
||||
public static class JsapiReqPayerInfo {
|
||||
@SerializedName("openid")
|
||||
public String openid;
|
||||
}
|
||||
|
||||
public static class CouponInfo {
|
||||
@SerializedName("cost_price")
|
||||
public Long costPrice;
|
||||
|
||||
@SerializedName("invoice_id")
|
||||
public String invoiceId;
|
||||
|
||||
@SerializedName("goods_detail")
|
||||
public List<GoodsDetail> goodsDetail;
|
||||
}
|
||||
|
||||
public static class CommonSceneInfo {
|
||||
@SerializedName("payer_client_ip")
|
||||
public String payerClientIp;
|
||||
|
||||
@SerializedName("device_id")
|
||||
public String deviceId;
|
||||
|
||||
@SerializedName("store_info")
|
||||
public StoreInfo storeInfo;
|
||||
}
|
||||
|
||||
public static class SettleInfo {
|
||||
@SerializedName("profit_sharing")
|
||||
public Boolean profitSharing;
|
||||
}
|
||||
|
||||
public static class GoodsDetail {
|
||||
@SerializedName("merchant_goods_id")
|
||||
public String merchantGoodsId;
|
||||
|
||||
@SerializedName("wechatpay_goods_id")
|
||||
public String wechatpayGoodsId;
|
||||
|
||||
@SerializedName("goods_name")
|
||||
public String goodsName;
|
||||
|
||||
@SerializedName("quantity")
|
||||
public Long quantity;
|
||||
|
||||
@SerializedName("unit_price")
|
||||
public Long unitPrice;
|
||||
}
|
||||
|
||||
public static class StoreInfo {
|
||||
@SerializedName("id")
|
||||
public String id;
|
||||
|
||||
@SerializedName("name")
|
||||
public String name;
|
||||
|
||||
@SerializedName("area_code")
|
||||
public String areaCode;
|
||||
|
||||
@SerializedName("address")
|
||||
public String address;
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
# 退款结果回调通知
|
||||
|
||||
> 参考官方文档:https://pay.weixin.qq.com/doc/v3/merchant/4012791865
|
||||
|
||||
## 回调描述
|
||||
|
||||
用户支付完成后,商户可根据需求发起退款。当退款单状态发生变更时(变更为退款成功/退款关闭/退款异常),微信支付会通过POST的请求方式,向商户预先设置的退款回调地址(申请退款传入的 notify_url)发送回调通知,让商户知晓退款单的处理结果。
|
||||
|
||||
> **注意**:商户侧对微信支付回调IP有防火墙策略限制的,需要对微信回调IP段开通白名单,详情参考回调处理逻辑注意事项。
|
||||
|
||||
## 回调报文格式
|
||||
|
||||
```json
|
||||
{
|
||||
"id": "EV-2018022511223320873",
|
||||
"create_time": "2018-06-08T10:34:56+08:00",
|
||||
"resource_type": "encrypt-resource",
|
||||
"event_type": "REFUND.SUCCESS",
|
||||
"summary": "退款成功",
|
||||
"resource": {
|
||||
"algorithm": "AEAD_AES_256_GCM",
|
||||
"original_type": "refund",
|
||||
"ciphertext": "...",
|
||||
"nonce": "...",
|
||||
"associated_data": ""
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## 关键字段说明
|
||||
|
||||
| 字段 | 说明 |
|
||||
|------|------|
|
||||
| `event_type` | 退款事件类型:`REFUND.SUCCESS`(退款成功)/ `REFUND.ABNORMAL`(退款异常)/ `REFUND.CLOSED`(退款关闭) |
|
||||
| `resource.original_type` | 固定为 `refund` |
|
||||
| `resource.algorithm` | 加密算法,固定为 `AEAD_AES_256_GCM` |
|
||||
| `resource.ciphertext` | 密文,需使用商户APIv3密钥解密后得到退款详情 |
|
||||
| `resource.associated_data` | 附加数据,解密时作为 AAD 参数 |
|
||||
| `resource.nonce` | 随机串,解密时作为 Nonce 参数 |
|
||||
|
||||
## 解密后的退款详情(resource.ciphertext 解密结果)
|
||||
|
||||
```json
|
||||
{
|
||||
"mchid": "1900000100",
|
||||
"transaction_id": "1008450740201411110005820873",
|
||||
"out_trade_no": "20150806125346",
|
||||
"refund_id": "50200207182018070300011301001",
|
||||
"out_refund_no": "7752501201407033233368018",
|
||||
"refund_status": "SUCCESS",
|
||||
"success_time": "2018-06-08T10:34:56+08:00",
|
||||
"user_received_account": "招商银行信用卡0403",
|
||||
"amount": {
|
||||
"total": 999,
|
||||
"refund": 999,
|
||||
"payer_total": 999,
|
||||
"payer_refund": 999
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
| 字段 | 说明 |
|
||||
|------|------|
|
||||
| `refund_status` | 退款状态:`SUCCESS`(成功)/ `CLOSED`(关闭)/ `PROCESSING`(处理中)/ `ABNORMAL`(异常) |
|
||||
| `user_received_account` | 退款入账账户,如"招商银行信用卡0403"、"支付用户零钱"等 |
|
||||
| `amount.total` | 原订单金额(分) |
|
||||
| `amount.refund` | 退款金额(分) |
|
||||
| `amount.payer_total` | 用户实际支付金额(分) |
|
||||
| `amount.payer_refund` | 用户实际收到的退款金额(分) |
|
||||
|
||||
## 处理要求
|
||||
|
||||
1. 接收到回调后,先验证请求签名(使用微信支付公钥验签)
|
||||
2. 使用商户APIv3密钥 + AEAD_AES_256_GCM 解密 `resource.ciphertext` 得到退款明文
|
||||
3. 验签通过:返回 HTTP 200 或 204,无需返回应答报文
|
||||
4. 验签不通过:返回 HTTP 4XX/5XX + `{"code": "FAIL", "message": "失败"}`
|
||||
5. 应答后再异步处理业务逻辑,避免超时
|
||||
|
||||
## 重试机制
|
||||
|
||||
若商户应答失败或超时(5秒),微信支付按以下频次重试:
|
||||
15s / 15s / 30s / 3m / 10m / 20m / 30m / 30m / 30m / 60m / 3h / 3h / 3h / 6h / 6h(最多15次)
|
||||
|
||||
> **重要**:商户系统不能仅依赖回调通知获取结果,需结合查询退款接口使用,避免遗漏或延迟问题。收到重复通知时请做好幂等处理并持续应答 200。
|
||||
@@ -0,0 +1,154 @@
|
||||
package com.java.demo;
|
||||
|
||||
import com.java.utils.WXPayUtility; // 引用微信支付工具库,参考:https://pay.weixin.qq.com/doc/v3/merchant/4014931831
|
||||
|
||||
import com.google.gson.annotations.SerializedName;
|
||||
import com.google.gson.annotations.Expose;
|
||||
import okhttp3.MediaType;
|
||||
import okhttp3.OkHttpClient;
|
||||
import okhttp3.Request;
|
||||
import okhttp3.RequestBody;
|
||||
import okhttp3.Response;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.io.UncheckedIOException;
|
||||
import java.security.PrivateKey;
|
||||
import java.security.PublicKey;
|
||||
import java.util.ArrayList;
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* 申请交易账单API
|
||||
*
|
||||
* 关键注意:
|
||||
* 1. 次日10点后拉取,API仅支持3个月内单日账单,更早的需在商户平台下载。
|
||||
* 2. 返回的是下载链接(download_url),需二次请求下载(gzip压缩CSV)。
|
||||
* 3. 账单金额单位为"元",与下单API的"分"不同,对账时注意转换。
|
||||
*/
|
||||
public class GetTradeBill {
|
||||
private static String HOST = "https://api.mch.weixin.qq.com";
|
||||
private static String METHOD = "GET";
|
||||
private static String PATH = "/v3/bill/tradebill";
|
||||
|
||||
public static void main(String[] args) {
|
||||
// TODO: 请准备商户开发必要参数,参考:https://pay.weixin.qq.com/doc/v3/merchant/4013070756
|
||||
GetTradeBill client = new GetTradeBill(
|
||||
"19xxxxxxxx", // 商户号,是由微信支付系统生成并分配给每个商户的唯一标识符,商户号获取方式参考 https://pay.weixin.qq.com/doc/v3/merchant/4013070756
|
||||
"1DDE55AD98Exxxxxxxxxx", // 商户API证书序列号,如何获取请参考 https://pay.weixin.qq.com/doc/v3/merchant/4013053053
|
||||
"/path/to/apiclient_key.pem", // 商户API证书私钥文件路径,本地文件路径
|
||||
"PUB_KEY_ID_xxxxxxxxxxxxx", // 微信支付公钥ID,如何获取请参考 https://pay.weixin.qq.com/doc/v3/merchant/4013038816
|
||||
"/path/to/wxp_pub.pem" // 微信支付公钥文件路径,本地文件路径
|
||||
);
|
||||
|
||||
GetTradeBillRequest request = new GetTradeBillRequest();
|
||||
request.billDate = "2019-06-11";
|
||||
request.billType = BillType.ALL;
|
||||
request.tarType = TarType.GZIP;
|
||||
try {
|
||||
QueryBillEntity response = client.run(request);
|
||||
// TODO: 请求成功,继续业务逻辑
|
||||
System.out.println(response);
|
||||
} catch (WXPayUtility.ApiException e) {
|
||||
// TODO: 请求失败,根据状态码执行不同的逻辑
|
||||
e.printStackTrace();
|
||||
}
|
||||
}
|
||||
|
||||
public QueryBillEntity run(GetTradeBillRequest request) {
|
||||
String uri = PATH;
|
||||
Map<String, Object> args = new HashMap<>();
|
||||
args.put("bill_date", request.billDate);
|
||||
args.put("bill_type", request.billType);
|
||||
args.put("tar_type", request.tarType);
|
||||
String queryString = WXPayUtility.urlEncode(args);
|
||||
if (!queryString.isEmpty()) {
|
||||
uri = uri + "?" + queryString;
|
||||
}
|
||||
|
||||
Request.Builder reqBuilder = new Request.Builder().url(HOST + uri);
|
||||
reqBuilder.addHeader("Accept", "application/json");
|
||||
reqBuilder.addHeader("Wechatpay-Serial", wechatPayPublicKeyId);
|
||||
reqBuilder.addHeader("Authorization", WXPayUtility.buildAuthorization(mchid, certificateSerialNo, privateKey, METHOD, uri, null));
|
||||
reqBuilder.method(METHOD, null);
|
||||
Request httpRequest = reqBuilder.build();
|
||||
|
||||
// 发送HTTP请求
|
||||
OkHttpClient client = new OkHttpClient.Builder().build();
|
||||
try (Response httpResponse = client.newCall(httpRequest).execute()) {
|
||||
String respBody = WXPayUtility.extractBody(httpResponse);
|
||||
if (httpResponse.code() >= 200 && httpResponse.code() < 300) {
|
||||
// 2XX 成功,验证应答签名
|
||||
WXPayUtility.validateResponse(this.wechatPayPublicKeyId, this.wechatPayPublicKey,
|
||||
httpResponse.headers(), respBody);
|
||||
|
||||
// 从HTTP应答报文构建返回数据
|
||||
return WXPayUtility.fromJson(respBody, QueryBillEntity.class);
|
||||
} else {
|
||||
throw new WXPayUtility.ApiException(httpResponse.code(), respBody, httpResponse.headers());
|
||||
}
|
||||
} catch (IOException e) {
|
||||
throw new UncheckedIOException("Sending request to " + uri + " failed.", e);
|
||||
}
|
||||
}
|
||||
|
||||
private final String mchid;
|
||||
private final String certificateSerialNo;
|
||||
private final PrivateKey privateKey;
|
||||
private final String wechatPayPublicKeyId;
|
||||
private final PublicKey wechatPayPublicKey;
|
||||
|
||||
public GetTradeBill(String mchid, String certificateSerialNo, String privateKeyFilePath, String wechatPayPublicKeyId, String wechatPayPublicKeyFilePath) {
|
||||
this.mchid = mchid;
|
||||
this.certificateSerialNo = certificateSerialNo;
|
||||
this.privateKey = WXPayUtility.loadPrivateKeyFromPath(privateKeyFilePath);
|
||||
this.wechatPayPublicKeyId = wechatPayPublicKeyId;
|
||||
this.wechatPayPublicKey = WXPayUtility.loadPublicKeyFromPath(wechatPayPublicKeyFilePath);
|
||||
}
|
||||
|
||||
public static class GetTradeBillRequest {
|
||||
@SerializedName("bill_date")
|
||||
@Expose(serialize = false)
|
||||
public String billDate;
|
||||
|
||||
@SerializedName("bill_type")
|
||||
@Expose(serialize = false)
|
||||
public BillType billType;
|
||||
|
||||
@SerializedName("tar_type")
|
||||
@Expose(serialize = false)
|
||||
public TarType tarType;
|
||||
}
|
||||
|
||||
public static class QueryBillEntity {
|
||||
@SerializedName("hash_type")
|
||||
public HashType hashType;
|
||||
|
||||
@SerializedName("hash_value")
|
||||
public String hashValue;
|
||||
|
||||
@SerializedName("download_url")
|
||||
public String downloadUrl;
|
||||
}
|
||||
|
||||
public enum BillType {
|
||||
@SerializedName("ALL")
|
||||
ALL,
|
||||
@SerializedName("SUCCESS")
|
||||
SUCCESS,
|
||||
@SerializedName("REFUND")
|
||||
REFUND
|
||||
}
|
||||
|
||||
public enum TarType {
|
||||
@SerializedName("GZIP")
|
||||
GZIP
|
||||
}
|
||||
|
||||
public enum HashType {
|
||||
@SerializedName("SHA1")
|
||||
SHA1
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,149 @@
|
||||
package com.java.demo;
|
||||
|
||||
import com.java.utils.WXPayUtility; // 引用微信支付工具库,参考:https://pay.weixin.qq.com/doc/v3/merchant/4014931831
|
||||
|
||||
import com.google.gson.annotations.SerializedName;
|
||||
import com.google.gson.annotations.Expose;
|
||||
import okhttp3.MediaType;
|
||||
import okhttp3.OkHttpClient;
|
||||
import okhttp3.Request;
|
||||
import okhttp3.RequestBody;
|
||||
import okhttp3.Response;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.io.UncheckedIOException;
|
||||
import java.security.PrivateKey;
|
||||
import java.security.PublicKey;
|
||||
import java.util.ArrayList;
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* 申请资金账单API
|
||||
*/
|
||||
public class GetFundFlowBill {
|
||||
private static String HOST = "https://api.mch.weixin.qq.com";
|
||||
private static String METHOD = "GET";
|
||||
private static String PATH = "/v3/bill/fundflowbill";
|
||||
|
||||
public static void main(String[] args) {
|
||||
// TODO: 请准备商户开发必要参数,参考:https://pay.weixin.qq.com/doc/v3/merchant/4013070756
|
||||
GetFundFlowBill client = new GetFundFlowBill(
|
||||
"19xxxxxxxx", // 商户号,是由微信支付系统生成并分配给每个商户的唯一标识符,商户号获取方式参考 https://pay.weixin.qq.com/doc/v3/merchant/4013070756
|
||||
"1DDE55AD98Exxxxxxxxxx", // 商户API证书序列号,如何获取请参考 https://pay.weixin.qq.com/doc/v3/merchant/4013053053
|
||||
"/path/to/apiclient_key.pem", // 商户API证书私钥文件路径,本地文件路径
|
||||
"PUB_KEY_ID_xxxxxxxxxxxxx", // 微信支付公钥ID,如何获取请参考 https://pay.weixin.qq.com/doc/v3/merchant/4013038816
|
||||
"/path/to/wxp_pub.pem" // 微信支付公钥文件路径,本地文件路径
|
||||
);
|
||||
|
||||
GetFundFlowBillRequest request = new GetFundFlowBillRequest();
|
||||
request.billDate = "2019-06-11";
|
||||
request.accountType = FundFlowBillAccountType.BASIC;
|
||||
request.tarType = TarType.GZIP;
|
||||
try {
|
||||
QueryBillEntity response = client.run(request);
|
||||
// TODO: 请求成功,继续业务逻辑
|
||||
System.out.println(response);
|
||||
} catch (WXPayUtility.ApiException e) {
|
||||
// TODO: 请求失败,根据状态码执行不同的逻辑
|
||||
e.printStackTrace();
|
||||
}
|
||||
}
|
||||
|
||||
public QueryBillEntity run(GetFundFlowBillRequest request) {
|
||||
String uri = PATH;
|
||||
Map<String, Object> args = new HashMap<>();
|
||||
args.put("bill_date", request.billDate);
|
||||
args.put("account_type", request.accountType);
|
||||
args.put("tar_type", request.tarType);
|
||||
String queryString = WXPayUtility.urlEncode(args);
|
||||
if (!queryString.isEmpty()) {
|
||||
uri = uri + "?" + queryString;
|
||||
}
|
||||
|
||||
Request.Builder reqBuilder = new Request.Builder().url(HOST + uri);
|
||||
reqBuilder.addHeader("Accept", "application/json");
|
||||
reqBuilder.addHeader("Wechatpay-Serial", wechatPayPublicKeyId);
|
||||
reqBuilder.addHeader("Authorization", WXPayUtility.buildAuthorization(mchid, certificateSerialNo, privateKey, METHOD, uri, null));
|
||||
reqBuilder.method(METHOD, null);
|
||||
Request httpRequest = reqBuilder.build();
|
||||
|
||||
// 发送HTTP请求
|
||||
OkHttpClient client = new OkHttpClient.Builder().build();
|
||||
try (Response httpResponse = client.newCall(httpRequest).execute()) {
|
||||
String respBody = WXPayUtility.extractBody(httpResponse);
|
||||
if (httpResponse.code() >= 200 && httpResponse.code() < 300) {
|
||||
// 2XX 成功,验证应答签名
|
||||
WXPayUtility.validateResponse(this.wechatPayPublicKeyId, this.wechatPayPublicKey,
|
||||
httpResponse.headers(), respBody);
|
||||
|
||||
// 从HTTP应答报文构建返回数据
|
||||
return WXPayUtility.fromJson(respBody, QueryBillEntity.class);
|
||||
} else {
|
||||
throw new WXPayUtility.ApiException(httpResponse.code(), respBody, httpResponse.headers());
|
||||
}
|
||||
} catch (IOException e) {
|
||||
throw new UncheckedIOException("Sending request to " + uri + " failed.", e);
|
||||
}
|
||||
}
|
||||
|
||||
private final String mchid;
|
||||
private final String certificateSerialNo;
|
||||
private final PrivateKey privateKey;
|
||||
private final String wechatPayPublicKeyId;
|
||||
private final PublicKey wechatPayPublicKey;
|
||||
|
||||
public GetFundFlowBill(String mchid, String certificateSerialNo, String privateKeyFilePath, String wechatPayPublicKeyId, String wechatPayPublicKeyFilePath) {
|
||||
this.mchid = mchid;
|
||||
this.certificateSerialNo = certificateSerialNo;
|
||||
this.privateKey = WXPayUtility.loadPrivateKeyFromPath(privateKeyFilePath);
|
||||
this.wechatPayPublicKeyId = wechatPayPublicKeyId;
|
||||
this.wechatPayPublicKey = WXPayUtility.loadPublicKeyFromPath(wechatPayPublicKeyFilePath);
|
||||
}
|
||||
|
||||
public static class GetFundFlowBillRequest {
|
||||
@SerializedName("bill_date")
|
||||
@Expose(serialize = false)
|
||||
public String billDate;
|
||||
|
||||
@SerializedName("account_type")
|
||||
@Expose(serialize = false)
|
||||
public FundFlowBillAccountType accountType;
|
||||
|
||||
@SerializedName("tar_type")
|
||||
@Expose(serialize = false)
|
||||
public TarType tarType;
|
||||
}
|
||||
|
||||
public static class QueryBillEntity {
|
||||
@SerializedName("hash_type")
|
||||
public HashType hashType;
|
||||
|
||||
@SerializedName("hash_value")
|
||||
public String hashValue;
|
||||
|
||||
@SerializedName("download_url")
|
||||
public String downloadUrl;
|
||||
}
|
||||
|
||||
public enum FundFlowBillAccountType {
|
||||
@SerializedName("BASIC")
|
||||
BASIC,
|
||||
@SerializedName("OPERATION")
|
||||
OPERATION,
|
||||
@SerializedName("FEES")
|
||||
FEES
|
||||
}
|
||||
|
||||
public enum TarType {
|
||||
@SerializedName("GZIP")
|
||||
GZIP
|
||||
}
|
||||
|
||||
public enum HashType {
|
||||
@SerializedName("SHA1")
|
||||
SHA1
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,167 @@
|
||||
package com.java.demo;
|
||||
|
||||
import com.google.gson.annotations.Expose;
|
||||
import com.google.gson.annotations.SerializedName;
|
||||
import com.java.utils.WXPayUtility; // 引用微信支付工具库 参考:https://pay.weixin.qq.com/doc/v3/merchant/4014931831
|
||||
import java.io.IOException;
|
||||
import java.io.UncheckedIOException;
|
||||
import java.io.InputStream;
|
||||
import java.io.FileOutputStream;
|
||||
import java.io.FileInputStream;
|
||||
import java.io.IOException;
|
||||
import java.security.PrivateKey;
|
||||
import java.security.PublicKey;
|
||||
import okhttp3.OkHttpClient;
|
||||
import okhttp3.Request;
|
||||
import okhttp3.RequestBody;
|
||||
import okhttp3.ResponseBody;
|
||||
import okhttp3.Response;
|
||||
import java.net.URI;
|
||||
import java.net.URISyntaxException;
|
||||
import org.apache.commons.codec.digest.DigestUtils;
|
||||
import java.util.zip.GZIPInputStream;
|
||||
|
||||
/**
|
||||
* 下载账单
|
||||
*
|
||||
* 当商户调用申请交易账单/申请资金账单接口,获取到下载账单链接download_url后,
|
||||
* 需按照V3接口规则生成签名,然后请求下载账单链接download_url获取对应的账单文件。
|
||||
*/
|
||||
public class DownloadBill {
|
||||
private static String METHOD = "GET";
|
||||
|
||||
public static void main(String[] args) {
|
||||
// TODO: 请准备商户开发必要参数,参考:https://pay.weixin.qq.com/doc/v3/merchant/4013070756
|
||||
DownloadBill client = new DownloadBill(
|
||||
"19xxxxxxxx", // 商户号,是由微信支付系统生成并分配给每个商户的唯一标识符,商户号获取方式参考 https://pay.weixin.qq.com/doc/v3/merchant/4013070756
|
||||
"1DDE55AD98Exxxxxxxxxx", // 商户API证书序列号,如何获取请参考 https://pay.weixin.qq.com/doc/v3/merchant/4013053053
|
||||
"/path/to/apiclient_key.pem", // 商户API证书私钥文件路径,本地文件路径
|
||||
"PUB_KEY_ID_xxxxxxxxxxxxx", // 微信支付公钥ID,如何获取请参考 https://pay.weixin.qq.com/doc/v3/merchant/4013038816
|
||||
"/path/to/wxp_pub.pem" // 微信支付公钥文件路径,本地文件路径
|
||||
);
|
||||
|
||||
DownloadBillRequest request = new DownloadBillRequest();
|
||||
request.downloadUrl = "https://api.mch.weixin.qq.com/v3/billdownload/file?token=xxx";
|
||||
request.localFilePath = "downloaded_bill.csv";
|
||||
request.expectedHashType = HashType.SHA1;
|
||||
request.expectedHashValue = "79bb0f45fc4c42234a918000b2668d689e2bde04";
|
||||
request.tarType = TarType.GZIP;
|
||||
try {
|
||||
client.run(request);
|
||||
|
||||
// TODO: 请求成功,继续业务逻辑
|
||||
System.out.println("File downloaded successfully! Local file path: " + request.localFilePath);
|
||||
} catch (WXPayUtility.ApiException e) {
|
||||
// TODO: 请求失败,根据状态码执行不同的逻辑
|
||||
e.printStackTrace();
|
||||
}
|
||||
}
|
||||
|
||||
public void run(DownloadBillRequest request) {
|
||||
Request.Builder reqBuilder = new Request.Builder().url(request.downloadUrl);
|
||||
|
||||
String uri = getPathQueryFromUrl(request.downloadUrl);
|
||||
reqBuilder.addHeader("Accept", "application/json");
|
||||
reqBuilder.addHeader("Wechatpay-Serial", wechatPayPublicKeyId);
|
||||
reqBuilder.addHeader("Authorization",
|
||||
WXPayUtility.buildAuthorization(mchid, certificateSerialNo, privateKey, METHOD, uri, null));
|
||||
reqBuilder.method(METHOD, null);
|
||||
Request httpRequest = reqBuilder.build();
|
||||
|
||||
// 发送HTTP请求
|
||||
OkHttpClient client = new OkHttpClient.Builder().build();
|
||||
try (Response httpResponse = client.newCall(httpRequest).execute()) {
|
||||
if (httpResponse.code() < 200 || httpResponse.code() > 300) {
|
||||
String respBody = WXPayUtility.extractBody(httpResponse);
|
||||
throw new WXPayUtility.ApiException(httpResponse.code(), respBody, httpResponse.headers());
|
||||
}
|
||||
|
||||
// 2XX 成功,流式下载文件
|
||||
ResponseBody body = httpResponse.body();
|
||||
if (body == null) {
|
||||
throw new IOException("Response body is null");
|
||||
}
|
||||
// 读取流
|
||||
try (InputStream inputStream = (request.tarType == DownloadBill.TarType.GZIP)
|
||||
? new GZIPInputStream(body.byteStream())
|
||||
: body.byteStream();
|
||||
FileOutputStream outputStream = new FileOutputStream(request.localFilePath)) {
|
||||
|
||||
byte[] buffer = new byte[8096];
|
||||
int bytesRead;
|
||||
while ((bytesRead = inputStream.read(buffer)) != -1) {
|
||||
outputStream.write(buffer, 0, bytesRead);
|
||||
}
|
||||
outputStream.flush();
|
||||
}
|
||||
// 下载成功后校验文件SHA1
|
||||
if (request.expectedHashType == HashType.SHA1) {
|
||||
String sha1 = DigestUtils.sha1Hex(new FileInputStream(request.localFilePath));
|
||||
if (!sha1.equals(request.expectedHashValue)) {
|
||||
throw new IOException("SHA1 checksum mismatch");
|
||||
}
|
||||
}
|
||||
} catch (IOException e) {
|
||||
throw new UncheckedIOException("Sending request to " + uri + " failed.", e);
|
||||
}
|
||||
}
|
||||
|
||||
private String getPathQueryFromUrl(String url) {
|
||||
try {
|
||||
URI uri = new URI(url);
|
||||
String path = uri.getRawPath(); // /v3/billdownload/file
|
||||
String query = uri.getRawQuery(); // token=xxx&tartype=gzip
|
||||
return (query == null || query.isEmpty()) ? path : path + "?" + query;
|
||||
} catch (URISyntaxException e) {
|
||||
e.printStackTrace();
|
||||
return "";
|
||||
}
|
||||
}
|
||||
|
||||
private final String mchid;
|
||||
private final String certificateSerialNo;
|
||||
private final PrivateKey privateKey;
|
||||
private final String wechatPayPublicKeyId;
|
||||
private final PublicKey wechatPayPublicKey;
|
||||
|
||||
public DownloadBill(String mchid, String certificateSerialNo, String privateKeyFilePath,
|
||||
String wechatPayPublicKeyId, String wechatPayPublicKeyFilePath) {
|
||||
this.mchid = mchid;
|
||||
this.certificateSerialNo = certificateSerialNo;
|
||||
this.privateKey = WXPayUtility.loadPrivateKeyFromPath(privateKeyFilePath);
|
||||
this.wechatPayPublicKeyId = wechatPayPublicKeyId;
|
||||
this.wechatPayPublicKey = WXPayUtility.loadPublicKeyFromPath(wechatPayPublicKeyFilePath);
|
||||
}
|
||||
|
||||
public enum HashType {
|
||||
@SerializedName("SHA1")
|
||||
SHA1
|
||||
}
|
||||
|
||||
public static class DownloadBillRequest {
|
||||
@SerializedName("download_url")
|
||||
@Expose(serialize = false)
|
||||
public String downloadUrl;
|
||||
|
||||
@SerializedName("local_file_path")
|
||||
@Expose(serialize = false)
|
||||
public String localFilePath;
|
||||
|
||||
@SerializedName("expected_hash_type")
|
||||
@Expose(serialize = false)
|
||||
public HashType expectedHashType;
|
||||
|
||||
@SerializedName("expected_hash_value")
|
||||
@Expose(serialize = false)
|
||||
public String expectedHashValue;
|
||||
|
||||
@SerializedName("tar_type")
|
||||
@Expose(serialize = false)
|
||||
public TarType tarType;
|
||||
}
|
||||
|
||||
public enum TarType {
|
||||
@SerializedName("GZIP")
|
||||
GZIP
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
package com.java.utils;
|
||||
|
||||
import okhttp3.MediaType;
|
||||
import okhttp3.OkHttpClient;
|
||||
import okhttp3.Request;
|
||||
import okhttp3.RequestBody;
|
||||
import okhttp3.Response;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.io.UncheckedIOException;
|
||||
import java.security.PrivateKey;
|
||||
import java.security.PublicKey;
|
||||
|
||||
/**
|
||||
* 微信支付 HTTP 客户端,封装了请求签名、发送、应答验签的完整流程。
|
||||
* 依赖 WXPayUtility 提供的签名、验签、序列化等基础能力。
|
||||
*/
|
||||
public class WXPayClient {
|
||||
private static final String HOST = "https://api.mch.weixin.qq.com";
|
||||
|
||||
private final String mchid;
|
||||
private final String certificateSerialNo;
|
||||
private final PrivateKey privateKey;
|
||||
private final String wechatPayPublicKeyId;
|
||||
private final PublicKey wechatPayPublicKey;
|
||||
|
||||
public WXPayClient(String mchid, String certificateSerialNo, String privateKeyFilePath,
|
||||
String wechatPayPublicKeyId, String wechatPayPublicKeyFilePath) {
|
||||
this.mchid = mchid;
|
||||
this.certificateSerialNo = certificateSerialNo;
|
||||
this.privateKey = WXPayUtility.loadPrivateKeyFromPath(privateKeyFilePath);
|
||||
this.wechatPayPublicKeyId = wechatPayPublicKeyId;
|
||||
this.wechatPayPublicKey = WXPayUtility.loadPublicKeyFromPath(wechatPayPublicKeyFilePath);
|
||||
}
|
||||
|
||||
/**
|
||||
* 发送 GET 请求,返回已验签的应答 Body
|
||||
*/
|
||||
public String sendGet(String uri) {
|
||||
return sendRequest("GET", uri, null);
|
||||
}
|
||||
|
||||
/**
|
||||
* 发送 POST 请求,返回已验签的应答 Body
|
||||
*/
|
||||
public String sendPost(String uri, String reqBody) {
|
||||
return sendRequest("POST", uri, reqBody);
|
||||
}
|
||||
|
||||
/**
|
||||
* 使用公钥加密敏感信息
|
||||
*/
|
||||
public String encrypt(String plainText) {
|
||||
return WXPayUtility.encrypt(this.wechatPayPublicKey, plainText);
|
||||
}
|
||||
|
||||
private String sendRequest(String method, String uri, String reqBody) {
|
||||
Request.Builder reqBuilder = new Request.Builder().url(HOST + uri);
|
||||
reqBuilder.addHeader("Accept", "application/json");
|
||||
reqBuilder.addHeader("Wechatpay-Serial", wechatPayPublicKeyId);
|
||||
reqBuilder.addHeader("Authorization", WXPayUtility.buildAuthorization(
|
||||
mchid, certificateSerialNo, privateKey, method, uri, reqBody));
|
||||
|
||||
if (reqBody != null) {
|
||||
reqBuilder.addHeader("Content-Type", "application/json");
|
||||
RequestBody body = RequestBody.create(
|
||||
MediaType.parse("application/json; charset=utf-8"), reqBody);
|
||||
reqBuilder.method(method, body);
|
||||
} else {
|
||||
reqBuilder.method(method, null);
|
||||
}
|
||||
|
||||
OkHttpClient client = new OkHttpClient.Builder().build();
|
||||
try (Response httpResponse = client.newCall(reqBuilder.build()).execute()) {
|
||||
String respBody = WXPayUtility.extractBody(httpResponse);
|
||||
if (httpResponse.code() >= 200 && httpResponse.code() < 300) {
|
||||
WXPayUtility.validateResponse(wechatPayPublicKeyId, wechatPayPublicKey,
|
||||
httpResponse.headers(), respBody);
|
||||
return respBody;
|
||||
} else {
|
||||
throw new WXPayUtility.ApiException(httpResponse.code(), respBody, httpResponse.headers());
|
||||
}
|
||||
} catch (IOException e) {
|
||||
throw new UncheckedIOException("Sending request to " + uri + " failed.", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,700 @@
|
||||
package com.java.utils;
|
||||
|
||||
import com.google.gson.ExclusionStrategy;
|
||||
import com.google.gson.FieldAttributes;
|
||||
import com.google.gson.Gson;
|
||||
import com.google.gson.GsonBuilder;
|
||||
import com.google.gson.JsonElement;
|
||||
import com.google.gson.JsonObject;
|
||||
import com.google.gson.JsonSyntaxException;
|
||||
import com.google.gson.annotations.Expose;
|
||||
import com.google.gson.annotations.SerializedName;
|
||||
import java.util.List;
|
||||
import java.util.Map.Entry;
|
||||
import okhttp3.Headers;
|
||||
import okhttp3.Response;
|
||||
import okio.BufferedSource;
|
||||
|
||||
import javax.crypto.BadPaddingException;
|
||||
import javax.crypto.Cipher;
|
||||
import javax.crypto.IllegalBlockSizeException;
|
||||
import javax.crypto.NoSuchPaddingException;
|
||||
import javax.crypto.spec.GCMParameterSpec;
|
||||
import javax.crypto.spec.SecretKeySpec;
|
||||
import java.io.IOException;
|
||||
import java.io.UncheckedIOException;
|
||||
import java.io.UnsupportedEncodingException;
|
||||
import java.net.URLEncoder;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Paths;
|
||||
import java.security.InvalidAlgorithmParameterException;
|
||||
import java.security.InvalidKeyException;
|
||||
import java.security.KeyFactory;
|
||||
import java.security.NoSuchAlgorithmException;
|
||||
import java.security.PrivateKey;
|
||||
import java.security.PublicKey;
|
||||
import java.security.SecureRandom;
|
||||
import java.security.Signature;
|
||||
import java.security.SignatureException;
|
||||
import java.security.spec.InvalidKeySpecException;
|
||||
import java.security.spec.PKCS8EncodedKeySpec;
|
||||
import java.security.spec.X509EncodedKeySpec;
|
||||
import java.time.DateTimeException;
|
||||
import java.time.Duration;
|
||||
import java.time.Instant;
|
||||
import java.util.Base64;
|
||||
import java.util.HashMap;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
import java.security.MessageDigest;
|
||||
import java.io.InputStream;
|
||||
import org.bouncycastle.crypto.digests.SM3Digest;
|
||||
import org.bouncycastle.jce.provider.BouncyCastleProvider;
|
||||
import java.security.Security;
|
||||
|
||||
public class WXPayUtility {
|
||||
private static final Gson gson = new GsonBuilder()
|
||||
.disableHtmlEscaping()
|
||||
.addSerializationExclusionStrategy(new ExclusionStrategy() {
|
||||
@Override
|
||||
public boolean shouldSkipField(FieldAttributes fieldAttributes) {
|
||||
final Expose expose = fieldAttributes.getAnnotation(Expose.class);
|
||||
return expose != null && !expose.serialize();
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean shouldSkipClass(Class<?> aClass) {
|
||||
return false;
|
||||
}
|
||||
})
|
||||
.addDeserializationExclusionStrategy(new ExclusionStrategy() {
|
||||
@Override
|
||||
public boolean shouldSkipField(FieldAttributes fieldAttributes) {
|
||||
final Expose expose = fieldAttributes.getAnnotation(Expose.class);
|
||||
return expose != null && !expose.deserialize();
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean shouldSkipClass(Class<?> aClass) {
|
||||
return false;
|
||||
}
|
||||
})
|
||||
.create();
|
||||
private static final char[] SYMBOLS =
|
||||
"0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ".toCharArray();
|
||||
private static final SecureRandom random = new SecureRandom();
|
||||
|
||||
public static String toJson(Object object) {
|
||||
return gson.toJson(object);
|
||||
}
|
||||
|
||||
public static <T> T fromJson(String json, Class<T> classOfT) throws JsonSyntaxException {
|
||||
return gson.fromJson(json, classOfT);
|
||||
}
|
||||
|
||||
private static String readKeyStringFromPath(String keyPath) {
|
||||
try {
|
||||
return new String(Files.readAllBytes(Paths.get(keyPath)), StandardCharsets.UTF_8);
|
||||
} catch (IOException e) {
|
||||
throw new UncheckedIOException(e);
|
||||
}
|
||||
}
|
||||
|
||||
public static PrivateKey loadPrivateKeyFromString(String keyString) {
|
||||
try {
|
||||
keyString = keyString.replace("-----BEGIN PRIVATE KEY-----", "")
|
||||
.replace("-----END PRIVATE KEY-----", "")
|
||||
.replaceAll("\\s+", "");
|
||||
return KeyFactory.getInstance("RSA").generatePrivate(
|
||||
new PKCS8EncodedKeySpec(Base64.getDecoder().decode(keyString)));
|
||||
} catch (NoSuchAlgorithmException e) {
|
||||
throw new UnsupportedOperationException(e);
|
||||
} catch (InvalidKeySpecException e) {
|
||||
throw new IllegalArgumentException(e);
|
||||
}
|
||||
}
|
||||
|
||||
public static PrivateKey loadPrivateKeyFromPath(String keyPath) {
|
||||
return loadPrivateKeyFromString(readKeyStringFromPath(keyPath));
|
||||
}
|
||||
|
||||
public static PublicKey loadPublicKeyFromString(String keyString) {
|
||||
try {
|
||||
keyString = keyString.replace("-----BEGIN PUBLIC KEY-----", "")
|
||||
.replace("-----END PUBLIC KEY-----", "")
|
||||
.replaceAll("\\s+", "");
|
||||
return KeyFactory.getInstance("RSA").generatePublic(
|
||||
new X509EncodedKeySpec(Base64.getDecoder().decode(keyString)));
|
||||
} catch (NoSuchAlgorithmException e) {
|
||||
throw new UnsupportedOperationException(e);
|
||||
} catch (InvalidKeySpecException e) {
|
||||
throw new IllegalArgumentException(e);
|
||||
}
|
||||
}
|
||||
|
||||
public static PublicKey loadPublicKeyFromPath(String keyPath) {
|
||||
return loadPublicKeyFromString(readKeyStringFromPath(keyPath));
|
||||
}
|
||||
|
||||
public static String createNonce(int length) {
|
||||
char[] buf = new char[length];
|
||||
for (int i = 0; i < length; ++i) {
|
||||
buf[i] = SYMBOLS[random.nextInt(SYMBOLS.length)];
|
||||
}
|
||||
return new String(buf);
|
||||
}
|
||||
|
||||
public static String encrypt(PublicKey publicKey, String plaintext) {
|
||||
final String transformation = "RSA/ECB/OAEPWithSHA-1AndMGF1Padding";
|
||||
|
||||
try {
|
||||
Cipher cipher = Cipher.getInstance(transformation);
|
||||
cipher.init(Cipher.ENCRYPT_MODE, publicKey);
|
||||
return Base64.getEncoder().encodeToString(cipher.doFinal(plaintext.getBytes(StandardCharsets.UTF_8)));
|
||||
} catch (NoSuchAlgorithmException | NoSuchPaddingException e) {
|
||||
throw new IllegalArgumentException("The current Java environment does not support " + transformation, e);
|
||||
} catch (InvalidKeyException e) {
|
||||
throw new IllegalArgumentException("RSA encryption using an illegal publicKey", e);
|
||||
} catch (BadPaddingException | IllegalBlockSizeException e) {
|
||||
throw new IllegalArgumentException("Plaintext is too long", e);
|
||||
}
|
||||
}
|
||||
|
||||
public static String rsaOaepDecrypt(PrivateKey privateKey, String ciphertext) {
|
||||
final String transformation = "RSA/ECB/OAEPWithSHA-1AndMGF1Padding";
|
||||
|
||||
try {
|
||||
Cipher cipher = Cipher.getInstance(transformation);
|
||||
cipher.init(Cipher.DECRYPT_MODE, privateKey);
|
||||
byte[] decryptedBytes = cipher.doFinal(Base64.getDecoder().decode(ciphertext));
|
||||
return new String(decryptedBytes, StandardCharsets.UTF_8);
|
||||
} catch (NoSuchAlgorithmException | NoSuchPaddingException e) {
|
||||
throw new IllegalArgumentException("The current Java environment does not support " + transformation, e);
|
||||
} catch (InvalidKeyException e) {
|
||||
throw new IllegalArgumentException("RSA decryption using an illegal privateKey", e);
|
||||
} catch (BadPaddingException | IllegalBlockSizeException e) {
|
||||
throw new IllegalArgumentException("Ciphertext decryption failed", e);
|
||||
}
|
||||
}
|
||||
|
||||
public static String aesAeadDecrypt(byte[] key, byte[] associatedData, byte[] nonce,
|
||||
byte[] ciphertext) {
|
||||
final String transformation = "AES/GCM/NoPadding";
|
||||
final String algorithm = "AES";
|
||||
final int tagLengthBit = 128;
|
||||
|
||||
try {
|
||||
Cipher cipher = Cipher.getInstance(transformation);
|
||||
cipher.init(
|
||||
Cipher.DECRYPT_MODE,
|
||||
new SecretKeySpec(key, algorithm),
|
||||
new GCMParameterSpec(tagLengthBit, nonce));
|
||||
if (associatedData != null) {
|
||||
cipher.updateAAD(associatedData);
|
||||
}
|
||||
return new String(cipher.doFinal(ciphertext), StandardCharsets.UTF_8);
|
||||
} catch (InvalidKeyException
|
||||
| InvalidAlgorithmParameterException
|
||||
| BadPaddingException
|
||||
| IllegalBlockSizeException
|
||||
| NoSuchAlgorithmException
|
||||
| NoSuchPaddingException e) {
|
||||
throw new IllegalArgumentException(String.format("AesAeadDecrypt with %s Failed",
|
||||
transformation), e);
|
||||
}
|
||||
}
|
||||
|
||||
public static String sign(String message, String algorithm, PrivateKey privateKey) {
|
||||
byte[] sign;
|
||||
try {
|
||||
Signature signature = Signature.getInstance(algorithm);
|
||||
signature.initSign(privateKey);
|
||||
signature.update(message.getBytes(StandardCharsets.UTF_8));
|
||||
sign = signature.sign();
|
||||
} catch (NoSuchAlgorithmException e) {
|
||||
throw new UnsupportedOperationException("The current Java environment does not support " + algorithm, e);
|
||||
} catch (InvalidKeyException e) {
|
||||
throw new IllegalArgumentException(algorithm + " signature uses an illegal privateKey.", e);
|
||||
} catch (SignatureException e) {
|
||||
throw new RuntimeException("An error occurred during the sign process.", e);
|
||||
}
|
||||
return Base64.getEncoder().encodeToString(sign);
|
||||
}
|
||||
|
||||
public static boolean verify(String message, String signature, String algorithm,
|
||||
PublicKey publicKey) {
|
||||
try {
|
||||
Signature sign = Signature.getInstance(algorithm);
|
||||
sign.initVerify(publicKey);
|
||||
sign.update(message.getBytes(StandardCharsets.UTF_8));
|
||||
return sign.verify(Base64.getDecoder().decode(signature));
|
||||
} catch (SignatureException e) {
|
||||
return false;
|
||||
} catch (InvalidKeyException e) {
|
||||
throw new IllegalArgumentException("verify uses an illegal publickey.", e);
|
||||
} catch (NoSuchAlgorithmException e) {
|
||||
throw new UnsupportedOperationException("The current Java environment does not support" + algorithm, e);
|
||||
}
|
||||
}
|
||||
|
||||
public static String buildAuthorization(String mchid, String certificateSerialNo,
|
||||
PrivateKey privateKey,
|
||||
String method, String uri, String body) {
|
||||
String nonce = createNonce(32);
|
||||
long timestamp = Instant.now().getEpochSecond();
|
||||
|
||||
String message = String.format("%s\n%s\n%d\n%s\n%s\n", method, uri, timestamp, nonce,
|
||||
body == null ? "" : body);
|
||||
|
||||
String signature = sign(message, "SHA256withRSA", privateKey);
|
||||
|
||||
return String.format(
|
||||
"WECHATPAY2-SHA256-RSA2048 mchid=\"%s\",nonce_str=\"%s\",signature=\"%s\"," +
|
||||
"timestamp=\"%d\",serial_no=\"%s\"",
|
||||
mchid, nonce, signature, timestamp, certificateSerialNo);
|
||||
}
|
||||
|
||||
private static String calculateHash(InputStream inputStream, String algorithm) {
|
||||
try {
|
||||
MessageDigest digest = MessageDigest.getInstance(algorithm);
|
||||
byte[] buffer = new byte[8192];
|
||||
int bytesRead;
|
||||
while ((bytesRead = inputStream.read(buffer)) != -1) {
|
||||
digest.update(buffer, 0, bytesRead);
|
||||
}
|
||||
byte[] hashBytes = digest.digest();
|
||||
StringBuilder hexString = new StringBuilder();
|
||||
for (byte b : hashBytes) {
|
||||
String hex = Integer.toHexString(0xff & b);
|
||||
if (hex.length() == 1) {
|
||||
hexString.append('0');
|
||||
}
|
||||
hexString.append(hex);
|
||||
}
|
||||
return hexString.toString();
|
||||
} catch (NoSuchAlgorithmException e) {
|
||||
throw new UnsupportedOperationException(algorithm + " algorithm not available", e);
|
||||
} catch (IOException e) {
|
||||
throw new RuntimeException("Error reading from input stream", e);
|
||||
}
|
||||
}
|
||||
|
||||
public static String sha256(InputStream inputStream) {
|
||||
return calculateHash(inputStream, "SHA-256");
|
||||
}
|
||||
|
||||
public static String sha1(InputStream inputStream) {
|
||||
return calculateHash(inputStream, "SHA-1");
|
||||
}
|
||||
|
||||
public static String sm3(InputStream inputStream) {
|
||||
if (Security.getProvider(BouncyCastleProvider.PROVIDER_NAME) == null) {
|
||||
Security.addProvider(new BouncyCastleProvider());
|
||||
}
|
||||
|
||||
try {
|
||||
SM3Digest digest = new SM3Digest();
|
||||
byte[] buffer = new byte[8192];
|
||||
int bytesRead;
|
||||
while ((bytesRead = inputStream.read(buffer)) != -1) {
|
||||
digest.update(buffer, 0, bytesRead);
|
||||
}
|
||||
byte[] hashBytes = new byte[digest.getDigestSize()];
|
||||
digest.doFinal(hashBytes, 0);
|
||||
|
||||
StringBuilder hexString = new StringBuilder();
|
||||
for (byte b : hashBytes) {
|
||||
String hex = Integer.toHexString(0xff & b);
|
||||
if (hex.length() == 1) {
|
||||
hexString.append('0');
|
||||
}
|
||||
hexString.append(hex);
|
||||
}
|
||||
return hexString.toString();
|
||||
} catch (IOException e) {
|
||||
throw new RuntimeException("Error reading from input stream", e);
|
||||
}
|
||||
}
|
||||
|
||||
public static String urlEncode(String content) {
|
||||
try {
|
||||
return URLEncoder.encode(content, StandardCharsets.UTF_8.name());
|
||||
} catch (UnsupportedEncodingException e) {
|
||||
throw new RuntimeException(e);
|
||||
}
|
||||
}
|
||||
|
||||
public static String urlEncode(Map<String, Object> params) {
|
||||
if (params == null || params.isEmpty()) {
|
||||
return "";
|
||||
}
|
||||
|
||||
StringBuilder result = new StringBuilder();
|
||||
for (Entry<String, Object> entry : params.entrySet()) {
|
||||
if (entry.getValue() == null) {
|
||||
continue;
|
||||
}
|
||||
|
||||
String key = entry.getKey();
|
||||
Object value = entry.getValue();
|
||||
if (value instanceof List) {
|
||||
List<?> list = (List<?>) entry.getValue();
|
||||
for (Object temp : list) {
|
||||
appendParam(result, key, temp);
|
||||
}
|
||||
} else {
|
||||
appendParam(result, key, value);
|
||||
}
|
||||
}
|
||||
return result.toString();
|
||||
}
|
||||
|
||||
private static void appendParam(StringBuilder result, String key, Object value) {
|
||||
if (result.length() > 0) {
|
||||
result.append("&");
|
||||
}
|
||||
|
||||
String valueString;
|
||||
if (value instanceof String || value instanceof Number ||
|
||||
value instanceof Boolean || value instanceof Enum) {
|
||||
valueString = value.toString();
|
||||
} else {
|
||||
valueString = toJson(value);
|
||||
}
|
||||
|
||||
result.append(key)
|
||||
.append("=")
|
||||
.append(urlEncode(valueString));
|
||||
}
|
||||
|
||||
public static String extractBody(Response response) {
|
||||
if (response.body() == null) {
|
||||
return "";
|
||||
}
|
||||
|
||||
try {
|
||||
BufferedSource source = response.body().source();
|
||||
return source.readUtf8();
|
||||
} catch (IOException e) {
|
||||
throw new RuntimeException(String.format("An error occurred during reading response body. " +
|
||||
"Status: %d", response.code()), e);
|
||||
}
|
||||
}
|
||||
|
||||
public static void validateResponse(String wechatpayPublicKeyId, PublicKey wechatpayPublicKey,
|
||||
Headers headers,
|
||||
String body) {
|
||||
String timestamp = headers.get("Wechatpay-Timestamp");
|
||||
String requestId = headers.get("Request-ID");
|
||||
try {
|
||||
Instant responseTime = Instant.ofEpochSecond(Long.parseLong(timestamp));
|
||||
if (Duration.between(responseTime, Instant.now()).abs().toMinutes() >= 5) {
|
||||
throw new IllegalArgumentException(
|
||||
String.format("Validate response failed, timestamp[%s] is expired, request-id[%s]",
|
||||
timestamp, requestId));
|
||||
}
|
||||
} catch (DateTimeException | NumberFormatException e) {
|
||||
throw new IllegalArgumentException(
|
||||
String.format("Validate response failed, timestamp[%s] is invalid, request-id[%s]",
|
||||
timestamp, requestId));
|
||||
}
|
||||
String serialNumber = headers.get("Wechatpay-Serial");
|
||||
if (!Objects.equals(serialNumber, wechatpayPublicKeyId)) {
|
||||
throw new IllegalArgumentException(
|
||||
String.format("Validate response failed, Invalid Wechatpay-Serial, Local: %s, Remote: " +
|
||||
"%s", wechatpayPublicKeyId, serialNumber));
|
||||
}
|
||||
|
||||
String signature = headers.get("Wechatpay-Signature");
|
||||
String message = String.format("%s\n%s\n%s\n", timestamp, headers.get("Wechatpay-Nonce"),
|
||||
body == null ? "" : body);
|
||||
|
||||
boolean success = verify(message, signature, "SHA256withRSA", wechatpayPublicKey);
|
||||
if (!success) {
|
||||
throw new IllegalArgumentException(
|
||||
String.format("Validate response failed,the WechatPay signature is incorrect.%n"
|
||||
+ "Request-ID[%s]\tresponseHeader[%s]\tresponseBody[%.1024s]",
|
||||
headers.get("Request-ID"), headers, body));
|
||||
}
|
||||
}
|
||||
|
||||
public static void validateNotification(String wechatpayPublicKeyId,
|
||||
PublicKey wechatpayPublicKey, Headers headers,
|
||||
String body) {
|
||||
String timestamp = headers.get("Wechatpay-Timestamp");
|
||||
try {
|
||||
Instant responseTime = Instant.ofEpochSecond(Long.parseLong(timestamp));
|
||||
if (Duration.between(responseTime, Instant.now()).abs().toMinutes() >= 5) {
|
||||
throw new IllegalArgumentException(
|
||||
String.format("Validate notification failed, timestamp[%s] is expired", timestamp));
|
||||
}
|
||||
} catch (DateTimeException | NumberFormatException e) {
|
||||
throw new IllegalArgumentException(
|
||||
String.format("Validate notification failed, timestamp[%s] is invalid", timestamp));
|
||||
}
|
||||
String serialNumber = headers.get("Wechatpay-Serial");
|
||||
if (!Objects.equals(serialNumber, wechatpayPublicKeyId)) {
|
||||
throw new IllegalArgumentException(
|
||||
String.format("Validate notification failed, Invalid Wechatpay-Serial, Local: %s, " +
|
||||
"Remote: %s",
|
||||
wechatpayPublicKeyId,
|
||||
serialNumber));
|
||||
}
|
||||
|
||||
String signature = headers.get("Wechatpay-Signature");
|
||||
String message = String.format("%s\n%s\n%s\n", timestamp, headers.get("Wechatpay-Nonce"),
|
||||
body == null ? "" : body);
|
||||
|
||||
boolean success = verify(message, signature, "SHA256withRSA", wechatpayPublicKey);
|
||||
if (!success) {
|
||||
throw new IllegalArgumentException(
|
||||
String.format("Validate notification failed, WechatPay signature is incorrect.\n"
|
||||
+ "responseHeader[%s]\tresponseBody[%.1024s]",
|
||||
headers, body));
|
||||
}
|
||||
}
|
||||
|
||||
public static Notification parseNotification(String apiv3Key, String wechatpayPublicKeyId,
|
||||
PublicKey wechatpayPublicKey, Headers headers,
|
||||
String body) {
|
||||
validateNotification(wechatpayPublicKeyId, wechatpayPublicKey, headers, body);
|
||||
Notification notification = gson.fromJson(body, Notification.class);
|
||||
notification.decrypt(apiv3Key);
|
||||
return notification;
|
||||
}
|
||||
|
||||
public static class ApiException extends RuntimeException {
|
||||
private static final long serialVersionUID = 2261086748874802175L;
|
||||
|
||||
private final int statusCode;
|
||||
private final String body;
|
||||
private final Headers headers;
|
||||
private final String errorCode;
|
||||
private final String errorMessage;
|
||||
|
||||
public ApiException(int statusCode, String body, Headers headers) {
|
||||
super(String.format("微信支付API访问失败,StatusCode: [%s], Body: [%s], Headers: [%s]", statusCode,
|
||||
body, headers));
|
||||
this.statusCode = statusCode;
|
||||
this.body = body;
|
||||
this.headers = headers;
|
||||
|
||||
if (body != null && !body.isEmpty()) {
|
||||
JsonElement code;
|
||||
JsonElement message;
|
||||
|
||||
try {
|
||||
JsonObject jsonObject = gson.fromJson(body, JsonObject.class);
|
||||
code = jsonObject.get("code");
|
||||
message = jsonObject.get("message");
|
||||
} catch (JsonSyntaxException ignored) {
|
||||
code = null;
|
||||
message = null;
|
||||
}
|
||||
this.errorCode = code == null ? null : code.getAsString();
|
||||
this.errorMessage = message == null ? null : message.getAsString();
|
||||
} else {
|
||||
this.errorCode = null;
|
||||
this.errorMessage = null;
|
||||
}
|
||||
}
|
||||
|
||||
public int getStatusCode() {
|
||||
return statusCode;
|
||||
}
|
||||
|
||||
public String getBody() {
|
||||
return body;
|
||||
}
|
||||
|
||||
public Headers getHeaders() {
|
||||
return headers;
|
||||
}
|
||||
|
||||
public String getErrorCode() {
|
||||
return errorCode;
|
||||
}
|
||||
|
||||
public String getErrorMessage() {
|
||||
return errorMessage;
|
||||
}
|
||||
}
|
||||
|
||||
public static class Notification {
|
||||
@SerializedName("id")
|
||||
private String id;
|
||||
@SerializedName("create_time")
|
||||
private String createTime;
|
||||
@SerializedName("event_type")
|
||||
private String eventType;
|
||||
@SerializedName("resource_type")
|
||||
private String resourceType;
|
||||
@SerializedName("summary")
|
||||
private String summary;
|
||||
@SerializedName("resource")
|
||||
private Resource resource;
|
||||
private String plaintext;
|
||||
|
||||
public String getId() {
|
||||
return id;
|
||||
}
|
||||
|
||||
public String getCreateTime() {
|
||||
return createTime;
|
||||
}
|
||||
|
||||
public String getEventType() {
|
||||
return eventType;
|
||||
}
|
||||
|
||||
public String getResourceType() {
|
||||
return resourceType;
|
||||
}
|
||||
|
||||
public String getSummary() {
|
||||
return summary;
|
||||
}
|
||||
|
||||
public Resource getResource() {
|
||||
return resource;
|
||||
}
|
||||
|
||||
public String getPlaintext() {
|
||||
return plaintext;
|
||||
}
|
||||
|
||||
private void validate() {
|
||||
if (resource == null) {
|
||||
throw new IllegalArgumentException("Missing required field `resource` in notification");
|
||||
}
|
||||
resource.validate();
|
||||
}
|
||||
|
||||
private void decrypt(String apiv3Key) {
|
||||
validate();
|
||||
|
||||
plaintext = aesAeadDecrypt(
|
||||
apiv3Key.getBytes(StandardCharsets.UTF_8),
|
||||
resource.associatedData.getBytes(StandardCharsets.UTF_8),
|
||||
resource.nonce.getBytes(StandardCharsets.UTF_8),
|
||||
Base64.getDecoder().decode(resource.ciphertext)
|
||||
);
|
||||
}
|
||||
|
||||
public static class Resource {
|
||||
@SerializedName("algorithm")
|
||||
private String algorithm;
|
||||
|
||||
@SerializedName("ciphertext")
|
||||
private String ciphertext;
|
||||
|
||||
@SerializedName("associated_data")
|
||||
private String associatedData;
|
||||
|
||||
@SerializedName("nonce")
|
||||
private String nonce;
|
||||
|
||||
@SerializedName("original_type")
|
||||
private String originalType;
|
||||
|
||||
public String getAlgorithm() {
|
||||
return algorithm;
|
||||
}
|
||||
|
||||
public String getCiphertext() {
|
||||
return ciphertext;
|
||||
}
|
||||
|
||||
public String getAssociatedData() {
|
||||
return associatedData;
|
||||
}
|
||||
|
||||
public String getNonce() {
|
||||
return nonce;
|
||||
}
|
||||
|
||||
public String getOriginalType() {
|
||||
return originalType;
|
||||
}
|
||||
|
||||
private void validate() {
|
||||
if (algorithm == null || algorithm.isEmpty()) {
|
||||
throw new IllegalArgumentException("Missing required field `algorithm` in Notification" +
|
||||
".Resource");
|
||||
}
|
||||
if (!Objects.equals(algorithm, "AEAD_AES_256_GCM")) {
|
||||
throw new IllegalArgumentException(String.format("Unsupported `algorithm`[%s] in " +
|
||||
"Notification.Resource", algorithm));
|
||||
}
|
||||
|
||||
if (ciphertext == null || ciphertext.isEmpty()) {
|
||||
throw new IllegalArgumentException("Missing required field `ciphertext` in Notification" +
|
||||
".Resource");
|
||||
}
|
||||
|
||||
if (associatedData == null || associatedData.isEmpty()) {
|
||||
throw new IllegalArgumentException("Missing required field `associatedData` in " +
|
||||
"Notification.Resource");
|
||||
}
|
||||
|
||||
if (nonce == null || nonce.isEmpty()) {
|
||||
throw new IllegalArgumentException("Missing required field `nonce` in Notification" +
|
||||
".Resource");
|
||||
}
|
||||
|
||||
if (originalType == null || originalType.isEmpty()) {
|
||||
throw new IllegalArgumentException("Missing required field `originalType` in " +
|
||||
"Notification.Resource");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public static String getContentTypeByFileName(String fileName) {
|
||||
if (fileName == null || fileName.isEmpty()) {
|
||||
return "application/octet-stream";
|
||||
}
|
||||
|
||||
String extension = "";
|
||||
int lastDotIndex = fileName.lastIndexOf('.');
|
||||
if (lastDotIndex > 0 && lastDotIndex < fileName.length() - 1) {
|
||||
extension = fileName.substring(lastDotIndex + 1).toLowerCase();
|
||||
}
|
||||
|
||||
Map<String, String> contentTypeMap = new HashMap<>();
|
||||
contentTypeMap.put("png", "image/png");
|
||||
contentTypeMap.put("jpg", "image/jpeg");
|
||||
contentTypeMap.put("jpeg", "image/jpeg");
|
||||
contentTypeMap.put("gif", "image/gif");
|
||||
contentTypeMap.put("bmp", "image/bmp");
|
||||
contentTypeMap.put("webp", "image/webp");
|
||||
contentTypeMap.put("svg", "image/svg+xml");
|
||||
contentTypeMap.put("ico", "image/x-icon");
|
||||
contentTypeMap.put("pdf", "application/pdf");
|
||||
contentTypeMap.put("doc", "application/msword");
|
||||
contentTypeMap.put("docx", "application/vnd.openxmlformats-officedocument.wordprocessingml.document");
|
||||
contentTypeMap.put("xls", "application/vnd.ms-excel");
|
||||
contentTypeMap.put("xlsx", "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet");
|
||||
contentTypeMap.put("ppt", "application/vnd.ms-powerpoint");
|
||||
contentTypeMap.put("pptx", "application/vnd.openxmlformats-officedocument.presentationml.presentation");
|
||||
contentTypeMap.put("txt", "text/plain");
|
||||
contentTypeMap.put("html", "text/html");
|
||||
contentTypeMap.put("css", "text/css");
|
||||
contentTypeMap.put("js", "application/javascript");
|
||||
contentTypeMap.put("json", "application/json");
|
||||
contentTypeMap.put("xml", "application/xml");
|
||||
contentTypeMap.put("csv", "text/csv");
|
||||
contentTypeMap.put("mp3", "audio/mpeg");
|
||||
contentTypeMap.put("wav", "audio/wav");
|
||||
contentTypeMap.put("mp4", "video/mp4");
|
||||
contentTypeMap.put("avi", "video/x-msvideo");
|
||||
contentTypeMap.put("mov", "video/quicktime");
|
||||
contentTypeMap.put("zip", "application/zip");
|
||||
contentTypeMap.put("rar", "application/x-rar-compressed");
|
||||
contentTypeMap.put("7z", "application/x-7z-compressed");
|
||||
|
||||
return contentTypeMap.getOrDefault(extension, "application/octet-stream");
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,222 @@
|
||||
package com.java.demo;
|
||||
|
||||
import com.java.utils.WXPayUtility; // 引用微信支付工具库,参考:https://pay.weixin.qq.com/doc/v3/merchant/4014931831
|
||||
|
||||
import com.google.gson.annotations.SerializedName;
|
||||
import com.google.gson.annotations.Expose;
|
||||
import okhttp3.MediaType;
|
||||
import okhttp3.OkHttpClient;
|
||||
import okhttp3.Request;
|
||||
import okhttp3.RequestBody;
|
||||
import okhttp3.Response;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.io.UncheckedIOException;
|
||||
import java.security.PrivateKey;
|
||||
import java.security.PublicKey;
|
||||
import java.util.ArrayList;
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* 合单下单-APP
|
||||
*/
|
||||
public class UnionAppPrepay {
|
||||
private static String HOST = "https://api.mch.weixin.qq.com";
|
||||
private static String METHOD = "POST";
|
||||
private static String PATH = "/v3/combine-transactions/app";
|
||||
|
||||
public static void main(String[] args) {
|
||||
// TODO: 请准备商户开发必要参数,参考:https://pay.weixin.qq.com/doc/v3/merchant/4013070756
|
||||
UnionAppPrepay client = new UnionAppPrepay(
|
||||
"19xxxxxxxx", // 商户号,是由微信支付系统生成并分配给每个商户的唯一标识符,商户号获取方式参考 https://pay.weixin.qq.com/doc/v3/merchant/4013070756
|
||||
"1DDE55AD98Exxxxxxxxxx", // 商户API证书序列号,如何获取请参考 https://pay.weixin.qq.com/doc/v3/merchant/4013053053
|
||||
"/path/to/apiclient_key.pem", // 商户API证书私钥文件路径,本地文件路径
|
||||
"PUB_KEY_ID_xxxxxxxxxxxxx", // 微信支付公钥ID,如何获取请参考 https://pay.weixin.qq.com/doc/v3/merchant/4013038816
|
||||
"/path/to/wxp_pub.pem" // 微信支付公钥文件路径,本地文件路径
|
||||
);
|
||||
|
||||
UnionAPIv3AppPrepayRequest request = new UnionAPIv3AppPrepayRequest();
|
||||
request.combineAppid = "wxd678efh567hg6787";
|
||||
request.combineOutTradeNo = "20150806125345";
|
||||
request.combineMchid = "1900000109";
|
||||
request.sceneInfo = new UnionSceneInfo();
|
||||
request.sceneInfo.deviceId = "POS1:1";
|
||||
request.sceneInfo.payerClientIp = "14.17.22.32";
|
||||
request.subOrders = new ArrayList<>();
|
||||
{
|
||||
UnionSubOrder subOrdersItem0 = new UnionSubOrder();
|
||||
subOrdersItem0.mchid = "1230000109";
|
||||
subOrdersItem0.outTradeNo = "20150806125346";
|
||||
subOrdersItem0.amount = new UnionAmountInfo();
|
||||
subOrdersItem0.amount.totalAmount = 10L;
|
||||
subOrdersItem0.amount.currency = "CNY";
|
||||
subOrdersItem0.attach = "深圳分店";
|
||||
subOrdersItem0.description = "腾讯充值中心-QQ会员充值";
|
||||
subOrdersItem0.detail = "买单费用";
|
||||
subOrdersItem0.goodsTag = "WXG";
|
||||
subOrdersItem0.settleInfo = new UnionSettleInfo();
|
||||
subOrdersItem0.settleInfo.profitSharing = false;
|
||||
request.subOrders.add(subOrdersItem0);
|
||||
UnionSubOrder subOrdersItem1 = new UnionSubOrder();
|
||||
subOrdersItem1.mchid = "1230000119";
|
||||
subOrdersItem1.outTradeNo = "20150806125347";
|
||||
subOrdersItem1.amount = new UnionAmountInfo();
|
||||
subOrdersItem1.amount.totalAmount = 10L;
|
||||
subOrdersItem1.amount.currency = "CNY";
|
||||
subOrdersItem1.attach = "广州分店";
|
||||
subOrdersItem1.description = "腾讯充值中心-微信充值";
|
||||
subOrdersItem1.detail = "买单费用";
|
||||
subOrdersItem1.goodsTag = "WXG";
|
||||
subOrdersItem1.settleInfo = new UnionSettleInfo();
|
||||
subOrdersItem1.settleInfo.profitSharing = false;
|
||||
request.subOrders.add(subOrdersItem1);
|
||||
};
|
||||
request.combinePayerInfo = new UnionAppPayerInfo();
|
||||
request.combinePayerInfo.openid = "oUpF8uMuAJO_M2pxb1Q9zNjWeS6o";
|
||||
request.timeExpire = "2000-01-01T00:00:00+08:00";
|
||||
request.notifyUrl = "https://yourapp.com/notify";
|
||||
try {
|
||||
UnionAPIv3AppPrepayResponse response = client.run(request);
|
||||
// TODO: 请求成功,继续业务逻辑
|
||||
System.out.println(response);
|
||||
} catch (WXPayUtility.ApiException e) {
|
||||
// TODO: 请求失败,根据状态码执行不同的逻辑
|
||||
e.printStackTrace();
|
||||
}
|
||||
}
|
||||
|
||||
public UnionAPIv3AppPrepayResponse run(UnionAPIv3AppPrepayRequest request) {
|
||||
String uri = PATH;
|
||||
String reqBody = WXPayUtility.toJson(request);
|
||||
|
||||
Request.Builder reqBuilder = new Request.Builder().url(HOST + uri);
|
||||
reqBuilder.addHeader("Accept", "application/json");
|
||||
reqBuilder.addHeader("Wechatpay-Serial", wechatPayPublicKeyId);
|
||||
reqBuilder.addHeader("Authorization", WXPayUtility.buildAuthorization(mchid, certificateSerialNo,privateKey, METHOD, uri, reqBody));
|
||||
reqBuilder.addHeader("Content-Type", "application/json");
|
||||
RequestBody requestBody = RequestBody.create(MediaType.parse("application/json; charset=utf-8"), reqBody);
|
||||
reqBuilder.method(METHOD, requestBody);
|
||||
Request httpRequest = reqBuilder.build();
|
||||
|
||||
// 发送HTTP请求
|
||||
OkHttpClient client = new OkHttpClient.Builder().build();
|
||||
try (Response httpResponse = client.newCall(httpRequest).execute()) {
|
||||
String respBody = WXPayUtility.extractBody(httpResponse);
|
||||
if (httpResponse.code() >= 200 && httpResponse.code() < 300) {
|
||||
// 2XX 成功,验证应答签名
|
||||
WXPayUtility.validateResponse(this.wechatPayPublicKeyId, this.wechatPayPublicKey,
|
||||
httpResponse.headers(), respBody);
|
||||
|
||||
// 从HTTP应答报文构建返回数据
|
||||
return WXPayUtility.fromJson(respBody, UnionAPIv3AppPrepayResponse.class);
|
||||
} else {
|
||||
throw new WXPayUtility.ApiException(httpResponse.code(), respBody, httpResponse.headers());
|
||||
}
|
||||
} catch (IOException e) {
|
||||
throw new UncheckedIOException("Sending request to " + uri + " failed.", e);
|
||||
}
|
||||
}
|
||||
|
||||
private final String mchid;
|
||||
private final String certificateSerialNo;
|
||||
private final PrivateKey privateKey;
|
||||
private final String wechatPayPublicKeyId;
|
||||
private final PublicKey wechatPayPublicKey;
|
||||
|
||||
public UnionAppPrepay(String mchid, String certificateSerialNo, String privateKeyFilePath, String wechatPayPublicKeyId, String wechatPayPublicKeyFilePath) {
|
||||
this.mchid = mchid;
|
||||
this.certificateSerialNo = certificateSerialNo;
|
||||
this.privateKey = WXPayUtility.loadPrivateKeyFromPath(privateKeyFilePath);
|
||||
this.wechatPayPublicKeyId = wechatPayPublicKeyId;
|
||||
this.wechatPayPublicKey = WXPayUtility.loadPublicKeyFromPath(wechatPayPublicKeyFilePath);
|
||||
}
|
||||
|
||||
public static class UnionAPIv3AppPrepayRequest {
|
||||
@SerializedName("combine_appid")
|
||||
public String combineAppid;
|
||||
|
||||
@SerializedName("combine_out_trade_no")
|
||||
public String combineOutTradeNo;
|
||||
|
||||
@SerializedName("combine_mchid")
|
||||
public String combineMchid;
|
||||
|
||||
@SerializedName("scene_info")
|
||||
public UnionSceneInfo sceneInfo;
|
||||
|
||||
@SerializedName("sub_orders")
|
||||
public List<UnionSubOrder> subOrders = new ArrayList<UnionSubOrder>();
|
||||
|
||||
@SerializedName("combine_payer_info")
|
||||
public UnionAppPayerInfo combinePayerInfo;
|
||||
|
||||
@SerializedName("time_expire")
|
||||
public String timeExpire;
|
||||
|
||||
@SerializedName("notify_url")
|
||||
public String notifyUrl;
|
||||
|
||||
@SerializedName("trade_scenario")
|
||||
public String tradeScenario;
|
||||
}
|
||||
|
||||
public static class UnionAPIv3AppPrepayResponse {
|
||||
@SerializedName("prepay_id")
|
||||
public String prepayId;
|
||||
}
|
||||
|
||||
public static class UnionSceneInfo {
|
||||
@SerializedName("device_id")
|
||||
public String deviceId;
|
||||
|
||||
@SerializedName("payer_client_ip")
|
||||
public String payerClientIp;
|
||||
}
|
||||
|
||||
public static class UnionSubOrder {
|
||||
@SerializedName("mchid")
|
||||
public String mchid;
|
||||
|
||||
@SerializedName("out_trade_no")
|
||||
public String outTradeNo;
|
||||
|
||||
@SerializedName("amount")
|
||||
public UnionAmountInfo amount;
|
||||
|
||||
@SerializedName("attach")
|
||||
public String attach;
|
||||
|
||||
@SerializedName("description")
|
||||
public String description;
|
||||
|
||||
@SerializedName("detail")
|
||||
public String detail;
|
||||
|
||||
@SerializedName("goods_tag")
|
||||
public String goodsTag;
|
||||
|
||||
@SerializedName("settle_info")
|
||||
public UnionSettleInfo settleInfo;
|
||||
}
|
||||
|
||||
public static class UnionAppPayerInfo {
|
||||
@SerializedName("openid")
|
||||
public String openid;
|
||||
}
|
||||
|
||||
public static class UnionAmountInfo {
|
||||
@SerializedName("total_amount")
|
||||
public Long totalAmount;
|
||||
|
||||
@SerializedName("currency")
|
||||
public String currency;
|
||||
}
|
||||
|
||||
public static class UnionSettleInfo {
|
||||
@SerializedName("profit_sharing")
|
||||
public Boolean profitSharing;
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,124 @@
|
||||
package com.java.demo;
|
||||
|
||||
import com.java.utils.WXPayUtility; // 引用微信支付工具库,参考:https://pay.weixin.qq.com/doc/v3/merchant/4014931831
|
||||
|
||||
import com.google.gson.annotations.SerializedName;
|
||||
import com.google.gson.annotations.Expose;
|
||||
import okhttp3.MediaType;
|
||||
import okhttp3.OkHttpClient;
|
||||
import okhttp3.Request;
|
||||
import okhttp3.RequestBody;
|
||||
import okhttp3.Response;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.io.UncheckedIOException;
|
||||
import java.security.PrivateKey;
|
||||
import java.security.PublicKey;
|
||||
import java.util.ArrayList;
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* 合单关单
|
||||
*/
|
||||
public class UnionClose {
|
||||
private static String HOST = "https://api.mch.weixin.qq.com";
|
||||
private static String METHOD = "POST";
|
||||
private static String PATH = "/v3/combine-transactions/out-trade-no/{combine_out_trade_no}/close";
|
||||
|
||||
public static void main(String[] args) {
|
||||
// TODO: 请准备商户开发必要参数,参考:https://pay.weixin.qq.com/doc/v3/merchant/4013070756
|
||||
UnionClose client = new UnionClose(
|
||||
"19xxxxxxxx", // 商户号,是由微信支付系统生成并分配给每个商户的唯一标识符,商户号获取方式参考 https://pay.weixin.qq.com/doc/v3/merchant/4013070756
|
||||
"1DDE55AD98Exxxxxxxxxx", // 商户API证书序列号,如何获取请参考 https://pay.weixin.qq.com/doc/v3/merchant/4013053053
|
||||
"/path/to/apiclient_key.pem", // 商户API证书私钥文件路径,本地文件路径
|
||||
"PUB_KEY_ID_xxxxxxxxxxxxx", // 微信支付公钥ID,如何获取请参考 https://pay.weixin.qq.com/doc/v3/merchant/4013038816
|
||||
"/path/to/wxp_pub.pem" // 微信支付公钥文件路径,本地文件路径
|
||||
);
|
||||
|
||||
UnionCloseRequest request = new UnionCloseRequest();
|
||||
request.combineOutTradeNo = "1217752501201407033233368018";
|
||||
request.combineAppid = "wxd678efh567hg6787";
|
||||
request.subOrders = new ArrayList<>();
|
||||
{
|
||||
UnionCloseSubOrder subOrdersItem = new UnionCloseSubOrder();
|
||||
subOrdersItem.mchid = "1900000109";
|
||||
subOrdersItem.outTradeNo = "20150806125346";
|
||||
request.subOrders.add(subOrdersItem);
|
||||
};
|
||||
try {
|
||||
client.run(request);
|
||||
} catch (WXPayUtility.ApiException e) {
|
||||
// TODO: 请求失败,根据状态码执行不同的逻辑
|
||||
e.printStackTrace();
|
||||
}
|
||||
}
|
||||
|
||||
public void run(UnionCloseRequest request) {
|
||||
String uri = PATH;
|
||||
uri = uri.replace("{combine_out_trade_no}", WXPayUtility.urlEncode(request.combineOutTradeNo));
|
||||
String reqBody = WXPayUtility.toJson(request);
|
||||
|
||||
Request.Builder reqBuilder = new Request.Builder().url(HOST + uri);
|
||||
reqBuilder.addHeader("Accept", "application/json");
|
||||
reqBuilder.addHeader("Wechatpay-Serial", wechatPayPublicKeyId);
|
||||
reqBuilder.addHeader("Authorization", WXPayUtility.buildAuthorization(mchid, certificateSerialNo,privateKey, METHOD, uri, reqBody));
|
||||
reqBuilder.addHeader("Content-Type", "application/json");
|
||||
RequestBody requestBody = RequestBody.create(MediaType.parse("application/json; charset=utf-8"), reqBody);
|
||||
reqBuilder.method(METHOD, requestBody);
|
||||
Request httpRequest = reqBuilder.build();
|
||||
|
||||
// 发送HTTP请求
|
||||
OkHttpClient client = new OkHttpClient.Builder().build();
|
||||
try (Response httpResponse = client.newCall(httpRequest).execute()) {
|
||||
String respBody = WXPayUtility.extractBody(httpResponse);
|
||||
if (httpResponse.code() >= 200 && httpResponse.code() < 300) {
|
||||
// 2XX 成功,验证应答签名
|
||||
WXPayUtility.validateResponse(this.wechatPayPublicKeyId, this.wechatPayPublicKey,
|
||||
httpResponse.headers(), respBody);
|
||||
|
||||
return;
|
||||
} else {
|
||||
throw new WXPayUtility.ApiException(httpResponse.code(), respBody, httpResponse.headers());
|
||||
}
|
||||
} catch (IOException e) {
|
||||
throw new UncheckedIOException("Sending request to " + uri + " failed.", e);
|
||||
}
|
||||
}
|
||||
|
||||
private final String mchid;
|
||||
private final String certificateSerialNo;
|
||||
private final PrivateKey privateKey;
|
||||
private final String wechatPayPublicKeyId;
|
||||
private final PublicKey wechatPayPublicKey;
|
||||
|
||||
public UnionClose(String mchid, String certificateSerialNo, String privateKeyFilePath, String wechatPayPublicKeyId, String wechatPayPublicKeyFilePath) {
|
||||
this.mchid = mchid;
|
||||
this.certificateSerialNo = certificateSerialNo;
|
||||
this.privateKey = WXPayUtility.loadPrivateKeyFromPath(privateKeyFilePath);
|
||||
this.wechatPayPublicKeyId = wechatPayPublicKeyId;
|
||||
this.wechatPayPublicKey = WXPayUtility.loadPublicKeyFromPath(wechatPayPublicKeyFilePath);
|
||||
}
|
||||
|
||||
public static class UnionCloseRequest {
|
||||
@SerializedName("combine_appid")
|
||||
public String combineAppid;
|
||||
|
||||
@SerializedName("combine_out_trade_no")
|
||||
@Expose(serialize = false)
|
||||
public String combineOutTradeNo;
|
||||
|
||||
@SerializedName("sub_orders")
|
||||
public List<UnionCloseSubOrder> subOrders = new ArrayList<UnionCloseSubOrder>();
|
||||
}
|
||||
|
||||
public static class UnionCloseSubOrder {
|
||||
@SerializedName("mchid")
|
||||
public String mchid;
|
||||
|
||||
@SerializedName("out_trade_no")
|
||||
public String outTradeNo;
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,230 @@
|
||||
package com.java.demo;
|
||||
|
||||
import com.java.utils.WXPayUtility; // 引用微信支付工具库,参考:https://pay.weixin.qq.com/doc/v3/merchant/4014931831
|
||||
|
||||
import com.google.gson.annotations.SerializedName;
|
||||
import com.google.gson.annotations.Expose;
|
||||
import okhttp3.MediaType;
|
||||
import okhttp3.OkHttpClient;
|
||||
import okhttp3.Request;
|
||||
import okhttp3.RequestBody;
|
||||
import okhttp3.Response;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.io.UncheckedIOException;
|
||||
import java.security.PrivateKey;
|
||||
import java.security.PublicKey;
|
||||
import java.util.ArrayList;
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* 合单下单-H5
|
||||
*/
|
||||
public class UnionH5Prepay {
|
||||
private static String HOST = "https://api.mch.weixin.qq.com";
|
||||
private static String METHOD = "POST";
|
||||
private static String PATH = "/v3/combine-transactions/h5";
|
||||
|
||||
public static void main(String[] args) {
|
||||
// TODO: 请准备商户开发必要参数,参考:https://pay.weixin.qq.com/doc/v3/merchant/4013070756
|
||||
UnionH5Prepay client = new UnionH5Prepay(
|
||||
"19xxxxxxxx", // 商户号,是由微信支付系统生成并分配给每个商户的唯一标识符,商户号获取方式参考 https://pay.weixin.qq.com/doc/v3/merchant/4013070756
|
||||
"1DDE55AD98Exxxxxxxxxx", // 商户API证书序列号,如何获取请参考 https://pay.weixin.qq.com/doc/v3/merchant/4013053053
|
||||
"/path/to/apiclient_key.pem", // 商户API证书私钥文件路径,本地文件路径
|
||||
"PUB_KEY_ID_xxxxxxxxxxxxx", // 微信支付公钥ID,如何获取请参考 https://pay.weixin.qq.com/doc/v3/merchant/4013038816
|
||||
"/path/to/wxp_pub.pem" // 微信支付公钥文件路径,本地文件路径
|
||||
);
|
||||
|
||||
UnionAPIv3H5PrepayRequest request = new UnionAPIv3H5PrepayRequest();
|
||||
request.combineAppid = "wxd678efh567hg6787";
|
||||
request.combineOutTradeNo = "1217752501201407033233368018";
|
||||
request.combineMchid = "1230000109";
|
||||
request.sceneInfo = new UnionH5SceneInfo();
|
||||
request.sceneInfo.payerClientIp = "14.23.150.211";
|
||||
request.sceneInfo.deviceId = "013467007045764";
|
||||
request.sceneInfo.h5Info = new UnionH5Info();
|
||||
request.sceneInfo.h5Info.type = "iOS";
|
||||
request.sceneInfo.h5Info.appName = "王者荣耀";
|
||||
request.sceneInfo.h5Info.appUrl = "https://pay.qq.com";
|
||||
request.sceneInfo.h5Info.bundleId = "com.tencent.wzryiOS";
|
||||
request.sceneInfo.h5Info.packageName = "com.tencent.tmgp.sgame";
|
||||
request.subOrders = new ArrayList<>();
|
||||
{
|
||||
UnionCommonSubOrder subOrdersItem0 = new UnionCommonSubOrder();
|
||||
subOrdersItem0.mchid = "1230000109";
|
||||
subOrdersItem0.outTradeNo = "20150806125346";
|
||||
subOrdersItem0.amount = new UnionAmountInfo();
|
||||
subOrdersItem0.amount.totalAmount = 10L;
|
||||
subOrdersItem0.amount.currency = "CNY";
|
||||
subOrdersItem0.attach = "深圳分店";
|
||||
subOrdersItem0.description = "腾讯充值中心-QQ会员充值";
|
||||
subOrdersItem0.goodsTag = "WXG";
|
||||
subOrdersItem0.settleInfo = new UnionSettleInfo();
|
||||
subOrdersItem0.settleInfo.profitSharing = false;
|
||||
request.subOrders.add(subOrdersItem0);
|
||||
UnionCommonSubOrder subOrdersItem1 = new UnionCommonSubOrder();
|
||||
subOrdersItem1.mchid = "1230000119";
|
||||
subOrdersItem1.outTradeNo = "20150806125347";
|
||||
subOrdersItem1.amount = new UnionAmountInfo();
|
||||
subOrdersItem1.amount.totalAmount = 10L;
|
||||
subOrdersItem1.amount.currency = "CNY";
|
||||
subOrdersItem1.attach = "广州分店";
|
||||
subOrdersItem1.description = "腾讯充值中心-微信充值";
|
||||
subOrdersItem1.goodsTag = "WXG";
|
||||
subOrdersItem1.settleInfo = new UnionSettleInfo();
|
||||
subOrdersItem1.settleInfo.profitSharing = false;
|
||||
request.subOrders.add(subOrdersItem1);
|
||||
};
|
||||
request.timeExpire = "2018-06-08T10:34:56+08:00";
|
||||
request.notifyUrl = "https://yourapp.com/notify";
|
||||
try {
|
||||
UnionAPIv3H5PrepayResponse response = client.run(request);
|
||||
// TODO: 请求成功,继续业务逻辑
|
||||
System.out.println(response);
|
||||
} catch (WXPayUtility.ApiException e) {
|
||||
// TODO: 请求失败,根据状态码执行不同的逻辑
|
||||
e.printStackTrace();
|
||||
}
|
||||
}
|
||||
|
||||
public UnionAPIv3H5PrepayResponse run(UnionAPIv3H5PrepayRequest request) {
|
||||
String uri = PATH;
|
||||
String reqBody = WXPayUtility.toJson(request);
|
||||
|
||||
Request.Builder reqBuilder = new Request.Builder().url(HOST + uri);
|
||||
reqBuilder.addHeader("Accept", "application/json");
|
||||
reqBuilder.addHeader("Wechatpay-Serial", wechatPayPublicKeyId);
|
||||
reqBuilder.addHeader("Authorization", WXPayUtility.buildAuthorization(mchid, certificateSerialNo,privateKey, METHOD, uri, reqBody));
|
||||
reqBuilder.addHeader("Content-Type", "application/json");
|
||||
RequestBody requestBody = RequestBody.create(MediaType.parse("application/json; charset=utf-8"), reqBody);
|
||||
reqBuilder.method(METHOD, requestBody);
|
||||
Request httpRequest = reqBuilder.build();
|
||||
|
||||
// 发送HTTP请求
|
||||
OkHttpClient client = new OkHttpClient.Builder().build();
|
||||
try (Response httpResponse = client.newCall(httpRequest).execute()) {
|
||||
String respBody = WXPayUtility.extractBody(httpResponse);
|
||||
if (httpResponse.code() >= 200 && httpResponse.code() < 300) {
|
||||
// 2XX 成功,验证应答签名
|
||||
WXPayUtility.validateResponse(this.wechatPayPublicKeyId, this.wechatPayPublicKey,
|
||||
httpResponse.headers(), respBody);
|
||||
|
||||
// 从HTTP应答报文构建返回数据
|
||||
return WXPayUtility.fromJson(respBody, UnionAPIv3H5PrepayResponse.class);
|
||||
} else {
|
||||
throw new WXPayUtility.ApiException(httpResponse.code(), respBody, httpResponse.headers());
|
||||
}
|
||||
} catch (IOException e) {
|
||||
throw new UncheckedIOException("Sending request to " + uri + " failed.", e);
|
||||
}
|
||||
}
|
||||
|
||||
private final String mchid;
|
||||
private final String certificateSerialNo;
|
||||
private final PrivateKey privateKey;
|
||||
private final String wechatPayPublicKeyId;
|
||||
private final PublicKey wechatPayPublicKey;
|
||||
|
||||
public UnionH5Prepay(String mchid, String certificateSerialNo, String privateKeyFilePath, String wechatPayPublicKeyId, String wechatPayPublicKeyFilePath) {
|
||||
this.mchid = mchid;
|
||||
this.certificateSerialNo = certificateSerialNo;
|
||||
this.privateKey = WXPayUtility.loadPrivateKeyFromPath(privateKeyFilePath);
|
||||
this.wechatPayPublicKeyId = wechatPayPublicKeyId;
|
||||
this.wechatPayPublicKey = WXPayUtility.loadPublicKeyFromPath(wechatPayPublicKeyFilePath);
|
||||
}
|
||||
|
||||
public static class UnionAPIv3H5PrepayRequest {
|
||||
@SerializedName("combine_appid")
|
||||
public String combineAppid;
|
||||
|
||||
@SerializedName("combine_out_trade_no")
|
||||
public String combineOutTradeNo;
|
||||
|
||||
@SerializedName("combine_mchid")
|
||||
public String combineMchid;
|
||||
|
||||
@SerializedName("scene_info")
|
||||
public UnionH5SceneInfo sceneInfo;
|
||||
|
||||
@SerializedName("sub_orders")
|
||||
public List<UnionCommonSubOrder> subOrders = new ArrayList<UnionCommonSubOrder>();
|
||||
|
||||
@SerializedName("time_expire")
|
||||
public String timeExpire;
|
||||
|
||||
@SerializedName("notify_url")
|
||||
public String notifyUrl;
|
||||
}
|
||||
|
||||
public static class UnionAPIv3H5PrepayResponse {
|
||||
@SerializedName("h5_url")
|
||||
public String h5Url;
|
||||
}
|
||||
|
||||
public static class UnionH5SceneInfo {
|
||||
@SerializedName("payer_client_ip")
|
||||
public String payerClientIp;
|
||||
|
||||
@SerializedName("device_id")
|
||||
public String deviceId;
|
||||
|
||||
@SerializedName("h5_info")
|
||||
public UnionH5Info h5Info;
|
||||
}
|
||||
|
||||
public static class UnionCommonSubOrder {
|
||||
@SerializedName("mchid")
|
||||
public String mchid;
|
||||
|
||||
@SerializedName("out_trade_no")
|
||||
public String outTradeNo;
|
||||
|
||||
@SerializedName("amount")
|
||||
public UnionAmountInfo amount;
|
||||
|
||||
@SerializedName("attach")
|
||||
public String attach;
|
||||
|
||||
@SerializedName("description")
|
||||
public String description;
|
||||
|
||||
@SerializedName("goods_tag")
|
||||
public String goodsTag;
|
||||
|
||||
@SerializedName("settle_info")
|
||||
public UnionSettleInfo settleInfo;
|
||||
}
|
||||
|
||||
public static class UnionH5Info {
|
||||
@SerializedName("type")
|
||||
public String type;
|
||||
|
||||
@SerializedName("app_name")
|
||||
public String appName;
|
||||
|
||||
@SerializedName("app_url")
|
||||
public String appUrl;
|
||||
|
||||
@SerializedName("bundle_id")
|
||||
public String bundleId;
|
||||
|
||||
@SerializedName("package_name")
|
||||
public String packageName;
|
||||
}
|
||||
|
||||
public static class UnionAmountInfo {
|
||||
@SerializedName("total_amount")
|
||||
public Long totalAmount;
|
||||
|
||||
@SerializedName("currency")
|
||||
public String currency;
|
||||
}
|
||||
|
||||
public static class UnionSettleInfo {
|
||||
@SerializedName("profit_sharing")
|
||||
public Boolean profitSharing;
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,222 @@
|
||||
package com.java.demo;
|
||||
|
||||
import com.java.utils.WXPayUtility; // 引用微信支付工具库,参考:https://pay.weixin.qq.com/doc/v3/merchant/4014931831
|
||||
|
||||
import com.google.gson.annotations.SerializedName;
|
||||
import com.google.gson.annotations.Expose;
|
||||
import okhttp3.MediaType;
|
||||
import okhttp3.OkHttpClient;
|
||||
import okhttp3.Request;
|
||||
import okhttp3.RequestBody;
|
||||
import okhttp3.Response;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.io.UncheckedIOException;
|
||||
import java.security.PrivateKey;
|
||||
import java.security.PublicKey;
|
||||
import java.util.ArrayList;
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* 合单下单-JSAPI
|
||||
*/
|
||||
public class UnionJsapiPrepay {
|
||||
private static String HOST = "https://api.mch.weixin.qq.com";
|
||||
private static String METHOD = "POST";
|
||||
private static String PATH = "/v3/combine-transactions/jsapi";
|
||||
|
||||
public static void main(String[] args) {
|
||||
// TODO: 请准备商户开发必要参数,参考:https://pay.weixin.qq.com/doc/v3/merchant/4013070756
|
||||
UnionJsapiPrepay client = new UnionJsapiPrepay(
|
||||
"19xxxxxxxx", // 商户号,是由微信支付系统生成并分配给每个商户的唯一标识符,商户号获取方式参考 https://pay.weixin.qq.com/doc/v3/merchant/4013070756
|
||||
"1DDE55AD98Exxxxxxxxxx", // 商户API证书序列号,如何获取请参考 https://pay.weixin.qq.com/doc/v3/merchant/4013053053
|
||||
"/path/to/apiclient_key.pem", // 商户API证书私钥文件路径,本地文件路径
|
||||
"PUB_KEY_ID_xxxxxxxxxxxxx", // 微信支付公钥ID,如何获取请参考 https://pay.weixin.qq.com/doc/v3/merchant/4013038816
|
||||
"/path/to/wxp_pub.pem" // 微信支付公钥文件路径,本地文件路径
|
||||
);
|
||||
|
||||
UnionAPIv3JsapiPrepayRequest request = new UnionAPIv3JsapiPrepayRequest();
|
||||
request.combineAppid = "wxd678efh567hg6787";
|
||||
request.combineMchid = "1230000109";
|
||||
request.combineOutTradeNo = "1217752501201407033233368018";
|
||||
request.combinePayerInfo = new UnionPayerInfo();
|
||||
request.combinePayerInfo.openid = "oUpF8uMuAJO_M2pxb1Q9zNjWeS6o";
|
||||
request.sceneInfo = new UnionSceneInfo();
|
||||
request.sceneInfo.deviceId = "POS1:1";
|
||||
request.sceneInfo.payerClientIp = "14.17.22.32";
|
||||
request.subOrders = new ArrayList<>();
|
||||
{
|
||||
UnionSubOrder subOrdersItem0 = new UnionSubOrder();
|
||||
subOrdersItem0.mchid = "1230000109";
|
||||
subOrdersItem0.outTradeNo = "20150806125346";
|
||||
subOrdersItem0.amount = new UnionAmountInfo();
|
||||
subOrdersItem0.amount.totalAmount = 10L;
|
||||
subOrdersItem0.amount.currency = "CNY";
|
||||
subOrdersItem0.attach = "深圳分店";
|
||||
subOrdersItem0.description = "腾讯充值中心-QQ会员充值";
|
||||
subOrdersItem0.detail = "买单费用";
|
||||
subOrdersItem0.goodsTag = "WXG";
|
||||
subOrdersItem0.settleInfo = new UnionSettleInfo();
|
||||
subOrdersItem0.settleInfo.profitSharing = false;
|
||||
request.subOrders.add(subOrdersItem0);
|
||||
UnionSubOrder subOrdersItem1 = new UnionSubOrder();
|
||||
subOrdersItem1.mchid = "1230000119";
|
||||
subOrdersItem1.outTradeNo = "20150806125347";
|
||||
subOrdersItem1.amount = new UnionAmountInfo();
|
||||
subOrdersItem1.amount.totalAmount = 10L;
|
||||
subOrdersItem1.amount.currency = "CNY";
|
||||
subOrdersItem1.attach = "广州分店";
|
||||
subOrdersItem1.description = "腾讯充值中心-微信充值";
|
||||
subOrdersItem1.detail = "买单费用";
|
||||
subOrdersItem1.goodsTag = "WXG";
|
||||
subOrdersItem1.settleInfo = new UnionSettleInfo();
|
||||
subOrdersItem1.settleInfo.profitSharing = false;
|
||||
request.subOrders.add(subOrdersItem1);
|
||||
};
|
||||
request.timeExpire = "2018-06-08T10:34:56+08:00";
|
||||
request.notifyUrl = "https://yourapp.com/notify";
|
||||
try {
|
||||
UnionAPIv3JsapiPrepayResponse response = client.run(request);
|
||||
// TODO: 请求成功,继续业务逻辑
|
||||
System.out.println(response);
|
||||
} catch (WXPayUtility.ApiException e) {
|
||||
// TODO: 请求失败,根据状态码执行不同的逻辑
|
||||
e.printStackTrace();
|
||||
}
|
||||
}
|
||||
|
||||
public UnionAPIv3JsapiPrepayResponse run(UnionAPIv3JsapiPrepayRequest request) {
|
||||
String uri = PATH;
|
||||
String reqBody = WXPayUtility.toJson(request);
|
||||
|
||||
Request.Builder reqBuilder = new Request.Builder().url(HOST + uri);
|
||||
reqBuilder.addHeader("Accept", "application/json");
|
||||
reqBuilder.addHeader("Wechatpay-Serial", wechatPayPublicKeyId);
|
||||
reqBuilder.addHeader("Authorization", WXPayUtility.buildAuthorization(mchid, certificateSerialNo,privateKey, METHOD, uri, reqBody));
|
||||
reqBuilder.addHeader("Content-Type", "application/json");
|
||||
RequestBody requestBody = RequestBody.create(MediaType.parse("application/json; charset=utf-8"), reqBody);
|
||||
reqBuilder.method(METHOD, requestBody);
|
||||
Request httpRequest = reqBuilder.build();
|
||||
|
||||
// 发送HTTP请求
|
||||
OkHttpClient client = new OkHttpClient.Builder().build();
|
||||
try (Response httpResponse = client.newCall(httpRequest).execute()) {
|
||||
String respBody = WXPayUtility.extractBody(httpResponse);
|
||||
if (httpResponse.code() >= 200 && httpResponse.code() < 300) {
|
||||
// 2XX 成功,验证应答签名
|
||||
WXPayUtility.validateResponse(this.wechatPayPublicKeyId, this.wechatPayPublicKey,
|
||||
httpResponse.headers(), respBody);
|
||||
|
||||
// 从HTTP应答报文构建返回数据
|
||||
return WXPayUtility.fromJson(respBody, UnionAPIv3JsapiPrepayResponse.class);
|
||||
} else {
|
||||
throw new WXPayUtility.ApiException(httpResponse.code(), respBody, httpResponse.headers());
|
||||
}
|
||||
} catch (IOException e) {
|
||||
throw new UncheckedIOException("Sending request to " + uri + " failed.", e);
|
||||
}
|
||||
}
|
||||
|
||||
private final String mchid;
|
||||
private final String certificateSerialNo;
|
||||
private final PrivateKey privateKey;
|
||||
private final String wechatPayPublicKeyId;
|
||||
private final PublicKey wechatPayPublicKey;
|
||||
|
||||
public UnionJsapiPrepay(String mchid, String certificateSerialNo, String privateKeyFilePath, String wechatPayPublicKeyId, String wechatPayPublicKeyFilePath) {
|
||||
this.mchid = mchid;
|
||||
this.certificateSerialNo = certificateSerialNo;
|
||||
this.privateKey = WXPayUtility.loadPrivateKeyFromPath(privateKeyFilePath);
|
||||
this.wechatPayPublicKeyId = wechatPayPublicKeyId;
|
||||
this.wechatPayPublicKey = WXPayUtility.loadPublicKeyFromPath(wechatPayPublicKeyFilePath);
|
||||
}
|
||||
|
||||
public static class UnionAPIv3JsapiPrepayRequest {
|
||||
@SerializedName("combine_appid")
|
||||
public String combineAppid;
|
||||
|
||||
@SerializedName("combine_mchid")
|
||||
public String combineMchid;
|
||||
|
||||
@SerializedName("combine_out_trade_no")
|
||||
public String combineOutTradeNo;
|
||||
|
||||
@SerializedName("combine_payer_info")
|
||||
public UnionPayerInfo combinePayerInfo;
|
||||
|
||||
@SerializedName("scene_info")
|
||||
public UnionSceneInfo sceneInfo;
|
||||
|
||||
@SerializedName("sub_orders")
|
||||
public List<UnionSubOrder> subOrders = new ArrayList<UnionSubOrder>();
|
||||
|
||||
@SerializedName("time_expire")
|
||||
public String timeExpire;
|
||||
|
||||
@SerializedName("notify_url")
|
||||
public String notifyUrl;
|
||||
}
|
||||
|
||||
public static class UnionAPIv3JsapiPrepayResponse {
|
||||
@SerializedName("prepay_id")
|
||||
public String prepayId;
|
||||
}
|
||||
|
||||
public static class UnionPayerInfo {
|
||||
@SerializedName("openid")
|
||||
public String openid;
|
||||
|
||||
@SerializedName("sub_openid")
|
||||
public String subOpenid;
|
||||
}
|
||||
|
||||
public static class UnionSceneInfo {
|
||||
@SerializedName("device_id")
|
||||
public String deviceId;
|
||||
|
||||
@SerializedName("payer_client_ip")
|
||||
public String payerClientIp;
|
||||
}
|
||||
|
||||
public static class UnionSubOrder {
|
||||
@SerializedName("mchid")
|
||||
public String mchid;
|
||||
|
||||
@SerializedName("out_trade_no")
|
||||
public String outTradeNo;
|
||||
|
||||
@SerializedName("amount")
|
||||
public UnionAmountInfo amount;
|
||||
|
||||
@SerializedName("attach")
|
||||
public String attach;
|
||||
|
||||
@SerializedName("description")
|
||||
public String description;
|
||||
|
||||
@SerializedName("detail")
|
||||
public String detail;
|
||||
|
||||
@SerializedName("goods_tag")
|
||||
public String goodsTag;
|
||||
|
||||
@SerializedName("settle_info")
|
||||
public UnionSettleInfo settleInfo;
|
||||
}
|
||||
|
||||
public static class UnionAmountInfo {
|
||||
@SerializedName("total_amount")
|
||||
public Long totalAmount;
|
||||
|
||||
@SerializedName("currency")
|
||||
public String currency;
|
||||
}
|
||||
|
||||
public static class UnionSettleInfo {
|
||||
@SerializedName("profit_sharing")
|
||||
public Boolean profitSharing;
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,204 @@
|
||||
package com.java.demo;
|
||||
|
||||
import com.java.utils.WXPayUtility; // 引用微信支付工具库,参考:https://pay.weixin.qq.com/doc/v3/merchant/4014931831
|
||||
|
||||
import com.google.gson.annotations.SerializedName;
|
||||
import com.google.gson.annotations.Expose;
|
||||
import okhttp3.MediaType;
|
||||
import okhttp3.OkHttpClient;
|
||||
import okhttp3.Request;
|
||||
import okhttp3.RequestBody;
|
||||
import okhttp3.Response;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.io.UncheckedIOException;
|
||||
import java.security.PrivateKey;
|
||||
import java.security.PublicKey;
|
||||
import java.util.ArrayList;
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* 合单下单-NATIVE
|
||||
*/
|
||||
public class UnionNativePrepay {
|
||||
private static String HOST = "https://api.mch.weixin.qq.com";
|
||||
private static String METHOD = "POST";
|
||||
private static String PATH = "/v3/combine-transactions/native";
|
||||
|
||||
public static void main(String[] args) {
|
||||
// TODO: 请准备商户开发必要参数,参考:https://pay.weixin.qq.com/doc/v3/merchant/4013070756
|
||||
UnionNativePrepay client = new UnionNativePrepay(
|
||||
"19xxxxxxxx", // 商户号,是由微信支付系统生成并分配给每个商户的唯一标识符,商户号获取方式参考 https://pay.weixin.qq.com/doc/v3/merchant/4013070756
|
||||
"1DDE55AD98Exxxxxxxxxx", // 商户API证书序列号,如何获取请参考 https://pay.weixin.qq.com/doc/v3/merchant/4013053053
|
||||
"/path/to/apiclient_key.pem", // 商户API证书私钥文件路径,本地文件路径
|
||||
"PUB_KEY_ID_xxxxxxxxxxxxx", // 微信支付公钥ID,如何获取请参考 https://pay.weixin.qq.com/doc/v3/merchant/4013038816
|
||||
"/path/to/wxp_pub.pem" // 微信支付公钥文件路径,本地文件路径
|
||||
);
|
||||
|
||||
UnionAPIv3NativePrepayRequest request = new UnionAPIv3NativePrepayRequest();
|
||||
request.combineAppid = "wxd678efh567hg6787";
|
||||
request.combineOutTradeNo = "20150806125346";
|
||||
request.combineMchid = "1900000109";
|
||||
request.sceneInfo = new UnionSceneInfo();
|
||||
request.sceneInfo.deviceId = "POS1:1";
|
||||
request.sceneInfo.payerClientIp = "14.17.22.32";
|
||||
request.subOrders = new ArrayList<>();
|
||||
{
|
||||
UnionCommonSubOrder subOrdersItem0 = new UnionCommonSubOrder();
|
||||
subOrdersItem0.mchid = "1230000109";
|
||||
subOrdersItem0.outTradeNo = "20150806125346";
|
||||
subOrdersItem0.amount = new UnionAmountInfo();
|
||||
subOrdersItem0.amount.totalAmount = 10L;
|
||||
subOrdersItem0.amount.currency = "CNY";
|
||||
subOrdersItem0.attach = "深圳分店";
|
||||
subOrdersItem0.description = "腾讯充值中心-QQ会员充值";
|
||||
subOrdersItem0.goodsTag = "WXG";
|
||||
subOrdersItem0.settleInfo = new UnionSettleInfo();
|
||||
subOrdersItem0.settleInfo.profitSharing = false;
|
||||
request.subOrders.add(subOrdersItem0);
|
||||
UnionCommonSubOrder subOrdersItem1 = new UnionCommonSubOrder();
|
||||
subOrdersItem1.mchid = "1230000119";
|
||||
subOrdersItem1.outTradeNo = "20150806125347";
|
||||
subOrdersItem1.amount = new UnionAmountInfo();
|
||||
subOrdersItem1.amount.totalAmount = 10L;
|
||||
subOrdersItem1.amount.currency = "CNY";
|
||||
subOrdersItem1.attach = "广州分店";
|
||||
subOrdersItem1.description = "腾讯充值中心-微信充值";
|
||||
subOrdersItem1.goodsTag = "WXG";
|
||||
subOrdersItem1.settleInfo = new UnionSettleInfo();
|
||||
subOrdersItem1.settleInfo.profitSharing = false;
|
||||
request.subOrders.add(subOrdersItem1);
|
||||
};
|
||||
request.timeExpire = "2000-01-01T00:00:00+08:00";
|
||||
request.notifyUrl = "https://yourapp.com/notify";
|
||||
try {
|
||||
UnionAPIv3NativePrepayResponse response = client.run(request);
|
||||
// TODO: 请求成功,继续业务逻辑
|
||||
System.out.println(response);
|
||||
} catch (WXPayUtility.ApiException e) {
|
||||
// TODO: 请求失败,根据状态码执行不同的逻辑
|
||||
e.printStackTrace();
|
||||
}
|
||||
}
|
||||
|
||||
public UnionAPIv3NativePrepayResponse run(UnionAPIv3NativePrepayRequest request) {
|
||||
String uri = PATH;
|
||||
String reqBody = WXPayUtility.toJson(request);
|
||||
|
||||
Request.Builder reqBuilder = new Request.Builder().url(HOST + uri);
|
||||
reqBuilder.addHeader("Accept", "application/json");
|
||||
reqBuilder.addHeader("Wechatpay-Serial", wechatPayPublicKeyId);
|
||||
reqBuilder.addHeader("Authorization", WXPayUtility.buildAuthorization(mchid, certificateSerialNo,privateKey, METHOD, uri, reqBody));
|
||||
reqBuilder.addHeader("Content-Type", "application/json");
|
||||
RequestBody requestBody = RequestBody.create(MediaType.parse("application/json; charset=utf-8"), reqBody);
|
||||
reqBuilder.method(METHOD, requestBody);
|
||||
Request httpRequest = reqBuilder.build();
|
||||
|
||||
// 发送HTTP请求
|
||||
OkHttpClient client = new OkHttpClient.Builder().build();
|
||||
try (Response httpResponse = client.newCall(httpRequest).execute()) {
|
||||
String respBody = WXPayUtility.extractBody(httpResponse);
|
||||
if (httpResponse.code() >= 200 && httpResponse.code() < 300) {
|
||||
// 2XX 成功,验证应答签名
|
||||
WXPayUtility.validateResponse(this.wechatPayPublicKeyId, this.wechatPayPublicKey,
|
||||
httpResponse.headers(), respBody);
|
||||
|
||||
// 从HTTP应答报文构建返回数据
|
||||
return WXPayUtility.fromJson(respBody, UnionAPIv3NativePrepayResponse.class);
|
||||
} else {
|
||||
throw new WXPayUtility.ApiException(httpResponse.code(), respBody, httpResponse.headers());
|
||||
}
|
||||
} catch (IOException e) {
|
||||
throw new UncheckedIOException("Sending request to " + uri + " failed.", e);
|
||||
}
|
||||
}
|
||||
|
||||
private final String mchid;
|
||||
private final String certificateSerialNo;
|
||||
private final PrivateKey privateKey;
|
||||
private final String wechatPayPublicKeyId;
|
||||
private final PublicKey wechatPayPublicKey;
|
||||
|
||||
public UnionNativePrepay(String mchid, String certificateSerialNo, String privateKeyFilePath, String wechatPayPublicKeyId, String wechatPayPublicKeyFilePath) {
|
||||
this.mchid = mchid;
|
||||
this.certificateSerialNo = certificateSerialNo;
|
||||
this.privateKey = WXPayUtility.loadPrivateKeyFromPath(privateKeyFilePath);
|
||||
this.wechatPayPublicKeyId = wechatPayPublicKeyId;
|
||||
this.wechatPayPublicKey = WXPayUtility.loadPublicKeyFromPath(wechatPayPublicKeyFilePath);
|
||||
}
|
||||
|
||||
public static class UnionAPIv3NativePrepayRequest {
|
||||
@SerializedName("combine_appid")
|
||||
public String combineAppid;
|
||||
|
||||
@SerializedName("combine_out_trade_no")
|
||||
public String combineOutTradeNo;
|
||||
|
||||
@SerializedName("combine_mchid")
|
||||
public String combineMchid;
|
||||
|
||||
@SerializedName("scene_info")
|
||||
public UnionSceneInfo sceneInfo;
|
||||
|
||||
@SerializedName("sub_orders")
|
||||
public List<UnionCommonSubOrder> subOrders = new ArrayList<UnionCommonSubOrder>();
|
||||
|
||||
@SerializedName("time_expire")
|
||||
public String timeExpire;
|
||||
|
||||
@SerializedName("notify_url")
|
||||
public String notifyUrl;
|
||||
}
|
||||
|
||||
public static class UnionAPIv3NativePrepayResponse {
|
||||
@SerializedName("code_url")
|
||||
public String codeUrl;
|
||||
}
|
||||
|
||||
public static class UnionSceneInfo {
|
||||
@SerializedName("device_id")
|
||||
public String deviceId;
|
||||
|
||||
@SerializedName("payer_client_ip")
|
||||
public String payerClientIp;
|
||||
}
|
||||
|
||||
public static class UnionCommonSubOrder {
|
||||
@SerializedName("mchid")
|
||||
public String mchid;
|
||||
|
||||
@SerializedName("out_trade_no")
|
||||
public String outTradeNo;
|
||||
|
||||
@SerializedName("amount")
|
||||
public UnionAmountInfo amount;
|
||||
|
||||
@SerializedName("attach")
|
||||
public String attach;
|
||||
|
||||
@SerializedName("description")
|
||||
public String description;
|
||||
|
||||
@SerializedName("goods_tag")
|
||||
public String goodsTag;
|
||||
|
||||
@SerializedName("settle_info")
|
||||
public UnionSettleInfo settleInfo;
|
||||
}
|
||||
|
||||
public static class UnionAmountInfo {
|
||||
@SerializedName("total_amount")
|
||||
public Long totalAmount;
|
||||
|
||||
@SerializedName("currency")
|
||||
public String currency;
|
||||
}
|
||||
|
||||
public static class UnionSettleInfo {
|
||||
@SerializedName("profit_sharing")
|
||||
public Boolean profitSharing;
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,242 @@
|
||||
package com.java.demo;
|
||||
|
||||
import com.java.utils.WXPayUtility; // 引用微信支付工具库,参考:https://pay.weixin.qq.com/doc/v3/merchant/4014931831
|
||||
|
||||
import com.google.gson.annotations.SerializedName;
|
||||
import com.google.gson.annotations.Expose;
|
||||
import okhttp3.MediaType;
|
||||
import okhttp3.OkHttpClient;
|
||||
import okhttp3.Request;
|
||||
import okhttp3.RequestBody;
|
||||
import okhttp3.Response;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.io.UncheckedIOException;
|
||||
import java.security.PrivateKey;
|
||||
import java.security.PublicKey;
|
||||
import java.util.ArrayList;
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* 商户订单号查询订单
|
||||
*/
|
||||
public class UnionQueryByOutTradeNo {
|
||||
private static String HOST = "https://api.mch.weixin.qq.com";
|
||||
private static String METHOD = "GET";
|
||||
private static String PATH = "/v3/combine-transactions/out-trade-no/{combine_out_trade_no}";
|
||||
|
||||
public static void main(String[] args) {
|
||||
// TODO: 请准备商户开发必要参数,参考:https://pay.weixin.qq.com/doc/v3/merchant/4013070756
|
||||
UnionQueryByOutTradeNo client = new UnionQueryByOutTradeNo(
|
||||
"19xxxxxxxx", // 商户号,是由微信支付系统生成并分配给每个商户的唯一标识符,商户号获取方式参考 https://pay.weixin.qq.com/doc/v3/merchant/4013070756
|
||||
"1DDE55AD98Exxxxxxxxxx", // 商户API证书序列号,如何获取请参考 https://pay.weixin.qq.com/doc/v3/merchant/4013053053
|
||||
"/path/to/apiclient_key.pem", // 商户API证书私钥文件路径,本地文件路径
|
||||
"PUB_KEY_ID_xxxxxxxxxxxxx", // 微信支付公钥ID,如何获取请参考 https://pay.weixin.qq.com/doc/v3/merchant/4013038816
|
||||
"/path/to/wxp_pub.pem" // 微信支付公钥文件路径,本地文件路径
|
||||
);
|
||||
|
||||
UnionQueryByOutTradeNoRequest request = new UnionQueryByOutTradeNoRequest();
|
||||
request.combineOutTradeNo = "P20150806125346";
|
||||
try {
|
||||
UnionAPIv3UnionQueryResponse response = client.run(request);
|
||||
// TODO: 请求成功,继续业务逻辑
|
||||
System.out.println(response);
|
||||
} catch (WXPayUtility.ApiException e) {
|
||||
// TODO: 请求失败,根据状态码执行不同的逻辑
|
||||
e.printStackTrace();
|
||||
}
|
||||
}
|
||||
|
||||
public UnionAPIv3UnionQueryResponse run(UnionQueryByOutTradeNoRequest request) {
|
||||
String uri = PATH;
|
||||
uri = uri.replace("{combine_out_trade_no}", WXPayUtility.urlEncode(request.combineOutTradeNo));
|
||||
|
||||
Request.Builder reqBuilder = new Request.Builder().url(HOST + uri);
|
||||
reqBuilder.addHeader("Accept", "application/json");
|
||||
reqBuilder.addHeader("Wechatpay-Serial", wechatPayPublicKeyId);
|
||||
reqBuilder.addHeader("Authorization", WXPayUtility.buildAuthorization(mchid, certificateSerialNo, privateKey, METHOD, uri, null));
|
||||
reqBuilder.method(METHOD, null);
|
||||
Request httpRequest = reqBuilder.build();
|
||||
|
||||
// 发送HTTP请求
|
||||
OkHttpClient client = new OkHttpClient.Builder().build();
|
||||
try (Response httpResponse = client.newCall(httpRequest).execute()) {
|
||||
String respBody = WXPayUtility.extractBody(httpResponse);
|
||||
if (httpResponse.code() >= 200 && httpResponse.code() < 300) {
|
||||
// 2XX 成功,验证应答签名
|
||||
WXPayUtility.validateResponse(this.wechatPayPublicKeyId, this.wechatPayPublicKey,
|
||||
httpResponse.headers(), respBody);
|
||||
|
||||
// 从HTTP应答报文构建返回数据
|
||||
return WXPayUtility.fromJson(respBody, UnionAPIv3UnionQueryResponse.class);
|
||||
} else {
|
||||
throw new WXPayUtility.ApiException(httpResponse.code(), respBody, httpResponse.headers());
|
||||
}
|
||||
} catch (IOException e) {
|
||||
throw new UncheckedIOException("Sending request to " + uri + " failed.", e);
|
||||
}
|
||||
}
|
||||
|
||||
private final String mchid;
|
||||
private final String certificateSerialNo;
|
||||
private final PrivateKey privateKey;
|
||||
private final String wechatPayPublicKeyId;
|
||||
private final PublicKey wechatPayPublicKey;
|
||||
|
||||
public UnionQueryByOutTradeNo(String mchid, String certificateSerialNo, String privateKeyFilePath, String wechatPayPublicKeyId, String wechatPayPublicKeyFilePath) {
|
||||
this.mchid = mchid;
|
||||
this.certificateSerialNo = certificateSerialNo;
|
||||
this.privateKey = WXPayUtility.loadPrivateKeyFromPath(privateKeyFilePath);
|
||||
this.wechatPayPublicKeyId = wechatPayPublicKeyId;
|
||||
this.wechatPayPublicKey = WXPayUtility.loadPublicKeyFromPath(wechatPayPublicKeyFilePath);
|
||||
}
|
||||
|
||||
public static class UnionQueryByOutTradeNoRequest {
|
||||
@SerializedName("combine_out_trade_no")
|
||||
@Expose(serialize = false)
|
||||
public String combineOutTradeNo;
|
||||
}
|
||||
|
||||
public static class UnionAPIv3UnionQueryResponse {
|
||||
@SerializedName("combine_appid")
|
||||
public String combineAppid;
|
||||
|
||||
@SerializedName("combine_mchid")
|
||||
public String combineMchid;
|
||||
|
||||
@SerializedName("combine_out_trade_no")
|
||||
public String combineOutTradeNo;
|
||||
|
||||
@SerializedName("combine_payer_info")
|
||||
public UnionCommRespPayerInfo combinePayerInfo;
|
||||
|
||||
@SerializedName("scene_info")
|
||||
public UnionCommRespSceneInfo sceneInfo;
|
||||
|
||||
@SerializedName("sub_orders")
|
||||
public List<UnionSubOrder> subOrders;
|
||||
}
|
||||
|
||||
public static class UnionCommRespPayerInfo {
|
||||
@SerializedName("openid")
|
||||
public String openid;
|
||||
}
|
||||
|
||||
public static class UnionCommRespSceneInfo {
|
||||
@SerializedName("device_id")
|
||||
public String deviceId;
|
||||
}
|
||||
|
||||
public static class UnionSubOrder {
|
||||
@SerializedName("mchid")
|
||||
public String mchid;
|
||||
|
||||
@SerializedName("sub_mchid")
|
||||
public String subMchid;
|
||||
|
||||
@SerializedName("sub_appid")
|
||||
public String subAppid;
|
||||
|
||||
@SerializedName("sub_openid")
|
||||
public String subOpenid;
|
||||
|
||||
@SerializedName("out_trade_no")
|
||||
public String outTradeNo;
|
||||
|
||||
@SerializedName("transaction_id")
|
||||
public String transactionId;
|
||||
|
||||
@SerializedName("trade_type")
|
||||
public String tradeType;
|
||||
|
||||
@SerializedName("trade_state")
|
||||
public String tradeState;
|
||||
|
||||
@SerializedName("bank_type")
|
||||
public String bankType;
|
||||
|
||||
@SerializedName("attach")
|
||||
public String attach;
|
||||
|
||||
@SerializedName("success_time")
|
||||
public String successTime;
|
||||
|
||||
@SerializedName("amount")
|
||||
public UnionCommRespAmountInfo amount;
|
||||
|
||||
@SerializedName("promotion_detail")
|
||||
public List<UnionPromotionDetail> promotionDetail;
|
||||
}
|
||||
|
||||
public static class UnionCommRespAmountInfo {
|
||||
@SerializedName("total_amount")
|
||||
public Long totalAmount;
|
||||
|
||||
@SerializedName("payer_amount")
|
||||
public Long payerAmount;
|
||||
|
||||
@SerializedName("currency")
|
||||
public String currency;
|
||||
|
||||
@SerializedName("payer_currency")
|
||||
public String payerCurrency;
|
||||
|
||||
@SerializedName("settlement_rate")
|
||||
public Long settlementRate;
|
||||
}
|
||||
|
||||
public static class UnionPromotionDetail {
|
||||
@SerializedName("coupon_id")
|
||||
public String couponId;
|
||||
|
||||
@SerializedName("name")
|
||||
public String name;
|
||||
|
||||
@SerializedName("scope")
|
||||
public String scope;
|
||||
|
||||
@SerializedName("type")
|
||||
public String type;
|
||||
|
||||
@SerializedName("amount")
|
||||
public Long amount;
|
||||
|
||||
@SerializedName("stock_id")
|
||||
public String stockId;
|
||||
|
||||
@SerializedName("wechatpay_contribute")
|
||||
public Long wechatpayContribute;
|
||||
|
||||
@SerializedName("merchant_contribute")
|
||||
public Long merchantContribute;
|
||||
|
||||
@SerializedName("other_contribute")
|
||||
public Long otherContribute;
|
||||
|
||||
@SerializedName("currency")
|
||||
public String currency;
|
||||
|
||||
@SerializedName("goods_detail")
|
||||
public List<GoodsDetailInPromotion> goodsDetail;
|
||||
}
|
||||
|
||||
public static class GoodsDetailInPromotion {
|
||||
@SerializedName("goods_id")
|
||||
public String goodsId;
|
||||
|
||||
@SerializedName("quantity")
|
||||
public Long quantity;
|
||||
|
||||
@SerializedName("unit_price")
|
||||
public Long unitPrice;
|
||||
|
||||
@SerializedName("discount_amount")
|
||||
public Long discountAmount;
|
||||
|
||||
@SerializedName("goods_remark")
|
||||
public String goodsRemark;
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,118 @@
|
||||
# 合单订单支付成功回调通知
|
||||
|
||||
## 回调描述
|
||||
|
||||
用户使用合单支付(APP合单支付/H5合单支付/JSAPI合单支付/Native合单支付/小程序合单支付)功能,当用户成功支付订单后,微信支付会通过POST的请求方式,向商户预先设置的回调地址(APP合单支付/H5合单支付/JSAPI合单支付/Native合单支付/小程序合单支付下单接口传入的notify_url)发送回调通知,让商户知晓用户已完成支付。
|
||||
|
||||
> **注意**:商户侧对微信支付回调IP有防火墙策略限制的,需要对微信回调IP段开通白名单,否则会导致收不到回调(微信支付回调被商户防火墙拦截),详情参考回调处理逻辑注意事项。
|
||||
|
||||
## 回调报文格式
|
||||
|
||||
微信支付会通过POST的方式向回调地址发送回调报文,回调通知的请求主体中会包含JSON格式的通知参数:
|
||||
|
||||
```json
|
||||
{
|
||||
"id": "EV-2018022511223320873",
|
||||
"create_time": "2015-05-20T13:29:35+08:00",
|
||||
"resource_type": "encrypt-resource",
|
||||
"event_type": "TRANSACTION.SUCCESS",
|
||||
"summary": "支付成功",
|
||||
"resource": {
|
||||
"original_type": "transaction",
|
||||
"algorithm": "AEAD_AES_256_GCM",
|
||||
"ciphertext": "",
|
||||
"associated_data": "",
|
||||
"nonce": ""
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## 关键字段说明
|
||||
|
||||
| 字段 | 说明 |
|
||||
|------|------|
|
||||
| `event_type` | 事件类型,支付成功为 `TRANSACTION.SUCCESS` |
|
||||
| `resource.algorithm` | 加密算法,固定为 `AEAD_AES_256_GCM` |
|
||||
| `resource.ciphertext` | 密文,需使用商户APIv3密钥解密后得到合单订单详情 |
|
||||
| `resource.associated_data` | 附加数据,解密时作为 AAD 参数 |
|
||||
| `resource.nonce` | 随机串,解密时作为 Nonce 参数 |
|
||||
|
||||
## 解密后的合单订单明文结构
|
||||
|
||||
> 与基础支付回调的关键区别:解密后返回的是 `sub_orders[]` 数组(含各子单状态),而非单个订单。
|
||||
|
||||
```json
|
||||
{
|
||||
"combine_appid": "wxd678efh567hg6787",
|
||||
"combine_out_trade_no": "20150806125346",
|
||||
"combine_mchid": "1900000109",
|
||||
"scene_info": {
|
||||
"device_id": "POS1:1"
|
||||
},
|
||||
"sub_orders": [
|
||||
{
|
||||
"mchid": "1900000109",
|
||||
"trade_type": "JSAPI",
|
||||
"trade_state": "SUCCESS",
|
||||
"bank_type": "CMC",
|
||||
"attach": "深圳分店",
|
||||
"amount": {
|
||||
"total_amount": 10,
|
||||
"currency": "CNY",
|
||||
"payer_amount": 10,
|
||||
"payer_currency": "CNY"
|
||||
},
|
||||
"success_time": "2015-05-20T13:29:35.120+08:00",
|
||||
"transaction_id": "1009660380201506130728806387",
|
||||
"out_trade_no": "20150806125346"
|
||||
},
|
||||
{
|
||||
"mchid": "1900000179",
|
||||
"trade_type": "JSAPI",
|
||||
"trade_state": "SUCCESS",
|
||||
"bank_type": "CMC",
|
||||
"attach": "广州分店",
|
||||
"amount": {
|
||||
"total_amount": 10,
|
||||
"currency": "CNY",
|
||||
"payer_amount": 10,
|
||||
"payer_currency": "CNY"
|
||||
},
|
||||
"success_time": "2015-05-20T13:29:35.120+08:00",
|
||||
"transaction_id": "1009660380201506130728452147",
|
||||
"out_trade_no": "20150806124855"
|
||||
}
|
||||
],
|
||||
"combine_payer_info": {
|
||||
"openid": "oUpF8uMuAJO_M2pxb1Q9zNjWeS6o"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## 解密后关键字段说明
|
||||
|
||||
| 字段 | 说明 |
|
||||
|------|------|
|
||||
| `combine_appid` | 合单发起方的 APPID |
|
||||
| `combine_mchid` | 合单发起方的商户号 |
|
||||
| `combine_out_trade_no` | 合单商户订单号 |
|
||||
| `sub_orders[].mchid` | 子单商户号 |
|
||||
| `sub_orders[].trade_type` | 交易类型:`JSAPI`、`NATIVE`、`APP`、`MWEB` |
|
||||
| `sub_orders[].trade_state` | 交易状态:`SUCCESS`(支付成功)、`NOTPAY`(未支付)、`CLOSED`(已关闭) |
|
||||
| `sub_orders[].transaction_id` | 子单微信支付订单号(退款时需用此字段) |
|
||||
| `sub_orders[].out_trade_no` | 子单商户订单号 |
|
||||
| `sub_orders[].amount.total_amount` | 子单金额,单位:分 |
|
||||
| `sub_orders[].amount.payer_amount` | 用户实付金额(= total_amount - 代金券优惠) |
|
||||
| `sub_orders[].success_time` | 支付完成时间,rfc3339 格式 |
|
||||
| `sub_orders[].bank_type` | 银行类型,非银行卡支付统一返回 `OTHERS` |
|
||||
| `combine_payer_info.openid` | 实际支付用户在 `combine_appid` 下的 openid |
|
||||
|
||||
## 处理要求
|
||||
|
||||
1. 接收到回调后,先验证请求签名(使用微信支付公钥验签),验签机制与基础支付相同
|
||||
2. 使用商户APIv3密钥 + AEAD_AES_256_GCM 解密 `resource.ciphertext` 得到合单订单明文
|
||||
3. 验签通过后返回 HTTP 200 或 204(无应答报文),表示确认收到
|
||||
4. 验签不通过返回 HTTP 5XX 或 4XX + `{"code": "FAIL", "message": "失败原因"}`
|
||||
5. 若返回非200或超时(5秒),微信支付按策略重试(15s/15s/30s/3m/10m/20m/30m/30m/30m/60m/3h/3h/3h/6h/6h),最多15次
|
||||
6. 不可仅依赖回调获取支付结果,需结合**查询合单订单接口**使用
|
||||
7. 收到重复回调通知时需做好幂等处理,持续应答 200
|
||||
@@ -0,0 +1,174 @@
|
||||
package com.java.demo;
|
||||
|
||||
import com.java.utils.WXPayUtility; // 引用微信支付工具库,参考:https://pay.weixin.qq.com/doc/v3/merchant/4014931831
|
||||
|
||||
import com.google.gson.annotations.SerializedName;
|
||||
import com.google.gson.annotations.Expose;
|
||||
import okhttp3.MediaType;
|
||||
import okhttp3.OkHttpClient;
|
||||
import okhttp3.Request;
|
||||
import okhttp3.RequestBody;
|
||||
import okhttp3.Response;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.io.UncheckedIOException;
|
||||
import java.security.PrivateKey;
|
||||
import java.security.PublicKey;
|
||||
import java.util.ArrayList;
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* 添加分账接收方
|
||||
*/
|
||||
public class AddReceiver {
|
||||
private static String HOST = "https://api.mch.weixin.qq.com";
|
||||
private static String METHOD = "POST";
|
||||
private static String PATH = "/v3/profitsharing/receivers/add";
|
||||
|
||||
public static void main(String[] args) {
|
||||
// TODO: 请准备商户开发必要参数,参考:https://pay.weixin.qq.com/doc/v3/merchant/4013070756
|
||||
AddReceiver client = new AddReceiver(
|
||||
"19xxxxxxxx", // 商户号,是由微信支付系统生成并分配给每个商户的唯一标识符,商户号获取方式参考 https://pay.weixin.qq.com/doc/v3/merchant/4013070756
|
||||
"1DDE55AD98Exxxxxxxxxx", // 商户API证书序列号,如何获取请参考 https://pay.weixin.qq.com/doc/v3/merchant/4013053053
|
||||
"/path/to/apiclient_key.pem", // 商户API证书私钥文件路径,本地文件路径
|
||||
"PUB_KEY_ID_xxxxxxxxxxxxx", // 微信支付公钥ID,如何获取请参考 https://pay.weixin.qq.com/doc/v3/merchant/4013038816
|
||||
"/path/to/wxp_pub.pem" // 微信支付公钥文件路径,本地文件路径
|
||||
);
|
||||
|
||||
AddReceiverRequest request = new AddReceiverRequest();
|
||||
request.appid = "wx8888888888888888";
|
||||
request.type = ReceiverType.MERCHANT_ID;
|
||||
request.account = "86693852";
|
||||
request.name = client.encrypt("name");
|
||||
request.relationType = ReceiverRelationType.STORE;
|
||||
request.customRelation = "代理商";
|
||||
try {
|
||||
AddReceiverResponse response = client.run(request);
|
||||
// TODO: 请求成功,继续业务逻辑
|
||||
System.out.println(response);
|
||||
} catch (WXPayUtility.ApiException e) {
|
||||
// TODO: 请求失败,根据状态码执行不同的逻辑
|
||||
e.printStackTrace();
|
||||
}
|
||||
}
|
||||
|
||||
public AddReceiverResponse run(AddReceiverRequest request) {
|
||||
String uri = PATH;
|
||||
String reqBody = WXPayUtility.toJson(request);
|
||||
|
||||
Request.Builder reqBuilder = new Request.Builder().url(HOST + uri);
|
||||
reqBuilder.addHeader("Accept", "application/json");
|
||||
reqBuilder.addHeader("Wechatpay-Serial", wechatPayPublicKeyId);
|
||||
reqBuilder.addHeader("Authorization", WXPayUtility.buildAuthorization(mchid, certificateSerialNo,privateKey, METHOD, uri, reqBody));
|
||||
reqBuilder.addHeader("Content-Type", "application/json");
|
||||
RequestBody requestBody = RequestBody.create(MediaType.parse("application/json; charset=utf-8"), reqBody);
|
||||
reqBuilder.method(METHOD, requestBody);
|
||||
Request httpRequest = reqBuilder.build();
|
||||
|
||||
// 发送HTTP请求
|
||||
OkHttpClient client = new OkHttpClient.Builder().build();
|
||||
try (Response httpResponse = client.newCall(httpRequest).execute()) {
|
||||
String respBody = WXPayUtility.extractBody(httpResponse);
|
||||
if (httpResponse.code() >= 200 && httpResponse.code() < 300) {
|
||||
// 2XX 成功,验证应答签名
|
||||
WXPayUtility.validateResponse(this.wechatPayPublicKeyId, this.wechatPayPublicKey,
|
||||
httpResponse.headers(), respBody);
|
||||
|
||||
// 从HTTP应答报文构建返回数据
|
||||
return WXPayUtility.fromJson(respBody, AddReceiverResponse.class);
|
||||
} else {
|
||||
throw new WXPayUtility.ApiException(httpResponse.code(), respBody, httpResponse.headers());
|
||||
}
|
||||
} catch (IOException e) {
|
||||
throw new UncheckedIOException("Sending request to " + uri + " failed.", e);
|
||||
}
|
||||
}
|
||||
|
||||
private final String mchid;
|
||||
private final String certificateSerialNo;
|
||||
private final PrivateKey privateKey;
|
||||
private final String wechatPayPublicKeyId;
|
||||
private final PublicKey wechatPayPublicKey;
|
||||
|
||||
public AddReceiver(String mchid, String certificateSerialNo, String privateKeyFilePath, String wechatPayPublicKeyId, String wechatPayPublicKeyFilePath) {
|
||||
this.mchid = mchid;
|
||||
this.certificateSerialNo = certificateSerialNo;
|
||||
this.privateKey = WXPayUtility.loadPrivateKeyFromPath(privateKeyFilePath);
|
||||
this.wechatPayPublicKeyId = wechatPayPublicKeyId;
|
||||
this.wechatPayPublicKey = WXPayUtility.loadPublicKeyFromPath(wechatPayPublicKeyFilePath);
|
||||
}
|
||||
|
||||
public String encrypt(String plainText) {
|
||||
return WXPayUtility.encrypt(this.wechatPayPublicKey, plainText);
|
||||
}
|
||||
|
||||
public static class AddReceiverRequest {
|
||||
@SerializedName("appid")
|
||||
public String appid;
|
||||
|
||||
@SerializedName("type")
|
||||
public ReceiverType type;
|
||||
|
||||
@SerializedName("account")
|
||||
public String account;
|
||||
|
||||
@SerializedName("name")
|
||||
public String name;
|
||||
|
||||
@SerializedName("relation_type")
|
||||
public ReceiverRelationType relationType;
|
||||
|
||||
@SerializedName("custom_relation")
|
||||
public String customRelation;
|
||||
}
|
||||
|
||||
public static class AddReceiverResponse {
|
||||
@SerializedName("type")
|
||||
public ReceiverType type;
|
||||
|
||||
@SerializedName("account")
|
||||
public String account;
|
||||
|
||||
@SerializedName("name")
|
||||
public String name;
|
||||
|
||||
@SerializedName("relation_type")
|
||||
public ReceiverRelationType relationType;
|
||||
|
||||
@SerializedName("custom_relation")
|
||||
public String customRelation;
|
||||
}
|
||||
|
||||
public enum ReceiverType {
|
||||
@SerializedName("MERCHANT_ID")
|
||||
MERCHANT_ID,
|
||||
@SerializedName("PERSONAL_OPENID")
|
||||
PERSONAL_OPENID
|
||||
}
|
||||
|
||||
public enum ReceiverRelationType {
|
||||
@SerializedName("STORE")
|
||||
STORE,
|
||||
@SerializedName("STAFF")
|
||||
STAFF,
|
||||
@SerializedName("STORE_OWNER")
|
||||
STORE_OWNER,
|
||||
@SerializedName("PARTNER")
|
||||
PARTNER,
|
||||
@SerializedName("HEADQUARTER")
|
||||
HEADQUARTER,
|
||||
@SerializedName("BRAND")
|
||||
BRAND,
|
||||
@SerializedName("DISTRIBUTOR")
|
||||
DISTRIBUTOR,
|
||||
@SerializedName("USER")
|
||||
USER,
|
||||
@SerializedName("SUPPLIER")
|
||||
SUPPLIER,
|
||||
@SerializedName("CUSTOM")
|
||||
CUSTOM
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,198 @@
|
||||
package com.java.demo;
|
||||
|
||||
import com.java.utils.WXPayUtility; // 引用微信支付工具库,参考:https://pay.weixin.qq.com/doc/v3/merchant/4014931831
|
||||
|
||||
import com.google.gson.annotations.SerializedName;
|
||||
import com.google.gson.annotations.Expose;
|
||||
import okhttp3.MediaType;
|
||||
import okhttp3.OkHttpClient;
|
||||
import okhttp3.Request;
|
||||
import okhttp3.RequestBody;
|
||||
import okhttp3.Response;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.io.UncheckedIOException;
|
||||
import java.security.PrivateKey;
|
||||
import java.security.PublicKey;
|
||||
import java.util.ArrayList;
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* 请求分账
|
||||
*/
|
||||
public class CreateOrder {
|
||||
private static String HOST = "https://api.mch.weixin.qq.com";
|
||||
private static String METHOD = "POST";
|
||||
private static String PATH = "/v3/profitsharing/orders";
|
||||
|
||||
public static void main(String[] args) {
|
||||
CreateOrder client = new CreateOrder(
|
||||
"19xxxxxxxx",
|
||||
"1DDE55AD98Exxxxxxxxxx",
|
||||
"/path/to/apiclient_key.pem",
|
||||
"PUB_KEY_ID_xxxxxxxxxxxxx",
|
||||
"/path/to/wxp_pub.pem"
|
||||
);
|
||||
|
||||
CreateOrderRequest request = new CreateOrderRequest();
|
||||
request.appid = "wx8888888888888888";
|
||||
request.transactionId = "4208450740201411110007820472";
|
||||
request.outOrderNo = "P20150806125346";
|
||||
request.receivers = new ArrayList<>();
|
||||
{
|
||||
CreateOrderReceiver receiversItem = new CreateOrderReceiver();
|
||||
receiversItem.type = "MERCHANT_ID";
|
||||
receiversItem.account = "86693852";
|
||||
receiversItem.name = client.encrypt("name");
|
||||
receiversItem.amount = 888L;
|
||||
receiversItem.description = "分给商户A";
|
||||
request.receivers.add(receiversItem);
|
||||
};
|
||||
request.unfreezeUnsplit = true;
|
||||
try {
|
||||
OrdersEntity response = client.run(request);
|
||||
System.out.println(response);
|
||||
} catch (WXPayUtility.ApiException e) {
|
||||
e.printStackTrace();
|
||||
}
|
||||
}
|
||||
|
||||
public OrdersEntity run(CreateOrderRequest request) {
|
||||
String uri = PATH;
|
||||
String reqBody = WXPayUtility.toJson(request);
|
||||
|
||||
Request.Builder reqBuilder = new Request.Builder().url(HOST + uri);
|
||||
reqBuilder.addHeader("Accept", "application/json");
|
||||
reqBuilder.addHeader("Wechatpay-Serial", wechatPayPublicKeyId);
|
||||
reqBuilder.addHeader("Authorization", WXPayUtility.buildAuthorization(mchid, certificateSerialNo,privateKey, METHOD, uri, reqBody));
|
||||
reqBuilder.addHeader("Content-Type", "application/json");
|
||||
RequestBody requestBody = RequestBody.create(MediaType.parse("application/json; charset=utf-8"), reqBody);
|
||||
reqBuilder.method(METHOD, requestBody);
|
||||
Request httpRequest = reqBuilder.build();
|
||||
|
||||
OkHttpClient client = new OkHttpClient.Builder().build();
|
||||
try (Response httpResponse = client.newCall(httpRequest).execute()) {
|
||||
String respBody = WXPayUtility.extractBody(httpResponse);
|
||||
if (httpResponse.code() >= 200 && httpResponse.code() < 300) {
|
||||
WXPayUtility.validateResponse(this.wechatPayPublicKeyId, this.wechatPayPublicKey,
|
||||
httpResponse.headers(), respBody);
|
||||
return WXPayUtility.fromJson(respBody, OrdersEntity.class);
|
||||
} else {
|
||||
throw new WXPayUtility.ApiException(httpResponse.code(), respBody, httpResponse.headers());
|
||||
}
|
||||
} catch (IOException e) {
|
||||
throw new UncheckedIOException("Sending request to " + uri + " failed.", e);
|
||||
}
|
||||
}
|
||||
|
||||
private final String mchid;
|
||||
private final String certificateSerialNo;
|
||||
private final PrivateKey privateKey;
|
||||
private final String wechatPayPublicKeyId;
|
||||
private final PublicKey wechatPayPublicKey;
|
||||
|
||||
public CreateOrder(String mchid, String certificateSerialNo, String privateKeyFilePath, String wechatPayPublicKeyId, String wechatPayPublicKeyFilePath) {
|
||||
this.mchid = mchid;
|
||||
this.certificateSerialNo = certificateSerialNo;
|
||||
this.privateKey = WXPayUtility.loadPrivateKeyFromPath(privateKeyFilePath);
|
||||
this.wechatPayPublicKeyId = wechatPayPublicKeyId;
|
||||
this.wechatPayPublicKey = WXPayUtility.loadPublicKeyFromPath(wechatPayPublicKeyFilePath);
|
||||
}
|
||||
|
||||
public String encrypt(String plainText) {
|
||||
return WXPayUtility.encrypt(this.wechatPayPublicKey, plainText);
|
||||
}
|
||||
|
||||
public static class CreateOrderRequest {
|
||||
@SerializedName("appid")
|
||||
public String appid;
|
||||
|
||||
@SerializedName("transaction_id")
|
||||
public String transactionId;
|
||||
|
||||
@SerializedName("out_order_no")
|
||||
public String outOrderNo;
|
||||
|
||||
@SerializedName("receivers")
|
||||
public List<CreateOrderReceiver> receivers = new ArrayList<CreateOrderReceiver>();
|
||||
|
||||
@SerializedName("unfreeze_unsplit")
|
||||
public Boolean unfreezeUnsplit;
|
||||
}
|
||||
|
||||
public static class OrdersEntity {
|
||||
@SerializedName("transaction_id")
|
||||
public String transactionId;
|
||||
|
||||
@SerializedName("out_order_no")
|
||||
public String outOrderNo;
|
||||
|
||||
@SerializedName("order_id")
|
||||
public String orderId;
|
||||
|
||||
@SerializedName("state")
|
||||
public OrderStatus state;
|
||||
|
||||
@SerializedName("receivers")
|
||||
public List<OrderReceiverDetail> receivers = new ArrayList<OrderReceiverDetail>();
|
||||
}
|
||||
|
||||
public static class CreateOrderReceiver {
|
||||
@SerializedName("type")
|
||||
public String type;
|
||||
|
||||
@SerializedName("account")
|
||||
public String account;
|
||||
|
||||
@SerializedName("name")
|
||||
public String name;
|
||||
|
||||
@SerializedName("amount")
|
||||
public Long amount;
|
||||
|
||||
@SerializedName("description")
|
||||
public String description;
|
||||
}
|
||||
|
||||
public enum OrderStatus {
|
||||
@SerializedName("PROCESSING") PROCESSING,
|
||||
@SerializedName("FINISHED") FINISHED
|
||||
}
|
||||
|
||||
public static class OrderReceiverDetail {
|
||||
@SerializedName("amount") public Long amount;
|
||||
@SerializedName("description") public String description;
|
||||
@SerializedName("type") public ReceiverType type;
|
||||
@SerializedName("account") public String account;
|
||||
@SerializedName("result") public DetailStatus result;
|
||||
@SerializedName("fail_reason") public DetailFailReason failReason;
|
||||
@SerializedName("create_time") public String createTime;
|
||||
@SerializedName("finish_time") public String finishTime;
|
||||
@SerializedName("detail_id") public String detailId;
|
||||
}
|
||||
|
||||
public enum ReceiverType {
|
||||
@SerializedName("MERCHANT_ID") MERCHANT_ID,
|
||||
@SerializedName("PERSONAL_OPENID") PERSONAL_OPENID
|
||||
}
|
||||
|
||||
public enum DetailStatus {
|
||||
@SerializedName("PENDING") PENDING,
|
||||
@SerializedName("SUCCESS") SUCCESS,
|
||||
@SerializedName("CLOSED") CLOSED
|
||||
}
|
||||
|
||||
public enum DetailFailReason {
|
||||
@SerializedName("ACCOUNT_ABNORMAL") ACCOUNT_ABNORMAL,
|
||||
@SerializedName("NO_RELATION") NO_RELATION,
|
||||
@SerializedName("RECEIVER_HIGH_RISK") RECEIVER_HIGH_RISK,
|
||||
@SerializedName("RECEIVER_REAL_NAME_NOT_VERIFIED") RECEIVER_REAL_NAME_NOT_VERIFIED,
|
||||
@SerializedName("NO_AUTH") NO_AUTH,
|
||||
@SerializedName("RECEIVER_RECEIPT_LIMIT") RECEIVER_RECEIPT_LIMIT,
|
||||
@SerializedName("PAYER_ACCOUNT_ABNORMAL") PAYER_ACCOUNT_ABNORMAL,
|
||||
@SerializedName("INVALID_REQUEST") INVALID_REQUEST
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,180 @@
|
||||
package com.java.demo;
|
||||
|
||||
import com.java.utils.WXPayUtility; // 引用微信支付工具库,参考:https://pay.weixin.qq.com/doc/v3/merchant/4014931831
|
||||
|
||||
import com.google.gson.annotations.SerializedName;
|
||||
import com.google.gson.annotations.Expose;
|
||||
import okhttp3.MediaType;
|
||||
import okhttp3.OkHttpClient;
|
||||
import okhttp3.Request;
|
||||
import okhttp3.RequestBody;
|
||||
import okhttp3.Response;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.io.UncheckedIOException;
|
||||
import java.security.PrivateKey;
|
||||
import java.security.PublicKey;
|
||||
import java.util.ArrayList;
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* 请求分账回退
|
||||
*/
|
||||
public class CreateReturnOrder {
|
||||
private static String HOST = "https://api.mch.weixin.qq.com";
|
||||
private static String METHOD = "POST";
|
||||
private static String PATH = "/v3/profitsharing/return-orders";
|
||||
|
||||
public static void main(String[] args) {
|
||||
// TODO: 请准备商户开发必要参数,参考:https://pay.weixin.qq.com/doc/v3/merchant/4013070756
|
||||
CreateReturnOrder client = new CreateReturnOrder(
|
||||
"19xxxxxxxx", // 商户号,是由微信支付系统生成并分配给每个商户的唯一标识符,商户号获取方式参考 https://pay.weixin.qq.com/doc/v3/merchant/4013070756
|
||||
"1DDE55AD98Exxxxxxxxxx", // 商户API证书序列号,如何获取请参考 https://pay.weixin.qq.com/doc/v3/merchant/4013053053
|
||||
"/path/to/apiclient_key.pem", // 商户API证书私钥文件路径,本地文件路径
|
||||
"PUB_KEY_ID_xxxxxxxxxxxxx", // 微信支付公钥ID,如何获取请参考 https://pay.weixin.qq.com/doc/v3/merchant/4013038816
|
||||
"/path/to/wxp_pub.pem" // 微信支付公钥文件路径,本地文件路径
|
||||
);
|
||||
|
||||
CreateReturnOrderRequest request = new CreateReturnOrderRequest();
|
||||
request.orderId = "3008450740201411110007820472";
|
||||
request.outOrderNo = "P20150806125346";
|
||||
request.outReturnNo = "R20190516001";
|
||||
request.returnMchid = "86693852";
|
||||
request.amount = 10L;
|
||||
request.description = "用户退款";
|
||||
try {
|
||||
ReturnOrdersEntity response = client.run(request);
|
||||
// TODO: 请求成功,继续业务逻辑
|
||||
System.out.println(response);
|
||||
} catch (WXPayUtility.ApiException e) {
|
||||
// TODO: 请求失败,根据状态码执行不同的逻辑
|
||||
e.printStackTrace();
|
||||
}
|
||||
}
|
||||
|
||||
public ReturnOrdersEntity run(CreateReturnOrderRequest request) {
|
||||
String uri = PATH;
|
||||
String reqBody = WXPayUtility.toJson(request);
|
||||
|
||||
Request.Builder reqBuilder = new Request.Builder().url(HOST + uri);
|
||||
reqBuilder.addHeader("Accept", "application/json");
|
||||
reqBuilder.addHeader("Wechatpay-Serial", wechatPayPublicKeyId);
|
||||
reqBuilder.addHeader("Authorization", WXPayUtility.buildAuthorization(mchid, certificateSerialNo,privateKey, METHOD, uri, reqBody));
|
||||
reqBuilder.addHeader("Content-Type", "application/json");
|
||||
RequestBody requestBody = RequestBody.create(MediaType.parse("application/json; charset=utf-8"), reqBody);
|
||||
reqBuilder.method(METHOD, requestBody);
|
||||
Request httpRequest = reqBuilder.build();
|
||||
|
||||
// 发送HTTP请求
|
||||
OkHttpClient client = new OkHttpClient.Builder().build();
|
||||
try (Response httpResponse = client.newCall(httpRequest).execute()) {
|
||||
String respBody = WXPayUtility.extractBody(httpResponse);
|
||||
if (httpResponse.code() >= 200 && httpResponse.code() < 300) {
|
||||
// 2XX 成功,验证应答签名
|
||||
WXPayUtility.validateResponse(this.wechatPayPublicKeyId, this.wechatPayPublicKey,
|
||||
httpResponse.headers(), respBody);
|
||||
|
||||
// 从HTTP应答报文构建返回数据
|
||||
return WXPayUtility.fromJson(respBody, ReturnOrdersEntity.class);
|
||||
} else {
|
||||
throw new WXPayUtility.ApiException(httpResponse.code(), respBody, httpResponse.headers());
|
||||
}
|
||||
} catch (IOException e) {
|
||||
throw new UncheckedIOException("Sending request to " + uri + " failed.", e);
|
||||
}
|
||||
}
|
||||
|
||||
private final String mchid;
|
||||
private final String certificateSerialNo;
|
||||
private final PrivateKey privateKey;
|
||||
private final String wechatPayPublicKeyId;
|
||||
private final PublicKey wechatPayPublicKey;
|
||||
|
||||
public CreateReturnOrder(String mchid, String certificateSerialNo, String privateKeyFilePath, String wechatPayPublicKeyId, String wechatPayPublicKeyFilePath) {
|
||||
this.mchid = mchid;
|
||||
this.certificateSerialNo = certificateSerialNo;
|
||||
this.privateKey = WXPayUtility.loadPrivateKeyFromPath(privateKeyFilePath);
|
||||
this.wechatPayPublicKeyId = wechatPayPublicKeyId;
|
||||
this.wechatPayPublicKey = WXPayUtility.loadPublicKeyFromPath(wechatPayPublicKeyFilePath);
|
||||
}
|
||||
|
||||
public static class CreateReturnOrderRequest {
|
||||
@SerializedName("order_id")
|
||||
public String orderId;
|
||||
|
||||
@SerializedName("out_order_no")
|
||||
public String outOrderNo;
|
||||
|
||||
@SerializedName("out_return_no")
|
||||
public String outReturnNo;
|
||||
|
||||
@SerializedName("return_mchid")
|
||||
public String returnMchid;
|
||||
|
||||
@SerializedName("amount")
|
||||
public Long amount;
|
||||
|
||||
@SerializedName("description")
|
||||
public String description;
|
||||
}
|
||||
|
||||
public static class ReturnOrdersEntity {
|
||||
@SerializedName("order_id")
|
||||
public String orderId;
|
||||
|
||||
@SerializedName("out_order_no")
|
||||
public String outOrderNo;
|
||||
|
||||
@SerializedName("out_return_no")
|
||||
public String outReturnNo;
|
||||
|
||||
@SerializedName("return_id")
|
||||
public String returnId;
|
||||
|
||||
@SerializedName("return_mchid")
|
||||
public String returnMchid;
|
||||
|
||||
@SerializedName("amount")
|
||||
public Long amount;
|
||||
|
||||
@SerializedName("description")
|
||||
public String description;
|
||||
|
||||
@SerializedName("result")
|
||||
public ReturnOrderStatus result;
|
||||
|
||||
@SerializedName("fail_reason")
|
||||
public ReturnOrderFailReason failReason;
|
||||
|
||||
@SerializedName("create_time")
|
||||
public String createTime;
|
||||
|
||||
@SerializedName("finish_time")
|
||||
public String finishTime;
|
||||
}
|
||||
|
||||
public enum ReturnOrderStatus {
|
||||
@SerializedName("PROCESSING")
|
||||
PROCESSING,
|
||||
@SerializedName("SUCCESS")
|
||||
SUCCESS,
|
||||
@SerializedName("FAILED")
|
||||
FAILED
|
||||
}
|
||||
|
||||
public enum ReturnOrderFailReason {
|
||||
@SerializedName("ACCOUNT_ABNORMAL")
|
||||
ACCOUNT_ABNORMAL,
|
||||
@SerializedName("BALANCE_NOT_ENOUGH")
|
||||
BALANCE_NOT_ENOUGH,
|
||||
@SerializedName("TIME_OUT_CLOSED")
|
||||
TIME_OUT_CLOSED,
|
||||
@SerializedName("PAYER_ACCOUNT_ABNORMAL")
|
||||
PAYER_ACCOUNT_ABNORMAL,
|
||||
@SerializedName("INVALID_REQUEST")
|
||||
INVALID_REQUEST
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,126 @@
|
||||
package com.java.demo;
|
||||
|
||||
import com.java.utils.WXPayUtility; // 引用微信支付工具库,参考:https://pay.weixin.qq.com/doc/v3/merchant/4014931831
|
||||
|
||||
import com.google.gson.annotations.SerializedName;
|
||||
import com.google.gson.annotations.Expose;
|
||||
import okhttp3.MediaType;
|
||||
import okhttp3.OkHttpClient;
|
||||
import okhttp3.Request;
|
||||
import okhttp3.RequestBody;
|
||||
import okhttp3.Response;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.io.UncheckedIOException;
|
||||
import java.security.PrivateKey;
|
||||
import java.security.PublicKey;
|
||||
import java.util.ArrayList;
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* 删除分账接收方
|
||||
*/
|
||||
public class DeleteReceiver {
|
||||
private static String HOST = "https://api.mch.weixin.qq.com";
|
||||
private static String METHOD = "POST";
|
||||
private static String PATH = "/v3/profitsharing/receivers/delete";
|
||||
|
||||
public static void main(String[] args) {
|
||||
// TODO: 请准备商户开发必要参数,参考:https://pay.weixin.qq.com/doc/v3/merchant/4013070756
|
||||
DeleteReceiver client = new DeleteReceiver(
|
||||
"19xxxxxxxx", // 商户号,是由微信支付系统生成并分配给每个商户的唯一标识符,商户号获取方式参考 https://pay.weixin.qq.com/doc/v3/merchant/4013070756
|
||||
"1DDE55AD98Exxxxxxxxxx", // 商户API证书序列号,如何获取请参考 https://pay.weixin.qq.com/doc/v3/merchant/4013053053
|
||||
"/path/to/apiclient_key.pem", // 商户API证书私钥文件路径,本地文件路径
|
||||
"PUB_KEY_ID_xxxxxxxxxxxxx", // 微信支付公钥ID,如何获取请参考 https://pay.weixin.qq.com/doc/v3/merchant/4013038816
|
||||
"/path/to/wxp_pub.pem" // 微信支付公钥文件路径,本地文件路径
|
||||
);
|
||||
|
||||
DeleteReceiverRequest request = new DeleteReceiverRequest();
|
||||
request.appid = "wx8888888888888888";
|
||||
request.type = ReceiverType.MERCHANT_ID;
|
||||
request.account = "1900000109";
|
||||
try {
|
||||
DeleteReceiverResponse response = client.run(request);
|
||||
// TODO: 请求成功,继续业务逻辑
|
||||
System.out.println(response);
|
||||
} catch (WXPayUtility.ApiException e) {
|
||||
// TODO: 请求失败,根据状态码执行不同的逻辑
|
||||
e.printStackTrace();
|
||||
}
|
||||
}
|
||||
|
||||
public DeleteReceiverResponse run(DeleteReceiverRequest request) {
|
||||
String uri = PATH;
|
||||
String reqBody = WXPayUtility.toJson(request);
|
||||
|
||||
Request.Builder reqBuilder = new Request.Builder().url(HOST + uri);
|
||||
reqBuilder.addHeader("Accept", "application/json");
|
||||
reqBuilder.addHeader("Wechatpay-Serial", wechatPayPublicKeyId);
|
||||
reqBuilder.addHeader("Authorization", WXPayUtility.buildAuthorization(mchid, certificateSerialNo,privateKey, METHOD, uri, reqBody));
|
||||
reqBuilder.addHeader("Content-Type", "application/json");
|
||||
RequestBody requestBody = RequestBody.create(MediaType.parse("application/json; charset=utf-8"), reqBody);
|
||||
reqBuilder.method(METHOD, requestBody);
|
||||
Request httpRequest = reqBuilder.build();
|
||||
|
||||
// 发送HTTP请求
|
||||
OkHttpClient client = new OkHttpClient.Builder().build();
|
||||
try (Response httpResponse = client.newCall(httpRequest).execute()) {
|
||||
String respBody = WXPayUtility.extractBody(httpResponse);
|
||||
if (httpResponse.code() >= 200 && httpResponse.code() < 300) {
|
||||
// 2XX 成功,验证应答签名
|
||||
WXPayUtility.validateResponse(this.wechatPayPublicKeyId, this.wechatPayPublicKey,
|
||||
httpResponse.headers(), respBody);
|
||||
|
||||
// 从HTTP应答报文构建返回数据
|
||||
return WXPayUtility.fromJson(respBody, DeleteReceiverResponse.class);
|
||||
} else {
|
||||
throw new WXPayUtility.ApiException(httpResponse.code(), respBody, httpResponse.headers());
|
||||
}
|
||||
} catch (IOException e) {
|
||||
throw new UncheckedIOException("Sending request to " + uri + " failed.", e);
|
||||
}
|
||||
}
|
||||
|
||||
private final String mchid;
|
||||
private final String certificateSerialNo;
|
||||
private final PrivateKey privateKey;
|
||||
private final String wechatPayPublicKeyId;
|
||||
private final PublicKey wechatPayPublicKey;
|
||||
|
||||
public DeleteReceiver(String mchid, String certificateSerialNo, String privateKeyFilePath, String wechatPayPublicKeyId, String wechatPayPublicKeyFilePath) {
|
||||
this.mchid = mchid;
|
||||
this.certificateSerialNo = certificateSerialNo;
|
||||
this.privateKey = WXPayUtility.loadPrivateKeyFromPath(privateKeyFilePath);
|
||||
this.wechatPayPublicKeyId = wechatPayPublicKeyId;
|
||||
this.wechatPayPublicKey = WXPayUtility.loadPublicKeyFromPath(wechatPayPublicKeyFilePath);
|
||||
}
|
||||
|
||||
public static class DeleteReceiverRequest {
|
||||
@SerializedName("appid")
|
||||
public String appid;
|
||||
|
||||
@SerializedName("type")
|
||||
public ReceiverType type;
|
||||
|
||||
@SerializedName("account")
|
||||
public String account;
|
||||
}
|
||||
|
||||
public static class DeleteReceiverResponse {
|
||||
@SerializedName("type")
|
||||
public ReceiverType type;
|
||||
|
||||
@SerializedName("account")
|
||||
public String account;
|
||||
}
|
||||
|
||||
public enum ReceiverType {
|
||||
@SerializedName("MERCHANT_ID")
|
||||
MERCHANT_ID,
|
||||
@SerializedName("PERSONAL_OPENID")
|
||||
PERSONAL_OPENID
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,201 @@
|
||||
package com.java.demo;
|
||||
|
||||
import com.java.utils.WXPayUtility; // 引用微信支付工具库,参考:https://pay.weixin.qq.com/doc/v3/merchant/4014931831
|
||||
|
||||
import com.google.gson.annotations.SerializedName;
|
||||
import com.google.gson.annotations.Expose;
|
||||
import okhttp3.MediaType;
|
||||
import okhttp3.OkHttpClient;
|
||||
import okhttp3.Request;
|
||||
import okhttp3.RequestBody;
|
||||
import okhttp3.Response;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.io.UncheckedIOException;
|
||||
import java.security.PrivateKey;
|
||||
import java.security.PublicKey;
|
||||
import java.util.ArrayList;
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* 查询分账结果
|
||||
*/
|
||||
public class QueryOrder {
|
||||
private static String HOST = "https://api.mch.weixin.qq.com";
|
||||
private static String METHOD = "GET";
|
||||
private static String PATH = "/v3/profitsharing/orders/{out_order_no}";
|
||||
|
||||
public static void main(String[] args) {
|
||||
// TODO: 请准备商户开发必要参数,参考:https://pay.weixin.qq.com/doc/v3/merchant/4013070756
|
||||
QueryOrder client = new QueryOrder(
|
||||
"19xxxxxxxx", // 商户号,是由微信支付系统生成并分配给每个商户的唯一标识符,商户号获取方式参考 https://pay.weixin.qq.com/doc/v3/merchant/4013070756
|
||||
"1DDE55AD98Exxxxxxxxxx", // 商户API证书序列号,如何获取请参考 https://pay.weixin.qq.com/doc/v3/merchant/4013053053
|
||||
"/path/to/apiclient_key.pem", // 商户API证书私钥文件路径,本地文件路径
|
||||
"PUB_KEY_ID_xxxxxxxxxxxxx", // 微信支付公钥ID,如何获取请参考 https://pay.weixin.qq.com/doc/v3/merchant/4013038816
|
||||
"/path/to/wxp_pub.pem" // 微信支付公钥文件路径,本地文件路径
|
||||
);
|
||||
|
||||
QueryOrderRequest request = new QueryOrderRequest();
|
||||
request.outOrderNo = "P20150806125346";
|
||||
request.transactionId = "4208450740201411110007820472";
|
||||
try {
|
||||
OrdersEntity response = client.run(request);
|
||||
// TODO: 请求成功,继续业务逻辑
|
||||
System.out.println(response);
|
||||
} catch (WXPayUtility.ApiException e) {
|
||||
// TODO: 请求失败,根据状态码执行不同的逻辑
|
||||
e.printStackTrace();
|
||||
}
|
||||
}
|
||||
|
||||
public OrdersEntity run(QueryOrderRequest request) {
|
||||
String uri = PATH;
|
||||
uri = uri.replace("{out_order_no}", WXPayUtility.urlEncode(request.outOrderNo));
|
||||
Map<String, Object> args = new HashMap<>();
|
||||
args.put("transaction_id", request.transactionId);
|
||||
String queryString = WXPayUtility.urlEncode(args);
|
||||
if (!queryString.isEmpty()) {
|
||||
uri = uri + "?" + queryString;
|
||||
}
|
||||
|
||||
Request.Builder reqBuilder = new Request.Builder().url(HOST + uri);
|
||||
reqBuilder.addHeader("Accept", "application/json");
|
||||
reqBuilder.addHeader("Wechatpay-Serial", wechatPayPublicKeyId);
|
||||
reqBuilder.addHeader("Authorization", WXPayUtility.buildAuthorization(mchid, certificateSerialNo, privateKey, METHOD, uri, null));
|
||||
reqBuilder.method(METHOD, null);
|
||||
Request httpRequest = reqBuilder.build();
|
||||
|
||||
// 发送HTTP请求
|
||||
OkHttpClient client = new OkHttpClient.Builder().build();
|
||||
try (Response httpResponse = client.newCall(httpRequest).execute()) {
|
||||
String respBody = WXPayUtility.extractBody(httpResponse);
|
||||
if (httpResponse.code() >= 200 && httpResponse.code() < 300) {
|
||||
// 2XX 成功,验证应答签名
|
||||
WXPayUtility.validateResponse(this.wechatPayPublicKeyId, this.wechatPayPublicKey,
|
||||
httpResponse.headers(), respBody);
|
||||
|
||||
// 从HTTP应答报文构建返回数据
|
||||
return WXPayUtility.fromJson(respBody, OrdersEntity.class);
|
||||
} else {
|
||||
throw new WXPayUtility.ApiException(httpResponse.code(), respBody, httpResponse.headers());
|
||||
}
|
||||
} catch (IOException e) {
|
||||
throw new UncheckedIOException("Sending request to " + uri + " failed.", e);
|
||||
}
|
||||
}
|
||||
|
||||
private final String mchid;
|
||||
private final String certificateSerialNo;
|
||||
private final PrivateKey privateKey;
|
||||
private final String wechatPayPublicKeyId;
|
||||
private final PublicKey wechatPayPublicKey;
|
||||
|
||||
public QueryOrder(String mchid, String certificateSerialNo, String privateKeyFilePath, String wechatPayPublicKeyId, String wechatPayPublicKeyFilePath) {
|
||||
this.mchid = mchid;
|
||||
this.certificateSerialNo = certificateSerialNo;
|
||||
this.privateKey = WXPayUtility.loadPrivateKeyFromPath(privateKeyFilePath);
|
||||
this.wechatPayPublicKeyId = wechatPayPublicKeyId;
|
||||
this.wechatPayPublicKey = WXPayUtility.loadPublicKeyFromPath(wechatPayPublicKeyFilePath);
|
||||
}
|
||||
|
||||
public static class QueryOrderRequest {
|
||||
@SerializedName("transaction_id")
|
||||
@Expose(serialize = false)
|
||||
public String transactionId;
|
||||
|
||||
@SerializedName("out_order_no")
|
||||
@Expose(serialize = false)
|
||||
public String outOrderNo;
|
||||
}
|
||||
|
||||
public static class OrdersEntity {
|
||||
@SerializedName("transaction_id")
|
||||
public String transactionId;
|
||||
|
||||
@SerializedName("out_order_no")
|
||||
public String outOrderNo;
|
||||
|
||||
@SerializedName("order_id")
|
||||
public String orderId;
|
||||
|
||||
@SerializedName("state")
|
||||
public OrderStatus state;
|
||||
|
||||
@SerializedName("receivers")
|
||||
public List<OrderReceiverDetail> receivers = new ArrayList<OrderReceiverDetail>();
|
||||
}
|
||||
|
||||
public enum OrderStatus {
|
||||
@SerializedName("PROCESSING")
|
||||
PROCESSING,
|
||||
@SerializedName("FINISHED")
|
||||
FINISHED
|
||||
}
|
||||
|
||||
public static class OrderReceiverDetail {
|
||||
@SerializedName("amount")
|
||||
public Long amount;
|
||||
|
||||
@SerializedName("description")
|
||||
public String description;
|
||||
|
||||
@SerializedName("type")
|
||||
public ReceiverType type;
|
||||
|
||||
@SerializedName("account")
|
||||
public String account;
|
||||
|
||||
@SerializedName("result")
|
||||
public DetailStatus result;
|
||||
|
||||
@SerializedName("fail_reason")
|
||||
public DetailFailReason failReason;
|
||||
|
||||
@SerializedName("create_time")
|
||||
public String createTime;
|
||||
|
||||
@SerializedName("finish_time")
|
||||
public String finishTime;
|
||||
|
||||
@SerializedName("detail_id")
|
||||
public String detailId;
|
||||
}
|
||||
|
||||
public enum ReceiverType {
|
||||
@SerializedName("MERCHANT_ID")
|
||||
MERCHANT_ID,
|
||||
@SerializedName("PERSONAL_OPENID")
|
||||
PERSONAL_OPENID
|
||||
}
|
||||
|
||||
public enum DetailStatus {
|
||||
@SerializedName("PENDING")
|
||||
PENDING,
|
||||
@SerializedName("SUCCESS")
|
||||
SUCCESS,
|
||||
@SerializedName("CLOSED")
|
||||
CLOSED
|
||||
}
|
||||
|
||||
public enum DetailFailReason {
|
||||
@SerializedName("ACCOUNT_ABNORMAL")
|
||||
ACCOUNT_ABNORMAL,
|
||||
@SerializedName("NO_RELATION")
|
||||
NO_RELATION,
|
||||
@SerializedName("RECEIVER_HIGH_RISK")
|
||||
RECEIVER_HIGH_RISK,
|
||||
@SerializedName("RECEIVER_REAL_NAME_NOT_VERIFIED")
|
||||
RECEIVER_REAL_NAME_NOT_VERIFIED,
|
||||
@SerializedName("NO_AUTH")
|
||||
NO_AUTH,
|
||||
@SerializedName("RECEIVER_RECEIPT_LIMIT")
|
||||
RECEIVER_RECEIPT_LIMIT,
|
||||
@SerializedName("PAYER_ACCOUNT_ABNORMAL")
|
||||
PAYER_ACCOUNT_ABNORMAL,
|
||||
@SerializedName("INVALID_REQUEST")
|
||||
INVALID_REQUEST
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
package com.java.demo;
|
||||
|
||||
import com.java.utils.WXPayUtility; // 引用微信支付工具库,参考:https://pay.weixin.qq.com/doc/v3/merchant/4014931831
|
||||
|
||||
import com.google.gson.annotations.SerializedName;
|
||||
import com.google.gson.annotations.Expose;
|
||||
import okhttp3.MediaType;
|
||||
import okhttp3.OkHttpClient;
|
||||
import okhttp3.Request;
|
||||
import okhttp3.RequestBody;
|
||||
import okhttp3.Response;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.io.UncheckedIOException;
|
||||
import java.security.PrivateKey;
|
||||
import java.security.PublicKey;
|
||||
import java.util.ArrayList;
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* 查询剩余待分金额
|
||||
*/
|
||||
public class QueryOrderAmount {
|
||||
private static String HOST = "https://api.mch.weixin.qq.com";
|
||||
private static String METHOD = "GET";
|
||||
private static String PATH = "/v3/profitsharing/transactions/{transaction_id}/amounts";
|
||||
|
||||
public static void main(String[] args) {
|
||||
// TODO: 请准备商户开发必要参数,参考:https://pay.weixin.qq.com/doc/v3/merchant/4013070756
|
||||
QueryOrderAmount client = new QueryOrderAmount(
|
||||
"19xxxxxxxx", // 商户号,是由微信支付系统生成并分配给每个商户的唯一标识符,商户号获取方式参考 https://pay.weixin.qq.com/doc/v3/merchant/4013070756
|
||||
"1DDE55AD98Exxxxxxxxxx", // 商户API证书序列号,如何获取请参考 https://pay.weixin.qq.com/doc/v3/merchant/4013053053
|
||||
"/path/to/apiclient_key.pem", // 商户API证书私钥文件路径,本地文件路径
|
||||
"PUB_KEY_ID_xxxxxxxxxxxxx", // 微信支付公钥ID,如何获取请参考 https://pay.weixin.qq.com/doc/v3/merchant/4013038816
|
||||
"/path/to/wxp_pub.pem" // 微信支付公钥文件路径,本地文件路径
|
||||
);
|
||||
|
||||
QueryOrderAmountRequest request = new QueryOrderAmountRequest();
|
||||
request.transactionId = "4208450740201411110007820472";
|
||||
try {
|
||||
QueryOrderAmountResponse response = client.run(request);
|
||||
// TODO: 请求成功,继续业务逻辑
|
||||
System.out.println(response);
|
||||
} catch (WXPayUtility.ApiException e) {
|
||||
// TODO: 请求失败,根据状态码执行不同的逻辑
|
||||
e.printStackTrace();
|
||||
}
|
||||
}
|
||||
|
||||
public QueryOrderAmountResponse run(QueryOrderAmountRequest request) {
|
||||
String uri = PATH;
|
||||
uri = uri.replace("{transaction_id}", WXPayUtility.urlEncode(request.transactionId));
|
||||
|
||||
Request.Builder reqBuilder = new Request.Builder().url(HOST + uri);
|
||||
reqBuilder.addHeader("Accept", "application/json");
|
||||
reqBuilder.addHeader("Wechatpay-Serial", wechatPayPublicKeyId);
|
||||
reqBuilder.addHeader("Authorization", WXPayUtility.buildAuthorization(mchid, certificateSerialNo, privateKey, METHOD, uri, null));
|
||||
reqBuilder.method(METHOD, null);
|
||||
Request httpRequest = reqBuilder.build();
|
||||
|
||||
// 发送HTTP请求
|
||||
OkHttpClient client = new OkHttpClient.Builder().build();
|
||||
try (Response httpResponse = client.newCall(httpRequest).execute()) {
|
||||
String respBody = WXPayUtility.extractBody(httpResponse);
|
||||
if (httpResponse.code() >= 200 && httpResponse.code() < 300) {
|
||||
// 2XX 成功,验证应答签名
|
||||
WXPayUtility.validateResponse(this.wechatPayPublicKeyId, this.wechatPayPublicKey,
|
||||
httpResponse.headers(), respBody);
|
||||
|
||||
// 从HTTP应答报文构建返回数据
|
||||
return WXPayUtility.fromJson(respBody, QueryOrderAmountResponse.class);
|
||||
} else {
|
||||
throw new WXPayUtility.ApiException(httpResponse.code(), respBody, httpResponse.headers());
|
||||
}
|
||||
} catch (IOException e) {
|
||||
throw new UncheckedIOException("Sending request to " + uri + " failed.", e);
|
||||
}
|
||||
}
|
||||
|
||||
private final String mchid;
|
||||
private final String certificateSerialNo;
|
||||
private final PrivateKey privateKey;
|
||||
private final String wechatPayPublicKeyId;
|
||||
private final PublicKey wechatPayPublicKey;
|
||||
|
||||
public QueryOrderAmount(String mchid, String certificateSerialNo, String privateKeyFilePath, String wechatPayPublicKeyId, String wechatPayPublicKeyFilePath) {
|
||||
this.mchid = mchid;
|
||||
this.certificateSerialNo = certificateSerialNo;
|
||||
this.privateKey = WXPayUtility.loadPrivateKeyFromPath(privateKeyFilePath);
|
||||
this.wechatPayPublicKeyId = wechatPayPublicKeyId;
|
||||
this.wechatPayPublicKey = WXPayUtility.loadPublicKeyFromPath(wechatPayPublicKeyFilePath);
|
||||
}
|
||||
|
||||
public static class QueryOrderAmountRequest {
|
||||
@SerializedName("transaction_id")
|
||||
@Expose(serialize = false)
|
||||
public String transactionId;
|
||||
}
|
||||
|
||||
public static class QueryOrderAmountResponse {
|
||||
@SerializedName("transaction_id")
|
||||
public String transactionId;
|
||||
|
||||
@SerializedName("unsplit_amount")
|
||||
public Long unsplitAmount;
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,170 @@
|
||||
package com.java.demo;
|
||||
|
||||
import com.java.utils.WXPayUtility; // 引用微信支付工具库,参考:https://pay.weixin.qq.com/doc/v3/merchant/4014931831
|
||||
|
||||
import com.google.gson.annotations.SerializedName;
|
||||
import com.google.gson.annotations.Expose;
|
||||
import okhttp3.MediaType;
|
||||
import okhttp3.OkHttpClient;
|
||||
import okhttp3.Request;
|
||||
import okhttp3.RequestBody;
|
||||
import okhttp3.Response;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.io.UncheckedIOException;
|
||||
import java.security.PrivateKey;
|
||||
import java.security.PublicKey;
|
||||
import java.util.ArrayList;
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* 查询分账回退结果
|
||||
*/
|
||||
public class QueryReturnOrder {
|
||||
private static String HOST = "https://api.mch.weixin.qq.com";
|
||||
private static String METHOD = "GET";
|
||||
private static String PATH = "/v3/profitsharing/return-orders/{out_return_no}";
|
||||
|
||||
public static void main(String[] args) {
|
||||
// TODO: 请准备商户开发必要参数,参考:https://pay.weixin.qq.com/doc/v3/merchant/4013070756
|
||||
QueryReturnOrder client = new QueryReturnOrder(
|
||||
"19xxxxxxxx", // 商户号,是由微信支付系统生成并分配给每个商户的唯一标识符,商户号获取方式参考 https://pay.weixin.qq.com/doc/v3/merchant/4013070756
|
||||
"1DDE55AD98Exxxxxxxxxx", // 商户API证书序列号,如何获取请参考 https://pay.weixin.qq.com/doc/v3/merchant/4013053053
|
||||
"/path/to/apiclient_key.pem", // 商户API证书私钥文件路径,本地文件路径
|
||||
"PUB_KEY_ID_xxxxxxxxxxxxx", // 微信支付公钥ID,如何获取请参考 https://pay.weixin.qq.com/doc/v3/merchant/4013038816
|
||||
"/path/to/wxp_pub.pem" // 微信支付公钥文件路径,本地文件路径
|
||||
);
|
||||
|
||||
QueryReturnOrderRequest request = new QueryReturnOrderRequest();
|
||||
request.outReturnNo = "R20190516001";
|
||||
request.outOrderNo = "P20190806125346";
|
||||
try {
|
||||
ReturnOrdersEntity response = client.run(request);
|
||||
// TODO: 请求成功,继续业务逻辑
|
||||
System.out.println(response);
|
||||
} catch (WXPayUtility.ApiException e) {
|
||||
// TODO: 请求失败,根据状态码执行不同的逻辑
|
||||
e.printStackTrace();
|
||||
}
|
||||
}
|
||||
|
||||
public ReturnOrdersEntity run(QueryReturnOrderRequest request) {
|
||||
String uri = PATH;
|
||||
uri = uri.replace("{out_return_no}", WXPayUtility.urlEncode(request.outReturnNo));
|
||||
Map<String, Object> args = new HashMap<>();
|
||||
args.put("out_order_no", request.outOrderNo);
|
||||
String queryString = WXPayUtility.urlEncode(args);
|
||||
if (!queryString.isEmpty()) {
|
||||
uri = uri + "?" + queryString;
|
||||
}
|
||||
|
||||
Request.Builder reqBuilder = new Request.Builder().url(HOST + uri);
|
||||
reqBuilder.addHeader("Accept", "application/json");
|
||||
reqBuilder.addHeader("Wechatpay-Serial", wechatPayPublicKeyId);
|
||||
reqBuilder.addHeader("Authorization", WXPayUtility.buildAuthorization(mchid, certificateSerialNo, privateKey, METHOD, uri, null));
|
||||
reqBuilder.method(METHOD, null);
|
||||
Request httpRequest = reqBuilder.build();
|
||||
|
||||
// 发送HTTP请求
|
||||
OkHttpClient client = new OkHttpClient.Builder().build();
|
||||
try (Response httpResponse = client.newCall(httpRequest).execute()) {
|
||||
String respBody = WXPayUtility.extractBody(httpResponse);
|
||||
if (httpResponse.code() >= 200 && httpResponse.code() < 300) {
|
||||
// 2XX 成功,验证应答签名
|
||||
WXPayUtility.validateResponse(this.wechatPayPublicKeyId, this.wechatPayPublicKey,
|
||||
httpResponse.headers(), respBody);
|
||||
|
||||
// 从HTTP应答报文构建返回数据
|
||||
return WXPayUtility.fromJson(respBody, ReturnOrdersEntity.class);
|
||||
} else {
|
||||
throw new WXPayUtility.ApiException(httpResponse.code(), respBody, httpResponse.headers());
|
||||
}
|
||||
} catch (IOException e) {
|
||||
throw new UncheckedIOException("Sending request to " + uri + " failed.", e);
|
||||
}
|
||||
}
|
||||
|
||||
private final String mchid;
|
||||
private final String certificateSerialNo;
|
||||
private final PrivateKey privateKey;
|
||||
private final String wechatPayPublicKeyId;
|
||||
private final PublicKey wechatPayPublicKey;
|
||||
|
||||
public QueryReturnOrder(String mchid, String certificateSerialNo, String privateKeyFilePath, String wechatPayPublicKeyId, String wechatPayPublicKeyFilePath) {
|
||||
this.mchid = mchid;
|
||||
this.certificateSerialNo = certificateSerialNo;
|
||||
this.privateKey = WXPayUtility.loadPrivateKeyFromPath(privateKeyFilePath);
|
||||
this.wechatPayPublicKeyId = wechatPayPublicKeyId;
|
||||
this.wechatPayPublicKey = WXPayUtility.loadPublicKeyFromPath(wechatPayPublicKeyFilePath);
|
||||
}
|
||||
|
||||
public static class QueryReturnOrderRequest {
|
||||
@SerializedName("out_return_no")
|
||||
@Expose(serialize = false)
|
||||
public String outReturnNo;
|
||||
|
||||
@SerializedName("out_order_no")
|
||||
@Expose(serialize = false)
|
||||
public String outOrderNo;
|
||||
}
|
||||
|
||||
public static class ReturnOrdersEntity {
|
||||
@SerializedName("order_id")
|
||||
public String orderId;
|
||||
|
||||
@SerializedName("out_order_no")
|
||||
public String outOrderNo;
|
||||
|
||||
@SerializedName("out_return_no")
|
||||
public String outReturnNo;
|
||||
|
||||
@SerializedName("return_id")
|
||||
public String returnId;
|
||||
|
||||
@SerializedName("return_mchid")
|
||||
public String returnMchid;
|
||||
|
||||
@SerializedName("amount")
|
||||
public Long amount;
|
||||
|
||||
@SerializedName("description")
|
||||
public String description;
|
||||
|
||||
@SerializedName("result")
|
||||
public ReturnOrderStatus result;
|
||||
|
||||
@SerializedName("fail_reason")
|
||||
public ReturnOrderFailReason failReason;
|
||||
|
||||
@SerializedName("create_time")
|
||||
public String createTime;
|
||||
|
||||
@SerializedName("finish_time")
|
||||
public String finishTime;
|
||||
}
|
||||
|
||||
public enum ReturnOrderStatus {
|
||||
@SerializedName("PROCESSING")
|
||||
PROCESSING,
|
||||
@SerializedName("SUCCESS")
|
||||
SUCCESS,
|
||||
@SerializedName("FAILED")
|
||||
FAILED
|
||||
}
|
||||
|
||||
public enum ReturnOrderFailReason {
|
||||
@SerializedName("ACCOUNT_ABNORMAL")
|
||||
ACCOUNT_ABNORMAL,
|
||||
@SerializedName("BALANCE_NOT_ENOUGH")
|
||||
BALANCE_NOT_ENOUGH,
|
||||
@SerializedName("TIME_OUT_CLOSED")
|
||||
TIME_OUT_CLOSED,
|
||||
@SerializedName("PAYER_ACCOUNT_ABNORMAL")
|
||||
PAYER_ACCOUNT_ABNORMAL,
|
||||
@SerializedName("INVALID_REQUEST")
|
||||
INVALID_REQUEST
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,134 @@
|
||||
package com.java.demo;
|
||||
|
||||
import com.java.utils.WXPayUtility; // 引用微信支付工具库,参考:https://pay.weixin.qq.com/doc/v3/merchant/4014931831
|
||||
|
||||
import com.google.gson.annotations.SerializedName;
|
||||
import com.google.gson.annotations.Expose;
|
||||
import okhttp3.MediaType;
|
||||
import okhttp3.OkHttpClient;
|
||||
import okhttp3.Request;
|
||||
import okhttp3.RequestBody;
|
||||
import okhttp3.Response;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.io.UncheckedIOException;
|
||||
import java.security.PrivateKey;
|
||||
import java.security.PublicKey;
|
||||
import java.util.ArrayList;
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* 申请分账账单
|
||||
*/
|
||||
public class SplitBill {
|
||||
private static String HOST = "https://api.mch.weixin.qq.com";
|
||||
private static String METHOD = "GET";
|
||||
private static String PATH = "/v3/profitsharing/bills";
|
||||
|
||||
public static void main(String[] args) {
|
||||
// TODO: 请准备商户开发必要参数,参考:https://pay.weixin.qq.com/doc/v3/merchant/4013070756
|
||||
SplitBill client = new SplitBill(
|
||||
"19xxxxxxxx", // 商户号,是由微信支付系统生成并分配给每个商户的唯一标识符,商户号获取方式参考 https://pay.weixin.qq.com/doc/v3/merchant/4013070756
|
||||
"1DDE55AD98Exxxxxxxxxx", // 商户API证书序列号,如何获取请参考 https://pay.weixin.qq.com/doc/v3/merchant/4013053053
|
||||
"/path/to/apiclient_key.pem", // 商户API证书私钥文件路径,本地文件路径
|
||||
"PUB_KEY_ID_xxxxxxxxxxxxx", // 微信支付公钥ID,如何获取请参考 https://pay.weixin.qq.com/doc/v3/merchant/4013038816
|
||||
"/path/to/wxp_pub.pem" // 微信支付公钥文件路径,本地文件路径
|
||||
);
|
||||
|
||||
SplitBillRequest request = new SplitBillRequest();
|
||||
request.billDate = "2019-06-11";
|
||||
request.tarType = SplitBillTarType.GZIP;
|
||||
try {
|
||||
SplitBillResponse response = client.run(request);
|
||||
// TODO: 请求成功,继续业务逻辑
|
||||
System.out.println(response);
|
||||
} catch (WXPayUtility.ApiException e) {
|
||||
// TODO: 请求失败,根据状态码执行不同的逻辑
|
||||
e.printStackTrace();
|
||||
}
|
||||
}
|
||||
|
||||
public SplitBillResponse run(SplitBillRequest request) {
|
||||
String uri = PATH;
|
||||
Map<String, Object> args = new HashMap<>();
|
||||
args.put("bill_date", request.billDate);
|
||||
args.put("tar_type", request.tarType);
|
||||
String queryString = WXPayUtility.urlEncode(args);
|
||||
if (!queryString.isEmpty()) {
|
||||
uri = uri + "?" + queryString;
|
||||
}
|
||||
|
||||
Request.Builder reqBuilder = new Request.Builder().url(HOST + uri);
|
||||
reqBuilder.addHeader("Accept", "application/json");
|
||||
reqBuilder.addHeader("Wechatpay-Serial", wechatPayPublicKeyId);
|
||||
reqBuilder.addHeader("Authorization", WXPayUtility.buildAuthorization(mchid, certificateSerialNo, privateKey, METHOD, uri, null));
|
||||
reqBuilder.method(METHOD, null);
|
||||
Request httpRequest = reqBuilder.build();
|
||||
|
||||
// 发送HTTP请求
|
||||
OkHttpClient client = new OkHttpClient.Builder().build();
|
||||
try (Response httpResponse = client.newCall(httpRequest).execute()) {
|
||||
String respBody = WXPayUtility.extractBody(httpResponse);
|
||||
if (httpResponse.code() >= 200 && httpResponse.code() < 300) {
|
||||
// 2XX 成功,验证应答签名
|
||||
WXPayUtility.validateResponse(this.wechatPayPublicKeyId, this.wechatPayPublicKey,
|
||||
httpResponse.headers(), respBody);
|
||||
|
||||
// 从HTTP应答报文构建返回数据
|
||||
return WXPayUtility.fromJson(respBody, SplitBillResponse.class);
|
||||
} else {
|
||||
throw new WXPayUtility.ApiException(httpResponse.code(), respBody, httpResponse.headers());
|
||||
}
|
||||
} catch (IOException e) {
|
||||
throw new UncheckedIOException("Sending request to " + uri + " failed.", e);
|
||||
}
|
||||
}
|
||||
|
||||
private final String mchid;
|
||||
private final String certificateSerialNo;
|
||||
private final PrivateKey privateKey;
|
||||
private final String wechatPayPublicKeyId;
|
||||
private final PublicKey wechatPayPublicKey;
|
||||
|
||||
public SplitBill(String mchid, String certificateSerialNo, String privateKeyFilePath, String wechatPayPublicKeyId, String wechatPayPublicKeyFilePath) {
|
||||
this.mchid = mchid;
|
||||
this.certificateSerialNo = certificateSerialNo;
|
||||
this.privateKey = WXPayUtility.loadPrivateKeyFromPath(privateKeyFilePath);
|
||||
this.wechatPayPublicKeyId = wechatPayPublicKeyId;
|
||||
this.wechatPayPublicKey = WXPayUtility.loadPublicKeyFromPath(wechatPayPublicKeyFilePath);
|
||||
}
|
||||
|
||||
public static class SplitBillRequest {
|
||||
@SerializedName("bill_date")
|
||||
@Expose(serialize = false)
|
||||
public String billDate;
|
||||
|
||||
@SerializedName("tar_type")
|
||||
@Expose(serialize = false)
|
||||
public SplitBillTarType tarType;
|
||||
}
|
||||
|
||||
public static class SplitBillResponse {
|
||||
@SerializedName("hash_type")
|
||||
public SplitBillHashType hashType;
|
||||
|
||||
@SerializedName("hash_value")
|
||||
public String hashValue;
|
||||
|
||||
@SerializedName("download_url")
|
||||
public String downloadUrl;
|
||||
}
|
||||
|
||||
public enum SplitBillTarType {
|
||||
@SerializedName("GZIP")
|
||||
GZIP
|
||||
}
|
||||
|
||||
public enum SplitBillHashType {
|
||||
@SerializedName("SHA1")
|
||||
SHA1
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,199 @@
|
||||
package com.java.demo;
|
||||
|
||||
import com.java.utils.WXPayUtility; // 引用微信支付工具库,参考:https://pay.weixin.qq.com/doc/v3/merchant/4014931831
|
||||
|
||||
import com.google.gson.annotations.SerializedName;
|
||||
import com.google.gson.annotations.Expose;
|
||||
import okhttp3.MediaType;
|
||||
import okhttp3.OkHttpClient;
|
||||
import okhttp3.Request;
|
||||
import okhttp3.RequestBody;
|
||||
import okhttp3.Response;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.io.UncheckedIOException;
|
||||
import java.security.PrivateKey;
|
||||
import java.security.PublicKey;
|
||||
import java.util.ArrayList;
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* 解冻剩余资金
|
||||
*/
|
||||
public class UnfreezeOrder {
|
||||
private static String HOST = "https://api.mch.weixin.qq.com";
|
||||
private static String METHOD = "POST";
|
||||
private static String PATH = "/v3/profitsharing/orders/unfreeze";
|
||||
|
||||
public static void main(String[] args) {
|
||||
// TODO: 请准备商户开发必要参数,参考:https://pay.weixin.qq.com/doc/v3/merchant/4013070756
|
||||
UnfreezeOrder client = new UnfreezeOrder(
|
||||
"19xxxxxxxx", // 商户号,是由微信支付系统生成并分配给每个商户的唯一标识符,商户号获取方式参考 https://pay.weixin.qq.com/doc/v3/merchant/4013070756
|
||||
"1DDE55AD98Exxxxxxxxxx", // 商户API证书序列号,如何获取请参考 https://pay.weixin.qq.com/doc/v3/merchant/4013053053
|
||||
"/path/to/apiclient_key.pem", // 商户API证书私钥文件路径,本地文件路径
|
||||
"PUB_KEY_ID_xxxxxxxxxxxxx", // 微信支付公钥ID,如何获取请参考 https://pay.weixin.qq.com/doc/v3/merchant/4013038816
|
||||
"/path/to/wxp_pub.pem" // 微信支付公钥文件路径,本地文件路径
|
||||
);
|
||||
|
||||
UnfreezeOrderRequest request = new UnfreezeOrderRequest();
|
||||
request.transactionId = "4208450740201411110007820472";
|
||||
request.outOrderNo = "P20150806125346";
|
||||
request.description = "解冻全部剩余资金";
|
||||
try {
|
||||
OrdersEntity response = client.run(request);
|
||||
// TODO: 请求成功,继续业务逻辑
|
||||
System.out.println(response);
|
||||
} catch (WXPayUtility.ApiException e) {
|
||||
// TODO: 请求失败,根据状态码执行不同的逻辑
|
||||
e.printStackTrace();
|
||||
}
|
||||
}
|
||||
|
||||
public OrdersEntity run(UnfreezeOrderRequest request) {
|
||||
String uri = PATH;
|
||||
String reqBody = WXPayUtility.toJson(request);
|
||||
|
||||
Request.Builder reqBuilder = new Request.Builder().url(HOST + uri);
|
||||
reqBuilder.addHeader("Accept", "application/json");
|
||||
reqBuilder.addHeader("Wechatpay-Serial", wechatPayPublicKeyId);
|
||||
reqBuilder.addHeader("Authorization", WXPayUtility.buildAuthorization(mchid, certificateSerialNo,privateKey, METHOD, uri, reqBody));
|
||||
reqBuilder.addHeader("Content-Type", "application/json");
|
||||
RequestBody requestBody = RequestBody.create(MediaType.parse("application/json; charset=utf-8"), reqBody);
|
||||
reqBuilder.method(METHOD, requestBody);
|
||||
Request httpRequest = reqBuilder.build();
|
||||
|
||||
// 发送HTTP请求
|
||||
OkHttpClient client = new OkHttpClient.Builder().build();
|
||||
try (Response httpResponse = client.newCall(httpRequest).execute()) {
|
||||
String respBody = WXPayUtility.extractBody(httpResponse);
|
||||
if (httpResponse.code() >= 200 && httpResponse.code() < 300) {
|
||||
// 2XX 成功,验证应答签名
|
||||
WXPayUtility.validateResponse(this.wechatPayPublicKeyId, this.wechatPayPublicKey,
|
||||
httpResponse.headers(), respBody);
|
||||
|
||||
// 从HTTP应答报文构建返回数据
|
||||
return WXPayUtility.fromJson(respBody, OrdersEntity.class);
|
||||
} else {
|
||||
throw new WXPayUtility.ApiException(httpResponse.code(), respBody, httpResponse.headers());
|
||||
}
|
||||
} catch (IOException e) {
|
||||
throw new UncheckedIOException("Sending request to " + uri + " failed.", e);
|
||||
}
|
||||
}
|
||||
|
||||
private final String mchid;
|
||||
private final String certificateSerialNo;
|
||||
private final PrivateKey privateKey;
|
||||
private final String wechatPayPublicKeyId;
|
||||
private final PublicKey wechatPayPublicKey;
|
||||
|
||||
public UnfreezeOrder(String mchid, String certificateSerialNo, String privateKeyFilePath, String wechatPayPublicKeyId, String wechatPayPublicKeyFilePath) {
|
||||
this.mchid = mchid;
|
||||
this.certificateSerialNo = certificateSerialNo;
|
||||
this.privateKey = WXPayUtility.loadPrivateKeyFromPath(privateKeyFilePath);
|
||||
this.wechatPayPublicKeyId = wechatPayPublicKeyId;
|
||||
this.wechatPayPublicKey = WXPayUtility.loadPublicKeyFromPath(wechatPayPublicKeyFilePath);
|
||||
}
|
||||
|
||||
public static class UnfreezeOrderRequest {
|
||||
@SerializedName("transaction_id")
|
||||
public String transactionId;
|
||||
|
||||
@SerializedName("out_order_no")
|
||||
public String outOrderNo;
|
||||
|
||||
@SerializedName("description")
|
||||
public String description;
|
||||
}
|
||||
|
||||
public static class OrdersEntity {
|
||||
@SerializedName("transaction_id")
|
||||
public String transactionId;
|
||||
|
||||
@SerializedName("out_order_no")
|
||||
public String outOrderNo;
|
||||
|
||||
@SerializedName("order_id")
|
||||
public String orderId;
|
||||
|
||||
@SerializedName("state")
|
||||
public OrderStatus state;
|
||||
|
||||
@SerializedName("receivers")
|
||||
public List<OrderReceiverDetail> receivers = new ArrayList<OrderReceiverDetail>();
|
||||
}
|
||||
|
||||
public enum OrderStatus {
|
||||
@SerializedName("PROCESSING")
|
||||
PROCESSING,
|
||||
@SerializedName("FINISHED")
|
||||
FINISHED
|
||||
}
|
||||
|
||||
public static class OrderReceiverDetail {
|
||||
@SerializedName("amount")
|
||||
public Long amount;
|
||||
|
||||
@SerializedName("description")
|
||||
public String description;
|
||||
|
||||
@SerializedName("type")
|
||||
public ReceiverType type;
|
||||
|
||||
@SerializedName("account")
|
||||
public String account;
|
||||
|
||||
@SerializedName("result")
|
||||
public DetailStatus result;
|
||||
|
||||
@SerializedName("fail_reason")
|
||||
public DetailFailReason failReason;
|
||||
|
||||
@SerializedName("create_time")
|
||||
public String createTime;
|
||||
|
||||
@SerializedName("finish_time")
|
||||
public String finishTime;
|
||||
|
||||
@SerializedName("detail_id")
|
||||
public String detailId;
|
||||
}
|
||||
|
||||
public enum ReceiverType {
|
||||
@SerializedName("MERCHANT_ID")
|
||||
MERCHANT_ID,
|
||||
@SerializedName("PERSONAL_OPENID")
|
||||
PERSONAL_OPENID
|
||||
}
|
||||
|
||||
public enum DetailStatus {
|
||||
@SerializedName("PENDING")
|
||||
PENDING,
|
||||
@SerializedName("SUCCESS")
|
||||
SUCCESS,
|
||||
@SerializedName("CLOSED")
|
||||
CLOSED
|
||||
}
|
||||
|
||||
public enum DetailFailReason {
|
||||
@SerializedName("ACCOUNT_ABNORMAL")
|
||||
ACCOUNT_ABNORMAL,
|
||||
@SerializedName("NO_RELATION")
|
||||
NO_RELATION,
|
||||
@SerializedName("RECEIVER_HIGH_RISK")
|
||||
RECEIVER_HIGH_RISK,
|
||||
@SerializedName("RECEIVER_REAL_NAME_NOT_VERIFIED")
|
||||
RECEIVER_REAL_NAME_NOT_VERIFIED,
|
||||
@SerializedName("NO_AUTH")
|
||||
NO_AUTH,
|
||||
@SerializedName("RECEIVER_RECEIPT_LIMIT")
|
||||
RECEIVER_RECEIPT_LIMIT,
|
||||
@SerializedName("PAYER_ACCOUNT_ABNORMAL")
|
||||
PAYER_ACCOUNT_ABNORMAL,
|
||||
@SerializedName("INVALID_REQUEST")
|
||||
INVALID_REQUEST
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
# APP调起支付
|
||||
|
||||
> 参考官方文档:https://pay.weixin.qq.com/doc/v3/merchant/4013070351
|
||||
|
||||
## 说明
|
||||
|
||||
商户通过APP下单接口获取到发起支付的必要参数 `prepay_id` 后,商户APP再通过 OpenSDK 的 `sendReq` 方法拉起微信支付。
|
||||
|
||||
**注意**:需严格遵循 OpenSDK 接入指引([Android](https://developers.weixin.qq.com/doc/oplatform/Mobile_App/Access_Guide/Android.html) / [iOS](https://developers.weixin.qq.com/doc/oplatform/Mobile_App/Access_Guide/iOS.html) / [鸿蒙](https://developers.weixin.qq.com/doc/oplatform/Mobile_App/Access_Guide/ohos.html))接入SDK以及配置开发环境。
|
||||
|
||||
## 请求参数
|
||||
|
||||
| 参数 | 必填 | 说明 |
|
||||
|------|------|------|
|
||||
| `appId` | 是 | 下单时传入的应用ID |
|
||||
| `partnerId` | 是 | 下单时传入的商户号 |
|
||||
| `prepayId` | 是 | APP下单接口返回的 prepay_id,有效期2小时 |
|
||||
| `packageValue` | 是 | 固定值 `Sign=WXPay`(iOS 中字段名为 `package`) |
|
||||
| `nonceStr` | 是 | 随机字符串,不长于32位 |
|
||||
| `timeStamp` | 是 | Unix 秒级时间戳 |
|
||||
| `sign` | 是 | 使用 appId、timeStamp、nonceStr、prepayId + 商户API证书私钥生成的 RSA 签名 |
|
||||
|
||||
## iOS 示例代码
|
||||
|
||||
```objectivec
|
||||
PayReq *request = [[[PayReq alloc] init] autorelease];
|
||||
request.appId = "wxd930ea5d5a258f4f";
|
||||
request.partnerId = "1900000109";
|
||||
request.prepayId= "1101000000140415649af9fc314aa427",;
|
||||
request.package = "Sign=WXPay";
|
||||
request.nonceStr= "1101000000140429eb40476f8896f4c9";
|
||||
request.timeStamp= "1398746574";
|
||||
request.sign= "oR9d8PuhnIc+YZ8cBHFCwfgpaK9gd7vaRvkYD7rthRAZ\/X+QBhcCYL21N7cHCTUxbQ+EAt6Uy+lwSN22f5YZvI45MLko8Pfso0jm46v5hqcVwrk6uddkGuT+Cdvu4WBqDzaDjnNa5UK3GfE1Wfl2gHxIIY5lLdUgWFts17D4WuolLLkiFZV+JSHMvH7eaLdT9N5GBovBwu5yYKUR7skR8Fu+LozcSqQixnlEZUfyE55feLOQTUYzLmR9pNtPbPsu6WVhbNHMS3Ss2+AehHvz+n64GDmXxbX++IOBvm2olHu3PsOUGRwhudhVf7UcGcunXt8cqNjKNqZLhLw4jq\/xDg==";
|
||||
[WXApi sendReq:request];
|
||||
```
|
||||
|
||||
## Android 示例代码
|
||||
|
||||
```java
|
||||
IWXAPI api;
|
||||
PayReq request = new PayReq();
|
||||
request.appId = "wxd930ea5d5a258f4f";
|
||||
request.partnerId = "1900000109";
|
||||
request.prepayId= "1101000000140415649af9fc314aa427",;
|
||||
request.packageValue = "Sign=WXPay";
|
||||
request.nonceStr= "1101000000140429eb40476f8896f4c9";
|
||||
request.timeStamp= "1398746574";
|
||||
request.sign= "oR9d8PuhnIc+YZ8cBHFCwfgpaK9gd7vaRvkYD7rthRAZ\/X+QBhcCYL21N7cHCTUxbQ+EAt6Uy+lwSN22f5YZvI45MLko8Pfso0jm46v5hqcVwrk6uddkGuT+Cdvu4WBqDzaDjnNa5UK3GfE1Wfl2gHxIIY5lLdUgWFts17D4WuolLLkiFZV+JSHMvH7eaLdT9N5GBovBwu5yYKUR7skR8Fu+LozcSqQixnlEZUfyE55feLOQTUYzLmR9pNtPbPsu6WVhbNHMS3Ss2+AehHvz+n64GDmXxbX++IOBvm2olHu3PsOUGRwhudhVf7UcGcunXt8cqNjKNqZLhLw4jq\/xDg==";
|
||||
api.sendReq(request);
|
||||
```
|
||||
|
||||
## 鸿蒙示例代码
|
||||
|
||||
```typescript
|
||||
IWXAPI api;
|
||||
let req = new wxopensdk.PayReq
|
||||
req.appId = 'wxd930ea5d5a258f4f'
|
||||
req.partnerId = '1900000109'
|
||||
req.prepayId = '1101000000140415649af9fc314aa427'
|
||||
req.packageValue = 'Sign=WXPay'
|
||||
req.nonceStr = '1101000000140429eb40476f8896f4c9'
|
||||
req.timeStamp = '1398746574'
|
||||
req.sign = 'oR9d8PuhnIc+YZ8cBHFCwfgpaK9gd7vaRvkYD7rthRAZ\/X+QBhcCYL21N7cHCTUxbQ+EAt6Uy+lwSN22f5YZvI45MLko8Pfso0jm46v5hqcVwrk6uddkGuT+Cdvu4WBqDzaDjnNa5UK3GfE1Wfl2gHxIIY5lLdUgWFts17D4WuolLLkiFZV+JSHMvH7eaLdT9N5GBovBwu5yYKUR7skR8Fu+LozcSqQixnlEZUfyE55feLOQTUYzLmR9pNtPbPsu6WVhbNHMS3Ss2+AehHvz+n64GDmXxbX++IOBvm2olHu3PsOUGRwhudhVf7UcGcunXt8cqNjKNqZLhLw4jq\/xDg=='
|
||||
api.sendReq(context: common.UIAbilityContext, req)
|
||||
```
|
||||
|
||||
## 回调 errCode 说明
|
||||
|
||||
| errCode | 描述 | 商户APP处理方案 |
|
||||
|---------|------|---------------|
|
||||
| 0 | 成功 | 调用后端查单接口,订单已支付则展示支付成功页面 |
|
||||
| -1 | 错误 | 可能原因:签名错误、未注册AppID、AppID不匹配等 |
|
||||
| -2 | 取消支付 | 用户取消支付返回App,商户自行处理展示 |
|
||||
|
||||
> **重要**:前端回调不保证绝对可靠,不可只依赖前端回调判断订单支付状态,订单状态需以后端查询订单和支付成功回调通知为准。
|
||||
@@ -0,0 +1,244 @@
|
||||
package com.java.demo;
|
||||
|
||||
import com.java.utils.WXPayUtility; // 引用微信支付工具库,参考:https://pay.weixin.qq.com/doc/v3/merchant/4014931831
|
||||
|
||||
import com.google.gson.annotations.SerializedName;
|
||||
import com.google.gson.annotations.Expose;
|
||||
import okhttp3.MediaType;
|
||||
import okhttp3.OkHttpClient;
|
||||
import okhttp3.Request;
|
||||
import okhttp3.RequestBody;
|
||||
import okhttp3.Response;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.io.UncheckedIOException;
|
||||
import java.security.PrivateKey;
|
||||
import java.security.PublicKey;
|
||||
import java.util.ArrayList;
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* App下单
|
||||
*/
|
||||
public class AppPrepay {
|
||||
private static String HOST = "https://api.mch.weixin.qq.com";
|
||||
private static String METHOD = "POST";
|
||||
private static String PATH = "/v3/pay/transactions/app";
|
||||
|
||||
public static void main(String[] args) {
|
||||
// TODO: 请准备商户开发必要参数,参考:https://pay.weixin.qq.com/doc/v3/merchant/4013070756
|
||||
AppPrepay client = new AppPrepay(
|
||||
"19xxxxxxxx", // 商户号,是由微信支付系统生成并分配给每个商户的唯一标识符,商户号获取方式参考 https://pay.weixin.qq.com/doc/v3/merchant/4013070756
|
||||
"1DDE55AD98Exxxxxxxxxx", // 商户API证书序列号,如何获取请参考 https://pay.weixin.qq.com/doc/v3/merchant/4013053053
|
||||
"/path/to/apiclient_key.pem", // 商户API证书私钥文件路径,本地文件路径
|
||||
"PUB_KEY_ID_xxxxxxxxxxxxx", // 微信支付公钥ID,如何获取请参考 https://pay.weixin.qq.com/doc/v3/merchant/4013038816
|
||||
"/path/to/wxp_pub.pem" // 微信支付公钥文件路径,本地文件路径
|
||||
);
|
||||
|
||||
CommonPrepayRequest request = new CommonPrepayRequest();
|
||||
request.appid = "wxd678efh567hg6787";
|
||||
request.mchid = "1230000109";
|
||||
request.description = "Image形象店-深圳腾大-QQ公仔";
|
||||
request.outTradeNo = "1217752501201407033233368018";
|
||||
request.timeExpire = "2018-06-08T10:34:56+08:00";
|
||||
request.attach = "自定义数据说明";
|
||||
request.notifyUrl = " https://www.weixin.qq.com/wxpay/pay.php";
|
||||
request.goodsTag = "WXG";
|
||||
request.supportFapiao = false;
|
||||
request.amount = new CommonAmountInfo();
|
||||
request.amount.total = 100L;
|
||||
request.amount.currency = "CNY";
|
||||
request.detail = new CouponInfo();
|
||||
request.detail.costPrice = 608800L;
|
||||
request.detail.invoiceId = "微信123";
|
||||
request.detail.goodsDetail = new ArrayList<>();
|
||||
{
|
||||
GoodsDetail goodsDetailItem = new GoodsDetail();
|
||||
goodsDetailItem.merchantGoodsId = "1246464644";
|
||||
goodsDetailItem.wechatpayGoodsId = "1001";
|
||||
goodsDetailItem.goodsName = "iPhoneX 256G";
|
||||
goodsDetailItem.quantity = 1L;
|
||||
goodsDetailItem.unitPrice = 528800L;
|
||||
request.detail.goodsDetail.add(goodsDetailItem);
|
||||
};
|
||||
request.sceneInfo = new CommonSceneInfo();
|
||||
request.sceneInfo.payerClientIp = "14.23.150.211";
|
||||
request.sceneInfo.deviceId = "013467007045764";
|
||||
request.sceneInfo.storeInfo = new StoreInfo();
|
||||
request.sceneInfo.storeInfo.id = "0001";
|
||||
request.sceneInfo.storeInfo.name = "腾讯大厦分店";
|
||||
request.sceneInfo.storeInfo.areaCode = "440305";
|
||||
request.sceneInfo.storeInfo.address = "广东省深圳市南山区科技中一道10000号";
|
||||
request.settleInfo = new SettleInfo();
|
||||
request.settleInfo.profitSharing = false;
|
||||
try {
|
||||
DirectAPIv3AppPrepayResponse response = client.run(request);
|
||||
// TODO: 请求成功,继续业务逻辑
|
||||
System.out.println(response);
|
||||
} catch (WXPayUtility.ApiException e) {
|
||||
// TODO: 请求失败,根据状态码执行不同的逻辑
|
||||
e.printStackTrace();
|
||||
}
|
||||
}
|
||||
|
||||
public DirectAPIv3AppPrepayResponse run(CommonPrepayRequest request) {
|
||||
String uri = PATH;
|
||||
String reqBody = WXPayUtility.toJson(request);
|
||||
|
||||
Request.Builder reqBuilder = new Request.Builder().url(HOST + uri);
|
||||
reqBuilder.addHeader("Accept", "application/json");
|
||||
reqBuilder.addHeader("Wechatpay-Serial", wechatPayPublicKeyId);
|
||||
reqBuilder.addHeader("Authorization", WXPayUtility.buildAuthorization(mchid, certificateSerialNo,privateKey, METHOD, uri, reqBody));
|
||||
reqBuilder.addHeader("Content-Type", "application/json");
|
||||
RequestBody requestBody = RequestBody.create(MediaType.parse("application/json; charset=utf-8"), reqBody);
|
||||
reqBuilder.method(METHOD, requestBody);
|
||||
Request httpRequest = reqBuilder.build();
|
||||
|
||||
// 发送HTTP请求
|
||||
OkHttpClient client = new OkHttpClient.Builder().build();
|
||||
try (Response httpResponse = client.newCall(httpRequest).execute()) {
|
||||
String respBody = WXPayUtility.extractBody(httpResponse);
|
||||
if (httpResponse.code() >= 200 && httpResponse.code() < 300) {
|
||||
// 2XX 成功,验证应答签名
|
||||
WXPayUtility.validateResponse(this.wechatPayPublicKeyId, this.wechatPayPublicKey,
|
||||
httpResponse.headers(), respBody);
|
||||
|
||||
// 从HTTP应答报文构建返回数据
|
||||
return WXPayUtility.fromJson(respBody, DirectAPIv3AppPrepayResponse.class);
|
||||
} else {
|
||||
throw new WXPayUtility.ApiException(httpResponse.code(), respBody, httpResponse.headers());
|
||||
}
|
||||
} catch (IOException e) {
|
||||
throw new UncheckedIOException("Sending request to " + uri + " failed.", e);
|
||||
}
|
||||
}
|
||||
|
||||
private final String mchid;
|
||||
private final String certificateSerialNo;
|
||||
private final PrivateKey privateKey;
|
||||
private final String wechatPayPublicKeyId;
|
||||
private final PublicKey wechatPayPublicKey;
|
||||
|
||||
public AppPrepay(String mchid, String certificateSerialNo, String privateKeyFilePath, String wechatPayPublicKeyId, String wechatPayPublicKeyFilePath) {
|
||||
this.mchid = mchid;
|
||||
this.certificateSerialNo = certificateSerialNo;
|
||||
this.privateKey = WXPayUtility.loadPrivateKeyFromPath(privateKeyFilePath);
|
||||
this.wechatPayPublicKeyId = wechatPayPublicKeyId;
|
||||
this.wechatPayPublicKey = WXPayUtility.loadPublicKeyFromPath(wechatPayPublicKeyFilePath);
|
||||
}
|
||||
|
||||
public static class CommonPrepayRequest {
|
||||
@SerializedName("appid")
|
||||
public String appid;
|
||||
|
||||
@SerializedName("mchid")
|
||||
public String mchid;
|
||||
|
||||
@SerializedName("description")
|
||||
public String description;
|
||||
|
||||
@SerializedName("out_trade_no")
|
||||
public String outTradeNo;
|
||||
|
||||
@SerializedName("time_expire")
|
||||
public String timeExpire;
|
||||
|
||||
@SerializedName("attach")
|
||||
public String attach;
|
||||
|
||||
@SerializedName("notify_url")
|
||||
public String notifyUrl;
|
||||
|
||||
@SerializedName("goods_tag")
|
||||
public String goodsTag;
|
||||
|
||||
@SerializedName("support_fapiao")
|
||||
public Boolean supportFapiao;
|
||||
|
||||
@SerializedName("amount")
|
||||
public CommonAmountInfo amount;
|
||||
|
||||
@SerializedName("detail")
|
||||
public CouponInfo detail;
|
||||
|
||||
@SerializedName("scene_info")
|
||||
public CommonSceneInfo sceneInfo;
|
||||
|
||||
@SerializedName("settle_info")
|
||||
public SettleInfo settleInfo;
|
||||
}
|
||||
|
||||
public static class DirectAPIv3AppPrepayResponse {
|
||||
@SerializedName("prepay_id")
|
||||
public String prepayId;
|
||||
}
|
||||
|
||||
public static class CommonAmountInfo {
|
||||
@SerializedName("total")
|
||||
public Long total;
|
||||
|
||||
@SerializedName("currency")
|
||||
public String currency;
|
||||
}
|
||||
|
||||
public static class CouponInfo {
|
||||
@SerializedName("cost_price")
|
||||
public Long costPrice;
|
||||
|
||||
@SerializedName("invoice_id")
|
||||
public String invoiceId;
|
||||
|
||||
@SerializedName("goods_detail")
|
||||
public List<GoodsDetail> goodsDetail;
|
||||
}
|
||||
|
||||
public static class CommonSceneInfo {
|
||||
@SerializedName("payer_client_ip")
|
||||
public String payerClientIp;
|
||||
|
||||
@SerializedName("device_id")
|
||||
public String deviceId;
|
||||
|
||||
@SerializedName("store_info")
|
||||
public StoreInfo storeInfo;
|
||||
}
|
||||
|
||||
public static class SettleInfo {
|
||||
@SerializedName("profit_sharing")
|
||||
public Boolean profitSharing;
|
||||
}
|
||||
|
||||
public static class GoodsDetail {
|
||||
@SerializedName("merchant_goods_id")
|
||||
public String merchantGoodsId;
|
||||
|
||||
@SerializedName("wechatpay_goods_id")
|
||||
public String wechatpayGoodsId;
|
||||
|
||||
@SerializedName("goods_name")
|
||||
public String goodsName;
|
||||
|
||||
@SerializedName("quantity")
|
||||
public Long quantity;
|
||||
|
||||
@SerializedName("unit_price")
|
||||
public Long unitPrice;
|
||||
}
|
||||
|
||||
public static class StoreInfo {
|
||||
@SerializedName("id")
|
||||
public String id;
|
||||
|
||||
@SerializedName("name")
|
||||
public String name;
|
||||
|
||||
@SerializedName("area_code")
|
||||
public String areaCode;
|
||||
|
||||
@SerializedName("address")
|
||||
public String address;
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
# H5调起支付
|
||||
|
||||
> 参考官方文档:https://pay.weixin.qq.com/doc/v3/merchant/4012791835
|
||||
|
||||
## 调起支付步骤
|
||||
|
||||
1. 商户通过H5下单接口获取到发起支付的必要参数 `h5_url`
|
||||
2. 商户在配置了H5支付域名的网页中跳转 `h5_url`,调起微信支付收银台中间页
|
||||
3. 微信支付收银台中间页会进行H5权限的校验和安全性检查,校验通过后用户可正常支付
|
||||
|
||||
## 支付后返回指定页面
|
||||
|
||||
用户支付完成后默认返回发起支付的页面。如需返回指定页面,可在 `h5_url` 后拼接 `redirect_url` 参数:
|
||||
|
||||
```
|
||||
h5_url=https://wx.tenpay.com/cgi-bin/mmpayweb-bin/checkmweb?prepay_id=wx20161110163838f231619da20804912345&package=1037687096&redirect_url=https%3A%2F%2Fwww.wechatpay.com.cn
|
||||
```
|
||||
|
||||
**注意**:
|
||||
- `redirect_url` 的域名必须为商户配置的H5支付域名
|
||||
- 需对 `redirect_url` 进行 urlencode 处理
|
||||
|
||||
## 返回商户页面后查单
|
||||
|
||||
用户点击"取消支付"或支付成功后点击"完成"时,会返回商户支付页面(或指定的 redirect_url 页面)。建议:
|
||||
|
||||
在回跳页面中增设"确认支付情况"按钮,用户点击后触发查单操作,确保用户能及时了解订单状态。
|
||||
|
||||
> **重要**:H5支付不像JSAPI/APP有前端回调,商户必须通过后端查单接口或支付成功回调通知来确认订单状态。
|
||||
@@ -0,0 +1,270 @@
|
||||
package com.java.demo;
|
||||
|
||||
import com.java.utils.WXPayUtility; // 引用微信支付工具库,参考:https://pay.weixin.qq.com/doc/v3/merchant/4014931831
|
||||
|
||||
import com.google.gson.annotations.SerializedName;
|
||||
import com.google.gson.annotations.Expose;
|
||||
import okhttp3.MediaType;
|
||||
import okhttp3.OkHttpClient;
|
||||
import okhttp3.Request;
|
||||
import okhttp3.RequestBody;
|
||||
import okhttp3.Response;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.io.UncheckedIOException;
|
||||
import java.security.PrivateKey;
|
||||
import java.security.PublicKey;
|
||||
import java.util.ArrayList;
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* H5下单
|
||||
*/
|
||||
public class H5Prepay {
|
||||
private static String HOST = "https://api.mch.weixin.qq.com";
|
||||
private static String METHOD = "POST";
|
||||
private static String PATH = "/v3/pay/transactions/h5";
|
||||
|
||||
public static void main(String[] args) {
|
||||
// TODO: 请准备商户开发必要参数,参考:https://pay.weixin.qq.com/doc/v3/merchant/4013070756
|
||||
H5Prepay client = new H5Prepay(
|
||||
"19xxxxxxxx", // 商户号,是由微信支付系统生成并分配给每个商户的唯一标识符,商户号获取方式参考 https://pay.weixin.qq.com/doc/v3/merchant/4013070756
|
||||
"1DDE55AD98Exxxxxxxxxx", // 商户API证书序列号,如何获取请参考 https://pay.weixin.qq.com/doc/v3/merchant/4013053053
|
||||
"/path/to/apiclient_key.pem", // 商户API证书私钥文件路径,本地文件路径
|
||||
"PUB_KEY_ID_xxxxxxxxxxxxx", // 微信支付公钥ID,如何获取请参考 https://pay.weixin.qq.com/doc/v3/merchant/4013038816
|
||||
"/path/to/wxp_pub.pem" // 微信支付公钥文件路径,本地文件路径
|
||||
);
|
||||
|
||||
DirectAPIv3H5PrepayRequest request = new DirectAPIv3H5PrepayRequest();
|
||||
request.appid = "wxd678efh567hg6787";
|
||||
request.mchid = "1230000109";
|
||||
request.description = "Image形象店-深圳腾大-QQ公仔";
|
||||
request.outTradeNo = "1217752501201407033233368018";
|
||||
request.timeExpire = "2018-06-08T10:34:56+08:00";
|
||||
request.attach = "自定义数据说明";
|
||||
request.notifyUrl = " https://www.weixin.qq.com/wxpay/pay.php";
|
||||
request.goodsTag = "WXG";
|
||||
request.supportFapiao = false;
|
||||
request.amount = new CommonAmountInfo();
|
||||
request.amount.total = 100L;
|
||||
request.amount.currency = "CNY";
|
||||
request.detail = new CouponInfo();
|
||||
request.detail.costPrice = 608800L;
|
||||
request.detail.invoiceId = "微信123";
|
||||
request.detail.goodsDetail = new ArrayList<>();
|
||||
{
|
||||
GoodsDetail goodsDetailItem = new GoodsDetail();
|
||||
goodsDetailItem.merchantGoodsId = "1246464644";
|
||||
goodsDetailItem.wechatpayGoodsId = "1001";
|
||||
goodsDetailItem.goodsName = "iPhoneX 256G";
|
||||
goodsDetailItem.quantity = 1L;
|
||||
goodsDetailItem.unitPrice = 528800L;
|
||||
request.detail.goodsDetail.add(goodsDetailItem);
|
||||
};
|
||||
request.sceneInfo = new H5ReqSceneInfo();
|
||||
request.sceneInfo.payerClientIp = "14.23.150.211";
|
||||
request.sceneInfo.deviceId = "013467007045764";
|
||||
request.sceneInfo.storeInfo = new StoreInfo();
|
||||
request.sceneInfo.storeInfo.id = "0001";
|
||||
request.sceneInfo.storeInfo.name = "腾讯大厦分店";
|
||||
request.sceneInfo.storeInfo.areaCode = "440305";
|
||||
request.sceneInfo.storeInfo.address = "广东省深圳市南山区科技中一道10000号";
|
||||
request.sceneInfo.h5Info = new H5Info();
|
||||
request.sceneInfo.h5Info.type = "iOS";
|
||||
request.sceneInfo.h5Info.appName = "王者荣耀";
|
||||
request.sceneInfo.h5Info.appUrl = "https://pay.qq.com";
|
||||
request.sceneInfo.h5Info.bundleId = "com.tencent.wzryiOS";
|
||||
request.sceneInfo.h5Info.packageName = "com.tencent.tmgp.sgame";
|
||||
request.settleInfo = new SettleInfo();
|
||||
request.settleInfo.profitSharing = false;
|
||||
try {
|
||||
DirectAPIv3H5PrepayResponse response = client.run(request);
|
||||
// TODO: 请求成功,继续业务逻辑
|
||||
System.out.println(response);
|
||||
} catch (WXPayUtility.ApiException e) {
|
||||
// TODO: 请求失败,根据状态码执行不同的逻辑
|
||||
e.printStackTrace();
|
||||
}
|
||||
}
|
||||
|
||||
public DirectAPIv3H5PrepayResponse run(DirectAPIv3H5PrepayRequest request) {
|
||||
String uri = PATH;
|
||||
String reqBody = WXPayUtility.toJson(request);
|
||||
|
||||
Request.Builder reqBuilder = new Request.Builder().url(HOST + uri);
|
||||
reqBuilder.addHeader("Accept", "application/json");
|
||||
reqBuilder.addHeader("Wechatpay-Serial", wechatPayPublicKeyId);
|
||||
reqBuilder.addHeader("Authorization", WXPayUtility.buildAuthorization(mchid, certificateSerialNo,privateKey, METHOD, uri, reqBody));
|
||||
reqBuilder.addHeader("Content-Type", "application/json");
|
||||
RequestBody requestBody = RequestBody.create(MediaType.parse("application/json; charset=utf-8"), reqBody);
|
||||
reqBuilder.method(METHOD, requestBody);
|
||||
Request httpRequest = reqBuilder.build();
|
||||
|
||||
// 发送HTTP请求
|
||||
OkHttpClient client = new OkHttpClient.Builder().build();
|
||||
try (Response httpResponse = client.newCall(httpRequest).execute()) {
|
||||
String respBody = WXPayUtility.extractBody(httpResponse);
|
||||
if (httpResponse.code() >= 200 && httpResponse.code() < 300) {
|
||||
// 2XX 成功,验证应答签名
|
||||
WXPayUtility.validateResponse(this.wechatPayPublicKeyId, this.wechatPayPublicKey,
|
||||
httpResponse.headers(), respBody);
|
||||
|
||||
// 从HTTP应答报文构建返回数据
|
||||
return WXPayUtility.fromJson(respBody, DirectAPIv3H5PrepayResponse.class);
|
||||
} else {
|
||||
throw new WXPayUtility.ApiException(httpResponse.code(), respBody, httpResponse.headers());
|
||||
}
|
||||
} catch (IOException e) {
|
||||
throw new UncheckedIOException("Sending request to " + uri + " failed.", e);
|
||||
}
|
||||
}
|
||||
|
||||
private final String mchid;
|
||||
private final String certificateSerialNo;
|
||||
private final PrivateKey privateKey;
|
||||
private final String wechatPayPublicKeyId;
|
||||
private final PublicKey wechatPayPublicKey;
|
||||
|
||||
public H5Prepay(String mchid, String certificateSerialNo, String privateKeyFilePath, String wechatPayPublicKeyId, String wechatPayPublicKeyFilePath) {
|
||||
this.mchid = mchid;
|
||||
this.certificateSerialNo = certificateSerialNo;
|
||||
this.privateKey = WXPayUtility.loadPrivateKeyFromPath(privateKeyFilePath);
|
||||
this.wechatPayPublicKeyId = wechatPayPublicKeyId;
|
||||
this.wechatPayPublicKey = WXPayUtility.loadPublicKeyFromPath(wechatPayPublicKeyFilePath);
|
||||
}
|
||||
|
||||
public static class DirectAPIv3H5PrepayRequest {
|
||||
@SerializedName("appid")
|
||||
public String appid;
|
||||
|
||||
@SerializedName("mchid")
|
||||
public String mchid;
|
||||
|
||||
@SerializedName("description")
|
||||
public String description;
|
||||
|
||||
@SerializedName("out_trade_no")
|
||||
public String outTradeNo;
|
||||
|
||||
@SerializedName("time_expire")
|
||||
public String timeExpire;
|
||||
|
||||
@SerializedName("attach")
|
||||
public String attach;
|
||||
|
||||
@SerializedName("notify_url")
|
||||
public String notifyUrl;
|
||||
|
||||
@SerializedName("goods_tag")
|
||||
public String goodsTag;
|
||||
|
||||
@SerializedName("support_fapiao")
|
||||
public Boolean supportFapiao;
|
||||
|
||||
@SerializedName("amount")
|
||||
public CommonAmountInfo amount;
|
||||
|
||||
@SerializedName("detail")
|
||||
public CouponInfo detail;
|
||||
|
||||
@SerializedName("scene_info")
|
||||
public H5ReqSceneInfo sceneInfo;
|
||||
|
||||
@SerializedName("settle_info")
|
||||
public SettleInfo settleInfo;
|
||||
}
|
||||
|
||||
public static class DirectAPIv3H5PrepayResponse {
|
||||
@SerializedName("h5_url")
|
||||
public String h5Url;
|
||||
}
|
||||
|
||||
public static class CommonAmountInfo {
|
||||
@SerializedName("total")
|
||||
public Long total;
|
||||
|
||||
@SerializedName("currency")
|
||||
public String currency;
|
||||
}
|
||||
|
||||
public static class CouponInfo {
|
||||
@SerializedName("cost_price")
|
||||
public Long costPrice;
|
||||
|
||||
@SerializedName("invoice_id")
|
||||
public String invoiceId;
|
||||
|
||||
@SerializedName("goods_detail")
|
||||
public List<GoodsDetail> goodsDetail;
|
||||
}
|
||||
|
||||
public static class H5ReqSceneInfo {
|
||||
@SerializedName("payer_client_ip")
|
||||
public String payerClientIp;
|
||||
|
||||
@SerializedName("device_id")
|
||||
public String deviceId;
|
||||
|
||||
@SerializedName("store_info")
|
||||
public StoreInfo storeInfo;
|
||||
|
||||
@SerializedName("h5_info")
|
||||
public H5Info h5Info;
|
||||
}
|
||||
|
||||
public static class SettleInfo {
|
||||
@SerializedName("profit_sharing")
|
||||
public Boolean profitSharing;
|
||||
}
|
||||
|
||||
public static class GoodsDetail {
|
||||
@SerializedName("merchant_goods_id")
|
||||
public String merchantGoodsId;
|
||||
|
||||
@SerializedName("wechatpay_goods_id")
|
||||
public String wechatpayGoodsId;
|
||||
|
||||
@SerializedName("goods_name")
|
||||
public String goodsName;
|
||||
|
||||
@SerializedName("quantity")
|
||||
public Long quantity;
|
||||
|
||||
@SerializedName("unit_price")
|
||||
public Long unitPrice;
|
||||
}
|
||||
|
||||
public static class StoreInfo {
|
||||
@SerializedName("id")
|
||||
public String id;
|
||||
|
||||
@SerializedName("name")
|
||||
public String name;
|
||||
|
||||
@SerializedName("area_code")
|
||||
public String areaCode;
|
||||
|
||||
@SerializedName("address")
|
||||
public String address;
|
||||
}
|
||||
|
||||
public static class H5Info {
|
||||
@SerializedName("type")
|
||||
public String type;
|
||||
|
||||
@SerializedName("app_name")
|
||||
public String appName;
|
||||
|
||||
@SerializedName("app_url")
|
||||
public String appUrl;
|
||||
|
||||
@SerializedName("bundle_id")
|
||||
public String bundleId;
|
||||
|
||||
@SerializedName("package_name")
|
||||
public String packageName;
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
# Native调起支付
|
||||
|
||||
> 参考官方文档:https://pay.weixin.qq.com/doc/v3/merchant/4012791878
|
||||
|
||||
## 调起支付步骤
|
||||
|
||||
1. 通过 Native下单接口获取到发起支付的必要参数 `code_url`
|
||||
2. 将 `code_url` 链接转换为二维码图片后,展示给用户
|
||||
3. 用户打开微信"扫一扫"功能,扫描二维码,进行 Native 支付
|
||||
|
||||
## 示例
|
||||
|
||||
将 `weixin://pay.weixin.qq.com/bizpayurl/up?pr=NwY5Mz9&groupid=00` 生成二维码,展示给用户扫码即可。
|
||||
|
||||
## 注意事项
|
||||
|
||||
- Native 支付的 `code_url` 有效期为2小时,超时需重新调用下单接口获取新的 `code_url`
|
||||
- 二维码仅支持微信"扫一扫"功能扫描,不支持长按识别或相册识别
|
||||
- 商户需通过后端查单接口或支付成功回调通知来确认订单状态,不能仅依赖用户告知
|
||||
@@ -0,0 +1,244 @@
|
||||
package com.java.demo;
|
||||
|
||||
import com.java.utils.WXPayUtility; // 引用微信支付工具库,参考:https://pay.weixin.qq.com/doc/v3/merchant/4014931831
|
||||
|
||||
import com.google.gson.annotations.SerializedName;
|
||||
import com.google.gson.annotations.Expose;
|
||||
import okhttp3.MediaType;
|
||||
import okhttp3.OkHttpClient;
|
||||
import okhttp3.Request;
|
||||
import okhttp3.RequestBody;
|
||||
import okhttp3.Response;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.io.UncheckedIOException;
|
||||
import java.security.PrivateKey;
|
||||
import java.security.PublicKey;
|
||||
import java.util.ArrayList;
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* Native下单
|
||||
*/
|
||||
public class NativePrepay {
|
||||
private static String HOST = "https://api.mch.weixin.qq.com";
|
||||
private static String METHOD = "POST";
|
||||
private static String PATH = "/v3/pay/transactions/native";
|
||||
|
||||
public static void main(String[] args) {
|
||||
// TODO: 请准备商户开发必要参数,参考:https://pay.weixin.qq.com/doc/v3/merchant/4013070756
|
||||
NativePrepay client = new NativePrepay(
|
||||
"19xxxxxxxx", // 商户号,是由微信支付系统生成并分配给每个商户的唯一标识符,商户号获取方式参考 https://pay.weixin.qq.com/doc/v3/merchant/4013070756
|
||||
"1DDE55AD98Exxxxxxxxxx", // 商户API证书序列号,如何获取请参考 https://pay.weixin.qq.com/doc/v3/merchant/4013053053
|
||||
"/path/to/apiclient_key.pem", // 商户API证书私钥文件路径,本地文件路径
|
||||
"PUB_KEY_ID_xxxxxxxxxxxxx", // 微信支付公钥ID,如何获取请参考 https://pay.weixin.qq.com/doc/v3/merchant/4013038816
|
||||
"/path/to/wxp_pub.pem" // 微信支付公钥文件路径,本地文件路径
|
||||
);
|
||||
|
||||
CommonPrepayRequest request = new CommonPrepayRequest();
|
||||
request.appid = "wxd678efh567hg6787";
|
||||
request.mchid = "1230000109";
|
||||
request.description = "Image形象店-深圳腾大-QQ公仔";
|
||||
request.outTradeNo = "1217752501201407033233368018";
|
||||
request.timeExpire = "2018-06-08T10:34:56+08:00";
|
||||
request.attach = "自定义数据说明";
|
||||
request.notifyUrl = " https://www.weixin.qq.com/wxpay/pay.php";
|
||||
request.goodsTag = "WXG";
|
||||
request.supportFapiao = false;
|
||||
request.amount = new CommonAmountInfo();
|
||||
request.amount.total = 100L;
|
||||
request.amount.currency = "CNY";
|
||||
request.detail = new CouponInfo();
|
||||
request.detail.costPrice = 608800L;
|
||||
request.detail.invoiceId = "微信123";
|
||||
request.detail.goodsDetail = new ArrayList<>();
|
||||
{
|
||||
GoodsDetail goodsDetailItem = new GoodsDetail();
|
||||
goodsDetailItem.merchantGoodsId = "1246464644";
|
||||
goodsDetailItem.wechatpayGoodsId = "1001";
|
||||
goodsDetailItem.goodsName = "iPhoneX 256G";
|
||||
goodsDetailItem.quantity = 1L;
|
||||
goodsDetailItem.unitPrice = 528800L;
|
||||
request.detail.goodsDetail.add(goodsDetailItem);
|
||||
};
|
||||
request.sceneInfo = new CommonSceneInfo();
|
||||
request.sceneInfo.payerClientIp = "14.23.150.211";
|
||||
request.sceneInfo.deviceId = "013467007045764";
|
||||
request.sceneInfo.storeInfo = new StoreInfo();
|
||||
request.sceneInfo.storeInfo.id = "0001";
|
||||
request.sceneInfo.storeInfo.name = "腾讯大厦分店";
|
||||
request.sceneInfo.storeInfo.areaCode = "440305";
|
||||
request.sceneInfo.storeInfo.address = "广东省深圳市南山区科技中一道10000号";
|
||||
request.settleInfo = new SettleInfo();
|
||||
request.settleInfo.profitSharing = false;
|
||||
try {
|
||||
DirectAPIv3DirectNativePrepayResponse response = client.run(request);
|
||||
// TODO: 请求成功,继续业务逻辑
|
||||
System.out.println(response);
|
||||
} catch (WXPayUtility.ApiException e) {
|
||||
// TODO: 请求失败,根据状态码执行不同的逻辑
|
||||
e.printStackTrace();
|
||||
}
|
||||
}
|
||||
|
||||
public DirectAPIv3DirectNativePrepayResponse run(CommonPrepayRequest request) {
|
||||
String uri = PATH;
|
||||
String reqBody = WXPayUtility.toJson(request);
|
||||
|
||||
Request.Builder reqBuilder = new Request.Builder().url(HOST + uri);
|
||||
reqBuilder.addHeader("Accept", "application/json");
|
||||
reqBuilder.addHeader("Wechatpay-Serial", wechatPayPublicKeyId);
|
||||
reqBuilder.addHeader("Authorization", WXPayUtility.buildAuthorization(mchid, certificateSerialNo,privateKey, METHOD, uri, reqBody));
|
||||
reqBuilder.addHeader("Content-Type", "application/json");
|
||||
RequestBody requestBody = RequestBody.create(MediaType.parse("application/json; charset=utf-8"), reqBody);
|
||||
reqBuilder.method(METHOD, requestBody);
|
||||
Request httpRequest = reqBuilder.build();
|
||||
|
||||
// 发送HTTP请求
|
||||
OkHttpClient client = new OkHttpClient.Builder().build();
|
||||
try (Response httpResponse = client.newCall(httpRequest).execute()) {
|
||||
String respBody = WXPayUtility.extractBody(httpResponse);
|
||||
if (httpResponse.code() >= 200 && httpResponse.code() < 300) {
|
||||
// 2XX 成功,验证应答签名
|
||||
WXPayUtility.validateResponse(this.wechatPayPublicKeyId, this.wechatPayPublicKey,
|
||||
httpResponse.headers(), respBody);
|
||||
|
||||
// 从HTTP应答报文构建返回数据
|
||||
return WXPayUtility.fromJson(respBody, DirectAPIv3DirectNativePrepayResponse.class);
|
||||
} else {
|
||||
throw new WXPayUtility.ApiException(httpResponse.code(), respBody, httpResponse.headers());
|
||||
}
|
||||
} catch (IOException e) {
|
||||
throw new UncheckedIOException("Sending request to " + uri + " failed.", e);
|
||||
}
|
||||
}
|
||||
|
||||
private final String mchid;
|
||||
private final String certificateSerialNo;
|
||||
private final PrivateKey privateKey;
|
||||
private final String wechatPayPublicKeyId;
|
||||
private final PublicKey wechatPayPublicKey;
|
||||
|
||||
public NativePrepay(String mchid, String certificateSerialNo, String privateKeyFilePath, String wechatPayPublicKeyId, String wechatPayPublicKeyFilePath) {
|
||||
this.mchid = mchid;
|
||||
this.certificateSerialNo = certificateSerialNo;
|
||||
this.privateKey = WXPayUtility.loadPrivateKeyFromPath(privateKeyFilePath);
|
||||
this.wechatPayPublicKeyId = wechatPayPublicKeyId;
|
||||
this.wechatPayPublicKey = WXPayUtility.loadPublicKeyFromPath(wechatPayPublicKeyFilePath);
|
||||
}
|
||||
|
||||
public static class CommonPrepayRequest {
|
||||
@SerializedName("appid")
|
||||
public String appid;
|
||||
|
||||
@SerializedName("mchid")
|
||||
public String mchid;
|
||||
|
||||
@SerializedName("description")
|
||||
public String description;
|
||||
|
||||
@SerializedName("out_trade_no")
|
||||
public String outTradeNo;
|
||||
|
||||
@SerializedName("time_expire")
|
||||
public String timeExpire;
|
||||
|
||||
@SerializedName("attach")
|
||||
public String attach;
|
||||
|
||||
@SerializedName("notify_url")
|
||||
public String notifyUrl;
|
||||
|
||||
@SerializedName("goods_tag")
|
||||
public String goodsTag;
|
||||
|
||||
@SerializedName("support_fapiao")
|
||||
public Boolean supportFapiao;
|
||||
|
||||
@SerializedName("amount")
|
||||
public CommonAmountInfo amount;
|
||||
|
||||
@SerializedName("detail")
|
||||
public CouponInfo detail;
|
||||
|
||||
@SerializedName("scene_info")
|
||||
public CommonSceneInfo sceneInfo;
|
||||
|
||||
@SerializedName("settle_info")
|
||||
public SettleInfo settleInfo;
|
||||
}
|
||||
|
||||
public static class DirectAPIv3DirectNativePrepayResponse {
|
||||
@SerializedName("code_url")
|
||||
public String codeUrl;
|
||||
}
|
||||
|
||||
public static class CommonAmountInfo {
|
||||
@SerializedName("total")
|
||||
public Long total;
|
||||
|
||||
@SerializedName("currency")
|
||||
public String currency;
|
||||
}
|
||||
|
||||
public static class CouponInfo {
|
||||
@SerializedName("cost_price")
|
||||
public Long costPrice;
|
||||
|
||||
@SerializedName("invoice_id")
|
||||
public String invoiceId;
|
||||
|
||||
@SerializedName("goods_detail")
|
||||
public List<GoodsDetail> goodsDetail;
|
||||
}
|
||||
|
||||
public static class CommonSceneInfo {
|
||||
@SerializedName("payer_client_ip")
|
||||
public String payerClientIp;
|
||||
|
||||
@SerializedName("device_id")
|
||||
public String deviceId;
|
||||
|
||||
@SerializedName("store_info")
|
||||
public StoreInfo storeInfo;
|
||||
}
|
||||
|
||||
public static class SettleInfo {
|
||||
@SerializedName("profit_sharing")
|
||||
public Boolean profitSharing;
|
||||
}
|
||||
|
||||
public static class GoodsDetail {
|
||||
@SerializedName("merchant_goods_id")
|
||||
public String merchantGoodsId;
|
||||
|
||||
@SerializedName("wechatpay_goods_id")
|
||||
public String wechatpayGoodsId;
|
||||
|
||||
@SerializedName("goods_name")
|
||||
public String goodsName;
|
||||
|
||||
@SerializedName("quantity")
|
||||
public Long quantity;
|
||||
|
||||
@SerializedName("unit_price")
|
||||
public Long unitPrice;
|
||||
}
|
||||
|
||||
public static class StoreInfo {
|
||||
@SerializedName("id")
|
||||
public String id;
|
||||
|
||||
@SerializedName("name")
|
||||
public String name;
|
||||
|
||||
@SerializedName("area_code")
|
||||
public String areaCode;
|
||||
|
||||
@SerializedName("address")
|
||||
public String address;
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
# 小程序调起支付
|
||||
|
||||
## 说明
|
||||
|
||||
商户通过JSAPI/小程序下单接口获取到发起支付的必要参数 `prepay_id` 后,在小程序中通过 `wx.requestPayment` 调起微信支付收银台。
|
||||
|
||||
小程序和JSAPI共用同一个下单接口(`/v3/pay/transactions/jsapi`),区别在于调起支付的前端方式不同:小程序用 `wx.requestPayment`,JSAPI 用 `WeixinJSBridge`。
|
||||
|
||||
## 示例代码
|
||||
|
||||
```javascript
|
||||
wx.requestPayment(
|
||||
{
|
||||
"timeStamp": "1414561699",
|
||||
"nonceStr": "5K8264ILTKCH16CQ2502SI8ZNMTM67VS",
|
||||
"package": "prepay_id=wx201410272009395522657a690389285100",
|
||||
"signType": "RSA",
|
||||
"paySign": "oR9d8PuhnIc+YZ8cBHFCwfgpaK9gd7vaRvkYD7rthRAZ\/X+QBhcCYL21N7cHCTUxbQ+EAt6Uy+lwSN22f5YZvI45MLko8Pfso0jm46v5hqcVwrk6uddkGuT+Cdvu4WBqDzaDjnNa5UK3GfE1Wfl2gHxIIY5lLdUgWFts17D4WuolLLkiFZV+JSHMvH7eaLdT9N5GBovBwu5yYKUR7skR8Fu+LozcSqQixnlEZUfyE55feLOQTUYzLmR9pNtPbPsu6WVhbNHMS3Ss2+AehHvz+n64GDmXxbX++IOBvm2olHu3PsOUGRwhudhVf7UcGcunXt8cqNjKNqZLhLw4jq\/xDg==",
|
||||
"success":function(res){},
|
||||
"fail":function(res){},
|
||||
"complete":function(res){}
|
||||
}
|
||||
)
|
||||
```
|
||||
|
||||
> **重要**:前端回调不保证绝对可靠,商户需通过后端查单接口或支付成功回调通知来确认订单状态。
|
||||
@@ -0,0 +1,233 @@
|
||||
package com.java.demo;
|
||||
|
||||
import com.java.utils.WXPayUtility; // 引用微信支付工具库,参考:https://pay.weixin.qq.com/doc/v3/merchant/4014931831
|
||||
|
||||
import com.google.gson.annotations.SerializedName;
|
||||
import com.google.gson.annotations.Expose;
|
||||
import okhttp3.MediaType;
|
||||
import okhttp3.OkHttpClient;
|
||||
import okhttp3.Request;
|
||||
import okhttp3.RequestBody;
|
||||
import okhttp3.Response;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.io.UncheckedIOException;
|
||||
import java.security.PrivateKey;
|
||||
import java.security.PublicKey;
|
||||
import java.util.ArrayList;
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* 微信支付商户订单号查询订单
|
||||
*/
|
||||
public class QueryByOutTradeNo {
|
||||
private static String HOST = "https://api.mch.weixin.qq.com";
|
||||
private static String METHOD = "GET";
|
||||
private static String PATH = "/v3/pay/transactions/out-trade-no/{out_trade_no}";
|
||||
|
||||
public static void main(String[] args) {
|
||||
// TODO: 请准备商户开发必要参数,参考:https://pay.weixin.qq.com/doc/v3/merchant/4013070756
|
||||
QueryByOutTradeNo client = new QueryByOutTradeNo(
|
||||
"19xxxxxxxx", // 商户号,是由微信支付系统生成并分配给每个商户的唯一标识符,商户号获取方式参考 https://pay.weixin.qq.com/doc/v3/merchant/4013070756
|
||||
"1DDE55AD98Exxxxxxxxxx", // 商户API证书序列号,如何获取请参考 https://pay.weixin.qq.com/doc/v3/merchant/4013053053
|
||||
"/path/to/apiclient_key.pem", // 商户API证书私钥文件路径,本地文件路径
|
||||
"PUB_KEY_ID_xxxxxxxxxxxxx", // 微信支付公钥ID,如何获取请参考 https://pay.weixin.qq.com/doc/v3/merchant/4013038816
|
||||
"/path/to/wxp_pub.pem" // 微信支付公钥文件路径,本地文件路径
|
||||
);
|
||||
|
||||
QueryByOutTradeNoRequest request = new QueryByOutTradeNoRequest();
|
||||
request.outTradeNo = "1217752501201407033233368018";
|
||||
request.mchid = "1230000109";
|
||||
try {
|
||||
DirectAPIv3QueryResponse response = client.run(request);
|
||||
// TODO: 请求成功,继续业务逻辑
|
||||
System.out.println(response);
|
||||
} catch (WXPayUtility.ApiException e) {
|
||||
// TODO: 请求失败,根据状态码执行不同的逻辑
|
||||
e.printStackTrace();
|
||||
}
|
||||
}
|
||||
|
||||
public DirectAPIv3QueryResponse run(QueryByOutTradeNoRequest request) {
|
||||
String uri = PATH;
|
||||
uri = uri.replace("{out_trade_no}", WXPayUtility.urlEncode(request.outTradeNo));
|
||||
Map<String, Object> args = new HashMap<>();
|
||||
args.put("mchid", request.mchid);
|
||||
String queryString = WXPayUtility.urlEncode(args);
|
||||
if (!queryString.isEmpty()) {
|
||||
uri = uri + "?" + queryString;
|
||||
}
|
||||
|
||||
Request.Builder reqBuilder = new Request.Builder().url(HOST + uri);
|
||||
reqBuilder.addHeader("Accept", "application/json");
|
||||
reqBuilder.addHeader("Wechatpay-Serial", wechatPayPublicKeyId);
|
||||
reqBuilder.addHeader("Authorization", WXPayUtility.buildAuthorization(mchid, certificateSerialNo, privateKey, METHOD, uri, null));
|
||||
reqBuilder.method(METHOD, null);
|
||||
Request httpRequest = reqBuilder.build();
|
||||
|
||||
// 发送HTTP请求
|
||||
OkHttpClient client = new OkHttpClient.Builder().build();
|
||||
try (Response httpResponse = client.newCall(httpRequest).execute()) {
|
||||
String respBody = WXPayUtility.extractBody(httpResponse);
|
||||
if (httpResponse.code() >= 200 && httpResponse.code() < 300) {
|
||||
// 2XX 成功,验证应答签名
|
||||
WXPayUtility.validateResponse(this.wechatPayPublicKeyId, this.wechatPayPublicKey,
|
||||
httpResponse.headers(), respBody);
|
||||
|
||||
// 从HTTP应答报文构建返回数据
|
||||
return WXPayUtility.fromJson(respBody, DirectAPIv3QueryResponse.class);
|
||||
} else {
|
||||
throw new WXPayUtility.ApiException(httpResponse.code(), respBody, httpResponse.headers());
|
||||
}
|
||||
} catch (IOException e) {
|
||||
throw new UncheckedIOException("Sending request to " + uri + " failed.", e);
|
||||
}
|
||||
}
|
||||
|
||||
private final String mchid;
|
||||
private final String certificateSerialNo;
|
||||
private final PrivateKey privateKey;
|
||||
private final String wechatPayPublicKeyId;
|
||||
private final PublicKey wechatPayPublicKey;
|
||||
|
||||
public QueryByOutTradeNo(String mchid, String certificateSerialNo, String privateKeyFilePath, String wechatPayPublicKeyId, String wechatPayPublicKeyFilePath) {
|
||||
this.mchid = mchid;
|
||||
this.certificateSerialNo = certificateSerialNo;
|
||||
this.privateKey = WXPayUtility.loadPrivateKeyFromPath(privateKeyFilePath);
|
||||
this.wechatPayPublicKeyId = wechatPayPublicKeyId;
|
||||
this.wechatPayPublicKey = WXPayUtility.loadPublicKeyFromPath(wechatPayPublicKeyFilePath);
|
||||
}
|
||||
|
||||
public static class QueryByOutTradeNoRequest {
|
||||
@SerializedName("mchid")
|
||||
@Expose(serialize = false)
|
||||
public String mchid;
|
||||
|
||||
@SerializedName("out_trade_no")
|
||||
@Expose(serialize = false)
|
||||
public String outTradeNo;
|
||||
}
|
||||
|
||||
public static class DirectAPIv3QueryResponse {
|
||||
@SerializedName("appid")
|
||||
public String appid;
|
||||
|
||||
@SerializedName("mchid")
|
||||
public String mchid;
|
||||
|
||||
@SerializedName("out_trade_no")
|
||||
public String outTradeNo;
|
||||
|
||||
@SerializedName("transaction_id")
|
||||
public String transactionId;
|
||||
|
||||
@SerializedName("trade_type")
|
||||
public String tradeType;
|
||||
|
||||
@SerializedName("trade_state")
|
||||
public String tradeState;
|
||||
|
||||
@SerializedName("trade_state_desc")
|
||||
public String tradeStateDesc;
|
||||
|
||||
@SerializedName("bank_type")
|
||||
public String bankType;
|
||||
|
||||
@SerializedName("attach")
|
||||
public String attach;
|
||||
|
||||
@SerializedName("success_time")
|
||||
public String successTime;
|
||||
|
||||
@SerializedName("payer")
|
||||
public CommRespPayerInfo payer;
|
||||
|
||||
@SerializedName("amount")
|
||||
public CommRespAmountInfo amount;
|
||||
|
||||
@SerializedName("scene_info")
|
||||
public CommRespSceneInfo sceneInfo;
|
||||
|
||||
@SerializedName("promotion_detail")
|
||||
public List<PromotionDetail> promotionDetail;
|
||||
}
|
||||
|
||||
public static class CommRespPayerInfo {
|
||||
@SerializedName("openid")
|
||||
public String openid;
|
||||
}
|
||||
|
||||
public static class CommRespAmountInfo {
|
||||
@SerializedName("total")
|
||||
public Long total;
|
||||
|
||||
@SerializedName("payer_total")
|
||||
public Long payerTotal;
|
||||
|
||||
@SerializedName("currency")
|
||||
public String currency;
|
||||
|
||||
@SerializedName("payer_currency")
|
||||
public String payerCurrency;
|
||||
}
|
||||
|
||||
public static class CommRespSceneInfo {
|
||||
@SerializedName("device_id")
|
||||
public String deviceId;
|
||||
}
|
||||
|
||||
public static class PromotionDetail {
|
||||
@SerializedName("coupon_id")
|
||||
public String couponId;
|
||||
|
||||
@SerializedName("name")
|
||||
public String name;
|
||||
|
||||
@SerializedName("scope")
|
||||
public String scope;
|
||||
|
||||
@SerializedName("type")
|
||||
public String type;
|
||||
|
||||
@SerializedName("amount")
|
||||
public Long amount;
|
||||
|
||||
@SerializedName("stock_id")
|
||||
public String stockId;
|
||||
|
||||
@SerializedName("wechatpay_contribute")
|
||||
public Long wechatpayContribute;
|
||||
|
||||
@SerializedName("merchant_contribute")
|
||||
public Long merchantContribute;
|
||||
|
||||
@SerializedName("other_contribute")
|
||||
public Long otherContribute;
|
||||
|
||||
@SerializedName("currency")
|
||||
public String currency;
|
||||
|
||||
@SerializedName("goods_detail")
|
||||
public List<GoodsDetailInPromotion> goodsDetail;
|
||||
}
|
||||
|
||||
public static class GoodsDetailInPromotion {
|
||||
@SerializedName("goods_id")
|
||||
public String goodsId;
|
||||
|
||||
@SerializedName("quantity")
|
||||
public Long quantity;
|
||||
|
||||
@SerializedName("unit_price")
|
||||
public Long unitPrice;
|
||||
|
||||
@SerializedName("discount_amount")
|
||||
public Long discountAmount;
|
||||
|
||||
@SerializedName("goods_remark")
|
||||
public String goodsRemark;
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,233 @@
|
||||
package com.java.demo;
|
||||
|
||||
import com.java.utils.WXPayUtility; // 引用微信支付工具库,参考:https://pay.weixin.qq.com/doc/v3/merchant/4014931831
|
||||
|
||||
import com.google.gson.annotations.SerializedName;
|
||||
import com.google.gson.annotations.Expose;
|
||||
import okhttp3.MediaType;
|
||||
import okhttp3.OkHttpClient;
|
||||
import okhttp3.Request;
|
||||
import okhttp3.RequestBody;
|
||||
import okhttp3.Response;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.io.UncheckedIOException;
|
||||
import java.security.PrivateKey;
|
||||
import java.security.PublicKey;
|
||||
import java.util.ArrayList;
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* 微信支付订单号查询订单
|
||||
*/
|
||||
public class QueryByWxTradeNo {
|
||||
private static String HOST = "https://api.mch.weixin.qq.com";
|
||||
private static String METHOD = "GET";
|
||||
private static String PATH = "/v3/pay/transactions/id/{transaction_id}";
|
||||
|
||||
public static void main(String[] args) {
|
||||
// TODO: 请准备商户开发必要参数,参考:https://pay.weixin.qq.com/doc/v3/merchant/4013070756
|
||||
QueryByWxTradeNo client = new QueryByWxTradeNo(
|
||||
"19xxxxxxxx", // 商户号,是由微信支付系统生成并分配给每个商户的唯一标识符,商户号获取方式参考 https://pay.weixin.qq.com/doc/v3/merchant/4013070756
|
||||
"1DDE55AD98Exxxxxxxxxx", // 商户API证书序列号,如何获取请参考 https://pay.weixin.qq.com/doc/v3/merchant/4013053053
|
||||
"/path/to/apiclient_key.pem", // 商户API证书私钥文件路径,本地文件路径
|
||||
"PUB_KEY_ID_xxxxxxxxxxxxx", // 微信支付公钥ID,如何获取请参考 https://pay.weixin.qq.com/doc/v3/merchant/4013038816
|
||||
"/path/to/wxp_pub.pem" // 微信支付公钥文件路径,本地文件路径
|
||||
);
|
||||
|
||||
QueryByWxTradeNoRequest request = new QueryByWxTradeNoRequest();
|
||||
request.transactionId = "1217752501201407033233368018";
|
||||
request.mchid = "1230000109";
|
||||
try {
|
||||
DirectAPIv3QueryResponse response = client.run(request);
|
||||
// TODO: 请求成功,继续业务逻辑
|
||||
System.out.println(response);
|
||||
} catch (WXPayUtility.ApiException e) {
|
||||
// TODO: 请求失败,根据状态码执行不同的逻辑
|
||||
e.printStackTrace();
|
||||
}
|
||||
}
|
||||
|
||||
public DirectAPIv3QueryResponse run(QueryByWxTradeNoRequest request) {
|
||||
String uri = PATH;
|
||||
uri = uri.replace("{transaction_id}", WXPayUtility.urlEncode(request.transactionId));
|
||||
Map<String, Object> args = new HashMap<>();
|
||||
args.put("mchid", request.mchid);
|
||||
String queryString = WXPayUtility.urlEncode(args);
|
||||
if (!queryString.isEmpty()) {
|
||||
uri = uri + "?" + queryString;
|
||||
}
|
||||
|
||||
Request.Builder reqBuilder = new Request.Builder().url(HOST + uri);
|
||||
reqBuilder.addHeader("Accept", "application/json");
|
||||
reqBuilder.addHeader("Wechatpay-Serial", wechatPayPublicKeyId);
|
||||
reqBuilder.addHeader("Authorization", WXPayUtility.buildAuthorization(mchid, certificateSerialNo, privateKey, METHOD, uri, null));
|
||||
reqBuilder.method(METHOD, null);
|
||||
Request httpRequest = reqBuilder.build();
|
||||
|
||||
// 发送HTTP请求
|
||||
OkHttpClient client = new OkHttpClient.Builder().build();
|
||||
try (Response httpResponse = client.newCall(httpRequest).execute()) {
|
||||
String respBody = WXPayUtility.extractBody(httpResponse);
|
||||
if (httpResponse.code() >= 200 && httpResponse.code() < 300) {
|
||||
// 2XX 成功,验证应答签名
|
||||
WXPayUtility.validateResponse(this.wechatPayPublicKeyId, this.wechatPayPublicKey,
|
||||
httpResponse.headers(), respBody);
|
||||
|
||||
// 从HTTP应答报文构建返回数据
|
||||
return WXPayUtility.fromJson(respBody, DirectAPIv3QueryResponse.class);
|
||||
} else {
|
||||
throw new WXPayUtility.ApiException(httpResponse.code(), respBody, httpResponse.headers());
|
||||
}
|
||||
} catch (IOException e) {
|
||||
throw new UncheckedIOException("Sending request to " + uri + " failed.", e);
|
||||
}
|
||||
}
|
||||
|
||||
private final String mchid;
|
||||
private final String certificateSerialNo;
|
||||
private final PrivateKey privateKey;
|
||||
private final String wechatPayPublicKeyId;
|
||||
private final PublicKey wechatPayPublicKey;
|
||||
|
||||
public QueryByWxTradeNo(String mchid, String certificateSerialNo, String privateKeyFilePath, String wechatPayPublicKeyId, String wechatPayPublicKeyFilePath) {
|
||||
this.mchid = mchid;
|
||||
this.certificateSerialNo = certificateSerialNo;
|
||||
this.privateKey = WXPayUtility.loadPrivateKeyFromPath(privateKeyFilePath);
|
||||
this.wechatPayPublicKeyId = wechatPayPublicKeyId;
|
||||
this.wechatPayPublicKey = WXPayUtility.loadPublicKeyFromPath(wechatPayPublicKeyFilePath);
|
||||
}
|
||||
|
||||
public static class QueryByWxTradeNoRequest {
|
||||
@SerializedName("mchid")
|
||||
@Expose(serialize = false)
|
||||
public String mchid;
|
||||
|
||||
@SerializedName("transaction_id")
|
||||
@Expose(serialize = false)
|
||||
public String transactionId;
|
||||
}
|
||||
|
||||
public static class DirectAPIv3QueryResponse {
|
||||
@SerializedName("appid")
|
||||
public String appid;
|
||||
|
||||
@SerializedName("mchid")
|
||||
public String mchid;
|
||||
|
||||
@SerializedName("out_trade_no")
|
||||
public String outTradeNo;
|
||||
|
||||
@SerializedName("transaction_id")
|
||||
public String transactionId;
|
||||
|
||||
@SerializedName("trade_type")
|
||||
public String tradeType;
|
||||
|
||||
@SerializedName("trade_state")
|
||||
public String tradeState;
|
||||
|
||||
@SerializedName("trade_state_desc")
|
||||
public String tradeStateDesc;
|
||||
|
||||
@SerializedName("bank_type")
|
||||
public String bankType;
|
||||
|
||||
@SerializedName("attach")
|
||||
public String attach;
|
||||
|
||||
@SerializedName("success_time")
|
||||
public String successTime;
|
||||
|
||||
@SerializedName("payer")
|
||||
public CommRespPayerInfo payer;
|
||||
|
||||
@SerializedName("amount")
|
||||
public CommRespAmountInfo amount;
|
||||
|
||||
@SerializedName("scene_info")
|
||||
public CommRespSceneInfo sceneInfo;
|
||||
|
||||
@SerializedName("promotion_detail")
|
||||
public List<PromotionDetail> promotionDetail;
|
||||
}
|
||||
|
||||
public static class CommRespPayerInfo {
|
||||
@SerializedName("openid")
|
||||
public String openid;
|
||||
}
|
||||
|
||||
public static class CommRespAmountInfo {
|
||||
@SerializedName("total")
|
||||
public Long total;
|
||||
|
||||
@SerializedName("payer_total")
|
||||
public Long payerTotal;
|
||||
|
||||
@SerializedName("currency")
|
||||
public String currency;
|
||||
|
||||
@SerializedName("payer_currency")
|
||||
public String payerCurrency;
|
||||
}
|
||||
|
||||
public static class CommRespSceneInfo {
|
||||
@SerializedName("device_id")
|
||||
public String deviceId;
|
||||
}
|
||||
|
||||
public static class PromotionDetail {
|
||||
@SerializedName("coupon_id")
|
||||
public String couponId;
|
||||
|
||||
@SerializedName("name")
|
||||
public String name;
|
||||
|
||||
@SerializedName("scope")
|
||||
public String scope;
|
||||
|
||||
@SerializedName("type")
|
||||
public String type;
|
||||
|
||||
@SerializedName("amount")
|
||||
public Long amount;
|
||||
|
||||
@SerializedName("stock_id")
|
||||
public String stockId;
|
||||
|
||||
@SerializedName("wechatpay_contribute")
|
||||
public Long wechatpayContribute;
|
||||
|
||||
@SerializedName("merchant_contribute")
|
||||
public Long merchantContribute;
|
||||
|
||||
@SerializedName("other_contribute")
|
||||
public Long otherContribute;
|
||||
|
||||
@SerializedName("currency")
|
||||
public String currency;
|
||||
|
||||
@SerializedName("goods_detail")
|
||||
public List<GoodsDetailInPromotion> goodsDetail;
|
||||
}
|
||||
|
||||
public static class GoodsDetailInPromotion {
|
||||
@SerializedName("goods_id")
|
||||
public String goodsId;
|
||||
|
||||
@SerializedName("quantity")
|
||||
public Long quantity;
|
||||
|
||||
@SerializedName("unit_price")
|
||||
public Long unitPrice;
|
||||
|
||||
@SerializedName("discount_amount")
|
||||
public Long discountAmount;
|
||||
|
||||
@SerializedName("goods_remark")
|
||||
public String goodsRemark;
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
package com.java.demo;
|
||||
|
||||
import com.java.utils.WXPayUtility; // 引用微信支付工具库,参考:https://pay.weixin.qq.com/doc/v3/merchant/4014931831
|
||||
|
||||
import com.google.gson.annotations.SerializedName;
|
||||
import com.google.gson.annotations.Expose;
|
||||
import okhttp3.MediaType;
|
||||
import okhttp3.OkHttpClient;
|
||||
import okhttp3.Request;
|
||||
import okhttp3.RequestBody;
|
||||
import okhttp3.Response;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.io.UncheckedIOException;
|
||||
import java.security.PrivateKey;
|
||||
import java.security.PublicKey;
|
||||
import java.util.ArrayList;
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* 关闭订单
|
||||
*
|
||||
* 未支付状态的订单,可在无需支付时调用此接口关闭订单。常见关单情况包括:
|
||||
* 1. 用户在商户系统提交取消订单请求,商户需执行关单操作。
|
||||
* 2. 订单超时未支付(超出商户系统设定的可支付时间或下单时的time_expire支付截止时间),商户需进行关单处理。
|
||||
*/
|
||||
public class CloseOrder {
|
||||
private static String HOST = "https://api.mch.weixin.qq.com";
|
||||
private static String METHOD = "POST";
|
||||
private static String PATH = "/v3/pay/transactions/out-trade-no/{out_trade_no}/close";
|
||||
|
||||
public static void main(String[] args) {
|
||||
// TODO: 请准备商户开发必要参数,参考:https://pay.weixin.qq.com/doc/v3/merchant/4013070756
|
||||
CloseOrder client = new CloseOrder(
|
||||
"19xxxxxxxx", // 商户号,是由微信支付系统生成并分配给每个商户的唯一标识符,商户号获取方式参考 https://pay.weixin.qq.com/doc/v3/merchant/4013070756
|
||||
"1DDE55AD98Exxxxxxxxxx", // 商户API证书序列号,如何获取请参考 https://pay.weixin.qq.com/doc/v3/merchant/4013053053
|
||||
"/path/to/apiclient_key.pem", // 商户API证书私钥文件路径,本地文件路径
|
||||
"PUB_KEY_ID_xxxxxxxxxxxxx", // 微信支付公钥ID,如何获取请参考 https://pay.weixin.qq.com/doc/v3/merchant/4013038816
|
||||
"/path/to/wxp_pub.pem" // 微信支付公钥文件路径,本地文件路径
|
||||
);
|
||||
|
||||
CloseOrderRequest request = new CloseOrderRequest();
|
||||
request.outTradeNo = "1217752501201407033233368018";
|
||||
request.mchid = "1230000109";
|
||||
try {
|
||||
client.run(request);
|
||||
} catch (WXPayUtility.ApiException e) {
|
||||
// TODO: 请求失败,根据状态码执行不同的逻辑
|
||||
e.printStackTrace();
|
||||
}
|
||||
}
|
||||
|
||||
public void run(CloseOrderRequest request) {
|
||||
String uri = PATH;
|
||||
uri = uri.replace("{out_trade_no}", WXPayUtility.urlEncode(request.outTradeNo));
|
||||
String reqBody = WXPayUtility.toJson(request);
|
||||
|
||||
Request.Builder reqBuilder = new Request.Builder().url(HOST + uri);
|
||||
reqBuilder.addHeader("Accept", "application/json");
|
||||
reqBuilder.addHeader("Wechatpay-Serial", wechatPayPublicKeyId);
|
||||
reqBuilder.addHeader("Authorization", WXPayUtility.buildAuthorization(mchid, certificateSerialNo,privateKey, METHOD, uri, reqBody));
|
||||
reqBuilder.addHeader("Content-Type", "application/json");
|
||||
RequestBody requestBody = RequestBody.create(MediaType.parse("application/json; charset=utf-8"), reqBody);
|
||||
reqBuilder.method(METHOD, requestBody);
|
||||
Request httpRequest = reqBuilder.build();
|
||||
|
||||
// 发送HTTP请求
|
||||
OkHttpClient client = new OkHttpClient.Builder().build();
|
||||
try (Response httpResponse = client.newCall(httpRequest).execute()) {
|
||||
String respBody = WXPayUtility.extractBody(httpResponse);
|
||||
if (httpResponse.code() >= 200 && httpResponse.code() < 300) {
|
||||
// 2XX 成功,验证应答签名
|
||||
WXPayUtility.validateResponse(this.wechatPayPublicKeyId, this.wechatPayPublicKey,
|
||||
httpResponse.headers(), respBody);
|
||||
|
||||
return;
|
||||
} else {
|
||||
throw new WXPayUtility.ApiException(httpResponse.code(), respBody, httpResponse.headers());
|
||||
}
|
||||
} catch (IOException e) {
|
||||
throw new UncheckedIOException("Sending request to " + uri + " failed.", e);
|
||||
}
|
||||
}
|
||||
|
||||
private final String mchid;
|
||||
private final String certificateSerialNo;
|
||||
private final PrivateKey privateKey;
|
||||
private final String wechatPayPublicKeyId;
|
||||
private final PublicKey wechatPayPublicKey;
|
||||
|
||||
public CloseOrder(String mchid, String certificateSerialNo, String privateKeyFilePath, String wechatPayPublicKeyId, String wechatPayPublicKeyFilePath) {
|
||||
this.mchid = mchid;
|
||||
this.certificateSerialNo = certificateSerialNo;
|
||||
this.privateKey = WXPayUtility.loadPrivateKeyFromPath(privateKeyFilePath);
|
||||
this.wechatPayPublicKeyId = wechatPayPublicKeyId;
|
||||
this.wechatPayPublicKey = WXPayUtility.loadPublicKeyFromPath(wechatPayPublicKeyFilePath);
|
||||
}
|
||||
|
||||
public static class CloseOrderRequest {
|
||||
@SerializedName("mchid")
|
||||
public String mchid;
|
||||
|
||||
@SerializedName("out_trade_no")
|
||||
@Expose(serialize = false)
|
||||
public String outTradeNo;
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,314 @@
|
||||
package com.java.demo;
|
||||
|
||||
import com.java.utils.WXPayUtility; // 引用微信支付工具库,参考:https://pay.weixin.qq.com/doc/v3/merchant/4014931831
|
||||
|
||||
import com.google.gson.annotations.SerializedName;
|
||||
import com.google.gson.annotations.Expose;
|
||||
import okhttp3.MediaType;
|
||||
import okhttp3.OkHttpClient;
|
||||
import okhttp3.Request;
|
||||
import okhttp3.RequestBody;
|
||||
import okhttp3.Response;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.io.UncheckedIOException;
|
||||
import java.security.PrivateKey;
|
||||
import java.security.PublicKey;
|
||||
import java.util.ArrayList;
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* 发起异常退款
|
||||
*/
|
||||
public class CreateAbnormalRefund {
|
||||
private static String HOST = "https://api.mch.weixin.qq.com";
|
||||
private static String METHOD = "POST";
|
||||
private static String PATH = "/v3/refund/domestic/refunds/{refund_id}/apply-abnormal-refund";
|
||||
|
||||
public static void main(String[] args) {
|
||||
// TODO: 请准备商户开发必要参数,参考:https://pay.weixin.qq.com/doc/v3/merchant/4013070756
|
||||
CreateAbnormalRefund client = new CreateAbnormalRefund(
|
||||
"19xxxxxxxx", // 商户号,是由微信支付系统生成并分配给每个商户的唯一标识符,商户号获取方式参考 https://pay.weixin.qq.com/doc/v3/merchant/4013070756
|
||||
"1DDE55AD98Exxxxxxxxxx", // 商户API证书序列号,如何获取请参考 https://pay.weixin.qq.com/doc/v3/merchant/4013053053
|
||||
"/path/to/apiclient_key.pem", // 商户API证书私钥文件路径,本地文件路径
|
||||
"PUB_KEY_ID_xxxxxxxxxxxxx", // 微信支付公钥ID,如何获取请参考 https://pay.weixin.qq.com/doc/v3/merchant/4013038816
|
||||
"/path/to/wxp_pub.pem" // 微信支付公钥文件路径,本地文件路径
|
||||
);
|
||||
|
||||
CreateAbnormalRefundRequest request = new CreateAbnormalRefundRequest();
|
||||
request.refundId = "50000000382019052709732678859";
|
||||
request.outRefundNo = "1217752501201407033233368018";
|
||||
request.type = AbnormalReceiveType.MERCHANT_BANK_CARD;
|
||||
request.bankType = "ICBC_DEBIT";
|
||||
request.bankAccount = client.encrypt("bank_account");
|
||||
request.realName = client.encrypt("real_name");
|
||||
try {
|
||||
Refund response = client.run(request);
|
||||
// TODO: 请求成功,继续业务逻辑
|
||||
System.out.println(response);
|
||||
} catch (WXPayUtility.ApiException e) {
|
||||
// TODO: 请求失败,根据状态码执行不同的逻辑
|
||||
e.printStackTrace();
|
||||
}
|
||||
}
|
||||
|
||||
public Refund run(CreateAbnormalRefundRequest request) {
|
||||
String uri = PATH;
|
||||
uri = uri.replace("{refund_id}", WXPayUtility.urlEncode(request.refundId));
|
||||
String reqBody = WXPayUtility.toJson(request);
|
||||
|
||||
Request.Builder reqBuilder = new Request.Builder().url(HOST + uri);
|
||||
reqBuilder.addHeader("Accept", "application/json");
|
||||
reqBuilder.addHeader("Wechatpay-Serial", wechatPayPublicKeyId);
|
||||
reqBuilder.addHeader("Authorization", WXPayUtility.buildAuthorization(mchid, certificateSerialNo,privateKey, METHOD, uri, reqBody));
|
||||
reqBuilder.addHeader("Content-Type", "application/json");
|
||||
RequestBody requestBody = RequestBody.create(MediaType.parse("application/json; charset=utf-8"), reqBody);
|
||||
reqBuilder.method(METHOD, requestBody);
|
||||
Request httpRequest = reqBuilder.build();
|
||||
|
||||
// 发送HTTP请求
|
||||
OkHttpClient client = new OkHttpClient.Builder().build();
|
||||
try (Response httpResponse = client.newCall(httpRequest).execute()) {
|
||||
String respBody = WXPayUtility.extractBody(httpResponse);
|
||||
if (httpResponse.code() >= 200 && httpResponse.code() < 300) {
|
||||
// 2XX 成功,验证应答签名
|
||||
WXPayUtility.validateResponse(this.wechatPayPublicKeyId, this.wechatPayPublicKey,
|
||||
httpResponse.headers(), respBody);
|
||||
|
||||
// 从HTTP应答报文构建返回数据
|
||||
return WXPayUtility.fromJson(respBody, Refund.class);
|
||||
} else {
|
||||
throw new WXPayUtility.ApiException(httpResponse.code(), respBody, httpResponse.headers());
|
||||
}
|
||||
} catch (IOException e) {
|
||||
throw new UncheckedIOException("Sending request to " + uri + " failed.", e);
|
||||
}
|
||||
}
|
||||
|
||||
private final String mchid;
|
||||
private final String certificateSerialNo;
|
||||
private final PrivateKey privateKey;
|
||||
private final String wechatPayPublicKeyId;
|
||||
private final PublicKey wechatPayPublicKey;
|
||||
|
||||
public CreateAbnormalRefund(String mchid, String certificateSerialNo, String privateKeyFilePath, String wechatPayPublicKeyId, String wechatPayPublicKeyFilePath) {
|
||||
this.mchid = mchid;
|
||||
this.certificateSerialNo = certificateSerialNo;
|
||||
this.privateKey = WXPayUtility.loadPrivateKeyFromPath(privateKeyFilePath);
|
||||
this.wechatPayPublicKeyId = wechatPayPublicKeyId;
|
||||
this.wechatPayPublicKey = WXPayUtility.loadPublicKeyFromPath(wechatPayPublicKeyFilePath);
|
||||
}
|
||||
|
||||
public String encrypt(String plainText) {
|
||||
return WXPayUtility.encrypt(this.wechatPayPublicKey, plainText);
|
||||
}
|
||||
|
||||
public static class CreateAbnormalRefundRequest {
|
||||
@SerializedName("refund_id")
|
||||
@Expose(serialize = false)
|
||||
public String refundId;
|
||||
|
||||
@SerializedName("out_refund_no")
|
||||
public String outRefundNo;
|
||||
|
||||
@SerializedName("type")
|
||||
public AbnormalReceiveType type;
|
||||
|
||||
@SerializedName("bank_type")
|
||||
public String bankType;
|
||||
|
||||
@SerializedName("bank_account")
|
||||
public String bankAccount;
|
||||
|
||||
@SerializedName("real_name")
|
||||
public String realName;
|
||||
}
|
||||
|
||||
public static class Refund {
|
||||
@SerializedName("refund_id")
|
||||
public String refundId;
|
||||
|
||||
@SerializedName("out_refund_no")
|
||||
public String outRefundNo;
|
||||
|
||||
@SerializedName("transaction_id")
|
||||
public String transactionId;
|
||||
|
||||
@SerializedName("out_trade_no")
|
||||
public String outTradeNo;
|
||||
|
||||
@SerializedName("channel")
|
||||
public Channel channel;
|
||||
|
||||
@SerializedName("user_received_account")
|
||||
public String userReceivedAccount;
|
||||
|
||||
@SerializedName("success_time")
|
||||
public String successTime;
|
||||
|
||||
@SerializedName("create_time")
|
||||
public String createTime;
|
||||
|
||||
@SerializedName("status")
|
||||
public Status status;
|
||||
|
||||
@SerializedName("funds_account")
|
||||
public FundsAccount fundsAccount;
|
||||
|
||||
@SerializedName("amount")
|
||||
public Amount amount;
|
||||
|
||||
@SerializedName("promotion_detail")
|
||||
public List<Promotion> promotionDetail;
|
||||
|
||||
}
|
||||
|
||||
public enum AbnormalReceiveType {
|
||||
@SerializedName("USER_BANK_CARD")
|
||||
USER_BANK_CARD,
|
||||
@SerializedName("MERCHANT_BANK_CARD")
|
||||
MERCHANT_BANK_CARD
|
||||
}
|
||||
|
||||
public enum Channel {
|
||||
@SerializedName("ORIGINAL")
|
||||
ORIGINAL,
|
||||
@SerializedName("BALANCE")
|
||||
BALANCE,
|
||||
@SerializedName("OTHER_BALANCE")
|
||||
OTHER_BALANCE,
|
||||
@SerializedName("OTHER_BANKCARD")
|
||||
OTHER_BANKCARD
|
||||
}
|
||||
|
||||
public enum Status {
|
||||
@SerializedName("SUCCESS")
|
||||
SUCCESS,
|
||||
@SerializedName("CLOSED")
|
||||
CLOSED,
|
||||
@SerializedName("PROCESSING")
|
||||
PROCESSING,
|
||||
@SerializedName("ABNORMAL")
|
||||
ABNORMAL
|
||||
}
|
||||
|
||||
public enum FundsAccount {
|
||||
@SerializedName("UNSETTLED")
|
||||
UNSETTLED,
|
||||
@SerializedName("AVAILABLE")
|
||||
AVAILABLE,
|
||||
@SerializedName("UNAVAILABLE")
|
||||
UNAVAILABLE,
|
||||
@SerializedName("OPERATION")
|
||||
OPERATION,
|
||||
@SerializedName("BASIC")
|
||||
BASIC,
|
||||
@SerializedName("ECNY_BASIC")
|
||||
ECNY_BASIC
|
||||
}
|
||||
|
||||
public static class Amount {
|
||||
@SerializedName("total")
|
||||
public Long total;
|
||||
|
||||
@SerializedName("refund")
|
||||
public Long refund;
|
||||
|
||||
@SerializedName("from")
|
||||
public List<FundsFromItem> from;
|
||||
|
||||
@SerializedName("payer_total")
|
||||
public Long payerTotal;
|
||||
|
||||
@SerializedName("payer_refund")
|
||||
public Long payerRefund;
|
||||
|
||||
@SerializedName("settlement_refund")
|
||||
public Long settlementRefund;
|
||||
|
||||
@SerializedName("settlement_total")
|
||||
public Long settlementTotal;
|
||||
|
||||
@SerializedName("discount_refund")
|
||||
public Long discountRefund;
|
||||
|
||||
@SerializedName("currency")
|
||||
public String currency;
|
||||
|
||||
@SerializedName("refund_fee")
|
||||
public Long refundFee;
|
||||
}
|
||||
|
||||
public static class Promotion {
|
||||
@SerializedName("promotion_id")
|
||||
public String promotionId;
|
||||
|
||||
@SerializedName("scope")
|
||||
public PromotionScope scope;
|
||||
|
||||
@SerializedName("type")
|
||||
public PromotionType type;
|
||||
|
||||
@SerializedName("amount")
|
||||
public Long amount;
|
||||
|
||||
@SerializedName("refund_amount")
|
||||
public Long refundAmount;
|
||||
|
||||
@SerializedName("goods_detail")
|
||||
public List<GoodsDetail> goodsDetail;
|
||||
}
|
||||
|
||||
|
||||
public static class FundsFromItem {
|
||||
@SerializedName("account")
|
||||
public Account account;
|
||||
|
||||
@SerializedName("amount")
|
||||
public Long amount;
|
||||
}
|
||||
|
||||
public enum PromotionScope {
|
||||
@SerializedName("GLOBAL")
|
||||
GLOBAL,
|
||||
@SerializedName("SINGLE")
|
||||
SINGLE
|
||||
}
|
||||
|
||||
public enum PromotionType {
|
||||
@SerializedName("CASH")
|
||||
CASH,
|
||||
@SerializedName("NOCASH")
|
||||
NOCASH
|
||||
}
|
||||
|
||||
public static class GoodsDetail {
|
||||
@SerializedName("merchant_goods_id")
|
||||
public String merchantGoodsId;
|
||||
|
||||
@SerializedName("wechatpay_goods_id")
|
||||
public String wechatpayGoodsId;
|
||||
|
||||
@SerializedName("goods_name")
|
||||
public String goodsName;
|
||||
|
||||
@SerializedName("unit_price")
|
||||
public Long unitPrice;
|
||||
|
||||
@SerializedName("refund_amount")
|
||||
public Long refundAmount;
|
||||
|
||||
@SerializedName("refund_quantity")
|
||||
public Long refundQuantity;
|
||||
}
|
||||
|
||||
public enum Account {
|
||||
@SerializedName("AVAILABLE")
|
||||
AVAILABLE,
|
||||
@SerializedName("UNAVAILABLE")
|
||||
UNAVAILABLE
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,360 @@
|
||||
package com.java.demo;
|
||||
|
||||
import com.java.utils.WXPayUtility; // 引用微信支付工具库,参考:https://pay.weixin.qq.com/doc/v3/merchant/4014931831
|
||||
|
||||
import com.google.gson.annotations.SerializedName;
|
||||
import com.google.gson.annotations.Expose;
|
||||
import okhttp3.MediaType;
|
||||
import okhttp3.OkHttpClient;
|
||||
import okhttp3.Request;
|
||||
import okhttp3.RequestBody;
|
||||
import okhttp3.Response;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.io.UncheckedIOException;
|
||||
import java.security.PrivateKey;
|
||||
import java.security.PublicKey;
|
||||
import java.util.ArrayList;
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* 退款申请
|
||||
*
|
||||
* 支付成功后1年内,可通过此接口将款项全部或部分原路退还给用户(也可在商户平台手动操作)。
|
||||
*
|
||||
* 关键注意:
|
||||
* 1. 一笔订单最多50次部分退款,重试必须用原 out_refund_no,否则会重复退款。
|
||||
* 2. 接口返回成功仅表示受理成功,实际结果以退款回调通知或查询退款接口为准。
|
||||
* 3. 原路退还:银行卡1-3个工作日到账,零钱即时到账。
|
||||
* 4. 有代金券的订单部分退款时,退给用户 = 退款金额 × (实付 ÷ 总额),四舍五入。
|
||||
* 5. 有分账的订单,需确保可用余额充足;部分分账未解冻时需先调"完结分账"。
|
||||
*/
|
||||
public class CreateRefund {
|
||||
private static String HOST = "https://api.mch.weixin.qq.com";
|
||||
private static String METHOD = "POST";
|
||||
private static String PATH = "/v3/refund/domestic/refunds";
|
||||
|
||||
public static void main(String[] args) {
|
||||
// TODO: 请准备商户开发必要参数,参考:https://pay.weixin.qq.com/doc/v3/merchant/4013070756
|
||||
CreateRefund client = new CreateRefund(
|
||||
"19xxxxxxxx", // 商户号,是由微信支付系统生成并分配给每个商户的唯一标识符,商户号获取方式参考 https://pay.weixin.qq.com/doc/v3/merchant/4013070756
|
||||
"1DDE55AD98Exxxxxxxxxx", // 商户API证书序列号,如何获取请参考 https://pay.weixin.qq.com/doc/v3/merchant/4013053053
|
||||
"/path/to/apiclient_key.pem", // 商户API证书私钥文件路径,本地文件路径
|
||||
"PUB_KEY_ID_xxxxxxxxxxxxx", // 微信支付公钥ID,如何获取请参考 https://pay.weixin.qq.com/doc/v3/merchant/4013038816
|
||||
"/path/to/wxp_pub.pem" // 微信支付公钥文件路径,本地文件路径
|
||||
);
|
||||
|
||||
CreateRequest request = new CreateRequest();
|
||||
request.transactionId = "1217752501201407033233368018";
|
||||
request.outTradeNo = "1217752501201407033233368018";
|
||||
request.outRefundNo = "1217752501201407033233368018";
|
||||
request.reason = "商品已售完";
|
||||
request.notifyUrl = "https://weixin.qq.com";
|
||||
request.fundsAccount = ReqFundsAccount.AVAILABLE;
|
||||
request.amount = new AmountReq();
|
||||
request.amount.refund = 888L;
|
||||
request.amount.from = new ArrayList<>();
|
||||
{
|
||||
FundsFromItem fromItem = new FundsFromItem();
|
||||
fromItem.account = Account.AVAILABLE;
|
||||
fromItem.amount = 444L;
|
||||
request.amount.from.add(fromItem);
|
||||
};
|
||||
request.amount.total = 888L;
|
||||
request.amount.currency = "CNY";
|
||||
request.goodsDetail = new ArrayList<>();
|
||||
{
|
||||
GoodsDetail goodsDetailItem = new GoodsDetail();
|
||||
goodsDetailItem.merchantGoodsId = "1217752501201407033233368018";
|
||||
goodsDetailItem.wechatpayGoodsId = "1001";
|
||||
goodsDetailItem.goodsName = "iPhone6s 16G";
|
||||
goodsDetailItem.unitPrice = 528800L;
|
||||
goodsDetailItem.refundAmount = 528800L;
|
||||
goodsDetailItem.refundQuantity = 1L;
|
||||
request.goodsDetail.add(goodsDetailItem);
|
||||
};
|
||||
try {
|
||||
Refund response = client.run(request);
|
||||
// TODO: 请求成功,继续业务逻辑
|
||||
System.out.println(response);
|
||||
} catch (WXPayUtility.ApiException e) {
|
||||
// TODO: 请求失败,根据状态码执行不同的逻辑
|
||||
e.printStackTrace();
|
||||
}
|
||||
}
|
||||
|
||||
public Refund run(CreateRequest request) {
|
||||
String uri = PATH;
|
||||
String reqBody = WXPayUtility.toJson(request);
|
||||
|
||||
Request.Builder reqBuilder = new Request.Builder().url(HOST + uri);
|
||||
reqBuilder.addHeader("Accept", "application/json");
|
||||
reqBuilder.addHeader("Wechatpay-Serial", wechatPayPublicKeyId);
|
||||
reqBuilder.addHeader("Authorization", WXPayUtility.buildAuthorization(mchid, certificateSerialNo,privateKey, METHOD, uri, reqBody));
|
||||
reqBuilder.addHeader("Content-Type", "application/json");
|
||||
RequestBody requestBody = RequestBody.create(MediaType.parse("application/json; charset=utf-8"), reqBody);
|
||||
reqBuilder.method(METHOD, requestBody);
|
||||
Request httpRequest = reqBuilder.build();
|
||||
|
||||
// 发送HTTP请求
|
||||
OkHttpClient client = new OkHttpClient.Builder().build();
|
||||
try (Response httpResponse = client.newCall(httpRequest).execute()) {
|
||||
String respBody = WXPayUtility.extractBody(httpResponse);
|
||||
if (httpResponse.code() >= 200 && httpResponse.code() < 300) {
|
||||
// 2XX 成功,验证应答签名
|
||||
WXPayUtility.validateResponse(this.wechatPayPublicKeyId, this.wechatPayPublicKey,
|
||||
httpResponse.headers(), respBody);
|
||||
|
||||
// 从HTTP应答报文构建返回数据
|
||||
return WXPayUtility.fromJson(respBody, Refund.class);
|
||||
} else {
|
||||
throw new WXPayUtility.ApiException(httpResponse.code(), respBody, httpResponse.headers());
|
||||
}
|
||||
} catch (IOException e) {
|
||||
throw new UncheckedIOException("Sending request to " + uri + " failed.", e);
|
||||
}
|
||||
}
|
||||
|
||||
private final String mchid;
|
||||
private final String certificateSerialNo;
|
||||
private final PrivateKey privateKey;
|
||||
private final String wechatPayPublicKeyId;
|
||||
private final PublicKey wechatPayPublicKey;
|
||||
|
||||
public CreateRefund(String mchid, String certificateSerialNo, String privateKeyFilePath, String wechatPayPublicKeyId, String wechatPayPublicKeyFilePath) {
|
||||
this.mchid = mchid;
|
||||
this.certificateSerialNo = certificateSerialNo;
|
||||
this.privateKey = WXPayUtility.loadPrivateKeyFromPath(privateKeyFilePath);
|
||||
this.wechatPayPublicKeyId = wechatPayPublicKeyId;
|
||||
this.wechatPayPublicKey = WXPayUtility.loadPublicKeyFromPath(wechatPayPublicKeyFilePath);
|
||||
}
|
||||
|
||||
public static class CreateRequest {
|
||||
@SerializedName("transaction_id")
|
||||
public String transactionId;
|
||||
|
||||
@SerializedName("out_trade_no")
|
||||
public String outTradeNo;
|
||||
|
||||
@SerializedName("out_refund_no")
|
||||
public String outRefundNo;
|
||||
|
||||
@SerializedName("reason")
|
||||
public String reason;
|
||||
|
||||
@SerializedName("notify_url")
|
||||
public String notifyUrl;
|
||||
|
||||
@SerializedName("funds_account")
|
||||
public ReqFundsAccount fundsAccount;
|
||||
|
||||
@SerializedName("amount")
|
||||
public AmountReq amount;
|
||||
|
||||
@SerializedName("goods_detail")
|
||||
public List<GoodsDetail> goodsDetail;
|
||||
|
||||
}
|
||||
|
||||
public static class Refund {
|
||||
@SerializedName("refund_id")
|
||||
public String refundId;
|
||||
|
||||
@SerializedName("out_refund_no")
|
||||
public String outRefundNo;
|
||||
|
||||
@SerializedName("transaction_id")
|
||||
public String transactionId;
|
||||
|
||||
@SerializedName("out_trade_no")
|
||||
public String outTradeNo;
|
||||
|
||||
@SerializedName("channel")
|
||||
public Channel channel;
|
||||
|
||||
@SerializedName("user_received_account")
|
||||
public String userReceivedAccount;
|
||||
|
||||
@SerializedName("success_time")
|
||||
public String successTime;
|
||||
|
||||
@SerializedName("create_time")
|
||||
public String createTime;
|
||||
|
||||
@SerializedName("status")
|
||||
public Status status;
|
||||
|
||||
@SerializedName("funds_account")
|
||||
public FundsAccount fundsAccount;
|
||||
|
||||
@SerializedName("amount")
|
||||
public Amount amount;
|
||||
|
||||
@SerializedName("promotion_detail")
|
||||
public List<Promotion> promotionDetail;
|
||||
|
||||
}
|
||||
|
||||
public enum ReqFundsAccount {
|
||||
@SerializedName("AVAILABLE")
|
||||
AVAILABLE,
|
||||
@SerializedName("UNSETTLED")
|
||||
UNSETTLED
|
||||
}
|
||||
|
||||
public static class AmountReq {
|
||||
@SerializedName("refund")
|
||||
public Long refund;
|
||||
|
||||
@SerializedName("from")
|
||||
public List<FundsFromItem> from;
|
||||
|
||||
@SerializedName("total")
|
||||
public Long total;
|
||||
|
||||
@SerializedName("currency")
|
||||
public String currency;
|
||||
}
|
||||
|
||||
public static class GoodsDetail {
|
||||
@SerializedName("merchant_goods_id")
|
||||
public String merchantGoodsId;
|
||||
|
||||
@SerializedName("wechatpay_goods_id")
|
||||
public String wechatpayGoodsId;
|
||||
|
||||
@SerializedName("goods_name")
|
||||
public String goodsName;
|
||||
|
||||
@SerializedName("unit_price")
|
||||
public Long unitPrice;
|
||||
|
||||
@SerializedName("refund_amount")
|
||||
public Long refundAmount;
|
||||
|
||||
@SerializedName("refund_quantity")
|
||||
public Long refundQuantity;
|
||||
}
|
||||
|
||||
|
||||
public enum Channel {
|
||||
@SerializedName("ORIGINAL")
|
||||
ORIGINAL,
|
||||
@SerializedName("BALANCE")
|
||||
BALANCE,
|
||||
@SerializedName("OTHER_BALANCE")
|
||||
OTHER_BALANCE,
|
||||
@SerializedName("OTHER_BANKCARD")
|
||||
OTHER_BANKCARD
|
||||
}
|
||||
|
||||
public enum Status {
|
||||
@SerializedName("SUCCESS")
|
||||
SUCCESS,
|
||||
@SerializedName("CLOSED")
|
||||
CLOSED,
|
||||
@SerializedName("PROCESSING")
|
||||
PROCESSING,
|
||||
@SerializedName("ABNORMAL")
|
||||
ABNORMAL
|
||||
}
|
||||
|
||||
public enum FundsAccount {
|
||||
@SerializedName("UNSETTLED")
|
||||
UNSETTLED,
|
||||
@SerializedName("AVAILABLE")
|
||||
AVAILABLE,
|
||||
@SerializedName("UNAVAILABLE")
|
||||
UNAVAILABLE,
|
||||
@SerializedName("OPERATION")
|
||||
OPERATION,
|
||||
@SerializedName("BASIC")
|
||||
BASIC,
|
||||
@SerializedName("ECNY_BASIC")
|
||||
ECNY_BASIC
|
||||
}
|
||||
|
||||
public static class Amount {
|
||||
@SerializedName("total")
|
||||
public Long total;
|
||||
|
||||
@SerializedName("refund")
|
||||
public Long refund;
|
||||
|
||||
@SerializedName("from")
|
||||
public List<FundsFromItem> from;
|
||||
|
||||
@SerializedName("payer_total")
|
||||
public Long payerTotal;
|
||||
|
||||
@SerializedName("payer_refund")
|
||||
public Long payerRefund;
|
||||
|
||||
@SerializedName("settlement_refund")
|
||||
public Long settlementRefund;
|
||||
|
||||
@SerializedName("settlement_total")
|
||||
public Long settlementTotal;
|
||||
|
||||
@SerializedName("discount_refund")
|
||||
public Long discountRefund;
|
||||
|
||||
@SerializedName("currency")
|
||||
public String currency;
|
||||
|
||||
@SerializedName("refund_fee")
|
||||
public Long refundFee;
|
||||
}
|
||||
|
||||
public static class Promotion {
|
||||
@SerializedName("promotion_id")
|
||||
public String promotionId;
|
||||
|
||||
@SerializedName("scope")
|
||||
public PromotionScope scope;
|
||||
|
||||
@SerializedName("type")
|
||||
public PromotionType type;
|
||||
|
||||
@SerializedName("amount")
|
||||
public Long amount;
|
||||
|
||||
@SerializedName("refund_amount")
|
||||
public Long refundAmount;
|
||||
|
||||
@SerializedName("goods_detail")
|
||||
public List<GoodsDetail> goodsDetail;
|
||||
}
|
||||
|
||||
public static class FundsFromItem {
|
||||
@SerializedName("account")
|
||||
public Account account;
|
||||
|
||||
@SerializedName("amount")
|
||||
public Long amount;
|
||||
}
|
||||
|
||||
public enum PromotionScope {
|
||||
@SerializedName("GLOBAL")
|
||||
GLOBAL,
|
||||
@SerializedName("SINGLE")
|
||||
SINGLE
|
||||
}
|
||||
|
||||
public enum PromotionType {
|
||||
@SerializedName("CASH")
|
||||
CASH,
|
||||
@SerializedName("NOCASH")
|
||||
NOCASH
|
||||
}
|
||||
|
||||
public enum Account {
|
||||
@SerializedName("AVAILABLE")
|
||||
AVAILABLE,
|
||||
@SerializedName("UNAVAILABLE")
|
||||
UNAVAILABLE
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,286 @@
|
||||
package com.java.demo;
|
||||
|
||||
import com.java.utils.WXPayUtility; // 引用微信支付工具库,参考:https://pay.weixin.qq.com/doc/v3/merchant/4014931831
|
||||
|
||||
import com.google.gson.annotations.SerializedName;
|
||||
import com.google.gson.annotations.Expose;
|
||||
import okhttp3.MediaType;
|
||||
import okhttp3.OkHttpClient;
|
||||
import okhttp3.Request;
|
||||
import okhttp3.RequestBody;
|
||||
import okhttp3.Response;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.io.UncheckedIOException;
|
||||
import java.security.PrivateKey;
|
||||
import java.security.PublicKey;
|
||||
import java.util.ArrayList;
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* 查询单笔退款(通过商户退款单号)
|
||||
*
|
||||
* 提交退款申请后,推荐每间隔1分钟调用该接口查询一次退款状态,若超过5分钟仍是退款处理中状态,
|
||||
* 建议开始逐步衰减查询频率(比如之后间隔5分钟、10分钟、20分钟、30分钟……查询一次)。
|
||||
*
|
||||
* 退款有一定延时,零钱支付的订单退款一般5分钟内到账,银行卡支付的订单退款一般1-3个工作日到账。
|
||||
*
|
||||
* 同一商户号查询退款频率限制为300qps,如返回FREQUENCY_LIMITED频率限制报错可间隔1分钟再重试查询。
|
||||
*/
|
||||
public class QueryByOutRefundNo {
|
||||
private static String HOST = "https://api.mch.weixin.qq.com";
|
||||
private static String METHOD = "GET";
|
||||
private static String PATH = "/v3/refund/domestic/refunds/{out_refund_no}";
|
||||
|
||||
public static void main(String[] args) {
|
||||
// TODO: 请准备商户开发必要参数,参考:https://pay.weixin.qq.com/doc/v3/merchant/4013070756
|
||||
QueryByOutRefundNo client = new QueryByOutRefundNo(
|
||||
"19xxxxxxxx", // 商户号,是由微信支付系统生成并分配给每个商户的唯一标识符,商户号获取方式参考 https://pay.weixin.qq.com/doc/v3/merchant/4013070756
|
||||
"1DDE55AD98Exxxxxxxxxx", // 商户API证书序列号,如何获取请参考 https://pay.weixin.qq.com/doc/v3/merchant/4013053053
|
||||
"/path/to/apiclient_key.pem", // 商户API证书私钥文件路径,本地文件路径
|
||||
"PUB_KEY_ID_xxxxxxxxxxxxx", // 微信支付公钥ID,如何获取请参考 https://pay.weixin.qq.com/doc/v3/merchant/4013038816
|
||||
"/path/to/wxp_pub.pem" // 微信支付公钥文件路径,本地文件路径
|
||||
);
|
||||
|
||||
QueryByOutRefundNoRequest request = new QueryByOutRefundNoRequest();
|
||||
request.outRefundNo = "1217752501201407033233368018";
|
||||
try {
|
||||
Refund response = client.run(request);
|
||||
// TODO: 请求成功,继续业务逻辑
|
||||
System.out.println(response);
|
||||
} catch (WXPayUtility.ApiException e) {
|
||||
// TODO: 请求失败,根据状态码执行不同的逻辑
|
||||
e.printStackTrace();
|
||||
}
|
||||
}
|
||||
|
||||
public Refund run(QueryByOutRefundNoRequest request) {
|
||||
String uri = PATH;
|
||||
uri = uri.replace("{out_refund_no}", WXPayUtility.urlEncode(request.outRefundNo));
|
||||
Request.Builder reqBuilder = new Request.Builder().url(HOST + uri);
|
||||
reqBuilder.addHeader("Accept", "application/json");
|
||||
reqBuilder.addHeader("Wechatpay-Serial", wechatPayPublicKeyId);
|
||||
reqBuilder.addHeader("Authorization", WXPayUtility.buildAuthorization(mchid, certificateSerialNo, privateKey, METHOD, uri, null));
|
||||
reqBuilder.method(METHOD, null);
|
||||
Request httpRequest = reqBuilder.build();
|
||||
|
||||
// 发送HTTP请求
|
||||
OkHttpClient client = new OkHttpClient.Builder().build();
|
||||
try (Response httpResponse = client.newCall(httpRequest).execute()) {
|
||||
String respBody = WXPayUtility.extractBody(httpResponse);
|
||||
if (httpResponse.code() >= 200 && httpResponse.code() < 300) {
|
||||
// 2XX 成功,验证应答签名
|
||||
WXPayUtility.validateResponse(this.wechatPayPublicKeyId, this.wechatPayPublicKey,
|
||||
httpResponse.headers(), respBody);
|
||||
|
||||
// 从HTTP应答报文构建返回数据
|
||||
return WXPayUtility.fromJson(respBody, Refund.class);
|
||||
} else {
|
||||
throw new WXPayUtility.ApiException(httpResponse.code(), respBody, httpResponse.headers());
|
||||
}
|
||||
} catch (IOException e) {
|
||||
throw new UncheckedIOException("Sending request to " + uri + " failed.", e);
|
||||
}
|
||||
}
|
||||
|
||||
private final String mchid;
|
||||
private final String certificateSerialNo;
|
||||
private final PrivateKey privateKey;
|
||||
private final String wechatPayPublicKeyId;
|
||||
private final PublicKey wechatPayPublicKey;
|
||||
|
||||
public QueryByOutRefundNo(String mchid, String certificateSerialNo, String privateKeyFilePath, String wechatPayPublicKeyId, String wechatPayPublicKeyFilePath) {
|
||||
this.mchid = mchid;
|
||||
this.certificateSerialNo = certificateSerialNo;
|
||||
this.privateKey = WXPayUtility.loadPrivateKeyFromPath(privateKeyFilePath);
|
||||
this.wechatPayPublicKeyId = wechatPayPublicKeyId;
|
||||
this.wechatPayPublicKey = WXPayUtility.loadPublicKeyFromPath(wechatPayPublicKeyFilePath);
|
||||
}
|
||||
|
||||
public static class QueryByOutRefundNoRequest {
|
||||
@SerializedName("out_refund_no")
|
||||
@Expose(serialize = false)
|
||||
public String outRefundNo;
|
||||
|
||||
}
|
||||
|
||||
public static class Refund {
|
||||
@SerializedName("refund_id")
|
||||
public String refundId;
|
||||
|
||||
@SerializedName("out_refund_no")
|
||||
public String outRefundNo;
|
||||
|
||||
@SerializedName("transaction_id")
|
||||
public String transactionId;
|
||||
|
||||
@SerializedName("out_trade_no")
|
||||
public String outTradeNo;
|
||||
|
||||
@SerializedName("channel")
|
||||
public Channel channel;
|
||||
|
||||
@SerializedName("user_received_account")
|
||||
public String userReceivedAccount;
|
||||
|
||||
@SerializedName("success_time")
|
||||
public String successTime;
|
||||
|
||||
@SerializedName("create_time")
|
||||
public String createTime;
|
||||
|
||||
@SerializedName("status")
|
||||
public Status status;
|
||||
|
||||
@SerializedName("funds_account")
|
||||
public FundsAccount fundsAccount;
|
||||
|
||||
@SerializedName("amount")
|
||||
public Amount amount;
|
||||
|
||||
@SerializedName("promotion_detail")
|
||||
public List<Promotion> promotionDetail;
|
||||
|
||||
}
|
||||
|
||||
public enum Channel {
|
||||
@SerializedName("ORIGINAL")
|
||||
ORIGINAL,
|
||||
@SerializedName("BALANCE")
|
||||
BALANCE,
|
||||
@SerializedName("OTHER_BALANCE")
|
||||
OTHER_BALANCE,
|
||||
@SerializedName("OTHER_BANKCARD")
|
||||
OTHER_BANKCARD
|
||||
}
|
||||
|
||||
public enum Status {
|
||||
@SerializedName("SUCCESS")
|
||||
SUCCESS,
|
||||
@SerializedName("CLOSED")
|
||||
CLOSED,
|
||||
@SerializedName("PROCESSING")
|
||||
PROCESSING,
|
||||
@SerializedName("ABNORMAL")
|
||||
ABNORMAL
|
||||
}
|
||||
|
||||
public enum FundsAccount {
|
||||
@SerializedName("UNSETTLED")
|
||||
UNSETTLED,
|
||||
@SerializedName("AVAILABLE")
|
||||
AVAILABLE,
|
||||
@SerializedName("UNAVAILABLE")
|
||||
UNAVAILABLE,
|
||||
@SerializedName("OPERATION")
|
||||
OPERATION,
|
||||
@SerializedName("BASIC")
|
||||
BASIC,
|
||||
@SerializedName("ECNY_BASIC")
|
||||
ECNY_BASIC
|
||||
}
|
||||
|
||||
public static class Amount {
|
||||
@SerializedName("total")
|
||||
public Long total;
|
||||
|
||||
@SerializedName("refund")
|
||||
public Long refund;
|
||||
|
||||
@SerializedName("from")
|
||||
public List<FundsFromItem> from;
|
||||
|
||||
@SerializedName("payer_total")
|
||||
public Long payerTotal;
|
||||
|
||||
@SerializedName("payer_refund")
|
||||
public Long payerRefund;
|
||||
|
||||
@SerializedName("settlement_refund")
|
||||
public Long settlementRefund;
|
||||
|
||||
@SerializedName("settlement_total")
|
||||
public Long settlementTotal;
|
||||
|
||||
@SerializedName("discount_refund")
|
||||
public Long discountRefund;
|
||||
|
||||
@SerializedName("currency")
|
||||
public String currency;
|
||||
|
||||
@SerializedName("refund_fee")
|
||||
public Long refundFee;
|
||||
}
|
||||
|
||||
public static class Promotion {
|
||||
@SerializedName("promotion_id")
|
||||
public String promotionId;
|
||||
|
||||
@SerializedName("scope")
|
||||
public PromotionScope scope;
|
||||
|
||||
@SerializedName("type")
|
||||
public PromotionType type;
|
||||
|
||||
@SerializedName("amount")
|
||||
public Long amount;
|
||||
|
||||
@SerializedName("refund_amount")
|
||||
public Long refundAmount;
|
||||
|
||||
@SerializedName("goods_detail")
|
||||
public List<GoodsDetail> goodsDetail;
|
||||
}
|
||||
|
||||
public static class FundsFromItem {
|
||||
@SerializedName("account")
|
||||
public Account account;
|
||||
|
||||
@SerializedName("amount")
|
||||
public Long amount;
|
||||
}
|
||||
|
||||
public enum PromotionScope {
|
||||
@SerializedName("GLOBAL")
|
||||
GLOBAL,
|
||||
@SerializedName("SINGLE")
|
||||
SINGLE
|
||||
}
|
||||
|
||||
public enum PromotionType {
|
||||
@SerializedName("CASH")
|
||||
CASH,
|
||||
@SerializedName("NOCASH")
|
||||
NOCASH
|
||||
}
|
||||
|
||||
public static class GoodsDetail {
|
||||
@SerializedName("merchant_goods_id")
|
||||
public String merchantGoodsId;
|
||||
|
||||
@SerializedName("wechatpay_goods_id")
|
||||
public String wechatpayGoodsId;
|
||||
|
||||
@SerializedName("goods_name")
|
||||
public String goodsName;
|
||||
|
||||
@SerializedName("unit_price")
|
||||
public Long unitPrice;
|
||||
|
||||
@SerializedName("refund_amount")
|
||||
public Long refundAmount;
|
||||
|
||||
@SerializedName("refund_quantity")
|
||||
public Long refundQuantity;
|
||||
}
|
||||
|
||||
public enum Account {
|
||||
@SerializedName("AVAILABLE")
|
||||
AVAILABLE,
|
||||
@SerializedName("UNAVAILABLE")
|
||||
UNAVAILABLE
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
# 支付成功回调通知
|
||||
|
||||
## 回调描述
|
||||
|
||||
用户使用普通支付(APP支付/H5支付/JSAPI支付/Native支付/小程序支付)功能,当用户成功支付订单后,微信支付会通过POST的请求方式,向商户预先设置的回调地址(APP支付/H5支付/JSAPI支付/Native支付/小程序支付下单接口传入的notify_url)发送回调通知,让商户知晓用户已完成支付。
|
||||
|
||||
> **注意**:商户侧对微信支付回调IP有防火墙策略限制的,需要对微信回调IP段开通白名单,否则会导致收不到回调(微信支付回调被商户防火墙拦截),详情参考回调处理逻辑注意事项。
|
||||
|
||||
## 回调报文格式
|
||||
|
||||
微信支付会通过POST的方式向回调地址发送回调报文,回调通知的请求主体中会包含JSON格式的通知参数:
|
||||
|
||||
```json
|
||||
{
|
||||
"id": "EV-2018022511223320873",
|
||||
"create_time": "2015-05-20T13:29:35+08:00",
|
||||
"resource_type": "encrypt-resource",
|
||||
"event_type": "TRANSACTION.SUCCESS",
|
||||
"summary": "支付成功",
|
||||
"resource": {
|
||||
"original_type": "transaction",
|
||||
"algorithm": "AEAD_AES_256_GCM",
|
||||
"ciphertext": "",
|
||||
"associated_data": "",
|
||||
"nonce": ""
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## 关键字段说明
|
||||
|
||||
| 字段 | 说明 |
|
||||
|------|------|
|
||||
| `event_type` | 事件类型,支付成功为 `TRANSACTION.SUCCESS` |
|
||||
| `resource.algorithm` | 加密算法,固定为 `AEAD_AES_256_GCM` |
|
||||
| `resource.ciphertext` | 密文,需使用商户APIv3密钥解密后得到支付订单详情 |
|
||||
| `resource.associated_data` | 附加数据,解密时作为 AAD 参数 |
|
||||
| `resource.nonce` | 随机串,解密时作为 Nonce 参数 |
|
||||
|
||||
## 处理要求
|
||||
|
||||
1. 接收到回调后,先验证请求签名(使用微信支付公钥验签)
|
||||
2. 使用商户APIv3密钥 + AEAD_AES_256_GCM 解密 `resource.ciphertext` 得到订单明文
|
||||
3. 处理完成后返回 HTTP 200 + `{"code": "SUCCESS", "message": "成功"}` 表示确认收到
|
||||
4. 若返回非200或超时,微信支付会按策略重试通知
|
||||
@@ -0,0 +1,104 @@
|
||||
# 基础支付 & 合单支付示例代码接口索引
|
||||
|
||||
> 根据用户确认的开发语言,加载对应语言目录下的文件。Java 和 Go 目录结构一致。
|
||||
|
||||
## 下单(需确认支付方式)
|
||||
|
||||
| 支付方式 | 接口 | Java | Go |
|
||||
|---------|------|------|-----|
|
||||
| JSAPI/小程序 | POST /v3/pay/transactions/jsapi | `Java/1-JSAPI支付/JsapiPrepay.java` | `Go/1-JSAPI支付/JsapiPrepay.go` |
|
||||
| APP | POST /v3/pay/transactions/app | `Java/2-APP支付/AppPrepay.java` | `Go/2-APP支付/AppPrepay.go` |
|
||||
| H5 | POST /v3/pay/transactions/h5 | `Java/3-H5支付/H5Prepay.java` | `Go/3-H5支付/H5Prepay.go` |
|
||||
| Native | POST /v3/pay/transactions/native | `Java/4-Native支付/NativePrepay.java` | `Go/4-Native支付/NativePrepay.go` |
|
||||
|
||||
## 调起支付(需确认支付方式,前端/客户端集成参考)
|
||||
|
||||
| 支付方式 | 调起方式 | Java |
|
||||
|---------|---------|------|
|
||||
| JSAPI | WeixinJSBridge | `Java/1-JSAPI支付/JsapiInvoke.md` |
|
||||
| 小程序 | wx.requestPayment | `Java/5-小程序支付/MiniProgramInvoke.md` |
|
||||
| APP | OpenSDK | `Java/2-APP支付/AppInvoke.md` |
|
||||
| H5 | 跳转h5_url | `Java/3-H5支付/H5Invoke.md` |
|
||||
| Native | code_url转二维码 | `Java/4-Native支付/NativeInvoke.md` |
|
||||
|
||||
## 通用接口(无需确认支付方式,各支付方式完全相同)
|
||||
|
||||
| 业务 | 接口 | Java | Go |
|
||||
|------|------|------|-----|
|
||||
| 微信订单号查单 | GET /v3/pay/transactions/id/{transaction_id} | `Java/6-订单查询/QueryByWxTradeNo.java` | `Go/6-订单查询/QueryByWxTradeNo.go` |
|
||||
| 商户订单号查单 | GET /v3/pay/transactions/out-trade-no/{out_trade_no} | `Java/6-订单查询/QueryByOutTradeNo.java` | `Go/6-订单查询/QueryByOutTradeNo.go` |
|
||||
| 关闭订单 | POST /v3/pay/transactions/out-trade-no/{out_trade_no}/close | `Java/7-关闭订单/CloseOrder.java` | `Go/7-关闭订单/CloseOrder.go` |
|
||||
| 退款申请 | POST /v3/refund/domestic/refunds | `Java/8-订单退款/CreateRefund.java` | `Go/8-订单退款/CreateRefund.go` |
|
||||
| 查询退款 | GET /v3/refund/domestic/refunds/{out_refund_no} | `Java/8-订单退款/QueryByOutRefundNo.java` | `Go/8-订单退款/QueryByOutRefundNo.go` |
|
||||
| 异常退款 | POST /v3/refund/domestic/refunds/{refund_id}/apply-abnormal-refund | `Java/8-订单退款/CreateAbnormalRefund.java` | `Go/8-订单退款/CreateAbnormalRefund.go` |
|
||||
| 支付回调通知 | 回调报文格式与处理要求 | `Java/9-支付回调通知/支付成功回调通知说明.md` | — |
|
||||
| 退款回调通知 | 回调报文格式与处理要求 | `Java/10-退款结果回调通知/退款结果回调通知说明.md` | — |
|
||||
| 申请交易账单 | GET /v3/bill/tradebill | `Java/11-申请交易账单/GetTradeBill.java` | `Go/11-申请交易账单/GetTradeBill.go` |
|
||||
| 申请资金账单 | GET /v3/bill/fundflowbill | `Java/12-申请资金账单/GetFundFlowBill.java` | `Go/12-申请资金账单/GetFundFlowBill.go` |
|
||||
| 下载账单 | GET download_url | `Java/13-下载账单/DownloadBill.java` | — |
|
||||
|
||||
---
|
||||
|
||||
## 合单支付下单(需确认支付方式)
|
||||
|
||||
> 合单支付不是独立的支付方式,而是将多个子订单合并为一笔支付的模式。下单 API 路径为 `/v3/combine-transactions/{type}`,与基础支付不同。
|
||||
|
||||
| 支付方式 | 接口 | Java | Go |
|
||||
|---------|------|------|-----|
|
||||
| JSAPI/小程序 | POST /v3/combine-transactions/jsapi | `Java/15-合单支付/UnionJsapiPrepay.java` | `Go/15-合单支付/UnionJsapiPrepay.go` |
|
||||
| APP | POST /v3/combine-transactions/app | `Java/15-合单支付/UnionAppPrepay.java` | `Go/15-合单支付/UnionAppPrepay.go` |
|
||||
| H5 | POST /v3/combine-transactions/h5 | `Java/15-合单支付/UnionH5Prepay.java` | `Go/15-合单支付/UnionH5Prepay.go` |
|
||||
| Native | POST /v3/combine-transactions/native | `Java/15-合单支付/UnionNativePrepay.java` | `Go/15-合单支付/UnionNativePrepay.go` |
|
||||
|
||||
> 合单支付的**调起支付**与基础支付完全一致(返回的 `prepay_id` / `h5_url` / `code_url` 用法相同),直接复用上方「调起支付」中的文件即可。
|
||||
|
||||
## 合单支付专用接口(与基础支付不同,必须使用合单专用接口)
|
||||
|
||||
> 合单订单的查单和关单**不能**使用基础支付的查单/关单接口,必须使用以下合单专用接口。
|
||||
|
||||
| 业务 | 接口 | Java | Go |
|
||||
|------|------|------|-----|
|
||||
| 查询合单订单 | GET /v3/combine-transactions/out-trade-no/{combine_out_trade_no} | `Java/15-合单支付/UnionQueryByOutTradeNo.java` | `Go/15-合单支付/UnionQueryByOutTradeNo.go` |
|
||||
| 关闭合单订单 | POST /v3/combine-transactions/out-trade-no/{combine_out_trade_no}/close | `Java/15-合单支付/UnionClose.java` | `Go/15-合单支付/UnionClose.go` |
|
||||
| 合单支付回调通知 | 回调报文格式与处理要求 | `Java/15-合单支付/合单支付成功回调通知说明.md` | — |
|
||||
|
||||
## 合单支付复用基础支付的接口
|
||||
|
||||
> 以下接口合单支付与基础支付完全相同,按**子单维度**操作。退款时使用子单的 `transaction_id`,账单以子单维度记录在各子单商户账单内。
|
||||
|
||||
| 业务 | 接口 | 参考文件 |
|
||||
|------|------|---------|
|
||||
| 退款申请 | POST /v3/refund/domestic/refunds | `Java/8-订单退款/CreateRefund.java`、`Go/8-订单退款/CreateRefund.go` |
|
||||
| 查询退款 | GET /v3/refund/domestic/refunds/{out_refund_no} | `Java/8-订单退款/QueryByOutRefundNo.java`、`Go/8-订单退款/QueryByOutRefundNo.go` |
|
||||
| 异常退款 | POST /v3/refund/domestic/refunds/{refund_id}/apply-abnormal-refund | `Java/8-订单退款/CreateAbnormalRefund.java`、`Go/8-订单退款/CreateAbnormalRefund.go` |
|
||||
| 退款回调通知 | 回调报文格式与处理要求 | `Java/10-退款结果回调通知/退款结果回调通知说明.md` |
|
||||
| 申请交易账单 | GET /v3/bill/tradebill | `Java/11-申请交易账单/GetTradeBill.java`、`Go/11-申请交易账单/GetTradeBill.go` |
|
||||
| 申请资金账单 | GET /v3/bill/fundflowbill | `Java/12-申请资金账单/GetFundFlowBill.java`、`Go/12-申请资金账单/GetFundFlowBill.go` |
|
||||
| 下载账单 | GET download_url | `Java/13-下载账单/DownloadBill.java` |
|
||||
|
||||
## 16. 分账
|
||||
|
||||
| 接口 | Java | Go |
|
||||
|------|------|------|
|
||||
| 请求分账 | `Java/16-分账/CreateOrder.java` | `Go/16-分账/CreateOrder.go` |
|
||||
| 查询分账结果 | `Java/16-分账/QueryOrder.java` | `Go/16-分账/QueryOrder.go` |
|
||||
| 请求分账回退 | `Java/16-分账/CreateReturnOrder.java` | `Go/16-分账/CreateReturnOrder.go` |
|
||||
| 查询分账回退结果 | `Java/16-分账/QueryReturnOrder.java` | `Go/16-分账/QueryReturnOrder.go` |
|
||||
| 解冻剩余资金 | `Java/16-分账/UnfreezeOrder.java` | `Go/16-分账/UnfreezeOrder.go` |
|
||||
| 查询剩余待分金额 | `Java/16-分账/QueryOrderAmount.java` | `Go/16-分账/QueryOrderAmount.go` |
|
||||
| 添加分账接收方 | `Java/16-分账/AddReceiver.java` | `Go/16-分账/AddReceiver.go` |
|
||||
| 删除分账接收方 | `Java/16-分账/DeleteReceiver.java` | `Go/16-分账/DeleteReceiver.go` |
|
||||
| 申请分账账单 | `Java/16-分账/SplitBill.java` | `Go/16-分账/SplitBill.go` |
|
||||
|
||||
> 分账规则、API 列表见 `3-商户与服务商通用/接入指南/分账接入指南.md`。
|
||||
|
||||
## SDK工具类(所有接口的公共依赖)
|
||||
|
||||
> 所有示例代码都依赖此工具类,提供签名、验签、加解密、HTTP请求等基础能力。提醒用户需一并集成。
|
||||
|
||||
| 语言 | 文件 |
|
||||
|------|------|
|
||||
| Java | `Java/14-SDK工具类/WXPayUtility.java` — 签名、验签、加解密等基础能力 |
|
||||
| Java | `Java/14-SDK工具类/WXPayClient.java` — HTTP 客户端,封装请求签名→发送→验签流程 |
|
||||
| Go | `Go/14-SDK工具类/wxpay_utility.go` — 签名、验签、加解密等基础能力 |
|
||||
| Go | `Go/14-SDK工具类/wxpay_client.go` — HTTP 客户端,封装请求签名→发送→验签流程 |
|
||||
@@ -0,0 +1,130 @@
|
||||
# 商户开户意愿确认
|
||||
|
||||
商户开户意愿确认是微信支付的风控要求,商户入网后需完成开户意愿确认,确保商户主体名称不被冒用。渠道商(银行、支付机构、普通服务商)可通过两种方式协助商户完成确认。
|
||||
|
||||
## 两种确认方式
|
||||
|
||||
| 方式 | 说明 |
|
||||
|------|------|
|
||||
| "商家注册"小程序 | 渠道商下载拓展二维码,商家扫码在小程序中提交资料并完成确认 |
|
||||
| 商户开户意愿确认 API | 渠道商通过 API 提交商家资料,联系人扫小程序码确认信息并完成验证 |
|
||||
|
||||
> API 方式仅减少商家在小程序提交资料的环节,联系人仍需扫描小程序码进行确认。
|
||||
|
||||
## API 流程
|
||||
|
||||
```
|
||||
1. 渠道商获取商户号(通过进件或已有商户号)
|
||||
|
||||
2. 渠道商创建开户意愿申请单
|
||||
├── 调用「提交申请单」接口,提交商家资料 + 商户号
|
||||
└── 在补充材料 - 待确认商户号列表中传入商户号,联系人确认时可同步完成授权
|
||||
|
||||
3. 微信支付审核(1-3 个工作日)
|
||||
├── 审核通过 → 状态变为"待联系人确认"
|
||||
└── 审核驳回 → 根据驳回原因修改资料,调用「撤销申请单」后重新提交
|
||||
|
||||
4. 联系人确认(扫描申请单小程序码)
|
||||
├── 核对联系信息(可修改手机号),完成短信验证
|
||||
└── 核实商家资质信息,点击确认
|
||||
|
||||
5. 商家授权商户号
|
||||
├── 若创建申请单时已传入商户号列表 → 联系人在确认流程中直接授权
|
||||
└── 若未传入 → 联系人后续通过小程序码或公众号消息进入授权页面
|
||||
⚠️ 只有商户全称与注册商家名称一致的商户号才能授权
|
||||
|
||||
6. 账户验证(按主体类型和联系人身份判断)
|
||||
├── 个体工商户(法人/经营者)→ 无需验证
|
||||
├── 个体工商户(负责人)→ 法人扫码验证
|
||||
├── 企业(法人/经营者)→ 无需验证
|
||||
├── 企业(负责人)→ 汇款验证或法人扫码(10天有效)
|
||||
├── 党政/事业单位 → 无需验证
|
||||
├── 其他组织 → 汇款验证(10天有效)
|
||||
└── 小微 → 法人/经营者验证
|
||||
|
||||
7. 渠道商确认完成
|
||||
├── 查询申请单状态为"审核通过"(APPLYMENT_STATE_PASSED)
|
||||
└── 查询授权状态为"已授权"
|
||||
```
|
||||
|
||||
## API 接口
|
||||
|
||||
| 接口 | 方法 | 路径 | 备注 |
|
||||
|------|------|------|------|
|
||||
| 提交申请单 | POST | `/v3/apply4subject/applyment/` | 频率限制:5QPS(商户号维度) |
|
||||
| 查询申请单审核结果 | GET | `/v3/apply4subject/applyment` | 通过 applyment_id 或 business_code 查询,频率 20QPS |
|
||||
| 撤销申请单 | POST | `/v3/apply4subject/applyment/{business_code}/cancel` 或 `{applyment_id}/cancel` | 驳回后修改前须先撤销 |
|
||||
| 获取商户开户意愿确认状态 | GET | `/v3/apply4subject/applyment/merchants/{sub_mchid}/state` | 确认商户是否已完成授权 |
|
||||
|
||||
> 敏感信息加密:联系人姓名、手机号、身份证号等敏感字段须使用微信支付公钥(推荐)或平台证书公钥加密,请求 Header 须携带 `Wechatpay-Serial`。
|
||||
|
||||
## 申请单状态流转
|
||||
|
||||
| 状态 | 含义 | 后续操作 |
|
||||
|------|------|---------|
|
||||
| APPLYMENT_STATE_EDITTING | 编辑中 | 提交可能发生错误,可用同一 business_code 重新提交 |
|
||||
| APPLYMENT_STATE_WAITTING_FOR_AUDIT | 审核中 | 等待 1-2 个工作日 |
|
||||
| APPLYMENT_STATE_WAITTING_FOR_CONFIRM_CONTACT | 待联系人确认 | 获取小程序码,引导联系人扫码确认联系信息 |
|
||||
| APPLYMENT_STATE_WAITTING_FOR_CONFIRM_LEGALPERSON | 待账户验证 | 引导联系人扫码完成账户验证 |
|
||||
| APPLYMENT_STATE_PASSED | 审核通过(终态) | 引导联系人扫码完成授权商户号 |
|
||||
| APPLYMENT_STATE_REJECTED | 审核驳回(终态) | 根据驳回原因修改,调用撤销接口后重新提交 |
|
||||
| APPLYMENT_STATE_FREEZED | 已冻结(终态) | 主体已完成过入驻,查看驳回原因,通知指定联系人扫码完成授权 |
|
||||
| APPLYMENT_STATE_CANCELED | 已作废(终态) | 申请单已被撤销或超 720 小时未完成 |
|
||||
|
||||
## 与特约商户进件的关系
|
||||
|
||||
| 维度 | 特约商户进件 | 商户开户意愿确认 |
|
||||
|------|-----------|----------------|
|
||||
| 目的 | 商户入驻微信支付,获取商户号 | 确认商户开户意愿,防止主体被冒用 |
|
||||
| 使用方 | 普通服务商 | 渠道商(银行、支付机构、普通服务商) |
|
||||
| API 前缀 | `/v3/applyment4sub/` | `/v3/apply4subject/` |
|
||||
| 是否必须 | 服务商拓展子商户时必须 | 根据风控要求,部分商户入网后需完成 |
|
||||
| 流程 | 服务商提交全部资料 → 审核 → 签约 | 渠道商提交基础资料 → 审核 → 联系人扫码确认 |
|
||||
|
||||
## 重要参数说明
|
||||
|
||||
| 参数 | 说明 |
|
||||
|------|------|
|
||||
| `business_code` | 业务申请编号,服务商自定义唯一编号,每个编号对应一个申请单 |
|
||||
| `subject_type` | 主体类型枚举:`SUBJECT_TYPE_ENTERPRISE`(企业)、`SUBJECT_TYPE_INDIVIDUAL`(个体工商户)、`SUBJECT_TYPE_INSTITUTIONS_CLONED`(事业单位)、`SUBJECT_TYPE_OTHERS`(其他组织)、`SUBJECT_TYPE_MICRO`(小微商户) |
|
||||
| `licence_number` | 营业执照注册号,个体工商户或企业须为 15 位数字或 18 位数字/大写字母 |
|
||||
| `merchant_name` | 商户名称须与营业执照一致;个体工商户不能以"公司"结尾;营业执照名称为空时填"个体户+经营者姓名" |
|
||||
| `confirm_mchid_list` | 补充材料中的待确认商户号列表,传入后联系人确认时可同步完成授权,无需二次授权 |
|
||||
| 敏感字段 | 联系人姓名、手机号、身份证号须加密,Header 须携带 `Wechatpay-Serial` |
|
||||
|
||||
## 常见问题
|
||||
|
||||
### 业务规则
|
||||
|
||||
| 问题 | 答案 |
|
||||
|------|------|
|
||||
| 一个微信号可同时提交多个申请单吗? | 不可以,一个微信只能有一个流程中的申请单 |
|
||||
| 申请单有效期多长? | 720 小时(30 天),超期自动作废 |
|
||||
| 账户验证需要多长时间? | 汇款后 2 小时内获知结果;法人扫码后立即完成 |
|
||||
| 审核驳回后怎么重新提交? | 须先调用「撤销申请单」接口,再用新的 business_code 重新提交 |
|
||||
| 商户名称有什么要求? | 必须与营业执照上的商户名称一致 |
|
||||
| "审核通过"是最终状态吗? | 是,"审核通过""已冻结""已作废"均为终态 |
|
||||
| 联系人微信号有什么要求? | 联系人微信实名的身份证号需与申请单填写的联系人身份证号一致 |
|
||||
| 什么是"已冻结"状态? | 该主体已完成过入驻,需查看驳回原因,通知指定联系人扫码完成授权 |
|
||||
| 进行开户意愿确认前是否需要先绑定 sub_mchid 与 sub_appid 的关系? | 不需要,需要先完成开户意愿确认 |
|
||||
| 查询申请单审核结果接口返回的 qrcode_data(小程序码图片)有效期是多久? | 有效期 30 天,超过 30 天后需要先撤销申请单,再重新提交 |
|
||||
| 申请单状态为 APPLYMENT_STATE_PASSED 但返回的二维码已过期怎么办? | 二维码有效期 30 天,过期后可撤销申请单再重新提交,也可调用「获取商户开户意愿确认状态」接口查询商户是否已完成授权 |
|
||||
| 授权完成后,申请单返回的二维码还有使用场景吗? | 已完成授权后,二维码一般不再有使用场景 |
|
||||
| 商户名称中的"特殊符号"怎么定义? | @#$%° 等均算特殊字符;商户名称不能使用纯数字或纯特殊字符,但名称中可以包含数字和特殊字符,按照营业执照上的正确名称填写即可 |
|
||||
| 商户名称字段规则中"不能为纯数字"和"可以包含数字"是否冲突? | 不冲突,规则含义是商户名称不能是纯数字或纯特殊字符,但名称中允许包含数字和特殊字符 |
|
||||
| 获取商户开户意愿确认状态接口有限频吗? | 有,限频为 10QPS |
|
||||
|
||||
### 常见报错
|
||||
|
||||
| 报错信息 | 原因与解决 |
|
||||
|---------|-----------|
|
||||
| "暂未查询到该营业执照注册号" | `licence_number` 填写错误,个体工商户/企业须为 15 位数字或 18 位数字/大写字母 |
|
||||
| "系统繁忙,请稍后重试" | ① 系统异常稍后重试;② 参数大小写/格式与文档不一致;③ 请求头 `mchid` 填写错误 |
|
||||
| "查询申请单不存在" | `applyment_id` 填写错误或未填写,申请单编号和业务申请编号至少传一个 |
|
||||
| "Authorization不合法" | ① 认证类型须为 `WECHATPAY2-SHA256-RSA2048`;② `mchid` 须为渠道商商户号;③ 检查证书序列号、签名值;④ Authorization 头不能有换行 |
|
||||
| "商户未申请过证书" | 须到商户平台申请下载 API 证书并正确使用 |
|
||||
| "无法将传入参数'申请单编号'转换为uint64类型" | `applyment_id` 参数类型错误,须为 uint64 |
|
||||
| "注册号/证书号填写错误或与证书类型不匹配" | 检查 `licence_number` 或 `cert_number` 是否与所选的证书类型(`cert_type`)匹配,确认格式和内容正确 |
|
||||
| "生成小程序二维码失败,请稍后再试[40001]" | 已知偶发问题,直接重试即可 |
|
||||
| "请填写有效身份证居住地址" | `identification_address` 字段填写有误,需填写身份证上的有效居住地址 |
|
||||
| "请选择门店所在省市区范围" | `store_address_code` 字段填写有误,需选择正确的门店所在省市区行政区划代码 |
|
||||
@@ -0,0 +1,80 @@
|
||||
# 开发必要参数说明(服务商模式)
|
||||
|
||||
> 两种接入模式的参数、API路径等核心差异见 `接入模式说明.md`,本文仅补充服务商模式的落地细节。
|
||||
> 未配置 `sub_appid` 时只能传 `openid`(即 sp_appid 下的用户标识)。
|
||||
|
||||
## 权限申请(各支付方式流程一致)
|
||||
|
||||
1. **服务商申请开通**:登录服务商平台 → 产品中心 → 特约商户授权产品 → 对应支付产品 → 申请开通(审核 7 个工作日)
|
||||
2. **子商户授权**:服务商在特约商户列表中发起邀请 → 子商户登录商户平台 → 产品中心 → 我的授权产品 → 授权
|
||||
|
||||
> 子商户需先通过服务商平台或接口入驻。如无服务商商户号,需先申请入驻。
|
||||
|
||||
## APPID 绑定
|
||||
|
||||
- **sp_appid**:服务商平台 → 产品中心 → APPID授权管理 → 关联 APPID
|
||||
- **sub_appid**(可选,由服务商操作):服务商平台 → 合作伙伴功能 → 开发参数配置 → 对应子商户 → 特约商户APPID配置
|
||||
|
||||
APPID 类型(公众号/小程序/移动应用)与商户模式相同,**三种格式相同但不能混用**。
|
||||
|
||||
| 常见报错 | 原因 | 处理 |
|
||||
|---------|------|------|
|
||||
| `appid and mchid not match` | sp_appid/sub_appid 与对应商户号未绑定 | 检查服务商平台 APPID 绑定配置 |
|
||||
| `appid is invalid` | appid 格式错或类型不对 | 确认使用了正确类型的 APPID |
|
||||
|
||||
## 各支付方式速查表
|
||||
|
||||
> 通用下单参数(各方式相同):`sub_mchid`(收款子商户号)、`time_expire`(默认 7 天)、`profit_sharing`(分账标识)。
|
||||
> 通用后续流程:查询订单、支付回调通知、下载账单、退款,各支付方式完全一致。
|
||||
|
||||
| | JSAPI | APP | H5 | Native | 小程序 |
|
||||
|--|-------|-----|----|---------|----|
|
||||
| **适用场景** | 微信内浏览器网页 | 原生APP | 手机浏览器(非微信) | PC端网页 | 微信小程序 |
|
||||
| **下单API** | `.../jsapi` | `.../app` | `.../h5` | `.../native` | 同JSAPI |
|
||||
| **下单产物** | prepay_id(2h) | prepay_id(2h) | h5_url(**5min**) | code_url(2h) | prepay_id(2h) |
|
||||
| **调起方式** | WeixinJSBridge | OpenSDK sendReq | 跳转h5_url | code_url转二维码 | wx.requestPayment |
|
||||
| **需openid** | ✅ | ❌ | ❌ | ❌ | ✅ |
|
||||
|
||||
> 下单 API 路径前缀均为 `/v3/pay/partner/transactions`。小程序与 JSAPI **共享权限和下单接口**。
|
||||
> 付款码支付仅有 V2 接口(XML + MD5/HMAC-SHA256),不在上表 V3 体系中,参考文档:https://pay.weixin.qq.com/doc/v2/partner/4011941052
|
||||
|
||||
## 各支付方式特殊注意
|
||||
|
||||
### JSAPI
|
||||
- **必须接入点金计划**:服务商模式下 JSAPI 支付完成后,页面会被替换为点金计划官方页面,服务商的 H5/小程序页面会被关闭。不接入商家小票功能则支付后用户无法返回商户页面。详见 `点金计划.md`
|
||||
- 必须在服务商平台配置授权目录
|
||||
|
||||
### APP
|
||||
- 调起支付的 `appId` 必须与下单参数一致:服务商APP中传 `sp_appid`,子商户APP中传 `sub_appid`(`sub_appid` 仅在子商户APP下单时需传)
|
||||
- Android 必须实现 `WXPayEntryActivity`;Android 13 需去除其 `intent-filter`
|
||||
- iOS 务必配置 URL scheme
|
||||
- 严格遵循 OpenSDK 接入指引(安卓/iOS/鸿蒙)
|
||||
|
||||
### H5
|
||||
- **不能在APP内使用**
|
||||
- 必须在服务商平台配置 H5 支付域名
|
||||
- 可在 `h5_url` 后拼接 `redirect_url` 指定支付后返回页面
|
||||
- **必须实现跨域安全校验**:OPTIONS 预检和 GET/POST 请求均需校验 `Origin` 白名单和用户登录态,否则可能被依据《微信支付服务协议》处理
|
||||
|
||||
### Native
|
||||
- 仅支持**扫一扫**,不支持相册识别或长按识别二维码
|
||||
- 支付完成后**无前端回调**(用户留在微信端),必须依赖回调通知或轮询查单确认状态
|
||||
|
||||
### 小程序
|
||||
- 与 JSAPI 区别仅在调起方式(wx.requestPayment)和 openid 获取方式(wx.login),无需配置授权目录
|
||||
- 小程序内嵌 H5 页面**不能**调用 JSAPI 收款,只能用小程序支付
|
||||
- 调起支付签名的 `appid` 需与实际调起的小程序一致(服务商小程序用 `sp_appid`,子商户小程序用 `sub_appid`)
|
||||
- 交易类小程序须满足《交易类小程序运营规范》,结算周期受公众平台管控
|
||||
|
||||
## 参数与代码示例的对应关系
|
||||
|
||||
```
|
||||
spMchid → 服务商商户号
|
||||
subMchid → 子商户号
|
||||
spAppid → 服务商APPID
|
||||
subAppid → 子商户APPID(可选)
|
||||
certificateSerialNo → 服务商API证书序列号
|
||||
privateKeyFilePath → 服务商API证书私钥文件路径(apiclient_key.pem)
|
||||
wechatPayPublicKeyId → 微信支付公钥ID
|
||||
wechatPayPublicKeyFilePath → 微信支付公钥文件路径(wxp_pub.pem)
|
||||
```
|
||||
@@ -0,0 +1,108 @@
|
||||
# 点金计划(服务商 JSAPI 支付必接)
|
||||
|
||||
## 什么是点金计划
|
||||
|
||||
点金计划是微信支付官方提供的支付后页面升级能力。服务商为子商户开通后,子商户的 **JSAPI 支付完成页**将被替换为点金计划官方页面,同时发起支付的服务商 H5 页面或小程序页面**会被关闭**。
|
||||
|
||||
**核心影响**:如果服务商不接入商家小票功能,支付后用户将看不到商户的业务内容(取餐码、停车时长、订单详情等),也无法返回商户页面。
|
||||
|
||||
## 两种小票模式
|
||||
|
||||
| | 官方小票 | 商家小票 |
|
||||
|--|---------|---------|
|
||||
| **内容** | 平台自动生成,仅展示订单金额等基础信息 | 商家自定义,可展示取餐码、停车时长等业务内容 |
|
||||
| **开发成本** | 无需开发,直接开通 | 需开发商家小票页面并完成 iframe 对接 |
|
||||
| **适用场景** | 无需展示业务信息的子商户 | 需要在支付后展示业务信息的子商户(餐饮、停车等) |
|
||||
|
||||
## 开通流程
|
||||
|
||||
### 1. 服务商开通点金计划
|
||||
|
||||
路径:服务商平台 → 服务商功能 → 点金计划 → 申请开通(签署承诺函 + 填写业务联络人)。
|
||||
|
||||
### 2. 为子商户开通官方小票
|
||||
|
||||
路径:服务商平台 → 服务商功能 → 点金计划 → 特约商户管理 → 打开"点金计划"开关。
|
||||
|
||||
也可通过"默认开通"按钮为全量子商户自动开通(有 JSAPI 支付的子商户会自动开通,无 JSAPI 支付权限的不会)。
|
||||
|
||||
> 主动关闭点金计划后,需间隔 **24 小时**方可再次开通。
|
||||
|
||||
### 3. 为子商户开通商家小票(可选)
|
||||
|
||||
步骤:
|
||||
1. 配置商家小票链接:服务商平台 → 服务商功能 → 点金计划 → 商家小票链接配置 → 添加链接(须 HTTPS + ICP 备案)→ 下载验证文件放到链接目录下验证所有权
|
||||
2. 打开商家小票开关:特约商户管理 → 打开"商家小票"开关
|
||||
3. 打开点金计划开关:特约商户管理 → 打开"点金计划"开关
|
||||
|
||||
> 先开商家小票再开点金计划,避免时间差导致部分订单展示官方小票。
|
||||
> 部分子商户(医院、学校等)可配置独立的商家小票链接。
|
||||
|
||||
## 商家小票开发要点
|
||||
|
||||
点金计划页面上方提供一个 **iframe 框架**,商家小票页面嵌入其中。核心交互流程:
|
||||
|
||||
1. 用户 JSAPI 支付完成 → 点金计划页面加载 → iframe 加载商家小票链接
|
||||
2. 点金计划页面向 iframe 传递订单信息(通过 postMessage)
|
||||
3. 商家小票页面获取订单信息、校验通过后展示业务内容
|
||||
4. **必须在 3 秒内调用父页面的 `onIframeReady` JSAPI**,否则提示"无法获取订单信息"
|
||||
|
||||
## 小程序左上角返回键管理
|
||||
|
||||
小程序支付场景下,可通过 `wx.requestPayment` 的 `payCompletedPageOptions` 控制点金计划页面左上角的返回按钮。
|
||||
|
||||
**隐藏返回按钮**:
|
||||
|
||||
```javascript
|
||||
wx.requestPayment({
|
||||
payCompletedPageOptions: {
|
||||
showNavBackButton: false
|
||||
}
|
||||
})
|
||||
```
|
||||
|
||||
**自定义返回按钮跳转页面**:
|
||||
|
||||
```javascript
|
||||
wx.requestPayment({
|
||||
payCompletedPageOptions: {
|
||||
showNavBackButton: true
|
||||
},
|
||||
success: res => {
|
||||
res.payCompletedPage.onUnload(() => {
|
||||
wx.navigateTo({
|
||||
url: '/page/index/index'
|
||||
})
|
||||
})
|
||||
}
|
||||
})
|
||||
```
|
||||
|
||||
> 点击左上角返回按钮会触发支付回调页的 `onUnload` 事件,在该事件中可跳转到指定页面。
|
||||
|
||||
## API 接口
|
||||
|
||||
| 接口 | 方法 | 路径 | 用途 |
|
||||
|------|------|------|------|
|
||||
| 点金计划管理 | POST | `/v3/goldplan/merchants/changegoldplanstatus` | 为子商户开通/关闭点金计划 |
|
||||
| 商家小票管理 | POST | `/v3/goldplan/merchants/changecustompagestatus` | 为子商户开通/关闭商家小票 |
|
||||
| 同业过滤标签 | POST | `/v3/goldplan/merchants/set-advertising-industry-filter` | 过滤同行业广告(最多 3 个标签) |
|
||||
| 开通广告展示 | PATCH | `/v3/goldplan/merchants/open-advertising-show` | 开通广告并设置过滤标签 |
|
||||
| 关闭广告展示 | POST | `/v3/goldplan/merchants/close-advertising-show` | 关闭广告展示 |
|
||||
|
||||
> 所有接口均需传 `sub_mchid`(子商户号)和 `operation_type`(OPEN/CLOSE)。
|
||||
|
||||
## 常见问题
|
||||
|
||||
| 问题 | 答案 |
|
||||
|------|------|
|
||||
| 开通/关闭点金计划后多久生效? | 开通后 **5 分钟**内生效 |
|
||||
| 看不到"商家小票"开关? | 需先在"商家小票链接配置"中添加链接,开关才会出现 |
|
||||
| 商家小票调试报"无法获取订单信息"? | ① 确认已打开商家小票和点金计划开关;② 页面须在 3 秒内调用 `onIframeReady` JSAPI;③ 扫码调试的微信号须与支付订单的微信号一致;④ 商家小票页面须可正常访问 |
|
||||
| 开通后支付完成页展示什么? | 只展示点金计划页面(含官方/商家小票 + 广告),不再展示服务商的支付回调页 |
|
||||
| 服务商返佣怎么算? | 返佣(元)= eCPM(元)/ 1000 × 支付后广告曝光量 |
|
||||
| 如何领取广告收益的技术服务费? | 参考技术服务费结果查询、技术服务费领取指引查询和领取 |
|
||||
| 自定义页面点按钮弹框"即将打开商家的新页面"能去掉吗? | **不支持**去掉或自定义弹窗内容 |
|
||||
| jumpOut 跳转公众号时弹窗显示商家简称,能隐藏或修改吗? | **不支持** |
|
||||
| 商家小票页面支持跳转小程序吗? | 小程序支付场景支持用点金计划 JS 跳转小程序;JSAPI 支付场景**不支持** |
|
||||
| 从业机构号可以开通点金计划吗? | **不支持**,需要从业机构下的渠道商去开通配置 |
|
||||
@@ -0,0 +1,146 @@
|
||||
# 特约商户进件
|
||||
|
||||
特约商户进件是微信支付面向**普通服务商**开放的接口能力,用于协助各类型商户发起入驻微信支付的申请。从业机构(银行及支付机构)及电商平台不可使用本接口。
|
||||
|
||||
## 支持的商户类型
|
||||
|
||||
| 类型 | 定义 | 所需资料 |
|
||||
|------|------|---------|
|
||||
| 个体工商户 | 营业执照主体类型为个体户/个体工商户/个体经营 | 营业执照、经营者证件、结算银行账户 |
|
||||
| 企业 | 营业执照主体类型为有限公司/有限责任公司 | 营业执照、法人证件、组织机构代码证(未三证合一)、结算银行账户 |
|
||||
| 党政/机关及事业单位 | 各级政府机构、事业单位 | 登记证书、法人证件、组织机构代码证(未三证合一)、结算银行账户 |
|
||||
| 其他组织 | 社会团体、民办非企业、基金会等 | 登记证书、法人证件、组织机构代码证(未三证合一) |
|
||||
|
||||
> 不支持进件个人小微商户。
|
||||
|
||||
## 进件流程
|
||||
|
||||
```
|
||||
1. 服务商提交进件申请
|
||||
├── 调用「提交申请单」接口,填写:主体信息、经营信息、结算规则、结算账户、超级管理员
|
||||
└── 调用「查询申请单状态」获取签约链接(建议转二维码),发给超级管理员
|
||||
└── 超管扫码后,"微信支付商家助手"公众号推送申请进展通知
|
||||
|
||||
2. 微信支付审核(约 1-3 个工作日)
|
||||
|
||||
3. 特约商户账户验证(审核通过后,按主体类型判断是否需要)
|
||||
├── 个体工商户:超管为法人/经营者 → 无需验证;超管为负责人 → 法人扫码验证(10天有效)
|
||||
├── 企业:超管为法人/经营者 → 无需验证;超管为负责人 → 汇款验证或法人扫码验证(10天有效)
|
||||
├── 党政/事业单位:上传盖章证明函 → 无需验证;汇款验证 → 指定账户汇款(10天有效)
|
||||
└── 其他组织:汇款账户验证
|
||||
⚠️ 10天内未完成验证,申请单自动驳回
|
||||
|
||||
4. 特约商户签约
|
||||
├── 服务商通过查询接口获取签约链接,发给超管
|
||||
└── 超管扫码完成签约
|
||||
|
||||
5. 开通权限(签约后约 30 分钟自动完成)
|
||||
├── 根据进件时选择的经营场景授权支付权限:
|
||||
│ ├── 线下门店 → 付款码、JSAPI、刷脸
|
||||
│ ├── 公众号 → JSAPI
|
||||
│ ├── 小程序 → JSAPI
|
||||
│ ├── App → App支付
|
||||
│ └── 互联网网站 → JSAPI、Native
|
||||
└── 默认授权 API 退款权限
|
||||
|
||||
6. 完成入驻(申请单状态为"已完成")
|
||||
└── 首笔交易后,微信支付汇款 0.01 元至结算银行卡验证账户信息
|
||||
```
|
||||
|
||||
## API 接口
|
||||
|
||||
| 接口 | 方法 | 路径 | 备注 |
|
||||
|------|------|------|------|
|
||||
| 提交申请单 | POST | `/v3/applyment4sub/applyment/` | 频率限制:15QPS(服务商维度) |
|
||||
| 通过业务申请编号查询申请状态 | GET | `/v3/applyment4sub/applyment/business_code/{business_code}` | |
|
||||
| 通过申请单ID查询申请状态 | GET | `/v3/applyment4sub/applyment/applyment_id/{applyment_id}` | |
|
||||
| 修改结算账户 | POST | `/v3/apply4sub/sub_merchants/{sub_mchid}/modify-settlement` | 每天最多 5 次,频率限制 20/min |
|
||||
| 查询结算账户 | GET | `/v3/apply4sub/sub_merchants/{sub_mchid}/settlement` | |
|
||||
| 查询结算账户修改申请状态 | GET | `/v3/apply4sub/sub_merchants/{sub_mchid}/application/{application_no}` | |
|
||||
| 图片上传 | POST | `/v3/merchant/media/upload` | 进件资料中的图片需先上传获取 MediaID |
|
||||
|
||||
> 敏感信息加密:进件请求中的姓名、身份证号、手机号、邮箱等敏感字段需使用**微信支付公钥**(推荐)或平台证书公钥加密,请求 Header 须携带 `Wechatpay-Serial`(公钥ID 或证书序列号)。
|
||||
|
||||
## 提交申请单核心参数结构
|
||||
|
||||
| 模块 | 必填 | 主要字段 |
|
||||
|------|------|---------|
|
||||
| `business_code` | 是 | 业务申请编号,服务商自定义唯一编号。若申请单被驳回,可填相同编号覆盖修改原申请单 |
|
||||
| `contact_info` | 是 | 超级管理员信息:类型(LEGAL 法人/SUPER 经办人)、姓名🔐、手机🔐、邮箱🔐 |
|
||||
| `subject_info` | 是 | 主体资料:主体类型、营业执照/登记证书、法人身份证件(姓名🔐、证件号🔐) |
|
||||
| `business_info` | 是 | 经营资料:商户简称、经营场景(线下/公众号/小程序/App/网站)、AppID |
|
||||
| `settlement_info` | 是 | 结算规则:结算账户类型(对公/对私)、开户银行、银行账号 |
|
||||
| `bank_account_info` | 是 | 结算银行账户:开户名称🔐、银行账号🔐、开户银行 |
|
||||
| `addition_info` | 否 | 补充材料:业务流程截图、业务合同等 |
|
||||
|
||||
> 🔐 标记的字段为敏感信息,需使用微信支付公钥或平台证书公钥加密,HTTP 头须上送 `Wechatpay-Serial`。
|
||||
>
|
||||
> 超级管理员类型为 `SUPER`(经办人)时,需额外上传经办人身份证件和业务办理授权函。
|
||||
|
||||
### 重要参数说明
|
||||
|
||||
| 参数 | 说明 |
|
||||
|------|------|
|
||||
| `business_code` | 服务商自定义唯一编号,建议前缀为服务商商户号。每个编号对应一个申请单,审核通过后生成微信支付商户号。被驳回可用相同编号覆盖修改 |
|
||||
| `bank_account_type` | 账户类型。企业/党政/事业/其他组织:`BANK_ACCOUNT_TYPE_CORPORATE`(对公)。个体户可选对公或 `BANK_ACCOUNT_TYPE_PERSONAL`(经营者个人银行卡) |
|
||||
| `account_name` | 开户名称。选"经营者个人银行卡"时须与经营者证件姓名一致;选"对公银行账户"时须与营业执照/登记证书的商户名称一致 |
|
||||
| `subject_type` | 主体类型枚举:`SUBJECT_TYPE_INDIVIDUAL`(个体户)、`SUBJECT_TYPE_ENTERPRISE`(企业)、`SUBJECT_TYPE_GOVERNMENT`(政府机关)、`SUBJECT_TYPE_INSTITUTIONS`(事业单位)、`SUBJECT_TYPE_OTHERS`(社会组织) |
|
||||
| 不需要的参数 | **不要传空值**,不需要的字段直接不上传,否则可能报"系统繁忙" |
|
||||
|
||||
## 申请单状态流转
|
||||
|
||||
| 状态 | 含义 | 后续操作 |
|
||||
|------|------|---------|
|
||||
| APPLYMENT_STATE_EDITTING | 编辑中 | — |
|
||||
| APPLYMENT_STATE_AUDITING | 审核中 | 等待审核结果(1-3个工作日) |
|
||||
| APPLYMENT_STATE_REJECTED | 已驳回 | 根据驳回原因修改后重新提交 |
|
||||
| APPLYMENT_STATE_TO_BE_CONFIRMED | 待账户验证 | 发送验证链接给超管 |
|
||||
| APPLYMENT_STATE_TO_BE_SIGNED | 待签约 | 发送签约链接给超管 |
|
||||
| APPLYMENT_STATE_SIGNING | 开通权限中 | 约30分钟自动完成 |
|
||||
| APPLYMENT_STATE_FINISHED | 已完成 | 入驻成功 |
|
||||
| APPLYMENT_STATE_CANCELED | 已作废 | 超30天未签约自动作废 |
|
||||
|
||||
## 常见问题
|
||||
|
||||
### 业务规则
|
||||
|
||||
| 问题 | 答案 |
|
||||
|------|------|
|
||||
| 服务商可以入驻成为自己的特约商户吗? | 不可以 |
|
||||
| 普通商户号可以和服务商号建立绑定关系吗? | 不支持,需重新在该服务商下进件 |
|
||||
| 特约商户能否变更为普通服务商? | 不支持 |
|
||||
| 申请单已审核通过但想修改信息? | 没有撤销接口,可更换进件单号重新提交。原申请单30天后自动作废 |
|
||||
| 重新提交进件返回的申请单号不变? | 需更换 `business_code`(业务申请编号),否则复用旧申请单 |
|
||||
| 提交申请单接口频率限制? | 服务商维度 15QPS |
|
||||
| 修改结算银行卡频率限制? | 每天最多提交 5 次,次日 0 点后可重新发起 |
|
||||
| 法人与营业执照上不一致? | 需先完成工商信息变更,确保一致后再进件 |
|
||||
| 查询结算账户不返回 `verify_result` 字段? | 入驻后若没修改过银行卡,除非汇款失败,否则不返回该字段 |
|
||||
|
||||
### 常见报错
|
||||
|
||||
| 报错信息 | 原因与解决 |
|
||||
|---------|-----------|
|
||||
| "暂无权限" / "NO_AUTH" | ① 服务商商户号被处罚,进入合作伙伴平台 → 合作伙伴功能 → 风险处理 → 功能限制记录,找到对应单据处理;② 请求头 `mchid` 填写错误,只能填普通服务商商户号;③ 不允许进件小微商户 |
|
||||
| "进件特约商户的权限已被受限" | 服务商进件权限被风控限制,登录服务商平台查看受限原因,解除限制后再调用接口 |
|
||||
| "身份证号码,与营业执照不匹配" | `id_card_number` 需填写营业执照上法人/经营者的身份证号,格式为 17位数字+1位数字/X,且须加密 |
|
||||
| "请填写法人证件姓名" | `id_card_name` 字段未传或传值有误,确认填写后重新提交 |
|
||||
| "经营者/负责人证件类型取值不在有效范围内" | 未传 `contact_type` 字段,需传入 `LEGAL` 或 `SUPER` |
|
||||
| "参数'组织机构代码证照片'是必填项" | 不需要的参数结构不要传入,去掉 `organization_info` 结构 |
|
||||
| "系统繁忙,请稍后重试" | ① 系统繁忙稍后重试;② `mchid` 填写错误;③ 不需要的参数不要上传,不能传空值 |
|
||||
| "开户银行取值有误" | 开户银行全称和联行号须匹配(如联行号 310290097606 对应"上海浦东发展银行股份有限公司三林支行"),调用「查询支持个人/对公业务的银行列表」接口获取正确值 |
|
||||
| "证书图片内容无效" | 调用图片上传接口重新获取 MediaID |
|
||||
| 命中敏感词(INVALID_REQUEST) | 商户名称或简称含敏感词(如"慈善""基金""医疗"等),修改后重新提交。具体敏感词不对外公开,需逐步排查 |
|
||||
| "商户号数量超过上限" | 系统基于已有商户号经营情况动态评估上限,注销闲置商户号后需等系统重新评估,评估标准为黑盒 |
|
||||
|
||||
### 线上高频问题(来自真实工单)
|
||||
|
||||
| 问题 | 答案 |
|
||||
|------|------|
|
||||
| 电商服务商调进件接口报错? | 电商服务商无权限调用特约商户进件接口,需先接入为**普通服务商**,参考特约商户进件文档 |
|
||||
| 进件时 AppID 填什么? | 填写与商家主体一致且已认证的应用 AppID(需是已认证的 App),在"经营信息"模块按指引填写 |
|
||||
| 申请单一直"资料校验中"不变? | 可能单据已驳回或已作废,调用查询申请单状态接口确认实际状态及驳回原因 |
|
||||
| 状态为 ACCOUNT_NEED_VERIFY 但未返回 `legal_validation_url`? | 接口根据商户当前所需操作返回对应链接,当天商户应先操作账户验证 |
|
||||
| 申请单卡在"待确认"无法取消或删除? | 没有取消/删除接口,审核需 1-3 个工作日,30 天不处理自动作废。可更换 `business_code` 重新提交 |
|
||||
| API 进件的商户如何修改商户简称? | 通过商户平台修改,不支持通过 API 修改 |
|
||||
| 单个人/主体进件次数有限制吗? | 有限制,系统动态评估,具体以实际返回为准 |
|
||||
| 商户已存在怎么办? | 根据提示先注销已有商户号,再重新进件 |
|
||||
@@ -0,0 +1,211 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"demo/wxpay_utility" // 引用微信支付工具库,参考 https://pay.weixin.qq.com/doc/v3/partner/4015119446
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"time"
|
||||
)
|
||||
|
||||
func main() {
|
||||
// TODO: 请准备商户开发必要参数,参考:https://pay.weixin.qq.com/doc/v3/partner/4013080340
|
||||
config, err := wxpay_utility.CreateMchConfig(
|
||||
"19xxxxxxxx", // 商户号,是由微信支付系统生成并分配给每个商户的唯一标识符,商户号获取方式参考 https://pay.weixin.qq.com/doc/v3/partner/4013080340
|
||||
"1DDE55AD98Exxxxxxxxxx", // 商户API证书序列号,如何获取请参考 https://pay.weixin.qq.com/doc/v3/partner/4013058924
|
||||
"/path/to/apiclient_key.pem", // 商户API证书私钥文件路径,本地文件路径
|
||||
"PUB_KEY_ID_xxxxxxxxxxxxx", // 微信支付公钥ID,如何获取请参考 https://pay.weixin.qq.com/doc/v3/partner/4013038589
|
||||
"/path/to/wxp_pub.pem", // 微信支付公钥文件路径,本地文件路径
|
||||
)
|
||||
if err != nil {
|
||||
fmt.Println(err)
|
||||
return
|
||||
}
|
||||
|
||||
request := &PartnerApiv3PartnerJsapiPrepayRequest{
|
||||
SpAppid: wxpay_utility.String("wx8888888888888888"),
|
||||
SpMchid: wxpay_utility.String("1230000109"),
|
||||
SubAppid: wxpay_utility.String("wxd678efh567hg6999"),
|
||||
SubMchid: wxpay_utility.String("1900000109"),
|
||||
Description: wxpay_utility.String("Image形象店-深圳腾大-QQ公仔"),
|
||||
OutTradeNo: wxpay_utility.String("1217752501201407033233368018"),
|
||||
TimeExpire: wxpay_utility.Time(time.Now()),
|
||||
Attach: wxpay_utility.String("自定义数据"),
|
||||
NotifyUrl: wxpay_utility.String(" https://www.weixin.qq.com/wxpay/pay.php"),
|
||||
GoodsTag: wxpay_utility.String("WXG"),
|
||||
SettleInfo: &PartnerSettleInfo{
|
||||
ProfitSharing: wxpay_utility.Bool(true),
|
||||
},
|
||||
SupportFapiao: wxpay_utility.Bool(true),
|
||||
Amount: &CommonAmountInfo{
|
||||
Total: wxpay_utility.Int64(100),
|
||||
Currency: wxpay_utility.String("CNY"),
|
||||
},
|
||||
Payer: &PartnerJsapiReqPayerInfo{
|
||||
SpOpenid: wxpay_utility.String("oUpF8uMuAJO_M2pxb1Q9zNjWeS6o"),
|
||||
SubOpenid: wxpay_utility.String("oUpF8uMuAJO_M2pxb1Q9zNjWeS6o"),
|
||||
},
|
||||
Detail: &CouponInfo{
|
||||
CostPrice: wxpay_utility.Int64(608800),
|
||||
InvoiceId: wxpay_utility.String("微信123"),
|
||||
GoodsDetail: []GoodsDetail{
|
||||
GoodsDetail{
|
||||
MerchantGoodsId: wxpay_utility.String("1246464644"),
|
||||
WechatpayGoodsId: wxpay_utility.String("1001"),
|
||||
GoodsName: wxpay_utility.String("iPhoneX 256G"),
|
||||
Quantity: wxpay_utility.Int64(1),
|
||||
UnitPrice: wxpay_utility.Int64(528800),
|
||||
},
|
||||
},
|
||||
},
|
||||
SceneInfo: &CommonSceneInfo{
|
||||
PayerClientIp: wxpay_utility.String("14.23.150.211"),
|
||||
DeviceId: wxpay_utility.String("013467007045764"),
|
||||
StoreInfo: &StoreInfo{
|
||||
Id: wxpay_utility.String("0001"),
|
||||
Name: wxpay_utility.String("腾讯大厦分店"),
|
||||
AreaCode: wxpay_utility.String("440305"),
|
||||
Address: wxpay_utility.String("广东省深圳市南山区科技中一道10000号"),
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
response, err := PartnerJsapiPrepay(config, request)
|
||||
if err != nil {
|
||||
fmt.Printf("请求失败: %+v\n", err)
|
||||
// TODO: 请求失败,根据状态码执行不同的处理
|
||||
return
|
||||
}
|
||||
|
||||
// TODO: 请求成功,继续业务逻辑
|
||||
fmt.Printf("请求成功: %+v\n", response)
|
||||
}
|
||||
|
||||
func PartnerJsapiPrepay(config *wxpay_utility.MchConfig, request *PartnerApiv3PartnerJsapiPrepayRequest) (response *PartnerApiv3PartnerJsapiPrepayResponse, err error) {
|
||||
const (
|
||||
host = "https://api.mch.weixin.qq.com"
|
||||
method = "POST"
|
||||
path = "/v3/pay/partner/transactions/jsapi"
|
||||
)
|
||||
|
||||
reqUrl, err := url.Parse(fmt.Sprintf("%s%s", host, path))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
reqBody, err := json.Marshal(request)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
httpRequest, err := http.NewRequest(method, reqUrl.String(), bytes.NewReader(reqBody))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
httpRequest.Header.Set("Accept", "application/json")
|
||||
httpRequest.Header.Set("Wechatpay-Serial", config.WechatPayPublicKeyId())
|
||||
httpRequest.Header.Set("Content-Type", "application/json")
|
||||
authorization, err := wxpay_utility.BuildAuthorization(config.MchId(), config.CertificateSerialNo(), config.PrivateKey(), method, reqUrl.RequestURI(), reqBody)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
httpRequest.Header.Set("Authorization", authorization)
|
||||
|
||||
client := &http.Client{}
|
||||
httpResponse, err := client.Do(httpRequest)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
respBody, err := wxpay_utility.ExtractResponseBody(httpResponse)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if httpResponse.StatusCode >= 200 && httpResponse.StatusCode < 300 {
|
||||
// 2XX 成功,验证应答签名
|
||||
err = wxpay_utility.ValidateResponse(
|
||||
config.WechatPayPublicKeyId(),
|
||||
config.WechatPayPublicKey(),
|
||||
&httpResponse.Header,
|
||||
respBody,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
response := &PartnerApiv3PartnerJsapiPrepayResponse{}
|
||||
if err := json.Unmarshal(respBody, response); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return response, nil
|
||||
} else {
|
||||
return nil, wxpay_utility.NewApiException(
|
||||
httpResponse.StatusCode,
|
||||
httpResponse.Header,
|
||||
respBody,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
type PartnerApiv3PartnerJsapiPrepayRequest struct {
|
||||
SpAppid *string `json:"sp_appid,omitempty"`
|
||||
SpMchid *string `json:"sp_mchid,omitempty"`
|
||||
SubAppid *string `json:"sub_appid,omitempty"`
|
||||
SubMchid *string `json:"sub_mchid,omitempty"`
|
||||
Description *string `json:"description,omitempty"`
|
||||
OutTradeNo *string `json:"out_trade_no,omitempty"`
|
||||
TimeExpire *time.Time `json:"time_expire,omitempty"`
|
||||
Attach *string `json:"attach,omitempty"`
|
||||
NotifyUrl *string `json:"notify_url,omitempty"`
|
||||
GoodsTag *string `json:"goods_tag,omitempty"`
|
||||
SettleInfo *PartnerSettleInfo `json:"settle_info,omitempty"`
|
||||
SupportFapiao *bool `json:"support_fapiao,omitempty"`
|
||||
Amount *CommonAmountInfo `json:"amount,omitempty"`
|
||||
Payer *PartnerJsapiReqPayerInfo `json:"payer,omitempty"`
|
||||
Detail *CouponInfo `json:"detail,omitempty"`
|
||||
SceneInfo *CommonSceneInfo `json:"scene_info,omitempty"`
|
||||
}
|
||||
|
||||
type PartnerApiv3PartnerJsapiPrepayResponse struct {
|
||||
PrepayId *string `json:"prepay_id,omitempty"`
|
||||
}
|
||||
|
||||
type PartnerSettleInfo struct {
|
||||
ProfitSharing *bool `json:"profit_sharing,omitempty"`
|
||||
}
|
||||
|
||||
type CommonAmountInfo struct {
|
||||
Total *int64 `json:"total,omitempty"`
|
||||
Currency *string `json:"currency,omitempty"`
|
||||
}
|
||||
|
||||
type PartnerJsapiReqPayerInfo struct {
|
||||
SpOpenid *string `json:"sp_openid,omitempty"`
|
||||
SubOpenid *string `json:"sub_openid,omitempty"`
|
||||
}
|
||||
|
||||
type CouponInfo struct {
|
||||
CostPrice *int64 `json:"cost_price,omitempty"`
|
||||
InvoiceId *string `json:"invoice_id,omitempty"`
|
||||
GoodsDetail []GoodsDetail `json:"goods_detail,omitempty"`
|
||||
}
|
||||
|
||||
type CommonSceneInfo struct {
|
||||
PayerClientIp *string `json:"payer_client_ip,omitempty"`
|
||||
DeviceId *string `json:"device_id,omitempty"`
|
||||
StoreInfo *StoreInfo `json:"store_info,omitempty"`
|
||||
}
|
||||
|
||||
type GoodsDetail struct {
|
||||
MerchantGoodsId *string `json:"merchant_goods_id,omitempty"`
|
||||
WechatpayGoodsId *string `json:"wechatpay_goods_id,omitempty"`
|
||||
GoodsName *string `json:"goods_name,omitempty"`
|
||||
Quantity *int64 `json:"quantity,omitempty"`
|
||||
UnitPrice *int64 `json:"unit_price,omitempty"`
|
||||
}
|
||||
|
||||
type StoreInfo struct {
|
||||
Id *string `json:"id,omitempty"`
|
||||
Name *string `json:"name,omitempty"`
|
||||
AreaCode *string `json:"area_code,omitempty"`
|
||||
Address *string `json:"address,omitempty"`
|
||||
}
|
||||
@@ -0,0 +1,168 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"demo/wxpay_utility" // 引用微信支付工具库,参考 https://pay.weixin.qq.com/doc/v3/partner/4015119446
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/url"
|
||||
)
|
||||
|
||||
func main() {
|
||||
// TODO: 请准备商户开发必要参数,参考:https://pay.weixin.qq.com/doc/v3/partner/4013080340
|
||||
config, err := wxpay_utility.CreateMchConfig(
|
||||
"19xxxxxxxx", // 商户号,是由微信支付系统生成并分配给每个商户的唯一标识符,商户号获取方式参考 https://pay.weixin.qq.com/doc/v3/partner/4013080340
|
||||
"1DDE55AD98Exxxxxxxxxx", // 商户API证书序列号,如何获取请参考 https://pay.weixin.qq.com/doc/v3/partner/4013058924
|
||||
"/path/to/apiclient_key.pem", // 商户API证书私钥文件路径,本地文件路径
|
||||
"PUB_KEY_ID_xxxxxxxxxxxxx", // 微信支付公钥ID,如何获取请参考 https://pay.weixin.qq.com/doc/v3/partner/4013038589
|
||||
"/path/to/wxp_pub.pem", // 微信支付公钥文件路径,本地文件路径
|
||||
)
|
||||
if err != nil {
|
||||
fmt.Println(err)
|
||||
return
|
||||
}
|
||||
|
||||
request := &GetTradeBillRequest{
|
||||
BillDate: wxpay_utility.String("2019-06-11"),
|
||||
SubMchid: wxpay_utility.String("19000000001"),
|
||||
BillType: BILLTYPE_ALL.Ptr(),
|
||||
TarType: TARTYPE_GZIP.Ptr(),
|
||||
}
|
||||
|
||||
response, err := GetTradeBill(config, request)
|
||||
if err != nil {
|
||||
fmt.Printf("请求失败: %+v\n", err)
|
||||
// TODO: 请求失败,根据状态码执行不同的处理
|
||||
return
|
||||
}
|
||||
|
||||
// TODO: 请求成功,继续业务逻辑
|
||||
fmt.Printf("请求成功: %+v\n", response)
|
||||
}
|
||||
|
||||
func GetTradeBill(config *wxpay_utility.MchConfig, request *GetTradeBillRequest) (response *QueryBillEntity, err error) {
|
||||
const (
|
||||
host = "https://api.mch.weixin.qq.com"
|
||||
method = "GET"
|
||||
path = "/v3/bill/tradebill"
|
||||
)
|
||||
|
||||
reqUrl, err := url.Parse(fmt.Sprintf("%s%s", host, path))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
query := reqUrl.Query()
|
||||
query.Add("bill_date", *request.BillDate)
|
||||
query.Add("sub_mchid", *request.SubMchid)
|
||||
query.Add("bill_type", fmt.Sprintf("%v", *request.BillType))
|
||||
query.Add("tar_type", fmt.Sprintf("%v", *request.TarType))
|
||||
reqUrl.RawQuery = query.Encode()
|
||||
httpRequest, err := http.NewRequest(method, reqUrl.String(), nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
httpRequest.Header.Set("Accept", "application/json")
|
||||
httpRequest.Header.Set("Wechatpay-Serial", config.WechatPayPublicKeyId())
|
||||
authorization, err := wxpay_utility.BuildAuthorization(config.MchId(), config.CertificateSerialNo(), config.PrivateKey(), method, reqUrl.RequestURI(), nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
httpRequest.Header.Set("Authorization", authorization)
|
||||
|
||||
client := &http.Client{}
|
||||
httpResponse, err := client.Do(httpRequest)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
respBody, err := wxpay_utility.ExtractResponseBody(httpResponse)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if httpResponse.StatusCode >= 200 && httpResponse.StatusCode < 300 {
|
||||
// 2XX 成功,验证应答签名
|
||||
err = wxpay_utility.ValidateResponse(
|
||||
config.WechatPayPublicKeyId(),
|
||||
config.WechatPayPublicKey(),
|
||||
&httpResponse.Header,
|
||||
respBody,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
response := &QueryBillEntity{}
|
||||
if err := json.Unmarshal(respBody, response); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return response, nil
|
||||
} else {
|
||||
return nil, wxpay_utility.NewApiException(
|
||||
httpResponse.StatusCode,
|
||||
httpResponse.Header,
|
||||
respBody,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
type GetTradeBillRequest struct {
|
||||
BillDate *string `json:"bill_date,omitempty"`
|
||||
SubMchid *string `json:"sub_mchid,omitempty"`
|
||||
BillType *BillType `json:"bill_type,omitempty"`
|
||||
TarType *TarType `json:"tar_type,omitempty"`
|
||||
}
|
||||
|
||||
func (o *GetTradeBillRequest) MarshalJSON() ([]byte, error) {
|
||||
type Alias GetTradeBillRequest
|
||||
a := &struct {
|
||||
BillDate *string `json:"bill_date,omitempty"`
|
||||
SubMchid *string `json:"sub_mchid,omitempty"`
|
||||
BillType *BillType `json:"bill_type,omitempty"`
|
||||
TarType *TarType `json:"tar_type,omitempty"`
|
||||
*Alias
|
||||
}{
|
||||
BillDate: nil,
|
||||
SubMchid: nil,
|
||||
BillType: nil,
|
||||
TarType: nil,
|
||||
Alias: (*Alias)(o),
|
||||
}
|
||||
return json.Marshal(a)
|
||||
}
|
||||
|
||||
type QueryBillEntity struct {
|
||||
HashType *HashType `json:"hash_type,omitempty"`
|
||||
HashValue *string `json:"hash_value,omitempty"`
|
||||
DownloadUrl *string `json:"download_url,omitempty"`
|
||||
}
|
||||
|
||||
type BillType string
|
||||
|
||||
func (e BillType) Ptr() *BillType {
|
||||
return &e
|
||||
}
|
||||
|
||||
const (
|
||||
BILLTYPE_ALL BillType = "ALL"
|
||||
BILLTYPE_SUCCESS BillType = "SUCCESS"
|
||||
BILLTYPE_REFUND BillType = "REFUND"
|
||||
)
|
||||
|
||||
type TarType string
|
||||
|
||||
func (e TarType) Ptr() *TarType {
|
||||
return &e
|
||||
}
|
||||
|
||||
const (
|
||||
TARTYPE_GZIP TarType = "GZIP"
|
||||
)
|
||||
|
||||
type HashType string
|
||||
|
||||
func (e HashType) Ptr() *HashType {
|
||||
return &e
|
||||
}
|
||||
|
||||
const (
|
||||
HASHTYPE_SHA1 HashType = "SHA1"
|
||||
)
|
||||
@@ -0,0 +1,163 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"demo/wxpay_utility" // 引用微信支付工具库,参考 https://pay.weixin.qq.com/doc/v3/partner/4015119446
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/url"
|
||||
)
|
||||
|
||||
func main() {
|
||||
// TODO: 请准备商户开发必要参数,参考:https://pay.weixin.qq.com/doc/v3/partner/4013080340
|
||||
config, err := wxpay_utility.CreateMchConfig(
|
||||
"19xxxxxxxx", // 商户号,是由微信支付系统生成并分配给每个商户的唯一标识符,商户号获取方式参考 https://pay.weixin.qq.com/doc/v3/partner/4013080340
|
||||
"1DDE55AD98Exxxxxxxxxx", // 商户API证书序列号,如何获取请参考 https://pay.weixin.qq.com/doc/v3/partner/4013058924
|
||||
"/path/to/apiclient_key.pem", // 商户API证书私钥文件路径,本地文件路径
|
||||
"PUB_KEY_ID_xxxxxxxxxxxxx", // 微信支付公钥ID,如何获取请参考 https://pay.weixin.qq.com/doc/v3/partner/4013038589
|
||||
"/path/to/wxp_pub.pem", // 微信支付公钥文件路径,本地文件路径
|
||||
)
|
||||
if err != nil {
|
||||
fmt.Println(err)
|
||||
return
|
||||
}
|
||||
|
||||
request := &GetFundFlowBillRequest{
|
||||
BillDate: wxpay_utility.String("2019-06-11"),
|
||||
AccountType: FUNDFLOWBILLACCOUNTTYPE_BASIC.Ptr(),
|
||||
TarType: TARTYPE_GZIP.Ptr(),
|
||||
}
|
||||
|
||||
response, err := GetFundFlowBill(config, request)
|
||||
if err != nil {
|
||||
fmt.Printf("请求失败: %+v\n", err)
|
||||
// TODO: 请求失败,根据状态码执行不同的处理
|
||||
return
|
||||
}
|
||||
|
||||
// TODO: 请求成功,继续业务逻辑
|
||||
fmt.Printf("请求成功: %+v\n", response)
|
||||
}
|
||||
|
||||
func GetFundFlowBill(config *wxpay_utility.MchConfig, request *GetFundFlowBillRequest) (response *QueryBillEntity, err error) {
|
||||
const (
|
||||
host = "https://api.mch.weixin.qq.com"
|
||||
method = "GET"
|
||||
path = "/v3/bill/fundflowbill"
|
||||
)
|
||||
|
||||
reqUrl, err := url.Parse(fmt.Sprintf("%s%s", host, path))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
query := reqUrl.Query()
|
||||
query.Add("bill_date", *request.BillDate)
|
||||
query.Add("account_type", fmt.Sprintf("%v", *request.AccountType))
|
||||
query.Add("tar_type", fmt.Sprintf("%v", *request.TarType))
|
||||
reqUrl.RawQuery = query.Encode()
|
||||
httpRequest, err := http.NewRequest(method, reqUrl.String(), nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
httpRequest.Header.Set("Accept", "application/json")
|
||||
httpRequest.Header.Set("Wechatpay-Serial", config.WechatPayPublicKeyId())
|
||||
authorization, err := wxpay_utility.BuildAuthorization(config.MchId(), config.CertificateSerialNo(), config.PrivateKey(), method, reqUrl.RequestURI(), nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
httpRequest.Header.Set("Authorization", authorization)
|
||||
|
||||
client := &http.Client{}
|
||||
httpResponse, err := client.Do(httpRequest)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
respBody, err := wxpay_utility.ExtractResponseBody(httpResponse)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if httpResponse.StatusCode >= 200 && httpResponse.StatusCode < 300 {
|
||||
// 2XX 成功,验证应答签名
|
||||
err = wxpay_utility.ValidateResponse(
|
||||
config.WechatPayPublicKeyId(),
|
||||
config.WechatPayPublicKey(),
|
||||
&httpResponse.Header,
|
||||
respBody,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
response := &QueryBillEntity{}
|
||||
if err := json.Unmarshal(respBody, response); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return response, nil
|
||||
} else {
|
||||
return nil, wxpay_utility.NewApiException(
|
||||
httpResponse.StatusCode,
|
||||
httpResponse.Header,
|
||||
respBody,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
type GetFundFlowBillRequest struct {
|
||||
BillDate *string `json:"bill_date,omitempty"`
|
||||
AccountType *FundFlowBillAccountType `json:"account_type,omitempty"`
|
||||
TarType *TarType `json:"tar_type,omitempty"`
|
||||
}
|
||||
|
||||
func (o *GetFundFlowBillRequest) MarshalJSON() ([]byte, error) {
|
||||
type Alias GetFundFlowBillRequest
|
||||
a := &struct {
|
||||
BillDate *string `json:"bill_date,omitempty"`
|
||||
AccountType *FundFlowBillAccountType `json:"account_type,omitempty"`
|
||||
TarType *TarType `json:"tar_type,omitempty"`
|
||||
*Alias
|
||||
}{
|
||||
BillDate: nil,
|
||||
AccountType: nil,
|
||||
TarType: nil,
|
||||
Alias: (*Alias)(o),
|
||||
}
|
||||
return json.Marshal(a)
|
||||
}
|
||||
|
||||
type QueryBillEntity struct {
|
||||
HashType *HashType `json:"hash_type,omitempty"`
|
||||
HashValue *string `json:"hash_value,omitempty"`
|
||||
DownloadUrl *string `json:"download_url,omitempty"`
|
||||
}
|
||||
|
||||
type FundFlowBillAccountType string
|
||||
|
||||
func (e FundFlowBillAccountType) Ptr() *FundFlowBillAccountType {
|
||||
return &e
|
||||
}
|
||||
|
||||
const (
|
||||
FUNDFLOWBILLACCOUNTTYPE_BASIC FundFlowBillAccountType = "BASIC"
|
||||
FUNDFLOWBILLACCOUNTTYPE_OPERATION FundFlowBillAccountType = "OPERATION"
|
||||
FUNDFLOWBILLACCOUNTTYPE_FEES FundFlowBillAccountType = "FEES"
|
||||
)
|
||||
|
||||
type TarType string
|
||||
|
||||
func (e TarType) Ptr() *TarType {
|
||||
return &e
|
||||
}
|
||||
|
||||
const (
|
||||
TARTYPE_GZIP TarType = "GZIP"
|
||||
)
|
||||
|
||||
type HashType string
|
||||
|
||||
func (e HashType) Ptr() *HashType {
|
||||
return &e
|
||||
}
|
||||
|
||||
const (
|
||||
HASHTYPE_SHA1 HashType = "SHA1"
|
||||
)
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user