Compare commits
628 Commits
v2.0.0-cdc
...
main
| Author | SHA1 | Date |
|---|---|---|
|
|
cd1d16fc7f | |
|
|
b2bace1687 | |
|
|
9771a3d69d | |
|
|
91132ec167 | |
|
|
f97eacdc70 | |
|
|
8b7872d205 | |
|
|
15019206c8 | |
|
|
d108d2c693 | |
|
|
80e3fdb7e0 | |
|
|
6845ad4a0f | |
|
|
0e058caa28 | |
|
|
05e2c29f37 | |
|
|
1bb12783db | |
|
|
86461a052d | |
|
|
5bacd21840 | |
|
|
a1aba14ccf | |
|
|
1c621c32ec | |
|
|
d29454fc74 | |
|
|
04d3b2470a | |
|
|
207b522754 | |
|
|
34ba209e44 | |
|
|
cba7ff590a | |
|
|
ef5ac2bf94 | |
|
|
abb0da36a9 | |
|
|
b639b5d499 | |
|
|
2e91686a88 | |
|
|
dcc46c37b6 | |
|
|
f5d25afab8 | |
|
|
deffdab18b | |
|
|
59acea33fe | |
|
|
c24f383501 | |
|
|
f13814e577 | |
|
|
d075853a7f | |
|
|
c7978f6fb5 | |
|
|
17ecc9954f | |
|
|
50dc18a224 | |
|
|
126169c631 | |
|
|
1baed76d8e | |
|
|
c93eeba79a | |
|
|
8980a169ed | |
|
|
000e8f7ef1 | |
|
|
4817d92507 | |
|
|
4a69fdd070 | |
|
|
2192f5e917 | |
|
|
ebe7123583 | |
|
|
38ee808239 | |
|
|
8cfd107a92 | |
|
|
7972163af6 | |
|
|
c89c8769d9 | |
|
|
a8098d801b | |
|
|
a5f6b23a95 | |
|
|
c51539e494 | |
|
|
46f85b13b0 | |
|
|
4f5b18be48 | |
|
|
4d2bcc7568 | |
|
|
6dbb620e82 | |
|
|
999d0389b3 | |
|
|
14e70b56bb | |
|
|
bf11a269a4 | |
|
|
0fe0f3b72a | |
|
|
7ee6d633c6 | |
|
|
2de4baf0af | |
|
|
21f51c5d84 | |
|
|
eab61abace | |
|
|
7a1d438f84 | |
|
|
cb8c69788b | |
|
|
d8ef156b5e | |
|
|
5b3c391340 | |
|
|
d575287713 | |
|
|
6f668d69bd | |
|
|
0f3d03d832 | |
|
|
758babfdf8 | |
|
|
1f434f32fb | |
|
|
fe6c1b3fce | |
|
|
3bce996dd3 | |
|
|
6f43408da5 | |
|
|
e99dc122ad | |
|
|
0b8c76f8b5 | |
|
|
59efdb1f78 | |
|
|
d9d8f69562 | |
|
|
6443acf34a | |
|
|
0204f748e6 | |
|
|
2a5b51aa8d | |
|
|
0156be8d25 | |
|
|
d53c2212a6 | |
|
|
394f2529cd | |
|
|
ad51aa521f | |
|
|
88ad3ab53d | |
|
|
981b11f746 | |
|
|
42cf189749 | |
|
|
ec73541fe1 | |
|
|
c657bf6e2b | |
|
|
3102f3e7fb | |
|
|
1106a40ff1 | |
|
|
0d47fadf59 | |
|
|
ee07b52af9 | |
|
|
f39fd52001 | |
|
|
be52ac979b | |
|
|
8d7e5b17a1 | |
|
|
7549b2b9a9 | |
|
|
48720d1846 | |
|
|
b9e9bb6e4e | |
|
|
3635369a8a | |
|
|
ef663c0c08 | |
|
|
ea3d256647 | |
|
|
5728953b41 | |
|
|
a4f3a8d3ab | |
|
|
b9f803c5c8 | |
|
|
c802519ec2 | |
|
|
251e37f772 | |
|
|
bf772967f5 | |
|
|
338321b3a2 | |
|
|
60d99add2c | |
|
|
49ba2fcb19 | |
|
|
627c3c943c | |
|
|
bcfa5143e3 | |
|
|
33dda98e81 | |
|
|
7f2479d995 | |
|
|
4e4a876341 | |
|
|
73a617b88c | |
|
|
64d998c7b3 | |
|
|
b8f8831516 | |
|
|
83c29f8540 | |
|
|
6ec829a804 | |
|
|
6ccc192bc6 | |
|
|
a1c3657390 | |
|
|
f9835c388e | |
|
|
f1c99949ad | |
|
|
5b1f4c82e6 | |
|
|
6bcc571453 | |
|
|
0ad1136e48 | |
|
|
f60e3751b8 | |
|
|
e783661002 | |
|
|
25ea0bf64e | |
|
|
ce173451f5 | |
|
|
4df23b02b8 | |
|
|
c5126187d2 | |
|
|
286b82c63b | |
|
|
8e63547a3e | |
|
|
3cbb874503 | |
|
|
0ffa875a85 | |
|
|
03cc5bc324 | |
|
|
8c31dee000 | |
|
|
f167f1227c | |
|
|
9b8f720915 | |
|
|
9e83127113 | |
|
|
7c416adecd | |
|
|
7180e2ac27 | |
|
|
7d548dac4e | |
|
|
17f8a61bcf | |
|
|
a31fcaa9b8 | |
|
|
b50091eb1e | |
|
|
a0750fbd42 | |
|
|
d91550f704 | |
|
|
45d038bd4b | |
|
|
a27ed0fa16 | |
|
|
3507805005 | |
|
|
6900905475 | |
|
|
94d4524ee3 | |
|
|
49bcb96c4c | |
|
|
cfa0e2ca40 | |
|
|
bde7f0c53b | |
|
|
53a2e64cad | |
|
|
f0c7cee94e | |
|
|
187b82e9ac | |
|
|
c28ccb6206 | |
|
|
9adef67bb8 | |
|
|
9f94344e8b | |
|
|
b1607666a0 | |
|
|
ca4e5393be | |
|
|
817b7d3a9f | |
|
|
83384acdac | |
|
|
454b379f6c | |
|
|
08cf4681f2 | |
|
|
4a803ea008 | |
|
|
6b92ab0dd8 | |
|
|
a41feb841f | |
|
|
534d4ce70c | |
|
|
830d99a504 | |
|
|
99dbce2053 | |
|
|
5dc37e24d2 | |
|
|
f2b83650b5 | |
|
|
6f01892945 | |
|
|
dc51c19dfd | |
|
|
3ff38ca9c2 | |
|
|
96e1fa4534 | |
|
|
f595c6f26d | |
|
|
35fc957eaf | |
|
|
4112b45b9e | |
|
|
c6137078ff | |
|
|
7b7c9cd9f6 | |
|
|
5ef0992448 | |
|
|
7e289430ae | |
|
|
83fa6bec74 | |
|
|
263f1ecf8e | |
|
|
2b7a30983e | |
|
|
c02d0b4c3c | |
|
|
7560940e14 | |
|
|
776d181ef3 | |
|
|
28c73136a8 | |
|
|
dcc83b9f79 | |
|
|
8b459dd33f | |
|
|
1448435b06 | |
|
|
6ebc1f8767 | |
|
|
8fe38525a2 | |
|
|
40389fcfc7 | |
|
|
6bcfa18b01 | |
|
|
7a8a3a8fd1 | |
|
|
812b127ace | |
|
|
6be4775506 | |
|
|
b4afc4615c | |
|
|
fd64903841 | |
|
|
8314dda670 | |
|
|
4ce43c20cc | |
|
|
edc0ea46c9 | |
|
|
76d566d145 | |
|
|
219fb7bb69 | |
|
|
3b95a8a332 | |
|
|
aa33803d08 | |
|
|
cfdcd9352a | |
|
|
4283a369ae | |
|
|
58feec255d | |
|
|
94f9e7d5b5 | |
|
|
1974c43eba | |
|
|
a2da841d59 | |
|
|
0c0750ce93 | |
|
|
042a52550b | |
|
|
cec98e9d3e | |
|
|
2597d0ef46 | |
|
|
06dbe133c2 | |
|
|
263be15028 | |
|
|
d83c859965 | |
|
|
b4541129aa | |
|
|
8a9a983cbd | |
|
|
1bc42c207a | |
|
|
7b8105d76c | |
|
|
613f85f1c2 | |
|
|
71eea98ea5 | |
|
|
d04f0a08e0 | |
|
|
aeb70a6579 | |
|
|
69de49a000 | |
|
|
4e4d731b44 | |
|
|
3e29b1c23a | |
|
|
f77becbdae | |
|
|
dfb601b274 | |
|
|
bfbd062eb3 | |
|
|
df9f9914a8 | |
|
|
7b95711406 | |
|
|
41e7eed2c1 | |
|
|
003871aded | |
|
|
c2ee9b6daf | |
|
|
20b5593a0b | |
|
|
05c6ab3dc4 | |
|
|
3f3a5b021e | |
|
|
c37c85838b | |
|
|
2d0692a96f | |
|
|
85665fb6d3 | |
|
|
62b2a87e90 | |
|
|
704ee523c9 | |
|
|
26ef03a1bc | |
|
|
bb6febb46b | |
|
|
6dda30c528 | |
|
|
6f38f96b5a | |
|
|
3a985b443f | |
|
|
9f7a5cbb12 | |
|
|
dfc984f536 | |
|
|
f9619b7df1 | |
|
|
514722143f | |
|
|
ad4549e767 | |
|
|
dbeef9f415 | |
|
|
0eea1815ae | |
|
|
0b22928d9a | |
|
|
656f75a4d1 | |
|
|
d974fddda5 | |
|
|
144d28238e | |
|
|
78e105d46d | |
|
|
6e03c1c798 | |
|
|
a516006117 | |
|
|
3727b0e817 | |
|
|
7b3d28c957 | |
|
|
c002640911 | |
|
|
2799eb5a3a | |
|
|
37d3300b17 | |
|
|
e9dea69ee9 | |
|
|
e56c86545c | |
|
|
0009a9358d | |
|
|
f3d460ba09 | |
|
|
ab320083f7 | |
|
|
c7f7c10d59 | |
|
|
623e695353 | |
|
|
b6d723333c | |
|
|
d5dc248a16 | |
|
|
134e45e0bf | |
|
|
8a47659c47 | |
|
|
f44af3a2ed | |
|
|
18e9749ad8 | |
|
|
d47276a460 | |
|
|
0adc4c8c26 | |
|
|
d98e22f151 | |
|
|
c90d88a047 | |
|
|
9e9c791283 | |
|
|
2358b3ea17 | |
|
|
f14ad0b7ad | |
|
|
702fa937e8 | |
|
|
8b8d1f7d16 | |
|
|
4dcbe38309 | |
|
|
97b3a20a7c | |
|
|
e79d42db61 | |
|
|
16daa7403c | |
|
|
ca5de3add1 | |
|
|
390cc3131d | |
|
|
e4c320970f | |
|
|
af95f8da0c | |
|
|
7a5faad665 | |
|
|
8f0fc09a4c | |
|
|
30a82f09f3 | |
|
|
a02813a8ea | |
|
|
7a4f5591b7 | |
|
|
cb9831f2fc | |
|
|
71151eaabf | |
|
|
f7dbe2f62b | |
|
|
21c6c25f7c | |
|
|
e7260be219 | |
|
|
e89c3166bf | |
|
|
7c8ea7a9d7 | |
|
|
63aba087b6 | |
|
|
946978f624 | |
|
|
eeaa43e044 | |
|
|
e0eb734196 | |
|
|
fda022d29c | |
|
|
974b45554d | |
|
|
97e974b6da | |
|
|
495a1445fd | |
|
|
27a045e082 | |
|
|
6de365e707 | |
|
|
96da7518bf | |
|
|
cded4b2134 | |
|
|
86c8ede198 | |
|
|
0a199ae3b5 | |
|
|
fff56e8baa | |
|
|
7e61ac7ff2 | |
|
|
40ac037c03 | |
|
|
9062346650 | |
|
|
81b2e7a4c2 | |
|
|
9c816266ac | |
|
|
5f2f223f7b | |
|
|
09b0bc077e | |
|
|
5fa0fd5d1a | |
|
|
1d5e3ebff2 | |
|
|
5ec310124d | |
|
|
d844228711 | |
|
|
e8e1193387 | |
|
|
6c77828944 | |
|
|
60f2c29ad8 | |
|
|
5668de0a58 | |
|
|
995dfa898e | |
|
|
7ff7157115 | |
|
|
e1cc364b0d | |
|
|
93c06920bd | |
|
|
9fb51fa30a | |
|
|
33bf14b225 | |
|
|
728497afc1 | |
|
|
9c705d7478 | |
|
|
21e536d829 | |
|
|
14d29b62ef | |
|
|
1aa655f243 | |
|
|
8728fdce4c | |
|
|
7da98c248b | |
|
|
63e02666ea | |
|
|
1c787a22a3 | |
|
|
0fddd3164a | |
|
|
b1d8561ca5 | |
|
|
edfdb1a899 | |
|
|
94d283696f | |
|
|
c5db77d23a | |
|
|
d332ef99a7 | |
|
|
d31bfc4221 | |
|
|
9333cd81c3 | |
|
|
84d920f98f | |
|
|
13f1b687ee | |
|
|
99c1ff1fb7 | |
|
|
900ba4a555 | |
|
|
453cab71e4 | |
|
|
f55fb13f26 | |
|
|
48ba72ce89 | |
|
|
7ae58e98e6 | |
|
|
684367941d | |
|
|
f149c2a06a | |
|
|
a15ab7600f | |
|
|
f51aa44cd9 | |
|
|
2745995a1a | |
|
|
61203d1baf | |
|
|
b0d1771b66 | |
|
|
bbe1754309 | |
|
|
a47b935bce | |
|
|
b00de68b01 | |
|
|
d8df50a68f | |
|
|
63c192e90d | |
|
|
d815792deb | |
|
|
a97e0b51b8 | |
|
|
8326f8c35c | |
|
|
964b06b370 | |
|
|
af339b19b9 | |
|
|
928d6c8df2 | |
|
|
7fb77bcc7e | |
|
|
f7cfb4ef8c | |
|
|
d957e5a841 | |
|
|
07498271d3 | |
|
|
8619b0bf26 | |
|
|
75e74b07c3 | |
|
|
e098cd44f6 | |
|
|
71a9961f94 | |
|
|
5ea8d8fea5 | |
|
|
1c9bb1aa60 | |
|
|
747e8bfee1 | |
|
|
1efe39c6bd | |
|
|
e48bf3e81f | |
|
|
d9d46065e0 | |
|
|
d4f7cd834a | |
|
|
7df57b9de5 | |
|
|
6109bf4584 | |
|
|
94153058d8 | |
|
|
c05bcc9a76 | |
|
|
192e2551bf | |
|
|
f6458dd12e | |
|
|
533ad3ba82 | |
|
|
cfa3979a97 | |
|
|
07247fe05f | |
|
|
dcf413fb72 | |
|
|
b7c8cdd249 | |
|
|
096d87e2a8 | |
|
|
64b9dcb6c7 | |
|
|
2154d5752f | |
|
|
4e181354f4 | |
|
|
1760f9b82c | |
|
|
dd011c13d4 | |
|
|
edd6ced2a3 | |
|
|
4bb5a2b09d | |
|
|
8319fe5e9a | |
|
|
7bc911d4d7 | |
|
|
4a4393f995 | |
|
|
5a719eef61 | |
|
|
b826511f3c | |
|
|
4eb466230e | |
|
|
4f1f1f9eaf | |
|
|
33233901a9 | |
|
|
d8dd38e91b | |
|
|
5c633b9979 | |
|
|
b1fedd417f | |
|
|
3265ee2506 | |
|
|
8c78f26e6d | |
|
|
3b6bd29283 | |
|
|
416495a398 | |
|
|
11ff3cc9bd | |
|
|
481a355d72 | |
|
|
e8f3c34723 | |
|
|
613fb33ff9 | |
|
|
6043d2fec8 | |
|
|
3e536115eb | |
|
|
68a583508b | |
|
|
d5f3f3b868 | |
|
|
1e33ab178d | |
|
|
1aaf32cbb3 | |
|
|
d424f2a18e | |
|
|
49949ff979 | |
|
|
725fb80f80 | |
|
|
76d6c30a20 | |
|
|
216394a44f | |
|
|
aee64d9be8 | |
|
|
22702e898b | |
|
|
e80e672ffe | |
|
|
ea1e376939 | |
|
|
9deffe2565 | |
|
|
d5e5bf642c | |
|
|
27bf67e561 | |
|
|
0ebb0ad076 | |
|
|
c84341be37 | |
|
|
b645621c81 | |
|
|
1f0bd15946 | |
|
|
4ec6c9f48b | |
|
|
3d6b6ae405 | |
|
|
64ccb8162a | |
|
|
20a90fce4c | |
|
|
3ce8bb0044 | |
|
|
7852b9d673 | |
|
|
9d65eef1b1 | |
|
|
3096297198 | |
|
|
854bb7a0ac | |
|
|
2534068f70 | |
|
|
f22c3efb11 | |
|
|
0241930011 | |
|
|
130bf57842 | |
|
|
962e7874c8 | |
|
|
bb75ff19a4 | |
|
|
23bb8baa9c | |
|
|
7909bcc3d1 | |
|
|
9e15fa4fd8 | |
|
|
de5416aee6 | |
|
|
b5fca7bb04 | |
|
|
7c00c900a0 | |
|
|
72b3b44d37 | |
|
|
8ab11c8f50 | |
|
|
88368d1705 | |
|
|
974d660544 | |
|
|
7b3c222b24 | |
|
|
52a5ae64c0 | |
|
|
1d7f05b12d | |
|
|
967e6c1f44 | |
|
|
2da02e0823 | |
|
|
8018fa5110 | |
|
|
1e2d8d1df7 | |
|
|
ed715111ae | |
|
|
e611894b55 | |
|
|
83b05ac146 | |
|
|
01bd638dbb | |
|
|
7a469be7cd | |
|
|
0420b0acab | |
|
|
4440f40fba | |
|
|
fdff3a3119 | |
|
|
4b1855f57a | |
|
|
4cef31b1d1 | |
|
|
109986ba49 | |
|
|
b5899497ea | |
|
|
40869ef00f | |
|
|
a1508b208e | |
|
|
c60d3b2f26 | |
|
|
bb4143d75b | |
|
|
d12bbb17be | |
|
|
19428a8cb7 | |
|
|
183b2bef59 | |
|
|
1bdb9bb336 | |
|
|
d7bbb19571 | |
|
|
420dfbfd9f | |
|
|
cfbf1b21f3 | |
|
|
1f15daa6c5 | |
|
|
8ae9e217ff | |
|
|
12f8fa67fc | |
|
|
b310fde426 | |
|
|
81a58edaca | |
|
|
debc8605df | |
|
|
dee9c511e5 | |
|
|
546c0060da | |
|
|
b81ae634a6 | |
|
|
0cccc0e2cd | |
|
|
cd938f4a34 | |
|
|
84fa3e5e19 | |
|
|
adeeadb495 | |
|
|
42a28efe74 | |
|
|
91b8cca41c | |
|
|
02cc79d67a | |
|
|
7bc8547a96 | |
|
|
caffb124d2 | |
|
|
141db46356 | |
|
|
f57b0f9c26 | |
|
|
c852f24a72 | |
|
|
cb3c7623dc | |
|
|
f2692a50ed | |
|
|
ed9f817fae | |
|
|
6bcb4af028 | |
|
|
106a287260 | |
|
|
30dc2f6665 | |
|
|
e1fb70e2ee | |
|
|
f3d4799efc | |
|
|
839feab97d | |
|
|
465e398040 | |
|
|
c6c875849a | |
|
|
ce95c40c84 | |
|
|
e6d966e89f | |
|
|
270c17829e | |
|
|
289ac0190c | |
|
|
467d637ccc | |
|
|
c9690b0d36 | |
|
|
7a65ab3319 | |
|
|
e99b5347da | |
|
|
29dd1affe1 | |
|
|
a15dcafc03 | |
|
|
d404521841 | |
|
|
09b15da3cb | |
|
|
901247366d | |
|
|
0abc04b9cb | |
|
|
2b083991d0 | |
|
|
8f616dd45b | |
|
|
1008672af9 | |
|
|
f4380604d9 | |
|
|
3b61f2e095 | |
|
|
25608babd6 | |
|
|
bd0f98cfb3 | |
|
|
a2adddbf3d | |
|
|
d6064294d7 | |
|
|
36c3ada6a6 | |
|
|
13e94db450 | |
|
|
feb871bcf1 | |
|
|
4292d5da66 | |
|
|
a7a2282ba7 | |
|
|
fa6826dde3 | |
|
|
eff71a6b22 | |
|
|
0bbb52284c | |
|
|
7588d18fff | |
|
|
e6e44d9a43 | |
|
|
bf004bab52 | |
|
|
a03b883350 | |
|
|
2a79c83715 | |
|
|
ef330a2687 | |
|
|
6594845d4c | |
|
|
77b682c8a8 | |
|
|
6ec79a6672 | |
|
|
631fe2bf31 | |
|
|
d968efcad4 | |
|
|
5a4970d7d9 | |
|
|
703c12e9f6 | |
|
|
8199bc4d66 | |
|
|
aef6feb2cd | |
|
|
22523aba14 | |
|
|
a01fd3aa86 | |
|
|
d58e8b44ee | |
|
|
30949af577 | |
|
|
1fbb88f773 | |
|
|
5eae4464ef | |
|
|
d43a70de93 | |
|
|
471702d562 | |
|
|
dbf97ae487 | |
|
|
fdfc2d6700 | |
|
|
3999d7cc51 | |
|
|
20eabbb85f | |
|
|
65bd4f9b65 | |
|
|
2f3a0f3652 | |
|
|
56ff8290c1 | |
|
|
1d7d38a82c |
|
|
@ -767,7 +767,65 @@
|
|||
"Bash(git -C \"c:\\\\Users\\\\dong\\\\Desktop\\\\rwadurian\" commit -m \"$\\(cat <<''EOF''\nfix\\(mining-app\\): update splash page theme and fix token refresh\n\n- Update splash_page.dart to orange theme \\(#FF6B00\\) matching other pages\n- Change app name from \"榴莲挖矿\" to \"榴莲生态\"\n- Fix refreshTokenIfNeeded to properly throw on failure instead of\n silently calling logout \\(which caused Riverpod ref errors\\)\n- Clear local storage directly on refresh failure without remote API call\n\nCo-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>\nEOF\n\\)\")",
|
||||
"Bash(python3 -c \" import sys content = sys.stdin.read\\(\\) old = '''''' done # 清空 processed_cdc_events 表(因为 migration 时可能已经消费了一些消息) # 这是事务性幂等消费的关键:重置 Kafka offset 后必须同时清空幂等记录 log_info \"\"Truncating processed_cdc_events tables to allow re-consumption...\"\" for db in \"\"rwa_contribution\"\" \"\"rwa_auth\"\"; do if run_psql \"\"$db\"\" \"\"TRUNCATE TABLE processed_cdc_events;\"\" 2>/dev/null; then log_success \"\"Truncated processed_cdc_events in $db\"\" else log_warn \"\"Could not truncate processed_cdc_events in $db \\(table may not exist yet\\)\"\" fi done log_step \"\"Step 9/18: Starting 2.0 services...\"\"'''''' new = '''''' done # 清空 processed_cdc_events 表(因为 migration 时可能已经消费了一些消息) # 这是事务性幂等消费的关键:重置 Kafka offset 后必须同时清空幂等记录 log_info \"\"Truncating processed_cdc_events tables to allow re-consumption...\"\" for db in \"\"rwa_contribution\"\" \"\"rwa_auth\"\"; do if run_psql \"\"$db\"\" \"\"TRUNCATE TABLE processed_cdc_events;\"\" 2>/dev/null; then log_success \"\"Truncated processed_cdc_events in $db\"\" else log_warn \"\"Could not truncate processed_cdc_events in $db \\(table may not exist yet\\)\"\" fi done log_step \"\"Step 9/18: Starting 2.0 services...\"\"'''''' print\\(content.replace\\(old, new\\)\\) \")",
|
||||
"Bash(git rm:*)",
|
||||
"Bash(echo \"请在服务器运行以下命令检查 outbox 事件:\n\ndocker exec -it rwa-postgres psql -U rwa_user -d rwa_contribution -c \"\"\nSELECT id, event_type, aggregate_id, \n payload->>''sourceType'' as source_type,\n payload->>''accountSequence'' as account_seq,\n payload->>''sourceAccountSequence'' as source_account_seq,\n payload->>''bonusTier'' as bonus_tier\nFROM outbox_events \nWHERE payload->>''accountSequence'' = ''D25122900007''\nORDER BY id;\n\"\"\")"
|
||||
"Bash(echo \"请在服务器运行以下命令检查 outbox 事件:\n\ndocker exec -it rwa-postgres psql -U rwa_user -d rwa_contribution -c \"\"\nSELECT id, event_type, aggregate_id, \n payload->>''sourceType'' as source_type,\n payload->>''accountSequence'' as account_seq,\n payload->>''sourceAccountSequence'' as source_account_seq,\n payload->>''bonusTier'' as bonus_tier\nFROM outbox_events \nWHERE payload->>''accountSequence'' = ''D25122900007''\nORDER BY id;\n\"\"\")",
|
||||
"Bash(ssh -o ConnectTimeout=10 ceshi@14.215.128.96 'find /home/ceshi/rwadurian/frontend/mining-admin-web -name \"\"*.tsx\"\" -o -name \"\"*.ts\"\" | xargs grep -l \"\"用户管理\\\\|users\"\" 2>/dev/null | head -10')",
|
||||
"Bash(dir /s /b \"c:\\\\Users\\\\dong\\\\Desktop\\\\rwadurian\")",
|
||||
"Bash(dir /b \"c:\\\\Users\\\\dong\\\\Desktop\\\\rwadurian\\\\backend\\\\services\")",
|
||||
"Bash(ssh -J ceshi@103.39.231.231 ceshi@192.168.1.111 \"curl -s http://localhost:3021/api/v2/admin/status\")",
|
||||
"Bash(del \"c:\\\\Users\\\\dong\\\\Desktop\\\\rwadurian\\\\frontend\\\\mining-app\\\\lib\\\\domain\\\\usecases\\\\trading\\\\buy_shares.dart\")",
|
||||
"Bash(del \"c:\\\\Users\\\\dong\\\\Desktop\\\\rwadurian\\\\frontend\\\\mining-app\\\\lib\\\\domain\\\\usecases\\\\trading\\\\sell_shares.dart\")",
|
||||
"Bash(ls -la \"c:\\\\Users\\\\dong\\\\Desktop\\\\rwadurian\\\\frontend\\\\mining-app\\\\lib\\\\presentation\\\\pages\"\" 2>/dev/null || dir /b \"c:UsersdongDesktoprwadurianfrontendmining-applibpresentationpages \")",
|
||||
"Bash(cd:*)",
|
||||
"Bash(ssh -o StrictHostKeyChecking=no -J ceshi@103.39.231.231 ceshi@192.168.1.111 \"curl -s http://localhost:3020/api/v1/ | head -100\")",
|
||||
"Bash(ssh -o StrictHostKeyChecking=no -J ceshi@103.39.231.231 ceshi@192.168.1.111:*)",
|
||||
"Bash(bc:*)",
|
||||
"Bash(DATABASE_URL=\"postgresql://postgres:password@localhost:5432/mining_db?schema=public\" npx prisma migrate diff:*)",
|
||||
"Bash(git status:*)",
|
||||
"Bash(xargs cat:*)",
|
||||
"Bash(ssh -o ProxyJump=ceshi@103.39.231.231 ceshi@192.168.1.111 \"docker ps | grep mining\")",
|
||||
"Bash(dir /b \"c:\\\\Users\\\\dong\\\\Desktop\\\\rwadurian\\\\backend\\\\services\\\\trading-service\\\\src\\\\application\\\\services\")",
|
||||
"Bash(DATABASE_URL=\"postgresql://postgres:password@localhost:5432/trading_db?schema=public\" npx prisma migrate dev:*)",
|
||||
"Bash(dir /b \"c:\\\\Users\\\\dong\\\\Desktop\\\\rwadurian\\\\backend\\\\services\\\\mining-admin-service\\\\src\")",
|
||||
"Bash(ssh -o ProxyJump=ceshi@103.39.231.231 ceshi@192.168.1.111 \"cd /home/ceshi/rwadurian/backend/service && ls -la\")",
|
||||
"Bash(ssh -o ProxyJump=ceshi@103.39.231.231 ceshi@192.168.1.111 \"ls -la /home/ceshi/rwadurian/backend/\")",
|
||||
"Bash(ssh -o ProxyJump=ceshi@103.39.231.231 ceshi@192.168.1.111 \"ls -la /home/ceshi/rwadurian/backend/services/\")",
|
||||
"Bash(where:*)",
|
||||
"Bash(npx md-to-pdf:*)",
|
||||
"Bash(ssh -J ceshi@103.39.231.231 ceshi@192.168.1.111 \"curl -s ''http://localhost:3000/api/price/klines?period=1h&limit=5'' | head -500\")",
|
||||
"Bash(dir /b /ad \"c:\\\\Users\\\\dong\\\\Desktop\\\\rwadurian\\\\backend\")",
|
||||
"Bash(timeout 30 cat:*)",
|
||||
"Bash(npm run lint)",
|
||||
"Bash(ssh -o ProxyCommand=\"ssh -W %h:%p ceshi@103.39.231.231\" -o StrictHostKeyChecking=no ceshi@192.168.1.111 \"cat /home/ceshi/rwadurian/backend/services/mining-service/src/application/services/batch-mining.service.ts | head -250\")",
|
||||
"Bash(ssh -o ProxyCommand=\"ssh -W %h:%p ceshi@103.39.231.231\" -o StrictHostKeyChecking=no ceshi@192.168.1.111 \"docker logs rwa-mining-admin-service --tail 50 2>&1 | grep ''第一条数据\\\\|最后一条数据''\")",
|
||||
"Bash(npx xlsx-cli 挖矿.xlsx)",
|
||||
"Bash(DATABASE_URL=\"postgresql://postgres:password@localhost:5432/mining_db?schema=public\" npx prisma migrate dev:*)",
|
||||
"Bash(md-to-pdf:*)",
|
||||
"Bash(dir \"c:\\\\Users\\\\dong\\\\Desktop\\\\rwadurian\\\\docs\\\\deployment\\\\*.pdf\")",
|
||||
"Bash(./gradlew compileDebugKotlin:*)",
|
||||
"Bash(cmd.exe /c \"cd /d c:\\\\Users\\\\dong\\\\Desktop\\\\rwadurian\\\\backend\\\\mpc-system\\\\services\\\\service-party-android && gradlew.bat :app:compileDebugKotlin --no-daemon\")",
|
||||
"Bash(powershell -Command \"Set-Location 'c:\\\\Users\\\\dong\\\\Desktop\\\\rwadurian\\\\backend\\\\mpc-system\\\\services\\\\service-party-android'; .\\\\gradlew.bat :app:compileDebugKotlin --no-daemon 2>&1\":*)",
|
||||
"Bash(powershell -Command \"Set-Location ''c:\\\\Users\\\\dong\\\\Desktop\\\\rwadurian\\\\backend\\\\mpc-system\\\\services\\\\service-party-android''; .\\\\gradlew.bat :app:compileDebugKotlin --no-daemon 2>&1 | Select-Object -Last 20\")",
|
||||
"Bash(cmd.exe /c \"gradlew.bat installDebug && adb logcat -c && adb logcat | findstr /C:\"\"EXPORT\"\" /C:\"\"IMPORT\"\" /C:\"\"STATE\"\"\")",
|
||||
"Bash(./gradlew:*)",
|
||||
"Bash(adb shell \"run-as com.durian.tssparty sqlite3 /data/data/com.durian.tssparty/databases/tss_party.db ''SELECT id, tx_hash, from_address, to_address, amount, token_type, status, direction, created_at FROM transaction_records ORDER BY id DESC LIMIT 5;''\")",
|
||||
"WebFetch(domain:docs.kava.io)",
|
||||
"WebFetch(domain:kavascan.com)",
|
||||
"Bash(.gradlew.bat compileDebugKotlin:*)",
|
||||
"WebFetch(domain:github.com)",
|
||||
"WebFetch(domain:oneuptime.com)",
|
||||
"Bash(gradlew.bat assembleDebug:*)",
|
||||
"Bash(cmd /c \"gradlew.bat assembleDebug --no-daemon\")",
|
||||
"Bash(./build-install-debug.bat)",
|
||||
"Bash(dir /s /b \"backend\\\\mpc-system\\\\services\\\\service-party-android\\\\*.kt\")",
|
||||
"Bash(set DATABASE_URL=postgresql://postgres:password@localhost:5432/trading_db?schema=public)",
|
||||
"Bash(ssh -o ProxyJump=ceshi@103.39.231.231 -o StrictHostKeyChecking=no ceshi@192.168.1.111 \"curl -s ''http://localhost:3000/api/v2/trading/asset/account/D25122700015'' | jq .\")",
|
||||
"Bash(ssh -o ProxyJump=ceshi@103.39.231.231 -o StrictHostKeyChecking=no ceshi@192.168.1.111 \"curl -s ''http://localhost:3000/api/v2/trading/trading/orders?accountSequence=D25122700015'' | jq .\")",
|
||||
"Bash(docker stop:*)",
|
||||
"Bash(ssh-add:*)",
|
||||
"Bash(ls -la \"c:\\\\Users\\\\dong\\\\Desktop\\\\rwadurian\\\\backend\\\\services\\\\auth-service\\\\src\"\" 2>/dev/null || echo \"Source directory structure: \")",
|
||||
"Bash($env:DATABASE_URL=\"postgresql://postgres:postgres@localhost:5432/rwa_auth\")",
|
||||
"Bash(DATABASE_URL=\"postgresql://postgres:postgres@localhost:5432/rwa_auth\" npx prisma migrate dev:*)",
|
||||
"Bash(ssh -J ceshi@103.39.231.231 ceshi@192.168.1.111:*)"
|
||||
],
|
||||
"deny": [],
|
||||
"ask": []
|
||||
|
|
|
|||
|
|
@ -120,6 +120,45 @@ cmd_up() {
|
|||
fi
|
||||
}
|
||||
|
||||
# 启动服务 (2.0 standalone 模式)
|
||||
# 使用 docker-compose.standalone.yml override:
|
||||
# - Kong 加 extra_hosts: host.docker.internal (访问同机 2.0 服务)
|
||||
# - kong-config 加载 kong-standalone.yml (2.0 → localhost, 1.0 → 192.168.1.111)
|
||||
cmd_up2() {
|
||||
log_info "启动 Kong API Gateway (standalone 模式)..."
|
||||
check_backend
|
||||
|
||||
local STANDALONE="$COMPOSE_CMD -f docker-compose.yml -f docker-compose.standalone.yml"
|
||||
$STANDALONE up -d
|
||||
|
||||
log_info "等待 Kong 启动..."
|
||||
sleep 10
|
||||
|
||||
if docker ps | grep -q rwa-kong; then
|
||||
log_success "Kong API Gateway (standalone) 启动成功!"
|
||||
echo ""
|
||||
echo "模式: standalone (2.0 → host.docker.internal, 1.0 → 192.168.1.111)"
|
||||
echo "服务地址:"
|
||||
echo " Proxy: http://localhost:8000"
|
||||
echo " Admin API: http://localhost:8001"
|
||||
echo " Admin GUI: http://localhost:8002"
|
||||
echo ""
|
||||
else
|
||||
log_error "Kong 启动失败,查看日志: $STANDALONE logs"
|
||||
exit 1
|
||||
fi
|
||||
}
|
||||
|
||||
# 重新同步 standalone 配置
|
||||
cmd_sync2() {
|
||||
log_info "同步 kong-standalone.yml 到 Kong..."
|
||||
local STANDALONE="$COMPOSE_CMD -f docker-compose.yml -f docker-compose.standalone.yml"
|
||||
$STANDALONE run --rm kong-config
|
||||
log_success "standalone 配置同步完成"
|
||||
echo ""
|
||||
echo "查看路由: ./deploy.sh routes"
|
||||
}
|
||||
|
||||
# 停止服务
|
||||
cmd_down() {
|
||||
log_info "停止 Kong API Gateway..."
|
||||
|
|
@ -268,6 +307,127 @@ cmd_metrics() {
|
|||
fi
|
||||
}
|
||||
|
||||
# 安装 Nginx + SSL 证书 (新域名)
|
||||
cmd_nginx_install() {
|
||||
local domain="${1:-mapi.szaiai.com}"
|
||||
local email="${2:-admin@szaiai.com}"
|
||||
local conf_file="$SCRIPT_DIR/nginx/${domain}.conf"
|
||||
|
||||
log_info "为域名 $domain 安装 Nginx + SSL..."
|
||||
|
||||
# 检查 conf 文件是否存在
|
||||
if [ ! -f "$conf_file" ]; then
|
||||
log_error "Nginx 配置文件不存在: $conf_file"
|
||||
log_error "请先在 nginx/ 目录下创建 ${domain}.conf"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# 检查 root 权限
|
||||
if [ "$EUID" -ne 0 ]; then
|
||||
log_error "需要 root 权限: sudo ./deploy.sh nginx install $domain"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# 1. 安装依赖
|
||||
log_info "[1/4] 检查并安装依赖..."
|
||||
if ! command -v nginx &> /dev/null; then
|
||||
apt update && apt install -y nginx
|
||||
systemctl enable nginx
|
||||
systemctl start nginx
|
||||
fi
|
||||
log_success "Nginx 已就绪"
|
||||
|
||||
if ! command -v certbot &> /dev/null; then
|
||||
apt install -y certbot python3-certbot-nginx
|
||||
fi
|
||||
log_success "Certbot 已就绪"
|
||||
|
||||
# 2. 部署 HTTP 临时配置
|
||||
log_info "[2/4] 部署 HTTP 临时配置..."
|
||||
mkdir -p /var/www/certbot
|
||||
|
||||
cat > /etc/nginx/sites-available/$domain << HTTPEOF
|
||||
server {
|
||||
listen 80;
|
||||
listen [::]:80;
|
||||
server_name $domain;
|
||||
|
||||
location /.well-known/acme-challenge/ {
|
||||
root /var/www/certbot;
|
||||
}
|
||||
|
||||
location / {
|
||||
proxy_pass http://127.0.0.1:8000;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host \$host;
|
||||
proxy_set_header X-Real-IP \$remote_addr;
|
||||
proxy_set_header X-Forwarded-For \$proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto \$scheme;
|
||||
}
|
||||
}
|
||||
HTTPEOF
|
||||
|
||||
ln -sf /etc/nginx/sites-available/$domain /etc/nginx/sites-enabled/
|
||||
nginx -t && systemctl reload nginx
|
||||
log_success "HTTP 配置完成"
|
||||
|
||||
# 3. 申请 SSL 证书
|
||||
log_info "[3/4] 申请 SSL 证书..."
|
||||
if [ -d "/etc/letsencrypt/live/$domain" ]; then
|
||||
log_warn "证书已存在,跳过申请"
|
||||
else
|
||||
echo ""
|
||||
log_warn "请确保 DNS A 记录 $domain 已指向本服务器 IP"
|
||||
read -p "继续申请证书? (y/n): " confirm
|
||||
if [ "$confirm" != "y" ] && [ "$confirm" != "Y" ]; then
|
||||
log_info "已跳过,当前为 HTTP 模式。稍后运行: sudo ./deploy.sh nginx ssl $domain"
|
||||
return 0
|
||||
fi
|
||||
certbot certonly --webroot --webroot-path=/var/www/certbot \
|
||||
--email $email --agree-tos --no-eff-email -d $domain
|
||||
fi
|
||||
log_success "SSL 证书就绪"
|
||||
|
||||
# 4. 部署 HTTPS 完整配置
|
||||
log_info "[4/4] 部署 HTTPS 配置..."
|
||||
cp "$conf_file" /etc/nginx/sites-available/$domain
|
||||
nginx -t && systemctl reload nginx
|
||||
|
||||
log_success "$domain 配置完成!"
|
||||
echo ""
|
||||
echo -e " 访问地址: ${BLUE}https://$domain${NC}"
|
||||
echo -e " 查看日志: tail -f /var/log/nginx/${domain}.access.log"
|
||||
echo ""
|
||||
}
|
||||
|
||||
# 仅申请/续期 SSL 证书
|
||||
cmd_nginx_ssl() {
|
||||
local domain="${1:-mapi.szaiai.com}"
|
||||
local email="${2:-admin@szaiai.com}"
|
||||
local conf_file="$SCRIPT_DIR/nginx/${domain}.conf"
|
||||
|
||||
if [ "$EUID" -ne 0 ]; then
|
||||
log_error "需要 root 权限: sudo ./deploy.sh nginx ssl $domain"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [ -d "/etc/letsencrypt/live/$domain" ]; then
|
||||
log_info "证书已存在,尝试续期..."
|
||||
certbot renew --cert-name $domain
|
||||
else
|
||||
log_info "为 $domain 申请 SSL 证书..."
|
||||
certbot certonly --webroot --webroot-path=/var/www/certbot \
|
||||
--email $email --agree-tos --no-eff-email -d $domain
|
||||
fi
|
||||
|
||||
# 部署 HTTPS 配置
|
||||
if [ -f "$conf_file" ]; then
|
||||
cp "$conf_file" /etc/nginx/sites-available/$domain
|
||||
nginx -t && systemctl reload nginx
|
||||
log_success "HTTPS 配置已部署"
|
||||
fi
|
||||
}
|
||||
|
||||
# 显示帮助
|
||||
show_help() {
|
||||
echo ""
|
||||
|
|
@ -289,6 +449,14 @@ show_help() {
|
|||
echo " test 测试 API 路由"
|
||||
echo " clean 清理容器和数据"
|
||||
echo ""
|
||||
echo "Standalone 模式 (2.0 服务与 Kong 同机):"
|
||||
echo " up2 启动 Kong (standalone, 2.0 → host.docker.internal)"
|
||||
echo " sync2 重新同步 kong-standalone.yml 配置"
|
||||
echo ""
|
||||
echo "Nginx 命令:"
|
||||
echo " nginx install [domain] 安装 Nginx + SSL 证书 (默认: mapi.szaiai.com)"
|
||||
echo " nginx ssl [domain] 申请/续期 SSL 证书"
|
||||
echo ""
|
||||
echo "监控命令:"
|
||||
echo " monitoring install [domain] 一键安装监控 (Nginx+SSL+服务)"
|
||||
echo " monitoring up 启动监控栈"
|
||||
|
|
@ -343,6 +511,27 @@ main() {
|
|||
clean)
|
||||
cmd_clean
|
||||
;;
|
||||
up2)
|
||||
cmd_up2
|
||||
;;
|
||||
sync2)
|
||||
cmd_sync2
|
||||
;;
|
||||
nginx)
|
||||
case "${2:-install}" in
|
||||
install)
|
||||
cmd_nginx_install "$3" "$4"
|
||||
;;
|
||||
ssl)
|
||||
cmd_nginx_ssl "$3" "$4"
|
||||
;;
|
||||
*)
|
||||
log_error "未知 nginx 命令: $2"
|
||||
echo "用法: ./deploy.sh nginx [install|ssl] [domain]"
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
;;
|
||||
monitoring)
|
||||
case "${2:-up}" in
|
||||
install)
|
||||
|
|
|
|||
|
|
@ -0,0 +1,15 @@
|
|||
# =============================================================================
|
||||
# Kong Standalone Override - 2.0 服务与 Kong 同机部署
|
||||
# =============================================================================
|
||||
# 用法: ./deploy.sh up2
|
||||
# 等价于: docker compose -f docker-compose.yml -f docker-compose.standalone.yml up -d
|
||||
# =============================================================================
|
||||
|
||||
services:
|
||||
kong:
|
||||
extra_hosts:
|
||||
- "host.docker.internal:host-gateway"
|
||||
|
||||
kong-config:
|
||||
volumes:
|
||||
- ./kong-standalone.yml:/etc/kong/kong.yml:ro
|
||||
|
|
@ -0,0 +1,390 @@
|
|||
# =============================================================================
|
||||
# Kong API Gateway - 2.0 Standalone 声明式配置
|
||||
# =============================================================================
|
||||
# 部署说明:
|
||||
# - Kong + 2.0 服务: 同一台物理机 (192.168.1.10)
|
||||
# - 1.0 后端服务器: 192.168.1.111
|
||||
# - 2.0 服务通过 host.docker.internal 访问宿主机端口 (无需走局域网)
|
||||
#
|
||||
# 使用方法:
|
||||
# ./deploy.sh up2 # 启动 Kong 并加载此配置
|
||||
# ./deploy.sh sync2 # 仅重新同步此配置
|
||||
# =============================================================================
|
||||
|
||||
_format_version: "3.0"
|
||||
_transform: true
|
||||
|
||||
# =============================================================================
|
||||
# Services
|
||||
# =============================================================================
|
||||
services:
|
||||
# ===========================================================================
|
||||
# 1.0 Services → 192.168.1.111 (通过局域网)
|
||||
# ===========================================================================
|
||||
|
||||
- name: identity-service
|
||||
url: http://192.168.1.111:3000
|
||||
routes:
|
||||
- name: identity-auth
|
||||
paths:
|
||||
- /api/v1/auth
|
||||
strip_path: false
|
||||
- name: identity-me
|
||||
paths:
|
||||
- /api/v1/me
|
||||
strip_path: false
|
||||
- name: identity-user
|
||||
paths:
|
||||
- /api/v1/user
|
||||
strip_path: false
|
||||
- name: identity-users
|
||||
paths:
|
||||
- /api/v1/users
|
||||
strip_path: false
|
||||
- name: identity-health
|
||||
paths:
|
||||
- /api/v1/identity/health
|
||||
strip_path: true
|
||||
- name: identity-admin-pending-actions
|
||||
paths:
|
||||
- /api/v1/admin/pending-actions
|
||||
strip_path: false
|
||||
|
||||
- name: wallet-service
|
||||
url: http://192.168.1.111:3001
|
||||
routes:
|
||||
- name: wallet-api
|
||||
paths:
|
||||
- /api/v1/wallets
|
||||
strip_path: false
|
||||
- name: wallet-main
|
||||
paths:
|
||||
- /api/v1/wallet
|
||||
strip_path: false
|
||||
- name: wallet-health
|
||||
paths:
|
||||
- /api/v1/wallet-service/health
|
||||
strip_path: true
|
||||
|
||||
- name: backup-service
|
||||
url: http://192.168.1.111:3002
|
||||
routes:
|
||||
- name: backup-api
|
||||
paths:
|
||||
- /api/v1/backups
|
||||
strip_path: false
|
||||
- name: backup-share-api
|
||||
paths:
|
||||
- /api/v1/backup-share
|
||||
strip_path: false
|
||||
|
||||
- name: planting-service
|
||||
url: http://192.168.1.111:3003
|
||||
routes:
|
||||
- name: planting-api
|
||||
paths:
|
||||
- /api/v1/planting
|
||||
strip_path: false
|
||||
|
||||
- name: referral-service
|
||||
url: http://192.168.1.111:3004
|
||||
routes:
|
||||
- name: referral-api
|
||||
paths:
|
||||
- /api/v1/referral
|
||||
strip_path: false
|
||||
- name: referral-referrals
|
||||
paths:
|
||||
- /api/v1/referrals
|
||||
strip_path: false
|
||||
- name: referral-team-statistics
|
||||
paths:
|
||||
- /api/v1/team-statistics
|
||||
strip_path: false
|
||||
|
||||
- name: reward-service
|
||||
url: http://192.168.1.111:3005
|
||||
routes:
|
||||
- name: reward-api
|
||||
paths:
|
||||
- /api/v1/rewards
|
||||
strip_path: false
|
||||
|
||||
- name: mpc-service
|
||||
url: http://192.168.1.111:3006
|
||||
routes:
|
||||
- name: mpc-api
|
||||
paths:
|
||||
- /api/v1/mpc
|
||||
strip_path: false
|
||||
- name: mpc-party-api
|
||||
paths:
|
||||
- /api/v1/mpc-party
|
||||
strip_path: false
|
||||
|
||||
- name: leaderboard-service
|
||||
url: http://192.168.1.111:3007
|
||||
routes:
|
||||
- name: leaderboard-api
|
||||
paths:
|
||||
- /api/v1/leaderboard
|
||||
strip_path: false
|
||||
- name: leaderboard-virtual-accounts
|
||||
paths:
|
||||
- /api/v1/virtual-accounts
|
||||
strip_path: false
|
||||
|
||||
- name: reporting-service
|
||||
url: http://192.168.1.111:3008
|
||||
routes:
|
||||
- name: reporting-dashboard
|
||||
paths:
|
||||
- /api/v1/dashboard
|
||||
strip_path: false
|
||||
- name: reporting-api
|
||||
paths:
|
||||
- /api/v1/reports
|
||||
strip_path: false
|
||||
- name: reporting-export
|
||||
paths:
|
||||
- /api/v1/export
|
||||
strip_path: false
|
||||
- name: reporting-system-accounts
|
||||
paths:
|
||||
- /api/v1/system-account-reports
|
||||
strip_path: false
|
||||
|
||||
- name: authorization-service
|
||||
url: http://192.168.1.111:3009
|
||||
routes:
|
||||
- name: authorization-api
|
||||
paths:
|
||||
- /api/v1/authorizations
|
||||
strip_path: false
|
||||
- name: authorization-admin
|
||||
paths:
|
||||
- /api/v1/admin/authorizations
|
||||
strip_path: false
|
||||
|
||||
- name: admin-service
|
||||
url: http://192.168.1.111:3010
|
||||
routes:
|
||||
- name: admin-versions
|
||||
paths:
|
||||
- /api/v1/versions
|
||||
strip_path: false
|
||||
- name: admin-api
|
||||
paths:
|
||||
- /api/v1/admin
|
||||
strip_path: false
|
||||
- name: admin-mobile-version
|
||||
paths:
|
||||
- /api/app/version
|
||||
strip_path: false
|
||||
- name: admin-downloads
|
||||
paths:
|
||||
- /downloads
|
||||
strip_path: false
|
||||
- name: admin-mobile-notifications
|
||||
paths:
|
||||
- /api/v1/mobile/notifications
|
||||
strip_path: false
|
||||
- name: admin-mobile-system
|
||||
paths:
|
||||
- /api/v1/mobile/system
|
||||
strip_path: false
|
||||
|
||||
- name: presence-service
|
||||
url: http://192.168.1.111:3011
|
||||
routes:
|
||||
- name: presence-api
|
||||
paths:
|
||||
- /api/v1/presence
|
||||
strip_path: false
|
||||
- name: presence-analytics
|
||||
paths:
|
||||
- /api/v1/analytics
|
||||
strip_path: false
|
||||
|
||||
- name: blockchain-service
|
||||
url: http://192.168.1.111:3012
|
||||
routes:
|
||||
- name: blockchain-deposit
|
||||
paths:
|
||||
- /api/v1/deposit
|
||||
strip_path: false
|
||||
- name: blockchain-balance
|
||||
paths:
|
||||
- /api/v1/balance
|
||||
strip_path: false
|
||||
|
||||
- name: mpc-account-service
|
||||
url: http://192.168.1.111:4000
|
||||
routes:
|
||||
- name: mpc-co-managed
|
||||
paths:
|
||||
- /api/v1/co-managed
|
||||
strip_path: false
|
||||
|
||||
# ===========================================================================
|
||||
# 2.0 Services → host.docker.internal (同一台物理机,通过宿主机端口)
|
||||
# ===========================================================================
|
||||
|
||||
- name: contribution-service-v2
|
||||
url: http://host.docker.internal:3020
|
||||
routes:
|
||||
- name: contribution-v2-api
|
||||
paths:
|
||||
- /api/v2/contribution
|
||||
strip_path: false
|
||||
- name: contribution-v2-health
|
||||
paths:
|
||||
- /api/v2/contribution/health
|
||||
strip_path: false
|
||||
|
||||
- name: mining-service-v2
|
||||
url: http://host.docker.internal:3021
|
||||
routes:
|
||||
- name: mining-v2-api
|
||||
paths:
|
||||
- /api/v2/mining
|
||||
strip_path: false
|
||||
- name: mining-v2-health
|
||||
paths:
|
||||
- /api/v2/mining/health
|
||||
strip_path: false
|
||||
|
||||
- name: trading-service-v2
|
||||
url: http://host.docker.internal:3022/api/v2
|
||||
routes:
|
||||
- name: trading-v2-api
|
||||
paths:
|
||||
- /api/v2/trading
|
||||
strip_path: true
|
||||
- name: trading-v2-health
|
||||
paths:
|
||||
- /api/v2/trading/health
|
||||
strip_path: true
|
||||
|
||||
- name: trading-ws-service
|
||||
url: http://host.docker.internal:3022
|
||||
routes:
|
||||
- name: trading-ws-price
|
||||
paths:
|
||||
- /ws/price
|
||||
strip_path: true
|
||||
protocols:
|
||||
- http
|
||||
- https
|
||||
|
||||
- name: mining-admin-service
|
||||
url: http://host.docker.internal:3023/api/v2
|
||||
routes:
|
||||
- name: mining-admin-api
|
||||
paths:
|
||||
- /api/v2/mining-admin
|
||||
strip_path: true
|
||||
- name: mining-admin-health
|
||||
paths:
|
||||
- /api/v2/mining-admin/health
|
||||
strip_path: true
|
||||
|
||||
- name: mining-admin-upgrade-service
|
||||
url: http://host.docker.internal:3023
|
||||
routes:
|
||||
- name: mining-admin-upgrade
|
||||
paths:
|
||||
- /mining-admin
|
||||
strip_path: true
|
||||
|
||||
- name: auth-service-v2
|
||||
url: http://host.docker.internal:3024
|
||||
routes:
|
||||
- name: auth-v2-api
|
||||
paths:
|
||||
- /api/v2/auth
|
||||
strip_path: false
|
||||
- name: auth-v2-health
|
||||
paths:
|
||||
- /api/v2/auth/health
|
||||
strip_path: false
|
||||
|
||||
- name: mining-wallet-service
|
||||
url: http://host.docker.internal:3025/api/v2
|
||||
routes:
|
||||
- name: mining-wallet-api
|
||||
paths:
|
||||
- /api/v2/mining-wallet
|
||||
strip_path: true
|
||||
- name: mining-wallet-health
|
||||
paths:
|
||||
- /api/v2/mining-wallet/health
|
||||
strip_path: true
|
||||
|
||||
- name: mining-blockchain-service
|
||||
url: http://host.docker.internal:3026
|
||||
routes:
|
||||
- name: mining-blockchain-api
|
||||
paths:
|
||||
- /api/v1/mining-blockchain
|
||||
strip_path: false
|
||||
|
||||
# =============================================================================
|
||||
# Plugins
|
||||
# =============================================================================
|
||||
plugins:
|
||||
- name: cors
|
||||
config:
|
||||
origins:
|
||||
- "https://rwaadmin.szaiai.com"
|
||||
- "https://madmin.szaiai.com"
|
||||
- "https://mapi.szaiai.com"
|
||||
- "https://update.szaiai.com"
|
||||
- "https://app.rwadurian.com"
|
||||
- "http://localhost:3000"
|
||||
- "http://localhost:3020"
|
||||
- "http://localhost:3100"
|
||||
methods:
|
||||
- GET
|
||||
- POST
|
||||
- PUT
|
||||
- PATCH
|
||||
- DELETE
|
||||
- OPTIONS
|
||||
headers:
|
||||
- Accept
|
||||
- Accept-Version
|
||||
- Content-Length
|
||||
- Content-MD5
|
||||
- Content-Type
|
||||
- Date
|
||||
- Authorization
|
||||
- X-Auth-Token
|
||||
exposed_headers:
|
||||
- X-Auth-Token
|
||||
credentials: true
|
||||
max_age: 3600
|
||||
|
||||
- name: rate-limiting
|
||||
config:
|
||||
minute: 10000
|
||||
hour: 500000
|
||||
policy: local
|
||||
|
||||
- name: file-log
|
||||
config:
|
||||
path: /tmp/kong-access.log
|
||||
reopen: true
|
||||
|
||||
- name: request-size-limiting
|
||||
config:
|
||||
allowed_payload_size: 500
|
||||
size_unit: megabytes
|
||||
|
||||
- name: prometheus
|
||||
config:
|
||||
per_consumer: true
|
||||
status_code_metrics: true
|
||||
latency_metrics: true
|
||||
bandwidth_metrics: true
|
||||
upstream_health_metrics: true
|
||||
|
|
@ -228,6 +228,18 @@ services:
|
|||
paths:
|
||||
- /api/v1/mobile/system
|
||||
strip_path: false
|
||||
- name: admin-app-assets-public
|
||||
paths:
|
||||
- /api/v1/app-assets
|
||||
strip_path: false
|
||||
- name: admin-system-config-public
|
||||
paths:
|
||||
- /api/v1/system-config
|
||||
strip_path: false
|
||||
- name: admin-customer-service-contacts-public
|
||||
paths:
|
||||
- /api/v1/customer-service-contacts
|
||||
strip_path: false
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Presence Service - 在线状态服务
|
||||
|
|
@ -309,24 +321,42 @@ services:
|
|||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Trading Service 2.0 - 交易服务
|
||||
# 前端路径: /api/v2/trading/... -> 后端路径: /api/v2/...
|
||||
# ---------------------------------------------------------------------------
|
||||
- name: trading-service-v2
|
||||
url: http://192.168.1.111:3022
|
||||
url: http://192.168.1.111:3022/api/v2
|
||||
routes:
|
||||
- name: trading-v2-api
|
||||
paths:
|
||||
- /api/v2/trading
|
||||
strip_path: false
|
||||
strip_path: true
|
||||
- name: trading-v2-health
|
||||
paths:
|
||||
- /api/v2/trading/health
|
||||
strip_path: false
|
||||
strip_path: true
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Trading Service WebSocket - 价格实时推送
|
||||
# WebSocket 连接: wss://api.xxx.com/ws/price -> ws://192.168.1.111:3022/price
|
||||
# Kong 会自动处理 HTTP -> WebSocket 升级,所以 protocols 只需要 http/https
|
||||
# ---------------------------------------------------------------------------
|
||||
- name: trading-ws-service
|
||||
url: http://192.168.1.111:3022
|
||||
routes:
|
||||
- name: trading-ws-price
|
||||
paths:
|
||||
- /ws/price
|
||||
strip_path: true
|
||||
protocols:
|
||||
- http
|
||||
- https
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Mining Admin Service 2.0 - 挖矿管理后台服务
|
||||
# 前端路径: /api/v2/mining-admin/... -> 后端路径: /api/v2/...
|
||||
# ---------------------------------------------------------------------------
|
||||
- name: mining-admin-service
|
||||
url: http://192.168.1.111:3023/api/v1
|
||||
url: http://192.168.1.111:3023/api/v2
|
||||
routes:
|
||||
- name: mining-admin-api
|
||||
paths:
|
||||
|
|
@ -337,6 +367,19 @@ services:
|
|||
- /api/v2/mining-admin/health
|
||||
strip_path: true
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Mining Admin Service 2.0 - 版本管理(供 mobile-upgrade 前端使用)
|
||||
# 前端路径: /mining-admin/api/v2/... -> 后端路径: /api/v2/...
|
||||
# 注意: 不带 /api/v2 service path,因为前端 URL 已包含 /api/v2
|
||||
# ---------------------------------------------------------------------------
|
||||
- name: mining-admin-upgrade-service
|
||||
url: http://192.168.1.111:3023
|
||||
routes:
|
||||
- name: mining-admin-upgrade
|
||||
paths:
|
||||
- /mining-admin
|
||||
strip_path: true
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Auth Service 2.0 - 用户认证服务
|
||||
# 前端路径: /api/v2/auth/...
|
||||
|
|
@ -356,18 +399,19 @@ services:
|
|||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Mining Wallet Service 2.0 - 挖矿钱包服务
|
||||
# 前端路径: /api/v2/mining-wallet/... -> 后端路径: /api/v2/...
|
||||
# ---------------------------------------------------------------------------
|
||||
- name: mining-wallet-service
|
||||
url: http://192.168.1.111:3025
|
||||
url: http://192.168.1.111:3025/api/v2
|
||||
routes:
|
||||
- name: mining-wallet-api
|
||||
paths:
|
||||
- /api/v2/mining-wallet
|
||||
strip_path: false
|
||||
strip_path: true
|
||||
- name: mining-wallet-health
|
||||
paths:
|
||||
- /api/v2/mining-wallet/health
|
||||
strip_path: false
|
||||
strip_path: true
|
||||
|
||||
# =============================================================================
|
||||
# Plugins - 全局插件配置
|
||||
|
|
|
|||
|
|
@ -0,0 +1,88 @@
|
|||
# RWADurian Mining Admin Web - Nginx 配置
|
||||
# 域名: madmin.szaiai.com
|
||||
# 后端: mining-admin-web (Next.js, 端口 3100)
|
||||
# API: 由 Next.js SSR rewrite 转发到 mapi.szaiai.com (Kong)
|
||||
|
||||
# HTTP 重定向到 HTTPS
|
||||
server {
|
||||
listen 80;
|
||||
listen [::]:80;
|
||||
server_name madmin.szaiai.com;
|
||||
|
||||
# Let's Encrypt 验证目录
|
||||
location /.well-known/acme-challenge/ {
|
||||
root /var/www/certbot;
|
||||
}
|
||||
|
||||
# 重定向到 HTTPS
|
||||
location / {
|
||||
return 301 https://$host$request_uri;
|
||||
}
|
||||
}
|
||||
|
||||
# HTTPS 配置
|
||||
server {
|
||||
listen 443 ssl http2;
|
||||
listen [::]:443 ssl http2;
|
||||
server_name madmin.szaiai.com;
|
||||
|
||||
# SSL 证书 (Let's Encrypt)
|
||||
ssl_certificate /etc/letsencrypt/live/madmin.szaiai.com/fullchain.pem;
|
||||
ssl_certificate_key /etc/letsencrypt/live/madmin.szaiai.com/privkey.pem;
|
||||
|
||||
# SSL 配置优化
|
||||
ssl_session_timeout 1d;
|
||||
ssl_session_cache shared:SSL:50m;
|
||||
ssl_session_tickets off;
|
||||
|
||||
ssl_protocols TLSv1.2 TLSv1.3;
|
||||
ssl_ciphers ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256:ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-GCM-SHA384:ECDHE-ECDSA-CHACHA20-POLY1305:ECDHE-RSA-CHACHA20-POLY1305:DHE-RSA-AES128-GCM-SHA256:DHE-RSA-AES256-GCM-SHA384;
|
||||
ssl_prefer_server_ciphers off;
|
||||
|
||||
# HSTS
|
||||
add_header Strict-Transport-Security "max-age=63072000" always;
|
||||
|
||||
# 日志
|
||||
access_log /var/log/nginx/madmin.szaiai.com.access.log;
|
||||
error_log /var/log/nginx/madmin.szaiai.com.error.log;
|
||||
|
||||
# Gzip
|
||||
gzip on;
|
||||
gzip_vary on;
|
||||
gzip_proxied any;
|
||||
gzip_comp_level 6;
|
||||
gzip_types text/plain text/css text/xml application/json application/javascript application/rss+xml application/atom+xml image/svg+xml;
|
||||
|
||||
# 安全头
|
||||
add_header X-Frame-Options "SAMEORIGIN" always;
|
||||
add_header X-Content-Type-Options "nosniff" always;
|
||||
add_header X-XSS-Protection "1; mode=block" always;
|
||||
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
|
||||
|
||||
# Next.js 静态资源 (长缓存)
|
||||
location /_next/static/ {
|
||||
proxy_pass http://127.0.0.1:3100;
|
||||
proxy_cache_valid 200 365d;
|
||||
add_header Cache-Control "public, max-age=31536000, immutable";
|
||||
}
|
||||
|
||||
# 反向代理到 mining-admin-web (Next.js)
|
||||
location / {
|
||||
proxy_pass http://127.0.0.1:3100;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection 'upgrade';
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_cache_bypass $http_upgrade;
|
||||
}
|
||||
|
||||
# 健康检查
|
||||
location = /health {
|
||||
access_log off;
|
||||
return 200 '{"status":"ok","service":"madmin-nginx"}';
|
||||
add_header Content-Type application/json;
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,99 @@
|
|||
# RWADurian Mining Admin API Gateway Nginx 配置
|
||||
# 域名: mapi.szaiai.com
|
||||
# 后端: Kong API Gateway (端口 8000)
|
||||
# 放置路径: /etc/nginx/sites-available/mapi.szaiai.com
|
||||
# 启用: ln -s /etc/nginx/sites-available/mapi.szaiai.com /etc/nginx/sites-enabled/
|
||||
|
||||
# HTTP 重定向到 HTTPS
|
||||
server {
|
||||
listen 80;
|
||||
listen [::]:80;
|
||||
server_name mapi.szaiai.com;
|
||||
|
||||
# Let's Encrypt 验证目录
|
||||
location /.well-known/acme-challenge/ {
|
||||
root /var/www/certbot;
|
||||
}
|
||||
|
||||
# 重定向到 HTTPS
|
||||
location / {
|
||||
return 301 https://$host$request_uri;
|
||||
}
|
||||
}
|
||||
|
||||
# HTTPS 配置
|
||||
server {
|
||||
listen 443 ssl http2;
|
||||
listen [::]:443 ssl http2;
|
||||
server_name mapi.szaiai.com;
|
||||
|
||||
# SSL 证书 (Let's Encrypt)
|
||||
ssl_certificate /etc/letsencrypt/live/mapi.szaiai.com/fullchain.pem;
|
||||
ssl_certificate_key /etc/letsencrypt/live/mapi.szaiai.com/privkey.pem;
|
||||
|
||||
# SSL 配置优化
|
||||
ssl_session_timeout 1d;
|
||||
ssl_session_cache shared:SSL:50m;
|
||||
ssl_session_tickets off;
|
||||
|
||||
# 现代加密套件
|
||||
ssl_protocols TLSv1.2 TLSv1.3;
|
||||
ssl_ciphers ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256:ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-GCM-SHA384:ECDHE-ECDSA-CHACHA20-POLY1305:ECDHE-RSA-CHACHA20-POLY1305:DHE-RSA-AES128-GCM-SHA256:DHE-RSA-AES256-GCM-SHA384;
|
||||
ssl_prefer_server_ciphers off;
|
||||
|
||||
# HSTS
|
||||
add_header Strict-Transport-Security "max-age=63072000" always;
|
||||
|
||||
# 日志
|
||||
access_log /var/log/nginx/mapi.szaiai.com.access.log;
|
||||
error_log /var/log/nginx/mapi.szaiai.com.error.log;
|
||||
|
||||
# Gzip 压缩
|
||||
gzip on;
|
||||
gzip_vary on;
|
||||
gzip_proxied any;
|
||||
gzip_comp_level 6;
|
||||
gzip_types text/plain text/css text/xml application/json application/javascript application/rss+xml application/atom+xml image/svg+xml;
|
||||
|
||||
# 安全头
|
||||
add_header X-Frame-Options "SAMEORIGIN" always;
|
||||
add_header X-Content-Type-Options "nosniff" always;
|
||||
add_header X-XSS-Protection "1; mode=block" always;
|
||||
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
|
||||
|
||||
# 客户端请求大小限制 (500MB 用于 APK/IPA 上传)
|
||||
client_max_body_size 500M;
|
||||
|
||||
# 反向代理到 Kong API Gateway
|
||||
location / {
|
||||
proxy_pass http://127.0.0.1:8000;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection 'upgrade';
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_set_header X-Forwarded-Host $host;
|
||||
proxy_set_header X-Forwarded-Port $server_port;
|
||||
proxy_cache_bypass $http_upgrade;
|
||||
|
||||
# 超时设置 (适配大文件上传)
|
||||
proxy_connect_timeout 60s;
|
||||
proxy_send_timeout 300s;
|
||||
proxy_read_timeout 300s;
|
||||
|
||||
# 缓冲设置
|
||||
proxy_buffering on;
|
||||
proxy_buffer_size 128k;
|
||||
proxy_buffers 4 256k;
|
||||
proxy_busy_buffers_size 256k;
|
||||
}
|
||||
|
||||
# 健康检查端点 (直接返回)
|
||||
location = /health {
|
||||
access_log off;
|
||||
return 200 '{"status":"ok","service":"mapi-nginx"}';
|
||||
add_header Content-Type application/json;
|
||||
}
|
||||
}
|
||||
|
|
@ -46,6 +46,7 @@ services:
|
|||
redis:
|
||||
image: redis:7-alpine
|
||||
container_name: rwa-redis
|
||||
command: redis-server --databases 20
|
||||
ports:
|
||||
- "6379:6379"
|
||||
healthcheck:
|
||||
|
|
|
|||
|
|
@ -680,8 +680,11 @@ type SessionEvent struct {
|
|||
ExpiresAt int64 `protobuf:"varint,10,opt,name=expires_at,json=expiresAt,proto3" json:"expires_at,omitempty"` // Unix timestamp milliseconds
|
||||
// For sign sessions with delegate party: user's share for delegate to use
|
||||
DelegateUserShare *DelegateUserShare `protobuf:"bytes,11,opt,name=delegate_user_share,json=delegateUserShare,proto3" json:"delegate_user_share,omitempty"`
|
||||
unknownFields protoimpl.UnknownFields
|
||||
sizeCache protoimpl.SizeCache
|
||||
// For session_started event: complete list of participants with their indices
|
||||
// CRITICAL: Use this for TSS protocol instead of JoinSession response
|
||||
Participants []*PartyInfo `protobuf:"bytes,12,rep,name=participants,proto3" json:"participants,omitempty"`
|
||||
unknownFields protoimpl.UnknownFields
|
||||
sizeCache protoimpl.SizeCache
|
||||
}
|
||||
|
||||
func (x *SessionEvent) Reset() {
|
||||
|
|
@ -791,6 +794,13 @@ func (x *SessionEvent) GetDelegateUserShare() *DelegateUserShare {
|
|||
return nil
|
||||
}
|
||||
|
||||
func (x *SessionEvent) GetParticipants() []*PartyInfo {
|
||||
if x != nil {
|
||||
return x.Participants
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// DelegateUserShare contains user's share for delegate party to use in signing
|
||||
type DelegateUserShare struct {
|
||||
state protoimpl.MessageState `protogen:"open.v1"`
|
||||
|
|
@ -2479,7 +2489,7 @@ const file_api_proto_message_router_proto_rawDesc = "" +
|
|||
"\x1dSubscribeSessionEventsRequest\x12\x19\n" +
|
||||
"\bparty_id\x18\x01 \x01(\tR\apartyId\x12\x1f\n" +
|
||||
"\vevent_types\x18\x02 \x03(\tR\n" +
|
||||
"eventTypes\"\x94\x04\n" +
|
||||
"eventTypes\"\xd2\x04\n" +
|
||||
"\fSessionEvent\x12\x19\n" +
|
||||
"\bevent_id\x18\x01 \x01(\tR\aeventId\x12\x1d\n" +
|
||||
"\n" +
|
||||
|
|
@ -2499,7 +2509,8 @@ const file_api_proto_message_router_proto_rawDesc = "" +
|
|||
"\n" +
|
||||
"expires_at\x18\n" +
|
||||
" \x01(\x03R\texpiresAt\x12P\n" +
|
||||
"\x13delegate_user_share\x18\v \x01(\v2 .mpc.router.v1.DelegateUserShareR\x11delegateUserShare\x1a=\n" +
|
||||
"\x13delegate_user_share\x18\v \x01(\v2 .mpc.router.v1.DelegateUserShareR\x11delegateUserShare\x12<\n" +
|
||||
"\fparticipants\x18\f \x03(\v2\x18.mpc.router.v1.PartyInfoR\fparticipants\x1a=\n" +
|
||||
"\x0fJoinTokensEntry\x12\x10\n" +
|
||||
"\x03key\x18\x01 \x01(\tR\x03key\x12\x14\n" +
|
||||
"\x05value\x18\x02 \x01(\tR\x05value:\x028\x01\"\x89\x01\n" +
|
||||
|
|
@ -2723,50 +2734,51 @@ var file_api_proto_message_router_proto_depIdxs = []int32{
|
|||
6, // 1: mpc.router.v1.RegisterPartyRequest.notification:type_name -> mpc.router.v1.NotificationChannel
|
||||
37, // 2: mpc.router.v1.SessionEvent.join_tokens:type_name -> mpc.router.v1.SessionEvent.JoinTokensEntry
|
||||
11, // 3: mpc.router.v1.SessionEvent.delegate_user_share:type_name -> mpc.router.v1.DelegateUserShare
|
||||
10, // 4: mpc.router.v1.PublishSessionEventRequest.event:type_name -> mpc.router.v1.SessionEvent
|
||||
6, // 5: mpc.router.v1.RegisteredParty.notification:type_name -> mpc.router.v1.NotificationChannel
|
||||
15, // 6: mpc.router.v1.GetRegisteredPartiesResponse.parties:type_name -> mpc.router.v1.RegisteredParty
|
||||
20, // 7: mpc.router.v1.GetMessageStatusResponse.deliveries:type_name -> mpc.router.v1.MessageDeliveryStatus
|
||||
24, // 8: mpc.router.v1.PartyInfo.device_info:type_name -> mpc.router.v1.DeviceInfo
|
||||
24, // 9: mpc.router.v1.JoinSessionRequest.device_info:type_name -> mpc.router.v1.DeviceInfo
|
||||
26, // 10: mpc.router.v1.JoinSessionResponse.session_info:type_name -> mpc.router.v1.SessionInfo
|
||||
25, // 11: mpc.router.v1.JoinSessionResponse.other_parties:type_name -> mpc.router.v1.PartyInfo
|
||||
25, // 12: mpc.router.v1.GetSessionStatusResponse.participants:type_name -> mpc.router.v1.PartyInfo
|
||||
0, // 13: mpc.router.v1.MessageRouter.RouteMessage:input_type -> mpc.router.v1.RouteMessageRequest
|
||||
2, // 14: mpc.router.v1.MessageRouter.SubscribeMessages:input_type -> mpc.router.v1.SubscribeMessagesRequest
|
||||
4, // 15: mpc.router.v1.MessageRouter.GetPendingMessages:input_type -> mpc.router.v1.GetPendingMessagesRequest
|
||||
17, // 16: mpc.router.v1.MessageRouter.AcknowledgeMessage:input_type -> mpc.router.v1.AcknowledgeMessageRequest
|
||||
19, // 17: mpc.router.v1.MessageRouter.GetMessageStatus:input_type -> mpc.router.v1.GetMessageStatusRequest
|
||||
7, // 18: mpc.router.v1.MessageRouter.RegisterParty:input_type -> mpc.router.v1.RegisterPartyRequest
|
||||
22, // 19: mpc.router.v1.MessageRouter.Heartbeat:input_type -> mpc.router.v1.HeartbeatRequest
|
||||
9, // 20: mpc.router.v1.MessageRouter.SubscribeSessionEvents:input_type -> mpc.router.v1.SubscribeSessionEventsRequest
|
||||
12, // 21: mpc.router.v1.MessageRouter.PublishSessionEvent:input_type -> mpc.router.v1.PublishSessionEventRequest
|
||||
14, // 22: mpc.router.v1.MessageRouter.GetRegisteredParties:input_type -> mpc.router.v1.GetRegisteredPartiesRequest
|
||||
27, // 23: mpc.router.v1.MessageRouter.JoinSession:input_type -> mpc.router.v1.JoinSessionRequest
|
||||
29, // 24: mpc.router.v1.MessageRouter.MarkPartyReady:input_type -> mpc.router.v1.MarkPartyReadyRequest
|
||||
31, // 25: mpc.router.v1.MessageRouter.ReportCompletion:input_type -> mpc.router.v1.ReportCompletionRequest
|
||||
33, // 26: mpc.router.v1.MessageRouter.GetSessionStatus:input_type -> mpc.router.v1.GetSessionStatusRequest
|
||||
35, // 27: mpc.router.v1.MessageRouter.SubmitDelegateShare:input_type -> mpc.router.v1.SubmitDelegateShareRequest
|
||||
1, // 28: mpc.router.v1.MessageRouter.RouteMessage:output_type -> mpc.router.v1.RouteMessageResponse
|
||||
3, // 29: mpc.router.v1.MessageRouter.SubscribeMessages:output_type -> mpc.router.v1.MPCMessage
|
||||
5, // 30: mpc.router.v1.MessageRouter.GetPendingMessages:output_type -> mpc.router.v1.GetPendingMessagesResponse
|
||||
18, // 31: mpc.router.v1.MessageRouter.AcknowledgeMessage:output_type -> mpc.router.v1.AcknowledgeMessageResponse
|
||||
21, // 32: mpc.router.v1.MessageRouter.GetMessageStatus:output_type -> mpc.router.v1.GetMessageStatusResponse
|
||||
8, // 33: mpc.router.v1.MessageRouter.RegisterParty:output_type -> mpc.router.v1.RegisterPartyResponse
|
||||
23, // 34: mpc.router.v1.MessageRouter.Heartbeat:output_type -> mpc.router.v1.HeartbeatResponse
|
||||
10, // 35: mpc.router.v1.MessageRouter.SubscribeSessionEvents:output_type -> mpc.router.v1.SessionEvent
|
||||
13, // 36: mpc.router.v1.MessageRouter.PublishSessionEvent:output_type -> mpc.router.v1.PublishSessionEventResponse
|
||||
16, // 37: mpc.router.v1.MessageRouter.GetRegisteredParties:output_type -> mpc.router.v1.GetRegisteredPartiesResponse
|
||||
28, // 38: mpc.router.v1.MessageRouter.JoinSession:output_type -> mpc.router.v1.JoinSessionResponse
|
||||
30, // 39: mpc.router.v1.MessageRouter.MarkPartyReady:output_type -> mpc.router.v1.MarkPartyReadyResponse
|
||||
32, // 40: mpc.router.v1.MessageRouter.ReportCompletion:output_type -> mpc.router.v1.ReportCompletionResponse
|
||||
34, // 41: mpc.router.v1.MessageRouter.GetSessionStatus:output_type -> mpc.router.v1.GetSessionStatusResponse
|
||||
36, // 42: mpc.router.v1.MessageRouter.SubmitDelegateShare:output_type -> mpc.router.v1.SubmitDelegateShareResponse
|
||||
28, // [28:43] is the sub-list for method output_type
|
||||
13, // [13:28] is the sub-list for method input_type
|
||||
13, // [13:13] is the sub-list for extension type_name
|
||||
13, // [13:13] is the sub-list for extension extendee
|
||||
0, // [0:13] is the sub-list for field type_name
|
||||
25, // 4: mpc.router.v1.SessionEvent.participants:type_name -> mpc.router.v1.PartyInfo
|
||||
10, // 5: mpc.router.v1.PublishSessionEventRequest.event:type_name -> mpc.router.v1.SessionEvent
|
||||
6, // 6: mpc.router.v1.RegisteredParty.notification:type_name -> mpc.router.v1.NotificationChannel
|
||||
15, // 7: mpc.router.v1.GetRegisteredPartiesResponse.parties:type_name -> mpc.router.v1.RegisteredParty
|
||||
20, // 8: mpc.router.v1.GetMessageStatusResponse.deliveries:type_name -> mpc.router.v1.MessageDeliveryStatus
|
||||
24, // 9: mpc.router.v1.PartyInfo.device_info:type_name -> mpc.router.v1.DeviceInfo
|
||||
24, // 10: mpc.router.v1.JoinSessionRequest.device_info:type_name -> mpc.router.v1.DeviceInfo
|
||||
26, // 11: mpc.router.v1.JoinSessionResponse.session_info:type_name -> mpc.router.v1.SessionInfo
|
||||
25, // 12: mpc.router.v1.JoinSessionResponse.other_parties:type_name -> mpc.router.v1.PartyInfo
|
||||
25, // 13: mpc.router.v1.GetSessionStatusResponse.participants:type_name -> mpc.router.v1.PartyInfo
|
||||
0, // 14: mpc.router.v1.MessageRouter.RouteMessage:input_type -> mpc.router.v1.RouteMessageRequest
|
||||
2, // 15: mpc.router.v1.MessageRouter.SubscribeMessages:input_type -> mpc.router.v1.SubscribeMessagesRequest
|
||||
4, // 16: mpc.router.v1.MessageRouter.GetPendingMessages:input_type -> mpc.router.v1.GetPendingMessagesRequest
|
||||
17, // 17: mpc.router.v1.MessageRouter.AcknowledgeMessage:input_type -> mpc.router.v1.AcknowledgeMessageRequest
|
||||
19, // 18: mpc.router.v1.MessageRouter.GetMessageStatus:input_type -> mpc.router.v1.GetMessageStatusRequest
|
||||
7, // 19: mpc.router.v1.MessageRouter.RegisterParty:input_type -> mpc.router.v1.RegisterPartyRequest
|
||||
22, // 20: mpc.router.v1.MessageRouter.Heartbeat:input_type -> mpc.router.v1.HeartbeatRequest
|
||||
9, // 21: mpc.router.v1.MessageRouter.SubscribeSessionEvents:input_type -> mpc.router.v1.SubscribeSessionEventsRequest
|
||||
12, // 22: mpc.router.v1.MessageRouter.PublishSessionEvent:input_type -> mpc.router.v1.PublishSessionEventRequest
|
||||
14, // 23: mpc.router.v1.MessageRouter.GetRegisteredParties:input_type -> mpc.router.v1.GetRegisteredPartiesRequest
|
||||
27, // 24: mpc.router.v1.MessageRouter.JoinSession:input_type -> mpc.router.v1.JoinSessionRequest
|
||||
29, // 25: mpc.router.v1.MessageRouter.MarkPartyReady:input_type -> mpc.router.v1.MarkPartyReadyRequest
|
||||
31, // 26: mpc.router.v1.MessageRouter.ReportCompletion:input_type -> mpc.router.v1.ReportCompletionRequest
|
||||
33, // 27: mpc.router.v1.MessageRouter.GetSessionStatus:input_type -> mpc.router.v1.GetSessionStatusRequest
|
||||
35, // 28: mpc.router.v1.MessageRouter.SubmitDelegateShare:input_type -> mpc.router.v1.SubmitDelegateShareRequest
|
||||
1, // 29: mpc.router.v1.MessageRouter.RouteMessage:output_type -> mpc.router.v1.RouteMessageResponse
|
||||
3, // 30: mpc.router.v1.MessageRouter.SubscribeMessages:output_type -> mpc.router.v1.MPCMessage
|
||||
5, // 31: mpc.router.v1.MessageRouter.GetPendingMessages:output_type -> mpc.router.v1.GetPendingMessagesResponse
|
||||
18, // 32: mpc.router.v1.MessageRouter.AcknowledgeMessage:output_type -> mpc.router.v1.AcknowledgeMessageResponse
|
||||
21, // 33: mpc.router.v1.MessageRouter.GetMessageStatus:output_type -> mpc.router.v1.GetMessageStatusResponse
|
||||
8, // 34: mpc.router.v1.MessageRouter.RegisterParty:output_type -> mpc.router.v1.RegisterPartyResponse
|
||||
23, // 35: mpc.router.v1.MessageRouter.Heartbeat:output_type -> mpc.router.v1.HeartbeatResponse
|
||||
10, // 36: mpc.router.v1.MessageRouter.SubscribeSessionEvents:output_type -> mpc.router.v1.SessionEvent
|
||||
13, // 37: mpc.router.v1.MessageRouter.PublishSessionEvent:output_type -> mpc.router.v1.PublishSessionEventResponse
|
||||
16, // 38: mpc.router.v1.MessageRouter.GetRegisteredParties:output_type -> mpc.router.v1.GetRegisteredPartiesResponse
|
||||
28, // 39: mpc.router.v1.MessageRouter.JoinSession:output_type -> mpc.router.v1.JoinSessionResponse
|
||||
30, // 40: mpc.router.v1.MessageRouter.MarkPartyReady:output_type -> mpc.router.v1.MarkPartyReadyResponse
|
||||
32, // 41: mpc.router.v1.MessageRouter.ReportCompletion:output_type -> mpc.router.v1.ReportCompletionResponse
|
||||
34, // 42: mpc.router.v1.MessageRouter.GetSessionStatus:output_type -> mpc.router.v1.GetSessionStatusResponse
|
||||
36, // 43: mpc.router.v1.MessageRouter.SubmitDelegateShare:output_type -> mpc.router.v1.SubmitDelegateShareResponse
|
||||
29, // [29:44] is the sub-list for method output_type
|
||||
14, // [14:29] is the sub-list for method input_type
|
||||
14, // [14:14] is the sub-list for extension type_name
|
||||
14, // [14:14] is the sub-list for extension extendee
|
||||
0, // [0:14] is the sub-list for field type_name
|
||||
}
|
||||
|
||||
func init() { file_api_proto_message_router_proto_init() }
|
||||
|
|
|
|||
|
|
@ -166,6 +166,9 @@ message SessionEvent {
|
|||
int64 expires_at = 10; // Unix timestamp milliseconds
|
||||
// For sign sessions with delegate party: user's share for delegate to use
|
||||
DelegateUserShare delegate_user_share = 11;
|
||||
// For session_started event: complete list of participants with their indices
|
||||
// CRITICAL: Use this for TSS protocol instead of JoinSession response
|
||||
repeated PartyInfo participants = 12;
|
||||
}
|
||||
|
||||
// DelegateUserShare contains user's share for delegate party to use in signing
|
||||
|
|
|
|||
|
|
@ -32,9 +32,11 @@ type PendingSession struct {
|
|||
SessionID uuid.UUID
|
||||
JoinToken string
|
||||
MessageHash []byte
|
||||
KeygenSessionID uuid.UUID // For sign sessions: the keygen session that created the keys
|
||||
ThresholdN int
|
||||
ThresholdT int
|
||||
SelectedParties []string
|
||||
Participants []use_cases.ParticipantInfo // CRITICAL: Correct PartyIndex from database (via JoinSession)
|
||||
CreatedAt time.Time
|
||||
}
|
||||
|
||||
|
|
@ -149,6 +151,14 @@ func main() {
|
|||
cryptoService,
|
||||
)
|
||||
|
||||
// Initialize signing use case (for co-managed sign sessions)
|
||||
participateSigningUC := use_cases.NewParticipateSigningUseCase(
|
||||
keyShareRepo,
|
||||
messageRouter,
|
||||
messageRouter,
|
||||
cryptoService,
|
||||
)
|
||||
|
||||
// Create shutdown context
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
|
|
@ -186,14 +196,15 @@ func main() {
|
|||
defer heartbeatCancel()
|
||||
logger.Info("Heartbeat started", zap.String("party_id", partyID), zap.Duration("interval", 30*time.Second))
|
||||
|
||||
// Subscribe to session events with two-phase handling for co_managed_keygen
|
||||
logger.Info("Subscribing to session events (co_managed_keygen only)", zap.String("party_id", partyID))
|
||||
// Subscribe to session events with two-phase handling for co_managed_keygen and co_managed_sign
|
||||
logger.Info("Subscribing to session events (co_managed_keygen and co_managed_sign)", zap.String("party_id", partyID))
|
||||
|
||||
eventHandler := createCoManagedSessionEventHandler(
|
||||
ctx,
|
||||
partyID,
|
||||
messageRouter,
|
||||
participateKeygenUC,
|
||||
participateSigningUC,
|
||||
)
|
||||
|
||||
if err := messageRouter.SubscribeSessionEvents(ctx, partyID, eventHandler); err != nil {
|
||||
|
|
@ -306,15 +317,17 @@ func startHTTPServer(cfg *config.Config) error {
|
|||
return r.Run(fmt.Sprintf(":%d", cfg.Server.HTTPPort))
|
||||
}
|
||||
|
||||
// createCoManagedSessionEventHandler creates a handler specifically for co_managed_keygen sessions
|
||||
// createCoManagedSessionEventHandler creates a handler for co_managed_keygen and co_managed_sign sessions
|
||||
// Two-phase event handling:
|
||||
// Phase 1 (session_created): JoinSession immediately + store session info
|
||||
// Phase 2 (session_started): Execute TSS protocol (same timing as user clients receiving all_joined)
|
||||
// Supports both keygen (no message_hash) and sign (with message_hash) sessions
|
||||
func createCoManagedSessionEventHandler(
|
||||
ctx context.Context,
|
||||
partyID string,
|
||||
messageRouter *grpcclient.MessageRouterClient,
|
||||
participateKeygenUC *use_cases.ParticipateKeygenUseCase,
|
||||
participateSigningUC *use_cases.ParticipateSigningUseCase,
|
||||
) func(*router.SessionEvent) {
|
||||
return func(event *router.SessionEvent) {
|
||||
// Check if this party is selected for the session
|
||||
|
|
@ -348,11 +361,26 @@ func createCoManagedSessionEventHandler(
|
|||
// Handle different event types
|
||||
switch event.EventType {
|
||||
case "session_created":
|
||||
// Only handle keygen sessions (no message_hash)
|
||||
// Handle both keygen (no message_hash) and sign (with message_hash) sessions
|
||||
// For sign sessions: only support 2-of-3 configuration
|
||||
if len(event.MessageHash) > 0 {
|
||||
logger.Debug("Ignoring sign session (co-managed only handles keygen)",
|
||||
zap.String("session_id", event.SessionId))
|
||||
return
|
||||
// This is a sign session
|
||||
// Security check: only support 2-of-3 configuration
|
||||
if event.ThresholdT != 2 || event.ThresholdN != 3 {
|
||||
logger.Warn("Ignoring sign session: only 2-of-3 configuration is supported",
|
||||
zap.String("session_id", event.SessionId),
|
||||
zap.Int32("threshold_t", event.ThresholdT),
|
||||
zap.Int32("threshold_n", event.ThresholdN))
|
||||
return
|
||||
}
|
||||
logger.Info("Sign session detected (2-of-3), proceeding with participation",
|
||||
zap.String("session_id", event.SessionId),
|
||||
zap.String("party_id", partyID))
|
||||
} else {
|
||||
// This is a keygen session
|
||||
logger.Info("Keygen session detected, proceeding with participation",
|
||||
zap.String("session_id", event.SessionId),
|
||||
zap.String("party_id", partyID))
|
||||
}
|
||||
|
||||
// Phase 1: Get join token
|
||||
|
|
@ -366,7 +394,7 @@ func createCoManagedSessionEventHandler(
|
|||
|
||||
// Immediately call JoinSession (this is required to trigger session_started)
|
||||
joinCtx, joinCancel := context.WithTimeout(ctx, 30*time.Second)
|
||||
_, err := messageRouter.JoinSession(joinCtx, sessionID, partyID, joinToken)
|
||||
sessionInfo, err := messageRouter.JoinSession(joinCtx, sessionID, partyID, joinToken)
|
||||
joinCancel()
|
||||
if err != nil {
|
||||
logger.Error("Failed to join session",
|
||||
|
|
@ -378,16 +406,19 @@ func createCoManagedSessionEventHandler(
|
|||
|
||||
logger.Info("Successfully joined session, waiting for session_started",
|
||||
zap.String("session_id", event.SessionId),
|
||||
zap.String("party_id", partyID))
|
||||
zap.String("party_id", partyID),
|
||||
zap.String("keygen_session_id", sessionInfo.KeygenSessionID.String()))
|
||||
|
||||
// Store pending session for later use when session_started arrives
|
||||
pendingSessionCache.Store(event.SessionId, &PendingSession{
|
||||
SessionID: sessionID,
|
||||
JoinToken: joinToken,
|
||||
MessageHash: event.MessageHash,
|
||||
KeygenSessionID: sessionInfo.KeygenSessionID, // CRITICAL: Save the correct keygen session ID from JoinSession
|
||||
ThresholdN: int(event.ThresholdN),
|
||||
ThresholdT: int(event.ThresholdT),
|
||||
SelectedParties: event.SelectedParties,
|
||||
Participants: sessionInfo.Participants, // CRITICAL: Save participants with correct PartyIndex from database
|
||||
CreatedAt: time.Now(),
|
||||
})
|
||||
|
||||
|
|
@ -401,57 +432,114 @@ func createCoManagedSessionEventHandler(
|
|||
return
|
||||
}
|
||||
|
||||
logger.Info("Session started event received, beginning TSS keygen protocol",
|
||||
zap.String("session_id", event.SessionId),
|
||||
zap.String("party_id", partyID))
|
||||
// CRITICAL FIX: Use participants from session_started event, NOT from JoinSession cache
|
||||
// The JoinSession response only contains parties that had joined at that moment,
|
||||
// but session_started event contains the COMPLETE list of all participants
|
||||
var participants []use_cases.ParticipantInfo
|
||||
if len(event.Participants) > 0 {
|
||||
// Use participants from event (preferred - complete list)
|
||||
participants = make([]use_cases.ParticipantInfo, len(event.Participants))
|
||||
for i, p := range event.Participants {
|
||||
participants[i] = use_cases.ParticipantInfo{
|
||||
PartyID: p.PartyId,
|
||||
PartyIndex: int(p.PartyIndex),
|
||||
}
|
||||
}
|
||||
logger.Info("Using participants from session_started event",
|
||||
zap.String("session_id", event.SessionId),
|
||||
zap.Int("participant_count", len(participants)))
|
||||
} else {
|
||||
// Fallback to cached participants (for backward compatibility)
|
||||
participants = pendingSession.Participants
|
||||
logger.Warn("No participants in session_started event, using cached participants",
|
||||
zap.String("session_id", event.SessionId),
|
||||
zap.Int("participant_count", len(participants)))
|
||||
}
|
||||
|
||||
// Execute TSS keygen protocol in goroutine
|
||||
// Determine session type based on message_hash
|
||||
isSignSession := len(pendingSession.MessageHash) > 0
|
||||
|
||||
if isSignSession {
|
||||
logger.Info("Session started event received, beginning TSS signing protocol",
|
||||
zap.String("session_id", event.SessionId),
|
||||
zap.String("party_id", partyID),
|
||||
zap.Int("participant_count", len(participants)))
|
||||
} else {
|
||||
logger.Info("Session started event received, beginning TSS keygen protocol",
|
||||
zap.String("session_id", event.SessionId),
|
||||
zap.String("party_id", partyID),
|
||||
zap.Int("participant_count", len(participants)))
|
||||
}
|
||||
|
||||
// Execute TSS protocol in goroutine
|
||||
// Timeout starts NOW (when session_started is received), not at session_created
|
||||
go func() {
|
||||
// 10 minute timeout for TSS protocol execution
|
||||
participateCtx, cancel := context.WithTimeout(ctx, 10*time.Minute)
|
||||
defer cancel()
|
||||
|
||||
logger.Info("Auto-participating in co_managed_keygen session",
|
||||
zap.String("session_id", event.SessionId),
|
||||
zap.String("party_id", partyID))
|
||||
|
||||
// Build SessionInfo from session_started event (NOT from pendingSession cache)
|
||||
// session_started event contains ALL participants who have joined,
|
||||
// including external parties that joined dynamically after session_created
|
||||
// Note: We already called JoinSession in session_created phase,
|
||||
// so we use ExecuteWithSessionInfo to skip the duplicate JoinSession call
|
||||
participants := make([]use_cases.ParticipantInfo, len(event.SelectedParties))
|
||||
for i, p := range event.SelectedParties {
|
||||
participants[i] = use_cases.ParticipantInfo{
|
||||
PartyID: p,
|
||||
PartyIndex: i,
|
||||
}
|
||||
}
|
||||
|
||||
sessionInfo := &use_cases.SessionInfo{
|
||||
SessionID: pendingSession.SessionID,
|
||||
SessionType: "co_managed_keygen",
|
||||
ThresholdN: int(event.ThresholdN),
|
||||
ThresholdT: int(event.ThresholdT),
|
||||
MessageHash: pendingSession.MessageHash,
|
||||
Participants: participants,
|
||||
}
|
||||
|
||||
result, err := participateKeygenUC.ExecuteWithSessionInfo(
|
||||
participateCtx,
|
||||
pendingSession.SessionID,
|
||||
partyID,
|
||||
sessionInfo,
|
||||
)
|
||||
if err != nil {
|
||||
logger.Error("Co-managed keygen participation failed",
|
||||
zap.Error(err),
|
||||
zap.String("session_id", event.SessionId))
|
||||
} else {
|
||||
logger.Info("Co-managed keygen participation completed",
|
||||
if isSignSession {
|
||||
// Execute signing protocol
|
||||
logger.Info("Auto-participating in co_managed_sign session",
|
||||
zap.String("session_id", event.SessionId),
|
||||
zap.String("public_key", hex.EncodeToString(result.PublicKey)))
|
||||
zap.String("party_id", partyID),
|
||||
zap.String("keygen_session_id", pendingSession.KeygenSessionID.String()))
|
||||
|
||||
sessionInfo := &use_cases.SessionInfo{
|
||||
SessionID: pendingSession.SessionID,
|
||||
SessionType: "co_managed_sign",
|
||||
ThresholdN: int(event.ThresholdN),
|
||||
ThresholdT: int(event.ThresholdT),
|
||||
MessageHash: pendingSession.MessageHash,
|
||||
KeygenSessionID: pendingSession.KeygenSessionID, // CRITICAL: Use the correct keygen session ID from JoinSession
|
||||
Participants: participants,
|
||||
}
|
||||
|
||||
result, err := participateSigningUC.ExecuteWithSessionInfo(
|
||||
participateCtx,
|
||||
pendingSession.SessionID,
|
||||
partyID,
|
||||
sessionInfo,
|
||||
)
|
||||
if err != nil {
|
||||
logger.Error("Co-managed signing participation failed",
|
||||
zap.Error(err),
|
||||
zap.String("session_id", event.SessionId))
|
||||
} else {
|
||||
logger.Info("Co-managed signing participation completed",
|
||||
zap.String("session_id", event.SessionId),
|
||||
zap.String("signature", hex.EncodeToString(result.Signature)))
|
||||
}
|
||||
} else {
|
||||
// Execute keygen protocol
|
||||
logger.Info("Auto-participating in co_managed_keygen session",
|
||||
zap.String("session_id", event.SessionId),
|
||||
zap.String("party_id", partyID))
|
||||
|
||||
sessionInfo := &use_cases.SessionInfo{
|
||||
SessionID: pendingSession.SessionID,
|
||||
SessionType: "co_managed_keygen",
|
||||
ThresholdN: int(event.ThresholdN),
|
||||
ThresholdT: int(event.ThresholdT),
|
||||
MessageHash: pendingSession.MessageHash,
|
||||
Participants: participants,
|
||||
}
|
||||
|
||||
result, err := participateKeygenUC.ExecuteWithSessionInfo(
|
||||
participateCtx,
|
||||
pendingSession.SessionID,
|
||||
partyID,
|
||||
sessionInfo,
|
||||
)
|
||||
if err != nil {
|
||||
logger.Error("Co-managed keygen participation failed",
|
||||
zap.Error(err),
|
||||
zap.String("session_id", event.SessionId))
|
||||
} else {
|
||||
logger.Info("Co-managed keygen participation completed",
|
||||
zap.String("session_id", event.SessionId),
|
||||
zap.String("public_key", hex.EncodeToString(result.PublicKey)))
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
|
|
|
|||
|
|
@ -63,6 +63,30 @@ func NewParticipateSigningUseCase(
|
|||
}
|
||||
}
|
||||
|
||||
// ExecuteWithSessionInfo participates in a signing session with pre-obtained SessionInfo
|
||||
// This is used by server-party-co-managed which has already called JoinSession in session_created phase
|
||||
// and receives session_started event when all participants have joined
|
||||
func (uc *ParticipateSigningUseCase) ExecuteWithSessionInfo(
|
||||
ctx context.Context,
|
||||
sessionID uuid.UUID,
|
||||
partyID string,
|
||||
sessionInfo *SessionInfo,
|
||||
) (*ParticipateSigningOutput, error) {
|
||||
// Validate session type
|
||||
if sessionInfo.SessionType != "sign" && sessionInfo.SessionType != "co_managed_sign" {
|
||||
return nil, ErrInvalidSignSession
|
||||
}
|
||||
|
||||
logger.Info("ExecuteWithSessionInfo: starting signing with pre-obtained session info",
|
||||
zap.String("session_id", sessionID.String()),
|
||||
zap.String("party_id", partyID),
|
||||
zap.String("session_type", sessionInfo.SessionType),
|
||||
zap.Int("participants", len(sessionInfo.Participants)))
|
||||
|
||||
// Delegate to the common execution logic (skipping JoinSession)
|
||||
return uc.executeWithSessionInfo(ctx, sessionID, partyID, sessionInfo)
|
||||
}
|
||||
|
||||
// Execute participates in a signing session using real TSS protocol
|
||||
func (uc *ParticipateSigningUseCase) Execute(
|
||||
ctx context.Context,
|
||||
|
|
@ -211,6 +235,123 @@ func (uc *ParticipateSigningUseCase) Execute(
|
|||
}, nil
|
||||
}
|
||||
|
||||
// executeWithSessionInfo is the internal logic for ExecuteWithSessionInfo (persistent party only)
|
||||
func (uc *ParticipateSigningUseCase) executeWithSessionInfo(
|
||||
ctx context.Context,
|
||||
sessionID uuid.UUID,
|
||||
partyID string,
|
||||
sessionInfo *SessionInfo,
|
||||
) (*ParticipateSigningOutput, error) {
|
||||
|
||||
// Get share data from database (persistent party only - used by server-party-co-managed)
|
||||
var shareData []byte
|
||||
var keyShareForUpdate *entities.PartyKeyShare
|
||||
var originalThresholdN int
|
||||
var err error
|
||||
|
||||
// Load from database using KeygenSessionID
|
||||
if sessionInfo.KeygenSessionID != uuid.Nil {
|
||||
keyShareForUpdate, err = uc.keyShareRepo.FindBySessionAndParty(ctx, sessionInfo.KeygenSessionID, partyID)
|
||||
if err != nil {
|
||||
logger.Error("Failed to find keyshare for keygen session",
|
||||
zap.String("party_id", partyID),
|
||||
zap.String("keygen_session_id", sessionInfo.KeygenSessionID.String()),
|
||||
zap.Error(err))
|
||||
return nil, ErrKeyShareNotFound
|
||||
}
|
||||
logger.Info("Using specific keyshare by keygen_session_id",
|
||||
zap.String("party_id", partyID),
|
||||
zap.String("keygen_session_id", sessionInfo.KeygenSessionID.String()))
|
||||
} else {
|
||||
// Fallback: use the most recent key share
|
||||
keyShares, err := uc.keyShareRepo.ListByParty(ctx, partyID)
|
||||
if err != nil || len(keyShares) == 0 {
|
||||
return nil, ErrKeyShareNotFound
|
||||
}
|
||||
keyShareForUpdate = keyShares[len(keyShares)-1]
|
||||
logger.Warn("Using most recent keyshare (keygen_session_id not provided)",
|
||||
zap.String("party_id", partyID),
|
||||
zap.String("fallback_session_id", keyShareForUpdate.SessionID.String()))
|
||||
}
|
||||
|
||||
originalThresholdN = keyShareForUpdate.ThresholdN
|
||||
shareData, err = uc.cryptoService.DecryptShare(keyShareForUpdate.ShareData, partyID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
logger.Info("Using database share (persistent party)",
|
||||
zap.String("party_id", partyID),
|
||||
zap.String("session_id", sessionID.String()),
|
||||
zap.String("keygen_session_id", keyShareForUpdate.SessionID.String()),
|
||||
zap.Int("original_threshold_n", originalThresholdN),
|
||||
zap.Int("threshold_t", keyShareForUpdate.ThresholdT))
|
||||
|
||||
// Find self in participants and build party index map
|
||||
var selfIndex int
|
||||
partyIndexMap := make(map[string]int)
|
||||
for _, p := range sessionInfo.Participants {
|
||||
partyIndexMap[p.PartyID] = p.PartyIndex
|
||||
if p.PartyID == partyID {
|
||||
selfIndex = p.PartyIndex
|
||||
}
|
||||
}
|
||||
|
||||
// Subscribe to messages
|
||||
msgChan, err := uc.messageRouter.SubscribeMessages(ctx, sessionID, partyID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Wait for all parties to subscribe
|
||||
expectedParties := len(sessionInfo.Participants)
|
||||
logger.Info("Waiting for all parties to subscribe",
|
||||
zap.String("session_id", sessionID.String()),
|
||||
zap.String("party_id", partyID),
|
||||
zap.Int("expected_parties", expectedParties))
|
||||
time.Sleep(500 * time.Millisecond)
|
||||
|
||||
messageHash := sessionInfo.MessageHash
|
||||
|
||||
// Run TSS Signing protocol
|
||||
signature, r, s, err := uc.runSigningProtocol(
|
||||
ctx,
|
||||
sessionID,
|
||||
partyID,
|
||||
selfIndex,
|
||||
sessionInfo.Participants,
|
||||
sessionInfo.ThresholdT,
|
||||
originalThresholdN,
|
||||
shareData,
|
||||
messageHash,
|
||||
msgChan,
|
||||
partyIndexMap,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Update key share last used
|
||||
if keyShareForUpdate != nil {
|
||||
keyShareForUpdate.MarkUsed()
|
||||
if err := uc.keyShareRepo.Update(ctx, keyShareForUpdate); err != nil {
|
||||
logger.Warn("failed to update key share last used", zap.Error(err))
|
||||
}
|
||||
}
|
||||
|
||||
// Report completion to coordinator
|
||||
if err := uc.sessionClient.ReportCompletion(ctx, sessionID, partyID, signature); err != nil {
|
||||
logger.Error("failed to report signing completion", zap.Error(err))
|
||||
}
|
||||
|
||||
return &ParticipateSigningOutput{
|
||||
Success: true,
|
||||
Signature: signature,
|
||||
R: r,
|
||||
S: s,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// runSigningProtocol runs the TSS signing protocol using tss-lib
|
||||
func (uc *ParticipateSigningUseCase) runSigningProtocol(
|
||||
ctx context.Context,
|
||||
|
|
|
|||
|
|
@ -0,0 +1,249 @@
|
|||
# 2-of-3 服务器参与选项 - 纯新增实施方案
|
||||
|
||||
## 目标
|
||||
允许 2-of-3 MPC 用户勾选"包含服务器备份"参与签名,以便在丢失一个设备时转出资产。
|
||||
|
||||
## 核心设计
|
||||
|
||||
### 安全限制
|
||||
- **仅** 2-of-3 配置显示此选项
|
||||
- 其他配置(3-of-5, 4-of-7等)不显示
|
||||
|
||||
### 实施范围
|
||||
- ✅ 只修改 Android 客户端
|
||||
- ❌ **不需要**修改后端(account-service, message-router)
|
||||
- ✅ 纯新增代码,现有逻辑保持不变
|
||||
|
||||
## 修改文件清单
|
||||
|
||||
### 1. TssRepository.kt(2处新增)
|
||||
|
||||
#### 1.1 新增辅助方法(private)
|
||||
```kotlin
|
||||
// 位置:3712行之前(类内部末尾)
|
||||
/**
|
||||
* 构建参与方列表(新增辅助方法)
|
||||
* @param participants 所有参与方
|
||||
* @param includeServerParties 是否包含服务器方(默认 false,保持现有行为)
|
||||
*/
|
||||
private fun buildSigningParticipantList(
|
||||
participants: List<ParticipantStatusInfo>,
|
||||
includeServerParties: Boolean = false
|
||||
): List<Pair<String, Int>> {
|
||||
val filtered = if (includeServerParties) {
|
||||
// 包含所有参与方(含服务器)
|
||||
participants
|
||||
} else {
|
||||
// 过滤掉服务器方(现有行为)
|
||||
participants.filter { !it.partyId.startsWith("co-managed-party-") }
|
||||
}
|
||||
return filtered.map { Pair(it.partyId, it.partyIndex) }
|
||||
}
|
||||
```
|
||||
|
||||
#### 1.2 新增签名会话创建方法
|
||||
```kotlin
|
||||
// 位置:buildSigningParticipantList 之后
|
||||
/**
|
||||
* 创建签名会话(支持选择是否包含服务器)
|
||||
* @param includeServerBackup 是否包含服务器备份参与方(仅 2-of-3 时使用)
|
||||
* 新增方法,不影响现有 createSignSession
|
||||
*/
|
||||
suspend fun createSignSessionWithOptions(
|
||||
shareId: Long,
|
||||
messageHash: String,
|
||||
password: String,
|
||||
initiatorName: String,
|
||||
includeServerBackup: Boolean = false // 新增参数
|
||||
): Result<SignSessionResult> {
|
||||
return withContext(Dispatchers.IO) {
|
||||
try {
|
||||
val shareEntity = shareRecordDao.getShareById(shareId)
|
||||
?: return@withContext Result.failure(Exception("Share not found"))
|
||||
|
||||
val signingPartyIdForEvents = shareEntity.partyId
|
||||
|
||||
android.util.Log.d("TssRepository", "[CO-SIGN-OPTIONS] Creating sign session with includeServerBackup=$includeServerBackup")
|
||||
ensureSessionEventSubscriptionActive(signingPartyIdForEvents)
|
||||
|
||||
val keygenStatusResult = getSessionStatus(shareEntity.sessionId)
|
||||
if (keygenStatusResult.isFailure) {
|
||||
return@withContext Result.failure(Exception("无法获取 keygen 会话的参与者信息: ${keygenStatusResult.exceptionOrNull()?.message}"))
|
||||
}
|
||||
val keygenStatus = keygenStatusResult.getOrThrow()
|
||||
|
||||
// 使用新的辅助方法构建参与方列表
|
||||
val signingParties = buildSigningParticipantList(
|
||||
keygenStatus.participants,
|
||||
includeServerBackup
|
||||
)
|
||||
|
||||
android.util.Log.d("TssRepository", "[CO-SIGN-OPTIONS] Signing parties: ${signingParties.size} of ${keygenStatus.participants.size} (includeServer=$includeServerBackup)")
|
||||
signingParties.forEach { (id, index) ->
|
||||
android.util.Log.d("TssRepository", "[CO-SIGN-OPTIONS] party_id=${id.take(16)}, party_index=$index")
|
||||
}
|
||||
|
||||
if (signingParties.size < shareEntity.thresholdT) {
|
||||
return@withContext Result.failure(Exception(
|
||||
"签名参与方不足: 需要 ${shareEntity.thresholdT} 个,但只有 ${signingParties.size} 个参与方"
|
||||
))
|
||||
}
|
||||
|
||||
// 后续逻辑与 createSignSession 相同
|
||||
// ... 构建请求、创建session、加入gRPC等
|
||||
// (复用现有 createSignSession 的代码)
|
||||
|
||||
// 调用现有方法的内部逻辑(需要提取)
|
||||
createSignSessionInternal(
|
||||
shareEntity,
|
||||
signingParties,
|
||||
messageHash,
|
||||
password,
|
||||
initiatorName
|
||||
)
|
||||
} catch (e: Exception) {
|
||||
Result.failure(e)
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 2. MainViewModel.kt(1处新增)
|
||||
|
||||
```kotlin
|
||||
// 位置:initiateSignSession 方法之后
|
||||
/**
|
||||
* 创建签名会话(支持选择服务器参与)
|
||||
* 新增方法,不影响现有 initiateSignSession
|
||||
*/
|
||||
fun initiateSignSessionWithOptions(
|
||||
shareId: Long,
|
||||
password: String,
|
||||
initiatorName: String = "发起者",
|
||||
includeServerBackup: Boolean = false // 新增参数
|
||||
) {
|
||||
viewModelScope.launch {
|
||||
_uiState.update { it.copy(isLoading = true, error = null) }
|
||||
|
||||
val tx = _preparedTx.value
|
||||
if (tx == null) {
|
||||
_uiState.update { it.copy(isLoading = false, error = "交易未准备") }
|
||||
return@launch
|
||||
}
|
||||
|
||||
android.util.Log.d("MainViewModel", "[SIGN-OPTIONS] Initiating sign session with includeServerBackup=$includeServerBackup")
|
||||
|
||||
val result = repository.createSignSessionWithOptions(
|
||||
shareId = shareId,
|
||||
messageHash = tx.signHash,
|
||||
password = password,
|
||||
initiatorName = initiatorName,
|
||||
includeServerBackup = includeServerBackup // 传递参数
|
||||
)
|
||||
|
||||
result.fold(
|
||||
onSuccess = { sessionResult ->
|
||||
_signSessionId.value = sessionResult.sessionId
|
||||
_signInviteCode.value = sessionResult.inviteCode
|
||||
_signParticipants.value = listOf(initiatorName)
|
||||
_uiState.update { it.copy(isLoading = false) }
|
||||
|
||||
pendingSignInitiatorInfo = PendingSignInitiatorInfo(
|
||||
sessionId = sessionResult.sessionId,
|
||||
shareId = shareId,
|
||||
password = password
|
||||
)
|
||||
|
||||
if (sessionResult.sessionAlreadyInProgress) {
|
||||
startSigningProcess(sessionResult.sessionId, shareId, password)
|
||||
}
|
||||
},
|
||||
onFailure = { e ->
|
||||
_uiState.update { it.copy(isLoading = false, error = e.message) }
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 3. TransferScreen.kt(UI 新增)
|
||||
|
||||
```kotlin
|
||||
// 在交易确认界面新增复选框(Step 2)
|
||||
// 位置:密码输入框之后
|
||||
|
||||
// 仅在 2-of-3 时显示
|
||||
if (wallet.thresholdT == 2 && wallet.thresholdN == 3) {
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
|
||||
var includeServerBackup by remember { mutableStateOf(false) }
|
||||
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 16.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Checkbox(
|
||||
checked = includeServerBackup,
|
||||
onCheckedChange = { includeServerBackup = it }
|
||||
)
|
||||
Spacer(modifier = Modifier.width(8.dp))
|
||||
Column {
|
||||
Text(
|
||||
text = "包含服务器备份参与签名",
|
||||
style = MaterialTheme.typography.bodyMedium
|
||||
)
|
||||
Text(
|
||||
text = "如果您丢失了一个设备,勾选此项以使用服务器备份完成签名",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 4. MainActivity.kt(传递参数)
|
||||
|
||||
```kotlin
|
||||
// 修改 TransferScreen 的 onConfirmTransaction 回调
|
||||
onConfirmTransaction = { includeServer ->
|
||||
viewModel.initiateSignSessionWithOptions(
|
||||
shareId = shareId,
|
||||
password = "",
|
||||
includeServerBackup = includeServer
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
## 测试场景
|
||||
|
||||
### 场景1:2-of-3 正常使用(不勾选)
|
||||
- 设备A + 设备B 签名 ✅
|
||||
- 服务器被过滤(现有行为)
|
||||
|
||||
### 场景2:2-of-3 设备丢失(勾选)
|
||||
- 设备A + 服务器 签名 ✅
|
||||
- 用户明确勾选"包含服务器备份"
|
||||
|
||||
### 场景3:3-of-5 配置
|
||||
- 不显示复选框 ✅
|
||||
- 保持现有行为
|
||||
|
||||
## 优势
|
||||
|
||||
1. ✅ **零后端修改**:后端只接收 parties 数组
|
||||
2. ✅ **完全向后兼容**:默认行为不变
|
||||
3. ✅ **安全限制**:仅 2-of-3 可用
|
||||
4. ✅ **纯新增**:不修改现有方法
|
||||
5. ✅ **用户明确选择**:需要主动勾选
|
||||
|
||||
## 实施顺序
|
||||
|
||||
1. TssRepository:新增辅助方法
|
||||
2. TssRepository:新增 createSignSessionWithOptions
|
||||
3. MainViewModel:新增 initiateSignSessionWithOptions
|
||||
4. TransferScreen:新增 UI 复选框
|
||||
5. MainActivity:传递参数
|
||||
6. 测试编译和功能
|
||||
|
|
@ -39,8 +39,9 @@ android {
|
|||
}
|
||||
|
||||
// NDK configuration for TSS native library
|
||||
// Only include ARM ABIs for real devices (x86_64 is for emulators only)
|
||||
ndk {
|
||||
abiFilters += listOf("arm64-v8a", "armeabi-v7a", "x86_64")
|
||||
abiFilters += listOf("arm64-v8a", "armeabi-v7a")
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -13,6 +13,7 @@ import androidx.activity.result.contract.ActivityResultContracts
|
|||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.material3.*
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.runtime.saveable.rememberSaveable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.hilt.navigation.compose.hiltViewModel
|
||||
|
|
@ -76,6 +77,7 @@ fun TssPartyApp(
|
|||
val currentSessionId by viewModel.currentSessionId.collectAsState()
|
||||
val sessionParticipants by viewModel.sessionParticipants.collectAsState()
|
||||
val currentRound by viewModel.currentRound.collectAsState()
|
||||
val totalRounds by viewModel.totalRounds.collectAsState()
|
||||
val publicKey by viewModel.publicKey.collectAsState()
|
||||
val hasEnteredSession by viewModel.hasEnteredSession.collectAsState()
|
||||
|
||||
|
|
@ -109,69 +111,111 @@ fun TssPartyApp(
|
|||
val exportResult by viewModel.exportResult.collectAsState()
|
||||
val importResult by viewModel.importResult.collectAsState()
|
||||
|
||||
// Transaction history state
|
||||
val transactionRecords by viewModel.transactionRecords.collectAsState()
|
||||
val isSyncingHistory by viewModel.isSyncingHistory.collectAsState()
|
||||
val syncResultMessage by viewModel.syncResultMessage.collectAsState()
|
||||
|
||||
// Current transfer wallet
|
||||
var transferWalletId by remember { mutableStateOf<Long?>(null) }
|
||||
|
||||
// Export/Import file handling
|
||||
val context = LocalContext.current
|
||||
var pendingExportJson by remember { mutableStateOf<String?>(null) }
|
||||
var pendingExportAddress by remember { mutableStateOf<String?>(null) }
|
||||
// Use rememberSaveable to persist across configuration changes (e.g., file picker activity)
|
||||
var pendingExportJson by rememberSaveable { mutableStateOf<String?>(null) }
|
||||
var pendingExportAddress by rememberSaveable { mutableStateOf<String?>(null) }
|
||||
|
||||
// File picker for saving backup
|
||||
val createDocumentLauncher = rememberLauncherForActivityResult(
|
||||
contract = ActivityResultContracts.CreateDocument(ShareBackup.MIME_TYPE)
|
||||
) { uri: Uri? ->
|
||||
android.util.Log.d("MainActivity", "[EXPORT-FILE] ========== createDocumentLauncher callback ==========")
|
||||
android.util.Log.d("MainActivity", "[EXPORT-FILE] uri: $uri")
|
||||
android.util.Log.d("MainActivity", "[EXPORT-FILE] pendingExportJson isNull: ${pendingExportJson == null}")
|
||||
android.util.Log.d("MainActivity", "[EXPORT-FILE] pendingExportJson length: ${pendingExportJson?.length ?: 0}")
|
||||
uri?.let { targetUri ->
|
||||
pendingExportJson?.let { json ->
|
||||
try {
|
||||
android.util.Log.d("MainActivity", "[EXPORT-FILE] Opening output stream to: $targetUri")
|
||||
context.contentResolver.openOutputStream(targetUri)?.use { outputStream ->
|
||||
android.util.Log.d("MainActivity", "[EXPORT-FILE] Writing ${json.length} bytes...")
|
||||
outputStream.write(json.toByteArray(Charsets.UTF_8))
|
||||
android.util.Log.d("MainActivity", "[EXPORT-FILE] Write completed")
|
||||
}
|
||||
android.util.Log.d("MainActivity", "[EXPORT-FILE] File saved successfully!")
|
||||
Toast.makeText(context, "备份文件已保存", Toast.LENGTH_SHORT).show()
|
||||
} catch (e: Exception) {
|
||||
android.util.Log.e("MainActivity", "[EXPORT-FILE] Failed to save file: ${e.message}", e)
|
||||
Toast.makeText(context, "保存失败: ${e.message}", Toast.LENGTH_LONG).show()
|
||||
}
|
||||
android.util.Log.d("MainActivity", "[EXPORT-FILE] Clearing pendingExportJson and pendingExportAddress")
|
||||
pendingExportJson = null
|
||||
pendingExportAddress = null
|
||||
} ?: run {
|
||||
android.util.Log.w("MainActivity", "[EXPORT-FILE] pendingExportJson is null, nothing to write!")
|
||||
}
|
||||
} ?: run {
|
||||
android.util.Log.w("MainActivity", "[EXPORT-FILE] User cancelled file picker (uri is null)")
|
||||
}
|
||||
android.util.Log.d("MainActivity", "[EXPORT-FILE] ========== callback finished ==========")
|
||||
}
|
||||
|
||||
// File picker for importing backup
|
||||
val openDocumentLauncher = rememberLauncherForActivityResult(
|
||||
contract = ActivityResultContracts.OpenDocument()
|
||||
) { uri: Uri? ->
|
||||
android.util.Log.d("MainActivity", "[IMPORT-FILE] ========== openDocumentLauncher callback ==========")
|
||||
android.util.Log.d("MainActivity", "[IMPORT-FILE] uri: $uri")
|
||||
uri?.let { sourceUri ->
|
||||
try {
|
||||
android.util.Log.d("MainActivity", "[IMPORT-FILE] Opening input stream from: $sourceUri")
|
||||
context.contentResolver.openInputStream(sourceUri)?.use { inputStream ->
|
||||
val json = inputStream.bufferedReader().readText()
|
||||
android.util.Log.d("MainActivity", "[IMPORT-FILE] Read ${json.length} bytes")
|
||||
android.util.Log.d("MainActivity", "[IMPORT-FILE] JSON preview: ${json.take(100)}...")
|
||||
android.util.Log.d("MainActivity", "[IMPORT-FILE] Calling viewModel.importShareBackup...")
|
||||
viewModel.importShareBackup(json)
|
||||
android.util.Log.d("MainActivity", "[IMPORT-FILE] viewModel.importShareBackup called")
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
android.util.Log.e("MainActivity", "[IMPORT-FILE] Failed to read file: ${e.message}", e)
|
||||
Toast.makeText(context, "读取文件失败: ${e.message}", Toast.LENGTH_LONG).show()
|
||||
}
|
||||
} ?: run {
|
||||
android.util.Log.w("MainActivity", "[IMPORT-FILE] User cancelled file picker (uri is null)")
|
||||
}
|
||||
android.util.Log.d("MainActivity", "[IMPORT-FILE] ========== callback finished ==========")
|
||||
}
|
||||
|
||||
// Handle export result - trigger file save dialog
|
||||
LaunchedEffect(pendingExportJson) {
|
||||
android.util.Log.d("MainActivity", "[EXPORT-EFFECT] LaunchedEffect(pendingExportJson) triggered")
|
||||
android.util.Log.d("MainActivity", "[EXPORT-EFFECT] pendingExportJson isNull: ${pendingExportJson == null}")
|
||||
android.util.Log.d("MainActivity", "[EXPORT-EFFECT] pendingExportJson length: ${pendingExportJson?.length ?: 0}")
|
||||
pendingExportJson?.let { json ->
|
||||
val timestamp = SimpleDateFormat("yyyyMMdd_HHmmss", Locale.getDefault()).format(Date())
|
||||
val addressSuffix = pendingExportAddress?.take(8) ?: "wallet"
|
||||
val fileName = "tss_backup_${addressSuffix}_$timestamp.${ShareBackup.FILE_EXTENSION}"
|
||||
android.util.Log.d("MainActivity", "[EXPORT-EFFECT] Launching file picker with filename: $fileName")
|
||||
createDocumentLauncher.launch(fileName)
|
||||
android.util.Log.d("MainActivity", "[EXPORT-EFFECT] File picker launched")
|
||||
}
|
||||
}
|
||||
|
||||
// Handle import result - show toast
|
||||
LaunchedEffect(importResult) {
|
||||
android.util.Log.d("MainActivity", "[IMPORT-EFFECT] LaunchedEffect(importResult) triggered")
|
||||
android.util.Log.d("MainActivity", "[IMPORT-EFFECT] importResult: $importResult")
|
||||
importResult?.let { result ->
|
||||
android.util.Log.d("MainActivity", "[IMPORT-EFFECT] isSuccess: ${result.isSuccess}, error: ${result.error}, message: ${result.message}")
|
||||
when {
|
||||
result.isSuccess -> {
|
||||
android.util.Log.d("MainActivity", "[IMPORT-EFFECT] Showing success toast")
|
||||
Toast.makeText(context, result.message ?: "导入成功", Toast.LENGTH_SHORT).show()
|
||||
viewModel.clearExportImportResult()
|
||||
}
|
||||
result.error != null -> {
|
||||
android.util.Log.d("MainActivity", "[IMPORT-EFFECT] Showing error toast: ${result.error}")
|
||||
Toast.makeText(context, result.error, Toast.LENGTH_LONG).show()
|
||||
viewModel.clearExportImportResult()
|
||||
}
|
||||
|
|
@ -180,7 +224,9 @@ fun TssPartyApp(
|
|||
}
|
||||
|
||||
// Track if startup is complete
|
||||
var startupComplete by remember { mutableStateOf(false) }
|
||||
// Use rememberSaveable to persist across configuration changes (e.g., file picker activity)
|
||||
var startupComplete by rememberSaveable { mutableStateOf(false) }
|
||||
android.util.Log.d("MainActivity", "[STATE] TssPartyApp composing, startupComplete: $startupComplete")
|
||||
|
||||
// Handle success messages
|
||||
LaunchedEffect(uiState.successMessage) {
|
||||
|
|
@ -256,18 +302,34 @@ fun TssPartyApp(
|
|||
transferWalletId = shareId
|
||||
navController.navigate("transfer/$shareId")
|
||||
},
|
||||
onHistory = { shareId, address ->
|
||||
navController.navigate("history/$shareId/$address")
|
||||
},
|
||||
onExportBackup = { shareId, _ ->
|
||||
android.util.Log.d("MainActivity", "[EXPORT-TRIGGER] ========== onExportBackup called ==========")
|
||||
android.util.Log.d("MainActivity", "[EXPORT-TRIGGER] shareId: $shareId")
|
||||
// Get address for filename
|
||||
val share = shares.find { it.id == shareId }
|
||||
android.util.Log.d("MainActivity", "[EXPORT-TRIGGER] share found: ${share != null}, address: ${share?.address}")
|
||||
pendingExportAddress = share?.address
|
||||
android.util.Log.d("MainActivity", "[EXPORT-TRIGGER] pendingExportAddress set to: $pendingExportAddress")
|
||||
// Export and save to file
|
||||
android.util.Log.d("MainActivity", "[EXPORT-TRIGGER] Calling viewModel.exportShareBackup...")
|
||||
viewModel.exportShareBackup(shareId) { json ->
|
||||
android.util.Log.d("MainActivity", "[EXPORT-TRIGGER] exportShareBackup callback received")
|
||||
android.util.Log.d("MainActivity", "[EXPORT-TRIGGER] json length: ${json.length}")
|
||||
android.util.Log.d("MainActivity", "[EXPORT-TRIGGER] Setting pendingExportJson...")
|
||||
pendingExportJson = json
|
||||
android.util.Log.d("MainActivity", "[EXPORT-TRIGGER] pendingExportJson set, length: ${pendingExportJson?.length}")
|
||||
}
|
||||
android.util.Log.d("MainActivity", "[EXPORT-TRIGGER] viewModel.exportShareBackup called (async)")
|
||||
},
|
||||
onImportBackup = {
|
||||
android.util.Log.d("MainActivity", "[IMPORT-TRIGGER] ========== onImportBackup called ==========")
|
||||
android.util.Log.d("MainActivity", "[IMPORT-TRIGGER] Launching file picker...")
|
||||
// Open file picker to select backup file
|
||||
openDocumentLauncher.launch(arrayOf("*/*"))
|
||||
android.util.Log.d("MainActivity", "[IMPORT-TRIGGER] File picker launched")
|
||||
},
|
||||
onCreateWallet = {
|
||||
navController.navigate(BottomNavItem.Create.route)
|
||||
|
|
@ -288,7 +350,7 @@ fun TssPartyApp(
|
|||
sessionStatus = sessionStatus,
|
||||
participants = signParticipants,
|
||||
currentRound = signCurrentRound,
|
||||
totalRounds = 9,
|
||||
totalRounds = if (totalRounds > 0) totalRounds else 9, // Default to sign rounds
|
||||
preparedTx = preparedTx,
|
||||
signSessionId = signSessionId,
|
||||
inviteCode = signInviteCode,
|
||||
|
|
@ -301,8 +363,19 @@ fun TssPartyApp(
|
|||
onPrepareTransaction = { toAddress, amount, tokenType ->
|
||||
viewModel.prepareTransfer(shareId, toAddress, amount, tokenType)
|
||||
},
|
||||
onConfirmTransaction = {
|
||||
viewModel.initiateSignSession(shareId, "")
|
||||
onConfirmTransaction = { includeServerBackup ->
|
||||
// 【新增】根据用户选择调用相应的签名方法
|
||||
// includeServerBackup = true: 使用新方法,包含服务器备份参与方
|
||||
// includeServerBackup = false: 使用现有方法,排除服务器方(默认行为)
|
||||
if (includeServerBackup) {
|
||||
viewModel.initiateSignSessionWithOptions(
|
||||
shareId = shareId,
|
||||
password = "",
|
||||
includeServerBackup = true
|
||||
)
|
||||
} else {
|
||||
viewModel.initiateSignSession(shareId, "")
|
||||
}
|
||||
},
|
||||
onCopyInviteCode = {
|
||||
signInviteCode?.let { onCopyToClipboard(it) }
|
||||
|
|
@ -325,6 +398,33 @@ fun TssPartyApp(
|
|||
}
|
||||
}
|
||||
|
||||
// Transaction History Screen
|
||||
composable("history/{shareId}/{address}") { backStackEntry ->
|
||||
val shareId = backStackEntry.arguments?.getString("shareId")?.toLongOrNull() ?: 0L
|
||||
val address = backStackEntry.arguments?.getString("address") ?: ""
|
||||
|
||||
// Load records and sync when entering screen
|
||||
LaunchedEffect(shareId, address) {
|
||||
viewModel.loadTransactionRecords(shareId)
|
||||
// Auto-sync from blockchain on first entry
|
||||
if (address.isNotEmpty()) {
|
||||
viewModel.syncTransactionHistory(shareId, address)
|
||||
}
|
||||
}
|
||||
|
||||
TransactionHistoryScreen(
|
||||
shareId = shareId,
|
||||
walletAddress = address,
|
||||
transactions = transactionRecords,
|
||||
networkType = settings.networkType,
|
||||
isSyncing = isSyncingHistory,
|
||||
syncResultMessage = syncResultMessage,
|
||||
onBack = { navController.popBackStack() },
|
||||
onRefresh = { viewModel.syncTransactionHistory(shareId, address) },
|
||||
onClearSyncMessage = { viewModel.clearSyncResultMessage() }
|
||||
)
|
||||
}
|
||||
|
||||
// Tab 2: Create Wallet (创建钱包)
|
||||
composable(BottomNavItem.Create.route) {
|
||||
CreateWalletScreen(
|
||||
|
|
@ -336,7 +436,7 @@ fun TssPartyApp(
|
|||
hasEnteredSession = hasEnteredSession,
|
||||
participants = sessionParticipants,
|
||||
currentRound = currentRound,
|
||||
totalRounds = 9,
|
||||
totalRounds = if (totalRounds > 0) totalRounds else 4, // Default to keygen rounds
|
||||
publicKey = publicKey,
|
||||
countdownSeconds = uiState.countdownSeconds,
|
||||
onCreateSession = { name, t, n, participantName ->
|
||||
|
|
@ -387,7 +487,7 @@ fun TssPartyApp(
|
|||
sessionInfo = screenSessionInfo,
|
||||
participants = joinKeygenParticipants,
|
||||
currentRound = joinKeygenRound,
|
||||
totalRounds = 9,
|
||||
totalRounds = if (totalRounds > 0) totalRounds else 4, // Default to keygen rounds
|
||||
publicKey = joinKeygenPublicKey,
|
||||
countdownSeconds = uiState.countdownSeconds,
|
||||
onValidateInviteCode = { inviteCode ->
|
||||
|
|
@ -443,7 +543,7 @@ fun TssPartyApp(
|
|||
signSessionInfo = screenSignSessionInfo,
|
||||
participants = coSignParticipants,
|
||||
currentRound = coSignRound,
|
||||
totalRounds = 9,
|
||||
totalRounds = if (totalRounds > 0) totalRounds else 9, // Default to sign rounds
|
||||
signature = coSignSignature,
|
||||
countdownSeconds = uiState.countdownSeconds,
|
||||
onValidateInviteCode = { inviteCode ->
|
||||
|
|
|
|||
|
|
@ -1,7 +1,87 @@
|
|||
package com.durian.tssparty
|
||||
|
||||
import android.app.Application
|
||||
import android.util.Log
|
||||
import dagger.hilt.android.HiltAndroidApp
|
||||
import java.io.File
|
||||
import java.io.PrintWriter
|
||||
import java.io.StringWriter
|
||||
import java.text.SimpleDateFormat
|
||||
import java.util.Date
|
||||
import java.util.Locale
|
||||
|
||||
@HiltAndroidApp
|
||||
class TssPartyApplication : Application()
|
||||
class TssPartyApplication : Application() {
|
||||
|
||||
companion object {
|
||||
private const val TAG = "TssPartyApplication"
|
||||
}
|
||||
|
||||
private var defaultExceptionHandler: Thread.UncaughtExceptionHandler? = null
|
||||
|
||||
override fun onCreate() {
|
||||
super.onCreate()
|
||||
Log.d(TAG, "Application onCreate")
|
||||
|
||||
// Set up global exception handler
|
||||
setupCrashHandler()
|
||||
}
|
||||
|
||||
private fun setupCrashHandler() {
|
||||
defaultExceptionHandler = Thread.getDefaultUncaughtExceptionHandler()
|
||||
|
||||
Thread.setDefaultUncaughtExceptionHandler { thread, throwable ->
|
||||
Log.e(TAG, "=== UNCAUGHT EXCEPTION ===")
|
||||
Log.e(TAG, "Thread: ${thread.name}")
|
||||
Log.e(TAG, "Exception: ${throwable.javaClass.simpleName}")
|
||||
Log.e(TAG, "Message: ${throwable.message}")
|
||||
|
||||
// Get full stack trace
|
||||
val sw = StringWriter()
|
||||
throwable.printStackTrace(PrintWriter(sw))
|
||||
val stackTrace = sw.toString()
|
||||
Log.e(TAG, "Stack trace:\n$stackTrace")
|
||||
|
||||
// Try to save crash log to file
|
||||
try {
|
||||
saveCrashLog(thread, throwable, stackTrace)
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Failed to save crash log: ${e.message}")
|
||||
}
|
||||
|
||||
// Call the default handler
|
||||
defaultExceptionHandler?.uncaughtException(thread, throwable)
|
||||
}
|
||||
|
||||
Log.d(TAG, "Crash handler installed")
|
||||
}
|
||||
|
||||
private fun saveCrashLog(thread: Thread, throwable: Throwable, stackTrace: String) {
|
||||
val crashDir = File(filesDir, "crash_logs")
|
||||
if (!crashDir.exists()) {
|
||||
crashDir.mkdirs()
|
||||
}
|
||||
|
||||
val dateFormat = SimpleDateFormat("yyyy-MM-dd_HH-mm-ss", Locale.getDefault())
|
||||
val timestamp = dateFormat.format(Date())
|
||||
val crashFile = File(crashDir, "crash_$timestamp.txt")
|
||||
|
||||
crashFile.writeText(buildString {
|
||||
appendLine("=== TSS Party Crash Report ===")
|
||||
appendLine("Time: $timestamp")
|
||||
appendLine("Thread: ${thread.name}")
|
||||
appendLine("Exception: ${throwable.javaClass.name}")
|
||||
appendLine("Message: ${throwable.message}")
|
||||
appendLine()
|
||||
appendLine("=== Stack Trace ===")
|
||||
appendLine(stackTrace)
|
||||
appendLine()
|
||||
appendLine("=== Device Info ===")
|
||||
appendLine("Android Version: ${android.os.Build.VERSION.RELEASE}")
|
||||
appendLine("SDK: ${android.os.Build.VERSION.SDK_INT}")
|
||||
appendLine("Device: ${android.os.Build.MANUFACTURER} ${android.os.Build.MODEL}")
|
||||
})
|
||||
|
||||
Log.d(TAG, "Crash log saved to: ${crashFile.absolutePath}")
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -29,6 +29,9 @@ data class ShareRecordEntity(
|
|||
@ColumnInfo(name = "party_index")
|
||||
val partyIndex: Int,
|
||||
|
||||
@ColumnInfo(name = "party_id")
|
||||
val partyId: String, // The original partyId used during keygen - required for signing
|
||||
|
||||
@ColumnInfo(name = "address")
|
||||
val address: String,
|
||||
|
||||
|
|
@ -90,15 +93,159 @@ interface AppSettingDao {
|
|||
suspend fun setValue(setting: AppSettingEntity)
|
||||
}
|
||||
|
||||
/**
|
||||
* 转账记录数据库实体
|
||||
* Entity for storing transaction history records
|
||||
*/
|
||||
@Entity(
|
||||
tableName = "transaction_records",
|
||||
foreignKeys = [
|
||||
ForeignKey(
|
||||
entity = ShareRecordEntity::class,
|
||||
parentColumns = ["id"],
|
||||
childColumns = ["share_id"],
|
||||
onDelete = ForeignKey.CASCADE // 删除钱包时自动删除关联的转账记录
|
||||
)
|
||||
],
|
||||
indices = [
|
||||
Index(value = ["share_id"]),
|
||||
Index(value = ["tx_hash"], unique = true),
|
||||
Index(value = ["from_address"]),
|
||||
Index(value = ["to_address"]),
|
||||
Index(value = ["created_at"])
|
||||
]
|
||||
)
|
||||
data class TransactionRecordEntity(
|
||||
@PrimaryKey(autoGenerate = true)
|
||||
val id: Long = 0,
|
||||
|
||||
@ColumnInfo(name = "share_id")
|
||||
val shareId: Long, // 关联的钱包ID
|
||||
|
||||
@ColumnInfo(name = "from_address")
|
||||
val fromAddress: String, // 发送方地址
|
||||
|
||||
@ColumnInfo(name = "to_address")
|
||||
val toAddress: String, // 接收方地址
|
||||
|
||||
@ColumnInfo(name = "amount")
|
||||
val amount: String, // 转账金额(人类可读格式)
|
||||
|
||||
@ColumnInfo(name = "token_type")
|
||||
val tokenType: String, // 代币类型:KAVA, GREEN_POINTS, ENERGY_POINTS, FUTURE_POINTS
|
||||
|
||||
@ColumnInfo(name = "tx_hash")
|
||||
val txHash: String, // 交易哈希
|
||||
|
||||
@ColumnInfo(name = "gas_price")
|
||||
val gasPrice: String, // Gas 价格(Wei)
|
||||
|
||||
@ColumnInfo(name = "gas_used")
|
||||
val gasUsed: String = "", // 实际消耗的 Gas
|
||||
|
||||
@ColumnInfo(name = "tx_fee")
|
||||
val txFee: String = "", // 交易手续费
|
||||
|
||||
@ColumnInfo(name = "status")
|
||||
val status: String, // 交易状态:PENDING, CONFIRMED, FAILED
|
||||
|
||||
@ColumnInfo(name = "direction")
|
||||
val direction: String, // 交易方向:SENT, RECEIVED
|
||||
|
||||
@ColumnInfo(name = "note")
|
||||
val note: String = "", // 备注
|
||||
|
||||
@ColumnInfo(name = "created_at")
|
||||
val createdAt: Long = System.currentTimeMillis(),
|
||||
|
||||
@ColumnInfo(name = "confirmed_at")
|
||||
val confirmedAt: Long? = null, // 确认时间
|
||||
|
||||
@ColumnInfo(name = "block_number")
|
||||
val blockNumber: Long? = null // 区块高度
|
||||
)
|
||||
|
||||
/**
|
||||
* 转账记录 DAO
|
||||
* Data Access Object for transaction records
|
||||
*/
|
||||
@Dao
|
||||
interface TransactionRecordDao {
|
||||
@Insert(onConflict = OnConflictStrategy.REPLACE)
|
||||
suspend fun insertRecord(record: TransactionRecordEntity): Long
|
||||
|
||||
@Query("SELECT * FROM transaction_records WHERE id = :id")
|
||||
suspend fun getRecordById(id: Long): TransactionRecordEntity?
|
||||
|
||||
@Query("SELECT * FROM transaction_records WHERE tx_hash = :txHash")
|
||||
suspend fun getRecordByTxHash(txHash: String): TransactionRecordEntity?
|
||||
|
||||
@Query("SELECT * FROM transaction_records WHERE share_id = :shareId ORDER BY created_at DESC")
|
||||
fun getRecordsForShare(shareId: Long): Flow<List<TransactionRecordEntity>>
|
||||
|
||||
@Query("SELECT * FROM transaction_records WHERE share_id = :shareId ORDER BY created_at DESC LIMIT :limit OFFSET :offset")
|
||||
suspend fun getRecordsForSharePaged(shareId: Long, limit: Int, offset: Int): List<TransactionRecordEntity>
|
||||
|
||||
@Query("SELECT * FROM transaction_records WHERE share_id = :shareId AND token_type = :tokenType ORDER BY created_at DESC")
|
||||
fun getRecordsForShareByToken(shareId: Long, tokenType: String): Flow<List<TransactionRecordEntity>>
|
||||
|
||||
@Query("SELECT * FROM transaction_records WHERE status = 'PENDING' ORDER BY created_at ASC")
|
||||
suspend fun getPendingRecords(): List<TransactionRecordEntity>
|
||||
|
||||
@Query("UPDATE transaction_records SET status = :status, confirmed_at = :confirmedAt, block_number = :blockNumber, gas_used = :gasUsed, tx_fee = :txFee WHERE id = :id")
|
||||
suspend fun updateStatus(id: Long, status: String, confirmedAt: Long?, blockNumber: Long?, gasUsed: String, txFee: String)
|
||||
|
||||
@Query("""
|
||||
SELECT
|
||||
COUNT(*) as total_count,
|
||||
SUM(CASE WHEN direction = 'SENT' THEN 1 ELSE 0 END) as sent_count,
|
||||
SUM(CASE WHEN direction = 'RECEIVED' THEN 1 ELSE 0 END) as received_count
|
||||
FROM transaction_records
|
||||
WHERE share_id = :shareId AND token_type = :tokenType
|
||||
""")
|
||||
suspend fun getTransactionStats(shareId: Long, tokenType: String): TransactionStats
|
||||
|
||||
@Query("SELECT COALESCE(SUM(CAST(amount AS REAL)), 0) FROM transaction_records WHERE share_id = :shareId AND token_type = :tokenType AND direction = 'SENT' AND status = 'CONFIRMED'")
|
||||
suspend fun getTotalSentAmount(shareId: Long, tokenType: String): Double
|
||||
|
||||
@Query("SELECT COALESCE(SUM(CAST(amount AS REAL)), 0) FROM transaction_records WHERE share_id = :shareId AND token_type = :tokenType AND direction = 'RECEIVED' AND status = 'CONFIRMED'")
|
||||
suspend fun getTotalReceivedAmount(shareId: Long, tokenType: String): Double
|
||||
|
||||
@Query("SELECT COALESCE(SUM(CAST(tx_fee AS REAL)), 0) FROM transaction_records WHERE share_id = :shareId AND direction = 'SENT' AND status = 'CONFIRMED'")
|
||||
suspend fun getTotalTxFee(shareId: Long): Double
|
||||
|
||||
@Query("DELETE FROM transaction_records WHERE id = :id")
|
||||
suspend fun deleteRecordById(id: Long)
|
||||
|
||||
@Query("DELETE FROM transaction_records WHERE share_id = :shareId")
|
||||
suspend fun deleteRecordsForShare(shareId: Long)
|
||||
|
||||
@Query("SELECT COUNT(*) FROM transaction_records WHERE share_id = :shareId")
|
||||
suspend fun getRecordCount(shareId: Long): Int
|
||||
}
|
||||
|
||||
/**
|
||||
* 转账统计数据类
|
||||
*/
|
||||
data class TransactionStats(
|
||||
@ColumnInfo(name = "total_count")
|
||||
val totalCount: Int,
|
||||
@ColumnInfo(name = "sent_count")
|
||||
val sentCount: Int,
|
||||
@ColumnInfo(name = "received_count")
|
||||
val receivedCount: Int
|
||||
)
|
||||
|
||||
/**
|
||||
* Room database
|
||||
*/
|
||||
@Database(
|
||||
entities = [ShareRecordEntity::class, AppSettingEntity::class],
|
||||
version = 2,
|
||||
entities = [ShareRecordEntity::class, AppSettingEntity::class, TransactionRecordEntity::class],
|
||||
version = 4, // Version 4: added transaction_records table for transfer history
|
||||
exportSchema = false
|
||||
)
|
||||
abstract class TssDatabase : RoomDatabase() {
|
||||
abstract fun shareRecordDao(): ShareRecordDao
|
||||
abstract fun appSettingDao(): AppSettingDao
|
||||
abstract fun transactionRecordDao(): TransactionRecordDao
|
||||
}
|
||||
|
|
|
|||
|
|
@ -97,6 +97,11 @@ class GrpcClient @Inject constructor() {
|
|||
private var registeredPartyId: String? = null
|
||||
private var registeredPartyRole: String? = null
|
||||
|
||||
// Additional signing party registration (for imported/restored shares)
|
||||
// When signing with a restored wallet, the signing partyId differs from the device partyId
|
||||
// and must also be registered with the message-router
|
||||
private var registeredSigningPartyId: String? = null
|
||||
|
||||
// Heartbeat state
|
||||
private var heartbeatJob: Job? = null
|
||||
private val heartbeatFailCount = AtomicInteger(0)
|
||||
|
|
@ -123,17 +128,26 @@ class GrpcClient @Inject constructor() {
|
|||
* Connect to the Message Router server
|
||||
*/
|
||||
fun connect(host: String, port: Int) {
|
||||
Log.d(TAG, "=== connect() called ===")
|
||||
Log.d(TAG, " host: $host, port: $port")
|
||||
Log.d(TAG, " isReconnecting before reset: ${isReconnecting.get()}")
|
||||
|
||||
// Save connection params for reconnection
|
||||
currentHost = host
|
||||
currentPort = port
|
||||
shouldReconnect.set(true)
|
||||
reconnectAttempts.set(0)
|
||||
|
||||
// 重要:初次连接时确保 isReconnecting 为 false
|
||||
// 这样 waitForConnection() 能正确区分初次连接和重连
|
||||
isReconnecting.set(false)
|
||||
Log.d(TAG, " isReconnecting after reset: ${isReconnecting.get()} (should be false for first connect)")
|
||||
|
||||
doConnect(host, port)
|
||||
}
|
||||
|
||||
private fun doConnect(host: String, port: Int) {
|
||||
Log.d(TAG, "Connecting to $host:$port")
|
||||
Log.d(TAG, "doConnect: $host:$port, isReconnecting=${isReconnecting.get()}")
|
||||
_connectionState.value = GrpcConnectionState.Connecting
|
||||
|
||||
try {
|
||||
|
|
@ -183,24 +197,39 @@ class GrpcClient @Inject constructor() {
|
|||
|
||||
when (state) {
|
||||
ConnectivityState.READY -> {
|
||||
Log.d(TAG, "Connected successfully")
|
||||
// 关键修复:先读取 isReconnecting 再重置,用于区分初次连接和重连
|
||||
// - 初次连接:isReconnecting = false(由 connect() 触发)
|
||||
// - 重连:isReconnecting = true(由 triggerReconnect() 触发,包括后台唤醒)
|
||||
val wasReconnecting = isReconnecting.getAndSet(false)
|
||||
|
||||
Log.d(TAG, "=== Channel READY ===")
|
||||
Log.d(TAG, " wasReconnecting: $wasReconnecting")
|
||||
Log.d(TAG, " registeredPartyId: $registeredPartyId")
|
||||
Log.d(TAG, " eventStreamSubscribed: ${eventStreamSubscribed.get()}")
|
||||
Log.d(TAG, " eventStreamPartyId: $eventStreamPartyId")
|
||||
|
||||
_connectionState.value = GrpcConnectionState.Connected
|
||||
reconnectAttempts.set(0)
|
||||
heartbeatFailCount.set(0)
|
||||
isReconnecting.set(false)
|
||||
|
||||
// Start channel state monitoring
|
||||
startChannelStateMonitor()
|
||||
|
||||
// Re-register if we were registered before
|
||||
reRegisterIfNeeded()
|
||||
// 只有重连时才需要恢复注册和订阅
|
||||
// 初次连接时,registerParty() 和 subscribeSessionEvents() 会在外部显式调用
|
||||
if (wasReconnecting) {
|
||||
Log.d(TAG, ">>> RECONNECT: Restoring registration and streams")
|
||||
// Re-register if we were registered before
|
||||
reRegisterIfNeeded()
|
||||
// Re-subscribe to streams
|
||||
reSubscribeStreams()
|
||||
} else {
|
||||
Log.d(TAG, ">>> FIRST CONNECT: Skipping restore (will be done by caller)")
|
||||
}
|
||||
|
||||
// Restart heartbeat
|
||||
// Restart heartbeat (both first connect and reconnect need this)
|
||||
startHeartbeat()
|
||||
|
||||
// Re-subscribe to streams
|
||||
reSubscribeStreams()
|
||||
|
||||
return@withTimeout
|
||||
}
|
||||
ConnectivityState.TRANSIENT_FAILURE, ConnectivityState.SHUTDOWN -> {
|
||||
|
|
@ -308,18 +337,23 @@ class GrpcClient @Inject constructor() {
|
|||
* Trigger reconnection with exponential backoff
|
||||
*/
|
||||
private fun triggerReconnect(reason: String) {
|
||||
Log.d(TAG, "[IDLE_CRASH_DEBUG] triggerReconnect called: $reason")
|
||||
Log.d(TAG, "[IDLE_CRASH_DEBUG] shouldReconnect=${shouldReconnect.get()}, isReconnecting=${isReconnecting.get()}")
|
||||
|
||||
if (!shouldReconnect.get() || isReconnecting.getAndSet(true)) {
|
||||
Log.d(TAG, "[IDLE_CRASH_DEBUG] triggerReconnect skipped (already reconnecting or disabled)")
|
||||
return
|
||||
}
|
||||
|
||||
val host = currentHost
|
||||
val port = currentPort
|
||||
if (host == null || port == null) {
|
||||
Log.d(TAG, "[IDLE_CRASH_DEBUG] triggerReconnect skipped (no host/port)")
|
||||
isReconnecting.set(false)
|
||||
return
|
||||
}
|
||||
|
||||
Log.d(TAG, "Triggering reconnect: $reason")
|
||||
Log.d(TAG, "[IDLE_CRASH_DEBUG] Triggering reconnect to $host:$port")
|
||||
|
||||
// Emit disconnected event
|
||||
_connectionEvents.tryEmit(GrpcConnectionEvent.Disconnected(reason))
|
||||
|
|
@ -347,7 +381,10 @@ class GrpcClient @Inject constructor() {
|
|||
Log.d(TAG, "Reconnecting in ${delay}ms (attempt $attempt/${reconnectConfig.maxRetries})")
|
||||
delay(delay)
|
||||
|
||||
isReconnecting.set(false)
|
||||
// 注意:不要在这里重置 isReconnecting!
|
||||
// isReconnecting 会在 waitForConnection() 的 READY 分支中被重置
|
||||
// 这样 waitForConnection() 才能知道这是重连而非初次连接
|
||||
Log.d(TAG, ">>> Starting reconnect, isReconnecting=$isReconnecting (should be true)")
|
||||
doConnect(host, port)
|
||||
}
|
||||
}
|
||||
|
|
@ -396,15 +433,18 @@ class GrpcClient @Inject constructor() {
|
|||
|
||||
private fun handleHeartbeatFailure(reason: String) {
|
||||
val fails = heartbeatFailCount.incrementAndGet()
|
||||
Log.w(TAG, "Heartbeat failed ($fails/$MAX_HEARTBEAT_FAILS): $reason")
|
||||
Log.w(TAG, "[IDLE_CRASH_DEBUG] Heartbeat failed ($fails/$MAX_HEARTBEAT_FAILS): $reason")
|
||||
Log.w(TAG, "[IDLE_CRASH_DEBUG] Connection state: ${_connectionState.value}")
|
||||
Log.w(TAG, "[IDLE_CRASH_DEBUG] Channel state: ${channel?.getState(false)}")
|
||||
|
||||
if (fails >= MAX_HEARTBEAT_FAILS) {
|
||||
Log.e(TAG, "Too many heartbeat failures, triggering reconnect")
|
||||
Log.e(TAG, "[IDLE_CRASH_DEBUG] Too many heartbeat failures, triggering reconnect")
|
||||
triggerReconnect("Heartbeat failed")
|
||||
}
|
||||
}
|
||||
|
||||
private fun stopHeartbeat() {
|
||||
Log.d(TAG, "[IDLE_CRASH_DEBUG] stopHeartbeat called")
|
||||
heartbeatJob?.cancel()
|
||||
heartbeatJob = null
|
||||
heartbeatFailCount.set(0)
|
||||
|
|
@ -425,6 +465,17 @@ class GrpcClient @Inject constructor() {
|
|||
Log.e(TAG, "Re-registration failed: ${e.message}")
|
||||
}
|
||||
}
|
||||
|
||||
// Also re-register the signing partyId if active (for imported/restored shares)
|
||||
val signingId = registeredSigningPartyId
|
||||
if (signingId != null && signingId != partyId) {
|
||||
Log.d(TAG, "Re-registering signing party: $signingId")
|
||||
try {
|
||||
registerPartyInternal(signingId, "temporary", "1.0.0")
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Re-registration of signing party failed: ${e.message}")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -448,23 +499,28 @@ class GrpcClient @Inject constructor() {
|
|||
* Notifies the repository layer to re-establish message/event subscriptions
|
||||
*/
|
||||
private fun reSubscribeStreams() {
|
||||
Log.d(TAG, "[IDLE_CRASH_DEBUG] reSubscribeStreams called")
|
||||
val needsResubscribe = eventStreamSubscribed.get() || activeMessageSubscription != null
|
||||
|
||||
if (needsResubscribe) {
|
||||
Log.d(TAG, "Triggering stream re-subscription callback")
|
||||
Log.d(TAG, " - Event stream: ${eventStreamSubscribed.get()}, partyId: $eventStreamPartyId")
|
||||
Log.d(TAG, " - Message stream: ${activeMessageSubscription?.sessionId}")
|
||||
Log.d(TAG, "[IDLE_CRASH_DEBUG] Triggering stream re-subscription callback")
|
||||
Log.d(TAG, "[IDLE_CRASH_DEBUG] - Event stream: ${eventStreamSubscribed.get()}, partyId: $eventStreamPartyId")
|
||||
Log.d(TAG, "[IDLE_CRASH_DEBUG] - Message stream: ${activeMessageSubscription?.sessionId}")
|
||||
|
||||
// Notify repository to re-establish streams
|
||||
scope.launch {
|
||||
Log.d(TAG, "[IDLE_CRASH_DEBUG] Waiting for channel to be ready...")
|
||||
// Wait for channel to be fully ready instead of fixed delay
|
||||
if (waitForChannelReady()) {
|
||||
Log.d(TAG, "[IDLE_CRASH_DEBUG] Channel ready, invoking reconnect callback")
|
||||
try {
|
||||
onReconnectedCallback?.invoke()
|
||||
Log.d(TAG, "[IDLE_CRASH_DEBUG] Reconnect callback completed")
|
||||
// Emit reconnected event
|
||||
_connectionEvents.tryEmit(GrpcConnectionEvent.Reconnected)
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Reconnect callback failed: ${e.message}")
|
||||
Log.e(TAG, "[IDLE_CRASH_DEBUG] Reconnect callback failed: ${e.message}")
|
||||
Log.e(TAG, "[IDLE_CRASH_DEBUG] Stack trace: ${e.stackTraceToString()}")
|
||||
// Don't let callback failure affect the connection state
|
||||
}
|
||||
} else {
|
||||
|
|
@ -566,6 +622,15 @@ class GrpcClient @Inject constructor() {
|
|||
partyRole: String = "temporary",
|
||||
version: String = "1.0.0"
|
||||
): Result<Boolean> = withContext(Dispatchers.IO) {
|
||||
// 必须等待 channel READY 后才能注册
|
||||
Log.d(TAG, "registerParty: Waiting for channel READY...")
|
||||
val isReady = waitForChannelReady(CONNECTION_TIMEOUT_SECONDS * 1000)
|
||||
if (!isReady) {
|
||||
Log.e(TAG, "registerParty: Channel not ready after timeout")
|
||||
return@withContext Result.failure(Exception("Channel not ready"))
|
||||
}
|
||||
Log.d(TAG, "registerParty: Channel is READY, proceeding with registration")
|
||||
|
||||
// Save for re-registration
|
||||
registeredPartyId = partyId
|
||||
registeredPartyRole = partyRole
|
||||
|
|
@ -602,6 +667,29 @@ class GrpcClient @Inject constructor() {
|
|||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Register an additional signing partyId with the message-router.
|
||||
* Used when signing with imported/restored shares where the signing partyId
|
||||
* differs from the device's own partyId. Does not overwrite the device registration.
|
||||
*/
|
||||
suspend fun registerSigningParty(signingPartyId: String): Result<Boolean> = withContext(Dispatchers.IO) {
|
||||
if (signingPartyId == registeredPartyId) {
|
||||
// Same as device partyId, already registered
|
||||
return@withContext Result.success(true)
|
||||
}
|
||||
Log.d(TAG, "Registering signing partyId: $signingPartyId (device partyId: $registeredPartyId)")
|
||||
registeredSigningPartyId = signingPartyId
|
||||
registerPartyInternal(signingPartyId, "temporary", "1.0.0")
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear the additional signing party registration.
|
||||
* Called when signing completes or fails.
|
||||
*/
|
||||
fun clearSigningPartyRegistration() {
|
||||
registeredSigningPartyId = null
|
||||
}
|
||||
|
||||
/**
|
||||
* Join a session
|
||||
*/
|
||||
|
|
@ -741,15 +829,16 @@ class GrpcClient @Inject constructor() {
|
|||
override fun onError(t: Throwable) {
|
||||
Log.e(TAG, "Message stream error: ${t.message}")
|
||||
|
||||
// Ignore events from stale streams
|
||||
// Ignore events from stale streams - close without exception to avoid crash
|
||||
if (messageStreamVersion.get() != streamVersion) {
|
||||
Log.d(TAG, "Ignoring error from stale message stream")
|
||||
close(t)
|
||||
close()
|
||||
return
|
||||
}
|
||||
|
||||
// Don't trigger reconnect for CANCELLED errors
|
||||
if (!t.message.orEmpty().contains("CANCELLED")) {
|
||||
// Don't trigger reconnect for CANCELLED or channel shutdown errors
|
||||
val errorMessage = t.message.orEmpty()
|
||||
if (!errorMessage.contains("CANCELLED") && !errorMessage.contains("shutdownNow")) {
|
||||
triggerReconnect("Message stream error: ${t.message}")
|
||||
}
|
||||
close(t)
|
||||
|
|
@ -821,15 +910,16 @@ class GrpcClient @Inject constructor() {
|
|||
override fun onError(t: Throwable) {
|
||||
Log.e(TAG, "Session event stream error: ${t.message}")
|
||||
|
||||
// Ignore events from stale streams
|
||||
// Ignore events from stale streams - close without exception to avoid crash
|
||||
if (eventStreamVersion.get() != streamVersion) {
|
||||
Log.d(TAG, "Ignoring error from stale event stream")
|
||||
close(t)
|
||||
close()
|
||||
return
|
||||
}
|
||||
|
||||
// Don't trigger reconnect for CANCELLED errors
|
||||
if (!t.message.orEmpty().contains("CANCELLED")) {
|
||||
// Don't trigger reconnect for CANCELLED or channel shutdown errors
|
||||
val errorMessage = t.message.orEmpty()
|
||||
if (!errorMessage.contains("CANCELLED") && !errorMessage.contains("shutdownNow")) {
|
||||
triggerReconnect("Event stream error: ${t.message}")
|
||||
}
|
||||
close(t)
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load Diff
|
|
@ -6,6 +6,7 @@ import androidx.room.migration.Migration
|
|||
import androidx.sqlite.db.SupportSQLiteDatabase
|
||||
import com.durian.tssparty.data.local.AppSettingDao
|
||||
import com.durian.tssparty.data.local.ShareRecordDao
|
||||
import com.durian.tssparty.data.local.TransactionRecordDao
|
||||
import com.durian.tssparty.data.local.TssDatabase
|
||||
import com.durian.tssparty.data.local.TssNativeBridge
|
||||
import com.durian.tssparty.data.remote.GrpcClient
|
||||
|
|
@ -34,6 +35,53 @@ object AppModule {
|
|||
}
|
||||
}
|
||||
|
||||
// Migration from version 2 to 3: add party_id column to share_records
|
||||
// This is critical for backup/restore - the partyId must be preserved for signing to work
|
||||
private val MIGRATION_2_3 = object : Migration(2, 3) {
|
||||
override fun migrate(database: SupportSQLiteDatabase) {
|
||||
// Add party_id column with empty default (existing records will need to be re-exported)
|
||||
database.execSQL(
|
||||
"ALTER TABLE `share_records` ADD COLUMN `party_id` TEXT NOT NULL DEFAULT ''"
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Migration from version 3 to 4: add transaction_records table for transfer history
|
||||
// 添加转账记录表,用于存储交易历史和分类账
|
||||
private val MIGRATION_3_4 = object : Migration(3, 4) {
|
||||
override fun migrate(database: SupportSQLiteDatabase) {
|
||||
// 创建转账记录表
|
||||
database.execSQL("""
|
||||
CREATE TABLE IF NOT EXISTS `transaction_records` (
|
||||
`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL,
|
||||
`share_id` INTEGER NOT NULL,
|
||||
`from_address` TEXT NOT NULL,
|
||||
`to_address` TEXT NOT NULL,
|
||||
`amount` TEXT NOT NULL,
|
||||
`token_type` TEXT NOT NULL,
|
||||
`tx_hash` TEXT NOT NULL,
|
||||
`gas_price` TEXT NOT NULL,
|
||||
`gas_used` TEXT NOT NULL DEFAULT '',
|
||||
`tx_fee` TEXT NOT NULL DEFAULT '',
|
||||
`status` TEXT NOT NULL,
|
||||
`direction` TEXT NOT NULL,
|
||||
`note` TEXT NOT NULL DEFAULT '',
|
||||
`created_at` INTEGER NOT NULL,
|
||||
`confirmed_at` INTEGER,
|
||||
`block_number` INTEGER,
|
||||
FOREIGN KEY(`share_id`) REFERENCES `share_records`(`id`) ON DELETE CASCADE
|
||||
)
|
||||
""".trimIndent())
|
||||
|
||||
// 创建索引以优化查询性能
|
||||
database.execSQL("CREATE INDEX IF NOT EXISTS `index_transaction_records_share_id` ON `transaction_records` (`share_id`)")
|
||||
database.execSQL("CREATE UNIQUE INDEX IF NOT EXISTS `index_transaction_records_tx_hash` ON `transaction_records` (`tx_hash`)")
|
||||
database.execSQL("CREATE INDEX IF NOT EXISTS `index_transaction_records_from_address` ON `transaction_records` (`from_address`)")
|
||||
database.execSQL("CREATE INDEX IF NOT EXISTS `index_transaction_records_to_address` ON `transaction_records` (`to_address`)")
|
||||
database.execSQL("CREATE INDEX IF NOT EXISTS `index_transaction_records_created_at` ON `transaction_records` (`created_at`)")
|
||||
}
|
||||
}
|
||||
|
||||
@Provides
|
||||
@Singleton
|
||||
fun provideGson(): Gson {
|
||||
|
|
@ -48,7 +96,7 @@ object AppModule {
|
|||
TssDatabase::class.java,
|
||||
"tss_party.db"
|
||||
)
|
||||
.addMigrations(MIGRATION_1_2)
|
||||
.addMigrations(MIGRATION_1_2, MIGRATION_2_3, MIGRATION_3_4)
|
||||
.build()
|
||||
}
|
||||
|
||||
|
|
@ -64,6 +112,12 @@ object AppModule {
|
|||
return database.appSettingDao()
|
||||
}
|
||||
|
||||
@Provides
|
||||
@Singleton
|
||||
fun provideTransactionRecordDao(database: TssDatabase): TransactionRecordDao {
|
||||
return database.transactionRecordDao()
|
||||
}
|
||||
|
||||
@Provides
|
||||
@Singleton
|
||||
fun provideGrpcClient(): GrpcClient {
|
||||
|
|
@ -82,8 +136,9 @@ object AppModule {
|
|||
grpcClient: GrpcClient,
|
||||
tssNativeBridge: TssNativeBridge,
|
||||
shareRecordDao: ShareRecordDao,
|
||||
appSettingDao: AppSettingDao
|
||||
appSettingDao: AppSettingDao,
|
||||
transactionRecordDao: TransactionRecordDao
|
||||
): TssRepository {
|
||||
return TssRepository(grpcClient, tssNativeBridge, shareRecordDao, appSettingDao)
|
||||
return TssRepository(grpcClient, tssNativeBridge, shareRecordDao, appSettingDao, transactionRecordDao)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -86,6 +86,7 @@ data class ShareRecord(
|
|||
val thresholdT: Int,
|
||||
val thresholdN: Int,
|
||||
val partyIndex: Int,
|
||||
val partyId: String, // The original partyId used during keygen - required for signing
|
||||
val address: String,
|
||||
val createdAt: Long = System.currentTimeMillis()
|
||||
)
|
||||
|
|
@ -129,7 +130,21 @@ enum class NetworkType {
|
|||
*/
|
||||
enum class TokenType {
|
||||
KAVA, // Native KAVA token
|
||||
GREEN_POINTS // 绿积分 (dUSDT) ERC-20 token
|
||||
GREEN_POINTS, // 绿积分 (dUSDT) ERC-20 token
|
||||
ENERGY_POINTS, // 积分股 (eUSDT) ERC-20 token
|
||||
FUTURE_POINTS // 积分值 (fUSDT) ERC-20 token
|
||||
}
|
||||
|
||||
/**
|
||||
* ERC-20 通用函数签名(keccak256 哈希的前4字节)
|
||||
* Common ERC-20 function selectors
|
||||
*/
|
||||
object ERC20Selectors {
|
||||
const val BALANCE_OF = "0x70a08231" // balanceOf(address)
|
||||
const val TRANSFER = "0xa9059cbb" // transfer(address,uint256)
|
||||
const val APPROVE = "0x095ea7b3" // approve(address,uint256)
|
||||
const val ALLOWANCE = "0xdd62ed3e" // allowance(address,address)
|
||||
const val TOTAL_SUPPLY = "0x18160ddd" // totalSupply()
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -142,22 +157,122 @@ object GreenPointsToken {
|
|||
const val SYMBOL = "dUSDT"
|
||||
const val DECIMALS = 6
|
||||
|
||||
// ERC-20 function signatures (first 4 bytes of keccak256 hash)
|
||||
const val BALANCE_OF_SELECTOR = "0x70a08231" // balanceOf(address)
|
||||
const val TRANSFER_SELECTOR = "0xa9059cbb" // transfer(address,uint256)
|
||||
const val APPROVE_SELECTOR = "0x095ea7b3" // approve(address,uint256)
|
||||
const val ALLOWANCE_SELECTOR = "0xdd62ed3e" // allowance(address,address)
|
||||
const val TOTAL_SUPPLY_SELECTOR = "0x18160ddd" // totalSupply()
|
||||
// ERC-20 function signatures (kept for backward compatibility)
|
||||
const val BALANCE_OF_SELECTOR = ERC20Selectors.BALANCE_OF
|
||||
const val TRANSFER_SELECTOR = ERC20Selectors.TRANSFER
|
||||
const val APPROVE_SELECTOR = ERC20Selectors.APPROVE
|
||||
const val ALLOWANCE_SELECTOR = ERC20Selectors.ALLOWANCE
|
||||
const val TOTAL_SUPPLY_SELECTOR = ERC20Selectors.TOTAL_SUPPLY
|
||||
}
|
||||
|
||||
/**
|
||||
* Wallet balance containing both native and token balances
|
||||
* Energy Points (积分股) Token Contract Configuration
|
||||
* eUSDT - ERC-20 token on Kava EVM
|
||||
* 总供应量:100.02亿 (10,002,000,000)
|
||||
*/
|
||||
object EnergyPointsToken {
|
||||
const val CONTRACT_ADDRESS = "0x7C3275D808eFbAE90C06C7E3A9AfDdcAa8563931"
|
||||
const val NAME = "积分股"
|
||||
const val SYMBOL = "eUSDT"
|
||||
const val DECIMALS = 6 // 与 dUSDT 相同的精度
|
||||
}
|
||||
|
||||
/**
|
||||
* Future Points (积分值) Token Contract Configuration
|
||||
* fUSDT - ERC-20 token on Kava EVM
|
||||
* 总供应量:1万亿 (1,000,000,000,000)
|
||||
*/
|
||||
object FuturePointsToken {
|
||||
const val CONTRACT_ADDRESS = "0x14dc4f7d3E4197438d058C3D156dd9826A161134"
|
||||
const val NAME = "积分值"
|
||||
const val SYMBOL = "fUSDT"
|
||||
const val DECIMALS = 6 // 与 dUSDT 相同的精度
|
||||
}
|
||||
|
||||
/**
|
||||
* 代币配置工具类
|
||||
* Token configuration utility
|
||||
*/
|
||||
object TokenConfig {
|
||||
/**
|
||||
* 获取代币合约地址
|
||||
*/
|
||||
fun getContractAddress(tokenType: TokenType): String? {
|
||||
return when (tokenType) {
|
||||
TokenType.KAVA -> null // 原生代币无合约地址
|
||||
TokenType.GREEN_POINTS -> GreenPointsToken.CONTRACT_ADDRESS
|
||||
TokenType.ENERGY_POINTS -> EnergyPointsToken.CONTRACT_ADDRESS
|
||||
TokenType.FUTURE_POINTS -> FuturePointsToken.CONTRACT_ADDRESS
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取代币精度
|
||||
*/
|
||||
fun getDecimals(tokenType: TokenType): Int {
|
||||
return when (tokenType) {
|
||||
TokenType.KAVA -> 18 // KAVA 原生代币精度
|
||||
TokenType.GREEN_POINTS -> GreenPointsToken.DECIMALS
|
||||
TokenType.ENERGY_POINTS -> EnergyPointsToken.DECIMALS
|
||||
TokenType.FUTURE_POINTS -> FuturePointsToken.DECIMALS
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取代币名称
|
||||
*/
|
||||
fun getName(tokenType: TokenType): String {
|
||||
return when (tokenType) {
|
||||
TokenType.KAVA -> "KAVA"
|
||||
TokenType.GREEN_POINTS -> GreenPointsToken.NAME
|
||||
TokenType.ENERGY_POINTS -> EnergyPointsToken.NAME
|
||||
TokenType.FUTURE_POINTS -> FuturePointsToken.NAME
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取代币符号
|
||||
*/
|
||||
fun getSymbol(tokenType: TokenType): String {
|
||||
return when (tokenType) {
|
||||
TokenType.KAVA -> "KAVA"
|
||||
TokenType.GREEN_POINTS -> GreenPointsToken.SYMBOL
|
||||
TokenType.ENERGY_POINTS -> EnergyPointsToken.SYMBOL
|
||||
TokenType.FUTURE_POINTS -> FuturePointsToken.SYMBOL
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 判断是否为 ERC-20 代币
|
||||
*/
|
||||
fun isERC20(tokenType: TokenType): Boolean {
|
||||
return tokenType != TokenType.KAVA
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Wallet balance containing native and all token balances
|
||||
* 钱包余额,包含原生代币和所有 ERC-20 代币余额
|
||||
*/
|
||||
data class WalletBalance(
|
||||
val address: String,
|
||||
val kavaBalance: String = "0", // Native KAVA balance
|
||||
val greenPointsBalance: String = "0" // 绿积分 (dUSDT) balance
|
||||
)
|
||||
val kavaBalance: String = "0", // Native KAVA balance
|
||||
val greenPointsBalance: String = "0", // 绿积分 (dUSDT) balance
|
||||
val energyPointsBalance: String = "0", // 积分股 (eUSDT) balance
|
||||
val futurePointsBalance: String = "0" // 积分值 (fUSDT) balance
|
||||
) {
|
||||
/**
|
||||
* 根据代币类型获取余额
|
||||
*/
|
||||
fun getBalance(tokenType: TokenType): String {
|
||||
return when (tokenType) {
|
||||
TokenType.KAVA -> kavaBalance
|
||||
TokenType.GREEN_POINTS -> greenPointsBalance
|
||||
TokenType.ENERGY_POINTS -> energyPointsBalance
|
||||
TokenType.FUTURE_POINTS -> futurePointsBalance
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Share backup data for export/import
|
||||
|
|
@ -165,7 +280,7 @@ data class WalletBalance(
|
|||
*/
|
||||
data class ShareBackup(
|
||||
@SerializedName("version")
|
||||
val version: Int = 1, // Backup format version for future compatibility
|
||||
val version: Int = 2, // Version 2: added partyId field for proper backup/restore
|
||||
|
||||
@SerializedName("sessionId")
|
||||
val sessionId: String,
|
||||
|
|
@ -185,6 +300,9 @@ data class ShareBackup(
|
|||
@SerializedName("partyIndex")
|
||||
val partyIndex: Int,
|
||||
|
||||
@SerializedName("partyId")
|
||||
val partyId: String, // The original partyId used during keygen - CRITICAL for signing after restore
|
||||
|
||||
@SerializedName("address")
|
||||
val address: String,
|
||||
|
||||
|
|
@ -209,6 +327,7 @@ data class ShareBackup(
|
|||
thresholdT = share.thresholdT,
|
||||
thresholdN = share.thresholdN,
|
||||
partyIndex = share.partyIndex,
|
||||
partyId = share.partyId,
|
||||
address = share.address,
|
||||
createdAt = share.createdAt
|
||||
)
|
||||
|
|
@ -227,6 +346,7 @@ data class ShareBackup(
|
|||
thresholdT = thresholdT,
|
||||
thresholdN = thresholdN,
|
||||
partyIndex = partyIndex,
|
||||
partyId = partyId,
|
||||
address = address,
|
||||
createdAt = createdAt
|
||||
)
|
||||
|
|
|
|||
|
|
@ -0,0 +1,398 @@
|
|||
package com.durian.tssparty.presentation.screens
|
||||
|
||||
import android.content.Intent
|
||||
import android.net.Uri
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.*
|
||||
import androidx.compose.material3.*
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.text.font.FontFamily
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.durian.tssparty.data.local.TransactionRecordEntity
|
||||
import com.durian.tssparty.domain.model.EnergyPointsToken
|
||||
import com.durian.tssparty.domain.model.FuturePointsToken
|
||||
import com.durian.tssparty.domain.model.GreenPointsToken
|
||||
import com.durian.tssparty.domain.model.NetworkType
|
||||
import java.text.SimpleDateFormat
|
||||
import java.util.*
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun TransactionHistoryScreen(
|
||||
shareId: Long,
|
||||
walletAddress: String,
|
||||
transactions: List<TransactionRecordEntity>,
|
||||
networkType: NetworkType,
|
||||
isSyncing: Boolean,
|
||||
syncResultMessage: String? = null,
|
||||
onBack: () -> Unit,
|
||||
onRefresh: () -> Unit,
|
||||
onClearSyncMessage: () -> Unit = {}
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
val snackbarHostState = remember { SnackbarHostState() }
|
||||
|
||||
// Show snackbar when sync result message changes
|
||||
LaunchedEffect(syncResultMessage) {
|
||||
syncResultMessage?.let { message ->
|
||||
snackbarHostState.showSnackbar(message)
|
||||
onClearSyncMessage()
|
||||
}
|
||||
}
|
||||
|
||||
Scaffold(
|
||||
snackbarHost = { SnackbarHost(snackbarHostState) },
|
||||
topBar = {
|
||||
TopAppBar(
|
||||
title = { Text("交易记录") },
|
||||
navigationIcon = {
|
||||
IconButton(onClick = onBack) {
|
||||
Icon(Icons.Default.ArrowBack, contentDescription = "返回")
|
||||
}
|
||||
},
|
||||
actions = {
|
||||
if (isSyncing) {
|
||||
CircularProgressIndicator(
|
||||
modifier = Modifier
|
||||
.size(24.dp)
|
||||
.padding(end = 8.dp),
|
||||
strokeWidth = 2.dp
|
||||
)
|
||||
} else {
|
||||
IconButton(onClick = onRefresh) {
|
||||
Icon(Icons.Default.Refresh, contentDescription = "刷新")
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
) { paddingValues ->
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(paddingValues)
|
||||
.padding(horizontal = 16.dp)
|
||||
) {
|
||||
// Wallet address header
|
||||
Card(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
colors = CardDefaults.cardColors(
|
||||
containerColor = MaterialTheme.colorScheme.surfaceVariant
|
||||
)
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(12.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Icon(
|
||||
Icons.Default.AccountBalanceWallet,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(20.dp),
|
||||
tint = MaterialTheme.colorScheme.primary
|
||||
)
|
||||
Spacer(modifier = Modifier.width(8.dp))
|
||||
Text(
|
||||
text = walletAddress,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
fontFamily = FontFamily.Monospace,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
|
||||
// Transaction count
|
||||
Text(
|
||||
text = "共 ${transactions.size} 条记录",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(12.dp))
|
||||
|
||||
if (transactions.isEmpty()) {
|
||||
// Empty state
|
||||
Box(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Column(horizontalAlignment = Alignment.CenterHorizontally) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.Receipt,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(80.dp),
|
||||
tint = MaterialTheme.colorScheme.outline
|
||||
)
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
Text(
|
||||
text = "暂无交易记录",
|
||||
style = MaterialTheme.typography.titleLarge,
|
||||
color = MaterialTheme.colorScheme.outline
|
||||
)
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
Text(
|
||||
text = if (isSyncing) "正在同步中..." else "发起转账后将在此显示",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.outline
|
||||
)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Transaction list
|
||||
LazyColumn(
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||
contentPadding = PaddingValues(bottom = 16.dp)
|
||||
) {
|
||||
items(
|
||||
items = transactions.sortedByDescending { it.createdAt },
|
||||
key = { it.id }
|
||||
) { tx ->
|
||||
TransactionItemCard(
|
||||
transaction = tx,
|
||||
walletAddress = walletAddress,
|
||||
networkType = networkType,
|
||||
onClick = {
|
||||
// Open transaction in block explorer
|
||||
val explorerUrl = getExplorerUrl(networkType, tx.txHash)
|
||||
val intent = Intent(Intent.ACTION_VIEW, Uri.parse(explorerUrl))
|
||||
context.startActivity(intent)
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun TransactionItemCard(
|
||||
transaction: TransactionRecordEntity,
|
||||
walletAddress: String,
|
||||
networkType: NetworkType,
|
||||
onClick: () -> Unit
|
||||
) {
|
||||
val isSent = transaction.direction == "SENT" ||
|
||||
transaction.fromAddress.equals(walletAddress, ignoreCase = true)
|
||||
|
||||
val statusColor = when (transaction.status) {
|
||||
"CONFIRMED" -> Color(0xFF4CAF50) // Green
|
||||
"FAILED" -> MaterialTheme.colorScheme.error
|
||||
else -> Color(0xFFFF9800) // Orange for PENDING
|
||||
}
|
||||
|
||||
val tokenColor = when (transaction.tokenType) {
|
||||
"GREEN_POINTS" -> Color(0xFF4CAF50)
|
||||
"ENERGY_POINTS" -> Color(0xFF2196F3)
|
||||
"FUTURE_POINTS" -> Color(0xFF9C27B0)
|
||||
else -> MaterialTheme.colorScheme.primary // KAVA
|
||||
}
|
||||
|
||||
val tokenName = when (transaction.tokenType) {
|
||||
"GREEN_POINTS" -> GreenPointsToken.NAME
|
||||
"ENERGY_POINTS" -> EnergyPointsToken.NAME
|
||||
"FUTURE_POINTS" -> FuturePointsToken.NAME
|
||||
else -> "KAVA"
|
||||
}
|
||||
|
||||
Card(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.clickable { onClick() }
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier.padding(12.dp)
|
||||
) {
|
||||
// Row 1: Direction icon + Amount + Status
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
// Direction icon
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.size(36.dp)
|
||||
.clip(RoundedCornerShape(8.dp))
|
||||
.background(
|
||||
if (isSent)
|
||||
MaterialTheme.colorScheme.errorContainer
|
||||
else
|
||||
Color(0xFFE8F5E9)
|
||||
),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Icon(
|
||||
imageVector = if (isSent) Icons.Default.ArrowUpward else Icons.Default.ArrowDownward,
|
||||
contentDescription = if (isSent) "发送" else "接收",
|
||||
tint = if (isSent)
|
||||
MaterialTheme.colorScheme.error
|
||||
else
|
||||
Color(0xFF4CAF50),
|
||||
modifier = Modifier.size(20.dp)
|
||||
)
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.width(12.dp))
|
||||
|
||||
// Amount and token
|
||||
Column {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Text(
|
||||
text = "${if (isSent) "-" else "+"}${transaction.amount}",
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
fontWeight = FontWeight.Bold,
|
||||
color = if (isSent)
|
||||
MaterialTheme.colorScheme.error
|
||||
else
|
||||
Color(0xFF4CAF50)
|
||||
)
|
||||
Spacer(modifier = Modifier.width(4.dp))
|
||||
Text(
|
||||
text = tokenName,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = tokenColor
|
||||
)
|
||||
}
|
||||
Text(
|
||||
text = if (isSent) "发送" else "接收",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Status badge
|
||||
Surface(
|
||||
color = statusColor.copy(alpha = 0.15f),
|
||||
shape = RoundedCornerShape(4.dp)
|
||||
) {
|
||||
Text(
|
||||
text = when (transaction.status) {
|
||||
"CONFIRMED" -> "已确认"
|
||||
"FAILED" -> "失败"
|
||||
else -> "待确认"
|
||||
},
|
||||
modifier = Modifier.padding(horizontal = 8.dp, vertical = 4.dp),
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = statusColor
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
|
||||
Divider(color = MaterialTheme.colorScheme.outlineVariant)
|
||||
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
|
||||
// Row 2: Address (to/from)
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.SpaceBetween
|
||||
) {
|
||||
Column {
|
||||
Text(
|
||||
text = if (isSent) "发送至" else "来自",
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = MaterialTheme.colorScheme.outline
|
||||
)
|
||||
Text(
|
||||
text = if (isSent) shortenAddress(transaction.toAddress) else shortenAddress(transaction.fromAddress),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
fontFamily = FontFamily.Monospace
|
||||
)
|
||||
}
|
||||
|
||||
Column(horizontalAlignment = Alignment.End) {
|
||||
Text(
|
||||
text = "时间",
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = MaterialTheme.colorScheme.outline
|
||||
)
|
||||
Text(
|
||||
text = formatTimestamp(transaction.createdAt),
|
||||
style = MaterialTheme.typography.bodySmall
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Row 3: Tx Hash (abbreviated)
|
||||
Spacer(modifier = Modifier.height(4.dp))
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Text(
|
||||
text = "交易哈希: ${shortenTxHash(transaction.txHash)}",
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = MaterialTheme.colorScheme.outline,
|
||||
fontFamily = FontFamily.Monospace
|
||||
)
|
||||
Spacer(modifier = Modifier.width(4.dp))
|
||||
Icon(
|
||||
Icons.Default.OpenInNew,
|
||||
contentDescription = "查看详情",
|
||||
modifier = Modifier.size(12.dp),
|
||||
tint = MaterialTheme.colorScheme.outline
|
||||
)
|
||||
}
|
||||
|
||||
// Row 4: Fee (if confirmed)
|
||||
if (transaction.status == "CONFIRMED" && transaction.txFee.isNotEmpty()) {
|
||||
Spacer(modifier = Modifier.height(4.dp))
|
||||
Text(
|
||||
text = "手续费: ${transaction.txFee} KAVA",
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = MaterialTheme.colorScheme.outline
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun shortenAddress(address: String): String {
|
||||
return if (address.length > 16) {
|
||||
"${address.take(10)}...${address.takeLast(6)}"
|
||||
} else {
|
||||
address
|
||||
}
|
||||
}
|
||||
|
||||
private fun shortenTxHash(txHash: String): String {
|
||||
return if (txHash.length > 20) {
|
||||
"${txHash.take(10)}...${txHash.takeLast(8)}"
|
||||
} else {
|
||||
txHash
|
||||
}
|
||||
}
|
||||
|
||||
private fun formatTimestamp(timestamp: Long): String {
|
||||
val sdf = SimpleDateFormat("MM-dd HH:mm", Locale.getDefault())
|
||||
return sdf.format(Date(timestamp))
|
||||
}
|
||||
|
||||
private fun getExplorerUrl(networkType: NetworkType, txHash: String): String {
|
||||
return when (networkType) {
|
||||
NetworkType.MAINNET -> "https://kavascan.com/tx/$txHash"
|
||||
NetworkType.TESTNET -> "https://testnet.kavascan.com/tx/$txHash"
|
||||
}
|
||||
}
|
||||
|
|
@ -27,10 +27,13 @@ import android.graphics.Bitmap
|
|||
import androidx.compose.foundation.Image
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.ui.graphics.asImageBitmap
|
||||
import com.durian.tssparty.domain.model.EnergyPointsToken
|
||||
import com.durian.tssparty.domain.model.FuturePointsToken
|
||||
import com.durian.tssparty.domain.model.GreenPointsToken
|
||||
import com.durian.tssparty.domain.model.NetworkType
|
||||
import com.durian.tssparty.domain.model.SessionStatus
|
||||
import com.durian.tssparty.domain.model.ShareRecord
|
||||
import com.durian.tssparty.domain.model.TokenConfig
|
||||
import com.durian.tssparty.domain.model.TokenType
|
||||
import com.durian.tssparty.domain.model.WalletBalance
|
||||
import com.durian.tssparty.util.TransactionUtils
|
||||
|
|
@ -75,7 +78,7 @@ fun TransferScreen(
|
|||
networkType: NetworkType = NetworkType.MAINNET,
|
||||
rpcUrl: String = "https://evm.kava.io",
|
||||
onPrepareTransaction: (toAddress: String, amount: String, tokenType: TokenType) -> Unit,
|
||||
onConfirmTransaction: () -> Unit,
|
||||
onConfirmTransaction: (includeServerBackup: Boolean) -> Unit, // 新增参数:是否包含服务器备份参与签名
|
||||
onCopyInviteCode: () -> Unit,
|
||||
onBroadcastTransaction: () -> Unit,
|
||||
onCancel: () -> Unit,
|
||||
|
|
@ -156,10 +159,8 @@ fun TransferScreen(
|
|||
rpcUrl = rpcUrl,
|
||||
onSubmit = {
|
||||
// Get current balance for the selected token type
|
||||
val currentBalance = when (selectedTokenType) {
|
||||
TokenType.KAVA -> walletBalance?.kavaBalance ?: balance
|
||||
TokenType.GREEN_POINTS -> walletBalance?.greenPointsBalance
|
||||
}
|
||||
val currentBalance = walletBalance?.getBalance(selectedTokenType)
|
||||
?: if (selectedTokenType == TokenType.KAVA) balance else null
|
||||
when {
|
||||
toAddress.isBlank() -> validationError = "请输入收款地址"
|
||||
!toAddress.startsWith("0x") || toAddress.length != 42 -> validationError = "地址格式不正确"
|
||||
|
|
@ -195,9 +196,9 @@ fun TransferScreen(
|
|||
toAddress = toAddress,
|
||||
amount = amount,
|
||||
error = error,
|
||||
onConfirm = {
|
||||
onConfirm = { includeServerBackup ->
|
||||
validationError = null
|
||||
onConfirmTransaction()
|
||||
onConfirmTransaction(includeServerBackup) // 传递服务器备份选项
|
||||
},
|
||||
onBack = onCancel
|
||||
)
|
||||
|
|
@ -257,14 +258,9 @@ private fun TransferInputScreen(
|
|||
var isCalculatingMax by remember { mutableStateOf(false) }
|
||||
|
||||
// Get current balance for the selected token type
|
||||
val currentBalance = when (selectedTokenType) {
|
||||
TokenType.KAVA -> walletBalance?.kavaBalance ?: balance
|
||||
TokenType.GREEN_POINTS -> walletBalance?.greenPointsBalance
|
||||
}
|
||||
val tokenSymbol = when (selectedTokenType) {
|
||||
TokenType.KAVA -> "KAVA"
|
||||
TokenType.GREEN_POINTS -> GreenPointsToken.NAME
|
||||
}
|
||||
val currentBalance = walletBalance?.getBalance(selectedTokenType)
|
||||
?: if (selectedTokenType == TokenType.KAVA) balance else null
|
||||
val tokenSymbol = TokenConfig.getName(selectedTokenType)
|
||||
|
||||
Column(
|
||||
modifier = Modifier
|
||||
|
|
@ -293,38 +289,74 @@ private fun TransferInputScreen(
|
|||
)
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
|
||||
// Show both balances
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.SpaceBetween
|
||||
) {
|
||||
// KAVA balance
|
||||
Column {
|
||||
Text(
|
||||
text = "KAVA",
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
Text(
|
||||
text = walletBalance?.kavaBalance ?: balance ?: "加载中...",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
fontWeight = FontWeight.Medium,
|
||||
color = MaterialTheme.colorScheme.primary
|
||||
)
|
||||
// Show all token balances in a 2x2 grid
|
||||
Column {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.SpaceBetween
|
||||
) {
|
||||
// KAVA balance
|
||||
Column {
|
||||
Text(
|
||||
text = "KAVA",
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
Text(
|
||||
text = walletBalance?.kavaBalance ?: balance ?: "加载中...",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
fontWeight = FontWeight.Medium,
|
||||
color = MaterialTheme.colorScheme.primary
|
||||
)
|
||||
}
|
||||
// Green Points balance (绿积分)
|
||||
Column(horizontalAlignment = Alignment.End) {
|
||||
Text(
|
||||
text = GreenPointsToken.NAME,
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
Text(
|
||||
text = walletBalance?.greenPointsBalance ?: "加载中...",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
fontWeight = FontWeight.Medium,
|
||||
color = Color(0xFF4CAF50)
|
||||
)
|
||||
}
|
||||
}
|
||||
// Green Points balance
|
||||
Column(horizontalAlignment = Alignment.End) {
|
||||
Text(
|
||||
text = GreenPointsToken.NAME,
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
Text(
|
||||
text = walletBalance?.greenPointsBalance ?: "加载中...",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
fontWeight = FontWeight.Medium,
|
||||
color = Color(0xFF4CAF50)
|
||||
)
|
||||
Spacer(modifier = Modifier.height(4.dp))
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.SpaceBetween
|
||||
) {
|
||||
// Energy Points balance (积分股)
|
||||
Column {
|
||||
Text(
|
||||
text = EnergyPointsToken.NAME,
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
Text(
|
||||
text = walletBalance?.energyPointsBalance ?: "加载中...",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
fontWeight = FontWeight.Medium,
|
||||
color = Color(0xFF2196F3) // Blue
|
||||
)
|
||||
}
|
||||
// Future Points balance (积分值)
|
||||
Column(horizontalAlignment = Alignment.End) {
|
||||
Text(
|
||||
text = FuturePointsToken.NAME,
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
Text(
|
||||
text = walletBalance?.futurePointsBalance ?: "加载中...",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
fontWeight = FontWeight.Medium,
|
||||
color = Color(0xFF9C27B0) // Purple
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -339,6 +371,7 @@ private fun TransferInputScreen(
|
|||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
// First row: KAVA and Green Points
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp)
|
||||
|
|
@ -359,7 +392,7 @@ private fun TransferInputScreen(
|
|||
},
|
||||
modifier = Modifier.weight(1f)
|
||||
)
|
||||
// Green Points option
|
||||
// Green Points option (绿积分)
|
||||
FilterChip(
|
||||
selected = selectedTokenType == TokenType.GREEN_POINTS,
|
||||
onClick = { onTokenTypeChange(TokenType.GREEN_POINTS) },
|
||||
|
|
@ -380,6 +413,53 @@ private fun TransferInputScreen(
|
|||
modifier = Modifier.weight(1f)
|
||||
)
|
||||
}
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
// Second row: Energy Points and Future Points
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp)
|
||||
) {
|
||||
// Energy Points option (积分股)
|
||||
FilterChip(
|
||||
selected = selectedTokenType == TokenType.ENERGY_POINTS,
|
||||
onClick = { onTokenTypeChange(TokenType.ENERGY_POINTS) },
|
||||
label = { Text(EnergyPointsToken.NAME) },
|
||||
leadingIcon = {
|
||||
if (selectedTokenType == TokenType.ENERGY_POINTS) {
|
||||
Icon(
|
||||
Icons.Default.Check,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(18.dp)
|
||||
)
|
||||
}
|
||||
},
|
||||
colors = FilterChipDefaults.filterChipColors(
|
||||
selectedContainerColor = Color(0xFF2196F3).copy(alpha = 0.2f),
|
||||
selectedLabelColor = Color(0xFF2196F3)
|
||||
),
|
||||
modifier = Modifier.weight(1f)
|
||||
)
|
||||
// Future Points option (积分值)
|
||||
FilterChip(
|
||||
selected = selectedTokenType == TokenType.FUTURE_POINTS,
|
||||
onClick = { onTokenTypeChange(TokenType.FUTURE_POINTS) },
|
||||
label = { Text(FuturePointsToken.NAME) },
|
||||
leadingIcon = {
|
||||
if (selectedTokenType == TokenType.FUTURE_POINTS) {
|
||||
Icon(
|
||||
Icons.Default.Check,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(18.dp)
|
||||
)
|
||||
}
|
||||
},
|
||||
colors = FilterChipDefaults.filterChipColors(
|
||||
selectedContainerColor = Color(0xFF9C27B0).copy(alpha = 0.2f),
|
||||
selectedLabelColor = Color(0xFF9C27B0)
|
||||
),
|
||||
modifier = Modifier.weight(1f)
|
||||
)
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
|
||||
|
|
@ -418,9 +498,14 @@ private fun TransferInputScreen(
|
|||
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Decimal),
|
||||
leadingIcon = {
|
||||
Icon(
|
||||
if (selectedTokenType == TokenType.GREEN_POINTS) Icons.Default.Stars else Icons.Default.AttachMoney,
|
||||
if (selectedTokenType == TokenType.KAVA) Icons.Default.AttachMoney else Icons.Default.Stars,
|
||||
contentDescription = null,
|
||||
tint = if (selectedTokenType == TokenType.GREEN_POINTS) Color(0xFF4CAF50) else MaterialTheme.colorScheme.onSurfaceVariant
|
||||
tint = when (selectedTokenType) {
|
||||
TokenType.KAVA -> MaterialTheme.colorScheme.onSurfaceVariant
|
||||
TokenType.GREEN_POINTS -> Color(0xFF4CAF50)
|
||||
TokenType.ENERGY_POINTS -> Color(0xFF2196F3)
|
||||
TokenType.FUTURE_POINTS -> Color(0xFF9C27B0)
|
||||
}
|
||||
)
|
||||
},
|
||||
trailingIcon = {
|
||||
|
|
@ -439,7 +524,7 @@ private fun TransferInputScreen(
|
|||
onAmountChange(currentBalance)
|
||||
}
|
||||
} else {
|
||||
// For tokens, use the full balance
|
||||
// For ERC-20 tokens (dUSDT, eUSDT, fUSDT), use the full balance
|
||||
onAmountChange(currentBalance)
|
||||
}
|
||||
isCalculatingMax = false
|
||||
|
|
@ -566,12 +651,15 @@ private fun TransferConfirmScreen(
|
|||
toAddress: String,
|
||||
amount: String,
|
||||
error: String?,
|
||||
onConfirm: () -> Unit,
|
||||
onConfirm: (includeServerBackup: Boolean) -> Unit, // 新增参数:是否包含服务器备份参与签名
|
||||
onBack: () -> Unit
|
||||
) {
|
||||
val gasFee = TransactionUtils.weiToKava(preparedTx.gasPrice.multiply(preparedTx.gasLimit))
|
||||
val gasGwei = TransactionUtils.weiToGwei(preparedTx.gasPrice)
|
||||
|
||||
// 【新增】服务器备份选项状态(仅 2-of-3 时使用)
|
||||
var includeServerBackup by remember { mutableStateOf(false) }
|
||||
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
|
|
@ -648,6 +736,48 @@ private fun TransferConfirmScreen(
|
|||
}
|
||||
}
|
||||
|
||||
// 【新增功能】2-of-3 服务器备份选项
|
||||
// 仅在 2-of-3 配置时显示此选项
|
||||
// 目的:允许用户在丢失一个设备时,使用服务器备份 + 剩余设备完成签名
|
||||
// 安全限制:仅 2-of-3 配置可用,其他配置(3-of-5, 4-of-7 等)不显示
|
||||
// 回滚方法:删除此代码块即可恢复原有行为
|
||||
if (wallet.thresholdT == 2 && wallet.thresholdN == 3) {
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
|
||||
Card(
|
||||
colors = CardDefaults.cardColors(
|
||||
containerColor = MaterialTheme.colorScheme.tertiaryContainer
|
||||
)
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(12.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Checkbox(
|
||||
checked = includeServerBackup,
|
||||
onCheckedChange = { includeServerBackup = it }
|
||||
)
|
||||
Spacer(modifier = Modifier.width(8.dp))
|
||||
Column {
|
||||
Text(
|
||||
text = "包含服务器备份参与签名",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
fontWeight = FontWeight.Medium,
|
||||
color = MaterialTheme.colorScheme.onTertiaryContainer
|
||||
)
|
||||
Spacer(modifier = Modifier.height(4.dp))
|
||||
Text(
|
||||
text = "如果您丢失了一个设备,勾选此项以使用服务器备份完成签名",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onTertiaryContainer
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Error display
|
||||
error?.let {
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
|
|
@ -689,7 +819,7 @@ private fun TransferConfirmScreen(
|
|||
Text("返回")
|
||||
}
|
||||
Button(
|
||||
onClick = onConfirm,
|
||||
onClick = { onConfirm(includeServerBackup) }, // 传递服务器备份选项
|
||||
modifier = Modifier.weight(1f)
|
||||
) {
|
||||
Icon(
|
||||
|
|
|
|||
|
|
@ -35,6 +35,8 @@ import androidx.compose.ui.unit.sp
|
|||
import androidx.compose.ui.window.Dialog
|
||||
import android.content.Intent
|
||||
import android.net.Uri
|
||||
import com.durian.tssparty.domain.model.EnergyPointsToken
|
||||
import com.durian.tssparty.domain.model.FuturePointsToken
|
||||
import com.durian.tssparty.domain.model.GreenPointsToken
|
||||
import com.durian.tssparty.domain.model.NetworkType
|
||||
import com.durian.tssparty.domain.model.ShareRecord
|
||||
|
|
@ -55,6 +57,7 @@ fun WalletsScreen(
|
|||
onDeleteShare: (Long) -> Unit,
|
||||
onRefreshBalance: ((String) -> Unit)? = null,
|
||||
onTransfer: ((shareId: Long) -> Unit)? = null,
|
||||
onHistory: ((shareId: Long, address: String) -> Unit)? = null,
|
||||
onExportBackup: ((shareId: Long, password: String) -> Unit)? = null,
|
||||
onImportBackup: (() -> Unit)? = null,
|
||||
onCreateWallet: (() -> Unit)? = null
|
||||
|
|
@ -155,6 +158,9 @@ fun WalletsScreen(
|
|||
onTransfer = {
|
||||
onTransfer?.invoke(share.id)
|
||||
},
|
||||
onHistory = {
|
||||
onHistory?.invoke(share.id, share.address)
|
||||
},
|
||||
onDelete = { onDeleteShare(share.id) }
|
||||
)
|
||||
}
|
||||
|
|
@ -223,6 +229,7 @@ private fun WalletItemCard(
|
|||
walletBalance: WalletBalance? = null,
|
||||
onViewDetails: () -> Unit,
|
||||
onTransfer: () -> Unit,
|
||||
onHistory: () -> Unit,
|
||||
onDelete: () -> Unit
|
||||
) {
|
||||
var showDeleteDialog by remember { mutableStateOf(false) }
|
||||
|
|
@ -281,62 +288,123 @@ private fun WalletItemCard(
|
|||
|
||||
Spacer(modifier = Modifier.height(12.dp))
|
||||
|
||||
// Balance display - now shows both KAVA and Green Points
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.SpaceBetween
|
||||
) {
|
||||
// KAVA balance
|
||||
Column {
|
||||
Text(
|
||||
text = "KAVA",
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = MaterialTheme.colorScheme.outline
|
||||
)
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Icon(
|
||||
Icons.Default.AccountBalance,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(16.dp),
|
||||
tint = MaterialTheme.colorScheme.primary
|
||||
)
|
||||
Spacer(modifier = Modifier.width(4.dp))
|
||||
// Balance display - shows all token balances in a 2x2 grid
|
||||
Column {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.SpaceBetween
|
||||
) {
|
||||
// KAVA balance
|
||||
Column {
|
||||
Text(
|
||||
text = walletBalance?.kavaBalance ?: balance ?: "加载中...",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = if (walletBalance != null || balance != null)
|
||||
MaterialTheme.colorScheme.primary
|
||||
else
|
||||
MaterialTheme.colorScheme.outline,
|
||||
fontWeight = FontWeight.Medium
|
||||
text = "KAVA",
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = MaterialTheme.colorScheme.outline
|
||||
)
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Icon(
|
||||
Icons.Default.AccountBalance,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(16.dp),
|
||||
tint = MaterialTheme.colorScheme.primary
|
||||
)
|
||||
Spacer(modifier = Modifier.width(4.dp))
|
||||
Text(
|
||||
text = walletBalance?.kavaBalance ?: balance ?: "加载中...",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = if (walletBalance != null || balance != null)
|
||||
MaterialTheme.colorScheme.primary
|
||||
else
|
||||
MaterialTheme.colorScheme.outline,
|
||||
fontWeight = FontWeight.Medium
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Green Points (绿积分) balance
|
||||
Column(horizontalAlignment = Alignment.End) {
|
||||
Text(
|
||||
text = GreenPointsToken.NAME,
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = MaterialTheme.colorScheme.outline
|
||||
)
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Icon(
|
||||
Icons.Default.Stars,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(16.dp),
|
||||
tint = Color(0xFF4CAF50)
|
||||
)
|
||||
Spacer(modifier = Modifier.width(4.dp))
|
||||
Text(
|
||||
text = walletBalance?.greenPointsBalance ?: "加载中...",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = if (walletBalance != null)
|
||||
Color(0xFF4CAF50)
|
||||
else
|
||||
MaterialTheme.colorScheme.outline,
|
||||
fontWeight = FontWeight.Medium
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Green Points (绿积分) balance
|
||||
Column(horizontalAlignment = Alignment.End) {
|
||||
Text(
|
||||
text = GreenPointsToken.NAME,
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = MaterialTheme.colorScheme.outline
|
||||
)
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Icon(
|
||||
Icons.Default.Stars,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(16.dp),
|
||||
tint = Color(0xFF4CAF50) // Green color for Green Points
|
||||
)
|
||||
Spacer(modifier = Modifier.width(4.dp))
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.SpaceBetween
|
||||
) {
|
||||
// Energy Points (积分股) balance
|
||||
Column {
|
||||
Text(
|
||||
text = walletBalance?.greenPointsBalance ?: "加载中...",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = if (walletBalance != null)
|
||||
Color(0xFF4CAF50)
|
||||
else
|
||||
MaterialTheme.colorScheme.outline,
|
||||
fontWeight = FontWeight.Medium
|
||||
text = EnergyPointsToken.NAME,
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = MaterialTheme.colorScheme.outline
|
||||
)
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Icon(
|
||||
Icons.Default.Stars,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(16.dp),
|
||||
tint = Color(0xFF2196F3) // Blue
|
||||
)
|
||||
Spacer(modifier = Modifier.width(4.dp))
|
||||
Text(
|
||||
text = walletBalance?.energyPointsBalance ?: "加载中...",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = if (walletBalance != null)
|
||||
Color(0xFF2196F3)
|
||||
else
|
||||
MaterialTheme.colorScheme.outline,
|
||||
fontWeight = FontWeight.Medium
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Future Points (积分值) balance
|
||||
Column(horizontalAlignment = Alignment.End) {
|
||||
Text(
|
||||
text = FuturePointsToken.NAME,
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = MaterialTheme.colorScheme.outline
|
||||
)
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Icon(
|
||||
Icons.Default.Stars,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(16.dp),
|
||||
tint = Color(0xFF9C27B0) // Purple
|
||||
)
|
||||
Spacer(modifier = Modifier.width(4.dp))
|
||||
Text(
|
||||
text = walletBalance?.futurePointsBalance ?: "加载中...",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = if (walletBalance != null)
|
||||
Color(0xFF9C27B0)
|
||||
else
|
||||
MaterialTheme.colorScheme.outline,
|
||||
fontWeight = FontWeight.Medium
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -372,6 +440,16 @@ private fun WalletItemCard(
|
|||
Text("转账")
|
||||
}
|
||||
|
||||
TextButton(onClick = onHistory) {
|
||||
Icon(
|
||||
Icons.Default.Receipt,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(18.dp)
|
||||
)
|
||||
Spacer(modifier = Modifier.width(4.dp))
|
||||
Text("记录")
|
||||
}
|
||||
|
||||
TextButton(
|
||||
onClick = { showDeleteDialog = true },
|
||||
colors = ButtonDefaults.textButtonColors(
|
||||
|
|
|
|||
|
|
@ -2,8 +2,8 @@ package com.durian.tssparty.presentation.viewmodel
|
|||
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.durian.tssparty.data.repository.JoinKeygenViaGrpcResult
|
||||
import com.durian.tssparty.data.repository.TssRepository
|
||||
import com.durian.tssparty.data.repository.TssRepository.JoinKeygenViaGrpcResult
|
||||
import com.durian.tssparty.domain.model.*
|
||||
import com.durian.tssparty.util.AddressUtils
|
||||
import com.durian.tssparty.util.TransactionUtils
|
||||
|
|
@ -45,6 +45,11 @@ class MainViewModel @Inject constructor(
|
|||
private val _hasEnteredSession = MutableStateFlow(false)
|
||||
val hasEnteredSession: StateFlow<Boolean> = _hasEnteredSession.asStateFlow()
|
||||
|
||||
// Synchronous flag to prevent participant_joined from adding duplicates after session_started
|
||||
// This is set immediately (synchronously) when session_started is processed, ensuring
|
||||
// any subsequent participant_joined events in the same callback queue will see the flag
|
||||
private var sessionStartedForSession: String? = null
|
||||
|
||||
init {
|
||||
// Start initialization on app launch
|
||||
checkAllServices()
|
||||
|
|
@ -218,6 +223,9 @@ class MainViewModel @Inject constructor(
|
|||
private val _currentRound = MutableStateFlow(0)
|
||||
val currentRound: StateFlow<Int> = _currentRound.asStateFlow()
|
||||
|
||||
private val _totalRounds = MutableStateFlow(0)
|
||||
val totalRounds: StateFlow<Int> = _totalRounds.asStateFlow()
|
||||
|
||||
private val _publicKey = MutableStateFlow<String?>(null)
|
||||
val publicKey: StateFlow<String?> = _publicKey.asStateFlow()
|
||||
|
||||
|
|
@ -288,19 +296,30 @@ class MainViewModel @Inject constructor(
|
|||
|
||||
// Setup keygen timeout callback (matching Electron's 5-minute timeout in checkAndTriggerKeygen)
|
||||
repository.setKeygenTimeoutCallback { errorMessage ->
|
||||
android.util.Log.e("MainViewModel", "Keygen timeout: $errorMessage")
|
||||
_uiState.update { it.copy(isLoading = false, error = errorMessage, countdownSeconds = -1L) }
|
||||
android.util.Log.e("MainViewModel", "[IDLE_CRASH_DEBUG] Keygen timeout callback invoked: $errorMessage")
|
||||
try {
|
||||
_uiState.update { it.copy(isLoading = false, error = errorMessage, countdownSeconds = -1L) }
|
||||
android.util.Log.d("MainViewModel", "[IDLE_CRASH_DEBUG] Keygen timeout callback completed")
|
||||
} catch (e: Exception) {
|
||||
android.util.Log.e("MainViewModel", "[IDLE_CRASH_DEBUG] Exception in keygen timeout callback: ${e.message}")
|
||||
android.util.Log.e("MainViewModel", "[IDLE_CRASH_DEBUG] Stack: ${e.stackTraceToString()}")
|
||||
}
|
||||
}
|
||||
|
||||
// Setup countdown tick callback for UI countdown display
|
||||
repository.setCountdownTickCallback { remainingSeconds ->
|
||||
android.util.Log.d("MainViewModel", "Countdown tick: $remainingSeconds seconds remaining")
|
||||
_uiState.update { it.copy(countdownSeconds = remainingSeconds) }
|
||||
try {
|
||||
_uiState.update { it.copy(countdownSeconds = remainingSeconds) }
|
||||
} catch (e: Exception) {
|
||||
android.util.Log.e("MainViewModel", "[IDLE_CRASH_DEBUG] Exception in countdown tick callback: ${e.message}")
|
||||
}
|
||||
}
|
||||
|
||||
// Setup progress callback for real-time round updates from native TSS bridge
|
||||
repository.setProgressCallback { round, totalRounds ->
|
||||
android.util.Log.d("MainViewModel", "Progress update: $round / $totalRounds")
|
||||
repository.setProgressCallback { round, totalRoundsFromGo ->
|
||||
android.util.Log.d("MainViewModel", "Progress update: $round / $totalRoundsFromGo")
|
||||
// Update totalRounds from Go library (keygen=4, sign=9)
|
||||
_totalRounds.value = totalRoundsFromGo
|
||||
// Update the appropriate round state based on which session type is active
|
||||
when {
|
||||
// Initiator keygen (CreateWallet)
|
||||
|
|
@ -323,21 +342,32 @@ class MainViewModel @Inject constructor(
|
|||
}
|
||||
|
||||
repository.setSessionEventCallback { event ->
|
||||
android.util.Log.d("MainViewModel", "=== MainViewModel received session event ===")
|
||||
android.util.Log.d("MainViewModel", " eventType: ${event.eventType}")
|
||||
android.util.Log.d("MainViewModel", " sessionId: ${event.sessionId}")
|
||||
android.util.Log.d("MainViewModel", " _currentSessionId: ${_currentSessionId.value}")
|
||||
android.util.Log.d("MainViewModel", " pendingJoinKeygenInfo?.sessionId: ${pendingJoinKeygenInfo?.sessionId}")
|
||||
android.util.Log.d("MainViewModel", " pendingJoinSignInfo?.sessionId: ${pendingJoinSignInfo?.sessionId}")
|
||||
android.util.Log.d("MainViewModel", " _signSessionId: ${_signSessionId.value}")
|
||||
android.util.Log.d("MainViewModel", " pendingSignInitiatorInfo?.sessionId: ${pendingSignInitiatorInfo?.sessionId}")
|
||||
try {
|
||||
android.util.Log.d("MainViewModel", "[IDLE_CRASH_DEBUG] === MainViewModel received session event ===")
|
||||
android.util.Log.d("MainViewModel", "[IDLE_CRASH_DEBUG] eventType: ${event.eventType}")
|
||||
android.util.Log.d("MainViewModel", "[IDLE_CRASH_DEBUG] sessionId: ${event.sessionId}")
|
||||
android.util.Log.d("MainViewModel", "[IDLE_CRASH_DEBUG] _currentSessionId: ${_currentSessionId.value}")
|
||||
android.util.Log.d("MainViewModel", "[IDLE_CRASH_DEBUG] pendingJoinKeygenInfo?.sessionId: ${pendingJoinKeygenInfo?.sessionId}")
|
||||
android.util.Log.d("MainViewModel", "[IDLE_CRASH_DEBUG] pendingJoinSignInfo?.sessionId: ${pendingJoinSignInfo?.sessionId}")
|
||||
android.util.Log.d("MainViewModel", "[IDLE_CRASH_DEBUG] _signSessionId: ${_signSessionId.value}")
|
||||
android.util.Log.d("MainViewModel", "[IDLE_CRASH_DEBUG] pendingSignInitiatorInfo?.sessionId: ${pendingSignInitiatorInfo?.sessionId}")
|
||||
|
||||
when (event.eventType) {
|
||||
"session_started" -> {
|
||||
// CRITICAL: Set flag immediately (synchronously) to prevent subsequent
|
||||
// participant_joined events from adding duplicates. This must be the
|
||||
// first line before any async operations.
|
||||
sessionStartedForSession = event.sessionId
|
||||
android.util.Log.d("MainViewModel", "[IDLE_CRASH_DEBUG] Session started flag set for: ${event.sessionId}")
|
||||
|
||||
// Check if this is for keygen initiator (CreateWallet)
|
||||
val currentSessionId = _currentSessionId.value
|
||||
if (currentSessionId != null && event.sessionId == currentSessionId) {
|
||||
android.util.Log.d("MainViewModel", "Session started event for keygen initiator, triggering keygen")
|
||||
// Ensure participant list has exactly N parties (fill if incomplete, don't add more)
|
||||
if (_sessionParticipants.value.size < event.thresholdN) {
|
||||
_sessionParticipants.value = (1..event.thresholdN).map { "参与方 $it" }
|
||||
}
|
||||
viewModelScope.launch {
|
||||
startKeygenAsInitiator(
|
||||
sessionId = currentSessionId,
|
||||
|
|
@ -352,6 +382,10 @@ class MainViewModel @Inject constructor(
|
|||
val joinKeygenInfo = pendingJoinKeygenInfo
|
||||
if (joinKeygenInfo != null && event.sessionId == joinKeygenInfo.sessionId) {
|
||||
android.util.Log.d("MainViewModel", "Session started event for keygen joiner, triggering keygen")
|
||||
// Ensure participant list has exactly N parties
|
||||
if (_joinKeygenParticipants.value.size < event.thresholdN) {
|
||||
_joinKeygenParticipants.value = (1..event.thresholdN).map { "参与方 $it" }
|
||||
}
|
||||
startKeygenAsJoiner()
|
||||
}
|
||||
|
||||
|
|
@ -359,6 +393,10 @@ class MainViewModel @Inject constructor(
|
|||
val joinSignInfo = pendingJoinSignInfo
|
||||
if (joinSignInfo != null && event.sessionId == joinSignInfo.sessionId) {
|
||||
android.util.Log.d("MainViewModel", "Session started event for sign joiner, triggering sign")
|
||||
// Ensure participant list has exactly T parties
|
||||
if (_coSignParticipants.value.size < event.thresholdT) {
|
||||
_coSignParticipants.value = (1..event.thresholdT).map { "参与方 $it" }
|
||||
}
|
||||
startSignAsJoiner()
|
||||
}
|
||||
|
||||
|
|
@ -367,6 +405,10 @@ class MainViewModel @Inject constructor(
|
|||
android.util.Log.d("MainViewModel", "Checking for sign initiator: signSessionId=$signSessionId, eventSessionId=${event.sessionId}")
|
||||
if (signSessionId != null && event.sessionId == signSessionId) {
|
||||
android.util.Log.d("MainViewModel", "Session started event for sign initiator, triggering sign")
|
||||
// Ensure participant list has exactly T parties
|
||||
if (_signParticipants.value.size < event.thresholdT) {
|
||||
_signParticipants.value = (1..event.thresholdT).map { "参与方 $it" }
|
||||
}
|
||||
startSignAsInitiator(event.selectedParties)
|
||||
} else {
|
||||
android.util.Log.d("MainViewModel", "NOT triggering sign initiator: signSessionId=$signSessionId, pendingSignInitiatorInfo=${pendingSignInitiatorInfo?.sessionId}")
|
||||
|
|
@ -375,6 +417,15 @@ class MainViewModel @Inject constructor(
|
|||
"party_joined", "participant_joined" -> {
|
||||
android.util.Log.d("MainViewModel", "Processing participant_joined event...")
|
||||
|
||||
// CRITICAL: Check synchronous flag first - if session_started was already
|
||||
// processed for this session, don't add more participants
|
||||
// This is 100% reliable because the flag is set synchronously in session_started
|
||||
// handler before any async operations, and callbacks are processed sequentially
|
||||
if (sessionStartedForSession == event.sessionId) {
|
||||
android.util.Log.d("MainViewModel", " Session already started for ${event.sessionId}, ignoring participant_joined")
|
||||
return@setSessionEventCallback
|
||||
}
|
||||
|
||||
// Update participant count for initiator's CreateWallet screen
|
||||
val currentSessionId = _currentSessionId.value
|
||||
android.util.Log.d("MainViewModel", " Checking for initiator: currentSessionId=$currentSessionId, eventSessionId=${event.sessionId}")
|
||||
|
|
@ -455,6 +506,12 @@ class MainViewModel @Inject constructor(
|
|||
}
|
||||
}
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
android.util.Log.e("MainViewModel", "[IDLE_CRASH_DEBUG] Exception in session event callback!")
|
||||
android.util.Log.e("MainViewModel", "[IDLE_CRASH_DEBUG] Event: ${event.eventType}, sessionId: ${event.sessionId}")
|
||||
android.util.Log.e("MainViewModel", "[IDLE_CRASH_DEBUG] Exception: ${e.javaClass.simpleName}: ${e.message}")
|
||||
android.util.Log.e("MainViewModel", "[IDLE_CRASH_DEBUG] Stack: ${e.stackTraceToString()}")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -515,9 +572,12 @@ class MainViewModel @Inject constructor(
|
|||
_currentSessionId.value = null
|
||||
_sessionParticipants.value = emptyList()
|
||||
_currentRound.value = 0
|
||||
_totalRounds.value = 0
|
||||
_publicKey.value = null
|
||||
_createdInviteCode.value = null
|
||||
_hasEnteredSession.value = false
|
||||
// Reset synchronous flag for fresh session
|
||||
sessionStartedForSession = null
|
||||
// Reset session status to WAITING for fresh start
|
||||
repository.resetSessionStatus()
|
||||
}
|
||||
|
|
@ -659,7 +719,11 @@ class MainViewModel @Inject constructor(
|
|||
viewModelScope.launch {
|
||||
_uiState.update { it.copy(isLoading = true, error = null) }
|
||||
|
||||
android.util.Log.d("MainViewModel", "Starting keygen as joiner: sessionId=${joinInfo.sessionId}, partyIndex=${joinInfo.partyIndex}")
|
||||
// Initialize participant list with all N parties (keygen requires all parties)
|
||||
// This ensures UI shows correct participant count even if we missed some participant_joined events
|
||||
_joinKeygenParticipants.value = (1..joinInfo.thresholdN).map { "参与方 $it" }
|
||||
|
||||
android.util.Log.d("MainViewModel", "Starting keygen as joiner: sessionId=${joinInfo.sessionId}, partyIndex=${joinInfo.partyIndex}, thresholdN=${joinInfo.thresholdN}")
|
||||
|
||||
val result = repository.executeKeygenAsJoiner(
|
||||
sessionId = joinInfo.sessionId,
|
||||
|
|
@ -706,6 +770,8 @@ class MainViewModel @Inject constructor(
|
|||
pendingJoinToken = ""
|
||||
pendingPassword = ""
|
||||
pendingJoinKeygenInfo = null
|
||||
// Reset synchronous flag for fresh session
|
||||
sessionStartedForSession = null
|
||||
// Reset session status to WAITING for fresh start
|
||||
repository.resetSessionStatus()
|
||||
}
|
||||
|
|
@ -891,6 +957,8 @@ class MainViewModel @Inject constructor(
|
|||
pendingCoSignInviteCode = ""
|
||||
pendingCoSignJoinToken = ""
|
||||
pendingJoinSignInfo = null
|
||||
// Reset synchronous flag for fresh session
|
||||
sessionStartedForSession = null
|
||||
// Reset session status to WAITING for fresh start
|
||||
repository.resetSessionStatus()
|
||||
}
|
||||
|
|
@ -917,6 +985,79 @@ class MainViewModel @Inject constructor(
|
|||
}
|
||||
}
|
||||
|
||||
// ========== Transaction Records ==========
|
||||
|
||||
private val _transactionRecords = MutableStateFlow<List<com.durian.tssparty.data.local.TransactionRecordEntity>>(emptyList())
|
||||
val transactionRecords: StateFlow<List<com.durian.tssparty.data.local.TransactionRecordEntity>> = _transactionRecords.asStateFlow()
|
||||
|
||||
private val _isSyncingHistory = MutableStateFlow(false)
|
||||
val isSyncingHistory: StateFlow<Boolean> = _isSyncingHistory.asStateFlow()
|
||||
|
||||
private val _syncResultMessage = MutableStateFlow<String?>(null)
|
||||
val syncResultMessage: StateFlow<String?> = _syncResultMessage.asStateFlow()
|
||||
|
||||
fun clearSyncResultMessage() {
|
||||
_syncResultMessage.value = null
|
||||
}
|
||||
|
||||
/**
|
||||
* 加载钱包的交易记录
|
||||
*/
|
||||
fun loadTransactionRecords(shareId: Long) {
|
||||
viewModelScope.launch {
|
||||
repository.getTransactionRecords(shareId).collect { records ->
|
||||
_transactionRecords.value = records
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 同步钱包的所有历史交易
|
||||
* 首次导入钱包时调用
|
||||
*/
|
||||
fun syncTransactionHistory(shareId: Long, address: String) {
|
||||
viewModelScope.launch {
|
||||
_isSyncingHistory.value = true
|
||||
android.util.Log.d("MainViewModel", "[SYNC] Starting transaction history sync for $address")
|
||||
|
||||
val rpcUrl = _settings.value.kavaRpcUrl
|
||||
val networkType = _settings.value.networkType
|
||||
|
||||
val result = repository.syncAllTransactionHistory(shareId, address, rpcUrl, networkType)
|
||||
result.fold(
|
||||
onSuccess = { count ->
|
||||
android.util.Log.d("MainViewModel", "[SYNC] Synced $count transactions")
|
||||
_syncResultMessage.value = if (count > 0) {
|
||||
"同步完成,新增 $count 条记录"
|
||||
} else {
|
||||
"同步完成,无新记录"
|
||||
}
|
||||
},
|
||||
onFailure = { e ->
|
||||
android.util.Log.e("MainViewModel", "[SYNC] Error syncing: ${e.message}")
|
||||
_syncResultMessage.value = "同步失败: ${e.message}"
|
||||
}
|
||||
)
|
||||
_isSyncingHistory.value = false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 确认所有待处理的交易
|
||||
* 应用启动时调用
|
||||
*/
|
||||
fun confirmPendingTransactions() {
|
||||
viewModelScope.launch {
|
||||
val rpcUrl = _settings.value.kavaRpcUrl
|
||||
val pendingRecords = repository.getPendingTransactions()
|
||||
android.util.Log.d("MainViewModel", "[TX-CONFIRM] Found ${pendingRecords.size} pending transactions")
|
||||
|
||||
for (record in pendingRecords) {
|
||||
repository.confirmTransaction(record.txHash, rpcUrl)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ========== Share Export/Import ==========
|
||||
|
||||
private val _exportResult = MutableStateFlow<ExportImportResult?>(null)
|
||||
|
|
@ -931,19 +1072,30 @@ class MainViewModel @Inject constructor(
|
|||
* @return The backup JSON string on success
|
||||
*/
|
||||
fun exportShareBackup(shareId: Long, onSuccess: (String) -> Unit) {
|
||||
android.util.Log.d("MainViewModel", "[EXPORT] ========== exportShareBackup called ==========")
|
||||
android.util.Log.d("MainViewModel", "[EXPORT] shareId: $shareId")
|
||||
viewModelScope.launch {
|
||||
android.util.Log.d("MainViewModel", "[EXPORT] Setting loading state...")
|
||||
_exportResult.value = ExportImportResult(isLoading = true)
|
||||
|
||||
android.util.Log.d("MainViewModel", "[EXPORT] Calling repository.exportShareBackup...")
|
||||
val result = repository.exportShareBackup(shareId)
|
||||
android.util.Log.d("MainViewModel", "[EXPORT] Repository returned, isSuccess: ${result.isSuccess}")
|
||||
result.fold(
|
||||
onSuccess = { json ->
|
||||
android.util.Log.d("MainViewModel", "[EXPORT] Export succeeded, json length: ${json.length}")
|
||||
android.util.Log.d("MainViewModel", "[EXPORT] Setting success state and calling onSuccess callback...")
|
||||
_exportResult.value = ExportImportResult(isSuccess = true)
|
||||
android.util.Log.d("MainViewModel", "[EXPORT] Calling onSuccess callback with json...")
|
||||
onSuccess(json)
|
||||
android.util.Log.d("MainViewModel", "[EXPORT] onSuccess callback completed")
|
||||
},
|
||||
onFailure = { e ->
|
||||
android.util.Log.e("MainViewModel", "[EXPORT] Export failed: ${e.message}", e)
|
||||
_exportResult.value = ExportImportResult(error = e.message ?: "导出失败")
|
||||
}
|
||||
)
|
||||
android.util.Log.d("MainViewModel", "[EXPORT] ========== exportShareBackup finished ==========")
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -952,27 +1104,46 @@ class MainViewModel @Inject constructor(
|
|||
* @param backupJson The backup JSON string to import
|
||||
*/
|
||||
fun importShareBackup(backupJson: String) {
|
||||
android.util.Log.d("MainViewModel", "[IMPORT] ========== importShareBackup called ==========")
|
||||
android.util.Log.d("MainViewModel", "[IMPORT] JSON length: ${backupJson.length}")
|
||||
android.util.Log.d("MainViewModel", "[IMPORT] JSON preview: ${backupJson.take(100)}...")
|
||||
viewModelScope.launch {
|
||||
android.util.Log.d("MainViewModel", "[IMPORT] Setting loading state...")
|
||||
_importResult.value = ExportImportResult(isLoading = true)
|
||||
|
||||
android.util.Log.d("MainViewModel", "[IMPORT] Calling repository.importShareBackup...")
|
||||
val result = repository.importShareBackup(backupJson)
|
||||
android.util.Log.d("MainViewModel", "[IMPORT] Repository returned, isSuccess: ${result.isSuccess}")
|
||||
result.fold(
|
||||
onSuccess = { share ->
|
||||
android.util.Log.d("MainViewModel", "[IMPORT] Import succeeded:")
|
||||
android.util.Log.d("MainViewModel", "[IMPORT] - id: ${share.id}")
|
||||
android.util.Log.d("MainViewModel", "[IMPORT] - address: ${share.address}")
|
||||
android.util.Log.d("MainViewModel", "[IMPORT] - partyId: ${share.partyId}")
|
||||
_importResult.value = ExportImportResult(
|
||||
isSuccess = true,
|
||||
message = "已成功导入钱包 (${share.address.take(10)}...)"
|
||||
)
|
||||
// Update wallet count
|
||||
android.util.Log.d("MainViewModel", "[IMPORT] Updating wallet count...")
|
||||
_appState.update { state ->
|
||||
state.copy(walletCount = state.walletCount + 1)
|
||||
}
|
||||
// Fetch balance for the imported wallet
|
||||
android.util.Log.d("MainViewModel", "[IMPORT] Fetching balance...")
|
||||
fetchBalanceForShare(share)
|
||||
|
||||
// Sync transaction history from blockchain (first-time import)
|
||||
android.util.Log.d("MainViewModel", "[IMPORT] Starting transaction history sync...")
|
||||
syncTransactionHistory(share.id, share.address)
|
||||
android.util.Log.d("MainViewModel", "[IMPORT] Import complete!")
|
||||
},
|
||||
onFailure = { e ->
|
||||
android.util.Log.e("MainViewModel", "[IMPORT] Import failed: ${e.message}", e)
|
||||
_importResult.value = ExportImportResult(error = e.message ?: "导入失败")
|
||||
}
|
||||
)
|
||||
android.util.Log.d("MainViewModel", "[IMPORT] ========== importShareBackup finished ==========")
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -1282,9 +1453,95 @@ class MainViewModel @Inject constructor(
|
|||
}
|
||||
}
|
||||
|
||||
// ========== 2-of-3 服务器参与选项(新增功能)==========
|
||||
// 新增日期:2026-01-27
|
||||
// 新增原因:允许 2-of-3 用户在丢失一个设备时,通过服务器参与签名转出资产
|
||||
// 影响范围:纯新增,不影响现有 initiateSignSession
|
||||
// 回滚方法:删除此方法及相关 UI 代码即可恢复
|
||||
|
||||
/**
|
||||
* 创建签名会话(支持选择服务器参与)
|
||||
*
|
||||
* 新增方法,不修改现有 initiateSignSession
|
||||
* 仅在 UI 层判断为 2-of-3 且用户主动勾选时调用此方法
|
||||
*
|
||||
* @param shareId 钱包 ID
|
||||
* @param password 钱包密码
|
||||
* @param initiatorName 发起者名称
|
||||
* @param includeServerBackup 是否包含服务器备份参与方(新增参数)
|
||||
*
|
||||
* 使用场景:
|
||||
* - 2-of-3 用户丢失一个设备
|
||||
* - 用户勾选"包含服务器备份"选项
|
||||
* - 使用剩余设备 + 服务器完成签名
|
||||
*
|
||||
* 安全保障:
|
||||
* - UI 层限制仅 2-of-3 显示此选项
|
||||
* - 用户主动明确选择
|
||||
* - 服务器只有 1 个 key < t=2
|
||||
*/
|
||||
fun initiateSignSessionWithOptions(
|
||||
shareId: Long,
|
||||
password: String,
|
||||
initiatorName: String = "发起者",
|
||||
includeServerBackup: Boolean = false // 新增参数
|
||||
) {
|
||||
viewModelScope.launch {
|
||||
_uiState.update { it.copy(isLoading = true, error = null) }
|
||||
|
||||
val tx = _preparedTx.value
|
||||
if (tx == null) {
|
||||
_uiState.update { it.copy(isLoading = false, error = "交易未准备") }
|
||||
return@launch
|
||||
}
|
||||
|
||||
android.util.Log.d("MainViewModel", "[SIGN-OPTIONS] Initiating sign session with includeServerBackup=$includeServerBackup")
|
||||
|
||||
// 调用新的 repository 方法
|
||||
val result = repository.createSignSessionWithOptions(
|
||||
shareId = shareId,
|
||||
messageHash = tx.signHash,
|
||||
password = password,
|
||||
initiatorName = initiatorName,
|
||||
includeServerBackup = includeServerBackup // 传递新参数
|
||||
)
|
||||
|
||||
result.fold(
|
||||
onSuccess = { sessionResult ->
|
||||
_signSessionId.value = sessionResult.sessionId
|
||||
_signInviteCode.value = sessionResult.inviteCode
|
||||
_signParticipants.value = listOf(initiatorName)
|
||||
_uiState.update { it.copy(isLoading = false) }
|
||||
|
||||
pendingSignInitiatorInfo = PendingSignInitiatorInfo(
|
||||
sessionId = sessionResult.sessionId,
|
||||
shareId = shareId,
|
||||
password = password
|
||||
)
|
||||
|
||||
android.util.Log.d("MainViewModel", "[SIGN-OPTIONS] Sign session created with server=${includeServerBackup}, sessionId=${sessionResult.sessionId}")
|
||||
|
||||
if (sessionResult.sessionAlreadyInProgress) {
|
||||
android.util.Log.d("MainViewModel", "[SIGN-OPTIONS] Session already in_progress, triggering sign immediately")
|
||||
startSigningProcess(sessionResult.sessionId, shareId, password)
|
||||
}
|
||||
},
|
||||
onFailure = { e ->
|
||||
android.util.Log.e("MainViewModel", "[SIGN-OPTIONS] Failed to create sign session: ${e.message}")
|
||||
_uiState.update { it.copy(isLoading = false, error = e.message) }
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
// ========== 2-of-3 服务器参与选项结束 ==========
|
||||
|
||||
/**
|
||||
* Start sign as initiator (called when session_started event is received)
|
||||
* Matches Electron's handleCoSignStart for initiator
|
||||
*
|
||||
* CRITICAL: This method includes防重入检查 to prevent double execution
|
||||
* Race condition fix: TssRepository may have already triggered signing via
|
||||
* its session_started handler. This callback serves as a fallback.
|
||||
*/
|
||||
private fun startSignAsInitiator(selectedParties: List<String>) {
|
||||
val info = pendingSignInitiatorInfo
|
||||
|
|
@ -1293,6 +1550,13 @@ class MainViewModel @Inject constructor(
|
|||
return
|
||||
}
|
||||
|
||||
// CRITICAL: Prevent double execution if TssRepository already started signing
|
||||
// TssRepository sets signingTriggered=true when it auto-triggers from session_started
|
||||
if (repository.isSigningTriggered()) {
|
||||
android.util.Log.d("MainViewModel", "[RACE-FIX] Signing already triggered by TssRepository, skipping duplicate from MainViewModel")
|
||||
return
|
||||
}
|
||||
|
||||
android.util.Log.d("MainViewModel", "Starting sign as initiator: sessionId=${info.sessionId}, selectedParties=$selectedParties")
|
||||
startSigningProcess(info.sessionId, info.shareId, info.password)
|
||||
}
|
||||
|
|
@ -1364,7 +1628,30 @@ class MainViewModel @Inject constructor(
|
|||
onSuccess = { hash ->
|
||||
android.util.Log.d("MainViewModel", "[BROADCAST] SUCCESS! txHash=$hash")
|
||||
_txHash.value = hash
|
||||
_uiState.update { it.copy(isLoading = false, successMessage = "交易已广播!") }
|
||||
|
||||
// 保存交易记录到本地数据库
|
||||
val state = _transferState.value
|
||||
android.util.Log.d("MainViewModel", "[BROADCAST] Saving transaction record: shareId=${state.shareId}, tokenType=${state.tokenType}")
|
||||
try {
|
||||
repository.saveTransactionRecord(
|
||||
shareId = state.shareId,
|
||||
fromAddress = tx.from,
|
||||
toAddress = tx.to,
|
||||
amount = state.amount,
|
||||
tokenType = state.tokenType,
|
||||
txHash = hash,
|
||||
gasPrice = tx.gasPrice.toString()
|
||||
)
|
||||
android.util.Log.d("MainViewModel", "[BROADCAST] Transaction record saved successfully")
|
||||
|
||||
// 启动后台确认交易状态
|
||||
confirmTransactionInBackground(hash, rpcUrl)
|
||||
|
||||
_uiState.update { it.copy(isLoading = false, successMessage = "交易已广播!") }
|
||||
} catch (e: Exception) {
|
||||
android.util.Log.e("MainViewModel", "[BROADCAST] Failed to save transaction record: ${e.message}", e)
|
||||
_uiState.update { it.copy(isLoading = false, error = "交易已广播但保存记录失败: ${e.message}") }
|
||||
}
|
||||
},
|
||||
onFailure = { e ->
|
||||
android.util.Log.e("MainViewModel", "[BROADCAST] FAILED: ${e.message}", e)
|
||||
|
|
@ -1374,6 +1661,37 @@ class MainViewModel @Inject constructor(
|
|||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 后台确认交易状态
|
||||
* 每 3 秒轮询一次,最多尝试 60 次(3 分钟)
|
||||
*/
|
||||
private fun confirmTransactionInBackground(txHash: String, rpcUrl: String) {
|
||||
viewModelScope.launch {
|
||||
android.util.Log.d("MainViewModel", "[TX-CONFIRM] Starting background confirmation for $txHash")
|
||||
var attempts = 0
|
||||
val maxAttempts = 60
|
||||
|
||||
while (attempts < maxAttempts) {
|
||||
kotlinx.coroutines.delay(3000) // 等待 3 秒
|
||||
attempts++
|
||||
|
||||
val result = repository.confirmTransaction(txHash, rpcUrl)
|
||||
result.fold(
|
||||
onSuccess = { confirmed ->
|
||||
if (confirmed) {
|
||||
android.util.Log.d("MainViewModel", "[TX-CONFIRM] Transaction confirmed after $attempts attempts")
|
||||
return@launch
|
||||
}
|
||||
},
|
||||
onFailure = { e ->
|
||||
android.util.Log.w("MainViewModel", "[TX-CONFIRM] Error checking confirmation: ${e.message}")
|
||||
}
|
||||
)
|
||||
}
|
||||
android.util.Log.w("MainViewModel", "[TX-CONFIRM] Max attempts reached, transaction may still be pending")
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Reset transfer state
|
||||
*/
|
||||
|
|
@ -1387,6 +1705,8 @@ class MainViewModel @Inject constructor(
|
|||
_signature.value = null
|
||||
_txHash.value = null
|
||||
pendingSignInitiatorInfo = null
|
||||
// Reset synchronous flag for fresh session
|
||||
sessionStartedForSession = null
|
||||
// Reset session status to WAITING for fresh start
|
||||
repository.resetSessionStatus()
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,10 @@
|
|||
package com.durian.tssparty.util
|
||||
|
||||
import com.durian.tssparty.domain.model.ERC20Selectors
|
||||
import com.durian.tssparty.domain.model.EnergyPointsToken
|
||||
import com.durian.tssparty.domain.model.FuturePointsToken
|
||||
import com.durian.tssparty.domain.model.GreenPointsToken
|
||||
import com.durian.tssparty.domain.model.TokenConfig
|
||||
import com.durian.tssparty.domain.model.TokenType
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
|
|
@ -19,13 +23,50 @@ import java.util.concurrent.TimeUnit
|
|||
*/
|
||||
object TransactionUtils {
|
||||
|
||||
/**
|
||||
* HTTP client for blockchain RPC calls
|
||||
*
|
||||
* 【架构安全修复 - 配置连接池防止资源泄漏】
|
||||
*
|
||||
* 配置连接池参数限制资源占用:
|
||||
* - maxIdleConnections: 5 (最多保留 5 个空闲连接)
|
||||
* - keepAliveDuration: 5 分钟 (空闲连接保活时间)
|
||||
*
|
||||
* 注意: TransactionUtils 是 object 单例,生命周期与应用一致
|
||||
* 如果应用需要完全清理资源,可调用 cleanup() 方法
|
||||
*/
|
||||
private val client = OkHttpClient.Builder()
|
||||
.connectTimeout(30, TimeUnit.SECONDS)
|
||||
.readTimeout(30, TimeUnit.SECONDS)
|
||||
.connectionPool(okhttp3.ConnectionPool(
|
||||
maxIdleConnections = 5,
|
||||
keepAliveDuration = 5,
|
||||
timeUnit = TimeUnit.MINUTES
|
||||
))
|
||||
.build()
|
||||
|
||||
private val jsonMediaType = "application/json; charset=utf-8".toMediaType()
|
||||
|
||||
/**
|
||||
* Cleanup OkHttpClient resources
|
||||
*
|
||||
* 【架构安全修复 - 提供资源清理方法】
|
||||
*
|
||||
* 虽然 TransactionUtils 是 object 单例,但提供此方法允许:
|
||||
* 1. 测试环境清理资源
|
||||
* 2. 应用完全退出时释放资源
|
||||
* 3. 内存紧张时主动清理
|
||||
*/
|
||||
fun cleanup() {
|
||||
try {
|
||||
client.connectionPool.evictAll()
|
||||
client.dispatcher.executorService.shutdown()
|
||||
client.cache?.close()
|
||||
} catch (e: Exception) {
|
||||
// 静默失败,因为这是清理操作
|
||||
}
|
||||
}
|
||||
|
||||
// Chain IDs
|
||||
const val KAVA_TESTNET_CHAIN_ID = 2221
|
||||
const val KAVA_MAINNET_CHAIN_ID = 2222
|
||||
|
|
@ -61,7 +102,7 @@ object TransactionUtils {
|
|||
/**
|
||||
* Prepare a transaction for signing
|
||||
* Gets nonce, gas price, estimates gas, and calculates sign hash
|
||||
* Supports both native KAVA transfers and ERC-20 token transfers (绿积分)
|
||||
* Supports both native KAVA transfers and ERC-20 token transfers (绿积分/积分股/积分值)
|
||||
*/
|
||||
suspend fun prepareTransaction(params: TransactionParams): Result<PreparedTransaction> = withContext(Dispatchers.IO) {
|
||||
try {
|
||||
|
|
@ -77,13 +118,16 @@ object TransactionUtils {
|
|||
// Native KAVA transfer
|
||||
Triple(params.to, kavaToWei(params.amount), ByteArray(0))
|
||||
}
|
||||
TokenType.GREEN_POINTS -> {
|
||||
// ERC-20 token transfer (绿积分)
|
||||
TokenType.GREEN_POINTS, TokenType.ENERGY_POINTS, TokenType.FUTURE_POINTS -> {
|
||||
// ERC-20 token transfer
|
||||
// To address is the contract, value is 0
|
||||
// Data is transfer(recipient, amount) encoded
|
||||
val tokenAmount = greenPointsToRaw(params.amount)
|
||||
val contractAddress = TokenConfig.getContractAddress(params.tokenType)
|
||||
?: return@withContext Result.failure(Exception("Invalid token type"))
|
||||
val decimals = TokenConfig.getDecimals(params.tokenType)
|
||||
val tokenAmount = tokenToRaw(params.amount, decimals)
|
||||
val transferData = encodeErc20Transfer(params.to, tokenAmount)
|
||||
Triple(GreenPointsToken.CONTRACT_ADDRESS, BigInteger.ZERO, transferData)
|
||||
Triple(contractAddress, BigInteger.ZERO, transferData)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -98,7 +142,7 @@ object TransactionUtils {
|
|||
// Default gas limits
|
||||
when (params.tokenType) {
|
||||
TokenType.KAVA -> BigInteger.valueOf(21000)
|
||||
TokenType.GREEN_POINTS -> BigInteger.valueOf(65000) // ERC-20 transfers need more gas
|
||||
else -> BigInteger.valueOf(65000) // ERC-20 transfers need more gas
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -139,7 +183,7 @@ object TransactionUtils {
|
|||
*/
|
||||
private fun encodeErc20Transfer(to: String, amount: BigInteger): ByteArray {
|
||||
// Function selector: transfer(address,uint256) = 0xa9059cbb
|
||||
val selector = GreenPointsToken.TRANSFER_SELECTOR.removePrefix("0x").hexToByteArray()
|
||||
val selector = ERC20Selectors.TRANSFER.removePrefix("0x").hexToByteArray()
|
||||
|
||||
// Encode recipient address (padded to 32 bytes)
|
||||
val paddedAddress = to.removePrefix("0x").lowercase().padStart(64, '0').hexToByteArray()
|
||||
|
|
@ -152,21 +196,43 @@ object TransactionUtils {
|
|||
}
|
||||
|
||||
/**
|
||||
* Convert Green Points amount to raw units (6 decimals)
|
||||
* Convert token amount to raw units based on decimals
|
||||
* @param amount Human-readable amount (e.g., "100.5")
|
||||
* @param decimals Token decimals (e.g., 6 for USDT-like tokens, 18 for native)
|
||||
*/
|
||||
fun greenPointsToRaw(amount: String): BigInteger {
|
||||
fun tokenToRaw(amount: String, decimals: Int): BigInteger {
|
||||
val decimal = BigDecimal(amount)
|
||||
val rawDecimal = decimal.multiply(BigDecimal("1000000")) // 10^6
|
||||
val multiplier = BigDecimal.TEN.pow(decimals)
|
||||
val rawDecimal = decimal.multiply(multiplier)
|
||||
return rawDecimal.toBigInteger()
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert raw units to human-readable token amount
|
||||
* @param raw Raw amount in smallest units
|
||||
* @param decimals Token decimals (e.g., 6 for USDT-like tokens, 18 for native)
|
||||
*/
|
||||
fun rawToToken(raw: BigInteger, decimals: Int): String {
|
||||
val rawDecimal = BigDecimal(raw)
|
||||
val divisor = BigDecimal.TEN.pow(decimals)
|
||||
val displayDecimal = rawDecimal.divide(divisor, decimals, java.math.RoundingMode.DOWN)
|
||||
return displayDecimal.toPlainString()
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert Green Points amount to raw units (6 decimals)
|
||||
* @deprecated Use tokenToRaw(amount, 6) instead
|
||||
*/
|
||||
fun greenPointsToRaw(amount: String): BigInteger {
|
||||
return tokenToRaw(amount, GreenPointsToken.DECIMALS)
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert raw units to Green Points display amount
|
||||
* @deprecated Use rawToToken(raw, 6) instead
|
||||
*/
|
||||
fun rawToGreenPoints(raw: BigInteger): String {
|
||||
val rawDecimal = BigDecimal(raw)
|
||||
val displayDecimal = rawDecimal.divide(BigDecimal("1000000"), 6, java.math.RoundingMode.DOWN)
|
||||
return displayDecimal.toPlainString()
|
||||
return rawToToken(raw, GreenPointsToken.DECIMALS)
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -75,6 +75,20 @@ echo [INFO] Using SDK from local.properties
|
|||
type local.properties
|
||||
echo.
|
||||
|
||||
:: Parse rebuild argument early - must happen BEFORE checking tsslib.aar
|
||||
set REBUILD_REQUESTED=0
|
||||
if "%1"=="rebuild" (
|
||||
set REBUILD_REQUESTED=1
|
||||
echo [INFO] Rebuild requested - deleting tsslib.aar to recompile Go code...
|
||||
if exist "app\libs\tsslib.aar" (
|
||||
del /f "app\libs\tsslib.aar"
|
||||
echo [INFO] tsslib.aar deleted, will be rebuilt
|
||||
) else (
|
||||
echo [INFO] tsslib.aar not found, will be built fresh
|
||||
)
|
||||
echo.
|
||||
)
|
||||
|
||||
:: Check and build tsslib.aar if needed
|
||||
if not exist "app\libs\tsslib.aar" (
|
||||
echo [INFO] tsslib.aar not found, attempting to build TSS library...
|
||||
|
|
@ -183,8 +197,14 @@ set BUILD_TYPE=all
|
|||
if "%1"=="debug" set BUILD_TYPE=debug
|
||||
if "%1"=="release" set BUILD_TYPE=release
|
||||
if "%1"=="clean" set BUILD_TYPE=clean
|
||||
if "%1"=="rebuild" set BUILD_TYPE=rebuild
|
||||
if "%1"=="help" goto :show_help
|
||||
|
||||
:: Handle rebuild - aar deletion already done above, just set build type
|
||||
if "%BUILD_TYPE%"=="rebuild" (
|
||||
set BUILD_TYPE=all
|
||||
)
|
||||
|
||||
:: Show build type
|
||||
echo Build type: %BUILD_TYPE%
|
||||
echo.
|
||||
|
|
@ -275,14 +295,16 @@ echo Options:
|
|||
echo debug - Build debug APK only
|
||||
echo release - Build release APK only
|
||||
echo all - Build both debug and release APKs (default)
|
||||
echo clean - Clean build files
|
||||
echo clean - Clean Gradle build files
|
||||
echo rebuild - Delete tsslib.aar and rebuild everything (use after Go code changes)
|
||||
echo help - Show this help message
|
||||
echo.
|
||||
echo Examples:
|
||||
echo build-apk.bat - Build both APKs
|
||||
echo build-apk.bat debug - Build debug APK only
|
||||
echo build-apk.bat release - Build release APK only
|
||||
echo build-apk.bat clean - Clean project
|
||||
echo build-apk.bat clean - Clean Gradle project
|
||||
echo build-apk.bat rebuild - Recompile Go code and build APKs
|
||||
echo.
|
||||
|
||||
:end
|
||||
|
|
|
|||
|
|
@ -0,0 +1,147 @@
|
|||
@echo off
|
||||
chcp 65001 >nul 2>&1
|
||||
setlocal enabledelayedexpansion
|
||||
|
||||
echo ========================================
|
||||
echo Build - Install - Launch - Debug
|
||||
echo ========================================
|
||||
echo.
|
||||
|
||||
:: Check for rebuild flag
|
||||
if "%1"=="rebuild" (
|
||||
echo [0/5] Rebuild requested - deleting tsslib.aar to recompile Go code...
|
||||
if exist "app\libs\tsslib.aar" (
|
||||
del /f "app\libs\tsslib.aar"
|
||||
echo [INFO] tsslib.aar deleted, will be rebuilt
|
||||
) else (
|
||||
echo [INFO] tsslib.aar not found, will be built fresh
|
||||
)
|
||||
echo.
|
||||
|
||||
:: Build tsslib.aar
|
||||
echo [0/5] Building tsslib.aar...
|
||||
|
||||
:: Get GOPATH for bin directory
|
||||
for /f "tokens=*" %%G in ('go env GOPATH') do set "GOPATH_DIR=%%G"
|
||||
if not defined GOPATH_DIR set "GOPATH_DIR=%USERPROFILE%\go"
|
||||
set "GOBIN_DIR=!GOPATH_DIR!\bin"
|
||||
|
||||
:: Add GOPATH/bin to PATH if not already there
|
||||
echo !PATH! | findstr /i /c:"!GOBIN_DIR!" >nul 2>nul
|
||||
if !errorlevel! neq 0 (
|
||||
set "PATH=!PATH!;!GOBIN_DIR!"
|
||||
)
|
||||
|
||||
pushd tsslib
|
||||
"!GOBIN_DIR!\gomobile.exe" bind -target=android -androidapi 21 -o "..\app\libs\tsslib.aar" .
|
||||
if !errorlevel! neq 0 (
|
||||
echo [ERROR] gomobile bind failed!
|
||||
popd
|
||||
pause
|
||||
exit /b 1
|
||||
)
|
||||
popd
|
||||
|
||||
echo [SUCCESS] tsslib.aar rebuilt!
|
||||
for %%F in ("app\libs\tsslib.aar") do echo Size: %%~zF bytes
|
||||
echo.
|
||||
)
|
||||
|
||||
:: Show help
|
||||
if "%1"=="help" (
|
||||
echo Usage: build-install-debug.bat [option]
|
||||
echo.
|
||||
echo Options:
|
||||
echo rebuild - Delete and rebuild tsslib.aar before building APK
|
||||
echo help - Show this help message
|
||||
echo.
|
||||
echo Examples:
|
||||
echo build-install-debug.bat - Build and install debug APK
|
||||
echo build-install-debug.bat rebuild - Rebuild Go code, then build and install
|
||||
echo.
|
||||
pause
|
||||
exit /b 0
|
||||
)
|
||||
|
||||
:: Step 1: Build Debug APK
|
||||
echo [1/5] Building Debug APK...
|
||||
call gradlew.bat assembleDebug --no-daemon
|
||||
if %errorlevel% neq 0 (
|
||||
echo [ERROR] Build failed!
|
||||
pause
|
||||
exit /b 1
|
||||
)
|
||||
echo [SUCCESS] Build completed!
|
||||
echo.
|
||||
|
||||
:: Step 2: Check device connection
|
||||
echo [2/5] Checking device connection...
|
||||
adb devices
|
||||
adb devices | find "device" | find /v "List" >nul
|
||||
if %errorlevel% neq 0 (
|
||||
echo [ERROR] No device detected! Please connect your phone and enable USB debugging.
|
||||
pause
|
||||
exit /b 1
|
||||
)
|
||||
echo [SUCCESS] Device connected!
|
||||
echo.
|
||||
|
||||
:: Step 3: Uninstall old version (to avoid signature conflicts)
|
||||
echo [3/5] Uninstalling old version (if exists)...
|
||||
adb uninstall com.durian.tssparty 2>nul
|
||||
echo Done!
|
||||
echo.
|
||||
|
||||
:: Step 4: Install APK
|
||||
echo [4/5] Installing APK...
|
||||
adb install app\build\outputs\apk\debug\app-debug.apk
|
||||
if %errorlevel% neq 0 (
|
||||
echo [ERROR] Installation failed!
|
||||
pause
|
||||
exit /b 1
|
||||
)
|
||||
echo [SUCCESS] Installation completed!
|
||||
echo.
|
||||
|
||||
:: Step 5: Launch app
|
||||
echo [5/5] Launching app...
|
||||
adb shell am start -n com.durian.tssparty/.MainActivity
|
||||
if %errorlevel% neq 0 (
|
||||
echo [ERROR] Launch failed!
|
||||
pause
|
||||
exit /b 1
|
||||
)
|
||||
echo [SUCCESS] App launched!
|
||||
echo.
|
||||
|
||||
:: Clear old logs
|
||||
echo Clearing old logs...
|
||||
adb logcat -c
|
||||
echo.
|
||||
|
||||
:: Show instructions
|
||||
echo ========================================
|
||||
echo App successfully launched!
|
||||
echo ========================================
|
||||
echo.
|
||||
echo Starting log monitoring...
|
||||
echo.
|
||||
echo Key log tags:
|
||||
echo - MainViewModel (ViewModel layer)
|
||||
echo - TssRepository (Repository layer)
|
||||
echo - GrpcClient (Network communication)
|
||||
echo - TssNativeBridge (TSS native library)
|
||||
echo - AndroidRuntime (Crash logs)
|
||||
echo.
|
||||
echo Press Ctrl+C to stop log monitoring
|
||||
echo.
|
||||
timeout /t 2 /nobreak >nul
|
||||
|
||||
:: Start monitoring logs
|
||||
adb logcat -v time MainViewModel:D TssRepository:D GrpcClient:D TssNativeBridge:D AndroidRuntime:E *:S
|
||||
|
||||
:: If user stops log monitoring
|
||||
echo.
|
||||
echo Log monitoring stopped.
|
||||
echo.
|
||||
pause
|
||||
|
|
@ -393,6 +393,17 @@ func SendIncomingMessage(fromPartyIndex int, isBroadcast bool, payloadBase64 str
|
|||
return fmt.Errorf("failed to parse message: %w", err)
|
||||
}
|
||||
|
||||
// Extract round from incoming message and update progress
|
||||
// This ensures progress updates on both sending and receiving messages
|
||||
totalRounds := 4 // GG20 keygen has 4 rounds
|
||||
if !session.isKeygen {
|
||||
totalRounds = 9 // GG20 signing has 9 rounds
|
||||
}
|
||||
currentRound := extractRoundFromMessageType(parsedMsg.Type())
|
||||
if currentRound > 0 {
|
||||
session.callback.OnProgress(currentRound, totalRounds)
|
||||
}
|
||||
|
||||
go func() {
|
||||
_, err := session.localParty.Update(parsedMsg)
|
||||
if err != nil {
|
||||
|
|
|
|||
|
|
@ -821,6 +821,21 @@ async function handleCoSignStart(event: {
|
|||
// 标记签名开始
|
||||
signInProgressSessionId = event.sessionId;
|
||||
|
||||
// CRITICAL: Get the original partyId from keygen (stored in share) for signing
|
||||
// This is essential for backup/restore - the partyId must match what was used during keygen
|
||||
const share = database?.getShare(activeCoSignSession.shareId, activeCoSignSession.sharePassword);
|
||||
if (!share) {
|
||||
debugLog.error('main', 'Failed to get share data');
|
||||
mainWindow?.webContents.send(`cosign:events:${event.sessionId}`, {
|
||||
type: 'failed',
|
||||
error: 'Failed to get share data',
|
||||
});
|
||||
signInProgressSessionId = null;
|
||||
return;
|
||||
}
|
||||
const signingPartyId = share.party_id || grpcClient?.getPartyId() || '';
|
||||
debugLog.info('main', `Using signingPartyId=${signingPartyId} (currentDevicePartyId=${grpcClient?.getPartyId()})`);
|
||||
|
||||
// 打印当前 activeCoSignSession.participants 状态
|
||||
console.log('[CO-SIGN] Current activeCoSignSession.participants before update:',
|
||||
activeCoSignSession.participants.map(p => ({
|
||||
|
|
@ -832,8 +847,9 @@ async function handleCoSignStart(event: {
|
|||
|
||||
// 从 event.selectedParties 更新参与者列表
|
||||
// 优先使用 activeCoSignSession.participants 中的 partyIndex(来自 signingParties 或 other_parties)
|
||||
// CRITICAL: Use signingPartyId (original from keygen) for identification
|
||||
if (event.selectedParties && event.selectedParties.length > 0) {
|
||||
const myPartyId = grpcClient?.getPartyId();
|
||||
const myPartyId = signingPartyId;
|
||||
const updatedParticipants: Array<{ partyId: string; partyIndex: number; name: string }> = [];
|
||||
|
||||
event.selectedParties.forEach((partyId) => {
|
||||
|
|
@ -869,21 +885,11 @@ async function handleCoSignStart(event: {
|
|||
})));
|
||||
}
|
||||
|
||||
// 获取 share 数据
|
||||
const share = database?.getShare(activeCoSignSession.shareId, activeCoSignSession.sharePassword);
|
||||
if (!share) {
|
||||
debugLog.error('main', 'Failed to get share data');
|
||||
mainWindow?.webContents.send(`cosign:events:${event.sessionId}`, {
|
||||
type: 'failed',
|
||||
error: 'Failed to get share data',
|
||||
});
|
||||
signInProgressSessionId = null;
|
||||
return;
|
||||
}
|
||||
// Note: share already fetched above for getting signingPartyId
|
||||
|
||||
console.log('[CO-SIGN] Calling tssHandler.participateSign with:', {
|
||||
sessionId: activeCoSignSession.sessionId,
|
||||
partyId: grpcClient?.getPartyId(),
|
||||
partyId: signingPartyId, // CRITICAL: Use signingPartyId (original from keygen)
|
||||
partyIndex: activeCoSignSession.partyIndex,
|
||||
participants: activeCoSignSession.participants.map(p => ({ partyId: p.partyId.substring(0, 8), partyIndex: p.partyIndex })),
|
||||
threshold: activeCoSignSession.threshold,
|
||||
|
|
@ -892,9 +898,10 @@ async function handleCoSignStart(event: {
|
|||
debugLog.info('tss', `Starting sign for session ${event.sessionId}...`);
|
||||
|
||||
try {
|
||||
// CRITICAL: Use signingPartyId (original partyId from keygen) for signing
|
||||
const result = await (tssHandler as TSSHandler).participateSign(
|
||||
activeCoSignSession.sessionId,
|
||||
grpcClient?.getPartyId() || '',
|
||||
signingPartyId, // CRITICAL: Use original partyId from keygen for backup/restore to work
|
||||
activeCoSignSession.partyIndex,
|
||||
activeCoSignSession.participants,
|
||||
activeCoSignSession.threshold,
|
||||
|
|
@ -1613,9 +1620,9 @@ function setupIpcHandlers() {
|
|||
initiatorName?: string;
|
||||
}) => {
|
||||
try {
|
||||
// 获取当前 party ID
|
||||
const partyId = grpcClient?.getPartyId();
|
||||
if (!partyId) {
|
||||
// 获取当前 party ID (用于检查连接状态)
|
||||
const currentDevicePartyId = grpcClient?.getPartyId();
|
||||
if (!currentDevicePartyId) {
|
||||
return { success: false, error: '请先连接到消息路由器' };
|
||||
}
|
||||
|
||||
|
|
@ -1625,6 +1632,11 @@ function setupIpcHandlers() {
|
|||
return { success: false, error: 'Share 不存在或密码错误' };
|
||||
}
|
||||
|
||||
// CRITICAL: Use the original partyId from keygen (stored in share) for signing
|
||||
// This is essential for backup/restore - the partyId must match what was used during keygen
|
||||
const partyId = share.party_id || currentDevicePartyId;
|
||||
debugLog.info('main', `Initiator using partyId=${partyId} (currentDevicePartyId=${currentDevicePartyId})`);
|
||||
|
||||
// 从后端获取 keygen 会话的参与者信息(包含正确的 party_index)
|
||||
const keygenStatus = await accountClient?.getSessionStatus(share.session_id);
|
||||
if (!keygenStatus?.participants || keygenStatus.participants.length === 0) {
|
||||
|
|
@ -1810,8 +1822,8 @@ function setupIpcHandlers() {
|
|||
parties?: Array<{ party_id: string; party_index: number }>;
|
||||
}) => {
|
||||
try {
|
||||
const partyId = grpcClient?.getPartyId();
|
||||
if (!partyId) {
|
||||
const currentDevicePartyId = grpcClient?.getPartyId();
|
||||
if (!currentDevicePartyId) {
|
||||
return { success: false, error: '请先连接到消息路由器' };
|
||||
}
|
||||
|
||||
|
|
@ -1821,9 +1833,12 @@ function setupIpcHandlers() {
|
|||
return { success: false, error: 'Share 不存在或密码错误' };
|
||||
}
|
||||
|
||||
debugLog.info('grpc', `Joining co-sign session: sessionId=${params.sessionId}, partyId=${partyId}`);
|
||||
// CRITICAL: Use the original partyId from keygen (stored in share) for signing
|
||||
// This is essential for backup/restore - the partyId must match what was used during keygen
|
||||
const signingPartyId = share.party_id || currentDevicePartyId;
|
||||
debugLog.info('grpc', `Joining co-sign session: sessionId=${params.sessionId}, signingPartyId=${signingPartyId} (currentDevicePartyId=${currentDevicePartyId})`);
|
||||
|
||||
const result = await grpcClient?.joinSession(params.sessionId, partyId, params.joinToken);
|
||||
const result = await grpcClient?.joinSession(params.sessionId, signingPartyId, params.joinToken);
|
||||
if (result?.success) {
|
||||
// 设置活跃的 Co-Sign 会话
|
||||
// 优先使用 params.parties(来自 validateInviteCode,包含所有预期参与者)
|
||||
|
|
@ -1832,10 +1847,11 @@ function setupIpcHandlers() {
|
|||
|
||||
if (params.parties && params.parties.length > 0) {
|
||||
// 使用完整的 parties 列表
|
||||
// CRITICAL: Use signingPartyId (original from keygen) for identification
|
||||
participants = params.parties.map(p => ({
|
||||
partyId: p.party_id,
|
||||
partyIndex: p.party_index,
|
||||
name: p.party_id === partyId ? '我' : `参与方 ${p.party_index + 1}`,
|
||||
name: p.party_id === signingPartyId ? '我' : `参与方 ${p.party_index + 1}`,
|
||||
}));
|
||||
console.log('[CO-SIGN] Participant using params.parties (complete list):', participants.map(p => ({
|
||||
partyId: p.partyId.substring(0, 8),
|
||||
|
|
@ -1850,9 +1866,9 @@ function setupIpcHandlers() {
|
|||
name: `参与方 ${idx + 1}`,
|
||||
})) || [];
|
||||
|
||||
// 添加自己
|
||||
// 添加自己 - CRITICAL: Use signingPartyId (original from keygen)
|
||||
participants.push({
|
||||
partyId: partyId,
|
||||
partyId: signingPartyId,
|
||||
partyIndex: result.party_index,
|
||||
name: '我',
|
||||
});
|
||||
|
|
@ -1886,11 +1902,11 @@ function setupIpcHandlers() {
|
|||
messageHash: params.messageHash,
|
||||
});
|
||||
|
||||
// 预订阅消息流
|
||||
// 预订阅消息流 - CRITICAL: Use signingPartyId (original from keygen)
|
||||
if (tssHandler && 'prepareForSign' in tssHandler) {
|
||||
try {
|
||||
debugLog.info('tss', `Preparing for sign: subscribing to messages for session ${params.sessionId}`);
|
||||
(tssHandler as TSSHandler).prepareForSign(params.sessionId, partyId);
|
||||
debugLog.info('tss', `Preparing for sign: subscribing to messages for session ${params.sessionId}, signingPartyId=${signingPartyId}`);
|
||||
(tssHandler as TSSHandler).prepareForSign(params.sessionId, signingPartyId);
|
||||
} catch (prepareErr) {
|
||||
debugLog.error('tss', `Failed to prepare for sign: ${(prepareErr as Error).message}`);
|
||||
return { success: false, error: `消息订阅失败: ${(prepareErr as Error).message}` };
|
||||
|
|
|
|||
|
|
@ -11,7 +11,12 @@ import {
|
|||
getCurrentRpcUrl,
|
||||
getGasPrice,
|
||||
fetchGreenPointsBalance,
|
||||
fetchEnergyPointsBalance,
|
||||
fetchFuturePointsBalance,
|
||||
GREEN_POINTS_TOKEN,
|
||||
ENERGY_POINTS_TOKEN,
|
||||
FUTURE_POINTS_TOKEN,
|
||||
TOKEN_CONFIG,
|
||||
type PreparedTransaction,
|
||||
type TokenType,
|
||||
} from '../utils/transaction';
|
||||
|
|
@ -32,6 +37,8 @@ interface ShareWithAddress extends ShareItem {
|
|||
evmAddress?: string;
|
||||
kavaBalance?: string;
|
||||
greenPointsBalance?: string;
|
||||
energyPointsBalance?: string;
|
||||
futurePointsBalance?: string;
|
||||
balanceLoading?: boolean;
|
||||
}
|
||||
|
||||
|
|
@ -89,15 +96,30 @@ export default function Home() {
|
|||
const [isCalculatingMax, setIsCalculatingMax] = useState(false);
|
||||
const [copySuccess, setCopySuccess] = useState(false);
|
||||
|
||||
// 获取当前选择代币的余额
|
||||
const getTokenBalance = (share: ShareWithAddress | null, tokenType: TokenType): string => {
|
||||
if (!share) return '0';
|
||||
switch (tokenType) {
|
||||
case 'KAVA':
|
||||
return share.kavaBalance || '0';
|
||||
case 'GREEN_POINTS':
|
||||
return share.greenPointsBalance || '0';
|
||||
case 'ENERGY_POINTS':
|
||||
return share.energyPointsBalance || '0';
|
||||
case 'FUTURE_POINTS':
|
||||
return share.futurePointsBalance || '0';
|
||||
}
|
||||
};
|
||||
|
||||
// 计算扣除 Gas 费后的最大可转账金额
|
||||
const calculateMaxAmount = async () => {
|
||||
if (!transferShare?.evmAddress) return;
|
||||
|
||||
setIsCalculatingMax(true);
|
||||
try {
|
||||
if (transferTokenType === 'GREEN_POINTS') {
|
||||
// For token transfers, use the full token balance (gas is paid in KAVA)
|
||||
const balance = transferShare.greenPointsBalance || '0';
|
||||
if (TOKEN_CONFIG.isERC20(transferTokenType)) {
|
||||
// For ERC-20 token transfers, use the full token balance (gas is paid in KAVA)
|
||||
const balance = getTokenBalance(transferShare, transferTokenType);
|
||||
setTransferAmount(balance);
|
||||
setTransferError(null);
|
||||
} else {
|
||||
|
|
@ -131,8 +153,8 @@ export default function Home() {
|
|||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to calculate max amount:', error);
|
||||
if (transferTokenType === 'GREEN_POINTS') {
|
||||
setTransferAmount(transferShare.greenPointsBalance || '0');
|
||||
if (TOKEN_CONFIG.isERC20(transferTokenType)) {
|
||||
setTransferAmount(getTokenBalance(transferShare, transferTokenType));
|
||||
} else {
|
||||
// 如果获取 Gas 失败,使用默认估算 (1 gwei * 21000)
|
||||
const defaultGasFee = 0.000021; // ~21000 * 1 gwei
|
||||
|
|
@ -165,12 +187,14 @@ export default function Home() {
|
|||
const updatedShares = await Promise.all(
|
||||
sharesWithAddrs.map(async (share) => {
|
||||
if (share.evmAddress) {
|
||||
// Fetch both balances in parallel
|
||||
const [kavaBalance, greenPointsBalance] = await Promise.all([
|
||||
// Fetch all balances in parallel
|
||||
const [kavaBalance, greenPointsBalance, energyPointsBalance, futurePointsBalance] = await Promise.all([
|
||||
fetchKavaBalance(share.evmAddress),
|
||||
fetchGreenPointsBalance(share.evmAddress),
|
||||
fetchEnergyPointsBalance(share.evmAddress),
|
||||
fetchFuturePointsBalance(share.evmAddress),
|
||||
]);
|
||||
return { ...share, kavaBalance, greenPointsBalance, balanceLoading: false };
|
||||
return { ...share, kavaBalance, greenPointsBalance, energyPointsBalance, futurePointsBalance, balanceLoading: false };
|
||||
}
|
||||
return { ...share, balanceLoading: false };
|
||||
})
|
||||
|
|
@ -315,11 +339,7 @@ export default function Home() {
|
|||
return '转账金额无效';
|
||||
}
|
||||
const amount = parseFloat(transferAmount);
|
||||
const balance = parseFloat(
|
||||
transferTokenType === 'GREEN_POINTS'
|
||||
? (transferShare?.greenPointsBalance || '0')
|
||||
: (transferShare?.kavaBalance || '0')
|
||||
);
|
||||
const balance = parseFloat(getTokenBalance(transferShare, transferTokenType));
|
||||
if (amount > balance) {
|
||||
return '余额不足';
|
||||
}
|
||||
|
|
@ -486,7 +506,7 @@ export default function Home() {
|
|||
</div>
|
||||
)}
|
||||
|
||||
{/* 余额显示 - KAVA 和 绿积分 */}
|
||||
{/* 余额显示 - 所有代币 */}
|
||||
{share.evmAddress && (
|
||||
<div className={styles.balanceSection}>
|
||||
<div className={styles.balanceRow}>
|
||||
|
|
@ -509,6 +529,26 @@ export default function Home() {
|
|||
)}
|
||||
</span>
|
||||
</div>
|
||||
<div className={styles.balanceRow}>
|
||||
<span className={styles.balanceLabel} style={{ color: '#2196F3' }}>{ENERGY_POINTS_TOKEN.name}</span>
|
||||
<span className={styles.balanceValue} style={{ color: '#2196F3' }}>
|
||||
{share.balanceLoading ? (
|
||||
<span className={styles.balanceLoading}>加载中...</span>
|
||||
) : (
|
||||
<>{share.energyPointsBalance || '0'}</>
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
<div className={styles.balanceRow}>
|
||||
<span className={styles.balanceLabel} style={{ color: '#9C27B0' }}>{FUTURE_POINTS_TOKEN.name}</span>
|
||||
<span className={styles.balanceValue} style={{ color: '#9C27B0' }}>
|
||||
{share.balanceLoading ? (
|
||||
<span className={styles.balanceLoading}>加载中...</span>
|
||||
) : (
|
||||
<>{share.futurePointsBalance || '0'}</>
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
|
@ -578,7 +618,10 @@ export default function Home() {
|
|||
<div className={styles.transferWalletInfo}>
|
||||
<div className={styles.transferWalletName}>{transferShare.walletName}</div>
|
||||
<div className={styles.transferWalletBalance}>
|
||||
KAVA: {transferShare.kavaBalance || '0'} | {GREEN_POINTS_TOKEN.name}: {transferShare.greenPointsBalance || '0'}
|
||||
KAVA: {transferShare.kavaBalance || '0'} | <span style={{color: '#4CAF50'}}>{GREEN_POINTS_TOKEN.name}: {transferShare.greenPointsBalance || '0'}</span>
|
||||
</div>
|
||||
<div className={styles.transferWalletBalance}>
|
||||
<span style={{color: '#2196F3'}}>{ENERGY_POINTS_TOKEN.name}: {transferShare.energyPointsBalance || '0'}</span> | <span style={{color: '#9C27B0'}}>{FUTURE_POINTS_TOKEN.name}: {transferShare.futurePointsBalance || '0'}</span>
|
||||
</div>
|
||||
<div className={styles.transferNetwork}>
|
||||
网络: Kava {getCurrentNetwork() === 'mainnet' ? '主网' : '测试网'}
|
||||
|
|
@ -605,6 +648,22 @@ export default function Home() {
|
|||
{GREEN_POINTS_TOKEN.name}
|
||||
</button>
|
||||
</div>
|
||||
<div className={styles.tokenTypeSelector} style={{ marginTop: '8px' }}>
|
||||
<button
|
||||
className={`${styles.tokenTypeButton} ${transferTokenType === 'ENERGY_POINTS' ? styles.tokenTypeActive : ''}`}
|
||||
onClick={() => { setTransferTokenType('ENERGY_POINTS'); setTransferAmount(''); }}
|
||||
style={transferTokenType === 'ENERGY_POINTS' ? { backgroundColor: '#2196F3', borderColor: '#2196F3' } : {}}
|
||||
>
|
||||
{ENERGY_POINTS_TOKEN.name}
|
||||
</button>
|
||||
<button
|
||||
className={`${styles.tokenTypeButton} ${transferTokenType === 'FUTURE_POINTS' ? styles.tokenTypeActive : ''}`}
|
||||
onClick={() => { setTransferTokenType('FUTURE_POINTS'); setTransferAmount(''); }}
|
||||
style={transferTokenType === 'FUTURE_POINTS' ? { backgroundColor: '#9C27B0', borderColor: '#9C27B0' } : {}}
|
||||
>
|
||||
{FUTURE_POINTS_TOKEN.name}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 收款地址 */}
|
||||
|
|
@ -622,7 +681,7 @@ export default function Home() {
|
|||
{/* 转账金额 */}
|
||||
<div className={styles.transferInputGroup}>
|
||||
<label className={styles.transferLabel}>
|
||||
转账金额 ({transferTokenType === 'GREEN_POINTS' ? GREEN_POINTS_TOKEN.name : 'KAVA'})
|
||||
转账金额 ({TOKEN_CONFIG.getName(transferTokenType)})
|
||||
</label>
|
||||
<div className={styles.transferAmountWrapper}>
|
||||
<input
|
||||
|
|
@ -689,8 +748,8 @@ export default function Home() {
|
|||
<div className={styles.confirmDetails}>
|
||||
<div className={styles.confirmRow}>
|
||||
<span className={styles.confirmLabel}>转账类型</span>
|
||||
<span className={styles.confirmValue} style={transferTokenType === 'GREEN_POINTS' ? { color: '#4CAF50' } : {}}>
|
||||
{transferTokenType === 'GREEN_POINTS' ? GREEN_POINTS_TOKEN.name : 'KAVA'}
|
||||
<span className={styles.confirmValue} style={TOKEN_CONFIG.isERC20(transferTokenType) ? { color: transferTokenType === 'GREEN_POINTS' ? '#4CAF50' : transferTokenType === 'ENERGY_POINTS' ? '#2196F3' : '#9C27B0' } : {}}>
|
||||
{TOKEN_CONFIG.getName(transferTokenType)}
|
||||
</span>
|
||||
</div>
|
||||
<div className={styles.confirmRow}>
|
||||
|
|
@ -699,8 +758,8 @@ export default function Home() {
|
|||
</div>
|
||||
<div className={styles.confirmRow}>
|
||||
<span className={styles.confirmLabel}>转账金额</span>
|
||||
<span className={styles.confirmValue} style={transferTokenType === 'GREEN_POINTS' ? { color: '#4CAF50' } : {}}>
|
||||
{transferAmount} {transferTokenType === 'GREEN_POINTS' ? GREEN_POINTS_TOKEN.name : 'KAVA'}
|
||||
<span className={styles.confirmValue} style={TOKEN_CONFIG.isERC20(transferTokenType) ? { color: transferTokenType === 'GREEN_POINTS' ? '#4CAF50' : transferTokenType === 'ENERGY_POINTS' ? '#2196F3' : '#9C27B0' } : {}}>
|
||||
{transferAmount} {TOKEN_CONFIG.getName(transferTokenType)}
|
||||
</span>
|
||||
</div>
|
||||
<div className={styles.confirmRow}>
|
||||
|
|
|
|||
|
|
@ -17,17 +17,97 @@ export const KAVA_RPC_URL = {
|
|||
};
|
||||
|
||||
// Token types
|
||||
export type TokenType = 'KAVA' | 'GREEN_POINTS';
|
||||
export type TokenType = 'KAVA' | 'GREEN_POINTS' | 'ENERGY_POINTS' | 'FUTURE_POINTS';
|
||||
|
||||
// Green Points (绿积分) Token Configuration
|
||||
// ERC-20 通用函数选择器
|
||||
export const ERC20_SELECTORS = {
|
||||
balanceOf: '0x70a08231', // balanceOf(address)
|
||||
transfer: '0xa9059cbb', // transfer(address,uint256)
|
||||
approve: '0x095ea7b3', // approve(address,uint256)
|
||||
allowance: '0xdd62ed3e', // allowance(address,address)
|
||||
totalSupply: '0x18160ddd', // totalSupply()
|
||||
};
|
||||
|
||||
// Green Points (绿积分) Token Configuration - dUSDT
|
||||
export const GREEN_POINTS_TOKEN = {
|
||||
contractAddress: '0xA9F3A35dBa8699c8C681D8db03F0c1A8CEB9D7c3',
|
||||
name: '绿积分',
|
||||
symbol: 'dUSDT',
|
||||
decimals: 6,
|
||||
// ERC-20 function selectors
|
||||
balanceOfSelector: '0x70a08231',
|
||||
transferSelector: '0xa9059cbb',
|
||||
// ERC-20 function selectors (kept for backward compatibility)
|
||||
balanceOfSelector: ERC20_SELECTORS.balanceOf,
|
||||
transferSelector: ERC20_SELECTORS.transfer,
|
||||
};
|
||||
|
||||
// Energy Points (积分股) Token Configuration - eUSDT
|
||||
export const ENERGY_POINTS_TOKEN = {
|
||||
contractAddress: '0x7C3275D808eFbAE90C06C7E3A9AfDdcAa8563931',
|
||||
name: '积分股',
|
||||
symbol: 'eUSDT',
|
||||
decimals: 6,
|
||||
};
|
||||
|
||||
// Future Points (积分值) Token Configuration - fUSDT
|
||||
export const FUTURE_POINTS_TOKEN = {
|
||||
contractAddress: '0x14dc4f7d3E4197438d058C3D156dd9826A161134',
|
||||
name: '积分值',
|
||||
symbol: 'fUSDT',
|
||||
decimals: 6,
|
||||
};
|
||||
|
||||
// Token configuration utility
|
||||
export const TOKEN_CONFIG = {
|
||||
getContractAddress: (tokenType: TokenType): string | null => {
|
||||
switch (tokenType) {
|
||||
case 'KAVA':
|
||||
return null; // Native token has no contract
|
||||
case 'GREEN_POINTS':
|
||||
return GREEN_POINTS_TOKEN.contractAddress;
|
||||
case 'ENERGY_POINTS':
|
||||
return ENERGY_POINTS_TOKEN.contractAddress;
|
||||
case 'FUTURE_POINTS':
|
||||
return FUTURE_POINTS_TOKEN.contractAddress;
|
||||
}
|
||||
},
|
||||
getDecimals: (tokenType: TokenType): number => {
|
||||
switch (tokenType) {
|
||||
case 'KAVA':
|
||||
return 18;
|
||||
case 'GREEN_POINTS':
|
||||
return GREEN_POINTS_TOKEN.decimals;
|
||||
case 'ENERGY_POINTS':
|
||||
return ENERGY_POINTS_TOKEN.decimals;
|
||||
case 'FUTURE_POINTS':
|
||||
return FUTURE_POINTS_TOKEN.decimals;
|
||||
}
|
||||
},
|
||||
getName: (tokenType: TokenType): string => {
|
||||
switch (tokenType) {
|
||||
case 'KAVA':
|
||||
return 'KAVA';
|
||||
case 'GREEN_POINTS':
|
||||
return GREEN_POINTS_TOKEN.name;
|
||||
case 'ENERGY_POINTS':
|
||||
return ENERGY_POINTS_TOKEN.name;
|
||||
case 'FUTURE_POINTS':
|
||||
return FUTURE_POINTS_TOKEN.name;
|
||||
}
|
||||
},
|
||||
getSymbol: (tokenType: TokenType): string => {
|
||||
switch (tokenType) {
|
||||
case 'KAVA':
|
||||
return 'KAVA';
|
||||
case 'GREEN_POINTS':
|
||||
return GREEN_POINTS_TOKEN.symbol;
|
||||
case 'ENERGY_POINTS':
|
||||
return ENERGY_POINTS_TOKEN.symbol;
|
||||
case 'FUTURE_POINTS':
|
||||
return FUTURE_POINTS_TOKEN.symbol;
|
||||
}
|
||||
},
|
||||
isERC20: (tokenType: TokenType): boolean => {
|
||||
return tokenType !== 'KAVA';
|
||||
},
|
||||
};
|
||||
|
||||
// 当前网络配置 (从 localStorage 读取或使用默认值)
|
||||
|
|
@ -327,44 +407,69 @@ export function weiToKava(wei: bigint): string {
|
|||
}
|
||||
|
||||
/**
|
||||
* 将绿积分金额转换为最小单位 (6 decimals)
|
||||
* 将代币金额转换为最小单位
|
||||
* @param amount Human-readable amount
|
||||
* @param decimals Token decimals (default 6 for USDT-like tokens)
|
||||
*/
|
||||
export function greenPointsToRaw(amount: string): bigint {
|
||||
export function tokenToRaw(amount: string, decimals: number = 6): bigint {
|
||||
const parts = amount.split('.');
|
||||
const whole = BigInt(parts[0] || '0');
|
||||
let fraction = parts[1] || '';
|
||||
|
||||
// 补齐或截断到 6 位
|
||||
if (fraction.length > 6) {
|
||||
fraction = fraction.substring(0, 6);
|
||||
// 补齐或截断到指定位数
|
||||
if (fraction.length > decimals) {
|
||||
fraction = fraction.substring(0, decimals);
|
||||
} else {
|
||||
fraction = fraction.padEnd(6, '0');
|
||||
fraction = fraction.padEnd(decimals, '0');
|
||||
}
|
||||
|
||||
return whole * BigInt(10 ** 6) + BigInt(fraction);
|
||||
return whole * BigInt(10 ** decimals) + BigInt(fraction);
|
||||
}
|
||||
|
||||
/**
|
||||
* 将最小单位转换为绿积分金额
|
||||
* 将最小单位转换为代币金额
|
||||
* @param raw Raw amount in smallest units
|
||||
* @param decimals Token decimals (default 6 for USDT-like tokens)
|
||||
*/
|
||||
export function rawToGreenPoints(raw: bigint): string {
|
||||
const rawStr = raw.toString().padStart(7, '0');
|
||||
const whole = rawStr.slice(0, -6) || '0';
|
||||
const fraction = rawStr.slice(-6).replace(/0+$/, '');
|
||||
export function rawToToken(raw: bigint, decimals: number = 6): string {
|
||||
const rawStr = raw.toString().padStart(decimals + 1, '0');
|
||||
const whole = rawStr.slice(0, -decimals) || '0';
|
||||
const fraction = rawStr.slice(-decimals).replace(/0+$/, '');
|
||||
return fraction ? `${whole}.${fraction}` : whole;
|
||||
}
|
||||
|
||||
/**
|
||||
* 查询绿积分 (ERC-20) 余额
|
||||
* 将绿积分金额转换为最小单位 (6 decimals)
|
||||
* @deprecated Use tokenToRaw(amount, 6) instead
|
||||
*/
|
||||
export async function fetchGreenPointsBalance(address: string): Promise<string> {
|
||||
export function greenPointsToRaw(amount: string): bigint {
|
||||
return tokenToRaw(amount, GREEN_POINTS_TOKEN.decimals);
|
||||
}
|
||||
|
||||
/**
|
||||
* 将最小单位转换为绿积分金额
|
||||
* @deprecated Use rawToToken(raw, 6) instead
|
||||
*/
|
||||
export function rawToGreenPoints(raw: bigint): string {
|
||||
return rawToToken(raw, GREEN_POINTS_TOKEN.decimals);
|
||||
}
|
||||
|
||||
/**
|
||||
* 查询 ERC-20 代币余额
|
||||
* @param address Wallet address
|
||||
* @param contractAddress Token contract address
|
||||
* @param decimals Token decimals
|
||||
*/
|
||||
export async function fetchERC20Balance(
|
||||
address: string,
|
||||
contractAddress: string,
|
||||
decimals: number = 6
|
||||
): Promise<string> {
|
||||
try {
|
||||
const rpcUrl = getCurrentRpcUrl();
|
||||
// Encode balanceOf(address) call data
|
||||
// Function selector: 0x70a08231
|
||||
// Address parameter: padded to 32 bytes
|
||||
const paddedAddress = address.toLowerCase().replace('0x', '').padStart(64, '0');
|
||||
const callData = GREEN_POINTS_TOKEN.balanceOfSelector + paddedAddress;
|
||||
const callData = ERC20_SELECTORS.balanceOf + paddedAddress;
|
||||
|
||||
const response = await fetch(rpcUrl, {
|
||||
method: 'POST',
|
||||
|
|
@ -374,7 +479,7 @@ export async function fetchGreenPointsBalance(address: string): Promise<string>
|
|||
method: 'eth_call',
|
||||
params: [
|
||||
{
|
||||
to: GREEN_POINTS_TOKEN.contractAddress,
|
||||
to: contractAddress,
|
||||
data: callData,
|
||||
},
|
||||
'latest',
|
||||
|
|
@ -386,21 +491,65 @@ export async function fetchGreenPointsBalance(address: string): Promise<string>
|
|||
const data = await response.json();
|
||||
if (data.result && data.result !== '0x') {
|
||||
const balanceRaw = BigInt(data.result);
|
||||
return rawToGreenPoints(balanceRaw);
|
||||
return rawToToken(balanceRaw, decimals);
|
||||
}
|
||||
return '0';
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch Green Points balance:', error);
|
||||
console.error('Failed to fetch ERC20 balance:', error);
|
||||
return '0';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 查询绿积分 (ERC-20) 余额
|
||||
*/
|
||||
export async function fetchGreenPointsBalance(address: string): Promise<string> {
|
||||
return fetchERC20Balance(address, GREEN_POINTS_TOKEN.contractAddress, GREEN_POINTS_TOKEN.decimals);
|
||||
}
|
||||
|
||||
/**
|
||||
* 查询积分股 (eUSDT) 余额
|
||||
*/
|
||||
export async function fetchEnergyPointsBalance(address: string): Promise<string> {
|
||||
return fetchERC20Balance(address, ENERGY_POINTS_TOKEN.contractAddress, ENERGY_POINTS_TOKEN.decimals);
|
||||
}
|
||||
|
||||
/**
|
||||
* 查询积分值 (fUSDT) 余额
|
||||
*/
|
||||
export async function fetchFuturePointsBalance(address: string): Promise<string> {
|
||||
return fetchERC20Balance(address, FUTURE_POINTS_TOKEN.contractAddress, FUTURE_POINTS_TOKEN.decimals);
|
||||
}
|
||||
|
||||
/**
|
||||
* 查询所有代币余额
|
||||
*/
|
||||
export async function fetchAllTokenBalances(address: string): Promise<{
|
||||
kava: string;
|
||||
greenPoints: string;
|
||||
energyPoints: string;
|
||||
futurePoints: string;
|
||||
}> {
|
||||
const [greenPoints, energyPoints, futurePoints] = await Promise.all([
|
||||
fetchGreenPointsBalance(address),
|
||||
fetchEnergyPointsBalance(address),
|
||||
fetchFuturePointsBalance(address),
|
||||
]);
|
||||
// Note: KAVA balance is fetched separately via eth_getBalance
|
||||
return {
|
||||
kava: '0', // Caller should fetch KAVA balance separately
|
||||
greenPoints,
|
||||
energyPoints,
|
||||
futurePoints,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Encode ERC-20 transfer function call
|
||||
*/
|
||||
function encodeErc20Transfer(to: string, amount: bigint): string {
|
||||
// Function selector: transfer(address,uint256) = 0xa9059cbb
|
||||
const selector = GREEN_POINTS_TOKEN.transferSelector;
|
||||
const selector = ERC20_SELECTORS.transfer;
|
||||
// Encode recipient address (padded to 32 bytes)
|
||||
const paddedAddress = to.toLowerCase().replace('0x', '').padStart(64, '0');
|
||||
// Encode amount (padded to 32 bytes)
|
||||
|
|
@ -476,13 +625,15 @@ export async function estimateGas(params: { from: string; to: string; value: str
|
|||
// For token transfers, we need different params
|
||||
let txParams: { from: string; to: string; value: string; data?: string };
|
||||
|
||||
if (tokenType === 'GREEN_POINTS') {
|
||||
if (TOKEN_CONFIG.isERC20(tokenType)) {
|
||||
// ERC-20 transfer: to is contract, value is 0, data is transfer call
|
||||
const tokenAmount = greenPointsToRaw(params.value);
|
||||
const contractAddress = TOKEN_CONFIG.getContractAddress(tokenType);
|
||||
const decimals = TOKEN_CONFIG.getDecimals(tokenType);
|
||||
const tokenAmount = tokenToRaw(params.value, decimals);
|
||||
const transferData = encodeErc20Transfer(params.to, tokenAmount);
|
||||
txParams = {
|
||||
from: params.from,
|
||||
to: GREEN_POINTS_TOKEN.contractAddress,
|
||||
to: contractAddress!,
|
||||
value: '0x0',
|
||||
data: transferData,
|
||||
};
|
||||
|
|
@ -511,7 +662,7 @@ export async function estimateGas(params: { from: string; to: string; value: str
|
|||
if (data.error) {
|
||||
// 如果估算失败,使用默认值
|
||||
console.warn('Gas 估算失败,使用默认值:', data.error);
|
||||
return tokenType === 'GREEN_POINTS' ? BigInt(65000) : BigInt(21000);
|
||||
return TOKEN_CONFIG.isERC20(tokenType) ? BigInt(65000) : BigInt(21000);
|
||||
}
|
||||
return BigInt(data.result);
|
||||
}
|
||||
|
|
@ -543,12 +694,14 @@ export async function prepareTransaction(params: TransactionParams): Promise<Pre
|
|||
let value: bigint;
|
||||
let data: string;
|
||||
|
||||
if (tokenType === 'GREEN_POINTS') {
|
||||
if (TOKEN_CONFIG.isERC20(tokenType)) {
|
||||
// ERC-20 token transfer
|
||||
// To address is the contract, value is 0
|
||||
// Data is transfer(recipient, amount) encoded
|
||||
const tokenAmount = greenPointsToRaw(params.value);
|
||||
toAddress = GREEN_POINTS_TOKEN.contractAddress.toLowerCase();
|
||||
const contractAddress = TOKEN_CONFIG.getContractAddress(tokenType);
|
||||
const decimals = TOKEN_CONFIG.getDecimals(tokenType);
|
||||
const tokenAmount = tokenToRaw(params.value, decimals);
|
||||
toAddress = contractAddress!.toLowerCase();
|
||||
value = BigInt(0);
|
||||
data = encodeErc20Transfer(params.to, tokenAmount);
|
||||
} else {
|
||||
|
|
|
|||
|
|
@ -149,6 +149,8 @@ func (c *MessageRouterClient) PublishSessionCreated(
|
|||
}
|
||||
|
||||
// PublishSessionStarted publishes a session_started event when all parties have joined
|
||||
// CRITICAL: participants contains the complete list of all parties with their indices
|
||||
// Receivers should use this list for TSS protocol instead of JoinSession response
|
||||
func (c *MessageRouterClient) PublishSessionStarted(
|
||||
ctx context.Context,
|
||||
sessionID string,
|
||||
|
|
@ -157,7 +159,17 @@ func (c *MessageRouterClient) PublishSessionStarted(
|
|||
selectedParties []string,
|
||||
joinTokens map[string]string,
|
||||
startedAt int64,
|
||||
participants []use_cases.SessionParticipantInfo,
|
||||
) error {
|
||||
// Convert participants to proto format
|
||||
protoParticipants := make([]*router.PartyInfo, len(participants))
|
||||
for i, p := range participants {
|
||||
protoParticipants[i] = &router.PartyInfo{
|
||||
PartyId: p.PartyID,
|
||||
PartyIndex: p.PartyIndex,
|
||||
}
|
||||
}
|
||||
|
||||
event := &router.SessionEvent{
|
||||
EventId: uuid.New().String(),
|
||||
EventType: "session_started",
|
||||
|
|
@ -167,8 +179,13 @@ func (c *MessageRouterClient) PublishSessionStarted(
|
|||
SelectedParties: selectedParties,
|
||||
JoinTokens: joinTokens,
|
||||
CreatedAt: startedAt,
|
||||
Participants: protoParticipants,
|
||||
}
|
||||
|
||||
logger.Info("Publishing session_started event with participants",
|
||||
zap.String("session_id", sessionID),
|
||||
zap.Int("participant_count", len(participants)))
|
||||
|
||||
return c.PublishSessionEvent(ctx, event)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -21,8 +21,16 @@ import (
|
|||
// Maximum retries for optimistic lock conflicts during join session
|
||||
const joinSessionMaxRetries = 3
|
||||
|
||||
// SessionParticipantInfo contains party ID and index for session_started event
|
||||
type SessionParticipantInfo struct {
|
||||
PartyID string
|
||||
PartyIndex int32
|
||||
}
|
||||
|
||||
// JoinSessionMessageRouterClient defines the interface for publishing session events via gRPC
|
||||
type JoinSessionMessageRouterClient interface {
|
||||
// PublishSessionStarted publishes session_started event with complete participants list
|
||||
// CRITICAL: participants contains all parties with their indices for TSS protocol
|
||||
PublishSessionStarted(
|
||||
ctx context.Context,
|
||||
sessionID string,
|
||||
|
|
@ -31,6 +39,7 @@ type JoinSessionMessageRouterClient interface {
|
|||
selectedParties []string,
|
||||
joinTokens map[string]string,
|
||||
startedAt int64,
|
||||
participants []SessionParticipantInfo,
|
||||
) error
|
||||
|
||||
// PublishParticipantJoined broadcasts a participant_joined event to all parties in the session
|
||||
|
|
@ -248,6 +257,16 @@ func (uc *JoinSessionUseCase) executeWithRetry(
|
|||
// Build join tokens map (empty for session_started, parties already have tokens)
|
||||
joinTokens := make(map[string]string)
|
||||
|
||||
// CRITICAL: Build complete participants list with party indices
|
||||
// This ensures all parties have the same participant list for TSS protocol
|
||||
participants := make([]SessionParticipantInfo, len(session.Participants))
|
||||
for i, p := range session.Participants {
|
||||
participants[i] = SessionParticipantInfo{
|
||||
PartyID: p.PartyID.String(),
|
||||
PartyIndex: int32(p.PartyIndex),
|
||||
}
|
||||
}
|
||||
|
||||
if err := uc.messageRouterClient.PublishSessionStarted(
|
||||
ctx,
|
||||
session.ID.String(),
|
||||
|
|
@ -256,6 +275,7 @@ func (uc *JoinSessionUseCase) executeWithRetry(
|
|||
selectedParties,
|
||||
joinTokens,
|
||||
startedAt,
|
||||
participants,
|
||||
); err != nil {
|
||||
logger.Error("failed to publish session started event to message router",
|
||||
zap.String("session_id", session.ID.String()),
|
||||
|
|
@ -263,7 +283,8 @@ func (uc *JoinSessionUseCase) executeWithRetry(
|
|||
} else {
|
||||
logger.Info("published session started event to message router",
|
||||
zap.String("session_id", session.ID.String()),
|
||||
zap.Int("party_count", len(selectedParties)))
|
||||
zap.Int("party_count", len(selectedParties)),
|
||||
zap.Int("participant_count", len(participants)))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -21,6 +21,8 @@
|
|||
"@prisma/client": "^5.7.0",
|
||||
"@types/multer": "^2.0.0",
|
||||
"adbkit-apkreader": "^3.2.0",
|
||||
"archiver": "^6.0.1",
|
||||
"axios": "^1.6.2",
|
||||
"bplist-parser": "^0.3.2",
|
||||
"class-transformer": "^0.5.1",
|
||||
"class-validator": "^0.14.0",
|
||||
|
|
@ -36,6 +38,7 @@
|
|||
"@nestjs/cli": "^10.0.0",
|
||||
"@nestjs/schematics": "^10.0.0",
|
||||
"@nestjs/testing": "^10.0.0",
|
||||
"@types/archiver": "^6.0.2",
|
||||
"@types/express": "^4.17.17",
|
||||
"@types/jest": "^29.5.2",
|
||||
"@types/node": "^20.3.1",
|
||||
|
|
@ -2284,6 +2287,16 @@
|
|||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/archiver": {
|
||||
"version": "6.0.4",
|
||||
"resolved": "https://registry.npmjs.org/@types/archiver/-/archiver-6.0.4.tgz",
|
||||
"integrity": "sha512-ULdQpARQ3sz9WH4nb98mJDYA0ft2A8C4f4fovvUcFwINa1cgGjY36JCAYuP5YypRq4mco1lJp1/7jEMS2oR0Hg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/readdir-glob": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/babel__core": {
|
||||
"version": "7.20.5",
|
||||
"resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz",
|
||||
|
|
@ -2562,6 +2575,16 @@
|
|||
"integrity": "sha512-hKormJbkJqzQGhziax5PItDUTMAM9uE2XXQmM37dyd4hVM+5aVl7oVxMVUiVQn2oCQFN/LKCZdvSM0pFRqbSmQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/readdir-glob": {
|
||||
"version": "1.1.5",
|
||||
"resolved": "https://registry.npmjs.org/@types/readdir-glob/-/readdir-glob-1.1.5.tgz",
|
||||
"integrity": "sha512-raiuEPUYqXu+nvtY2Pe8s8FEmZ3x5yAH4VkLdihcPdalvsHltomrRC9BzuStrJ9yk06470hS0Crw0f1pXqD+Hg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/node": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/semver": {
|
||||
"version": "7.7.1",
|
||||
"resolved": "https://registry.npmjs.org/@types/semver/-/semver-7.7.1.tgz",
|
||||
|
|
@ -3284,6 +3307,73 @@
|
|||
"integrity": "sha512-klpgFSWLW1ZEs8svjfb7g4qWY0YS5imI82dTg+QahUvJ8YqAY0P10Uk8tTyh9ZGuYEZEMaeJYCF5BFuX552hsw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/archiver": {
|
||||
"version": "6.0.2",
|
||||
"resolved": "https://registry.npmjs.org/archiver/-/archiver-6.0.2.tgz",
|
||||
"integrity": "sha512-UQ/2nW7NMl1G+1UnrLypQw1VdT9XZg/ECcKPq7l+STzStrSivFIXIp34D8M5zeNGW5NoOupdYCHv6VySCPNNlw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"archiver-utils": "^4.0.1",
|
||||
"async": "^3.2.4",
|
||||
"buffer-crc32": "^0.2.1",
|
||||
"readable-stream": "^3.6.0",
|
||||
"readdir-glob": "^1.1.2",
|
||||
"tar-stream": "^3.0.0",
|
||||
"zip-stream": "^5.0.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 12.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/archiver-utils": {
|
||||
"version": "4.0.1",
|
||||
"resolved": "https://registry.npmjs.org/archiver-utils/-/archiver-utils-4.0.1.tgz",
|
||||
"integrity": "sha512-Q4Q99idbvzmgCTEAAhi32BkOyq8iVI5EwdO0PmBDSGIzzjYNdcFn7Q7k3OzbLy4kLUPXfJtG6fO2RjftXbobBg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"glob": "^8.0.0",
|
||||
"graceful-fs": "^4.2.0",
|
||||
"lazystream": "^1.0.0",
|
||||
"lodash": "^4.17.15",
|
||||
"normalize-path": "^3.0.0",
|
||||
"readable-stream": "^3.6.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 12.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/archiver-utils/node_modules/glob": {
|
||||
"version": "8.1.0",
|
||||
"resolved": "https://registry.npmjs.org/glob/-/glob-8.1.0.tgz",
|
||||
"integrity": "sha512-r8hpEjiQEYlF2QU0df3dS+nxxSIreXQS1qRhMJM0Q5NDdR386C7jb7Hwwod8Fgiuex+k0GFjgft18yvxm5XoCQ==",
|
||||
"deprecated": "Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"fs.realpath": "^1.0.0",
|
||||
"inflight": "^1.0.4",
|
||||
"inherits": "2",
|
||||
"minimatch": "^5.0.1",
|
||||
"once": "^1.3.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/isaacs"
|
||||
}
|
||||
},
|
||||
"node_modules/archiver-utils/node_modules/minimatch": {
|
||||
"version": "5.1.6",
|
||||
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-5.1.6.tgz",
|
||||
"integrity": "sha512-lKwV/1brpG6mBUFHtb7NUmtABCb2WZZmm2wNiOA5hAb8VdCS4B3dtMWyvcoViccwAW/COERjXLt0zP1zXUN26g==",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"brace-expansion": "^2.0.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
}
|
||||
},
|
||||
"node_modules/arg": {
|
||||
"version": "4.1.3",
|
||||
"resolved": "https://registry.npmjs.org/arg/-/arg-4.1.3.tgz",
|
||||
|
|
@ -3327,13 +3417,43 @@
|
|||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/async": {
|
||||
"version": "3.2.6",
|
||||
"resolved": "https://registry.npmjs.org/async/-/async-3.2.6.tgz",
|
||||
"integrity": "sha512-htCUDlxyyCLMgaM3xXg0C0LW2xqfuQ6p05pCEIsXuyQ+a1koYKTuBMzRNwmybfLgvJDMd0r1LTn4+E0Ti6C2AA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/asynckit": {
|
||||
"version": "0.4.0",
|
||||
"resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz",
|
||||
"integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/axios": {
|
||||
"version": "1.13.4",
|
||||
"resolved": "https://registry.npmjs.org/axios/-/axios-1.13.4.tgz",
|
||||
"integrity": "sha512-1wVkUaAO6WyaYtCkcYCOx12ZgpGf9Zif+qXa4n+oYzK558YryKqiL6UWwd5DqiH3VRW0GYhTZQ/vlgJrCoNQlg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"follow-redirects": "^1.15.6",
|
||||
"form-data": "^4.0.4",
|
||||
"proxy-from-env": "^1.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/b4a": {
|
||||
"version": "1.7.3",
|
||||
"resolved": "https://registry.npmjs.org/b4a/-/b4a-1.7.3.tgz",
|
||||
"integrity": "sha512-5Q2mfq2WfGuFp3uS//0s6baOJLMoVduPYVeNmDYxu5OUA1/cBfvr2RIS7vi62LdNj/urk1hfmj867I3qt6uZ7Q==",
|
||||
"license": "Apache-2.0",
|
||||
"peerDependencies": {
|
||||
"react-native-b4a": "*"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"react-native-b4a": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/babel-jest": {
|
||||
"version": "29.7.0",
|
||||
"resolved": "https://registry.npmjs.org/babel-jest/-/babel-jest-29.7.0.tgz",
|
||||
|
|
@ -3464,9 +3584,22 @@
|
|||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz",
|
||||
"integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/bare-events": {
|
||||
"version": "2.8.2",
|
||||
"resolved": "https://registry.npmjs.org/bare-events/-/bare-events-2.8.2.tgz",
|
||||
"integrity": "sha512-riJjyv1/mHLIPX4RwiK+oW9/4c3TEUeORHKefKAKnZ5kyslbN+HXowtbaVEqt4IMUB7OXlfixcs6gsFeo/jhiQ==",
|
||||
"license": "Apache-2.0",
|
||||
"peerDependencies": {
|
||||
"bare-abort-controller": "*"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"bare-abort-controller": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/base64-js": {
|
||||
"version": "1.5.1",
|
||||
"resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz",
|
||||
|
|
@ -3593,7 +3726,6 @@
|
|||
"version": "2.0.2",
|
||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz",
|
||||
"integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"balanced-match": "^1.0.0"
|
||||
|
|
@ -4079,7 +4211,6 @@
|
|||
"version": "1.0.8",
|
||||
"resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz",
|
||||
"integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"delayed-stream": "~1.0.0"
|
||||
|
|
@ -4125,6 +4256,21 @@
|
|||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/compress-commons": {
|
||||
"version": "5.0.3",
|
||||
"resolved": "https://registry.npmjs.org/compress-commons/-/compress-commons-5.0.3.tgz",
|
||||
"integrity": "sha512-/UIcLWvwAQyVibgpQDPtfNM3SvqN7G9elAPAV7GM0L53EbNWwWiCsWtK8Fwed/APEbptPHXs5PuW+y8Bq8lFTA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"crc-32": "^1.2.0",
|
||||
"crc32-stream": "^5.0.0",
|
||||
"normalize-path": "^3.0.0",
|
||||
"readable-stream": "^3.6.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 12.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/concat-map": {
|
||||
"version": "0.0.1",
|
||||
"resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz",
|
||||
|
|
@ -4249,6 +4395,31 @@
|
|||
}
|
||||
}
|
||||
},
|
||||
"node_modules/crc-32": {
|
||||
"version": "1.2.2",
|
||||
"resolved": "https://registry.npmjs.org/crc-32/-/crc-32-1.2.2.tgz",
|
||||
"integrity": "sha512-ROmzCKrTnOwybPcJApAA6WBWij23HVfGVNKqqrZpuyZOHqK2CwHSvpGuyt/UNNvaIjEd8X5IFGp4Mh+Ie1IHJQ==",
|
||||
"license": "Apache-2.0",
|
||||
"bin": {
|
||||
"crc32": "bin/crc32.njs"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/crc32-stream": {
|
||||
"version": "5.0.1",
|
||||
"resolved": "https://registry.npmjs.org/crc32-stream/-/crc32-stream-5.0.1.tgz",
|
||||
"integrity": "sha512-lO1dFui+CEUh/ztYIpgpKItKW9Bb4NWakCRJrnqAbFIYD+OZAwb2VfD5T5eXMw2FNcsDHkQcNl/Wh3iVXYwU6g==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"crc-32": "^1.2.0",
|
||||
"readable-stream": "^3.4.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 12.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/create-jest": {
|
||||
"version": "29.7.0",
|
||||
"resolved": "https://registry.npmjs.org/create-jest/-/create-jest-29.7.0.tgz",
|
||||
|
|
@ -4387,7 +4558,6 @@
|
|||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz",
|
||||
"integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=0.4.0"
|
||||
|
|
@ -4685,7 +4855,6 @@
|
|||
"version": "2.1.0",
|
||||
"resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz",
|
||||
"integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"es-errors": "^1.3.0",
|
||||
|
|
@ -5017,6 +5186,15 @@
|
|||
"node": ">=0.8.x"
|
||||
}
|
||||
},
|
||||
"node_modules/events-universal": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/events-universal/-/events-universal-1.0.1.tgz",
|
||||
"integrity": "sha512-LUd5euvbMLpwOF8m6ivPCbhQeSiYVNb8Vs0fQ8QjXo0JTkEHpz8pxdQf0gStltaPpw0Cca8b39KxvK9cfKRiAw==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"bare-events": "^2.7.0"
|
||||
}
|
||||
},
|
||||
"node_modules/execa": {
|
||||
"version": "5.1.1",
|
||||
"resolved": "https://registry.npmjs.org/execa/-/execa-5.1.1.tgz",
|
||||
|
|
@ -5171,6 +5349,12 @@
|
|||
"dev": true,
|
||||
"license": "Apache-2.0"
|
||||
},
|
||||
"node_modules/fast-fifo": {
|
||||
"version": "1.3.2",
|
||||
"resolved": "https://registry.npmjs.org/fast-fifo/-/fast-fifo-1.3.2.tgz",
|
||||
"integrity": "sha512-/d9sfos4yxzpwkDkuN7k2SqFKtYNmCTzgfEpz82x34IM9/zc8KGxQoXg1liNC/izpRM/MBdt44Nmx41ZWqk+FQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/fast-glob": {
|
||||
"version": "3.3.3",
|
||||
"resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.3.tgz",
|
||||
|
|
@ -5385,6 +5569,26 @@
|
|||
"dev": true,
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/follow-redirects": {
|
||||
"version": "1.15.11",
|
||||
"resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.11.tgz",
|
||||
"integrity": "sha512-deG2P0JfjrTxl50XGCDyfI97ZGVCxIpfKYmfyrQ54n5FO/0gfIES8C/Psl6kWVDolizcaaxZJnTS0QSMxvnsBQ==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "individual",
|
||||
"url": "https://github.com/sponsors/RubenVerborgh"
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=4.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"debug": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/foreground-child": {
|
||||
"version": "3.3.1",
|
||||
"resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-3.3.1.tgz",
|
||||
|
|
@ -5459,7 +5663,6 @@
|
|||
"version": "4.0.5",
|
||||
"resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.5.tgz",
|
||||
"integrity": "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"asynckit": "^0.4.0",
|
||||
|
|
@ -5532,7 +5735,6 @@
|
|||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz",
|
||||
"integrity": "sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==",
|
||||
"dev": true,
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/fsevents": {
|
||||
|
|
@ -5838,7 +6040,6 @@
|
|||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz",
|
||||
"integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"has-symbols": "^1.0.3"
|
||||
|
|
@ -5989,7 +6190,6 @@
|
|||
"resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz",
|
||||
"integrity": "sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==",
|
||||
"deprecated": "This module is not supported, and leaks memory. Do not use it. Check out lru-cache if you want a good and tested way to coalesce async requests by a key value, which is much more comprehensive and powerful.",
|
||||
"dev": true,
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"once": "^1.3.0",
|
||||
|
|
@ -7190,6 +7390,48 @@
|
|||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/lazystream": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/lazystream/-/lazystream-1.0.1.tgz",
|
||||
"integrity": "sha512-b94GiNHQNy6JNTrt5w6zNyffMrNkXZb3KTkCZJb2V1xaEGCk093vkZ2jk3tpaeP33/OiXC+WvK9AxUebnf5nbw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"readable-stream": "^2.0.5"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.6.3"
|
||||
}
|
||||
},
|
||||
"node_modules/lazystream/node_modules/readable-stream": {
|
||||
"version": "2.3.8",
|
||||
"resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.8.tgz",
|
||||
"integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"core-util-is": "~1.0.0",
|
||||
"inherits": "~2.0.3",
|
||||
"isarray": "~1.0.0",
|
||||
"process-nextick-args": "~2.0.0",
|
||||
"safe-buffer": "~5.1.1",
|
||||
"string_decoder": "~1.1.1",
|
||||
"util-deprecate": "~1.0.1"
|
||||
}
|
||||
},
|
||||
"node_modules/lazystream/node_modules/safe-buffer": {
|
||||
"version": "5.1.2",
|
||||
"resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz",
|
||||
"integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/lazystream/node_modules/string_decoder": {
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz",
|
||||
"integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"safe-buffer": "~5.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/leven": {
|
||||
"version": "3.1.0",
|
||||
"resolved": "https://registry.npmjs.org/leven/-/leven-3.1.0.tgz",
|
||||
|
|
@ -7704,7 +7946,6 @@
|
|||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz",
|
||||
"integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
|
|
@ -7760,7 +8001,6 @@
|
|||
"version": "1.4.0",
|
||||
"resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz",
|
||||
"integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==",
|
||||
"dev": true,
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"wrappy": "1"
|
||||
|
|
@ -8280,6 +8520,12 @@
|
|||
"node": ">= 0.10"
|
||||
}
|
||||
},
|
||||
"node_modules/proxy-from-env": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz",
|
||||
"integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/punycode": {
|
||||
"version": "2.3.1",
|
||||
"resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz",
|
||||
|
|
@ -8398,6 +8644,27 @@
|
|||
"node": ">= 6"
|
||||
}
|
||||
},
|
||||
"node_modules/readdir-glob": {
|
||||
"version": "1.1.3",
|
||||
"resolved": "https://registry.npmjs.org/readdir-glob/-/readdir-glob-1.1.3.tgz",
|
||||
"integrity": "sha512-v05I2k7xN8zXvPD9N+z/uhXPaj0sUFCe2rcWZIpBsqxfP7xXFQ0tipAd/wjj1YxWyWtUS5IDJpOG82JKt2EAVA==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"minimatch": "^5.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/readdir-glob/node_modules/minimatch": {
|
||||
"version": "5.1.6",
|
||||
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-5.1.6.tgz",
|
||||
"integrity": "sha512-lKwV/1brpG6mBUFHtb7NUmtABCb2WZZmm2wNiOA5hAb8VdCS4B3dtMWyvcoViccwAW/COERjXLt0zP1zXUN26g==",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"brace-expansion": "^2.0.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
}
|
||||
},
|
||||
"node_modules/readdirp": {
|
||||
"version": "3.6.0",
|
||||
"resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz",
|
||||
|
|
@ -9083,6 +9350,17 @@
|
|||
"node": ">=10.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/streamx": {
|
||||
"version": "2.23.0",
|
||||
"resolved": "https://registry.npmjs.org/streamx/-/streamx-2.23.0.tgz",
|
||||
"integrity": "sha512-kn+e44esVfn2Fa/O0CPFcex27fjIL6MkVae0Mm6q+E6f0hWv578YCERbv+4m02cjxvDsPKLnmxral/rR6lBMAg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"events-universal": "^1.0.0",
|
||||
"fast-fifo": "^1.3.2",
|
||||
"text-decoder": "^1.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/string_decoder": {
|
||||
"version": "1.3.0",
|
||||
"resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz",
|
||||
|
|
@ -9335,6 +9613,17 @@
|
|||
"url": "https://opencollective.com/webpack"
|
||||
}
|
||||
},
|
||||
"node_modules/tar-stream": {
|
||||
"version": "3.1.7",
|
||||
"resolved": "https://registry.npmjs.org/tar-stream/-/tar-stream-3.1.7.tgz",
|
||||
"integrity": "sha512-qJj60CXt7IU1Ffyc3NJMjh6EkuCFej46zUqJ4J7pqYlThyd9bO0XBTmcOIhSzZJVWfsLks0+nle/j538YAW9RQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"b4a": "^1.6.4",
|
||||
"fast-fifo": "^1.2.0",
|
||||
"streamx": "^2.15.0"
|
||||
}
|
||||
},
|
||||
"node_modules/terser": {
|
||||
"version": "5.44.1",
|
||||
"resolved": "https://registry.npmjs.org/terser/-/terser-5.44.1.tgz",
|
||||
|
|
@ -9508,6 +9797,15 @@
|
|||
"node": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/text-decoder": {
|
||||
"version": "1.2.3",
|
||||
"resolved": "https://registry.npmjs.org/text-decoder/-/text-decoder-1.2.3.tgz",
|
||||
"integrity": "sha512-3/o9z3X0X0fTupwsYvR03pJ/DjWuqqrfwBgTQzdWDiQSm9KitAyz/9WqsT2JQW7KV2m+bC2ol/zqpW37NHxLaA==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"b4a": "^1.6.4"
|
||||
}
|
||||
},
|
||||
"node_modules/text-table": {
|
||||
"version": "0.2.0",
|
||||
"resolved": "https://registry.npmjs.org/text-table/-/text-table-0.2.0.tgz",
|
||||
|
|
@ -10293,7 +10591,6 @@
|
|||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz",
|
||||
"integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==",
|
||||
"dev": true,
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/write-file-atomic": {
|
||||
|
|
@ -10404,6 +10701,20 @@
|
|||
"funding": {
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/zip-stream": {
|
||||
"version": "5.0.2",
|
||||
"resolved": "https://registry.npmjs.org/zip-stream/-/zip-stream-5.0.2.tgz",
|
||||
"integrity": "sha512-LfOdrUvPB8ZoXtvOBz6DlNClfvi//b5d56mSWyJi7XbH/HfhOHfUhOqxhT/rUiR7yiktlunqRo+jY6y/cWC/5g==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"archiver-utils": "^4.0.1",
|
||||
"compress-commons": "^5.0.1",
|
||||
"readable-stream": "^3.6.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 12.0.0"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -41,6 +41,8 @@
|
|||
"@prisma/client": "^5.7.0",
|
||||
"@types/multer": "^2.0.0",
|
||||
"adbkit-apkreader": "^3.2.0",
|
||||
"archiver": "^6.0.1",
|
||||
"axios": "^1.6.2",
|
||||
"bplist-parser": "^0.3.2",
|
||||
"class-transformer": "^0.5.1",
|
||||
"class-validator": "^0.14.0",
|
||||
|
|
@ -56,6 +58,7 @@
|
|||
"@nestjs/cli": "^10.0.0",
|
||||
"@nestjs/schematics": "^10.0.0",
|
||||
"@nestjs/testing": "^10.0.0",
|
||||
"@types/archiver": "^6.0.2",
|
||||
"@types/express": "^4.17.17",
|
||||
"@types/jest": "^29.5.2",
|
||||
"@types/node": "^20.3.1",
|
||||
|
|
|
|||
|
|
@ -0,0 +1,34 @@
|
|||
-- =============================================================================
|
||||
-- App Assets Migration
|
||||
-- 应用资源管理 (开屏图/引导图)
|
||||
-- =============================================================================
|
||||
|
||||
-- -----------------------------------------------------------------------------
|
||||
-- 1. 应用资源类型枚举
|
||||
-- -----------------------------------------------------------------------------
|
||||
|
||||
CREATE TYPE "AppAssetType" AS ENUM ('SPLASH', 'GUIDE');
|
||||
|
||||
-- -----------------------------------------------------------------------------
|
||||
-- 2. 应用资源表
|
||||
-- -----------------------------------------------------------------------------
|
||||
|
||||
CREATE TABLE "app_assets" (
|
||||
"id" TEXT NOT NULL,
|
||||
"type" "AppAssetType" NOT NULL,
|
||||
"sort_order" INTEGER NOT NULL,
|
||||
"image_url" TEXT NOT NULL,
|
||||
"title" VARCHAR(100),
|
||||
"subtitle" VARCHAR(200),
|
||||
"is_enabled" BOOLEAN NOT NULL DEFAULT true,
|
||||
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updated_at" TIMESTAMP(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "app_assets_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- Unique constraint: 同类型同排序位置只能有一条记录
|
||||
CREATE UNIQUE INDEX "app_assets_type_sort_order_key" ON "app_assets"("type", "sort_order");
|
||||
|
||||
-- 按类型和启用状态查询索引
|
||||
CREATE INDEX "app_assets_type_is_enabled_idx" ON "app_assets"("type", "is_enabled");
|
||||
|
|
@ -0,0 +1,43 @@
|
|||
-- =============================================================================
|
||||
-- Customer Service Contacts Migration
|
||||
-- 客服联系方式管理
|
||||
-- =============================================================================
|
||||
|
||||
-- -----------------------------------------------------------------------------
|
||||
-- 1. 客服联系方式类型枚举
|
||||
-- -----------------------------------------------------------------------------
|
||||
|
||||
CREATE TYPE "ContactType" AS ENUM ('WECHAT', 'QQ');
|
||||
|
||||
-- -----------------------------------------------------------------------------
|
||||
-- 2. 客服联系方式表
|
||||
-- -----------------------------------------------------------------------------
|
||||
|
||||
CREATE TABLE "customer_service_contacts" (
|
||||
"id" TEXT NOT NULL,
|
||||
"type" "ContactType" NOT NULL,
|
||||
"label" VARCHAR(100) NOT NULL,
|
||||
"value" VARCHAR(200) NOT NULL,
|
||||
"sort_order" INTEGER NOT NULL,
|
||||
"is_enabled" BOOLEAN NOT NULL DEFAULT true,
|
||||
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updated_at" TIMESTAMP(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "customer_service_contacts_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- 按类型和启用状态查询索引
|
||||
CREATE INDEX "customer_service_contacts_type_is_enabled_idx" ON "customer_service_contacts"("type", "is_enabled");
|
||||
|
||||
-- 按排序查询索引
|
||||
CREATE INDEX "customer_service_contacts_sort_order_idx" ON "customer_service_contacts"("sort_order");
|
||||
|
||||
-- -----------------------------------------------------------------------------
|
||||
-- 3. 初始数据 (保留现有硬编码的联系方式)
|
||||
-- -----------------------------------------------------------------------------
|
||||
|
||||
INSERT INTO "customer_service_contacts" ("id", "type", "label", "value", "sort_order", "is_enabled", "created_at", "updated_at") VALUES
|
||||
(gen_random_uuid(), 'WECHAT', '客服微信1', 'liulianhuanghou1', 1, true, NOW(), NOW()),
|
||||
(gen_random_uuid(), 'WECHAT', '客服微信2', 'liulianhuanghou2', 2, true, NOW(), NOW()),
|
||||
(gen_random_uuid(), 'QQ', '客服QQ1', '1502109619', 3, true, NOW(), NOW()),
|
||||
(gen_random_uuid(), 'QQ', '客服QQ2', '2171447109', 4, true, NOW(), NOW());
|
||||
|
|
@ -0,0 +1,40 @@
|
|||
-- 合同批量下载任务表
|
||||
-- [2026-02-05] 新增:用于记录和追踪合同批量下载任务
|
||||
|
||||
-- CreateEnum
|
||||
CREATE TYPE "BatchDownloadStatus" AS ENUM ('PENDING', 'PROCESSING', 'COMPLETED', 'FAILED', 'CANCELLED');
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "contract_batch_download_tasks" (
|
||||
"id" BIGSERIAL NOT NULL,
|
||||
"task_no" VARCHAR(50) NOT NULL,
|
||||
"status" "BatchDownloadStatus" NOT NULL DEFAULT 'PENDING',
|
||||
"total_contracts" INTEGER NOT NULL DEFAULT 0,
|
||||
"downloaded_count" INTEGER NOT NULL DEFAULT 0,
|
||||
"failed_count" INTEGER NOT NULL DEFAULT 0,
|
||||
"progress" INTEGER NOT NULL DEFAULT 0,
|
||||
"last_processed_order_no" VARCHAR(50),
|
||||
"result_file_url" VARCHAR(500),
|
||||
"result_file_size" BIGINT,
|
||||
"errors" JSONB,
|
||||
"operator_id" VARCHAR(50) NOT NULL,
|
||||
"filters" JSONB,
|
||||
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"started_at" TIMESTAMP(3),
|
||||
"completed_at" TIMESTAMP(3),
|
||||
"expires_at" TIMESTAMP(3),
|
||||
|
||||
CONSTRAINT "contract_batch_download_tasks_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "contract_batch_download_tasks_task_no_key" ON "contract_batch_download_tasks"("task_no");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "contract_batch_download_tasks_status_idx" ON "contract_batch_download_tasks"("status");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "contract_batch_download_tasks_operator_id_idx" ON "contract_batch_download_tasks"("operator_id");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "contract_batch_download_tasks_created_at_idx" ON "contract_batch_download_tasks"("created_at");
|
||||
|
|
@ -1150,3 +1150,111 @@ model CoManagedWallet {
|
|||
@@index([createdAt])
|
||||
@@map("co_managed_wallets")
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// App Assets (应用资源 - 开屏图/引导图)
|
||||
// =============================================================================
|
||||
|
||||
/// 应用资源类型
|
||||
enum AppAssetType {
|
||||
SPLASH // 开屏图
|
||||
GUIDE // 引导图
|
||||
}
|
||||
|
||||
/// 应用资源 - 管理开屏图和引导图
|
||||
model AppAsset {
|
||||
id String @id @default(uuid())
|
||||
type AppAssetType
|
||||
sortOrder Int @map("sort_order")
|
||||
imageUrl String @map("image_url") @db.Text
|
||||
title String? @db.VarChar(100)
|
||||
subtitle String? @db.VarChar(200)
|
||||
isEnabled Boolean @default(true) @map("is_enabled")
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
updatedAt DateTime @updatedAt @map("updated_at")
|
||||
|
||||
@@unique([type, sortOrder])
|
||||
@@index([type, isEnabled])
|
||||
@@map("app_assets")
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Contract Batch Download Tasks (合同批量下载任务)
|
||||
// [2026-02-05] 新增:支持管理后台合同批量下载
|
||||
// 回滚方式:删除此 model 并运行 prisma migrate
|
||||
// =============================================================================
|
||||
|
||||
/// 批量下载任务状态
|
||||
enum BatchDownloadStatus {
|
||||
PENDING // 待处理
|
||||
PROCESSING // 处理中
|
||||
COMPLETED // 已完成
|
||||
FAILED // 失败
|
||||
CANCELLED // 已取消
|
||||
}
|
||||
|
||||
/// 合同批量下载任务 - 记录批量下载请求的执行状态
|
||||
model ContractBatchDownloadTask {
|
||||
id BigInt @id @default(autoincrement())
|
||||
taskNo String @unique @map("task_no") @db.VarChar(50)
|
||||
status BatchDownloadStatus @default(PENDING)
|
||||
|
||||
// 下载统计
|
||||
totalContracts Int @default(0) @map("total_contracts")
|
||||
downloadedCount Int @default(0) @map("downloaded_count")
|
||||
failedCount Int @default(0) @map("failed_count")
|
||||
progress Int @default(0) // 0-100
|
||||
|
||||
// 断点续传支持
|
||||
lastProcessedOrderNo String? @map("last_processed_order_no") @db.VarChar(50)
|
||||
|
||||
// 结果文件
|
||||
resultFileUrl String? @map("result_file_url") @db.VarChar(500)
|
||||
resultFileSize BigInt? @map("result_file_size")
|
||||
|
||||
// 错误信息
|
||||
errors Json? // 失败的合同列表
|
||||
|
||||
// 操作者
|
||||
operatorId String @map("operator_id") @db.VarChar(50)
|
||||
|
||||
// 筛选条件
|
||||
filters Json? // { signedAfter, signedBefore, provinceCode, cityCode }
|
||||
|
||||
// 时间戳
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
startedAt DateTime? @map("started_at")
|
||||
completedAt DateTime? @map("completed_at")
|
||||
expiresAt DateTime? @map("expires_at") // 结果文件过期时间
|
||||
|
||||
@@index([status])
|
||||
@@index([operatorId])
|
||||
@@index([createdAt])
|
||||
@@map("contract_batch_download_tasks")
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Customer Service Contacts (客服联系方式)
|
||||
// =============================================================================
|
||||
|
||||
/// 客服联系方式类型
|
||||
enum ContactType {
|
||||
WECHAT // 微信
|
||||
QQ // QQ
|
||||
}
|
||||
|
||||
/// 客服联系方式 - 管理员配置的客服联系信息
|
||||
model CustomerServiceContact {
|
||||
id String @id @default(uuid())
|
||||
type ContactType
|
||||
label String @db.VarChar(100)
|
||||
value String @db.VarChar(200)
|
||||
sortOrder Int @map("sort_order")
|
||||
isEnabled Boolean @default(true) @map("is_enabled")
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
updatedAt DateTime @updatedAt @map("updated_at")
|
||||
|
||||
@@index([type, isEnabled])
|
||||
@@index([sortOrder])
|
||||
@@map("customer_service_contacts")
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,262 @@
|
|||
import {
|
||||
Controller,
|
||||
Get,
|
||||
Post,
|
||||
Put,
|
||||
Delete,
|
||||
Body,
|
||||
Param,
|
||||
Query,
|
||||
UploadedFile,
|
||||
HttpCode,
|
||||
HttpStatus,
|
||||
UseInterceptors,
|
||||
ParseFilePipe,
|
||||
MaxFileSizeValidator,
|
||||
FileTypeValidator,
|
||||
NotFoundException,
|
||||
BadRequestException,
|
||||
Logger,
|
||||
} from '@nestjs/common'
|
||||
import { FileInterceptor } from '@nestjs/platform-express'
|
||||
import { ApiTags, ApiOperation, ApiBearerAuth, ApiConsumes, ApiBody, ApiQuery } from '@nestjs/swagger'
|
||||
import { PrismaService } from '../../infrastructure/persistence/prisma/prisma.service'
|
||||
import { FileStorageService } from '../../infrastructure/storage/file-storage.service'
|
||||
import { AppAssetType } from '@prisma/client'
|
||||
|
||||
// 图片最大 10MB
|
||||
const MAX_IMAGE_SIZE = 10 * 1024 * 1024
|
||||
|
||||
// 每种类型的数量上限
|
||||
const ASSET_LIMITS: Record<AppAssetType, number> = {
|
||||
SPLASH: 10,
|
||||
GUIDE: 10,
|
||||
}
|
||||
|
||||
// ===== Response DTO =====
|
||||
|
||||
interface AppAssetResponseDto {
|
||||
id: string
|
||||
type: AppAssetType
|
||||
sortOrder: number
|
||||
imageUrl: string
|
||||
title: string | null
|
||||
subtitle: string | null
|
||||
isEnabled: boolean
|
||||
createdAt: Date
|
||||
updatedAt: Date
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Admin Controller (需要认证)
|
||||
// =============================================================================
|
||||
|
||||
@ApiTags('App Asset Management')
|
||||
@Controller('admin/app-assets')
|
||||
export class AdminAppAssetController {
|
||||
private readonly logger = new Logger(AdminAppAssetController.name)
|
||||
|
||||
constructor(
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly fileStorage: FileStorageService,
|
||||
) {}
|
||||
|
||||
@Get()
|
||||
@ApiBearerAuth()
|
||||
@ApiOperation({ summary: '查询应用资源列表' })
|
||||
@ApiQuery({ name: 'type', required: false, enum: AppAssetType })
|
||||
async list(@Query('type') type?: AppAssetType): Promise<AppAssetResponseDto[]> {
|
||||
const where = type ? { type } : {}
|
||||
const assets = await this.prisma.appAsset.findMany({
|
||||
where,
|
||||
orderBy: [{ type: 'asc' }, { sortOrder: 'asc' }],
|
||||
})
|
||||
return assets.map(this.toDto)
|
||||
}
|
||||
|
||||
@Post('upload')
|
||||
@ApiBearerAuth()
|
||||
@UseInterceptors(FileInterceptor('file'))
|
||||
@ApiConsumes('multipart/form-data')
|
||||
@ApiOperation({ summary: '上传图片并创建/替换资源' })
|
||||
@ApiBody({
|
||||
schema: {
|
||||
type: 'object',
|
||||
required: ['file', 'type', 'sortOrder'],
|
||||
properties: {
|
||||
file: { type: 'string', format: 'binary' },
|
||||
type: { type: 'string', enum: ['SPLASH', 'GUIDE'] },
|
||||
sortOrder: { type: 'integer', minimum: 1 },
|
||||
title: { type: 'string' },
|
||||
subtitle: { type: 'string' },
|
||||
},
|
||||
},
|
||||
})
|
||||
async upload(
|
||||
@UploadedFile(
|
||||
new ParseFilePipe({
|
||||
validators: [
|
||||
new MaxFileSizeValidator({ maxSize: MAX_IMAGE_SIZE }),
|
||||
new FileTypeValidator({ fileType: /^image\/(jpeg|jpg|png|webp)$/ }),
|
||||
],
|
||||
fileIsRequired: true,
|
||||
}),
|
||||
)
|
||||
file: Express.Multer.File,
|
||||
@Body('type') type: string,
|
||||
@Body('sortOrder') sortOrderStr: string,
|
||||
@Body('title') title?: string,
|
||||
@Body('subtitle') subtitle?: string,
|
||||
): Promise<AppAssetResponseDto> {
|
||||
// 校验 type
|
||||
const assetType = type as AppAssetType
|
||||
if (!Object.values(AppAssetType).includes(assetType)) {
|
||||
throw new BadRequestException(`Invalid type: ${type}. Must be SPLASH or GUIDE`)
|
||||
}
|
||||
|
||||
// 校验 sortOrder
|
||||
const sortOrder = parseInt(sortOrderStr, 10)
|
||||
if (isNaN(sortOrder) || sortOrder < 1) {
|
||||
throw new BadRequestException('sortOrder must be a positive integer')
|
||||
}
|
||||
|
||||
const limit = ASSET_LIMITS[assetType]
|
||||
if (sortOrder > limit) {
|
||||
throw new BadRequestException(`sortOrder for ${assetType} must be between 1 and ${limit}`)
|
||||
}
|
||||
|
||||
// 保存文件
|
||||
const uploadResult = await this.fileStorage.saveFile(
|
||||
file.buffer,
|
||||
file.originalname,
|
||||
'app-assets',
|
||||
`${assetType.toLowerCase()}-${sortOrder}`,
|
||||
)
|
||||
|
||||
this.logger.log(
|
||||
`Uploaded app asset: type=${assetType}, sortOrder=${sortOrder}, url=${uploadResult.url}`,
|
||||
)
|
||||
|
||||
// Upsert: 同 type+sortOrder 自动替换
|
||||
const asset = await this.prisma.appAsset.upsert({
|
||||
where: {
|
||||
type_sortOrder: { type: assetType, sortOrder },
|
||||
},
|
||||
create: {
|
||||
type: assetType,
|
||||
sortOrder,
|
||||
imageUrl: uploadResult.url,
|
||||
title: title || null,
|
||||
subtitle: subtitle || null,
|
||||
isEnabled: true,
|
||||
},
|
||||
update: {
|
||||
imageUrl: uploadResult.url,
|
||||
title: title !== undefined ? (title || null) : undefined,
|
||||
subtitle: subtitle !== undefined ? (subtitle || null) : undefined,
|
||||
},
|
||||
})
|
||||
|
||||
return this.toDto(asset)
|
||||
}
|
||||
|
||||
@Put(':id')
|
||||
@ApiBearerAuth()
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@ApiOperation({ summary: '更新资源元数据 (标题/副标题/启停)' })
|
||||
async update(
|
||||
@Param('id') id: string,
|
||||
@Body() body: { title?: string; subtitle?: string; isEnabled?: boolean },
|
||||
): Promise<AppAssetResponseDto> {
|
||||
const existing = await this.prisma.appAsset.findUnique({ where: { id } })
|
||||
if (!existing) {
|
||||
throw new NotFoundException('App asset not found')
|
||||
}
|
||||
|
||||
const data: Record<string, unknown> = {}
|
||||
if (body.title !== undefined) data.title = body.title || null
|
||||
if (body.subtitle !== undefined) data.subtitle = body.subtitle || null
|
||||
if (body.isEnabled !== undefined) data.isEnabled = body.isEnabled
|
||||
|
||||
const updated = await this.prisma.appAsset.update({
|
||||
where: { id },
|
||||
data,
|
||||
})
|
||||
|
||||
return this.toDto(updated)
|
||||
}
|
||||
|
||||
@Delete(':id')
|
||||
@ApiBearerAuth()
|
||||
@HttpCode(HttpStatus.NO_CONTENT)
|
||||
@ApiOperation({ summary: '删除资源' })
|
||||
async delete(@Param('id') id: string): Promise<void> {
|
||||
const existing = await this.prisma.appAsset.findUnique({ where: { id } })
|
||||
if (!existing) {
|
||||
throw new NotFoundException('App asset not found')
|
||||
}
|
||||
await this.prisma.appAsset.delete({ where: { id } })
|
||||
this.logger.log(`Deleted app asset: id=${id}, type=${existing.type}, sortOrder=${existing.sortOrder}`)
|
||||
}
|
||||
|
||||
private toDto(asset: {
|
||||
id: string
|
||||
type: AppAssetType
|
||||
sortOrder: number
|
||||
imageUrl: string
|
||||
title: string | null
|
||||
subtitle: string | null
|
||||
isEnabled: boolean
|
||||
createdAt: Date
|
||||
updatedAt: Date
|
||||
}): AppAssetResponseDto {
|
||||
return {
|
||||
id: asset.id,
|
||||
type: asset.type,
|
||||
sortOrder: asset.sortOrder,
|
||||
imageUrl: asset.imageUrl,
|
||||
title: asset.title,
|
||||
subtitle: asset.subtitle,
|
||||
isEnabled: asset.isEnabled,
|
||||
createdAt: asset.createdAt,
|
||||
updatedAt: asset.updatedAt,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Public Controller (移动端调用,无需认证)
|
||||
// =============================================================================
|
||||
|
||||
@ApiTags('App Assets (Public)')
|
||||
@Controller('app-assets')
|
||||
export class PublicAppAssetController {
|
||||
constructor(private readonly prisma: PrismaService) {}
|
||||
|
||||
@Get()
|
||||
@ApiOperation({ summary: '获取已启用的应用资源 (移动端)' })
|
||||
@ApiQuery({ name: 'type', required: false, enum: AppAssetType })
|
||||
async list(@Query('type') type?: AppAssetType): Promise<AppAssetResponseDto[]> {
|
||||
const where: { isEnabled: boolean; type?: AppAssetType } = { isEnabled: true }
|
||||
if (type && Object.values(AppAssetType).includes(type)) {
|
||||
where.type = type
|
||||
}
|
||||
|
||||
const assets = await this.prisma.appAsset.findMany({
|
||||
where,
|
||||
orderBy: [{ type: 'asc' }, { sortOrder: 'asc' }],
|
||||
})
|
||||
|
||||
return assets.map((asset) => ({
|
||||
id: asset.id,
|
||||
type: asset.type,
|
||||
sortOrder: asset.sortOrder,
|
||||
imageUrl: asset.imageUrl,
|
||||
title: asset.title,
|
||||
subtitle: asset.subtitle,
|
||||
isEnabled: asset.isEnabled,
|
||||
createdAt: asset.createdAt,
|
||||
updatedAt: asset.updatedAt,
|
||||
}))
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,309 @@
|
|||
/**
|
||||
* 合同管理控制器
|
||||
* [2026-02-05] 新增:提供合同查询、下载、批量下载功能
|
||||
* 回滚方式:删除此文件并从 app.module.ts 中移除引用
|
||||
*/
|
||||
|
||||
import {
|
||||
Controller,
|
||||
Get,
|
||||
Post,
|
||||
Param,
|
||||
Query,
|
||||
Body,
|
||||
Res,
|
||||
Req,
|
||||
NotFoundException,
|
||||
BadRequestException,
|
||||
Logger,
|
||||
StreamableFile,
|
||||
} from '@nestjs/common';
|
||||
import { ApiTags, ApiOperation, ApiQuery, ApiResponse, ApiParam, ApiBody } from '@nestjs/swagger';
|
||||
import { Request, Response } from 'express';
|
||||
import { PrismaService } from '../../infrastructure/persistence/prisma/prisma.service';
|
||||
import { ContractService, ContractQueryParams } from '../../application/services/contract.service';
|
||||
|
||||
/**
|
||||
* 批量下载请求 DTO
|
||||
*/
|
||||
interface BatchDownloadRequestDto {
|
||||
filters?: {
|
||||
signedAfter?: string;
|
||||
signedBefore?: string;
|
||||
provinceCode?: string;
|
||||
cityCode?: string;
|
||||
};
|
||||
orderNos?: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* 合同管理控制器
|
||||
* 为管理后台提供合同查询和下载功能
|
||||
*/
|
||||
@ApiTags('Admin - Contracts')
|
||||
@Controller('admin/contracts')
|
||||
export class ContractController {
|
||||
private readonly logger = new Logger(ContractController.name);
|
||||
|
||||
constructor(
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly contractService: ContractService,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* 获取合同列表
|
||||
*/
|
||||
@Get()
|
||||
@ApiOperation({ summary: '获取合同列表' })
|
||||
@ApiQuery({ name: 'signedAfter', required: false, description: '签署时间起始(ISO格式)' })
|
||||
@ApiQuery({ name: 'signedBefore', required: false, description: '签署时间结束(ISO格式)' })
|
||||
@ApiQuery({ name: 'provinceCode', required: false, description: '省份代码' })
|
||||
@ApiQuery({ name: 'cityCode', required: false, description: '城市代码' })
|
||||
@ApiQuery({ name: 'status', required: false, description: '合同状态' })
|
||||
@ApiQuery({ name: 'page', required: false, description: '页码,默认1' })
|
||||
@ApiQuery({ name: 'pageSize', required: false, description: '每页条数,默认50' })
|
||||
@ApiQuery({ name: 'orderBy', required: false, description: '排序字段:signedAt/createdAt' })
|
||||
@ApiQuery({ name: 'orderDir', required: false, description: '排序方向:asc/desc' })
|
||||
@ApiResponse({ status: 200, description: '合同列表' })
|
||||
async getContracts(
|
||||
@Query('signedAfter') signedAfter?: string,
|
||||
@Query('signedBefore') signedBefore?: string,
|
||||
@Query('provinceCode') provinceCode?: string,
|
||||
@Query('cityCode') cityCode?: string,
|
||||
@Query('status') status?: string,
|
||||
@Query('page') page?: string,
|
||||
@Query('pageSize') pageSize?: string,
|
||||
@Query('orderBy') orderBy?: string,
|
||||
@Query('orderDir') orderDir?: string,
|
||||
) {
|
||||
this.logger.log(`========== GET /v1/admin/contracts 请求 ==========`);
|
||||
|
||||
const params: ContractQueryParams = {
|
||||
signedAfter,
|
||||
signedBefore,
|
||||
provinceCode,
|
||||
cityCode,
|
||||
status,
|
||||
page: page ? parseInt(page, 10) : undefined,
|
||||
pageSize: pageSize ? parseInt(pageSize, 10) : undefined,
|
||||
orderBy: orderBy as 'signedAt' | 'createdAt',
|
||||
orderDir: orderDir as 'asc' | 'desc',
|
||||
};
|
||||
|
||||
return this.contractService.getContracts(params);
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取合同统计信息
|
||||
*/
|
||||
@Get('statistics')
|
||||
@ApiOperation({ summary: '获取合同统计信息' })
|
||||
@ApiQuery({ name: 'provinceCode', required: false, description: '省份代码' })
|
||||
@ApiQuery({ name: 'cityCode', required: false, description: '城市代码' })
|
||||
@ApiResponse({ status: 200, description: '合同统计' })
|
||||
async getStatistics(
|
||||
@Query('provinceCode') provinceCode?: string,
|
||||
@Query('cityCode') cityCode?: string,
|
||||
) {
|
||||
this.logger.log(`========== GET /v1/admin/contracts/statistics 请求 ==========`);
|
||||
return this.contractService.getStatistics({ provinceCode, cityCode });
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建批量下载任务
|
||||
*/
|
||||
@Post('batch-download')
|
||||
@ApiOperation({ summary: '创建合同批量下载任务' })
|
||||
@ApiBody({ description: '筛选条件' })
|
||||
@ApiResponse({ status: 201, description: '任务创建成功' })
|
||||
async createBatchDownload(
|
||||
@Body() body: BatchDownloadRequestDto,
|
||||
@Req() req: Request,
|
||||
) {
|
||||
this.logger.log(`========== POST /v1/admin/contracts/batch-download 请求 ==========`);
|
||||
this.logger.log(`筛选条件: ${JSON.stringify(body.filters)}`);
|
||||
|
||||
// 生成任务号
|
||||
const taskNo = `BD${Date.now()}`;
|
||||
|
||||
// 获取操作者 ID(从请求头或默认值)
|
||||
const operatorId = (req.headers['x-operator-id'] as string) || 'system';
|
||||
|
||||
// 创建任务记录
|
||||
const task = await this.prisma.contractBatchDownloadTask.create({
|
||||
data: {
|
||||
taskNo,
|
||||
operatorId,
|
||||
filters: body.filters ? JSON.parse(JSON.stringify(body.filters)) : null,
|
||||
status: 'PENDING',
|
||||
expiresAt: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000), // 7天后过期
|
||||
},
|
||||
});
|
||||
|
||||
// TODO: 触发异步任务处理(后续实现)
|
||||
// 可以使用 Bull Queue 或 Kafka 消息
|
||||
|
||||
this.logger.log(`批量下载任务创建成功: ${taskNo}`);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
taskId: task.id.toString(),
|
||||
taskNo: task.taskNo,
|
||||
status: task.status,
|
||||
createdAt: task.createdAt.toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 查询批量下载任务状态
|
||||
*/
|
||||
@Get('batch-download/:taskNo')
|
||||
@ApiOperation({ summary: '查询批量下载任务状态' })
|
||||
@ApiParam({ name: 'taskNo', description: '任务号' })
|
||||
@ApiResponse({ status: 200, description: '任务状态' })
|
||||
@ApiResponse({ status: 404, description: '任务不存在' })
|
||||
async getBatchDownloadStatus(@Param('taskNo') taskNo: string) {
|
||||
this.logger.log(`========== GET /v1/admin/contracts/batch-download/${taskNo} 请求 ==========`);
|
||||
|
||||
const task = await this.prisma.contractBatchDownloadTask.findUnique({
|
||||
where: { taskNo },
|
||||
});
|
||||
|
||||
if (!task) {
|
||||
throw new NotFoundException(`任务不存在: ${taskNo}`);
|
||||
}
|
||||
|
||||
return {
|
||||
taskId: task.id.toString(),
|
||||
taskNo: task.taskNo,
|
||||
status: task.status,
|
||||
totalContracts: task.totalContracts,
|
||||
downloadedCount: task.downloadedCount,
|
||||
failedCount: task.failedCount,
|
||||
progress: task.progress,
|
||||
resultFileUrl: task.resultFileUrl,
|
||||
resultFileSize: task.resultFileSize?.toString(),
|
||||
errors: task.errors,
|
||||
createdAt: task.createdAt.toISOString(),
|
||||
startedAt: task.startedAt?.toISOString(),
|
||||
completedAt: task.completedAt?.toISOString(),
|
||||
expiresAt: task.expiresAt?.toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取用户的合同列表
|
||||
*/
|
||||
@Get('users/:accountSequence')
|
||||
@ApiOperation({ summary: '获取用户的合同列表' })
|
||||
@ApiParam({ name: 'accountSequence', description: '用户账户序列号' })
|
||||
@ApiQuery({ name: 'page', required: false, description: '页码' })
|
||||
@ApiQuery({ name: 'pageSize', required: false, description: '每页条数' })
|
||||
@ApiResponse({ status: 200, description: '合同列表' })
|
||||
async getUserContracts(
|
||||
@Param('accountSequence') accountSequence: string,
|
||||
@Query('page') page?: string,
|
||||
@Query('pageSize') pageSize?: string,
|
||||
) {
|
||||
this.logger.log(`========== GET /v1/admin/contracts/users/${accountSequence} 请求 ==========`);
|
||||
|
||||
return this.contractService.getUserContracts(accountSequence, {
|
||||
page: page ? parseInt(page, 10) : undefined,
|
||||
pageSize: pageSize ? parseInt(pageSize, 10) : undefined,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取单个合同详情
|
||||
*/
|
||||
@Get(':orderNo')
|
||||
@ApiOperation({ summary: '获取合同详情' })
|
||||
@ApiParam({ name: 'orderNo', description: '订单号' })
|
||||
@ApiResponse({ status: 200, description: '合同详情' })
|
||||
@ApiResponse({ status: 404, description: '合同不存在' })
|
||||
async getContract(@Param('orderNo') orderNo: string) {
|
||||
this.logger.log(`========== GET /v1/admin/contracts/${orderNo} 请求 ==========`);
|
||||
|
||||
const contract = await this.contractService.getContract(orderNo);
|
||||
if (!contract) {
|
||||
throw new NotFoundException(`合同不存在: ${orderNo}`);
|
||||
}
|
||||
|
||||
return contract;
|
||||
}
|
||||
|
||||
/**
|
||||
* 下载合同 PDF(支持断点续传)
|
||||
*/
|
||||
@Get(':orderNo/download')
|
||||
@ApiOperation({ summary: '下载合同 PDF(支持断点续传)' })
|
||||
@ApiParam({ name: 'orderNo', description: '订单号' })
|
||||
@ApiResponse({ status: 200, description: 'PDF 文件' })
|
||||
@ApiResponse({ status: 206, description: '部分内容(断点续传)' })
|
||||
@ApiResponse({ status: 404, description: '合同不存在' })
|
||||
async downloadContract(
|
||||
@Param('orderNo') orderNo: string,
|
||||
@Req() req: Request,
|
||||
@Res() res: Response,
|
||||
): Promise<void> {
|
||||
this.logger.log(`========== GET /v1/admin/contracts/${orderNo}/download 请求 ==========`);
|
||||
|
||||
// 获取合同详情
|
||||
const contract = await this.contractService.getContract(orderNo);
|
||||
if (!contract) {
|
||||
throw new NotFoundException(`合同不存在: ${orderNo}`);
|
||||
}
|
||||
|
||||
if (!contract.signedPdfUrl) {
|
||||
throw new NotFoundException(`合同PDF不存在: ${orderNo},状态: ${contract.status}`);
|
||||
}
|
||||
|
||||
// 下载 PDF
|
||||
const pdfBuffer = await this.contractService.downloadContractPdf(orderNo);
|
||||
const fileSize = pdfBuffer.length;
|
||||
|
||||
// 生成文件名
|
||||
const safeRealName = contract.userRealName?.replace(/[\/\\:*?"<>|]/g, '_') || '未知';
|
||||
const fileName = `${contract.contractNo}_${safeRealName}_${contract.treeCount}棵_${contract.provinceName}${contract.cityName}.pdf`;
|
||||
const encodedFileName = encodeURIComponent(fileName);
|
||||
|
||||
// 检查 Range 请求头
|
||||
const range = req.headers.range;
|
||||
|
||||
// 设置通用响应头
|
||||
res.setHeader('Accept-Ranges', 'bytes');
|
||||
res.setHeader('Content-Type', 'application/pdf');
|
||||
res.setHeader('Content-Disposition', `attachment; filename*=UTF-8''${encodedFileName}`);
|
||||
res.setHeader('Cache-Control', 'public, max-age=86400');
|
||||
|
||||
if (range) {
|
||||
// 断点续传
|
||||
const parts = range.replace(/bytes=/, '').split('-');
|
||||
const start = parseInt(parts[0], 10);
|
||||
const end = parts[1] ? parseInt(parts[1], 10) : fileSize - 1;
|
||||
|
||||
if (start >= fileSize || end >= fileSize || start > end) {
|
||||
res.status(416);
|
||||
res.setHeader('Content-Range', `bytes */${fileSize}`);
|
||||
res.end();
|
||||
return;
|
||||
}
|
||||
|
||||
const chunkSize = end - start + 1;
|
||||
const chunk = pdfBuffer.slice(start, end + 1);
|
||||
|
||||
this.logger.log(`Range 请求: ${fileName}, bytes ${start}-${end}/${fileSize}`);
|
||||
|
||||
res.status(206);
|
||||
res.setHeader('Content-Range', `bytes ${start}-${end}/${fileSize}`);
|
||||
res.setHeader('Content-Length', chunkSize);
|
||||
res.end(chunk);
|
||||
} else {
|
||||
// 完整文件
|
||||
this.logger.log(`完整下载: ${fileName}, size=${fileSize}`);
|
||||
|
||||
res.setHeader('Content-Length', fileSize);
|
||||
res.end(pdfBuffer);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,196 @@
|
|||
import {
|
||||
Controller,
|
||||
Get,
|
||||
Post,
|
||||
Put,
|
||||
Delete,
|
||||
Body,
|
||||
Param,
|
||||
HttpCode,
|
||||
HttpStatus,
|
||||
NotFoundException,
|
||||
BadRequestException,
|
||||
Logger,
|
||||
} from '@nestjs/common'
|
||||
import { ApiTags, ApiOperation, ApiBearerAuth } from '@nestjs/swagger'
|
||||
import { PrismaService } from '../../infrastructure/persistence/prisma/prisma.service'
|
||||
import { ContactType } from '@prisma/client'
|
||||
|
||||
// ===== DTOs =====
|
||||
|
||||
interface CreateContactDto {
|
||||
type: string
|
||||
label: string
|
||||
value: string
|
||||
sortOrder: number
|
||||
isEnabled?: boolean
|
||||
}
|
||||
|
||||
interface UpdateContactDto {
|
||||
type?: string
|
||||
label?: string
|
||||
value?: string
|
||||
sortOrder?: number
|
||||
isEnabled?: boolean
|
||||
}
|
||||
|
||||
interface ContactResponseDto {
|
||||
id: string
|
||||
type: ContactType
|
||||
label: string
|
||||
value: string
|
||||
sortOrder: number
|
||||
isEnabled: boolean
|
||||
createdAt: Date
|
||||
updatedAt: Date
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Admin Controller (需要认证)
|
||||
// =============================================================================
|
||||
|
||||
@ApiTags('Customer Service Contact Management')
|
||||
@Controller('admin/customer-service-contacts')
|
||||
export class AdminCustomerServiceContactController {
|
||||
private readonly logger = new Logger(AdminCustomerServiceContactController.name)
|
||||
|
||||
constructor(private readonly prisma: PrismaService) {}
|
||||
|
||||
@Get()
|
||||
@ApiBearerAuth()
|
||||
@ApiOperation({ summary: '查询客服联系方式列表 (全部)' })
|
||||
async list(): Promise<ContactResponseDto[]> {
|
||||
const contacts = await this.prisma.customerServiceContact.findMany({
|
||||
orderBy: [{ sortOrder: 'asc' }],
|
||||
})
|
||||
return contacts.map(this.toDto)
|
||||
}
|
||||
|
||||
@Post()
|
||||
@ApiBearerAuth()
|
||||
@ApiOperation({ summary: '新增客服联系方式' })
|
||||
async create(@Body() body: CreateContactDto): Promise<ContactResponseDto> {
|
||||
const contactType = body.type as ContactType
|
||||
if (!Object.values(ContactType).includes(contactType)) {
|
||||
throw new BadRequestException(`Invalid type: ${body.type}. Must be WECHAT or QQ`)
|
||||
}
|
||||
if (!body.label || !body.value) {
|
||||
throw new BadRequestException('label and value are required')
|
||||
}
|
||||
if (body.sortOrder === undefined || body.sortOrder < 0) {
|
||||
throw new BadRequestException('sortOrder must be a non-negative integer')
|
||||
}
|
||||
|
||||
const contact = await this.prisma.customerServiceContact.create({
|
||||
data: {
|
||||
type: contactType,
|
||||
label: body.label,
|
||||
value: body.value,
|
||||
sortOrder: body.sortOrder,
|
||||
isEnabled: body.isEnabled ?? true,
|
||||
},
|
||||
})
|
||||
|
||||
this.logger.log(`Created customer service contact: id=${contact.id}, type=${contact.type}, label=${contact.label}`)
|
||||
return this.toDto(contact)
|
||||
}
|
||||
|
||||
@Put(':id')
|
||||
@ApiBearerAuth()
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@ApiOperation({ summary: '更新客服联系方式' })
|
||||
async update(
|
||||
@Param('id') id: string,
|
||||
@Body() body: UpdateContactDto,
|
||||
): Promise<ContactResponseDto> {
|
||||
const existing = await this.prisma.customerServiceContact.findUnique({ where: { id } })
|
||||
if (!existing) {
|
||||
throw new NotFoundException('Contact not found')
|
||||
}
|
||||
|
||||
const data: Record<string, unknown> = {}
|
||||
if (body.type !== undefined) {
|
||||
const contactType = body.type as ContactType
|
||||
if (!Object.values(ContactType).includes(contactType)) {
|
||||
throw new BadRequestException(`Invalid type: ${body.type}`)
|
||||
}
|
||||
data.type = contactType
|
||||
}
|
||||
if (body.label !== undefined) data.label = body.label
|
||||
if (body.value !== undefined) data.value = body.value
|
||||
if (body.sortOrder !== undefined) data.sortOrder = body.sortOrder
|
||||
if (body.isEnabled !== undefined) data.isEnabled = body.isEnabled
|
||||
|
||||
const updated = await this.prisma.customerServiceContact.update({
|
||||
where: { id },
|
||||
data,
|
||||
})
|
||||
|
||||
return this.toDto(updated)
|
||||
}
|
||||
|
||||
@Delete(':id')
|
||||
@ApiBearerAuth()
|
||||
@HttpCode(HttpStatus.NO_CONTENT)
|
||||
@ApiOperation({ summary: '删除客服联系方式' })
|
||||
async delete(@Param('id') id: string): Promise<void> {
|
||||
const existing = await this.prisma.customerServiceContact.findUnique({ where: { id } })
|
||||
if (!existing) {
|
||||
throw new NotFoundException('Contact not found')
|
||||
}
|
||||
await this.prisma.customerServiceContact.delete({ where: { id } })
|
||||
this.logger.log(`Deleted customer service contact: id=${id}, type=${existing.type}`)
|
||||
}
|
||||
|
||||
private toDto(contact: {
|
||||
id: string
|
||||
type: ContactType
|
||||
label: string
|
||||
value: string
|
||||
sortOrder: number
|
||||
isEnabled: boolean
|
||||
createdAt: Date
|
||||
updatedAt: Date
|
||||
}): ContactResponseDto {
|
||||
return {
|
||||
id: contact.id,
|
||||
type: contact.type,
|
||||
label: contact.label,
|
||||
value: contact.value,
|
||||
sortOrder: contact.sortOrder,
|
||||
isEnabled: contact.isEnabled,
|
||||
createdAt: contact.createdAt,
|
||||
updatedAt: contact.updatedAt,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Public Controller (移动端调用,无需认证)
|
||||
// =============================================================================
|
||||
|
||||
@ApiTags('Customer Service Contacts (Public)')
|
||||
@Controller('customer-service-contacts')
|
||||
export class PublicCustomerServiceContactController {
|
||||
constructor(private readonly prisma: PrismaService) {}
|
||||
|
||||
@Get()
|
||||
@ApiOperation({ summary: '获取已启用的客服联系方式 (移动端)' })
|
||||
async list(): Promise<ContactResponseDto[]> {
|
||||
const contacts = await this.prisma.customerServiceContact.findMany({
|
||||
where: { isEnabled: true },
|
||||
orderBy: [{ sortOrder: 'asc' }],
|
||||
})
|
||||
|
||||
return contacts.map((c) => ({
|
||||
id: c.id,
|
||||
type: c.type,
|
||||
label: c.label,
|
||||
value: c.value,
|
||||
sortOrder: c.sortOrder,
|
||||
isEnabled: c.isEnabled,
|
||||
createdAt: c.createdAt,
|
||||
updatedAt: c.updatedAt,
|
||||
}))
|
||||
}
|
||||
}
|
||||
|
|
@ -173,7 +173,7 @@ export class AdminSystemConfigController {
|
|||
* 移动端/公开系统配置控制器
|
||||
* 用于 mobile-app 获取展示相关的配置
|
||||
*/
|
||||
@Controller('api/v1/system-config')
|
||||
@Controller('system-config')
|
||||
export class PublicSystemConfigController {
|
||||
constructor(
|
||||
@Inject(SYSTEM_CONFIG_REPOSITORY)
|
||||
|
|
|
|||
|
|
@ -61,6 +61,7 @@ import { UserTagController } from './api/controllers/user-tag.controller';
|
|||
import { ClassificationRuleController } from './api/controllers/classification-rule.controller';
|
||||
import { AudienceSegmentController } from './api/controllers/audience-segment.controller';
|
||||
import { AutoTagSyncJob } from './infrastructure/jobs/auto-tag-sync.job';
|
||||
import { ContractBatchDownloadJob } from './infrastructure/jobs/contract-batch-download.job';
|
||||
// Co-Managed Wallet imports
|
||||
import { CoManagedWalletController } from './api/controllers/co-managed-wallet.controller';
|
||||
import { CoManagedWalletService } from './application/services/co-managed-wallet.service';
|
||||
|
|
@ -76,6 +77,13 @@ import { SYSTEM_MAINTENANCE_REPOSITORY } from './domain/repositories/system-main
|
|||
import { SystemMaintenanceRepositoryImpl } from './infrastructure/persistence/repositories/system-maintenance.repository.impl';
|
||||
import { AdminMaintenanceController, MobileMaintenanceController } from './api/controllers/system-maintenance.controller';
|
||||
import { MaintenanceInterceptor } from './api/interceptors/maintenance.interceptor';
|
||||
// App Asset imports
|
||||
import { AdminAppAssetController, PublicAppAssetController } from './api/controllers/app-asset.controller'
|
||||
// Customer Service Contact imports
|
||||
import { AdminCustomerServiceContactController, PublicCustomerServiceContactController } from './api/controllers/customer-service-contact.controller';
|
||||
// [2026-02-05] 新增:合同管理模块
|
||||
import { ContractController } from './api/controllers/contract.controller';
|
||||
import { ContractService } from './application/services/contract.service';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
|
|
@ -111,6 +119,14 @@ import { MaintenanceInterceptor } from './api/interceptors/maintenance.intercept
|
|||
// System Maintenance Controllers
|
||||
AdminMaintenanceController,
|
||||
MobileMaintenanceController,
|
||||
// App Asset Controllers
|
||||
AdminAppAssetController,
|
||||
PublicAppAssetController,
|
||||
// Customer Service Contact Controllers
|
||||
AdminCustomerServiceContactController,
|
||||
PublicCustomerServiceContactController,
|
||||
// [2026-02-05] 新增:合同管理控制器
|
||||
ContractController,
|
||||
],
|
||||
providers: [
|
||||
PrismaService,
|
||||
|
|
@ -176,6 +192,7 @@ import { MaintenanceInterceptor } from './api/interceptors/maintenance.intercept
|
|||
AudienceSegmentService,
|
||||
// Scheduled Jobs
|
||||
AutoTagSyncJob,
|
||||
ContractBatchDownloadJob,
|
||||
// Co-Managed Wallet
|
||||
CoManagedWalletMapper,
|
||||
CoManagedWalletService,
|
||||
|
|
@ -197,6 +214,8 @@ import { MaintenanceInterceptor } from './api/interceptors/maintenance.intercept
|
|||
provide: APP_INTERCEPTOR,
|
||||
useClass: MaintenanceInterceptor,
|
||||
},
|
||||
// [2026-02-05] 新增:合同管理服务
|
||||
ContractService,
|
||||
],
|
||||
})
|
||||
export class AppModule {}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,203 @@
|
|||
/**
|
||||
* 合同管理服务
|
||||
* [2026-02-05] 新增:调用 planting-service 内部 API 获取合同数据
|
||||
* 回滚方式:删除此文件并从 app.module.ts 中移除引用
|
||||
*/
|
||||
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import axios, { AxiosInstance } from 'axios';
|
||||
|
||||
/**
|
||||
* 合同 DTO
|
||||
*/
|
||||
export interface ContractDto {
|
||||
orderNo: string;
|
||||
contractNo: string;
|
||||
userId: string;
|
||||
accountSequence: string;
|
||||
userRealName: string | null;
|
||||
userPhoneNumber: string | null;
|
||||
treeCount: number;
|
||||
totalAmount: number;
|
||||
provinceCode: string;
|
||||
provinceName: string;
|
||||
cityCode: string;
|
||||
cityName: string;
|
||||
status: string;
|
||||
signedAt: string | null;
|
||||
signedPdfUrl: string | null;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 合同列表响应
|
||||
*/
|
||||
export interface ContractsListResponse {
|
||||
items: ContractDto[];
|
||||
total: number;
|
||||
page: number;
|
||||
pageSize: number;
|
||||
totalPages: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* 合同统计响应
|
||||
*/
|
||||
export interface ContractStatisticsResponse {
|
||||
totalContracts: number;
|
||||
signedContracts: number;
|
||||
pendingContracts: number;
|
||||
expiredContracts: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* 合同查询参数
|
||||
*/
|
||||
export interface ContractQueryParams {
|
||||
accountSequences?: string[];
|
||||
signedAfter?: string;
|
||||
signedBefore?: string;
|
||||
provinceCode?: string;
|
||||
cityCode?: string;
|
||||
status?: string;
|
||||
page?: number;
|
||||
pageSize?: number;
|
||||
orderBy?: 'signedAt' | 'createdAt';
|
||||
orderDir?: 'asc' | 'desc';
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class ContractService {
|
||||
private readonly logger = new Logger(ContractService.name);
|
||||
private readonly httpClient: AxiosInstance;
|
||||
private readonly plantingServiceUrl: string;
|
||||
|
||||
constructor(private readonly configService: ConfigService) {
|
||||
this.plantingServiceUrl = this.configService.get<string>(
|
||||
'PLANTING_SERVICE_URL',
|
||||
'http://rwa-planting-service:3003',
|
||||
);
|
||||
|
||||
this.httpClient = axios.create({
|
||||
baseURL: this.plantingServiceUrl,
|
||||
timeout: 30000,
|
||||
});
|
||||
|
||||
this.logger.log(`ContractService initialized, planting-service URL: ${this.plantingServiceUrl}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取合同列表
|
||||
*/
|
||||
async getContracts(params: ContractQueryParams): Promise<ContractsListResponse> {
|
||||
try {
|
||||
const queryParams = new URLSearchParams();
|
||||
|
||||
if (params.accountSequences?.length) {
|
||||
queryParams.append('accountSequences', params.accountSequences.join(','));
|
||||
}
|
||||
if (params.signedAfter) queryParams.append('signedAfter', params.signedAfter);
|
||||
if (params.signedBefore) queryParams.append('signedBefore', params.signedBefore);
|
||||
if (params.provinceCode) queryParams.append('provinceCode', params.provinceCode);
|
||||
if (params.cityCode) queryParams.append('cityCode', params.cityCode);
|
||||
if (params.status) queryParams.append('status', params.status);
|
||||
if (params.page) queryParams.append('page', params.page.toString());
|
||||
if (params.pageSize) queryParams.append('pageSize', params.pageSize.toString());
|
||||
if (params.orderBy) queryParams.append('orderBy', params.orderBy);
|
||||
if (params.orderDir) queryParams.append('orderDir', params.orderDir);
|
||||
|
||||
const url = `/api/v1/planting/internal/contracts?${queryParams.toString()}`;
|
||||
this.logger.debug(`[getContracts] 请求: ${url}`);
|
||||
|
||||
const response = await this.httpClient.get<ContractsListResponse>(url);
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
this.logger.error(`[getContracts] 失败: ${error.message}`);
|
||||
return {
|
||||
items: [],
|
||||
total: 0,
|
||||
page: params.page ?? 1,
|
||||
pageSize: params.pageSize ?? 50,
|
||||
totalPages: 0,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取用户的合同列表
|
||||
*/
|
||||
async getUserContracts(accountSequence: string, params?: {
|
||||
page?: number;
|
||||
pageSize?: number;
|
||||
}): Promise<ContractsListResponse> {
|
||||
return this.getContracts({
|
||||
accountSequences: [accountSequence],
|
||||
page: params?.page,
|
||||
pageSize: params?.pageSize,
|
||||
status: undefined, // 查询所有状态
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取单个合同详情
|
||||
*/
|
||||
async getContract(orderNo: string): Promise<ContractDto | null> {
|
||||
try {
|
||||
const url = `/api/v1/planting/internal/contracts/${orderNo}`;
|
||||
this.logger.debug(`[getContract] 请求: ${url}`);
|
||||
|
||||
const response = await this.httpClient.get<ContractDto>(url);
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
if (error.response?.status === 404) {
|
||||
return null;
|
||||
}
|
||||
this.logger.error(`[getContract] 失败: ${error.message}`);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 下载合同 PDF
|
||||
* @returns PDF Buffer
|
||||
*/
|
||||
async downloadContractPdf(orderNo: string): Promise<Buffer> {
|
||||
const url = `/api/v1/planting/internal/contracts/${orderNo}/pdf`;
|
||||
this.logger.debug(`[downloadContractPdf] 请求: ${url}`);
|
||||
|
||||
const response = await this.httpClient.get(url, {
|
||||
responseType: 'arraybuffer',
|
||||
});
|
||||
|
||||
return Buffer.from(response.data);
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取合同统计信息
|
||||
*/
|
||||
async getStatistics(params?: {
|
||||
provinceCode?: string;
|
||||
cityCode?: string;
|
||||
}): Promise<ContractStatisticsResponse> {
|
||||
try {
|
||||
const queryParams = new URLSearchParams();
|
||||
if (params?.provinceCode) queryParams.append('provinceCode', params.provinceCode);
|
||||
if (params?.cityCode) queryParams.append('cityCode', params.cityCode);
|
||||
|
||||
const url = `/api/v1/planting/internal/contracts/statistics?${queryParams.toString()}`;
|
||||
this.logger.debug(`[getStatistics] 请求: ${url}`);
|
||||
|
||||
const response = await this.httpClient.get<ContractStatisticsResponse>(url);
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
this.logger.error(`[getStatistics] 失败: ${error.message}`);
|
||||
return {
|
||||
totalContracts: 0,
|
||||
signedContracts: 0,
|
||||
pendingContracts: 0,
|
||||
expiredContracts: 0,
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,343 @@
|
|||
/**
|
||||
* 合同批量下载任务处理器
|
||||
* [2026-02-05] 新增:定时处理批量下载任务
|
||||
* 回滚方式:删除此文件并从 app.module.ts 中移除引用
|
||||
*/
|
||||
|
||||
import { Injectable, Logger, OnModuleInit } from '@nestjs/common';
|
||||
import { Cron } from '@nestjs/schedule';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import * as fs from 'fs/promises';
|
||||
import * as path from 'path';
|
||||
import * as archiver from 'archiver';
|
||||
import { createWriteStream, existsSync, mkdirSync } from 'fs';
|
||||
import { PrismaService } from '../persistence/prisma/prisma.service';
|
||||
import { ContractService, ContractDto } from '../../application/services/contract.service';
|
||||
|
||||
/**
|
||||
* 筛选条件类型
|
||||
*/
|
||||
interface BatchDownloadFilters {
|
||||
signedAfter?: string;
|
||||
signedBefore?: string;
|
||||
provinceCode?: string;
|
||||
cityCode?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 合同批量下载任务处理 Job
|
||||
* 每分钟检查是否有待处理的批量下载任务
|
||||
*/
|
||||
@Injectable()
|
||||
export class ContractBatchDownloadJob implements OnModuleInit {
|
||||
private readonly logger = new Logger(ContractBatchDownloadJob.name);
|
||||
private isRunning = false;
|
||||
private readonly downloadDir: string;
|
||||
private readonly baseUrl: string;
|
||||
|
||||
constructor(
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly contractService: ContractService,
|
||||
private readonly configService: ConfigService,
|
||||
) {
|
||||
this.downloadDir = this.configService.get<string>('UPLOAD_DIR') || './uploads';
|
||||
this.baseUrl = this.configService.get<string>('BASE_URL') || 'http://localhost:3005';
|
||||
}
|
||||
|
||||
onModuleInit() {
|
||||
this.logger.log('ContractBatchDownloadJob initialized');
|
||||
// 确保下载目录存在
|
||||
const contractsDir = path.join(this.downloadDir, 'contracts');
|
||||
if (!existsSync(contractsDir)) {
|
||||
mkdirSync(contractsDir, { recursive: true });
|
||||
this.logger.log(`Created contracts download directory: ${contractsDir}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 每分钟检查并处理待处理的批量下载任务
|
||||
*/
|
||||
@Cron('0 * * * * *') // 每分钟的第0秒
|
||||
async processPendingTasks(): Promise<void> {
|
||||
if (this.isRunning) {
|
||||
this.logger.debug('Batch download job is already running, skipping...');
|
||||
return;
|
||||
}
|
||||
|
||||
this.isRunning = true;
|
||||
|
||||
try {
|
||||
// 查找待处理的任务
|
||||
const pendingTask = await this.prisma.contractBatchDownloadTask.findFirst({
|
||||
where: { status: 'PENDING' },
|
||||
orderBy: { createdAt: 'asc' },
|
||||
});
|
||||
|
||||
if (!pendingTask) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.logger.log(`开始处理批量下载任务: ${pendingTask.taskNo}`);
|
||||
|
||||
// 更新状态为处理中
|
||||
await this.prisma.contractBatchDownloadTask.update({
|
||||
where: { id: pendingTask.id },
|
||||
data: {
|
||||
status: 'PROCESSING',
|
||||
startedAt: new Date(),
|
||||
},
|
||||
});
|
||||
|
||||
try {
|
||||
await this.processTask(pendingTask.id, pendingTask.taskNo, pendingTask.filters as BatchDownloadFilters);
|
||||
} catch (error) {
|
||||
this.logger.error(`任务处理失败: ${pendingTask.taskNo}`, error);
|
||||
await this.prisma.contractBatchDownloadTask.update({
|
||||
where: { id: pendingTask.id },
|
||||
data: {
|
||||
status: 'FAILED',
|
||||
errors: { message: error.message, stack: error.stack },
|
||||
completedAt: new Date(),
|
||||
},
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
this.logger.error('批量下载任务检查失败', error);
|
||||
} finally {
|
||||
this.isRunning = false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理单个批量下载任务
|
||||
*/
|
||||
private async processTask(
|
||||
taskId: bigint,
|
||||
taskNo: string,
|
||||
filters: BatchDownloadFilters | null,
|
||||
): Promise<void> {
|
||||
const errors: Array<{ orderNo: string; error: string }> = [];
|
||||
let downloadedCount = 0;
|
||||
let failedCount = 0;
|
||||
|
||||
// 1. 获取符合条件的合同列表(只获取已签署的)
|
||||
this.logger.log(`获取合同列表, 筛选条件: ${JSON.stringify(filters)}`);
|
||||
|
||||
const contractsResult = await this.contractService.getContracts({
|
||||
signedAfter: filters?.signedAfter,
|
||||
signedBefore: filters?.signedBefore,
|
||||
provinceCode: filters?.provinceCode,
|
||||
cityCode: filters?.cityCode,
|
||||
status: 'SIGNED',
|
||||
pageSize: 10000, // 最大获取1万份
|
||||
orderBy: 'signedAt',
|
||||
orderDir: 'asc',
|
||||
});
|
||||
|
||||
const contracts = contractsResult.items;
|
||||
const totalContracts = contracts.length;
|
||||
|
||||
this.logger.log(`共找到 ${totalContracts} 份已签署合同`);
|
||||
|
||||
if (totalContracts === 0) {
|
||||
// 没有合同需要下载
|
||||
await this.prisma.contractBatchDownloadTask.update({
|
||||
where: { id: taskId },
|
||||
data: {
|
||||
status: 'COMPLETED',
|
||||
totalContracts: 0,
|
||||
downloadedCount: 0,
|
||||
failedCount: 0,
|
||||
progress: 100,
|
||||
completedAt: new Date(),
|
||||
},
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// 更新总数
|
||||
await this.prisma.contractBatchDownloadTask.update({
|
||||
where: { id: taskId },
|
||||
data: { totalContracts },
|
||||
});
|
||||
|
||||
// 2. 创建临时目录
|
||||
const tempDir = path.join(this.downloadDir, 'temp', taskNo);
|
||||
if (!existsSync(tempDir)) {
|
||||
mkdirSync(tempDir, { recursive: true });
|
||||
}
|
||||
|
||||
// 3. 逐个下载合同 PDF
|
||||
for (let i = 0; i < contracts.length; i++) {
|
||||
const contract = contracts[i];
|
||||
|
||||
try {
|
||||
// 下载 PDF
|
||||
const pdfBuffer = await this.contractService.downloadContractPdf(contract.orderNo);
|
||||
|
||||
// 生成文件路径(按省市分组)
|
||||
const safeProvince = this.sanitizeFileName(contract.provinceName || '未知省份');
|
||||
const safeCity = this.sanitizeFileName(contract.cityName || '未知城市');
|
||||
const subDir = path.join(tempDir, safeProvince, safeCity);
|
||||
|
||||
if (!existsSync(subDir)) {
|
||||
mkdirSync(subDir, { recursive: true });
|
||||
}
|
||||
|
||||
// 生成文件名
|
||||
const safeRealName = this.sanitizeFileName(contract.userRealName || '未知');
|
||||
const fileName = `${contract.contractNo}_${safeRealName}_${contract.treeCount}棵.pdf`;
|
||||
const filePath = path.join(subDir, fileName);
|
||||
|
||||
// 保存文件
|
||||
await fs.writeFile(filePath, pdfBuffer);
|
||||
|
||||
downloadedCount++;
|
||||
this.logger.debug(`下载成功: ${contract.orderNo} -> ${fileName}`);
|
||||
} catch (error) {
|
||||
failedCount++;
|
||||
errors.push({ orderNo: contract.orderNo, error: error.message });
|
||||
this.logger.warn(`下载失败: ${contract.orderNo} - ${error.message}`);
|
||||
}
|
||||
|
||||
// 更新进度
|
||||
const progress = Math.floor(((i + 1) / totalContracts) * 100);
|
||||
if (progress % 10 === 0 || i === totalContracts - 1) {
|
||||
await this.prisma.contractBatchDownloadTask.update({
|
||||
where: { id: taskId },
|
||||
data: {
|
||||
downloadedCount,
|
||||
failedCount,
|
||||
progress,
|
||||
lastProcessedOrderNo: contract.orderNo,
|
||||
errors: errors.length > 0 ? errors : undefined,
|
||||
},
|
||||
});
|
||||
this.logger.log(`进度: ${progress}% (${downloadedCount}/${totalContracts})`);
|
||||
}
|
||||
}
|
||||
|
||||
// 4. 打包成 ZIP
|
||||
this.logger.log('开始打包 ZIP...');
|
||||
|
||||
const zipFileName = this.generateZipFileName(filters, downloadedCount);
|
||||
const zipDir = path.join(this.downloadDir, 'contracts');
|
||||
const zipPath = path.join(zipDir, zipFileName);
|
||||
|
||||
await this.createZipArchive(tempDir, zipPath);
|
||||
|
||||
// 获取 ZIP 文件大小
|
||||
const zipStats = await fs.stat(zipPath);
|
||||
const resultFileUrl = `${this.baseUrl}/uploads/contracts/${zipFileName}`;
|
||||
|
||||
this.logger.log(`ZIP 打包完成: ${zipFileName}, 大小: ${zipStats.size} bytes`);
|
||||
|
||||
// 5. 清理临时文件
|
||||
await this.cleanupTempDir(tempDir);
|
||||
|
||||
// 6. 更新任务状态为完成
|
||||
await this.prisma.contractBatchDownloadTask.update({
|
||||
where: { id: taskId },
|
||||
data: {
|
||||
status: 'COMPLETED',
|
||||
downloadedCount,
|
||||
failedCount,
|
||||
progress: 100,
|
||||
resultFileUrl,
|
||||
resultFileSize: BigInt(zipStats.size),
|
||||
errors: errors.length > 0 ? errors : undefined,
|
||||
completedAt: new Date(),
|
||||
expiresAt: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000), // 7天后过期
|
||||
},
|
||||
});
|
||||
|
||||
this.logger.log(`任务完成: ${taskNo}, 成功: ${downloadedCount}, 失败: ${failedCount}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成 ZIP 文件名
|
||||
*/
|
||||
private generateZipFileName(filters: BatchDownloadFilters | null, count: number): string {
|
||||
const dateStr = new Date().toISOString().slice(0, 10).replace(/-/g, '');
|
||||
let rangeStr = '';
|
||||
|
||||
if (filters?.signedAfter || filters?.signedBefore) {
|
||||
const start = filters.signedAfter
|
||||
? new Date(filters.signedAfter).toISOString().slice(0, 10).replace(/-/g, '')
|
||||
: 'all';
|
||||
const end = filters.signedBefore
|
||||
? new Date(filters.signedBefore).toISOString().slice(0, 10).replace(/-/g, '')
|
||||
: 'now';
|
||||
rangeStr = `_${start}-${end}`;
|
||||
}
|
||||
|
||||
return `contracts_${dateStr}${rangeStr}_${count}份.zip`;
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建 ZIP 压缩包
|
||||
*/
|
||||
private async createZipArchive(sourceDir: string, zipPath: string): Promise<void> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const output = createWriteStream(zipPath);
|
||||
const archive = archiver('zip', {
|
||||
zlib: { level: 6 }, // 压缩级别
|
||||
});
|
||||
|
||||
output.on('close', () => {
|
||||
this.logger.log(`ZIP 文件大小: ${archive.pointer()} bytes`);
|
||||
resolve();
|
||||
});
|
||||
|
||||
archive.on('error', (err: Error) => {
|
||||
reject(err);
|
||||
});
|
||||
|
||||
archive.pipe(output);
|
||||
|
||||
// 添加目录下所有文件
|
||||
archive.directory(sourceDir, false);
|
||||
|
||||
archive.finalize();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 清理临时目录
|
||||
*/
|
||||
private async cleanupTempDir(tempDir: string): Promise<void> {
|
||||
try {
|
||||
await fs.rm(tempDir, { recursive: true, force: true });
|
||||
this.logger.debug(`清理临时目录: ${tempDir}`);
|
||||
} catch (error) {
|
||||
this.logger.warn(`清理临时目录失败: ${tempDir}`, error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 清理文件名中的非法字符
|
||||
*/
|
||||
private sanitizeFileName(name: string): string {
|
||||
return name.replace(/[\/\\:*?"<>|]/g, '_').trim() || '未知';
|
||||
}
|
||||
|
||||
/**
|
||||
* 手动触发任务处理(供 API 调用)
|
||||
*/
|
||||
async triggerProcessing(): Promise<{ processed: boolean; taskNo?: string }> {
|
||||
if (this.isRunning) {
|
||||
return { processed: false };
|
||||
}
|
||||
|
||||
await this.processPendingTasks();
|
||||
|
||||
return { processed: true };
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取处理状态
|
||||
*/
|
||||
getProcessingStatus(): { isRunning: boolean } {
|
||||
return { isRunning: this.isRunning };
|
||||
}
|
||||
}
|
||||
|
|
@ -24,6 +24,32 @@ export class UserQueryRepositoryImpl implements IUserQueryRepository {
|
|||
const where = this.buildWhereClause(filters);
|
||||
const orderBy = this.buildOrderBy(sort);
|
||||
|
||||
// 认种筛选:UserQueryView.personalAdoptionCount 可能未同步,
|
||||
// 改为实时查询 PlantingOrderQueryView(与 getBatchUserStats 数据源一致)
|
||||
if (filters.minAdoptions !== undefined || filters.maxAdoptions !== undefined) {
|
||||
const adoptedAccounts = await this.prisma.plantingOrderQueryView.groupBy({
|
||||
by: ['accountSequence'],
|
||||
where: { status: 'MINING_ENABLED' },
|
||||
_count: { id: true },
|
||||
});
|
||||
const adoptedSeqs = new Set(adoptedAccounts.map(a => a.accountSequence));
|
||||
|
||||
if (filters.minAdoptions !== undefined && filters.minAdoptions > 0) {
|
||||
// 已认种:accountSequence 必须在有 MINING_ENABLED 订单的集合中
|
||||
where.accountSequence = {
|
||||
...(typeof where.accountSequence === 'object' ? where.accountSequence as any : {}),
|
||||
in: [...adoptedSeqs],
|
||||
};
|
||||
}
|
||||
if (filters.maxAdoptions !== undefined && filters.maxAdoptions === 0) {
|
||||
// 未认种:accountSequence 不在有 MINING_ENABLED 订单的集合中
|
||||
where.accountSequence = {
|
||||
...(typeof where.accountSequence === 'object' ? where.accountSequence as any : {}),
|
||||
notIn: [...adoptedSeqs],
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
const [items, total] = await Promise.all([
|
||||
this.prisma.userQueryView.findMany({
|
||||
where,
|
||||
|
|
@ -264,16 +290,8 @@ export class UserQueryRepositoryImpl implements IUserQueryRepository {
|
|||
where.inviterSequence = filters.hasInviter ? { not: null } : null;
|
||||
}
|
||||
|
||||
// 认种数范围
|
||||
if (filters.minAdoptions !== undefined || filters.maxAdoptions !== undefined) {
|
||||
where.personalAdoptionCount = {};
|
||||
if (filters.minAdoptions !== undefined) {
|
||||
where.personalAdoptionCount.gte = filters.minAdoptions;
|
||||
}
|
||||
if (filters.maxAdoptions !== undefined) {
|
||||
where.personalAdoptionCount.lte = filters.maxAdoptions;
|
||||
}
|
||||
}
|
||||
// 认种数范围:不再使用 personalAdoptionCount(预计算字段可能未同步),
|
||||
// 改为在 findMany 中实时查询 PlantingOrderQueryView 处理
|
||||
|
||||
// 注册时间范围
|
||||
if (filters.registeredAfter || filters.registeredBefore) {
|
||||
|
|
|
|||
|
|
@ -1,7 +1,6 @@
|
|||
-- ============================================================================
|
||||
-- auth-service 初始化 migration
|
||||
-- 合并自: 20260111000000_init, 20260111083500_allow_nullable_phone_password,
|
||||
-- 20260112110000_add_nickname_to_synced_legacy_users
|
||||
-- 合并自: 0001_init, 0002_add_transactional_idempotency
|
||||
-- ============================================================================
|
||||
|
||||
-- CreateEnum
|
||||
|
|
@ -241,3 +240,26 @@ ALTER TABLE "sms_logs" ADD CONSTRAINT "sms_logs_user_id_fkey" FOREIGN KEY ("user
|
|||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "login_logs" ADD CONSTRAINT "login_logs_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
|
||||
-- ============================================================================
|
||||
-- 事务性幂等消费支持 (从 0002_add_transactional_idempotency 合并)
|
||||
-- 用于 1.0 -> 2.0 CDC 同步的 100% exactly-once 语义
|
||||
-- ============================================================================
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "processed_cdc_events" (
|
||||
"id" BIGSERIAL NOT NULL,
|
||||
"source_topic" TEXT NOT NULL,
|
||||
"offset" BIGINT NOT NULL,
|
||||
"table_name" TEXT NOT NULL,
|
||||
"operation" TEXT NOT NULL,
|
||||
"processed_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
CONSTRAINT "processed_cdc_events_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateIndex (复合唯一索引保证幂等性)
|
||||
CREATE UNIQUE INDEX "processed_cdc_events_source_topic_offset_key" ON "processed_cdc_events"("source_topic", "offset");
|
||||
|
||||
-- CreateIndex (时间索引用于清理旧数据)
|
||||
CREATE INDEX "processed_cdc_events_processed_at_idx" ON "processed_cdc_events"("processed_at");
|
||||
|
|
|
|||
|
|
@ -0,0 +1,27 @@
|
|||
-- CreateTable
|
||||
CREATE TABLE "synced_wallet_addresses" (
|
||||
"id" BIGSERIAL NOT NULL,
|
||||
"legacy_address_id" BIGINT NOT NULL,
|
||||
"legacy_user_id" BIGINT NOT NULL,
|
||||
"chain_type" TEXT NOT NULL,
|
||||
"address" TEXT NOT NULL,
|
||||
"public_key" TEXT NOT NULL,
|
||||
"status" TEXT NOT NULL DEFAULT 'ACTIVE',
|
||||
"legacy_bound_at" TIMESTAMP(3) NOT NULL,
|
||||
"source_sequence_num" BIGINT NOT NULL,
|
||||
"synced_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
CONSTRAINT "synced_wallet_addresses_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "synced_wallet_addresses_legacy_address_id_key" ON "synced_wallet_addresses"("legacy_address_id");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "synced_wallet_addresses_legacy_user_id_chain_type_key" ON "synced_wallet_addresses"("legacy_user_id", "chain_type");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "synced_wallet_addresses_legacy_user_id_idx" ON "synced_wallet_addresses"("legacy_user_id");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "synced_wallet_addresses_chain_type_address_idx" ON "synced_wallet_addresses"("chain_type", "address");
|
||||
|
|
@ -1,25 +0,0 @@
|
|||
-- ============================================================================
|
||||
-- 添加事务性幂等消费支持
|
||||
-- 用于 1.0 -> 2.0 CDC 同步的 100% exactly-once 语义
|
||||
-- ============================================================================
|
||||
|
||||
-- 创建 processed_cdc_events 表(用于 CDC 事件幂等)
|
||||
-- 唯一键: (source_topic, offset) - Kafka topic 名称 + 消息偏移量
|
||||
-- 用于保证每个 CDC 事件只处理一次(exactly-once 语义)
|
||||
CREATE TABLE IF NOT EXISTS "processed_cdc_events" (
|
||||
"id" BIGSERIAL NOT NULL,
|
||||
"source_topic" VARCHAR(200) NOT NULL, -- Kafka topic 名称(如 cdc.identity.public.user_accounts)
|
||||
"offset" BIGINT NOT NULL, -- Kafka 消息偏移量(在 partition 内唯一)
|
||||
"table_name" VARCHAR(100) NOT NULL, -- 源表名
|
||||
"operation" VARCHAR(10) NOT NULL, -- CDC 操作类型: c(create), u(update), d(delete), r(snapshot read)
|
||||
"processed_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
CONSTRAINT "processed_cdc_events_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- 复合唯一索引:(source_topic, offset) 保证幂等性
|
||||
-- 注意:这不是数据库自增 ID,而是 Kafka 消息的唯一标识
|
||||
CREATE UNIQUE INDEX "processed_cdc_events_source_topic_offset_key" ON "processed_cdc_events"("source_topic", "offset");
|
||||
|
||||
-- 时间索引用于清理旧数据
|
||||
CREATE INDEX "processed_cdc_events_processed_at_idx" ON "processed_cdc_events"("processed_at");
|
||||
|
|
@ -0,0 +1,8 @@
|
|||
-- AlterTable
|
||||
-- 添加支付密码字段
|
||||
-- 支付密码独立于登录密码,用于交易时的二次验证
|
||||
-- 存储的是bcrypt哈希值,不是明文密码
|
||||
ALTER TABLE "users" ADD COLUMN "trade_password_hash" TEXT;
|
||||
|
||||
-- 添加注释说明该字段用途
|
||||
COMMENT ON COLUMN "users"."trade_password_hash" IS '支付密码哈希值 - 用于交易时的二次安全验证,独立于登录密码';
|
||||
|
|
@ -20,6 +20,7 @@ model User {
|
|||
// 基本信息
|
||||
phone String @unique
|
||||
passwordHash String @map("password_hash")
|
||||
tradePasswordHash String? @map("trade_password_hash") // 支付密码(独立于登录密码)
|
||||
|
||||
// 统一关联键 (跨所有服务)
|
||||
// V1: 12位 (D + 6位日期 + 5位序号), 如 D2512110008
|
||||
|
|
@ -104,6 +105,33 @@ model SyncedLegacyUser {
|
|||
@@map("synced_legacy_users")
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// CDC 同步的 1.0 钱包地址(只读)
|
||||
// ============================================================================
|
||||
|
||||
model SyncedWalletAddress {
|
||||
id BigInt @id @default(autoincrement())
|
||||
|
||||
// 1.0 钱包地址数据
|
||||
legacyAddressId BigInt @unique @map("legacy_address_id") // 1.0 的 wallet_addresses.address_id
|
||||
legacyUserId BigInt @map("legacy_user_id") // 1.0 的 wallet_addresses.user_id
|
||||
chainType String @map("chain_type") // KAVA, BSC 等
|
||||
address String // 钱包地址
|
||||
publicKey String @map("public_key") // MPC 公钥
|
||||
status String @default("ACTIVE") // ACTIVE, DELETED
|
||||
|
||||
legacyBoundAt DateTime @map("legacy_bound_at") // 1.0 绑定时间
|
||||
|
||||
// CDC 元数据
|
||||
sourceSequenceNum BigInt @map("source_sequence_num")
|
||||
syncedAt DateTime @default(now()) @map("synced_at")
|
||||
|
||||
@@unique([legacyUserId, chainType])
|
||||
@@index([legacyUserId])
|
||||
@@index([chainType, address])
|
||||
@@map("synced_wallet_addresses")
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// 刷新令牌
|
||||
// ============================================================================
|
||||
|
|
|
|||
|
|
@ -5,10 +5,12 @@ import {
|
|||
AuthController,
|
||||
SmsController,
|
||||
PasswordController,
|
||||
TradePasswordController,
|
||||
KycController,
|
||||
UserController,
|
||||
HealthController,
|
||||
AdminController,
|
||||
InternalController,
|
||||
} from './controllers';
|
||||
import { ApplicationModule } from '@/application';
|
||||
import { JwtAuthGuard } from '@/shared/guards/jwt-auth.guard';
|
||||
|
|
@ -31,10 +33,12 @@ import { JwtAuthGuard } from '@/shared/guards/jwt-auth.guard';
|
|||
AuthController,
|
||||
SmsController,
|
||||
PasswordController,
|
||||
TradePasswordController,
|
||||
KycController,
|
||||
UserController,
|
||||
HealthController,
|
||||
AdminController,
|
||||
InternalController,
|
||||
],
|
||||
providers: [JwtAuthGuard],
|
||||
})
|
||||
|
|
|
|||
|
|
@ -1,7 +1,9 @@
|
|||
export * from './auth.controller';
|
||||
export * from './sms.controller';
|
||||
export * from './password.controller';
|
||||
export * from './trade-password.controller';
|
||||
export * from './kyc.controller';
|
||||
export * from './user.controller';
|
||||
export * from './health.controller';
|
||||
export * from './admin.controller';
|
||||
export * from './internal.controller';
|
||||
|
|
|
|||
|
|
@ -0,0 +1,50 @@
|
|||
import { Controller, Get, Param, NotFoundException, Logger } from '@nestjs/common';
|
||||
import { PrismaService } from '@/infrastructure/persistence/prisma/prisma.service';
|
||||
|
||||
/**
|
||||
* 内部 API - 供 2.0 服务间调用,不需要 JWT 认证
|
||||
*/
|
||||
@Controller('internal')
|
||||
export class InternalController {
|
||||
private readonly logger = new Logger(InternalController.name);
|
||||
|
||||
constructor(private readonly prisma: PrismaService) {}
|
||||
|
||||
/**
|
||||
* 根据 accountSequence 获取用户的 Kava 地址
|
||||
* trading-service 创建卖单时调用
|
||||
*/
|
||||
@Get('users/:accountSequence/kava-address')
|
||||
async getUserKavaAddress(
|
||||
@Param('accountSequence') accountSequence: string,
|
||||
): Promise<{ kavaAddress: string }> {
|
||||
// 1. 通过 SyncedLegacyUser 查找 legacyId
|
||||
const legacyUser = await this.prisma.syncedLegacyUser.findUnique({
|
||||
where: { accountSequence },
|
||||
select: { legacyId: true },
|
||||
});
|
||||
|
||||
if (!legacyUser) {
|
||||
this.logger.warn(`[Internal] Legacy user not found: ${accountSequence}`);
|
||||
throw new NotFoundException(`用户未找到: ${accountSequence}`);
|
||||
}
|
||||
|
||||
// 2. 通过 legacyUserId + chainType 查找 KAVA 钱包地址
|
||||
const walletAddress = await this.prisma.syncedWalletAddress.findUnique({
|
||||
where: {
|
||||
legacyUserId_chainType: {
|
||||
legacyUserId: legacyUser.legacyId,
|
||||
chainType: 'KAVA',
|
||||
},
|
||||
},
|
||||
select: { address: true, status: true },
|
||||
});
|
||||
|
||||
if (!walletAddress || walletAddress.status !== 'ACTIVE') {
|
||||
this.logger.warn(`[Internal] Kava address not found for: ${accountSequence}`);
|
||||
throw new NotFoundException(`未找到 Kava 钱包地址: ${accountSequence}`);
|
||||
}
|
||||
|
||||
return { kavaAddress: walletAddress.address };
|
||||
}
|
||||
}
|
||||
|
|
@ -22,7 +22,7 @@ class ChangePasswordDto {
|
|||
newPassword: string;
|
||||
}
|
||||
|
||||
@Controller('password')
|
||||
@Controller('auth/password')
|
||||
@UseGuards(ThrottlerGuard)
|
||||
export class PasswordController {
|
||||
constructor(private readonly passwordService: PasswordService) {}
|
||||
|
|
|
|||
|
|
@ -21,7 +21,7 @@ class VerifySmsDto {
|
|||
type: 'REGISTER' | 'LOGIN' | 'RESET_PASSWORD' | 'CHANGE_PHONE';
|
||||
}
|
||||
|
||||
@Controller('sms')
|
||||
@Controller('auth/sms')
|
||||
@UseGuards(ThrottlerGuard)
|
||||
export class SmsController {
|
||||
constructor(private readonly smsService: SmsService) {}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,104 @@
|
|||
import {
|
||||
Controller,
|
||||
Post,
|
||||
Get,
|
||||
Body,
|
||||
HttpCode,
|
||||
HttpStatus,
|
||||
UseGuards,
|
||||
} from '@nestjs/common';
|
||||
import { ThrottlerGuard } from '@nestjs/throttler';
|
||||
import { TradePasswordService } from '@/application/services/trade-password.service';
|
||||
import { JwtAuthGuard } from '@/shared/guards/jwt-auth.guard';
|
||||
import { CurrentUser } from '@/shared/decorators/current-user.decorator';
|
||||
|
||||
class SetTradePasswordDto {
|
||||
loginPassword: string;
|
||||
tradePassword: string;
|
||||
}
|
||||
|
||||
class ChangeTradePasswordDto {
|
||||
oldTradePassword: string;
|
||||
newTradePassword: string;
|
||||
}
|
||||
|
||||
class VerifyTradePasswordDto {
|
||||
tradePassword: string;
|
||||
}
|
||||
|
||||
@Controller('auth/trade-password')
|
||||
@UseGuards(ThrottlerGuard)
|
||||
export class TradePasswordController {
|
||||
constructor(private readonly tradePasswordService: TradePasswordService) {}
|
||||
|
||||
/**
|
||||
* 获取支付密码状态
|
||||
* GET /trade-password/status
|
||||
*/
|
||||
@Get('status')
|
||||
@UseGuards(JwtAuthGuard)
|
||||
async getStatus(
|
||||
@CurrentUser() user: { accountSequence: string },
|
||||
): Promise<{ hasTradePassword: boolean }> {
|
||||
return this.tradePasswordService.getStatus(user.accountSequence);
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置支付密码(需要验证登录密码)
|
||||
* POST /trade-password/set
|
||||
*/
|
||||
@Post('set')
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@UseGuards(JwtAuthGuard)
|
||||
async setTradePassword(
|
||||
@CurrentUser() user: { accountSequence: string },
|
||||
@Body() dto: SetTradePasswordDto,
|
||||
): Promise<{ success: boolean }> {
|
||||
await this.tradePasswordService.setTradePassword({
|
||||
accountSequence: user.accountSequence,
|
||||
loginPassword: dto.loginPassword,
|
||||
tradePassword: dto.tradePassword,
|
||||
});
|
||||
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
/**
|
||||
* 修改支付密码
|
||||
* POST /trade-password/change
|
||||
*/
|
||||
@Post('change')
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@UseGuards(JwtAuthGuard)
|
||||
async changeTradePassword(
|
||||
@CurrentUser() user: { accountSequence: string },
|
||||
@Body() dto: ChangeTradePasswordDto,
|
||||
): Promise<{ success: boolean }> {
|
||||
await this.tradePasswordService.changeTradePassword({
|
||||
accountSequence: user.accountSequence,
|
||||
oldTradePassword: dto.oldTradePassword,
|
||||
newTradePassword: dto.newTradePassword,
|
||||
});
|
||||
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证支付密码
|
||||
* POST /trade-password/verify
|
||||
*/
|
||||
@Post('verify')
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@UseGuards(JwtAuthGuard)
|
||||
async verifyTradePassword(
|
||||
@CurrentUser() user: { accountSequence: string },
|
||||
@Body() dto: VerifyTradePasswordDto,
|
||||
): Promise<{ valid: boolean }> {
|
||||
const valid = await this.tradePasswordService.verifyTradePassword({
|
||||
accountSequence: user.accountSequence,
|
||||
tradePassword: dto.tradePassword,
|
||||
});
|
||||
|
||||
return { valid };
|
||||
}
|
||||
}
|
||||
|
|
@ -1,13 +1,15 @@
|
|||
import {
|
||||
Controller,
|
||||
Get,
|
||||
Query,
|
||||
UseGuards,
|
||||
BadRequestException,
|
||||
} from '@nestjs/common';
|
||||
import { UserService, UserProfileResult } from '@/application/services';
|
||||
import { JwtAuthGuard } from '@/shared/guards/jwt-auth.guard';
|
||||
import { CurrentUser } from '@/shared/decorators/current-user.decorator';
|
||||
|
||||
@Controller('user')
|
||||
@Controller('auth/user')
|
||||
@UseGuards(JwtAuthGuard)
|
||||
export class UserController {
|
||||
constructor(private readonly userService: UserService) {}
|
||||
|
|
@ -23,4 +25,21 @@ export class UserController {
|
|||
const result = await this.userService.getProfile(user.accountSequence);
|
||||
return { success: true, data: result };
|
||||
}
|
||||
|
||||
/**
|
||||
* 根据手机号查找用户(用于P2P转账验证)
|
||||
* GET /user/lookup?phone=13800138000
|
||||
*/
|
||||
@Get('lookup')
|
||||
async lookupByPhone(
|
||||
@Query('phone') phone: string,
|
||||
@CurrentUser() currentUser: { accountSequence: string },
|
||||
): Promise<{ success: boolean; data: { exists: boolean; nickname?: string; accountSequence?: string } }> {
|
||||
if (!phone || phone.length !== 11) {
|
||||
throw new BadRequestException('请输入有效的11位手机号');
|
||||
}
|
||||
|
||||
const result = await this.userService.lookupByPhone(phone);
|
||||
return { success: true, data: result };
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -9,7 +9,7 @@ import { InfrastructureModule } from './infrastructure/infrastructure.module';
|
|||
// 配置模块
|
||||
ConfigModule.forRoot({
|
||||
isGlobal: true,
|
||||
envFilePath: ['.env.local', '.env'],
|
||||
envFilePath: ['.env.local', '.env', '../.env'],
|
||||
}),
|
||||
|
||||
// 限流模块
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ import { ScheduleModule } from '@nestjs/schedule';
|
|||
import {
|
||||
AuthService,
|
||||
PasswordService,
|
||||
TradePasswordService,
|
||||
SmsService,
|
||||
KycService,
|
||||
UserService,
|
||||
|
|
@ -32,6 +33,7 @@ import { InfrastructureModule } from '@/infrastructure/infrastructure.module';
|
|||
providers: [
|
||||
AuthService,
|
||||
PasswordService,
|
||||
TradePasswordService,
|
||||
SmsService,
|
||||
KycService,
|
||||
UserService,
|
||||
|
|
@ -42,6 +44,7 @@ import { InfrastructureModule } from '@/infrastructure/infrastructure.module';
|
|||
exports: [
|
||||
AuthService,
|
||||
PasswordService,
|
||||
TradePasswordService,
|
||||
SmsService,
|
||||
KycService,
|
||||
UserService,
|
||||
|
|
|
|||
|
|
@ -347,7 +347,7 @@ export class AuthService {
|
|||
expiresIn,
|
||||
user: {
|
||||
accountSequence: user.accountSequence.value,
|
||||
phone: user.phone.masked,
|
||||
phone: user.phone.value,
|
||||
source: user.source,
|
||||
kycStatus: user.kycStatus,
|
||||
},
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
export * from './auth.service';
|
||||
export * from './password.service';
|
||||
export * from './trade-password.service';
|
||||
export * from './sms.service';
|
||||
export * from './kyc.service';
|
||||
export * from './user.service';
|
||||
|
|
|
|||
|
|
@ -0,0 +1,144 @@
|
|||
import { Injectable, Inject, BadRequestException, NotFoundException } from '@nestjs/common';
|
||||
import {
|
||||
USER_REPOSITORY,
|
||||
UserRepository,
|
||||
AccountSequence,
|
||||
} from '@/domain';
|
||||
|
||||
export interface SetTradePasswordDto {
|
||||
accountSequence: string;
|
||||
loginPassword: string; // 需要验证登录密码
|
||||
tradePassword: string;
|
||||
}
|
||||
|
||||
export interface ChangeTradePasswordDto {
|
||||
accountSequence: string;
|
||||
oldTradePassword: string;
|
||||
newTradePassword: string;
|
||||
}
|
||||
|
||||
export interface VerifyTradePasswordDto {
|
||||
accountSequence: string;
|
||||
tradePassword: string;
|
||||
}
|
||||
|
||||
export interface TradePasswordStatusDto {
|
||||
hasTradePassword: boolean;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class TradePasswordService {
|
||||
constructor(
|
||||
@Inject(USER_REPOSITORY)
|
||||
private readonly userRepository: UserRepository,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* 获取支付密码状态
|
||||
*/
|
||||
async getStatus(accountSequence: string): Promise<TradePasswordStatusDto> {
|
||||
const user = await this.userRepository.findByAccountSequence(
|
||||
AccountSequence.create(accountSequence),
|
||||
);
|
||||
|
||||
if (!user) {
|
||||
throw new NotFoundException('用户不存在');
|
||||
}
|
||||
|
||||
return {
|
||||
hasTradePassword: user.hasTradePassword,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置支付密码(首次设置或修改)
|
||||
* 首次设置需要验证登录密码
|
||||
*/
|
||||
async setTradePassword(dto: SetTradePasswordDto): Promise<void> {
|
||||
const user = await this.userRepository.findByAccountSequence(
|
||||
AccountSequence.create(dto.accountSequence),
|
||||
);
|
||||
|
||||
if (!user) {
|
||||
throw new NotFoundException('用户不存在');
|
||||
}
|
||||
|
||||
// 验证登录密码
|
||||
const isLoginPasswordValid = await user.verifyPassword(dto.loginPassword);
|
||||
if (!isLoginPasswordValid) {
|
||||
throw new BadRequestException('登录密码错误');
|
||||
}
|
||||
|
||||
// 支付密码不能与登录密码相同
|
||||
const isSameAsLogin = await user.verifyPassword(dto.tradePassword);
|
||||
if (isSameAsLogin) {
|
||||
throw new BadRequestException('支付密码不能与登录密码相同');
|
||||
}
|
||||
|
||||
// 验证密码格式(6位数字)
|
||||
if (!/^\d{6}$/.test(dto.tradePassword)) {
|
||||
throw new BadRequestException('支付密码必须是6位数字');
|
||||
}
|
||||
|
||||
// 设置支付密码
|
||||
await user.setTradePassword(dto.tradePassword);
|
||||
await this.userRepository.save(user);
|
||||
}
|
||||
|
||||
/**
|
||||
* 修改支付密码(需要验证旧密码)
|
||||
*/
|
||||
async changeTradePassword(dto: ChangeTradePasswordDto): Promise<void> {
|
||||
const user = await this.userRepository.findByAccountSequence(
|
||||
AccountSequence.create(dto.accountSequence),
|
||||
);
|
||||
|
||||
if (!user) {
|
||||
throw new NotFoundException('用户不存在');
|
||||
}
|
||||
|
||||
if (!user.hasTradePassword) {
|
||||
throw new BadRequestException('尚未设置支付密码');
|
||||
}
|
||||
|
||||
// 验证旧支付密码
|
||||
const isOldPasswordValid = await user.verifyTradePassword(dto.oldTradePassword);
|
||||
if (!isOldPasswordValid) {
|
||||
throw new BadRequestException('原支付密码错误');
|
||||
}
|
||||
|
||||
// 新密码不能与旧密码相同
|
||||
if (dto.oldTradePassword === dto.newTradePassword) {
|
||||
throw new BadRequestException('新密码不能与原密码相同');
|
||||
}
|
||||
|
||||
// 验证新密码格式(6位数字)
|
||||
if (!/^\d{6}$/.test(dto.newTradePassword)) {
|
||||
throw new BadRequestException('支付密码必须是6位数字');
|
||||
}
|
||||
|
||||
// 设置新支付密码
|
||||
await user.setTradePassword(dto.newTradePassword);
|
||||
await this.userRepository.save(user);
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证支付密码
|
||||
*/
|
||||
async verifyTradePassword(dto: VerifyTradePasswordDto): Promise<boolean> {
|
||||
const user = await this.userRepository.findByAccountSequence(
|
||||
AccountSequence.create(dto.accountSequence),
|
||||
);
|
||||
|
||||
if (!user) {
|
||||
throw new NotFoundException('用户不存在');
|
||||
}
|
||||
|
||||
if (!user.hasTradePassword) {
|
||||
// 未设置支付密码,视为验证通过(允许交易)
|
||||
return true;
|
||||
}
|
||||
|
||||
return user.verifyTradePassword(dto.tradePassword);
|
||||
}
|
||||
}
|
||||
|
|
@ -4,6 +4,8 @@ import {
|
|||
Phone,
|
||||
USER_REPOSITORY,
|
||||
UserRepository,
|
||||
SYNCED_LEGACY_USER_REPOSITORY,
|
||||
SyncedLegacyUserRepository,
|
||||
} from '@/domain';
|
||||
|
||||
export interface UserProfileResult {
|
||||
|
|
@ -22,6 +24,8 @@ export class UserService {
|
|||
constructor(
|
||||
@Inject(USER_REPOSITORY)
|
||||
private readonly userRepository: UserRepository,
|
||||
@Inject(SYNCED_LEGACY_USER_REPOSITORY)
|
||||
private readonly syncedLegacyUserRepository: SyncedLegacyUserRepository,
|
||||
) {}
|
||||
|
||||
/**
|
||||
|
|
@ -38,7 +42,7 @@ export class UserService {
|
|||
|
||||
return {
|
||||
accountSequence: user.accountSequence.value,
|
||||
phone: user.phone.masked,
|
||||
phone: user.phone.value,
|
||||
source: user.source,
|
||||
status: user.status,
|
||||
kycStatus: user.kycStatus,
|
||||
|
|
@ -48,6 +52,36 @@ export class UserService {
|
|||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 根据手机号查找用户(用于P2P转账验证)
|
||||
* 先查 V2 users 表,未找到再查 synced_legacy_users 表
|
||||
*/
|
||||
async lookupByPhone(phone: string): Promise<{ exists: boolean; accountSequence?: string; nickname?: string }> {
|
||||
const phoneVO = Phone.create(phone);
|
||||
|
||||
// 1. 先查 V2 用户表
|
||||
const user = await this.userRepository.findByPhone(phoneVO);
|
||||
if (user && user.status === 'ACTIVE') {
|
||||
return {
|
||||
exists: true,
|
||||
accountSequence: user.accountSequence.value,
|
||||
nickname: user.isKycVerified ? this.maskName(user.realName!) : user.phone.masked,
|
||||
};
|
||||
}
|
||||
|
||||
// 2. 查 1.0 同步用户表(未迁移的老用户)
|
||||
const legacyUser = await this.syncedLegacyUserRepository.findByPhone(phoneVO);
|
||||
if (legacyUser && legacyUser.status === 'ACTIVE' && !legacyUser.migratedToV2) {
|
||||
return {
|
||||
exists: true,
|
||||
accountSequence: legacyUser.accountSequence.value,
|
||||
nickname: legacyUser.nickname || legacyUser.phone.masked,
|
||||
};
|
||||
}
|
||||
|
||||
return { exists: false };
|
||||
}
|
||||
|
||||
/**
|
||||
* 更换手机号
|
||||
*/
|
||||
|
|
|
|||
|
|
@ -17,6 +17,7 @@ export interface UserProps {
|
|||
id?: bigint;
|
||||
phone: Phone;
|
||||
passwordHash: string;
|
||||
tradePasswordHash?: string; // 支付密码(独立于登录密码)
|
||||
accountSequence: AccountSequence;
|
||||
status: UserStatus;
|
||||
kycStatus: KycStatus;
|
||||
|
|
@ -42,6 +43,7 @@ export class UserAggregate {
|
|||
private _id?: bigint;
|
||||
private _phone: Phone;
|
||||
private _passwordHash: string;
|
||||
private _tradePasswordHash?: string; // 支付密码哈希
|
||||
private _accountSequence: AccountSequence;
|
||||
private _status: UserStatus;
|
||||
private _kycStatus: KycStatus;
|
||||
|
|
@ -63,6 +65,7 @@ export class UserAggregate {
|
|||
this._id = props.id;
|
||||
this._phone = props.phone;
|
||||
this._passwordHash = props.passwordHash;
|
||||
this._tradePasswordHash = props.tradePasswordHash;
|
||||
this._accountSequence = props.accountSequence;
|
||||
this._status = props.status;
|
||||
this._kycStatus = props.kycStatus;
|
||||
|
|
@ -120,6 +123,17 @@ export class UserAggregate {
|
|||
return this._passwordHash;
|
||||
}
|
||||
|
||||
get tradePasswordHash(): string | undefined {
|
||||
return this._tradePasswordHash;
|
||||
}
|
||||
|
||||
/**
|
||||
* 是否已设置支付密码
|
||||
*/
|
||||
get hasTradePassword(): boolean {
|
||||
return this._tradePasswordHash !== undefined && this._tradePasswordHash !== null;
|
||||
}
|
||||
|
||||
get accountSequence(): AccountSequence {
|
||||
return this._accountSequence;
|
||||
}
|
||||
|
|
@ -236,6 +250,34 @@ export class UserAggregate {
|
|||
this._updatedAt = new Date();
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置支付密码
|
||||
*/
|
||||
async setTradePassword(newPlainPassword: string): Promise<void> {
|
||||
const password = await Password.create(newPlainPassword);
|
||||
this._tradePasswordHash = password.hash;
|
||||
this._updatedAt = new Date();
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证支付密码
|
||||
*/
|
||||
async verifyTradePassword(plainPassword: string): Promise<boolean> {
|
||||
if (!this._tradePasswordHash) {
|
||||
return false;
|
||||
}
|
||||
const password = Password.fromHash(this._tradePasswordHash);
|
||||
return password.verify(plainPassword);
|
||||
}
|
||||
|
||||
/**
|
||||
* 清除支付密码
|
||||
*/
|
||||
clearTradePassword(): void {
|
||||
this._tradePasswordHash = undefined;
|
||||
this._updatedAt = new Date();
|
||||
}
|
||||
|
||||
/**
|
||||
* 记录登录成功
|
||||
*/
|
||||
|
|
@ -398,6 +440,7 @@ export class UserAggregate {
|
|||
id: this._id,
|
||||
phone: this._phone,
|
||||
passwordHash: this._passwordHash,
|
||||
tradePasswordHash: this._tradePasswordHash,
|
||||
accountSequence: this._accountSequence,
|
||||
status: this._status,
|
||||
kycStatus: this._kycStatus,
|
||||
|
|
|
|||
|
|
@ -7,7 +7,7 @@ import {
|
|||
PrismaRefreshTokenRepository,
|
||||
PrismaSmsVerificationRepository,
|
||||
} from './persistence/repositories';
|
||||
import { LegacyUserCdcConsumer } from './messaging/cdc';
|
||||
import { LegacyUserCdcConsumer, WalletAddressCdcConsumer } from './messaging/cdc';
|
||||
import { KafkaModule, KafkaProducerService } from './kafka';
|
||||
import { RedisService } from './redis';
|
||||
import {
|
||||
|
|
@ -24,6 +24,7 @@ import { ApplicationModule } from '@/application/application.module';
|
|||
providers: [
|
||||
// CDC
|
||||
LegacyUserCdcConsumer,
|
||||
WalletAddressCdcConsumer,
|
||||
|
||||
// Kafka Producer
|
||||
KafkaProducerService,
|
||||
|
|
|
|||
|
|
@ -1 +1,2 @@
|
|||
export * from './legacy-user-cdc.consumer';
|
||||
export * from './wallet-address-cdc.consumer';
|
||||
|
|
|
|||
|
|
@ -0,0 +1,243 @@
|
|||
import { Injectable, OnModuleInit, OnModuleDestroy, Logger } from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { Kafka, Consumer, EachMessagePayload } from 'kafkajs';
|
||||
import { Prisma, PrismaClient } from '@prisma/client';
|
||||
import { PrismaService } from '@/infrastructure/persistence/prisma/prisma.service';
|
||||
|
||||
/** Prisma 事务客户端类型 */
|
||||
type TransactionClient = Omit<
|
||||
PrismaClient,
|
||||
'$connect' | '$disconnect' | '$on' | '$transaction' | '$use' | '$extends'
|
||||
>;
|
||||
|
||||
/**
|
||||
* ExtractNewRecordState 转换后的消息格式
|
||||
* 字段来自 identity-service 的 wallet_addresses 表 + Debezium 元数据
|
||||
*/
|
||||
interface UnwrappedCdcWalletAddress {
|
||||
// 1.0 identity-service wallet_addresses 表字段
|
||||
address_id: number;
|
||||
user_id: number;
|
||||
chain_type: string;
|
||||
address: string;
|
||||
public_key: string;
|
||||
address_digest: string;
|
||||
mpc_signature_r: string;
|
||||
mpc_signature_s: string;
|
||||
mpc_signature_v: number;
|
||||
status: string;
|
||||
bound_at: number; // timestamp in milliseconds
|
||||
|
||||
// Debezium ExtractNewRecordState 添加的元数据字段
|
||||
__op: 'c' | 'u' | 'd' | 'r';
|
||||
__table: string;
|
||||
__source_ts_ms: number;
|
||||
__deleted?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* CDC Consumer - 消费 1.0 钱包地址变更事件
|
||||
* 监听 Debezium 发送的 CDC 事件,同步到 synced_wallet_addresses 表
|
||||
*
|
||||
* 实现事务性幂等消费(Transactional Idempotent Consumer)确保:
|
||||
* - 每个 CDC 事件只处理一次(exactly-once 语义)
|
||||
* - 幂等记录(processed_cdc_events)和业务逻辑在同一事务中执行
|
||||
* - 任何失败都会导致整个事务回滚
|
||||
*/
|
||||
@Injectable()
|
||||
export class WalletAddressCdcConsumer implements OnModuleInit, OnModuleDestroy {
|
||||
private readonly logger = new Logger(WalletAddressCdcConsumer.name);
|
||||
private kafka: Kafka;
|
||||
private consumer: Consumer;
|
||||
private isConnected = false;
|
||||
private topic: string;
|
||||
|
||||
constructor(
|
||||
private readonly configService: ConfigService,
|
||||
private readonly prisma: PrismaService,
|
||||
) {
|
||||
const brokers = this.configService.get<string>('KAFKA_BROKERS', 'localhost:9092').split(',');
|
||||
|
||||
this.kafka = new Kafka({
|
||||
clientId: 'auth-service-cdc-wallet',
|
||||
brokers,
|
||||
});
|
||||
|
||||
this.consumer = this.kafka.consumer({
|
||||
groupId: this.configService.get<string>('CDC_CONSUMER_GROUP', 'auth-service-cdc-group') + '-wallet',
|
||||
});
|
||||
|
||||
this.topic = this.configService.get<string>(
|
||||
'CDC_TOPIC_WALLET_ADDRESSES',
|
||||
'cdc.identity.public.wallet_addresses',
|
||||
);
|
||||
}
|
||||
|
||||
async onModuleInit() {
|
||||
if (this.configService.get('CDC_ENABLED', 'true') !== 'true') {
|
||||
this.logger.log('Wallet Address CDC Consumer is disabled');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await this.consumer.connect();
|
||||
this.isConnected = true;
|
||||
|
||||
await this.consumer.subscribe({ topic: this.topic, fromBeginning: true });
|
||||
|
||||
await this.consumer.run({
|
||||
eachMessage: async (payload) => {
|
||||
await this.handleMessage(payload);
|
||||
},
|
||||
});
|
||||
|
||||
this.logger.log(
|
||||
`Wallet Address CDC Consumer started, listening to topic: ${this.topic}`,
|
||||
);
|
||||
} catch (error) {
|
||||
this.logger.error('Failed to start Wallet Address CDC Consumer', error);
|
||||
}
|
||||
}
|
||||
|
||||
async onModuleDestroy() {
|
||||
if (this.isConnected) {
|
||||
await this.consumer.disconnect();
|
||||
this.logger.log('Wallet Address CDC Consumer disconnected');
|
||||
}
|
||||
}
|
||||
|
||||
private async handleMessage(payload: EachMessagePayload) {
|
||||
const { topic, partition, message } = payload;
|
||||
|
||||
if (!message.value) return;
|
||||
|
||||
const offset = BigInt(message.offset);
|
||||
const idempotencyKey = `${topic}:${offset}`;
|
||||
|
||||
try {
|
||||
const cdcEvent: UnwrappedCdcWalletAddress = JSON.parse(message.value.toString());
|
||||
const op = cdcEvent.__op;
|
||||
const tableName = cdcEvent.__table || 'wallet_addresses';
|
||||
|
||||
this.logger.log(`[CDC] Processing wallet address event: topic=${topic}, offset=${offset}, op=${op}`);
|
||||
|
||||
await this.processWithIdempotency(topic, offset, tableName, op, cdcEvent);
|
||||
|
||||
this.logger.log(`[CDC] Successfully processed wallet address event: ${idempotencyKey}`);
|
||||
} catch (error: any) {
|
||||
if (error.code === 'P2002') {
|
||||
this.logger.debug(`[CDC] Skipping duplicate wallet address event: ${idempotencyKey}`);
|
||||
return;
|
||||
}
|
||||
this.logger.error(
|
||||
`[CDC] Failed to process wallet address message from ${topic}[${partition}], offset=${offset}`,
|
||||
error,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 事务性幂等处理
|
||||
*/
|
||||
private async processWithIdempotency(
|
||||
topic: string,
|
||||
offset: bigint,
|
||||
tableName: string,
|
||||
operation: string,
|
||||
event: UnwrappedCdcWalletAddress,
|
||||
): Promise<void> {
|
||||
await this.prisma.$transaction(async (tx) => {
|
||||
// 1. 尝试插入幂等记录
|
||||
try {
|
||||
await tx.processedCdcEvent.create({
|
||||
data: {
|
||||
sourceTopic: topic,
|
||||
offset: offset,
|
||||
tableName: tableName,
|
||||
operation: operation,
|
||||
},
|
||||
});
|
||||
} catch (error: any) {
|
||||
if (error.code === 'P2002') {
|
||||
this.logger.debug(`[CDC] Wallet address event already processed: ${topic}:${offset}`);
|
||||
return;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
|
||||
// 2. 执行业务逻辑
|
||||
await this.processCdcEvent(event, offset, tx);
|
||||
}, {
|
||||
isolationLevel: Prisma.TransactionIsolationLevel.Serializable,
|
||||
timeout: 30000,
|
||||
});
|
||||
}
|
||||
|
||||
private async processCdcEvent(
|
||||
event: UnwrappedCdcWalletAddress,
|
||||
sequenceNum: bigint,
|
||||
tx: TransactionClient,
|
||||
): Promise<void> {
|
||||
const op = event.__op;
|
||||
const isDeleted = event.__deleted === 'true';
|
||||
|
||||
if (isDeleted || op === 'd') {
|
||||
await this.deleteWalletAddress(event.address_id, tx);
|
||||
return;
|
||||
}
|
||||
|
||||
switch (op) {
|
||||
case 'c':
|
||||
case 'r':
|
||||
case 'u':
|
||||
await this.upsertWalletAddress(event, sequenceNum, tx);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
private async upsertWalletAddress(
|
||||
walletAddress: UnwrappedCdcWalletAddress,
|
||||
sequenceNum: bigint,
|
||||
tx: TransactionClient,
|
||||
): Promise<void> {
|
||||
await tx.syncedWalletAddress.upsert({
|
||||
where: { legacyAddressId: BigInt(walletAddress.address_id) },
|
||||
update: {
|
||||
legacyUserId: BigInt(walletAddress.user_id),
|
||||
chainType: walletAddress.chain_type,
|
||||
address: walletAddress.address,
|
||||
publicKey: walletAddress.public_key,
|
||||
status: walletAddress.status,
|
||||
sourceSequenceNum: sequenceNum,
|
||||
syncedAt: new Date(),
|
||||
},
|
||||
create: {
|
||||
legacyAddressId: BigInt(walletAddress.address_id),
|
||||
legacyUserId: BigInt(walletAddress.user_id),
|
||||
chainType: walletAddress.chain_type,
|
||||
address: walletAddress.address,
|
||||
publicKey: walletAddress.public_key,
|
||||
status: walletAddress.status,
|
||||
legacyBoundAt: new Date(walletAddress.bound_at),
|
||||
sourceSequenceNum: sequenceNum,
|
||||
},
|
||||
});
|
||||
|
||||
this.logger.debug(
|
||||
`[CDC] Synced wallet address: addressId=${walletAddress.address_id}, chain=${walletAddress.chain_type}`,
|
||||
);
|
||||
}
|
||||
|
||||
private async deleteWalletAddress(addressId: number, tx: TransactionClient): Promise<void> {
|
||||
try {
|
||||
await tx.syncedWalletAddress.update({
|
||||
where: { legacyAddressId: BigInt(addressId) },
|
||||
data: { status: 'DELETED' },
|
||||
});
|
||||
|
||||
this.logger.debug(`[CDC] Marked wallet address as deleted: ${addressId}`);
|
||||
} catch (error) {
|
||||
this.logger.error(`[CDC] Failed to mark wallet address as deleted: ${addressId}`, error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -45,6 +45,7 @@ export class PrismaUserRepository implements UserRepository {
|
|||
const data = {
|
||||
phone: snapshot.phone.value,
|
||||
passwordHash: snapshot.passwordHash,
|
||||
tradePasswordHash: snapshot.tradePasswordHash,
|
||||
accountSequence: snapshot.accountSequence.value,
|
||||
status: snapshot.status,
|
||||
kycStatus: snapshot.kycStatus,
|
||||
|
|
@ -120,6 +121,7 @@ export class PrismaUserRepository implements UserRepository {
|
|||
id: user.id,
|
||||
phone: Phone.create(user.phone),
|
||||
passwordHash: user.passwordHash,
|
||||
tradePasswordHash: user.tradePasswordHash,
|
||||
accountSequence: AccountSequence.create(user.accountSequence),
|
||||
status: user.status as UserStatus,
|
||||
kycStatus: user.kycStatus as KycStatus,
|
||||
|
|
|
|||
|
|
@ -0,0 +1,78 @@
|
|||
// SPDX-License-Identifier: MIT
|
||||
pragma solidity 0.8.19;
|
||||
|
||||
/**
|
||||
* @title EnergyUSDT
|
||||
* @dev Fixed supply ERC-20 token - NO MINTING CAPABILITY
|
||||
* Total Supply: 10,002,000,000 (100.02 Billion) tokens with 6 decimals (matching USDT)
|
||||
*
|
||||
* IMPORTANT: This contract has NO mint function and NO way to increase supply.
|
||||
* All tokens are minted to the deployer at construction time.
|
||||
*/
|
||||
contract EnergyUSDT {
|
||||
string public constant name = "Energy USDT";
|
||||
string public constant symbol = "eUSDT";
|
||||
uint8 public constant decimals = 6;
|
||||
|
||||
// Fixed total supply: 100.02 billion tokens (10,002,000,000 * 10^6)
|
||||
uint256 public constant totalSupply = 10_002_000_000 * 10**6;
|
||||
|
||||
mapping(address => uint256) private _balances;
|
||||
mapping(address => mapping(address => uint256)) private _allowances;
|
||||
|
||||
event Transfer(address indexed from, address indexed to, uint256 value);
|
||||
event Approval(address indexed owner, address indexed spender, uint256 value);
|
||||
|
||||
/**
|
||||
* @dev Constructor - mints entire fixed supply to deployer
|
||||
* No mint function exists - supply is permanently fixed
|
||||
*/
|
||||
constructor() {
|
||||
_balances[msg.sender] = totalSupply;
|
||||
emit Transfer(address(0), msg.sender, totalSupply);
|
||||
}
|
||||
|
||||
function balanceOf(address account) public view returns (uint256) {
|
||||
return _balances[account];
|
||||
}
|
||||
|
||||
function transfer(address to, uint256 amount) public returns (bool) {
|
||||
require(to != address(0), "Transfer to zero address");
|
||||
require(_balances[msg.sender] >= amount, "Insufficient balance");
|
||||
|
||||
unchecked {
|
||||
_balances[msg.sender] -= amount;
|
||||
_balances[to] += amount;
|
||||
}
|
||||
|
||||
emit Transfer(msg.sender, to, amount);
|
||||
return true;
|
||||
}
|
||||
|
||||
function allowance(address owner, address spender) public view returns (uint256) {
|
||||
return _allowances[owner][spender];
|
||||
}
|
||||
|
||||
function approve(address spender, uint256 amount) public returns (bool) {
|
||||
require(spender != address(0), "Approve to zero address");
|
||||
_allowances[msg.sender][spender] = amount;
|
||||
emit Approval(msg.sender, spender, amount);
|
||||
return true;
|
||||
}
|
||||
|
||||
function transferFrom(address from, address to, uint256 amount) public returns (bool) {
|
||||
require(from != address(0), "Transfer from zero address");
|
||||
require(to != address(0), "Transfer to zero address");
|
||||
require(_balances[from] >= amount, "Insufficient balance");
|
||||
require(_allowances[from][msg.sender] >= amount, "Insufficient allowance");
|
||||
|
||||
unchecked {
|
||||
_balances[from] -= amount;
|
||||
_balances[to] += amount;
|
||||
_allowances[from][msg.sender] -= amount;
|
||||
}
|
||||
|
||||
emit Transfer(from, to, amount);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,81 @@
|
|||
# eUSDT (Energy USDT)
|
||||
|
||||
## 代币信息
|
||||
|
||||
| 属性 | 值 |
|
||||
|------|-----|
|
||||
| 名称 | Energy USDT |
|
||||
| 符号 | eUSDT |
|
||||
| 精度 | 6 decimals |
|
||||
| 总供应量 | 10,002,000,000 (100.02亿) |
|
||||
| 标准 | ERC-20 |
|
||||
| 部署链 | KAVA Mainnet (Chain ID: 2222) |
|
||||
|
||||
## 合约特性
|
||||
|
||||
- **固定供应量**:100.02亿代币,部署时全部铸造给部署者
|
||||
- **不可增发**:合约中没有 mint 函数,供应量永久固定
|
||||
- **不可销毁**:合约层面无销毁功能
|
||||
- **不可升级**:合约逻辑永久固定
|
||||
- **标准ERC-20**:完全兼容所有主流钱包和DEX
|
||||
|
||||
## 部署步骤
|
||||
|
||||
### 1. 安装依赖
|
||||
|
||||
```bash
|
||||
cd backend/services/blockchain-service/contracts/eUSDT
|
||||
npm install
|
||||
```
|
||||
|
||||
### 2. 编译合约
|
||||
|
||||
```bash
|
||||
node compile.mjs
|
||||
```
|
||||
|
||||
编译后会在 `build/` 目录生成:
|
||||
- `EnergyUSDT.abi` - 合约ABI
|
||||
- `EnergyUSDT.bin` - 合约字节码
|
||||
|
||||
### 3. 部署合约
|
||||
|
||||
确保部署账户有足够的 KAVA 支付 gas 费(约 0.02 KAVA)。
|
||||
|
||||
```bash
|
||||
node deploy.mjs
|
||||
```
|
||||
|
||||
## 合约函数
|
||||
|
||||
| 函数 | 说明 |
|
||||
|------|------|
|
||||
| `name()` | 返回 "Energy USDT" |
|
||||
| `symbol()` | 返回 "eUSDT" |
|
||||
| `decimals()` | 返回 6 |
|
||||
| `totalSupply()` | 返回 10,002,000,000 * 10^6 |
|
||||
| `balanceOf(address)` | 查询账户余额 |
|
||||
| `transfer(address, uint256)` | 转账 |
|
||||
| `approve(address, uint256)` | 授权额度 |
|
||||
| `transferFrom(address, address, uint256)` | 代理转账 |
|
||||
| `allowance(address, address)` | 查询授权额度 |
|
||||
|
||||
## 事件
|
||||
|
||||
| 事件 | 说明 |
|
||||
|------|------|
|
||||
| `Transfer(from, to, value)` | 转账事件 |
|
||||
| `Approval(owner, spender, value)` | 授权事件 |
|
||||
|
||||
## 部署信息
|
||||
|
||||
| 网络 | 合约地址 | 区块浏览器 |
|
||||
|------|---------|-----------|
|
||||
| KAVA Mainnet | `0x7C3275D808eFbAE90C06C7E3A9AfDdcAa8563931` | https://kavascan.com/address/0x7C3275D808eFbAE90C06C7E3A9AfDdcAa8563931 |
|
||||
|
||||
**部署详情:**
|
||||
- 部署者/代币拥有者:`0x4F7E78d6B7C5FC502Ec7039848690f08c8970F1E`
|
||||
- 私钥:`0x886ea4cffe76c386fecf3ff321ac9ae913737c46c17bc6ce2413752144668a2a`
|
||||
- 初始持有量:10,002,000,000 eUSDT(全部代币)
|
||||
- 交易哈希:`0x5bebaa4a35378438ba5c891972024a1766935d2e01397a33502aa99e956a6b19`
|
||||
- 部署时间:2026-01-19
|
||||
|
|
@ -0,0 +1,51 @@
|
|||
import solc from 'solc';
|
||||
import fs from 'fs';
|
||||
|
||||
const source = fs.readFileSync('EnergyUSDT.sol', 'utf8');
|
||||
|
||||
const input = {
|
||||
language: 'Solidity',
|
||||
sources: {
|
||||
'EnergyUSDT.sol': {
|
||||
content: source
|
||||
}
|
||||
},
|
||||
settings: {
|
||||
optimizer: {
|
||||
enabled: true,
|
||||
runs: 200
|
||||
},
|
||||
evmVersion: 'paris', // Use paris to avoid PUSH0
|
||||
outputSelection: {
|
||||
'*': {
|
||||
'*': ['abi', 'evm.bytecode']
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const output = JSON.parse(solc.compile(JSON.stringify(input)));
|
||||
|
||||
if (output.errors) {
|
||||
output.errors.forEach(err => {
|
||||
console.log(err.formattedMessage);
|
||||
});
|
||||
|
||||
// Check for actual errors (not just warnings)
|
||||
const hasErrors = output.errors.some(err => err.severity === 'error');
|
||||
if (hasErrors) {
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
const contract = output.contracts['EnergyUSDT.sol']['EnergyUSDT'];
|
||||
const bytecode = contract.evm.bytecode.object;
|
||||
const abi = contract.abi;
|
||||
|
||||
fs.mkdirSync('build', { recursive: true });
|
||||
fs.writeFileSync('build/EnergyUSDT.bin', bytecode);
|
||||
fs.writeFileSync('build/EnergyUSDT.abi', JSON.stringify(abi, null, 2));
|
||||
|
||||
console.log('Compiled successfully!');
|
||||
console.log('Bytecode length:', bytecode.length);
|
||||
console.log('ABI functions:', abi.filter(x => x.type === 'function').map(x => x.name).join(', '));
|
||||
|
|
@ -0,0 +1,86 @@
|
|||
import { ethers } from 'ethers';
|
||||
import fs from 'fs';
|
||||
|
||||
// Same deployer account as dUSDT
|
||||
const PRIVATE_KEY = '0x886ea4cffe76c386fecf3ff321ac9ae913737c46c17bc6ce2413752144668a2a';
|
||||
const RPC_URL = 'https://evm.kava.io';
|
||||
|
||||
// Contract bytecode
|
||||
const BYTECODE = '0x' + fs.readFileSync('build/EnergyUSDT.bin', 'utf8');
|
||||
const ABI = JSON.parse(fs.readFileSync('build/EnergyUSDT.abi', 'utf8'));
|
||||
|
||||
async function deploy() {
|
||||
// Connect to Kava mainnet
|
||||
const provider = new ethers.JsonRpcProvider(RPC_URL);
|
||||
const wallet = new ethers.Wallet(PRIVATE_KEY, provider);
|
||||
|
||||
console.log('Deployer address:', wallet.address);
|
||||
|
||||
// Check balance
|
||||
const balance = await provider.getBalance(wallet.address);
|
||||
console.log('Balance:', ethers.formatEther(balance), 'KAVA');
|
||||
|
||||
if (parseFloat(ethers.formatEther(balance)) < 0.01) {
|
||||
console.error('Insufficient KAVA balance for deployment!');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// Get network info
|
||||
const network = await provider.getNetwork();
|
||||
console.log('Chain ID:', network.chainId.toString());
|
||||
|
||||
// Create contract factory
|
||||
const factory = new ethers.ContractFactory(ABI, BYTECODE, wallet);
|
||||
|
||||
console.log('Deploying EnergyUSDT (eUSDT) contract...');
|
||||
|
||||
// Deploy
|
||||
const contract = await factory.deploy();
|
||||
console.log('Transaction hash:', contract.deploymentTransaction().hash);
|
||||
|
||||
// Wait for deployment
|
||||
console.log('Waiting for confirmation...');
|
||||
await contract.waitForDeployment();
|
||||
|
||||
const contractAddress = await contract.getAddress();
|
||||
console.log('Contract deployed at:', contractAddress);
|
||||
|
||||
// Verify deployment
|
||||
console.log('\nVerifying deployment...');
|
||||
const name = await contract.name();
|
||||
const symbol = await contract.symbol();
|
||||
const decimals = await contract.decimals();
|
||||
const totalSupply = await contract.totalSupply();
|
||||
const ownerBalance = await contract.balanceOf(wallet.address);
|
||||
|
||||
console.log('Token name:', name);
|
||||
console.log('Token symbol:', symbol);
|
||||
console.log('Decimals:', decimals.toString());
|
||||
console.log('Total supply:', ethers.formatUnits(totalSupply, 6), 'eUSDT');
|
||||
console.log('Owner balance:', ethers.formatUnits(ownerBalance, 6), 'eUSDT');
|
||||
|
||||
console.log('\n=== DEPLOYMENT COMPLETE ===');
|
||||
console.log('Contract Address:', contractAddress);
|
||||
console.log('Explorer:', `https://kavascan.com/address/${contractAddress}`);
|
||||
|
||||
// Save deployment info
|
||||
const deploymentInfo = {
|
||||
network: 'KAVA Mainnet',
|
||||
chainId: 2222,
|
||||
contractAddress,
|
||||
deployer: wallet.address,
|
||||
transactionHash: contract.deploymentTransaction().hash,
|
||||
deployedAt: new Date().toISOString(),
|
||||
token: {
|
||||
name,
|
||||
symbol,
|
||||
decimals: decimals.toString(),
|
||||
totalSupply: totalSupply.toString()
|
||||
}
|
||||
};
|
||||
|
||||
fs.writeFileSync('deployment.json', JSON.stringify(deploymentInfo, null, 2));
|
||||
console.log('\nDeployment info saved to deployment.json');
|
||||
}
|
||||
|
||||
deploy().catch(console.error);
|
||||
|
|
@ -0,0 +1,14 @@
|
|||
{
|
||||
"network": "KAVA Mainnet",
|
||||
"chainId": 2222,
|
||||
"contractAddress": "0x7C3275D808eFbAE90C06C7E3A9AfDdcAa8563931",
|
||||
"deployer": "0x4F7E78d6B7C5FC502Ec7039848690f08c8970F1E",
|
||||
"transactionHash": "0x5bebaa4a35378438ba5c891972024a1766935d2e01397a33502aa99e956a6b19",
|
||||
"deployedAt": "2026-01-19T13:25:28.071Z",
|
||||
"token": {
|
||||
"name": "Energy USDT",
|
||||
"symbol": "eUSDT",
|
||||
"decimals": "6",
|
||||
"totalSupply": "10002000000000000"
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,222 @@
|
|||
{
|
||||
"name": "eusdt-contract",
|
||||
"version": "1.0.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "eusdt-contract",
|
||||
"version": "1.0.0",
|
||||
"dependencies": {
|
||||
"ethers": "^6.9.0",
|
||||
"solc": "^0.8.19"
|
||||
}
|
||||
},
|
||||
"node_modules/@adraffy/ens-normalize": {
|
||||
"version": "1.10.1",
|
||||
"resolved": "https://registry.npmjs.org/@adraffy/ens-normalize/-/ens-normalize-1.10.1.tgz",
|
||||
"integrity": "sha512-96Z2IP3mYmF1Xg2cDm8f1gWGf/HUVedQ3FMifV4kG/PQ4yEP51xDtRAEfhVNt5f/uzpNkZHwWQuUcu6D6K+Ekw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@noble/curves": {
|
||||
"version": "1.2.0",
|
||||
"resolved": "https://registry.npmjs.org/@noble/curves/-/curves-1.2.0.tgz",
|
||||
"integrity": "sha512-oYclrNgRaM9SsBUBVbb8M6DTV7ZHRTKugureoYEncY5c65HOmRzvSiTE3y5CYaPYJA/GVkrhXEoF0M3Ya9PMnw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@noble/hashes": "1.3.2"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://paulmillr.com/funding/"
|
||||
}
|
||||
},
|
||||
"node_modules/@noble/hashes": {
|
||||
"version": "1.3.2",
|
||||
"resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.3.2.tgz",
|
||||
"integrity": "sha512-MVC8EAQp7MvEcm30KWENFjgR+Mkmf+D189XJTkFIlwohU5hcBbn1ZkKq7KVTi2Hme3PMGF390DaL52beVrIihQ==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 16"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://paulmillr.com/funding/"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/node": {
|
||||
"version": "22.7.5",
|
||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-22.7.5.tgz",
|
||||
"integrity": "sha512-jML7s2NAzMWc//QSJ1a3prpk78cOPchGvXJsC3C6R6PSMoooztvRVQEz89gmBTBY1SPMaqo5teB4uNHPdetShQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"undici-types": "~6.19.2"
|
||||
}
|
||||
},
|
||||
"node_modules/aes-js": {
|
||||
"version": "4.0.0-beta.5",
|
||||
"resolved": "https://registry.npmjs.org/aes-js/-/aes-js-4.0.0-beta.5.tgz",
|
||||
"integrity": "sha512-G965FqalsNyrPqgEGON7nIx1e/OVENSgiEIzyC63haUMuvNnwIgIjMs52hlTCKhkBny7A2ORNlfY9Zu+jmGk1Q==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/command-exists": {
|
||||
"version": "1.2.9",
|
||||
"resolved": "https://registry.npmjs.org/command-exists/-/command-exists-1.2.9.tgz",
|
||||
"integrity": "sha512-LTQ/SGc+s0Xc0Fu5WaKnR0YiygZkm9eKFvyS+fRsU7/ZWFF8ykFM6Pc9aCVf1+xasOOZpO3BAVgVrKvsqKHV7w==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/commander": {
|
||||
"version": "8.3.0",
|
||||
"resolved": "https://registry.npmjs.org/commander/-/commander-8.3.0.tgz",
|
||||
"integrity": "sha512-OkTL9umf+He2DZkUq8f8J9of7yL6RJKI24dVITBmNfZBmri9zYZQrKkuXiKhyfPSu8tUhnVBB1iKXevvnlR4Ww==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 12"
|
||||
}
|
||||
},
|
||||
"node_modules/ethers": {
|
||||
"version": "6.16.0",
|
||||
"resolved": "https://registry.npmjs.org/ethers/-/ethers-6.16.0.tgz",
|
||||
"integrity": "sha512-U1wulmetNymijEhpSEQ7Ct/P/Jw9/e7R1j5XIbPRydgV2DjLVMsULDlNksq3RQnFgKoLlZf88ijYtWEXcPa07A==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "individual",
|
||||
"url": "https://github.com/sponsors/ethers-io/"
|
||||
},
|
||||
{
|
||||
"type": "individual",
|
||||
"url": "https://www.buymeacoffee.com/ricmoo"
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@adraffy/ens-normalize": "1.10.1",
|
||||
"@noble/curves": "1.2.0",
|
||||
"@noble/hashes": "1.3.2",
|
||||
"@types/node": "22.7.5",
|
||||
"aes-js": "4.0.0-beta.5",
|
||||
"tslib": "2.7.0",
|
||||
"ws": "8.17.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=14.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/follow-redirects": {
|
||||
"version": "1.15.11",
|
||||
"resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.11.tgz",
|
||||
"integrity": "sha512-deG2P0JfjrTxl50XGCDyfI97ZGVCxIpfKYmfyrQ54n5FO/0gfIES8C/Psl6kWVDolizcaaxZJnTS0QSMxvnsBQ==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "individual",
|
||||
"url": "https://github.com/sponsors/RubenVerborgh"
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=4.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"debug": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/js-sha3": {
|
||||
"version": "0.8.0",
|
||||
"resolved": "https://registry.npmjs.org/js-sha3/-/js-sha3-0.8.0.tgz",
|
||||
"integrity": "sha512-gF1cRrHhIzNfToc802P800N8PpXS+evLLXfsVpowqmAFR9uwbi89WvXg2QspOmXL8QL86J4T1EpFu+yUkwJY3Q==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/memorystream": {
|
||||
"version": "0.3.1",
|
||||
"resolved": "https://registry.npmjs.org/memorystream/-/memorystream-0.3.1.tgz",
|
||||
"integrity": "sha512-S3UwM3yj5mtUSEfP41UZmt/0SCoVYUcU1rkXv+BQ5Ig8ndL4sPoJNBUJERafdPb5jjHJGuMgytgKvKIf58XNBw==",
|
||||
"engines": {
|
||||
"node": ">= 0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/os-tmpdir": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/os-tmpdir/-/os-tmpdir-1.0.2.tgz",
|
||||
"integrity": "sha512-D2FR03Vir7FIu45XBY20mTb+/ZSWB00sjU9jdQXt83gDrI4Ztz5Fs7/yy74g2N5SVQY4xY1qDr4rNddwYRVX0g==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/semver": {
|
||||
"version": "5.7.2",
|
||||
"resolved": "https://registry.npmjs.org/semver/-/semver-5.7.2.tgz",
|
||||
"integrity": "sha512-cBznnQ9KjJqU67B52RMC65CMarK2600WFnbkcaiwWq3xy/5haFJlshgnpjovMVJ+Hff49d8GEn0b87C5pDQ10g==",
|
||||
"license": "ISC",
|
||||
"bin": {
|
||||
"semver": "bin/semver"
|
||||
}
|
||||
},
|
||||
"node_modules/solc": {
|
||||
"version": "0.8.19",
|
||||
"resolved": "https://registry.npmjs.org/solc/-/solc-0.8.19.tgz",
|
||||
"integrity": "sha512-yqurS3wzC4LdEvmMobODXqprV4MYJcVtinuxgrp61ac8K2zz40vXA0eSAskSHPgv8dQo7Nux39i3QBsHx4pqyA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"command-exists": "^1.2.8",
|
||||
"commander": "^8.1.0",
|
||||
"follow-redirects": "^1.12.1",
|
||||
"js-sha3": "0.8.0",
|
||||
"memorystream": "^0.3.1",
|
||||
"semver": "^5.5.0",
|
||||
"tmp": "0.0.33"
|
||||
},
|
||||
"bin": {
|
||||
"solcjs": "solc.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=10.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/tmp": {
|
||||
"version": "0.0.33",
|
||||
"resolved": "https://registry.npmjs.org/tmp/-/tmp-0.0.33.tgz",
|
||||
"integrity": "sha512-jRCJlojKnZ3addtTOjdIqoRuPEKBvNXcGYqzO6zWZX8KfKEpnGY5jfggJQ3EjKuu8D4bJRr0y+cYJFmYbImXGw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"os-tmpdir": "~1.0.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=0.6.0"
|
||||
}
|
||||
},
|
||||
"node_modules/tslib": {
|
||||
"version": "2.7.0",
|
||||
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.7.0.tgz",
|
||||
"integrity": "sha512-gLXCKdN1/j47AiHiOkJN69hJmcbGTHI0ImLmbYLHykhgeN0jVGola9yVjFgzCUklsZQMW55o+dW7IXv3RCXDzA==",
|
||||
"license": "0BSD"
|
||||
},
|
||||
"node_modules/undici-types": {
|
||||
"version": "6.19.8",
|
||||
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.19.8.tgz",
|
||||
"integrity": "sha512-ve2KP6f/JnbPBFyobGHuerC9g1FYGn/F8n1LWTwNxCEzd6IfqTwUQcNXgEtmmQ6DlRrC1hrSrBnCZPokRrDHjw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/ws": {
|
||||
"version": "8.17.1",
|
||||
"resolved": "https://registry.npmjs.org/ws/-/ws-8.17.1.tgz",
|
||||
"integrity": "sha512-6XQFvXTkbfUOZOKKILFG1PDK2NDQs4azKQl26T0YS5CxqWLgXajbPZ+h4gZekJyRqFU8pvnbAbbs/3TgRPy+GQ==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=10.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"bufferutil": "^4.0.1",
|
||||
"utf-8-validate": ">=5.0.2"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"bufferutil": {
|
||||
"optional": true
|
||||
},
|
||||
"utf-8-validate": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,14 @@
|
|||
{
|
||||
"name": "eusdt-contract",
|
||||
"version": "1.0.0",
|
||||
"type": "module",
|
||||
"description": "Energy USDT (eUSDT) ERC-20 Token Contract",
|
||||
"scripts": {
|
||||
"compile": "node compile.mjs",
|
||||
"deploy": "node deploy.mjs"
|
||||
},
|
||||
"dependencies": {
|
||||
"ethers": "^6.9.0",
|
||||
"solc": "^0.8.19"
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,78 @@
|
|||
// SPDX-License-Identifier: MIT
|
||||
pragma solidity 0.8.19;
|
||||
|
||||
/**
|
||||
* @title FutureUSDT
|
||||
* @dev Fixed supply ERC-20 token - NO MINTING CAPABILITY
|
||||
* Total Supply: 1,000,000,000,000 (1 Trillion) tokens with 6 decimals (matching USDT)
|
||||
*
|
||||
* IMPORTANT: This contract has NO mint function and NO way to increase supply.
|
||||
* All tokens are minted to the deployer at construction time.
|
||||
*/
|
||||
contract FutureUSDT {
|
||||
string public constant name = "Future USDT";
|
||||
string public constant symbol = "fUSDT";
|
||||
uint8 public constant decimals = 6;
|
||||
|
||||
// Fixed total supply: 1 trillion tokens (1,000,000,000,000 * 10^6)
|
||||
uint256 public constant totalSupply = 1_000_000_000_000 * 10**6;
|
||||
|
||||
mapping(address => uint256) private _balances;
|
||||
mapping(address => mapping(address => uint256)) private _allowances;
|
||||
|
||||
event Transfer(address indexed from, address indexed to, uint256 value);
|
||||
event Approval(address indexed owner, address indexed spender, uint256 value);
|
||||
|
||||
/**
|
||||
* @dev Constructor - mints entire fixed supply to deployer
|
||||
* No mint function exists - supply is permanently fixed
|
||||
*/
|
||||
constructor() {
|
||||
_balances[msg.sender] = totalSupply;
|
||||
emit Transfer(address(0), msg.sender, totalSupply);
|
||||
}
|
||||
|
||||
function balanceOf(address account) public view returns (uint256) {
|
||||
return _balances[account];
|
||||
}
|
||||
|
||||
function transfer(address to, uint256 amount) public returns (bool) {
|
||||
require(to != address(0), "Transfer to zero address");
|
||||
require(_balances[msg.sender] >= amount, "Insufficient balance");
|
||||
|
||||
unchecked {
|
||||
_balances[msg.sender] -= amount;
|
||||
_balances[to] += amount;
|
||||
}
|
||||
|
||||
emit Transfer(msg.sender, to, amount);
|
||||
return true;
|
||||
}
|
||||
|
||||
function allowance(address owner, address spender) public view returns (uint256) {
|
||||
return _allowances[owner][spender];
|
||||
}
|
||||
|
||||
function approve(address spender, uint256 amount) public returns (bool) {
|
||||
require(spender != address(0), "Approve to zero address");
|
||||
_allowances[msg.sender][spender] = amount;
|
||||
emit Approval(msg.sender, spender, amount);
|
||||
return true;
|
||||
}
|
||||
|
||||
function transferFrom(address from, address to, uint256 amount) public returns (bool) {
|
||||
require(from != address(0), "Transfer from zero address");
|
||||
require(to != address(0), "Transfer to zero address");
|
||||
require(_balances[from] >= amount, "Insufficient balance");
|
||||
require(_allowances[from][msg.sender] >= amount, "Insufficient allowance");
|
||||
|
||||
unchecked {
|
||||
_balances[from] -= amount;
|
||||
_balances[to] += amount;
|
||||
_allowances[from][msg.sender] -= amount;
|
||||
}
|
||||
|
||||
emit Transfer(from, to, amount);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,81 @@
|
|||
# fUSDT (Future USDT)
|
||||
|
||||
## 代币信息
|
||||
|
||||
| 属性 | 值 |
|
||||
|------|-----|
|
||||
| 名称 | Future USDT |
|
||||
| 符号 | fUSDT |
|
||||
| 精度 | 6 decimals |
|
||||
| 总供应量 | 1,000,000,000,000 (1万亿) |
|
||||
| 标准 | ERC-20 |
|
||||
| 部署链 | KAVA Mainnet (Chain ID: 2222) |
|
||||
|
||||
## 合约特性
|
||||
|
||||
- **固定供应量**:1万亿代币,部署时全部铸造给部署者
|
||||
- **不可增发**:合约中没有 mint 函数,供应量永久固定
|
||||
- **不可销毁**:合约层面无销毁功能
|
||||
- **不可升级**:合约逻辑永久固定
|
||||
- **标准ERC-20**:完全兼容所有主流钱包和DEX
|
||||
|
||||
## 部署步骤
|
||||
|
||||
### 1. 安装依赖
|
||||
|
||||
```bash
|
||||
cd backend/services/blockchain-service/contracts/fUSDT
|
||||
npm install
|
||||
```
|
||||
|
||||
### 2. 编译合约
|
||||
|
||||
```bash
|
||||
node compile.mjs
|
||||
```
|
||||
|
||||
编译后会在 `build/` 目录生成:
|
||||
- `FutureUSDT.abi` - 合约ABI
|
||||
- `FutureUSDT.bin` - 合约字节码
|
||||
|
||||
### 3. 部署合约
|
||||
|
||||
确保部署账户有足够的 KAVA 支付 gas 费(约 0.02 KAVA)。
|
||||
|
||||
```bash
|
||||
node deploy.mjs
|
||||
```
|
||||
|
||||
## 合约函数
|
||||
|
||||
| 函数 | 说明 |
|
||||
|------|------|
|
||||
| `name()` | 返回 "Future USDT" |
|
||||
| `symbol()` | 返回 "fUSDT" |
|
||||
| `decimals()` | 返回 6 |
|
||||
| `totalSupply()` | 返回 1,000,000,000,000 * 10^6 |
|
||||
| `balanceOf(address)` | 查询账户余额 |
|
||||
| `transfer(address, uint256)` | 转账 |
|
||||
| `approve(address, uint256)` | 授权额度 |
|
||||
| `transferFrom(address, address, uint256)` | 代理转账 |
|
||||
| `allowance(address, address)` | 查询授权额度 |
|
||||
|
||||
## 事件
|
||||
|
||||
| 事件 | 说明 |
|
||||
|------|------|
|
||||
| `Transfer(from, to, value)` | 转账事件 |
|
||||
| `Approval(owner, spender, value)` | 授权事件 |
|
||||
|
||||
## 部署信息
|
||||
|
||||
| 网络 | 合约地址 | 区块浏览器 |
|
||||
|------|---------|-----------|
|
||||
| KAVA Mainnet | `0x14dc4f7d3E4197438d058C3D156dd9826A161134` | https://kavascan.com/address/0x14dc4f7d3E4197438d058C3D156dd9826A161134 |
|
||||
|
||||
**部署详情:**
|
||||
- 部署者/代币拥有者:`0x4F7E78d6B7C5FC502Ec7039848690f08c8970F1E`
|
||||
- 私钥:`0x886ea4cffe76c386fecf3ff321ac9ae913737c46c17bc6ce2413752144668a2a`
|
||||
- 初始持有量:1,000,000,000,000 fUSDT(全部代币)
|
||||
- 交易哈希:`0x071f535971bc3a134dd26c182b6f05c53f0c3783e91fe6ef471d6c914e4cdb06`
|
||||
- 部署时间:2026-01-19
|
||||
|
|
@ -0,0 +1,51 @@
|
|||
import solc from 'solc';
|
||||
import fs from 'fs';
|
||||
|
||||
const source = fs.readFileSync('FutureUSDT.sol', 'utf8');
|
||||
|
||||
const input = {
|
||||
language: 'Solidity',
|
||||
sources: {
|
||||
'FutureUSDT.sol': {
|
||||
content: source
|
||||
}
|
||||
},
|
||||
settings: {
|
||||
optimizer: {
|
||||
enabled: true,
|
||||
runs: 200
|
||||
},
|
||||
evmVersion: 'paris', // Use paris to avoid PUSH0
|
||||
outputSelection: {
|
||||
'*': {
|
||||
'*': ['abi', 'evm.bytecode']
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const output = JSON.parse(solc.compile(JSON.stringify(input)));
|
||||
|
||||
if (output.errors) {
|
||||
output.errors.forEach(err => {
|
||||
console.log(err.formattedMessage);
|
||||
});
|
||||
|
||||
// Check for actual errors (not just warnings)
|
||||
const hasErrors = output.errors.some(err => err.severity === 'error');
|
||||
if (hasErrors) {
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
const contract = output.contracts['FutureUSDT.sol']['FutureUSDT'];
|
||||
const bytecode = contract.evm.bytecode.object;
|
||||
const abi = contract.abi;
|
||||
|
||||
fs.mkdirSync('build', { recursive: true });
|
||||
fs.writeFileSync('build/FutureUSDT.bin', bytecode);
|
||||
fs.writeFileSync('build/FutureUSDT.abi', JSON.stringify(abi, null, 2));
|
||||
|
||||
console.log('Compiled successfully!');
|
||||
console.log('Bytecode length:', bytecode.length);
|
||||
console.log('ABI functions:', abi.filter(x => x.type === 'function').map(x => x.name).join(', '));
|
||||
|
|
@ -0,0 +1,86 @@
|
|||
import { ethers } from 'ethers';
|
||||
import fs from 'fs';
|
||||
|
||||
// Same deployer account as dUSDT
|
||||
const PRIVATE_KEY = '0x886ea4cffe76c386fecf3ff321ac9ae913737c46c17bc6ce2413752144668a2a';
|
||||
const RPC_URL = 'https://evm.kava.io';
|
||||
|
||||
// Contract bytecode
|
||||
const BYTECODE = '0x' + fs.readFileSync('build/FutureUSDT.bin', 'utf8');
|
||||
const ABI = JSON.parse(fs.readFileSync('build/FutureUSDT.abi', 'utf8'));
|
||||
|
||||
async function deploy() {
|
||||
// Connect to Kava mainnet
|
||||
const provider = new ethers.JsonRpcProvider(RPC_URL);
|
||||
const wallet = new ethers.Wallet(PRIVATE_KEY, provider);
|
||||
|
||||
console.log('Deployer address:', wallet.address);
|
||||
|
||||
// Check balance
|
||||
const balance = await provider.getBalance(wallet.address);
|
||||
console.log('Balance:', ethers.formatEther(balance), 'KAVA');
|
||||
|
||||
if (parseFloat(ethers.formatEther(balance)) < 0.01) {
|
||||
console.error('Insufficient KAVA balance for deployment!');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// Get network info
|
||||
const network = await provider.getNetwork();
|
||||
console.log('Chain ID:', network.chainId.toString());
|
||||
|
||||
// Create contract factory
|
||||
const factory = new ethers.ContractFactory(ABI, BYTECODE, wallet);
|
||||
|
||||
console.log('Deploying FutureUSDT (fUSDT) contract...');
|
||||
|
||||
// Deploy
|
||||
const contract = await factory.deploy();
|
||||
console.log('Transaction hash:', contract.deploymentTransaction().hash);
|
||||
|
||||
// Wait for deployment
|
||||
console.log('Waiting for confirmation...');
|
||||
await contract.waitForDeployment();
|
||||
|
||||
const contractAddress = await contract.getAddress();
|
||||
console.log('Contract deployed at:', contractAddress);
|
||||
|
||||
// Verify deployment
|
||||
console.log('\nVerifying deployment...');
|
||||
const name = await contract.name();
|
||||
const symbol = await contract.symbol();
|
||||
const decimals = await contract.decimals();
|
||||
const totalSupply = await contract.totalSupply();
|
||||
const ownerBalance = await contract.balanceOf(wallet.address);
|
||||
|
||||
console.log('Token name:', name);
|
||||
console.log('Token symbol:', symbol);
|
||||
console.log('Decimals:', decimals.toString());
|
||||
console.log('Total supply:', ethers.formatUnits(totalSupply, 6), 'fUSDT');
|
||||
console.log('Owner balance:', ethers.formatUnits(ownerBalance, 6), 'fUSDT');
|
||||
|
||||
console.log('\n=== DEPLOYMENT COMPLETE ===');
|
||||
console.log('Contract Address:', contractAddress);
|
||||
console.log('Explorer:', `https://kavascan.com/address/${contractAddress}`);
|
||||
|
||||
// Save deployment info
|
||||
const deploymentInfo = {
|
||||
network: 'KAVA Mainnet',
|
||||
chainId: 2222,
|
||||
contractAddress,
|
||||
deployer: wallet.address,
|
||||
transactionHash: contract.deploymentTransaction().hash,
|
||||
deployedAt: new Date().toISOString(),
|
||||
token: {
|
||||
name,
|
||||
symbol,
|
||||
decimals: decimals.toString(),
|
||||
totalSupply: totalSupply.toString()
|
||||
}
|
||||
};
|
||||
|
||||
fs.writeFileSync('deployment.json', JSON.stringify(deploymentInfo, null, 2));
|
||||
console.log('\nDeployment info saved to deployment.json');
|
||||
}
|
||||
|
||||
deploy().catch(console.error);
|
||||
|
|
@ -0,0 +1,14 @@
|
|||
{
|
||||
"network": "KAVA Mainnet",
|
||||
"chainId": 2222,
|
||||
"contractAddress": "0x14dc4f7d3E4197438d058C3D156dd9826A161134",
|
||||
"deployer": "0x4F7E78d6B7C5FC502Ec7039848690f08c8970F1E",
|
||||
"transactionHash": "0x071f535971bc3a134dd26c182b6f05c53f0c3783e91fe6ef471d6c914e4cdb06",
|
||||
"deployedAt": "2026-01-19T13:26:05.111Z",
|
||||
"token": {
|
||||
"name": "Future USDT",
|
||||
"symbol": "fUSDT",
|
||||
"decimals": "6",
|
||||
"totalSupply": "1000000000000000000"
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,222 @@
|
|||
{
|
||||
"name": "fusdt-contract",
|
||||
"version": "1.0.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "fusdt-contract",
|
||||
"version": "1.0.0",
|
||||
"dependencies": {
|
||||
"ethers": "^6.9.0",
|
||||
"solc": "^0.8.19"
|
||||
}
|
||||
},
|
||||
"node_modules/@adraffy/ens-normalize": {
|
||||
"version": "1.10.1",
|
||||
"resolved": "https://registry.npmjs.org/@adraffy/ens-normalize/-/ens-normalize-1.10.1.tgz",
|
||||
"integrity": "sha512-96Z2IP3mYmF1Xg2cDm8f1gWGf/HUVedQ3FMifV4kG/PQ4yEP51xDtRAEfhVNt5f/uzpNkZHwWQuUcu6D6K+Ekw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@noble/curves": {
|
||||
"version": "1.2.0",
|
||||
"resolved": "https://registry.npmjs.org/@noble/curves/-/curves-1.2.0.tgz",
|
||||
"integrity": "sha512-oYclrNgRaM9SsBUBVbb8M6DTV7ZHRTKugureoYEncY5c65HOmRzvSiTE3y5CYaPYJA/GVkrhXEoF0M3Ya9PMnw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@noble/hashes": "1.3.2"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://paulmillr.com/funding/"
|
||||
}
|
||||
},
|
||||
"node_modules/@noble/hashes": {
|
||||
"version": "1.3.2",
|
||||
"resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.3.2.tgz",
|
||||
"integrity": "sha512-MVC8EAQp7MvEcm30KWENFjgR+Mkmf+D189XJTkFIlwohU5hcBbn1ZkKq7KVTi2Hme3PMGF390DaL52beVrIihQ==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 16"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://paulmillr.com/funding/"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/node": {
|
||||
"version": "22.7.5",
|
||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-22.7.5.tgz",
|
||||
"integrity": "sha512-jML7s2NAzMWc//QSJ1a3prpk78cOPchGvXJsC3C6R6PSMoooztvRVQEz89gmBTBY1SPMaqo5teB4uNHPdetShQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"undici-types": "~6.19.2"
|
||||
}
|
||||
},
|
||||
"node_modules/aes-js": {
|
||||
"version": "4.0.0-beta.5",
|
||||
"resolved": "https://registry.npmjs.org/aes-js/-/aes-js-4.0.0-beta.5.tgz",
|
||||
"integrity": "sha512-G965FqalsNyrPqgEGON7nIx1e/OVENSgiEIzyC63haUMuvNnwIgIjMs52hlTCKhkBny7A2ORNlfY9Zu+jmGk1Q==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/command-exists": {
|
||||
"version": "1.2.9",
|
||||
"resolved": "https://registry.npmjs.org/command-exists/-/command-exists-1.2.9.tgz",
|
||||
"integrity": "sha512-LTQ/SGc+s0Xc0Fu5WaKnR0YiygZkm9eKFvyS+fRsU7/ZWFF8ykFM6Pc9aCVf1+xasOOZpO3BAVgVrKvsqKHV7w==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/commander": {
|
||||
"version": "8.3.0",
|
||||
"resolved": "https://registry.npmjs.org/commander/-/commander-8.3.0.tgz",
|
||||
"integrity": "sha512-OkTL9umf+He2DZkUq8f8J9of7yL6RJKI24dVITBmNfZBmri9zYZQrKkuXiKhyfPSu8tUhnVBB1iKXevvnlR4Ww==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 12"
|
||||
}
|
||||
},
|
||||
"node_modules/ethers": {
|
||||
"version": "6.16.0",
|
||||
"resolved": "https://registry.npmjs.org/ethers/-/ethers-6.16.0.tgz",
|
||||
"integrity": "sha512-U1wulmetNymijEhpSEQ7Ct/P/Jw9/e7R1j5XIbPRydgV2DjLVMsULDlNksq3RQnFgKoLlZf88ijYtWEXcPa07A==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "individual",
|
||||
"url": "https://github.com/sponsors/ethers-io/"
|
||||
},
|
||||
{
|
||||
"type": "individual",
|
||||
"url": "https://www.buymeacoffee.com/ricmoo"
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@adraffy/ens-normalize": "1.10.1",
|
||||
"@noble/curves": "1.2.0",
|
||||
"@noble/hashes": "1.3.2",
|
||||
"@types/node": "22.7.5",
|
||||
"aes-js": "4.0.0-beta.5",
|
||||
"tslib": "2.7.0",
|
||||
"ws": "8.17.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=14.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/follow-redirects": {
|
||||
"version": "1.15.11",
|
||||
"resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.11.tgz",
|
||||
"integrity": "sha512-deG2P0JfjrTxl50XGCDyfI97ZGVCxIpfKYmfyrQ54n5FO/0gfIES8C/Psl6kWVDolizcaaxZJnTS0QSMxvnsBQ==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "individual",
|
||||
"url": "https://github.com/sponsors/RubenVerborgh"
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=4.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"debug": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/js-sha3": {
|
||||
"version": "0.8.0",
|
||||
"resolved": "https://registry.npmjs.org/js-sha3/-/js-sha3-0.8.0.tgz",
|
||||
"integrity": "sha512-gF1cRrHhIzNfToc802P800N8PpXS+evLLXfsVpowqmAFR9uwbi89WvXg2QspOmXL8QL86J4T1EpFu+yUkwJY3Q==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/memorystream": {
|
||||
"version": "0.3.1",
|
||||
"resolved": "https://registry.npmjs.org/memorystream/-/memorystream-0.3.1.tgz",
|
||||
"integrity": "sha512-S3UwM3yj5mtUSEfP41UZmt/0SCoVYUcU1rkXv+BQ5Ig8ndL4sPoJNBUJERafdPb5jjHJGuMgytgKvKIf58XNBw==",
|
||||
"engines": {
|
||||
"node": ">= 0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/os-tmpdir": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/os-tmpdir/-/os-tmpdir-1.0.2.tgz",
|
||||
"integrity": "sha512-D2FR03Vir7FIu45XBY20mTb+/ZSWB00sjU9jdQXt83gDrI4Ztz5Fs7/yy74g2N5SVQY4xY1qDr4rNddwYRVX0g==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/semver": {
|
||||
"version": "5.7.2",
|
||||
"resolved": "https://registry.npmjs.org/semver/-/semver-5.7.2.tgz",
|
||||
"integrity": "sha512-cBznnQ9KjJqU67B52RMC65CMarK2600WFnbkcaiwWq3xy/5haFJlshgnpjovMVJ+Hff49d8GEn0b87C5pDQ10g==",
|
||||
"license": "ISC",
|
||||
"bin": {
|
||||
"semver": "bin/semver"
|
||||
}
|
||||
},
|
||||
"node_modules/solc": {
|
||||
"version": "0.8.19",
|
||||
"resolved": "https://registry.npmjs.org/solc/-/solc-0.8.19.tgz",
|
||||
"integrity": "sha512-yqurS3wzC4LdEvmMobODXqprV4MYJcVtinuxgrp61ac8K2zz40vXA0eSAskSHPgv8dQo7Nux39i3QBsHx4pqyA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"command-exists": "^1.2.8",
|
||||
"commander": "^8.1.0",
|
||||
"follow-redirects": "^1.12.1",
|
||||
"js-sha3": "0.8.0",
|
||||
"memorystream": "^0.3.1",
|
||||
"semver": "^5.5.0",
|
||||
"tmp": "0.0.33"
|
||||
},
|
||||
"bin": {
|
||||
"solcjs": "solc.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=10.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/tmp": {
|
||||
"version": "0.0.33",
|
||||
"resolved": "https://registry.npmjs.org/tmp/-/tmp-0.0.33.tgz",
|
||||
"integrity": "sha512-jRCJlojKnZ3addtTOjdIqoRuPEKBvNXcGYqzO6zWZX8KfKEpnGY5jfggJQ3EjKuu8D4bJRr0y+cYJFmYbImXGw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"os-tmpdir": "~1.0.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=0.6.0"
|
||||
}
|
||||
},
|
||||
"node_modules/tslib": {
|
||||
"version": "2.7.0",
|
||||
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.7.0.tgz",
|
||||
"integrity": "sha512-gLXCKdN1/j47AiHiOkJN69hJmcbGTHI0ImLmbYLHykhgeN0jVGola9yVjFgzCUklsZQMW55o+dW7IXv3RCXDzA==",
|
||||
"license": "0BSD"
|
||||
},
|
||||
"node_modules/undici-types": {
|
||||
"version": "6.19.8",
|
||||
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.19.8.tgz",
|
||||
"integrity": "sha512-ve2KP6f/JnbPBFyobGHuerC9g1FYGn/F8n1LWTwNxCEzd6IfqTwUQcNXgEtmmQ6DlRrC1hrSrBnCZPokRrDHjw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/ws": {
|
||||
"version": "8.17.1",
|
||||
"resolved": "https://registry.npmjs.org/ws/-/ws-8.17.1.tgz",
|
||||
"integrity": "sha512-6XQFvXTkbfUOZOKKILFG1PDK2NDQs4azKQl26T0YS5CxqWLgXajbPZ+h4gZekJyRqFU8pvnbAbbs/3TgRPy+GQ==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=10.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"bufferutil": "^4.0.1",
|
||||
"utf-8-validate": ">=5.0.2"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"bufferutil": {
|
||||
"optional": true
|
||||
},
|
||||
"utf-8-validate": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,14 @@
|
|||
{
|
||||
"name": "fusdt-contract",
|
||||
"version": "1.0.0",
|
||||
"type": "module",
|
||||
"description": "Future USDT (fUSDT) ERC-20 Token Contract",
|
||||
"scripts": {
|
||||
"compile": "node compile.mjs",
|
||||
"deploy": "node deploy.mjs"
|
||||
},
|
||||
"dependencies": {
|
||||
"ethers": "^6.9.0",
|
||||
"solc": "^0.8.19"
|
||||
}
|
||||
}
|
||||
|
|
@ -30,6 +30,8 @@ export default registerAs('blockchain', () => {
|
|||
? {
|
||||
// KAVA Testnet
|
||||
rpcUrl: process.env.KAVA_RPC_URL || 'https://evm.testnet.kava.io',
|
||||
// 逗号分隔的多个 RPC URL,用于故障转移(可选,不配置则仅使用 rpcUrl)
|
||||
rpcUrls: process.env.KAVA_RPC_URLS || '',
|
||||
chainId: parseInt(process.env.KAVA_CHAIN_ID || '2221', 10),
|
||||
// 测试网 USDT 合约 (自定义部署的 TestUSDT)
|
||||
usdtContract: process.env.KAVA_USDT_CONTRACT || '0xc12f6A4A7Fd0965085B044A67a39CcA2ff7fe0dF',
|
||||
|
|
@ -38,6 +40,8 @@ export default registerAs('blockchain', () => {
|
|||
: {
|
||||
// KAVA Mainnet
|
||||
rpcUrl: process.env.KAVA_RPC_URL || 'https://evm.kava.io',
|
||||
// 逗号分隔的多个 RPC URL,用于故障转移(可选,不配置则仅使用 rpcUrl)
|
||||
rpcUrls: process.env.KAVA_RPC_URLS || '',
|
||||
chainId: parseInt(process.env.KAVA_CHAIN_ID || '2222', 10),
|
||||
// dUSDT (绿积分) 合约地址 - Durian USDT, 精度6位
|
||||
usdtContract: process.env.KAVA_USDT_CONTRACT || '0xA9F3A35dBa8699c8C681D8db03F0c1A8CEB9D7c3',
|
||||
|
|
@ -49,6 +53,7 @@ export default registerAs('blockchain', () => {
|
|||
? {
|
||||
// BSC Testnet (BNB Smart Chain Testnet)
|
||||
rpcUrl: process.env.BSC_RPC_URL || 'https://data-seed-prebsc-1-s1.binance.org:8545',
|
||||
rpcUrls: process.env.BSC_RPC_URLS || '',
|
||||
chainId: parseInt(process.env.BSC_CHAIN_ID || '97', 10),
|
||||
// BSC Testnet 官方测试 USDT 合约
|
||||
usdtContract: process.env.BSC_USDT_CONTRACT || '0x337610d27c682E347C9cD60BD4b3b107C9d34dDd',
|
||||
|
|
@ -57,6 +62,7 @@ export default registerAs('blockchain', () => {
|
|||
: {
|
||||
// BSC Mainnet
|
||||
rpcUrl: process.env.BSC_RPC_URL || 'https://bsc-dataseed.binance.org',
|
||||
rpcUrls: process.env.BSC_RPC_URLS || '',
|
||||
chainId: parseInt(process.env.BSC_CHAIN_ID || '56', 10),
|
||||
usdtContract: process.env.BSC_USDT_CONTRACT || '0x55d398326f99059fF775485246999027B3197955',
|
||||
confirmations: parseInt(process.env.BSC_CONFIRMATIONS || '15', 10),
|
||||
|
|
|
|||
|
|
@ -1,9 +1,9 @@
|
|||
import { Module } from '@nestjs/common';
|
||||
import { ConfirmationPolicyService, ChainConfigService } from './services';
|
||||
import { ConfirmationPolicyService, ChainConfigService, RpcProviderManager } from './services';
|
||||
import { Erc20TransferService } from './services/erc20-transfer.service';
|
||||
|
||||
@Module({
|
||||
providers: [ConfirmationPolicyService, ChainConfigService, Erc20TransferService],
|
||||
exports: [ConfirmationPolicyService, ChainConfigService, Erc20TransferService],
|
||||
providers: [ConfirmationPolicyService, ChainConfigService, RpcProviderManager, Erc20TransferService],
|
||||
exports: [ConfirmationPolicyService, ChainConfigService, RpcProviderManager, Erc20TransferService],
|
||||
})
|
||||
export class DomainModule {}
|
||||
|
|
|
|||
|
|
@ -7,6 +7,8 @@ export interface ChainConfig {
|
|||
chainType: ChainTypeEnum;
|
||||
chainId: number;
|
||||
rpcUrl: string;
|
||||
/** RPC URL 列表(含主端点和备选端点),用于故障转移 */
|
||||
rpcUrls: string[];
|
||||
usdtContract: string;
|
||||
nativeSymbol: string;
|
||||
blockTime: number; // 平均出块时间(秒)
|
||||
|
|
@ -42,6 +44,13 @@ export class ChainConfigService {
|
|||
'blockchain.kava.rpcUrl',
|
||||
this.isTestnet ? 'https://evm.testnet.kava.io' : 'https://evm.kava.io',
|
||||
),
|
||||
rpcUrls: this.parseRpcUrls(
|
||||
'blockchain.kava.rpcUrls',
|
||||
this.configService.get<string>(
|
||||
'blockchain.kava.rpcUrl',
|
||||
this.isTestnet ? 'https://evm.testnet.kava.io' : 'https://evm.kava.io',
|
||||
),
|
||||
),
|
||||
// dUSDT (绿积分) 合约地址 - Durian USDT, 精度6位
|
||||
usdtContract: this.configService.get<string>(
|
||||
'blockchain.kava.usdtContract',
|
||||
|
|
@ -61,6 +70,13 @@ export class ChainConfigService {
|
|||
'blockchain.bsc.rpcUrl',
|
||||
this.isTestnet ? 'https://data-seed-prebsc-1-s1.binance.org:8545' : 'https://bsc-dataseed.binance.org',
|
||||
),
|
||||
rpcUrls: this.parseRpcUrls(
|
||||
'blockchain.bsc.rpcUrls',
|
||||
this.configService.get<string>(
|
||||
'blockchain.bsc.rpcUrl',
|
||||
this.isTestnet ? 'https://data-seed-prebsc-1-s1.binance.org:8545' : 'https://bsc-dataseed.binance.org',
|
||||
),
|
||||
),
|
||||
usdtContract: this.configService.get<string>(
|
||||
'blockchain.bsc.usdtContract',
|
||||
this.isTestnet ? '0x337610d27c682E347C9cD60BD4b3b107C9d34dDd' : '0x55d398326f99059fF775485246999027B3197955',
|
||||
|
|
@ -114,4 +130,24 @@ export class ChainConfigService {
|
|||
isSupported(chainType: ChainType): boolean {
|
||||
return this.configs.has(chainType.value);
|
||||
}
|
||||
|
||||
/**
|
||||
* 解析 RPC URL 列表
|
||||
*
|
||||
* 如果配置了逗号分隔的多 URL(如 KAVA_RPC_URLS),使用它作为完整列表;
|
||||
* 否则回退到单个 rpcUrl,行为与之前完全一致。
|
||||
*/
|
||||
private parseRpcUrls(configKey: string, fallbackUrl: string): string[] {
|
||||
const urlsStr = this.configService.get<string>(configKey, '');
|
||||
if (urlsStr) {
|
||||
const urls = urlsStr
|
||||
.split(',')
|
||||
.map((u) => u.trim())
|
||||
.filter((u) => u.length > 0);
|
||||
if (urls.length > 0) {
|
||||
return urls;
|
||||
}
|
||||
}
|
||||
return [fallbackUrl];
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@ import {
|
|||
recoverAddress,
|
||||
} from 'ethers';
|
||||
import { ChainConfigService } from './chain-config.service';
|
||||
import { RpcProviderManager } from './rpc-provider-manager.service';
|
||||
import { ChainType } from '@/domain/value-objects';
|
||||
import { ChainTypeEnum } from '@/domain/enums';
|
||||
|
||||
|
|
@ -47,16 +48,16 @@ export const MPC_SIGNING_CLIENT = Symbol('MPC_SIGNING_CLIENT');
|
|||
@Injectable()
|
||||
export class Erc20TransferService {
|
||||
private readonly logger = new Logger(Erc20TransferService.name);
|
||||
private readonly providers: Map<ChainTypeEnum, JsonRpcProvider> = new Map();
|
||||
private readonly hotWalletAddress: string;
|
||||
private mpcSigningClient: IMpcSigningClient | null = null;
|
||||
|
||||
constructor(
|
||||
private readonly configService: ConfigService,
|
||||
private readonly chainConfig: ChainConfigService,
|
||||
private readonly rpcProviderManager: RpcProviderManager,
|
||||
) {
|
||||
this.hotWalletAddress = this.configService.get<string>('HOT_WALLET_ADDRESS', '');
|
||||
this.initializeProviders();
|
||||
this.initializeWalletConfig();
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -67,19 +68,40 @@ export class Erc20TransferService {
|
|||
this.logger.log(`[INIT] MPC Signing Client injected`);
|
||||
}
|
||||
|
||||
private initializeProviders(): void {
|
||||
// 为每条支持的链创建 Provider
|
||||
for (const chainType of this.chainConfig.getSupportedChains()) {
|
||||
try {
|
||||
const config = this.chainConfig.getConfig(ChainType.fromEnum(chainType));
|
||||
const provider = new JsonRpcProvider(config.rpcUrl, config.chainId);
|
||||
this.providers.set(chainType, provider);
|
||||
this.logger.log(`[INIT] Provider initialized for ${chainType}: ${config.rpcUrl}`);
|
||||
} catch (error) {
|
||||
this.logger.error(`[INIT] Failed to initialize provider for ${chainType}`, error);
|
||||
}
|
||||
}
|
||||
/**
|
||||
* 获取某条链当前活跃的 provider(由 RpcProviderManager 统一管理,支持故障转移)
|
||||
*/
|
||||
private getProvider(chainType: ChainTypeEnum): JsonRpcProvider {
|
||||
return this.rpcProviderManager.getProvider(chainType);
|
||||
}
|
||||
|
||||
/**
|
||||
* 判断错误是否为 RPC 连接类错误(需要触发故障转移)
|
||||
* 区分 RPC 网络错误(503、超时等)和合约执行错误(revert、余额不足等)
|
||||
*/
|
||||
private isRpcConnectionError(error: any): boolean {
|
||||
const message = (error?.message || '').toLowerCase();
|
||||
return (
|
||||
message.includes('could not detect network') ||
|
||||
message.includes('connection refused') ||
|
||||
message.includes('timeout') ||
|
||||
message.includes('econnrefused') ||
|
||||
message.includes('enotfound') ||
|
||||
message.includes('503') ||
|
||||
message.includes('502') ||
|
||||
message.includes('server error') ||
|
||||
message.includes('missing response') ||
|
||||
message.includes('request failed') ||
|
||||
error?.code === 'NETWORK_ERROR' ||
|
||||
error?.code === 'SERVER_ERROR' ||
|
||||
error?.code === 'TIMEOUT'
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 初始化钱包配置检查(provider 由 RpcProviderManager 统一管理)
|
||||
*/
|
||||
private initializeWalletConfig(): void {
|
||||
// 检查热钱包地址配置
|
||||
if (this.hotWalletAddress) {
|
||||
this.logger.log(`[INIT] Hot wallet address configured: ${this.hotWalletAddress}`);
|
||||
|
|
@ -100,10 +122,7 @@ export class Erc20TransferService {
|
|||
* 获取热钱包 USDT 余额
|
||||
*/
|
||||
async getHotWalletBalance(chainType: ChainTypeEnum): Promise<string> {
|
||||
const provider = this.providers.get(chainType);
|
||||
if (!provider) {
|
||||
throw new Error(`Provider not configured for chain: ${chainType}`);
|
||||
}
|
||||
const provider = this.getProvider(chainType);
|
||||
|
||||
if (!this.hotWalletAddress) {
|
||||
throw new Error('Hot wallet address not configured');
|
||||
|
|
@ -136,12 +155,7 @@ export class Erc20TransferService {
|
|||
this.logger.log(`[TRANSFER] To: ${toAddress}`);
|
||||
this.logger.log(`[TRANSFER] Amount: ${amount} USDT`);
|
||||
|
||||
const provider = this.providers.get(chainType);
|
||||
if (!provider) {
|
||||
const error = `Provider not configured for chain: ${chainType}`;
|
||||
this.logger.error(`[TRANSFER] ${error}`);
|
||||
return { success: false, error };
|
||||
}
|
||||
const provider = this.getProvider(chainType);
|
||||
|
||||
if (!this.mpcSigningClient || !this.mpcSigningClient.isConfigured()) {
|
||||
const error = 'MPC signing client not configured';
|
||||
|
|
@ -296,6 +310,7 @@ export class Erc20TransferService {
|
|||
this.logger.log(`[TRANSFER] Block: ${receipt.blockNumber}`);
|
||||
this.logger.log(`[TRANSFER] Gas used: ${receipt.gasUsed.toString()}`);
|
||||
|
||||
this.rpcProviderManager.reportSuccess(chainType);
|
||||
return {
|
||||
success: true,
|
||||
txHash: txResponse.hash,
|
||||
|
|
@ -308,6 +323,9 @@ export class Erc20TransferService {
|
|||
return { success: false, txHash: txResponse.hash, error };
|
||||
}
|
||||
} catch (error: any) {
|
||||
if (this.isRpcConnectionError(error)) {
|
||||
this.rpcProviderManager.reportFailure(chainType, error);
|
||||
}
|
||||
this.logger.error(`[TRANSFER] Transfer failed:`, error);
|
||||
return {
|
||||
success: false,
|
||||
|
|
@ -320,8 +338,11 @@ export class Erc20TransferService {
|
|||
* 检查热钱包是否已配置
|
||||
*/
|
||||
isConfigured(chainType: ChainTypeEnum): boolean {
|
||||
return this.providers.has(chainType) &&
|
||||
!!this.hotWalletAddress &&
|
||||
!!this.mpcSigningClient?.isConfigured();
|
||||
try {
|
||||
this.rpcProviderManager.getProvider(chainType);
|
||||
return !!this.hotWalletAddress && !!this.mpcSigningClient?.isConfigured();
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,2 +1,3 @@
|
|||
export * from './confirmation-policy.service';
|
||||
export * from './chain-config.service';
|
||||
export * from './rpc-provider-manager.service';
|
||||
|
|
|
|||
|
|
@ -0,0 +1,212 @@
|
|||
import { Injectable, Logger, OnModuleInit } from '@nestjs/common';
|
||||
import { JsonRpcProvider } from 'ethers';
|
||||
import { ChainConfigService } from './chain-config.service';
|
||||
import { ChainTypeEnum } from '@/domain/enums';
|
||||
|
||||
/**
|
||||
* 每条链的 RPC 端点健康状态
|
||||
*/
|
||||
interface RpcHealthState {
|
||||
/** 当前活跃的 JsonRpcProvider 实例 */
|
||||
provider: JsonRpcProvider;
|
||||
/** 该链可用的所有 RPC URL 列表(第一个为默认主端点) */
|
||||
urls: string[];
|
||||
/** 当前使用的 URL 在 urls 数组中的索引 */
|
||||
currentIndex: number;
|
||||
/** 该链的 chainId(用于创建新 provider) */
|
||||
chainId: number;
|
||||
/** 首次连续失败的时间戳(null 表示当前健康) */
|
||||
firstFailureAt: number | null;
|
||||
/** 连续失败次数(用于日志) */
|
||||
consecutiveFailures: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* RPC Provider 管理器 — 自动故障转移
|
||||
*
|
||||
* 集中管理各链的 JsonRpcProvider 实例。当某条链的 RPC 端点
|
||||
* 持续失败超过 FAILOVER_THRESHOLD_MS(默认 3 分钟)时,
|
||||
* 自动切换到下一个备选端点。
|
||||
*
|
||||
* 使用方式:
|
||||
* - EvmProviderAdapter 和 Erc20TransferService 通过此服务获取 provider
|
||||
* - RPC 调用成功后调用 reportSuccess(chain) 重置失败状态
|
||||
* - RPC 调用失败后调用 reportFailure(chain, error) 记录失败
|
||||
* - 超过阈值后自动轮转到下一个 URL
|
||||
*
|
||||
* 环境变量:
|
||||
* - KAVA_RPC_URLS: 逗号分隔的多个 Kava RPC URL(可选,默认使用 KAVA_RPC_URL)
|
||||
* - BSC_RPC_URLS: 逗号分隔的多个 BSC RPC URL(可选,默认使用 BSC_RPC_URL)
|
||||
*/
|
||||
@Injectable()
|
||||
export class RpcProviderManager implements OnModuleInit {
|
||||
private readonly logger = new Logger(RpcProviderManager.name);
|
||||
private readonly healthStates: Map<ChainTypeEnum, RpcHealthState> = new Map();
|
||||
|
||||
/** 持续失败多久后触发端点切换(毫秒),默认 3 分钟 */
|
||||
private readonly FAILOVER_THRESHOLD_MS = 3 * 60 * 1000;
|
||||
|
||||
constructor(private readonly chainConfig: ChainConfigService) {}
|
||||
|
||||
onModuleInit(): void {
|
||||
this.initializeAllChains();
|
||||
}
|
||||
|
||||
/**
|
||||
* 初始化所有支持链的 provider
|
||||
* 从 ChainConfig 中读取 rpcUrls 列表,创建初始 provider
|
||||
*/
|
||||
private initializeAllChains(): void {
|
||||
for (const chainType of this.chainConfig.getSupportedChains()) {
|
||||
const config = this.chainConfig.getConfig(
|
||||
{ value: chainType, toString: () => chainType } as any,
|
||||
);
|
||||
|
||||
const urls = config.rpcUrls;
|
||||
const primaryUrl = urls[0];
|
||||
const provider = new JsonRpcProvider(primaryUrl, config.chainId);
|
||||
|
||||
this.healthStates.set(chainType, {
|
||||
provider,
|
||||
urls,
|
||||
currentIndex: 0,
|
||||
chainId: config.chainId,
|
||||
firstFailureAt: null,
|
||||
consecutiveFailures: 0,
|
||||
});
|
||||
|
||||
if (urls.length > 1) {
|
||||
this.logger.log(
|
||||
`[INIT] ${chainType} RPC 端点列表 (${urls.length} 个): ${urls.join(', ')}`,
|
||||
);
|
||||
} else {
|
||||
this.logger.log(
|
||||
`[INIT] ${chainType} RPC 端点: ${primaryUrl}(未配置备选端点)`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取某条链当前活跃的 provider
|
||||
*
|
||||
* @param chain 链类型枚举
|
||||
* @returns 当前活跃的 JsonRpcProvider
|
||||
* @throws Error 如果该链未初始化
|
||||
*/
|
||||
getProvider(chain: ChainTypeEnum): JsonRpcProvider {
|
||||
const state = this.healthStates.get(chain);
|
||||
if (!state) {
|
||||
throw new Error(`RPC Provider 未初始化: ${chain}`);
|
||||
}
|
||||
return state.provider;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取某条链当前使用的 RPC URL(用于日志/调试)
|
||||
*/
|
||||
getCurrentUrl(chain: ChainTypeEnum): string {
|
||||
const state = this.healthStates.get(chain);
|
||||
return state ? state.urls[state.currentIndex] : 'unknown';
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取某条链的所有可用 RPC URL 数量
|
||||
*/
|
||||
getUrlCount(chain: ChainTypeEnum): number {
|
||||
const state = this.healthStates.get(chain);
|
||||
return state ? state.urls.length : 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* 报告 RPC 调用成功
|
||||
* 重置该链的失败计时和连续失败次数
|
||||
*/
|
||||
reportSuccess(chain: ChainTypeEnum): void {
|
||||
const state = this.healthStates.get(chain);
|
||||
if (!state) return;
|
||||
|
||||
// 如果之前处于失败状态,记录恢复日志
|
||||
if (state.firstFailureAt !== null) {
|
||||
this.logger.log(
|
||||
`[${chain}] RPC 恢复正常: ${state.urls[state.currentIndex]}` +
|
||||
` (之前连续失败 ${state.consecutiveFailures} 次)`,
|
||||
);
|
||||
}
|
||||
state.firstFailureAt = null;
|
||||
state.consecutiveFailures = 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* 报告 RPC 调用失败
|
||||
*
|
||||
* 首次失败时记录时间戳。后续持续失败超过 FAILOVER_THRESHOLD_MS 后,
|
||||
* 自动切换到下一个备选端点。
|
||||
*
|
||||
* @param chain 链类型枚举
|
||||
* @param error 可选的错误对象(用于日志)
|
||||
*/
|
||||
reportFailure(chain: ChainTypeEnum, error?: Error): void {
|
||||
const state = this.healthStates.get(chain);
|
||||
if (!state) return;
|
||||
|
||||
const now = Date.now();
|
||||
state.consecutiveFailures++;
|
||||
|
||||
// 首次失败:记录起始时间
|
||||
if (state.firstFailureAt === null) {
|
||||
state.firstFailureAt = now;
|
||||
this.logger.warn(
|
||||
`[${chain}] RPC 开始失败: ${state.urls[state.currentIndex]}` +
|
||||
` — ${error?.message || 'unknown error'}`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// 检查是否超过故障转移阈值
|
||||
const elapsedMs = now - state.firstFailureAt;
|
||||
if (elapsedMs >= this.FAILOVER_THRESHOLD_MS) {
|
||||
this.switchToNextUrl(chain, state);
|
||||
} else {
|
||||
// 每 30 秒输出一条持续失败日志,避免日志洪水
|
||||
if (state.consecutiveFailures % 6 === 0) {
|
||||
this.logger.warn(
|
||||
`[${chain}] RPC 持续失败中 (${Math.round(elapsedMs / 1000)}s / ` +
|
||||
`${this.FAILOVER_THRESHOLD_MS / 1000}s): ` +
|
||||
`${state.urls[state.currentIndex]}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 切换到下一个 RPC URL
|
||||
*
|
||||
* 轮转到 urls 列表中的下一个端点,创建新的 JsonRpcProvider 实例。
|
||||
* 如果只有一个 URL,则重新创建 provider(处理临时网络问题)。
|
||||
*/
|
||||
private switchToNextUrl(chain: ChainTypeEnum, state: RpcHealthState): void {
|
||||
const oldUrl = state.urls[state.currentIndex];
|
||||
|
||||
// 轮转到下一个 URL
|
||||
state.currentIndex = (state.currentIndex + 1) % state.urls.length;
|
||||
const newUrl = state.urls[state.currentIndex];
|
||||
|
||||
if (state.urls.length === 1) {
|
||||
this.logger.error(
|
||||
`[${chain}] 仅有一个 RPC URL,无法切换到备选端点,将重新创建 provider: ${newUrl}`,
|
||||
);
|
||||
} else {
|
||||
this.logger.warn(
|
||||
`[${chain}] === RPC 端点切换 === ${oldUrl} → ${newUrl}`,
|
||||
);
|
||||
}
|
||||
|
||||
// 创建新的 provider 实例(ethers.js v6 的 JsonRpcProvider 创建后 URL 不可变)
|
||||
state.provider = new JsonRpcProvider(newUrl, state.chainId);
|
||||
|
||||
// 重置失败状态,给新端点一个全新的 3 分钟窗口
|
||||
state.firstFailureAt = null;
|
||||
state.consecutiveFailures = 0;
|
||||
}
|
||||
}
|
||||
|
|
@ -1,6 +1,6 @@
|
|||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { JsonRpcProvider, Contract } from 'ethers';
|
||||
import { ChainConfigService } from '@/domain/services/chain-config.service';
|
||||
import { RpcProviderManager } from '@/domain/services/rpc-provider-manager.service';
|
||||
import { ChainType, BlockNumber, TokenAmount } from '@/domain/value-objects';
|
||||
import { ChainTypeEnum } from '@/domain/enums';
|
||||
|
||||
|
|
@ -28,53 +28,71 @@ export interface TransferEvent {
|
|||
|
||||
/**
|
||||
* EVM 区块链提供者适配器
|
||||
* 封装与 EVM 链的交互
|
||||
*
|
||||
* 封装与 EVM 链的交互。通过 RpcProviderManager 获取 provider,
|
||||
* 自动上报 RPC 调用的成功/失败状态,实现故障转移。
|
||||
*/
|
||||
@Injectable()
|
||||
export class EvmProviderAdapter {
|
||||
private readonly logger = new Logger(EvmProviderAdapter.name);
|
||||
private readonly providers: Map<ChainTypeEnum, JsonRpcProvider> = new Map();
|
||||
|
||||
constructor(private readonly chainConfig: ChainConfigService) {
|
||||
this.initializeProviders();
|
||||
}
|
||||
|
||||
private initializeProviders(): void {
|
||||
for (const chainType of this.chainConfig.getSupportedChains()) {
|
||||
const config = this.chainConfig.getConfig(ChainType.fromEnum(chainType));
|
||||
const provider = new JsonRpcProvider(config.rpcUrl, config.chainId);
|
||||
this.providers.set(chainType, provider);
|
||||
this.logger.log(`Initialized provider for ${chainType}: ${config.rpcUrl}`);
|
||||
}
|
||||
}
|
||||
constructor(private readonly rpcProviderManager: RpcProviderManager) {}
|
||||
|
||||
/**
|
||||
* 获取某条链当前活跃的 provider(由 RpcProviderManager 统一管理)
|
||||
*/
|
||||
private getProvider(chainType: ChainType): JsonRpcProvider {
|
||||
const provider = this.providers.get(chainType.value);
|
||||
if (!provider) {
|
||||
throw new Error(`No provider for chain: ${chainType.toString()}`);
|
||||
return this.rpcProviderManager.getProvider(chainType.value);
|
||||
}
|
||||
|
||||
/**
|
||||
* 执行 RPC 调用并自动上报成功/失败状态
|
||||
*
|
||||
* 所有公开方法通过此辅助方法包裹 RPC 调用:
|
||||
* - 成功时调用 reportSuccess() 重置失败计时
|
||||
* - 失败时调用 reportFailure() 记录失败(超过 3 分钟自动切换端点)
|
||||
* - 错误会 re-throw,不影响调用方的错误处理逻辑
|
||||
*/
|
||||
private async executeWithFailover<T>(
|
||||
chainType: ChainType,
|
||||
operation: () => Promise<T>,
|
||||
): Promise<T> {
|
||||
try {
|
||||
const result = await operation();
|
||||
this.rpcProviderManager.reportSuccess(chainType.value);
|
||||
return result;
|
||||
} catch (error) {
|
||||
this.rpcProviderManager.reportFailure(
|
||||
chainType.value,
|
||||
error instanceof Error ? error : new Error(String(error)),
|
||||
);
|
||||
throw error;
|
||||
}
|
||||
return provider;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取当前区块号
|
||||
*/
|
||||
async getCurrentBlockNumber(chainType: ChainType): Promise<BlockNumber> {
|
||||
const provider = this.getProvider(chainType);
|
||||
const blockNumber = await provider.getBlockNumber();
|
||||
return BlockNumber.create(blockNumber);
|
||||
return this.executeWithFailover(chainType, async () => {
|
||||
const provider = this.getProvider(chainType);
|
||||
const blockNumber = await provider.getBlockNumber();
|
||||
return BlockNumber.create(blockNumber);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取区块时间戳
|
||||
*/
|
||||
async getBlockTimestamp(chainType: ChainType, blockNumber: BlockNumber): Promise<Date> {
|
||||
const provider = this.getProvider(chainType);
|
||||
const block = await provider.getBlock(blockNumber.asNumber);
|
||||
if (!block) {
|
||||
throw new Error(`Block not found: ${blockNumber.toString()}`);
|
||||
}
|
||||
return new Date(block.timestamp * 1000);
|
||||
return this.executeWithFailover(chainType, async () => {
|
||||
const provider = this.getProvider(chainType);
|
||||
const block = await provider.getBlock(blockNumber.asNumber);
|
||||
if (!block) {
|
||||
throw new Error(`Block not found: ${blockNumber.toString()}`);
|
||||
}
|
||||
return new Date(block.timestamp * 1000);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -86,38 +104,40 @@ export class EvmProviderAdapter {
|
|||
toBlock: BlockNumber,
|
||||
tokenContract: string,
|
||||
): Promise<TransferEvent[]> {
|
||||
const provider = this.getProvider(chainType);
|
||||
const contract = new Contract(tokenContract, ERC20_TRANSFER_EVENT_ABI, provider);
|
||||
return this.executeWithFailover(chainType, async () => {
|
||||
const provider = this.getProvider(chainType);
|
||||
const contract = new Contract(tokenContract, ERC20_TRANSFER_EVENT_ABI, provider);
|
||||
|
||||
const filter = contract.filters.Transfer();
|
||||
const logs = await contract.queryFilter(filter, fromBlock.asNumber, toBlock.asNumber);
|
||||
const filter = contract.filters.Transfer();
|
||||
const logs = await contract.queryFilter(filter, fromBlock.asNumber, toBlock.asNumber);
|
||||
|
||||
const events: TransferEvent[] = [];
|
||||
const events: TransferEvent[] = [];
|
||||
|
||||
for (const log of logs) {
|
||||
const block = await provider.getBlock(log.blockNumber);
|
||||
if (!block) continue;
|
||||
for (const log of logs) {
|
||||
const block = await provider.getBlock(log.blockNumber);
|
||||
if (!block) continue;
|
||||
|
||||
const parsedLog = contract.interface.parseLog({
|
||||
topics: log.topics as string[],
|
||||
data: log.data,
|
||||
});
|
||||
|
||||
if (parsedLog) {
|
||||
events.push({
|
||||
txHash: log.transactionHash,
|
||||
logIndex: log.index,
|
||||
blockNumber: BigInt(log.blockNumber),
|
||||
blockTimestamp: new Date(block.timestamp * 1000),
|
||||
from: parsedLog.args[0],
|
||||
to: parsedLog.args[1],
|
||||
value: parsedLog.args[2],
|
||||
tokenContract,
|
||||
const parsedLog = contract.interface.parseLog({
|
||||
topics: log.topics as string[],
|
||||
data: log.data,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return events;
|
||||
if (parsedLog) {
|
||||
events.push({
|
||||
txHash: log.transactionHash,
|
||||
logIndex: log.index,
|
||||
blockNumber: BigInt(log.blockNumber),
|
||||
blockTimestamp: new Date(block.timestamp * 1000),
|
||||
from: parsedLog.args[0],
|
||||
to: parsedLog.args[1],
|
||||
value: parsedLog.args[2],
|
||||
tokenContract,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return events;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -128,42 +148,50 @@ export class EvmProviderAdapter {
|
|||
tokenContract: string,
|
||||
address: string,
|
||||
): Promise<TokenAmount> {
|
||||
const provider = this.getProvider(chainType);
|
||||
const contract = new Contract(tokenContract, ERC20_BALANCE_ABI, provider);
|
||||
const [balance, decimals] = await Promise.all([
|
||||
contract.balanceOf(address),
|
||||
contract.decimals(),
|
||||
]);
|
||||
return TokenAmount.fromRaw(balance, Number(decimals));
|
||||
return this.executeWithFailover(chainType, async () => {
|
||||
const provider = this.getProvider(chainType);
|
||||
const contract = new Contract(tokenContract, ERC20_BALANCE_ABI, provider);
|
||||
const [balance, decimals] = await Promise.all([
|
||||
contract.balanceOf(address),
|
||||
contract.decimals(),
|
||||
]);
|
||||
return TokenAmount.fromRaw(balance, Number(decimals));
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 查询 ERC20 代币的 decimals
|
||||
*/
|
||||
async getTokenDecimals(chainType: ChainType, tokenContract: string): Promise<number> {
|
||||
const provider = this.getProvider(chainType);
|
||||
const contract = new Contract(tokenContract, ERC20_BALANCE_ABI, provider);
|
||||
const decimals = await contract.decimals();
|
||||
return Number(decimals);
|
||||
return this.executeWithFailover(chainType, async () => {
|
||||
const provider = this.getProvider(chainType);
|
||||
const contract = new Contract(tokenContract, ERC20_BALANCE_ABI, provider);
|
||||
const decimals = await contract.decimals();
|
||||
return Number(decimals);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 查询原生代币余额
|
||||
*/
|
||||
async getNativeBalance(chainType: ChainType, address: string): Promise<TokenAmount> {
|
||||
const provider = this.getProvider(chainType);
|
||||
const balance = await provider.getBalance(address);
|
||||
return TokenAmount.fromRaw(balance, 18);
|
||||
return this.executeWithFailover(chainType, async () => {
|
||||
const provider = this.getProvider(chainType);
|
||||
const balance = await provider.getBalance(address);
|
||||
return TokenAmount.fromRaw(balance, 18);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 广播签名交易
|
||||
*/
|
||||
async broadcastTransaction(chainType: ChainType, signedTx: string): Promise<string> {
|
||||
const provider = this.getProvider(chainType);
|
||||
const txResponse = await provider.broadcastTransaction(signedTx);
|
||||
this.logger.log(`Transaction broadcasted: ${txResponse.hash}`);
|
||||
return txResponse.hash;
|
||||
return this.executeWithFailover(chainType, async () => {
|
||||
const provider = this.getProvider(chainType);
|
||||
const txResponse = await provider.broadcastTransaction(signedTx);
|
||||
this.logger.log(`Transaction broadcasted: ${txResponse.hash}`);
|
||||
return txResponse.hash;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -174,9 +202,11 @@ export class EvmProviderAdapter {
|
|||
txHash: string,
|
||||
confirmations: number = 1,
|
||||
): Promise<boolean> {
|
||||
const provider = this.getProvider(chainType);
|
||||
const receipt = await provider.waitForTransaction(txHash, confirmations);
|
||||
return receipt !== null && receipt.status === 1;
|
||||
return this.executeWithFailover(chainType, async () => {
|
||||
const provider = this.getProvider(chainType);
|
||||
const receipt = await provider.waitForTransaction(txHash, confirmations);
|
||||
return receipt !== null && receipt.status === 1;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -187,12 +217,14 @@ export class EvmProviderAdapter {
|
|||
txHash: string,
|
||||
requiredConfirmations: number,
|
||||
): Promise<boolean> {
|
||||
const provider = this.getProvider(chainType);
|
||||
const receipt = await provider.getTransactionReceipt(txHash);
|
||||
if (!receipt) return false;
|
||||
return this.executeWithFailover(chainType, async () => {
|
||||
const provider = this.getProvider(chainType);
|
||||
const receipt = await provider.getTransactionReceipt(txHash);
|
||||
if (!receipt) return false;
|
||||
|
||||
const currentBlock = await provider.getBlockNumber();
|
||||
const confirmations = currentBlock - receipt.blockNumber;
|
||||
return confirmations >= requiredConfirmations;
|
||||
const currentBlock = await provider.getBlockNumber();
|
||||
const confirmations = currentBlock - receipt.blockNumber;
|
||||
return confirmations >= requiredConfirmations;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -72,6 +72,7 @@ export class MpcEventConsumerService implements OnModuleInit, OnModuleDestroy {
|
|||
private kafka: Kafka;
|
||||
private consumer: Consumer;
|
||||
private isConnected = false;
|
||||
private isShuttingDown = false;
|
||||
|
||||
private keygenCompletedHandler?: MpcEventHandler<KeygenCompletedPayload>;
|
||||
private signingCompletedHandler?: MpcEventHandler<SigningCompletedPayload>;
|
||||
|
|
@ -111,24 +112,71 @@ export class MpcEventConsumerService implements OnModuleInit, OnModuleDestroy {
|
|||
heartbeatInterval: 3000,
|
||||
});
|
||||
|
||||
try {
|
||||
this.logger.log(`[CONNECT] Connecting MPC Event consumer...`);
|
||||
await this.consumer.connect();
|
||||
this.isConnected = true;
|
||||
this.logger.log(`[CONNECT] MPC Event Kafka consumer connected successfully`);
|
||||
// 监听 consumer crash 事件,自动重连
|
||||
// 当 Kafka topic-partition 不可用或其他运行时错误导致 consumer 崩溃时触发
|
||||
this.consumer.on(this.consumer.events.CRASH, async (event) => {
|
||||
if (this.isShuttingDown) return;
|
||||
this.logger.error(`[CRASH] Kafka consumer crashed: ${event.payload.error?.message || 'unknown'}, restart: ${event.payload.restart}`);
|
||||
// 如果 KafkaJS 内部不自动重启(restart=false),手动触发重连
|
||||
if (!event.payload.restart) {
|
||||
this.logger.warn(`[CRASH] KafkaJS will not auto-restart, triggering manual reconnect...`);
|
||||
this.isConnected = false;
|
||||
await this.connectWithRetry();
|
||||
}
|
||||
});
|
||||
|
||||
// Subscribe to MPC topics
|
||||
await this.consumer.subscribe({ topics: Object.values(MPC_TOPICS), fromBeginning: false });
|
||||
this.logger.log(`[SUBSCRIBE] Subscribed to MPC topics: ${Object.values(MPC_TOPICS).join(', ')}`);
|
||||
await this.connectWithRetry();
|
||||
}
|
||||
|
||||
// Start consuming
|
||||
await this.startConsuming();
|
||||
} catch (error) {
|
||||
this.logger.error(`[ERROR] Failed to connect MPC Event Kafka consumer`, error);
|
||||
/**
|
||||
* 带指数退避的连接重试逻辑
|
||||
*
|
||||
* 解决问题:服务启动时 Kafka topic-partition 未就绪,导致 subscribe() 抛出
|
||||
* "This server does not host this topic-partition" 错误。原实现只 catch 一次就放弃,
|
||||
* consumer 永久失效,后续所有 MPC 签名结果都收不到(表现为 signing timeout 300s)。
|
||||
*
|
||||
* 策略:指数退避重试,2s→4s→8s→...→60s(上限),最多 10 次,总等待约 5 分钟。
|
||||
*/
|
||||
private async connectWithRetry(maxRetries = 10): Promise<void> {
|
||||
for (let attempt = 1; attempt <= maxRetries; attempt++) {
|
||||
if (this.isShuttingDown) return;
|
||||
|
||||
try {
|
||||
if (!this.isConnected) {
|
||||
this.logger.log(`[CONNECT] Connecting MPC Event consumer (attempt ${attempt}/${maxRetries})...`);
|
||||
await this.consumer.connect();
|
||||
this.isConnected = true;
|
||||
this.logger.log(`[CONNECT] MPC Event Kafka consumer connected successfully`);
|
||||
}
|
||||
|
||||
// Subscribe to MPC topics
|
||||
await this.consumer.subscribe({ topics: Object.values(MPC_TOPICS), fromBeginning: false });
|
||||
this.logger.log(`[SUBSCRIBE] Subscribed to MPC topics: ${Object.values(MPC_TOPICS).join(', ')}`);
|
||||
|
||||
// Start consuming
|
||||
await this.startConsuming();
|
||||
return; // 成功,退出重试循环
|
||||
} catch (error: any) {
|
||||
this.logger.error(`[ERROR] Failed to connect/subscribe Kafka consumer (attempt ${attempt}/${maxRetries}): ${error.message}`);
|
||||
|
||||
if (attempt < maxRetries) {
|
||||
// 指数退避:2s, 4s, 8s, 16s, 32s, 60s, 60s, ...
|
||||
const delay = Math.min(2000 * Math.pow(2, attempt - 1), 60000);
|
||||
this.logger.log(`[RETRY] Will retry in ${delay / 1000}s...`);
|
||||
await new Promise(resolve => setTimeout(resolve, delay));
|
||||
|
||||
// 断开连接以清理状态,下次循环重新建立
|
||||
try { await this.consumer.disconnect(); } catch (_) {}
|
||||
this.isConnected = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
this.logger.error(`[FATAL] Failed to connect Kafka consumer after ${maxRetries} attempts. MPC events will NOT be received!`);
|
||||
}
|
||||
|
||||
async onModuleDestroy() {
|
||||
this.isShuttingDown = true;
|
||||
if (this.isConnected) {
|
||||
await this.consumer.disconnect();
|
||||
this.logger.log('MPC Event Kafka consumer disconnected');
|
||||
|
|
|
|||
|
|
@ -1,7 +1,6 @@
|
|||
-- ============================================================================
|
||||
-- contribution-service 初始化 migration
|
||||
-- 合并自: 20260111000000_init, 20260111100000_add_referral_user_ids,
|
||||
-- 20260112020000_fix_status_varchar_length, 20260112200000_add_adoption_province_city
|
||||
-- 合并自: 0001_init, 0002_add_transactional_idempotency, 20250120000001_add_region_to_system_accounts
|
||||
-- ============================================================================
|
||||
|
||||
-- ============================================
|
||||
|
|
@ -228,8 +227,9 @@ CREATE INDEX "unallocated_contributions_status_idx" ON "unallocated_contribution
|
|||
|
||||
CREATE TABLE "system_accounts" (
|
||||
"id" BIGSERIAL NOT NULL,
|
||||
"account_type" VARCHAR(20) NOT NULL,
|
||||
"name" VARCHAR(100) NOT NULL,
|
||||
"account_type" TEXT NOT NULL,
|
||||
"region_code" TEXT,
|
||||
"name" TEXT NOT NULL,
|
||||
"contribution_balance" DECIMAL(30,10) NOT NULL DEFAULT 0,
|
||||
"contribution_never_expires" BOOLEAN NOT NULL DEFAULT false,
|
||||
"version" INTEGER NOT NULL DEFAULT 1,
|
||||
|
|
@ -239,18 +239,26 @@ CREATE TABLE "system_accounts" (
|
|||
CONSTRAINT "system_accounts_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
CREATE UNIQUE INDEX "system_accounts_account_type_key" ON "system_accounts"("account_type");
|
||||
CREATE UNIQUE INDEX "system_accounts_account_type_region_code_key" ON "system_accounts"("account_type", "region_code");
|
||||
CREATE INDEX "system_accounts_account_type_idx" ON "system_accounts"("account_type");
|
||||
CREATE INDEX "system_accounts_region_code_idx" ON "system_accounts"("region_code");
|
||||
|
||||
CREATE TABLE "system_contribution_records" (
|
||||
"id" BIGSERIAL NOT NULL,
|
||||
"system_account_id" BIGINT NOT NULL,
|
||||
"source_adoption_id" BIGINT NOT NULL,
|
||||
"source_account_sequence" VARCHAR(20) NOT NULL,
|
||||
-- 来源类型: FIXED_RATE(固定比例) / LEVEL_OVERFLOW(层级溢出) / LEVEL_NO_ANCESTOR(无上线) / BONUS_TIER_1/2/3(团队奖励未解锁)
|
||||
"source_type" VARCHAR(30) NOT NULL,
|
||||
-- 层级深度(1-15),仅对 LEVEL_OVERFLOW 和 LEVEL_NO_ANCESTOR 类型有效
|
||||
"level_depth" INTEGER,
|
||||
"distribution_rate" DECIMAL(10,6) NOT NULL,
|
||||
"amount" DECIMAL(30,10) NOT NULL,
|
||||
"effective_date" DATE NOT NULL,
|
||||
"expire_date" DATE,
|
||||
"is_expired" BOOLEAN NOT NULL DEFAULT false,
|
||||
-- 软删除时间戳
|
||||
"deleted_at" TIMESTAMP(3),
|
||||
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
CONSTRAINT "system_contribution_records_pkey" PRIMARY KEY ("id")
|
||||
|
|
@ -258,6 +266,8 @@ CREATE TABLE "system_contribution_records" (
|
|||
|
||||
CREATE INDEX "system_contribution_records_system_account_id_idx" ON "system_contribution_records"("system_account_id");
|
||||
CREATE INDEX "system_contribution_records_source_adoption_id_idx" ON "system_contribution_records"("source_adoption_id");
|
||||
CREATE INDEX "system_contribution_records_source_type_idx" ON "system_contribution_records"("source_type");
|
||||
CREATE INDEX "system_contribution_records_deleted_at_idx" ON "system_contribution_records"("deleted_at");
|
||||
|
||||
ALTER TABLE "system_contribution_records" ADD CONSTRAINT "system_contribution_records_system_account_id_fkey" FOREIGN KEY ("system_account_id") REFERENCES "system_accounts"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
|
||||
|
|
@ -327,20 +337,36 @@ CREATE TABLE "cdc_sync_progress" (
|
|||
|
||||
CREATE UNIQUE INDEX "cdc_sync_progress_source_topic_key" ON "cdc_sync_progress"("source_topic");
|
||||
|
||||
-- 2.0 服务间 Outbox 事件幂等表
|
||||
CREATE TABLE "processed_events" (
|
||||
"id" BIGSERIAL NOT NULL,
|
||||
"event_id" VARCHAR(100) NOT NULL,
|
||||
"event_type" VARCHAR(50) NOT NULL,
|
||||
"source_service" VARCHAR(50),
|
||||
"source_service" VARCHAR(100) NOT NULL,
|
||||
"processed_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
CONSTRAINT "processed_events_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
CREATE UNIQUE INDEX "processed_events_event_id_key" ON "processed_events"("event_id");
|
||||
CREATE UNIQUE INDEX "processed_events_source_service_event_id_key" ON "processed_events"("source_service", "event_id");
|
||||
CREATE INDEX "processed_events_event_type_idx" ON "processed_events"("event_type");
|
||||
CREATE INDEX "processed_events_processed_at_idx" ON "processed_events"("processed_at");
|
||||
|
||||
-- 1.0 CDC 事件幂等表
|
||||
CREATE TABLE "processed_cdc_events" (
|
||||
"id" BIGSERIAL NOT NULL,
|
||||
"source_topic" VARCHAR(200) NOT NULL,
|
||||
"offset" BIGINT NOT NULL,
|
||||
"table_name" VARCHAR(100) NOT NULL,
|
||||
"operation" VARCHAR(10) NOT NULL,
|
||||
"processed_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
CONSTRAINT "processed_cdc_events_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
CREATE UNIQUE INDEX "processed_cdc_events_source_topic_offset_key" ON "processed_cdc_events"("source_topic", "offset");
|
||||
CREATE INDEX "processed_cdc_events_processed_at_idx" ON "processed_cdc_events"("processed_at");
|
||||
|
||||
-- ============================================
|
||||
-- 9. 配置表
|
||||
-- ============================================
|
||||
|
|
|
|||
|
|
@ -1,45 +0,0 @@
|
|||
-- ============================================================================
|
||||
-- 添加事务性幂等消费支持
|
||||
-- 用于 1.0 -> 2.0 CDC 同步的 100% exactly-once 语义
|
||||
-- ============================================================================
|
||||
|
||||
-- 1. 创建 processed_cdc_events 表(用于 CDC 事件幂等)
|
||||
-- 唯一键: (source_topic, offset) - Kafka topic 名称 + 消息偏移量
|
||||
-- 用于保证每个 CDC 事件只处理一次(exactly-once 语义)
|
||||
CREATE TABLE IF NOT EXISTS "processed_cdc_events" (
|
||||
"id" BIGSERIAL NOT NULL,
|
||||
"source_topic" VARCHAR(200) NOT NULL, -- Kafka topic 名称(如 cdc.identity.public.user_accounts)
|
||||
"offset" BIGINT NOT NULL, -- Kafka 消息偏移量(在 partition 内唯一)
|
||||
"table_name" VARCHAR(100) NOT NULL, -- 源表名
|
||||
"operation" VARCHAR(10) NOT NULL, -- CDC 操作类型: c(create), u(update), d(delete), r(snapshot read)
|
||||
"processed_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
CONSTRAINT "processed_cdc_events_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- 复合唯一索引:(source_topic, offset) 保证幂等性
|
||||
-- 注意:这不是数据库自增 ID,而是 Kafka 消息的唯一标识
|
||||
CREATE UNIQUE INDEX "processed_cdc_events_source_topic_offset_key" ON "processed_cdc_events"("source_topic", "offset");
|
||||
|
||||
-- 时间索引用于清理旧数据
|
||||
CREATE INDEX "processed_cdc_events_processed_at_idx" ON "processed_cdc_events"("processed_at");
|
||||
|
||||
-- 2. 修复 processed_events 表(用于 2.0 服务间 Outbox 事件幂等)
|
||||
-- 唯一键: (source_service, event_id) - 服务名 + outbox 表的 ID
|
||||
-- 不同服务的 outbox ID 可能相同,所以需要组合服务名作为复合唯一键
|
||||
|
||||
-- 2.1 修改 source_service 列:扩展长度 50->100,且设为 NOT NULL
|
||||
-- 先为已有 NULL 值设置默认值
|
||||
UPDATE "processed_events" SET "source_service" = 'unknown' WHERE "source_service" IS NULL;
|
||||
|
||||
-- 修改列类型和约束
|
||||
ALTER TABLE "processed_events"
|
||||
ALTER COLUMN "source_service" SET NOT NULL,
|
||||
ALTER COLUMN "source_service" TYPE VARCHAR(100);
|
||||
|
||||
-- 2.2 删除旧的单字段唯一索引
|
||||
DROP INDEX IF EXISTS "processed_events_event_id_key";
|
||||
|
||||
-- 2.3 创建新的复合唯一索引
|
||||
-- 索引名使用蛇形命名以与列名保持一致
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS "processed_events_source_service_event_id_key" ON "processed_events"("source_service", "event_id");
|
||||
|
|
@ -299,9 +299,10 @@ model UnallocatedContribution {
|
|||
|
||||
// 系统账户(运营/省/市/总部)
|
||||
model SystemAccount {
|
||||
id BigInt @id @default(autoincrement())
|
||||
accountType String @unique @map("account_type") @db.VarChar(20) // OPERATION / PROVINCE / CITY / HEADQUARTERS
|
||||
name String @db.VarChar(100)
|
||||
id BigInt @id @default(autoincrement())
|
||||
accountType String @map("account_type") // OPERATION / PROVINCE / CITY / HEADQUARTERS
|
||||
regionCode String? @map("region_code") // 省/市代码,如 440000, 440100
|
||||
name String
|
||||
|
||||
contributionBalance Decimal @default(0) @map("contribution_balance") @db.Decimal(30, 10)
|
||||
contributionNeverExpires Boolean @default(false) @map("contribution_never_expires")
|
||||
|
|
@ -313,6 +314,9 @@ model SystemAccount {
|
|||
|
||||
records SystemContributionRecord[]
|
||||
|
||||
@@unique([accountType, regionCode])
|
||||
@@index([accountType])
|
||||
@@index([regionCode])
|
||||
@@map("system_accounts")
|
||||
}
|
||||
|
||||
|
|
@ -323,6 +327,11 @@ model SystemContributionRecord {
|
|||
sourceAdoptionId BigInt @map("source_adoption_id")
|
||||
sourceAccountSequence String @map("source_account_sequence") @db.VarChar(20)
|
||||
|
||||
// 来源类型:FIXED_RATE(固定比例分配) / LEVEL_OVERFLOW(层级溢出) / LEVEL_NO_ANCESTOR(无上线) / BONUS_TIER_1/2/3(团队奖励未解锁)
|
||||
sourceType String @map("source_type") @db.VarChar(30)
|
||||
// 层级深度:对于 LEVEL_OVERFLOW 和 LEVEL_NO_ANCESTOR 类型,表示第几级(1-15)
|
||||
levelDepth Int? @map("level_depth")
|
||||
|
||||
distributionRate Decimal @map("distribution_rate") @db.Decimal(10, 6)
|
||||
amount Decimal @map("amount") @db.Decimal(30, 10)
|
||||
|
||||
|
|
@ -330,12 +339,15 @@ model SystemContributionRecord {
|
|||
expireDate DateTime? @map("expire_date") @db.Date
|
||||
isExpired Boolean @default(false) @map("is_expired")
|
||||
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
deletedAt DateTime? @map("deleted_at") // 软删除标记
|
||||
|
||||
systemAccount SystemAccount @relation(fields: [systemAccountId], references: [id])
|
||||
|
||||
@@index([systemAccountId])
|
||||
@@index([sourceAdoptionId])
|
||||
@@index([deletedAt])
|
||||
@@index([sourceType])
|
||||
@@map("system_contribution_records")
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -10,6 +10,8 @@ import {
|
|||
AdoptionSyncedEvent,
|
||||
ContributionRecordSyncedEvent,
|
||||
NetworkProgressUpdatedEvent,
|
||||
SystemAccountSyncedEvent,
|
||||
UnallocatedContributionSyncedEvent,
|
||||
} from '../../domain/events';
|
||||
import { Public } from '../../shared/guards/jwt-auth.guard';
|
||||
|
||||
|
|
@ -420,4 +422,190 @@ export class AdminController {
|
|||
};
|
||||
}
|
||||
}
|
||||
|
||||
@Post('system-accounts/publish-all')
|
||||
@Public()
|
||||
@ApiOperation({ summary: '发布所有系统账户算力事件到 outbox,用于同步到 mining-service' })
|
||||
async publishAllSystemAccounts(): Promise<{
|
||||
success: boolean;
|
||||
publishedCount: number;
|
||||
message: string;
|
||||
}> {
|
||||
try {
|
||||
const systemAccounts = await this.prisma.systemAccount.findMany();
|
||||
|
||||
await this.unitOfWork.executeInTransaction(async () => {
|
||||
const events = systemAccounts.map((account) => {
|
||||
const event = new SystemAccountSyncedEvent(
|
||||
account.accountType,
|
||||
account.regionCode,
|
||||
account.name,
|
||||
account.contributionBalance.toString(),
|
||||
account.createdAt,
|
||||
);
|
||||
|
||||
return {
|
||||
aggregateType: SystemAccountSyncedEvent.AGGREGATE_TYPE,
|
||||
aggregateId: `${account.accountType}:${account.regionCode || 'null'}`,
|
||||
eventType: SystemAccountSyncedEvent.EVENT_TYPE,
|
||||
payload: event.toPayload(),
|
||||
};
|
||||
});
|
||||
|
||||
await this.outboxRepository.saveMany(events);
|
||||
});
|
||||
|
||||
this.logger.log(`Published ${systemAccounts.length} system account events`);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
publishedCount: systemAccounts.length,
|
||||
message: `Published ${systemAccounts.length} system account events`,
|
||||
};
|
||||
} catch (error) {
|
||||
this.logger.error('Failed to publish system accounts', error);
|
||||
return {
|
||||
success: false,
|
||||
publishedCount: 0,
|
||||
message: `Failed: ${error.message}`,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@Get('system-accounts')
|
||||
@Public()
|
||||
@ApiOperation({ summary: '获取所有系统账户算力' })
|
||||
async getSystemAccounts() {
|
||||
const systemAccounts = await this.prisma.systemAccount.findMany();
|
||||
|
||||
return {
|
||||
accounts: systemAccounts.map((a) => ({
|
||||
accountType: a.accountType,
|
||||
name: a.name,
|
||||
contributionBalance: a.contributionBalance.toString(),
|
||||
createdAt: a.createdAt,
|
||||
updatedAt: a.updatedAt,
|
||||
})),
|
||||
total: systemAccounts.length,
|
||||
};
|
||||
}
|
||||
|
||||
@Get('unallocated-contributions')
|
||||
@Public()
|
||||
@ApiOperation({ summary: '获取所有未分配算力列表,供 mining-service 定时同步' })
|
||||
async getUnallocatedContributions(): Promise<{
|
||||
contributions: Array<{
|
||||
sourceAdoptionId: string;
|
||||
sourceAccountSequence: string;
|
||||
wouldBeAccountSequence: string | null;
|
||||
contributionType: string;
|
||||
amount: string;
|
||||
reason: string | null;
|
||||
effectiveDate: string;
|
||||
expireDate: string;
|
||||
}>;
|
||||
total: number;
|
||||
}> {
|
||||
const unallocatedContributions = await this.prisma.unallocatedContribution.findMany({
|
||||
where: { status: 'PENDING' },
|
||||
select: {
|
||||
sourceAdoptionId: true,
|
||||
sourceAccountSequence: true,
|
||||
wouldBeAccountSequence: true,
|
||||
unallocType: true,
|
||||
amount: true,
|
||||
reason: true,
|
||||
effectiveDate: true,
|
||||
expireDate: true,
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
contributions: unallocatedContributions.map((uc) => ({
|
||||
sourceAdoptionId: uc.sourceAdoptionId.toString(),
|
||||
sourceAccountSequence: uc.sourceAccountSequence,
|
||||
wouldBeAccountSequence: uc.wouldBeAccountSequence,
|
||||
contributionType: uc.unallocType,
|
||||
amount: uc.amount.toString(),
|
||||
reason: uc.reason,
|
||||
effectiveDate: uc.effectiveDate.toISOString(),
|
||||
expireDate: uc.expireDate.toISOString(),
|
||||
})),
|
||||
total: unallocatedContributions.length,
|
||||
};
|
||||
}
|
||||
|
||||
@Post('unallocated-contributions/publish-all')
|
||||
@Public()
|
||||
@ApiOperation({ summary: '发布所有未分配算力事件到 outbox,用于同步到 mining-service' })
|
||||
async publishAllUnallocatedContributions(): Promise<{
|
||||
success: boolean;
|
||||
publishedCount: number;
|
||||
failedCount: number;
|
||||
message: string;
|
||||
}> {
|
||||
const unallocatedContributions = await this.prisma.unallocatedContribution.findMany({
|
||||
where: { status: 'PENDING' },
|
||||
select: {
|
||||
id: true,
|
||||
sourceAdoptionId: true,
|
||||
sourceAccountSequence: true,
|
||||
wouldBeAccountSequence: true,
|
||||
unallocType: true,
|
||||
amount: true,
|
||||
reason: true,
|
||||
effectiveDate: true,
|
||||
expireDate: true,
|
||||
},
|
||||
});
|
||||
|
||||
let publishedCount = 0;
|
||||
let failedCount = 0;
|
||||
|
||||
const batchSize = 100;
|
||||
for (let i = 0; i < unallocatedContributions.length; i += batchSize) {
|
||||
const batch = unallocatedContributions.slice(i, i + batchSize);
|
||||
|
||||
try {
|
||||
await this.unitOfWork.executeInTransaction(async () => {
|
||||
const events = batch.map((uc) => {
|
||||
const event = new UnallocatedContributionSyncedEvent(
|
||||
uc.sourceAdoptionId,
|
||||
uc.sourceAccountSequence,
|
||||
uc.wouldBeAccountSequence,
|
||||
uc.unallocType,
|
||||
uc.amount.toString(),
|
||||
uc.reason,
|
||||
uc.effectiveDate,
|
||||
uc.expireDate,
|
||||
);
|
||||
|
||||
return {
|
||||
aggregateType: UnallocatedContributionSyncedEvent.AGGREGATE_TYPE,
|
||||
aggregateId: `${uc.sourceAdoptionId}-${uc.unallocType}`,
|
||||
eventType: UnallocatedContributionSyncedEvent.EVENT_TYPE,
|
||||
payload: event.toPayload(),
|
||||
};
|
||||
});
|
||||
|
||||
await this.outboxRepository.saveMany(events);
|
||||
});
|
||||
|
||||
publishedCount += batch.length;
|
||||
this.logger.debug(`Published unallocated contribution batch ${Math.floor(i / batchSize) + 1}: ${batch.length} events`);
|
||||
} catch (error) {
|
||||
failedCount += batch.length;
|
||||
this.logger.error(`Failed to publish unallocated contribution batch ${Math.floor(i / batchSize) + 1}`, error);
|
||||
}
|
||||
}
|
||||
|
||||
this.logger.log(`Published ${publishedCount} unallocated contribution events, ${failedCount} failed`);
|
||||
|
||||
return {
|
||||
success: failedCount === 0,
|
||||
publishedCount,
|
||||
failedCount,
|
||||
message: `Published ${publishedCount} events, ${failedCount} failed out of ${unallocatedContributions.length} total`,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,8 +1,10 @@
|
|||
import { Controller, Get, Param, Query, NotFoundException } from '@nestjs/common';
|
||||
import { ApiTags, ApiOperation, ApiResponse, ApiParam } from '@nestjs/swagger';
|
||||
import { ApiTags, ApiOperation, ApiResponse, ApiParam, ApiQuery } from '@nestjs/swagger';
|
||||
import { GetContributionAccountQuery } from '../../application/queries/get-contribution-account.query';
|
||||
import { GetContributionStatsQuery } from '../../application/queries/get-contribution-stats.query';
|
||||
import { GetContributionRankingQuery } from '../../application/queries/get-contribution-ranking.query';
|
||||
import { GetPlantingLedgerQuery, PlantingLedgerDto } from '../../application/queries/get-planting-ledger.query';
|
||||
import { GetTeamTreeQuery, DirectReferralsResponseDto, MyTeamInfoDto } from '../../application/queries/get-team-tree.query';
|
||||
import {
|
||||
ContributionAccountResponse,
|
||||
ContributionRecordsResponse,
|
||||
|
|
@ -11,6 +13,7 @@ import {
|
|||
import { ContributionStatsResponse } from '../dto/response/contribution-stats.response';
|
||||
import { ContributionRankingResponse, UserRankResponse } from '../dto/response/contribution-ranking.response';
|
||||
import { GetContributionRecordsRequest } from '../dto/request/get-records.request';
|
||||
import { Public } from '../../shared/guards/jwt-auth.guard';
|
||||
|
||||
@ApiTags('Contribution')
|
||||
@Controller('contribution')
|
||||
|
|
@ -19,9 +22,12 @@ export class ContributionController {
|
|||
private readonly getAccountQuery: GetContributionAccountQuery,
|
||||
private readonly getStatsQuery: GetContributionStatsQuery,
|
||||
private readonly getRankingQuery: GetContributionRankingQuery,
|
||||
private readonly getPlantingLedgerQuery: GetPlantingLedgerQuery,
|
||||
private readonly getTeamTreeQuery: GetTeamTreeQuery,
|
||||
) {}
|
||||
|
||||
@Get('stats')
|
||||
@Public()
|
||||
@ApiOperation({ summary: '获取算力统计数据' })
|
||||
@ApiResponse({ status: 200, type: ContributionStatsResponse })
|
||||
async getStats(): Promise<ContributionStatsResponse> {
|
||||
|
|
@ -95,4 +101,52 @@ export class ContributionController {
|
|||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
@Get('accounts/:accountSequence/planting-ledger')
|
||||
@ApiOperation({ summary: '获取账户认种分类账' })
|
||||
@ApiParam({ name: 'accountSequence', description: '账户序号' })
|
||||
@ApiQuery({ name: 'page', required: false, type: Number, description: '页码' })
|
||||
@ApiQuery({ name: 'pageSize', required: false, type: Number, description: '每页数量' })
|
||||
@ApiResponse({ status: 200, description: '认种分类账' })
|
||||
async getPlantingLedger(
|
||||
@Param('accountSequence') accountSequence: string,
|
||||
@Query('page') page?: number,
|
||||
@Query('pageSize') pageSize?: number,
|
||||
): Promise<PlantingLedgerDto> {
|
||||
return this.getPlantingLedgerQuery.execute(
|
||||
accountSequence,
|
||||
page ?? 1,
|
||||
pageSize ?? 20,
|
||||
);
|
||||
}
|
||||
|
||||
// ========== 团队树 API ==========
|
||||
|
||||
@Get('accounts/:accountSequence/team')
|
||||
@ApiOperation({ summary: '获取账户团队信息' })
|
||||
@ApiParam({ name: 'accountSequence', description: '账户序号' })
|
||||
@ApiResponse({ status: 200, description: '团队信息' })
|
||||
async getMyTeamInfo(
|
||||
@Param('accountSequence') accountSequence: string,
|
||||
): Promise<MyTeamInfoDto> {
|
||||
return this.getTeamTreeQuery.getMyTeamInfo(accountSequence);
|
||||
}
|
||||
|
||||
@Get('accounts/:accountSequence/team/direct-referrals')
|
||||
@ApiOperation({ summary: '获取账户直推列表(用于伞下树懒加载)' })
|
||||
@ApiParam({ name: 'accountSequence', description: '账户序号' })
|
||||
@ApiQuery({ name: 'limit', required: false, type: Number, description: '每页数量' })
|
||||
@ApiQuery({ name: 'offset', required: false, type: Number, description: '偏移量' })
|
||||
@ApiResponse({ status: 200, description: '直推列表' })
|
||||
async getDirectReferrals(
|
||||
@Param('accountSequence') accountSequence: string,
|
||||
@Query('limit') limit?: number,
|
||||
@Query('offset') offset?: number,
|
||||
): Promise<DirectReferralsResponseDto> {
|
||||
return this.getTeamTreeQuery.getDirectReferrals(
|
||||
accountSequence,
|
||||
limit ?? 100,
|
||||
offset ?? 0,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ import { Controller, Get } from '@nestjs/common';
|
|||
import { ApiTags, ApiOperation, ApiResponse } from '@nestjs/swagger';
|
||||
import { PrismaService } from '../../infrastructure/persistence/prisma/prisma.service';
|
||||
import { RedisService } from '../../infrastructure/redis/redis.service';
|
||||
import { CDCConsumerService } from '../../infrastructure/kafka/cdc-consumer.service';
|
||||
import { Public } from '../../shared/guards/jwt-auth.guard';
|
||||
|
||||
interface HealthStatus {
|
||||
|
|
@ -20,6 +21,7 @@ export class HealthController {
|
|||
constructor(
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly redis: RedisService,
|
||||
private readonly cdcConsumer: CDCConsumerService,
|
||||
) {}
|
||||
|
||||
@Get()
|
||||
|
|
@ -68,4 +70,15 @@ export class HealthController {
|
|||
async live(): Promise<{ alive: boolean }> {
|
||||
return { alive: true };
|
||||
}
|
||||
|
||||
@Get('cdc-sync')
|
||||
@ApiOperation({ summary: 'CDC 同步状态检查' })
|
||||
@ApiResponse({ status: 200, description: 'CDC 同步状态' })
|
||||
async cdcSyncStatus(): Promise<{
|
||||
isRunning: boolean;
|
||||
sequentialMode: boolean;
|
||||
allPhasesCompleted: boolean;
|
||||
}> {
|
||||
return this.cdcConsumer.getSyncStatus();
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -16,6 +16,7 @@ import { JwtAuthGuard } from './shared/guards/jwt-auth.guard';
|
|||
envFilePath: [
|
||||
`.env.${process.env.NODE_ENV || 'development'}`,
|
||||
'.env',
|
||||
'../.env', // 父目录共享 .env
|
||||
],
|
||||
ignoreEnvFile: false,
|
||||
}),
|
||||
|
|
|
|||
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue