Compare commits
595 Commits
v2.0.0-cdc
...
main
| Author | SHA1 | Date |
|---|---|---|
|
|
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
|
||||
|
|
@ -309,24 +309,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 +355,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 +387,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)))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
|
|
@ -104,6 +104,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")
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// 刷新令牌
|
||||
// ============================================================================
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@ import {
|
|||
UserController,
|
||||
HealthController,
|
||||
AdminController,
|
||||
InternalController,
|
||||
} from './controllers';
|
||||
import { ApplicationModule } from '@/application';
|
||||
import { JwtAuthGuard } from '@/shared/guards/jwt-auth.guard';
|
||||
|
|
@ -35,6 +36,7 @@ import { JwtAuthGuard } from '@/shared/guards/jwt-auth.guard';
|
|||
UserController,
|
||||
HealthController,
|
||||
AdminController,
|
||||
InternalController,
|
||||
],
|
||||
providers: [JwtAuthGuard],
|
||||
})
|
||||
|
|
|
|||
|
|
@ -5,3 +5,4 @@ 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) {}
|
||||
|
|
|
|||
|
|
@ -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'],
|
||||
}),
|
||||
|
||||
// 限流模块
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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 };
|
||||
}
|
||||
|
||||
/**
|
||||
* 更换手机号
|
||||
*/
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
}),
|
||||
|
|
|
|||
|
|
@ -12,12 +12,15 @@ import { CDCEventDispatcher } from './event-handlers/cdc-event-dispatcher';
|
|||
import { ContributionCalculationService } from './services/contribution-calculation.service';
|
||||
import { ContributionDistributionPublisherService } from './services/contribution-distribution-publisher.service';
|
||||
import { ContributionRateService } from './services/contribution-rate.service';
|
||||
import { BonusClaimService } from './services/bonus-claim.service';
|
||||
import { SnapshotService } from './services/snapshot.service';
|
||||
|
||||
// Queries
|
||||
import { GetContributionAccountQuery } from './queries/get-contribution-account.query';
|
||||
import { GetContributionStatsQuery } from './queries/get-contribution-stats.query';
|
||||
import { GetContributionRankingQuery } from './queries/get-contribution-ranking.query';
|
||||
import { GetPlantingLedgerQuery } from './queries/get-planting-ledger.query';
|
||||
import { GetTeamTreeQuery } from './queries/get-team-tree.query';
|
||||
|
||||
// Schedulers
|
||||
import { ContributionScheduler } from './schedulers/contribution.scheduler';
|
||||
|
|
@ -38,12 +41,15 @@ import { ContributionScheduler } from './schedulers/contribution.scheduler';
|
|||
ContributionCalculationService,
|
||||
ContributionDistributionPublisherService,
|
||||
ContributionRateService,
|
||||
BonusClaimService,
|
||||
SnapshotService,
|
||||
|
||||
// Queries
|
||||
GetContributionAccountQuery,
|
||||
GetContributionStatsQuery,
|
||||
GetContributionRankingQuery,
|
||||
GetPlantingLedgerQuery,
|
||||
GetTeamTreeQuery,
|
||||
|
||||
// Schedulers
|
||||
ContributionScheduler,
|
||||
|
|
@ -55,6 +61,8 @@ import { ContributionScheduler } from './schedulers/contribution.scheduler';
|
|||
GetContributionAccountQuery,
|
||||
GetContributionStatsQuery,
|
||||
GetContributionRankingQuery,
|
||||
GetPlantingLedgerQuery,
|
||||
GetTeamTreeQuery,
|
||||
],
|
||||
})
|
||||
export class ApplicationModule {}
|
||||
|
|
|
|||
|
|
@ -1,7 +1,10 @@
|
|||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import Decimal from 'decimal.js';
|
||||
import { CDCEvent, TransactionClient } from '../../infrastructure/kafka/cdc-consumer.service';
|
||||
import { AdoptionSyncedEvent } from '../../domain/events/adoption-synced.event';
|
||||
import { AdoptionFusdtInjectionRequestedEvent } from '../../domain/events/adoption-fusdt-injection-requested.event';
|
||||
import { ContributionCalculationService } from '../services/contribution-calculation.service';
|
||||
import { ContributionRateService } from '../services/contribution-rate.service';
|
||||
|
||||
/**
|
||||
* 认种同步结果,用于事务提交后的算力计算
|
||||
|
|
@ -15,19 +18,11 @@ export interface AdoptionSyncResult {
|
|||
* 认种订单 CDC 事件处理器
|
||||
* 处理从1.0 planting-service同步过来的planting_orders数据
|
||||
*
|
||||
* 重要设计说明(符合业界最佳实践):
|
||||
* 设计说明:
|
||||
* ===========================================
|
||||
* - handle() 方法在事务内执行,只负责数据同步(synced_adoptions 表)
|
||||
* - 返回 AdoptionSyncResult,包含需要计算算力的认种ID
|
||||
* - 算力计算(calculateForAdoption)必须在事务提交后单独执行
|
||||
*
|
||||
* 为什么不能在事务内调用 calculateForAdoption:
|
||||
* 1. calculateForAdoption 内部使用独立的数据库连接查询数据
|
||||
* 2. 在 Serializable 隔离级别下,内部查询无法看到外部事务未提交的数据
|
||||
* 3. 这会导致 "Adoption not found" 错误,因为 synced_adoptions 还未提交
|
||||
*
|
||||
* 参考:Kafka Idempotent Consumer & Transactional Outbox Pattern
|
||||
* https://www.lydtechconsulting.com/blog/kafka-idempotent-consumer-transactional-outbox
|
||||
* - handle() 方法100%同步数据,不跳过任何更新
|
||||
* - 算力计算只在 status 变为 MINING_ENABLED 时触发
|
||||
* - 算力计算在事务提交后执行(避免 Serializable 隔离级别的可见性问题)
|
||||
*/
|
||||
@Injectable()
|
||||
export class AdoptionSyncedHandler {
|
||||
|
|
@ -35,6 +30,7 @@ export class AdoptionSyncedHandler {
|
|||
|
||||
constructor(
|
||||
private readonly contributionCalculationService: ContributionCalculationService,
|
||||
private readonly contributionRateService: ContributionRateService,
|
||||
) {}
|
||||
|
||||
/**
|
||||
|
|
@ -48,13 +44,28 @@ export class AdoptionSyncedHandler {
|
|||
this.logger.log(`[CDC] Adoption event received: op=${op}, seq=${event.sequenceNum}`);
|
||||
this.logger.debug(`[CDC] Adoption event payload: ${JSON.stringify(after || before)}`);
|
||||
|
||||
// 获取认种日期,用于查询当日贡献值
|
||||
const data = after || before;
|
||||
const adoptionDate = data?.created_at || data?.createdAt || data?.paid_at || data?.paidAt;
|
||||
|
||||
// 在事务外获取当日每棵树的贡献值
|
||||
let contributionPerTree = new Decimal('22617'); // 默认值
|
||||
if (adoptionDate) {
|
||||
try {
|
||||
contributionPerTree = await this.contributionRateService.getContributionPerTree(new Date(adoptionDate));
|
||||
this.logger.log(`[CDC] Got contributionPerTree for ${adoptionDate}: ${contributionPerTree.toString()}`);
|
||||
} catch (error) {
|
||||
this.logger.warn(`[CDC] Failed to get contributionPerTree, using default 22617`, error);
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
switch (op) {
|
||||
case 'c': // create
|
||||
case 'r': // read (snapshot)
|
||||
return await this.handleCreate(after, event.sequenceNum, tx);
|
||||
return await this.handleCreate(after, event.sequenceNum, tx, contributionPerTree);
|
||||
case 'u': // update
|
||||
return await this.handleUpdate(after, before, event.sequenceNum, tx);
|
||||
return await this.handleUpdate(after, before, event.sequenceNum, tx, contributionPerTree);
|
||||
case 'd': // delete
|
||||
await this.handleDelete(before);
|
||||
return null;
|
||||
|
|
@ -86,21 +97,21 @@ export class AdoptionSyncedHandler {
|
|||
}
|
||||
}
|
||||
|
||||
private async handleCreate(data: any, sequenceNum: bigint, tx: TransactionClient): Promise<AdoptionSyncResult | null> {
|
||||
private async handleCreate(data: any, sequenceNum: bigint, tx: TransactionClient, contributionPerTree: Decimal): Promise<AdoptionSyncResult | null> {
|
||||
if (!data) {
|
||||
this.logger.warn(`[CDC] Adoption create: empty data received`);
|
||||
return null;
|
||||
}
|
||||
|
||||
// planting_orders表字段: order_id, account_sequence, tree_count, created_at, status, selected_province, selected_city
|
||||
const orderId = data.order_id || data.id;
|
||||
const accountSequence = data.account_sequence || data.accountSequence;
|
||||
const treeCount = data.tree_count || data.treeCount;
|
||||
const createdAt = data.created_at || data.createdAt || data.paid_at || data.paidAt;
|
||||
const selectedProvince = data.selected_province || data.selectedProvince || null;
|
||||
const selectedCity = data.selected_city || data.selectedCity || null;
|
||||
const status = data.status ?? null;
|
||||
|
||||
this.logger.log(`[CDC] Adoption create: orderId=${orderId}, account=${accountSequence}, trees=${treeCount}, province=${selectedProvince}, city=${selectedCity}`);
|
||||
this.logger.log(`[CDC] Adoption create: orderId=${orderId}, account=${accountSequence}, trees=${treeCount}, status=${status}, contributionPerTree=${contributionPerTree.toString()}`);
|
||||
|
||||
if (!orderId || !accountSequence) {
|
||||
this.logger.warn(`[CDC] Invalid adoption data: missing order_id or account_sequence`, { data });
|
||||
|
|
@ -109,8 +120,7 @@ export class AdoptionSyncedHandler {
|
|||
|
||||
const originalAdoptionId = BigInt(orderId);
|
||||
|
||||
// 在事务中保存同步的认种订单数据
|
||||
this.logger.log(`[CDC] Upserting synced adoption: ${orderId}`);
|
||||
// 100%同步数据,使用真实的每棵树贡献值
|
||||
await tx.syncedAdoption.upsert({
|
||||
where: { originalAdoptionId },
|
||||
create: {
|
||||
|
|
@ -118,10 +128,10 @@ export class AdoptionSyncedHandler {
|
|||
accountSequence,
|
||||
treeCount,
|
||||
adoptionDate: new Date(createdAt),
|
||||
status: data.status ?? null,
|
||||
status,
|
||||
selectedProvince,
|
||||
selectedCity,
|
||||
contributionPerTree: new Decimal('1'), // 每棵树1算力
|
||||
contributionPerTree,
|
||||
sourceSequenceNum: sequenceNum,
|
||||
syncedAt: new Date(),
|
||||
},
|
||||
|
|
@ -129,25 +139,33 @@ export class AdoptionSyncedHandler {
|
|||
accountSequence,
|
||||
treeCount,
|
||||
adoptionDate: new Date(createdAt),
|
||||
status: data.status ?? undefined,
|
||||
selectedProvince: selectedProvince ?? undefined,
|
||||
selectedCity: selectedCity ?? undefined,
|
||||
contributionPerTree: new Decimal('1'),
|
||||
status,
|
||||
selectedProvince,
|
||||
selectedCity,
|
||||
contributionPerTree,
|
||||
sourceSequenceNum: sequenceNum,
|
||||
syncedAt: new Date(),
|
||||
},
|
||||
});
|
||||
|
||||
this.logger.log(`[CDC] Adoption synced successfully: orderId=${orderId}, account=${accountSequence}, trees=${treeCount}`);
|
||||
this.logger.log(`[CDC] Adoption synced: orderId=${orderId}, status=${status}`);
|
||||
|
||||
// 发布 AdoptionSynced outbox 事件,实时同步到 mining-admin-service
|
||||
await this.publishAdoptionOutboxEvent(tx, originalAdoptionId, accountSequence, treeCount, new Date(createdAt), status, contributionPerTree);
|
||||
|
||||
// 只有 MINING_ENABLED 状态才触发算力计算和 fUSDT 注入
|
||||
const needsCalculation = status === 'MINING_ENABLED';
|
||||
if (needsCalculation) {
|
||||
await this.publishFusdtInjectionEvent(tx, originalAdoptionId, accountSequence, treeCount, new Date(createdAt));
|
||||
}
|
||||
|
||||
// 返回结果,供事务提交后计算算力
|
||||
return {
|
||||
originalAdoptionId,
|
||||
needsCalculation: true,
|
||||
needsCalculation,
|
||||
};
|
||||
}
|
||||
|
||||
private async handleUpdate(after: any, before: any, sequenceNum: bigint, tx: TransactionClient): Promise<AdoptionSyncResult | null> {
|
||||
private async handleUpdate(after: any, before: any, sequenceNum: bigint, tx: TransactionClient, contributionPerTree: Decimal): Promise<AdoptionSyncResult | null> {
|
||||
if (!after) {
|
||||
this.logger.warn(`[CDC] Adoption update: empty after data received`);
|
||||
return null;
|
||||
|
|
@ -155,37 +173,22 @@ export class AdoptionSyncedHandler {
|
|||
|
||||
const orderId = after.order_id || after.id;
|
||||
const originalAdoptionId = BigInt(orderId);
|
||||
|
||||
this.logger.log(`[CDC] Adoption update: orderId=${orderId}`);
|
||||
|
||||
// 检查是否已经处理过(使用事务客户端)
|
||||
const existingAdoption = await tx.syncedAdoption.findUnique({
|
||||
where: { originalAdoptionId },
|
||||
});
|
||||
|
||||
if (existingAdoption?.contributionDistributed) {
|
||||
// 如果树数量发生变化,需要重新计算(这种情况较少)
|
||||
const newTreeCount = after.tree_count || after.treeCount;
|
||||
if (existingAdoption.treeCount !== newTreeCount) {
|
||||
this.logger.warn(
|
||||
`[CDC] Adoption tree count changed after processing: ${originalAdoptionId}, old=${existingAdoption.treeCount}, new=${newTreeCount}. This requires special handling.`,
|
||||
);
|
||||
// TODO: 实现树数量变化的处理逻辑
|
||||
} else {
|
||||
this.logger.debug(`[CDC] Adoption ${orderId} already distributed, skipping update`);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
const accountSequence = after.account_sequence || after.accountSequence;
|
||||
const treeCount = after.tree_count || after.treeCount;
|
||||
const createdAt = after.created_at || after.createdAt || after.paid_at || after.paidAt;
|
||||
const selectedProvince = after.selected_province || after.selectedProvince || null;
|
||||
const selectedCity = after.selected_city || after.selectedCity || null;
|
||||
const newStatus = after.status ?? null;
|
||||
const oldStatus = before?.status ?? null;
|
||||
|
||||
this.logger.log(`[CDC] Adoption update data: account=${accountSequence}, trees=${treeCount}, province=${selectedProvince}, city=${selectedCity}`);
|
||||
this.logger.log(`[CDC] Adoption update: orderId=${orderId}, status=${oldStatus} -> ${newStatus}, contributionPerTree=${contributionPerTree.toString()}`);
|
||||
|
||||
// 在事务中保存同步的认种订单数据
|
||||
// 查询现有记录
|
||||
const existingAdoption = await tx.syncedAdoption.findUnique({
|
||||
where: { originalAdoptionId },
|
||||
});
|
||||
|
||||
// 100%同步数据,使用真实的每棵树贡献值
|
||||
await tx.syncedAdoption.upsert({
|
||||
where: { originalAdoptionId },
|
||||
create: {
|
||||
|
|
@ -193,10 +196,10 @@ export class AdoptionSyncedHandler {
|
|||
accountSequence,
|
||||
treeCount,
|
||||
adoptionDate: new Date(createdAt),
|
||||
status: after.status ?? null,
|
||||
status: newStatus,
|
||||
selectedProvince,
|
||||
selectedCity,
|
||||
contributionPerTree: new Decimal('1'),
|
||||
contributionPerTree,
|
||||
sourceSequenceNum: sequenceNum,
|
||||
syncedAt: new Date(),
|
||||
},
|
||||
|
|
@ -204,21 +207,30 @@ export class AdoptionSyncedHandler {
|
|||
accountSequence,
|
||||
treeCount,
|
||||
adoptionDate: new Date(createdAt),
|
||||
status: after.status ?? undefined,
|
||||
selectedProvince: selectedProvince ?? undefined,
|
||||
selectedCity: selectedCity ?? undefined,
|
||||
contributionPerTree: new Decimal('1'),
|
||||
status: newStatus,
|
||||
selectedProvince,
|
||||
selectedCity,
|
||||
contributionPerTree,
|
||||
sourceSequenceNum: sequenceNum,
|
||||
syncedAt: new Date(),
|
||||
},
|
||||
});
|
||||
|
||||
this.logger.log(`[CDC] Adoption updated successfully: ${originalAdoptionId}`);
|
||||
this.logger.log(`[CDC] Adoption synced: orderId=${orderId}, status=${newStatus}`);
|
||||
|
||||
// 发布 AdoptionSynced outbox 事件,实时同步到 mining-admin-service
|
||||
await this.publishAdoptionOutboxEvent(tx, originalAdoptionId, accountSequence, treeCount, new Date(createdAt), newStatus, contributionPerTree);
|
||||
|
||||
// 只有当 status 变为 MINING_ENABLED 且尚未计算过算力时,才触发算力计算和 fUSDT 注入
|
||||
const statusChangedToMiningEnabled = newStatus === 'MINING_ENABLED' && oldStatus !== 'MINING_ENABLED';
|
||||
const needsCalculation = statusChangedToMiningEnabled && !existingAdoption?.contributionDistributed;
|
||||
if (needsCalculation) {
|
||||
await this.publishFusdtInjectionEvent(tx, originalAdoptionId, accountSequence, treeCount, new Date(createdAt));
|
||||
}
|
||||
|
||||
// 只有尚未分配算力的认种才需要计算
|
||||
return {
|
||||
originalAdoptionId,
|
||||
needsCalculation: !existingAdoption?.contributionDistributed,
|
||||
needsCalculation,
|
||||
};
|
||||
}
|
||||
|
||||
|
|
@ -232,4 +244,75 @@ export class AdoptionSyncedHandler {
|
|||
// 但通常不会发生删除操作
|
||||
this.logger.warn(`[CDC] Adoption delete event received: ${orderId}. This may require contribution rollback.`);
|
||||
}
|
||||
|
||||
/**
|
||||
* 在事务内发布 AdoptionSynced outbox 事件
|
||||
* 确保 mining-admin-service 能实时收到认种数据变更
|
||||
*/
|
||||
private async publishAdoptionOutboxEvent(
|
||||
tx: TransactionClient,
|
||||
originalAdoptionId: bigint,
|
||||
accountSequence: string,
|
||||
treeCount: number,
|
||||
adoptionDate: Date,
|
||||
status: string | null,
|
||||
contributionPerTree: Decimal,
|
||||
): Promise<void> {
|
||||
const event = new AdoptionSyncedEvent(
|
||||
originalAdoptionId,
|
||||
accountSequence,
|
||||
treeCount,
|
||||
adoptionDate,
|
||||
status,
|
||||
contributionPerTree.toString(),
|
||||
);
|
||||
|
||||
await tx.outboxEvent.create({
|
||||
data: {
|
||||
aggregateType: AdoptionSyncedEvent.AGGREGATE_TYPE,
|
||||
aggregateId: originalAdoptionId.toString(),
|
||||
eventType: AdoptionSyncedEvent.EVENT_TYPE,
|
||||
topic: 'contribution.adoptionsynced',
|
||||
key: originalAdoptionId.toString(),
|
||||
payload: event.toPayload(),
|
||||
status: 'PENDING',
|
||||
},
|
||||
});
|
||||
|
||||
this.logger.debug(`[CDC] Published AdoptionSynced outbox event: orderId=${originalAdoptionId}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* 在事务内发布 fUSDT 注入请求到 outbox
|
||||
* 当认种状态变为 MINING_ENABLED 时触发
|
||||
* mining-blockchain-service 会消费此事件并执行区块链转账
|
||||
*/
|
||||
private async publishFusdtInjectionEvent(
|
||||
tx: TransactionClient,
|
||||
originalAdoptionId: bigint,
|
||||
accountSequence: string,
|
||||
treeCount: number,
|
||||
adoptionDate: Date,
|
||||
): Promise<void> {
|
||||
const event = new AdoptionFusdtInjectionRequestedEvent(
|
||||
originalAdoptionId,
|
||||
accountSequence,
|
||||
treeCount,
|
||||
adoptionDate,
|
||||
);
|
||||
|
||||
await tx.outboxEvent.create({
|
||||
data: {
|
||||
aggregateType: AdoptionFusdtInjectionRequestedEvent.AGGREGATE_TYPE,
|
||||
aggregateId: originalAdoptionId.toString(),
|
||||
eventType: AdoptionFusdtInjectionRequestedEvent.EVENT_TYPE,
|
||||
topic: 'contribution.adoptionfusdtinjectionrequested',
|
||||
key: originalAdoptionId.toString(),
|
||||
payload: event.toPayload(),
|
||||
status: 'PENDING',
|
||||
},
|
||||
});
|
||||
|
||||
this.logger.log(`[CDC] Published fUSDT injection request: orderId=${originalAdoptionId}, amount=${event.amount} (${treeCount} trees × ${AdoptionFusdtInjectionRequestedEvent.FUSDT_PER_TREE})`);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -51,14 +51,17 @@ export class CDCEventDispatcher implements OnModuleInit {
|
|||
this.handleAdoptionPostCommit.bind(this),
|
||||
);
|
||||
|
||||
// 启动 CDC 消费者
|
||||
try {
|
||||
await this.cdcConsumer.start();
|
||||
this.logger.log('CDC event dispatcher started with transactional idempotency');
|
||||
} catch (error) {
|
||||
this.logger.error('Failed to start CDC event dispatcher', error);
|
||||
// 不抛出错误,允许服务在没有 Kafka 的情况下启动(用于本地开发)
|
||||
}
|
||||
// 非阻塞启动 CDC 消费者
|
||||
// 让 HTTP 服务器先启动,CDC 同步在后台进行
|
||||
// 脚本通过 /health/cdc-sync API 轮询同步状态
|
||||
this.cdcConsumer.start()
|
||||
.then(() => {
|
||||
this.logger.log('CDC event dispatcher started with transactional idempotency');
|
||||
})
|
||||
.catch((error) => {
|
||||
this.logger.error('Failed to start CDC event dispatcher', error);
|
||||
// 不抛出错误,允许服务在没有 Kafka 的情况下启动(用于本地开发)
|
||||
});
|
||||
}
|
||||
|
||||
private async handleUserEvent(event: CDCEvent, tx: TransactionClient): Promise<void> {
|
||||
|
|
|
|||
|
|
@ -1,26 +1,12 @@
|
|||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { CDCEvent, TransactionClient } from '../../infrastructure/kafka/cdc-consumer.service';
|
||||
import { ReferralSyncedEvent } from '../../domain/events/referral-synced.event';
|
||||
|
||||
/**
|
||||
* 引荐关系 CDC 事件处理器
|
||||
* 处理从1.0 referral-service同步过来的referral_relationships数据
|
||||
*
|
||||
* 1.0 表结构 (referral_relationships):
|
||||
* - user_id: BigInt (用户ID)
|
||||
* - account_sequence: String (账户序列号)
|
||||
* - referrer_id: BigInt (推荐人用户ID, 注意:不是 account_sequence)
|
||||
* - ancestor_path: BigInt[] (祖先路径数组,存储 user_id)
|
||||
* - depth: Int (层级深度)
|
||||
*
|
||||
* 2.0 存储策略:
|
||||
* - 保存 original_user_id (1.0 的 user_id)
|
||||
* - 保存 referrer_user_id (1.0 的 referrer_id)
|
||||
* - 尝试查找 referrer 的 account_sequence 并保存
|
||||
* - ancestor_path 转换为逗号分隔的字符串
|
||||
*
|
||||
* 注意:此 handler 现在接收外部传入的事务客户端(tx),
|
||||
* 所有数据库操作都必须使用此事务客户端执行,
|
||||
* 以确保幂等记录和业务数据在同一事务中处理。
|
||||
* 设计说明:100%同步数据,不跳过任何字段更新
|
||||
*/
|
||||
@Injectable()
|
||||
export class ReferralSyncedHandler {
|
||||
|
|
@ -61,12 +47,11 @@ export class ReferralSyncedHandler {
|
|||
return;
|
||||
}
|
||||
|
||||
// 1.0 字段映射
|
||||
const accountSequence = data.account_sequence || data.accountSequence;
|
||||
const originalUserId = data.user_id || data.userId;
|
||||
const referrerUserId = data.referrer_id || data.referrerId;
|
||||
const ancestorPathArray = data.ancestor_path || data.ancestorPath;
|
||||
const depth = data.depth || 0;
|
||||
const depth = data.depth ?? 0;
|
||||
|
||||
this.logger.log(`[CDC] Referral create: account=${accountSequence}, userId=${originalUserId}, referrerId=${referrerUserId}, depth=${depth}`);
|
||||
|
||||
|
|
@ -75,11 +60,9 @@ export class ReferralSyncedHandler {
|
|||
return;
|
||||
}
|
||||
|
||||
// 将 BigInt[] 转换为逗号分隔的字符串
|
||||
const ancestorPath = this.convertAncestorPath(ancestorPathArray);
|
||||
this.logger.debug(`[CDC] Referral ancestorPath converted: ${ancestorPath}`);
|
||||
|
||||
// 尝试查找推荐人的 account_sequence(使用事务客户端)
|
||||
// 尝试查找推荐人的 account_sequence
|
||||
let referrerAccountSequence: string | null = null;
|
||||
if (referrerUserId) {
|
||||
const referrer = await tx.syncedReferral.findFirst({
|
||||
|
|
@ -87,14 +70,10 @@ export class ReferralSyncedHandler {
|
|||
});
|
||||
if (referrer) {
|
||||
referrerAccountSequence = referrer.accountSequence;
|
||||
this.logger.debug(`[CDC] Found referrer account_sequence: ${referrerAccountSequence} for referrer_id: ${referrerUserId}`);
|
||||
} else {
|
||||
this.logger.log(`[CDC] Referrer user_id ${referrerUserId} not found yet for ${accountSequence}, will resolve later`);
|
||||
}
|
||||
}
|
||||
|
||||
// 使用外部事务客户端执行所有操作
|
||||
this.logger.log(`[CDC] Upserting synced referral: ${accountSequence}`);
|
||||
// 100%同步数据
|
||||
await tx.syncedReferral.upsert({
|
||||
where: { accountSequence },
|
||||
create: {
|
||||
|
|
@ -108,17 +87,20 @@ export class ReferralSyncedHandler {
|
|||
syncedAt: new Date(),
|
||||
},
|
||||
update: {
|
||||
referrerAccountSequence: referrerAccountSequence ?? undefined,
|
||||
referrerUserId: referrerUserId ? BigInt(referrerUserId) : undefined,
|
||||
originalUserId: originalUserId ? BigInt(originalUserId) : undefined,
|
||||
ancestorPath: ancestorPath ?? undefined,
|
||||
depth: depth ?? undefined,
|
||||
referrerAccountSequence,
|
||||
referrerUserId: referrerUserId ? BigInt(referrerUserId) : null,
|
||||
originalUserId: originalUserId ? BigInt(originalUserId) : null,
|
||||
ancestorPath,
|
||||
depth,
|
||||
sourceSequenceNum: sequenceNum,
|
||||
syncedAt: new Date(),
|
||||
},
|
||||
});
|
||||
|
||||
this.logger.log(`[CDC] Referral synced successfully: ${accountSequence} (user_id: ${originalUserId}) -> referrer_id: ${referrerUserId || 'none'}, depth: ${depth}`);
|
||||
this.logger.log(`[CDC] Referral synced: ${accountSequence}, referrerId=${referrerUserId || 'none'}, depth=${depth}`);
|
||||
|
||||
// 发布 ReferralSynced outbox 事件,实时同步到 mining-admin-service
|
||||
await this.publishReferralOutboxEvent(tx, accountSequence, referrerAccountSequence, referrerUserId ? BigInt(referrerUserId) : null, originalUserId ? BigInt(originalUserId) : null, ancestorPath, depth);
|
||||
}
|
||||
|
||||
private async handleUpdate(data: any, sequenceNum: bigint, tx: TransactionClient): Promise<void> {
|
||||
|
|
@ -131,7 +113,7 @@ export class ReferralSyncedHandler {
|
|||
const originalUserId = data.user_id || data.userId;
|
||||
const referrerUserId = data.referrer_id || data.referrerId;
|
||||
const ancestorPathArray = data.ancestor_path || data.ancestorPath;
|
||||
const depth = data.depth || 0;
|
||||
const depth = data.depth ?? 0;
|
||||
|
||||
this.logger.log(`[CDC] Referral update: account=${accountSequence}, referrerId=${referrerUserId}, depth=${depth}`);
|
||||
|
||||
|
|
@ -142,7 +124,7 @@ export class ReferralSyncedHandler {
|
|||
|
||||
const ancestorPath = this.convertAncestorPath(ancestorPathArray);
|
||||
|
||||
// 尝试查找推荐人的 account_sequence(使用事务客户端)
|
||||
// 尝试查找推荐人的 account_sequence
|
||||
let referrerAccountSequence: string | null = null;
|
||||
if (referrerUserId) {
|
||||
const referrer = await tx.syncedReferral.findFirst({
|
||||
|
|
@ -150,10 +132,10 @@ export class ReferralSyncedHandler {
|
|||
});
|
||||
if (referrer) {
|
||||
referrerAccountSequence = referrer.accountSequence;
|
||||
this.logger.debug(`[CDC] Found referrer account_sequence: ${referrerAccountSequence}`);
|
||||
}
|
||||
}
|
||||
|
||||
// 100%同步数据
|
||||
await tx.syncedReferral.upsert({
|
||||
where: { accountSequence },
|
||||
create: {
|
||||
|
|
@ -167,17 +149,20 @@ export class ReferralSyncedHandler {
|
|||
syncedAt: new Date(),
|
||||
},
|
||||
update: {
|
||||
referrerAccountSequence: referrerAccountSequence ?? undefined,
|
||||
referrerUserId: referrerUserId ? BigInt(referrerUserId) : undefined,
|
||||
originalUserId: originalUserId ? BigInt(originalUserId) : undefined,
|
||||
ancestorPath: ancestorPath ?? undefined,
|
||||
depth: depth ?? undefined,
|
||||
referrerAccountSequence,
|
||||
referrerUserId: referrerUserId ? BigInt(referrerUserId) : null,
|
||||
originalUserId: originalUserId ? BigInt(originalUserId) : null,
|
||||
ancestorPath,
|
||||
depth,
|
||||
sourceSequenceNum: sequenceNum,
|
||||
syncedAt: new Date(),
|
||||
},
|
||||
});
|
||||
|
||||
this.logger.log(`[CDC] Referral updated successfully: ${accountSequence}`);
|
||||
this.logger.log(`[CDC] Referral synced: ${accountSequence}`);
|
||||
|
||||
// 发布 ReferralSynced outbox 事件,实时同步到 mining-admin-service
|
||||
await this.publishReferralOutboxEvent(tx, accountSequence, referrerAccountSequence, referrerUserId ? BigInt(referrerUserId) : null, originalUserId ? BigInt(originalUserId) : null, ancestorPath, depth);
|
||||
}
|
||||
|
||||
private async handleDelete(data: any): Promise<void> {
|
||||
|
|
@ -190,6 +175,43 @@ export class ReferralSyncedHandler {
|
|||
this.logger.warn(`[CDC] Referral delete event received: ${accountSequence} (not processed, keeping history)`);
|
||||
}
|
||||
|
||||
/**
|
||||
* 在事务内发布 ReferralSynced outbox 事件
|
||||
* 确保 mining-admin-service 能实时收到推荐关系变更
|
||||
*/
|
||||
private async publishReferralOutboxEvent(
|
||||
tx: TransactionClient,
|
||||
accountSequence: string,
|
||||
referrerAccountSequence: string | null,
|
||||
referrerUserId: bigint | null,
|
||||
originalUserId: bigint | null,
|
||||
ancestorPath: string | null,
|
||||
depth: number,
|
||||
): Promise<void> {
|
||||
const event = new ReferralSyncedEvent(
|
||||
accountSequence,
|
||||
referrerAccountSequence,
|
||||
referrerUserId,
|
||||
originalUserId,
|
||||
ancestorPath,
|
||||
depth,
|
||||
);
|
||||
|
||||
await tx.outboxEvent.create({
|
||||
data: {
|
||||
aggregateType: ReferralSyncedEvent.AGGREGATE_TYPE,
|
||||
aggregateId: accountSequence,
|
||||
eventType: ReferralSyncedEvent.EVENT_TYPE,
|
||||
topic: 'contribution.referralsynced',
|
||||
key: accountSequence,
|
||||
payload: event.toPayload(),
|
||||
status: 'PENDING',
|
||||
},
|
||||
});
|
||||
|
||||
this.logger.debug(`[CDC] Published ReferralSynced outbox event: account=${accountSequence}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* 将 BigInt[] 数组转换为逗号分隔的字符串
|
||||
* @param ancestorPath BigInt 数组或 null
|
||||
|
|
|
|||
|
|
@ -6,9 +6,7 @@ import { ContributionAccountAggregate } from '../../domain/aggregates/contributi
|
|||
* 用户 CDC 事件处理器
|
||||
* 处理从身份服务同步过来的用户数据
|
||||
*
|
||||
* 注意:此 handler 现在接收外部传入的事务客户端(tx),
|
||||
* 所有数据库操作都必须使用此事务客户端执行,
|
||||
* 以确保幂等记录和业务数据在同一事务中处理。
|
||||
* 设计说明:100%同步数据,不跳过任何字段更新
|
||||
*/
|
||||
@Injectable()
|
||||
export class UserSyncedHandler {
|
||||
|
|
@ -49,22 +47,19 @@ export class UserSyncedHandler {
|
|||
return;
|
||||
}
|
||||
|
||||
// 兼容不同的字段命名(CDC 使用 snake_case)
|
||||
const userId = data.user_id ?? data.id;
|
||||
const accountSequence = data.account_sequence ?? data.accountSequence;
|
||||
const phone = data.phone_number ?? data.phone ?? null;
|
||||
const status = data.status ?? 'ACTIVE';
|
||||
const status = data.status ?? null;
|
||||
|
||||
this.logger.log(`[CDC] User create: userId=${userId}, accountSequence=${accountSequence}, phone=${phone}, status=${status}`);
|
||||
this.logger.log(`[CDC] User create: userId=${userId}, accountSequence=${accountSequence}, status=${status}`);
|
||||
|
||||
if (!userId || !accountSequence) {
|
||||
this.logger.warn(`[CDC] Invalid user data: missing user_id or account_sequence`, { data });
|
||||
return;
|
||||
}
|
||||
|
||||
// 使用外部事务客户端执行所有操作
|
||||
// 保存同步的用户数据
|
||||
this.logger.log(`[CDC] Upserting synced user: ${accountSequence}`);
|
||||
// 100%同步数据
|
||||
await tx.syncedUser.upsert({
|
||||
where: { accountSequence },
|
||||
create: {
|
||||
|
|
@ -76,8 +71,9 @@ export class UserSyncedHandler {
|
|||
syncedAt: new Date(),
|
||||
},
|
||||
update: {
|
||||
phone: phone ?? undefined,
|
||||
status: status ?? undefined,
|
||||
originalUserId: BigInt(userId),
|
||||
phone,
|
||||
status,
|
||||
sourceSequenceNum: sequenceNum,
|
||||
syncedAt: new Date(),
|
||||
},
|
||||
|
|
@ -95,11 +91,9 @@ export class UserSyncedHandler {
|
|||
data: persistData,
|
||||
});
|
||||
this.logger.log(`[CDC] Created contribution account for user: ${accountSequence}`);
|
||||
} else {
|
||||
this.logger.debug(`[CDC] Contribution account already exists for user: ${accountSequence}`);
|
||||
}
|
||||
|
||||
this.logger.log(`[CDC] User synced successfully: ${accountSequence}`);
|
||||
this.logger.log(`[CDC] User synced: ${accountSequence}`);
|
||||
}
|
||||
|
||||
private async handleUpdate(data: any, sequenceNum: bigint, tx: TransactionClient): Promise<void> {
|
||||
|
|
@ -108,11 +102,10 @@ export class UserSyncedHandler {
|
|||
return;
|
||||
}
|
||||
|
||||
// 兼容不同的字段命名(CDC 使用 snake_case)
|
||||
const userId = data.user_id ?? data.id;
|
||||
const accountSequence = data.account_sequence ?? data.accountSequence;
|
||||
const phone = data.phone_number ?? data.phone ?? null;
|
||||
const status = data.status ?? 'ACTIVE';
|
||||
const status = data.status ?? null;
|
||||
|
||||
this.logger.log(`[CDC] User update: userId=${userId}, accountSequence=${accountSequence}, status=${status}`);
|
||||
|
||||
|
|
@ -121,6 +114,7 @@ export class UserSyncedHandler {
|
|||
return;
|
||||
}
|
||||
|
||||
// 100%同步数据
|
||||
await tx.syncedUser.upsert({
|
||||
where: { accountSequence },
|
||||
create: {
|
||||
|
|
@ -132,14 +126,15 @@ export class UserSyncedHandler {
|
|||
syncedAt: new Date(),
|
||||
},
|
||||
update: {
|
||||
phone: phone ?? undefined,
|
||||
status: status ?? undefined,
|
||||
originalUserId: BigInt(userId),
|
||||
phone,
|
||||
status,
|
||||
sourceSequenceNum: sequenceNum,
|
||||
syncedAt: new Date(),
|
||||
},
|
||||
});
|
||||
|
||||
this.logger.log(`[CDC] User updated successfully: ${accountSequence}`);
|
||||
this.logger.log(`[CDC] User synced: ${accountSequence}`);
|
||||
}
|
||||
|
||||
private async handleDelete(data: any): Promise<void> {
|
||||
|
|
|
|||
|
|
@ -183,16 +183,16 @@ export class GetContributionAccountQuery {
|
|||
|
||||
private toRecordDto(record: any): ContributionRecordDto {
|
||||
return {
|
||||
id: record.id,
|
||||
id: record.id?.toString() ?? '',
|
||||
sourceType: record.sourceType,
|
||||
sourceAdoptionId: record.sourceAdoptionId,
|
||||
sourceAdoptionId: record.sourceAdoptionId?.toString() ?? '',
|
||||
sourceAccountSequence: record.sourceAccountSequence,
|
||||
treeCount: record.treeCount,
|
||||
baseContribution: record.baseContribution.value.toString(),
|
||||
distributionRate: record.distributionRate.value.toString(),
|
||||
baseContribution: record.baseContribution?.value?.toString() ?? '0',
|
||||
distributionRate: record.distributionRate?.value?.toString() ?? '0',
|
||||
levelDepth: record.levelDepth,
|
||||
bonusTier: record.bonusTier,
|
||||
finalContribution: record.finalContribution.value.toString(),
|
||||
finalContribution: record.amount?.value?.toString() ?? '0',
|
||||
effectiveDate: record.effectiveDate,
|
||||
expireDate: record.expireDate,
|
||||
isExpired: record.isExpired,
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import { Injectable } from '@nestjs/common';
|
||||
import Decimal from 'decimal.js';
|
||||
import { ContributionAccountRepository } from '../../infrastructure/persistence/repositories/contribution-account.repository';
|
||||
import { ContributionRecordRepository } from '../../infrastructure/persistence/repositories/contribution-record.repository';
|
||||
import { UnallocatedContributionRepository } from '../../infrastructure/persistence/repositories/unallocated-contribution.repository';
|
||||
|
|
@ -6,6 +7,15 @@ import { SystemAccountRepository } from '../../infrastructure/persistence/reposi
|
|||
import { SyncedDataRepository } from '../../infrastructure/persistence/repositories/synced-data.repository';
|
||||
import { ContributionSourceType } from '../../domain/aggregates/contribution-account.aggregate';
|
||||
|
||||
// 基准算力常量
|
||||
const BASE_CONTRIBUTION_PER_TREE = new Decimal('22617');
|
||||
const RATE_PERSONAL = new Decimal('0.70');
|
||||
const RATE_OPERATION = new Decimal('0.12');
|
||||
const RATE_PROVINCE = new Decimal('0.01');
|
||||
const RATE_CITY = new Decimal('0.02');
|
||||
const RATE_LEVEL_TOTAL = new Decimal('0.075');
|
||||
const RATE_BONUS_TOTAL = new Decimal('0.075');
|
||||
|
||||
export interface ContributionStatsDto {
|
||||
// 用户统计
|
||||
totalUsers: number;
|
||||
|
|
@ -16,17 +26,57 @@ export interface ContributionStatsDto {
|
|||
totalAdoptions: number;
|
||||
processedAdoptions: number;
|
||||
unprocessedAdoptions: number;
|
||||
totalTrees: number;
|
||||
|
||||
// 算力统计
|
||||
totalContribution: string;
|
||||
|
||||
// 算力分布
|
||||
// 算力分布(基础)
|
||||
contributionByType: {
|
||||
personal: string;
|
||||
teamLevel: string;
|
||||
teamBonus: string;
|
||||
};
|
||||
|
||||
// ========== 详细算力分解(按用户需求) ==========
|
||||
// 全网算力 = 总认种树 * 22617
|
||||
networkTotalContribution: string;
|
||||
// 个人用户总算力 = 总认种树 * (22617 * 70%)
|
||||
personalTotalContribution: string;
|
||||
// 运营账户总算力 = 总认种树 * (22617 * 12%)
|
||||
operationTotalContribution: string;
|
||||
// 省公司总算力 = 总认种树 * (22617 * 1%)
|
||||
provinceTotalContribution: string;
|
||||
// 市公司总算力 = 总认种树 * (22617 * 2%)
|
||||
cityTotalContribution: string;
|
||||
|
||||
// 层级算力详情 (7.5%)
|
||||
levelContribution: {
|
||||
total: string;
|
||||
unlocked: string;
|
||||
pending: string;
|
||||
byTier: {
|
||||
// 1档: 1-5级
|
||||
tier1: { unlocked: string; pending: string };
|
||||
// 2档: 6-10级
|
||||
tier2: { unlocked: string; pending: string };
|
||||
// 3档: 11-15级
|
||||
tier3: { unlocked: string; pending: string };
|
||||
};
|
||||
};
|
||||
|
||||
// 团队奖励算力详情 (7.5%)
|
||||
bonusContribution: {
|
||||
total: string;
|
||||
unlocked: string;
|
||||
pending: string;
|
||||
byTier: {
|
||||
tier1: { unlocked: string; pending: string };
|
||||
tier2: { unlocked: string; pending: string };
|
||||
tier3: { unlocked: string; pending: string };
|
||||
};
|
||||
};
|
||||
|
||||
// 系统账户
|
||||
systemAccounts: {
|
||||
accountType: string;
|
||||
|
|
@ -61,6 +111,10 @@ export class GetContributionStatsQuery {
|
|||
systemAccounts,
|
||||
totalUnallocated,
|
||||
unallocatedByType,
|
||||
detailedStats,
|
||||
unallocatedByLevelTier,
|
||||
unallocatedByBonusTier,
|
||||
totalTrees,
|
||||
] = await Promise.all([
|
||||
this.syncedDataRepository.countUsers(),
|
||||
this.accountRepository.countAccounts(),
|
||||
|
|
@ -72,8 +126,33 @@ export class GetContributionStatsQuery {
|
|||
this.systemAccountRepository.findAll(),
|
||||
this.unallocatedRepository.getTotalUnallocated(),
|
||||
this.unallocatedRepository.getTotalUnallocatedByType(),
|
||||
this.accountRepository.getDetailedContributionStats(),
|
||||
this.unallocatedRepository.getUnallocatedByLevelTier(),
|
||||
this.unallocatedRepository.getUnallocatedByBonusTier(),
|
||||
this.syncedDataRepository.getTotalTrees(),
|
||||
]);
|
||||
|
||||
// 计算理论算力(基于总认种树 * 基准算力)
|
||||
const networkTotal = BASE_CONTRIBUTION_PER_TREE.mul(totalTrees);
|
||||
const personalTotal = networkTotal.mul(RATE_PERSONAL);
|
||||
const operationTotal = networkTotal.mul(RATE_OPERATION);
|
||||
const provinceTotal = networkTotal.mul(RATE_PROVINCE);
|
||||
const cityTotal = networkTotal.mul(RATE_CITY);
|
||||
const levelTotal = networkTotal.mul(RATE_LEVEL_TOTAL);
|
||||
const bonusTotal = networkTotal.mul(RATE_BONUS_TOTAL);
|
||||
|
||||
// 层级算力: 已解锁 + 未解锁
|
||||
const levelUnlocked = new Decimal(detailedStats.levelUnlocked);
|
||||
const levelPending = new Decimal(unallocatedByLevelTier.tier1)
|
||||
.plus(unallocatedByLevelTier.tier2)
|
||||
.plus(unallocatedByLevelTier.tier3);
|
||||
|
||||
// 团队奖励算力: 已解锁 + 未解锁
|
||||
const bonusUnlocked = new Decimal(detailedStats.bonusUnlocked);
|
||||
const bonusPending = new Decimal(unallocatedByBonusTier.tier1)
|
||||
.plus(unallocatedByBonusTier.tier2)
|
||||
.plus(unallocatedByBonusTier.tier3);
|
||||
|
||||
return {
|
||||
totalUsers,
|
||||
totalAccounts,
|
||||
|
|
@ -81,12 +160,63 @@ export class GetContributionStatsQuery {
|
|||
totalAdoptions,
|
||||
processedAdoptions: totalAdoptions - undistributedAdoptions,
|
||||
unprocessedAdoptions: undistributedAdoptions,
|
||||
totalTrees,
|
||||
totalContribution: totalContribution.value.toString(),
|
||||
contributionByType: {
|
||||
personal: (contributionByType.get(ContributionSourceType.PERSONAL)?.value || 0).toString(),
|
||||
teamLevel: (contributionByType.get(ContributionSourceType.TEAM_LEVEL)?.value || 0).toString(),
|
||||
teamBonus: (contributionByType.get(ContributionSourceType.TEAM_BONUS)?.value || 0).toString(),
|
||||
},
|
||||
|
||||
// 详细算力分解
|
||||
networkTotalContribution: networkTotal.toString(),
|
||||
personalTotalContribution: personalTotal.toString(),
|
||||
operationTotalContribution: operationTotal.toString(),
|
||||
provinceTotalContribution: provinceTotal.toString(),
|
||||
cityTotalContribution: cityTotal.toString(),
|
||||
|
||||
// 层级算力详情
|
||||
levelContribution: {
|
||||
total: levelTotal.toString(),
|
||||
unlocked: levelUnlocked.toString(),
|
||||
pending: levelPending.toString(),
|
||||
byTier: {
|
||||
tier1: {
|
||||
unlocked: detailedStats.levelByTier.tier1.unlocked,
|
||||
pending: unallocatedByLevelTier.tier1,
|
||||
},
|
||||
tier2: {
|
||||
unlocked: detailedStats.levelByTier.tier2.unlocked,
|
||||
pending: unallocatedByLevelTier.tier2,
|
||||
},
|
||||
tier3: {
|
||||
unlocked: detailedStats.levelByTier.tier3.unlocked,
|
||||
pending: unallocatedByLevelTier.tier3,
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
// 团队奖励算力详情
|
||||
bonusContribution: {
|
||||
total: bonusTotal.toString(),
|
||||
unlocked: bonusUnlocked.toString(),
|
||||
pending: bonusPending.toString(),
|
||||
byTier: {
|
||||
tier1: {
|
||||
unlocked: detailedStats.bonusByTier.tier1.unlocked,
|
||||
pending: unallocatedByBonusTier.tier1,
|
||||
},
|
||||
tier2: {
|
||||
unlocked: detailedStats.bonusByTier.tier2.unlocked,
|
||||
pending: unallocatedByBonusTier.tier2,
|
||||
},
|
||||
tier3: {
|
||||
unlocked: detailedStats.bonusByTier.tier3.unlocked,
|
||||
pending: unallocatedByBonusTier.tier3,
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
systemAccounts: systemAccounts.map((a) => ({
|
||||
accountType: a.accountType,
|
||||
name: a.name,
|
||||
|
|
@ -98,4 +228,5 @@ export class GetContributionStatsQuery {
|
|||
),
|
||||
};
|
||||
}
|
||||
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,85 @@
|
|||
import { Injectable } from '@nestjs/common';
|
||||
import { SyncedDataRepository } from '../../infrastructure/persistence/repositories/synced-data.repository';
|
||||
import { ContributionAccountRepository } from '../../infrastructure/persistence/repositories/contribution-account.repository';
|
||||
|
||||
export interface PlantingRecordDto {
|
||||
orderId: string;
|
||||
orderNo: string;
|
||||
originalAdoptionId: string;
|
||||
treeCount: number;
|
||||
contributionPerTree: string;
|
||||
totalContribution: string;
|
||||
status: string;
|
||||
adoptionDate: string | null;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
export interface PlantingSummaryDto {
|
||||
totalOrders: number;
|
||||
totalTreeCount: number;
|
||||
totalAmount: string;
|
||||
effectiveTreeCount: number;
|
||||
/** 用户实际的有效贡献值(个人算力) */
|
||||
effectiveContribution: string;
|
||||
firstPlantingAt: string | null;
|
||||
lastPlantingAt: string | null;
|
||||
}
|
||||
|
||||
export interface PlantingLedgerDto {
|
||||
summary: PlantingSummaryDto;
|
||||
items: PlantingRecordDto[];
|
||||
total: number;
|
||||
page: number;
|
||||
pageSize: number;
|
||||
totalPages: number;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class GetPlantingLedgerQuery {
|
||||
constructor(
|
||||
private readonly syncedDataRepository: SyncedDataRepository,
|
||||
private readonly contributionAccountRepository: ContributionAccountRepository,
|
||||
) {}
|
||||
|
||||
async execute(
|
||||
accountSequence: string,
|
||||
page: number = 1,
|
||||
pageSize: number = 20,
|
||||
): Promise<PlantingLedgerDto> {
|
||||
const [summary, ledger, contributionAccount] = await Promise.all([
|
||||
this.syncedDataRepository.getPlantingSummary(accountSequence),
|
||||
this.syncedDataRepository.getPlantingLedger(accountSequence, page, pageSize),
|
||||
this.contributionAccountRepository.findByAccountSequence(accountSequence),
|
||||
]);
|
||||
|
||||
// 获取用户实际的有效贡献值(个人算力)
|
||||
const effectiveContribution = contributionAccount?.personalContribution.toString() || '0';
|
||||
|
||||
return {
|
||||
summary: {
|
||||
totalOrders: summary.totalOrders,
|
||||
totalTreeCount: summary.totalTreeCount,
|
||||
totalAmount: summary.totalAmount,
|
||||
effectiveTreeCount: summary.effectiveTreeCount,
|
||||
effectiveContribution,
|
||||
firstPlantingAt: summary.firstPlantingAt?.toISOString() || null,
|
||||
lastPlantingAt: summary.lastPlantingAt?.toISOString() || null,
|
||||
},
|
||||
items: ledger.items.map((item) => ({
|
||||
orderId: item.id.toString(),
|
||||
orderNo: `ORD-${item.originalAdoptionId}`,
|
||||
originalAdoptionId: item.originalAdoptionId.toString(),
|
||||
treeCount: item.treeCount,
|
||||
contributionPerTree: item.contributionPerTree.toString(),
|
||||
totalContribution: item.contributionPerTree.mul(item.treeCount).toString(),
|
||||
status: item.status || 'UNKNOWN',
|
||||
adoptionDate: item.adoptionDate?.toISOString() || null,
|
||||
createdAt: item.createdAt.toISOString(),
|
||||
})),
|
||||
total: ledger.total,
|
||||
page: ledger.page,
|
||||
pageSize: ledger.pageSize,
|
||||
totalPages: ledger.totalPages,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,121 @@
|
|||
import { Injectable, Inject } from '@nestjs/common';
|
||||
import {
|
||||
ISyncedDataRepository,
|
||||
SYNCED_DATA_REPOSITORY,
|
||||
} from '../../domain/repositories/synced-data.repository.interface';
|
||||
|
||||
/**
|
||||
* 团队成员信息
|
||||
*/
|
||||
export interface TeamMemberDto {
|
||||
accountSequence: string;
|
||||
personalPlantingCount: number;
|
||||
teamPlantingCount: number;
|
||||
directReferralCount: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* 直推列表响应
|
||||
*/
|
||||
export interface DirectReferralsResponseDto {
|
||||
referrals: TeamMemberDto[];
|
||||
total: number;
|
||||
hasMore: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* 我的团队信息响应
|
||||
*/
|
||||
export interface MyTeamInfoDto {
|
||||
accountSequence: string;
|
||||
personalPlantingCount: number;
|
||||
teamPlantingCount: number;
|
||||
directReferralCount: number;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class GetTeamTreeQuery {
|
||||
constructor(
|
||||
@Inject(SYNCED_DATA_REPOSITORY)
|
||||
private readonly syncedDataRepository: ISyncedDataRepository,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* 获取当前用户的团队信息
|
||||
*/
|
||||
async getMyTeamInfo(accountSequence: string): Promise<MyTeamInfoDto> {
|
||||
// 获取个人认种棵数
|
||||
const personalPlantingCount = await this.syncedDataRepository.getTotalTreesByAccountSequence(accountSequence);
|
||||
|
||||
// 获取直推数量
|
||||
const directReferrals = await this.syncedDataRepository.findDirectReferrals(accountSequence);
|
||||
|
||||
// 获取团队认种棵数(伞下各级总和)
|
||||
const teamTreesByLevel = await this.syncedDataRepository.getTeamTreesByLevel(accountSequence, 15);
|
||||
let teamPlantingCount = 0;
|
||||
teamTreesByLevel.forEach((count) => {
|
||||
teamPlantingCount += count;
|
||||
});
|
||||
|
||||
return {
|
||||
accountSequence,
|
||||
personalPlantingCount,
|
||||
teamPlantingCount,
|
||||
directReferralCount: directReferrals.length,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取指定用户的直推列表
|
||||
*/
|
||||
async getDirectReferrals(
|
||||
accountSequence: string,
|
||||
limit: number = 100,
|
||||
offset: number = 0,
|
||||
): Promise<DirectReferralsResponseDto> {
|
||||
// 获取所有直推
|
||||
const allDirectReferrals = await this.syncedDataRepository.findDirectReferrals(accountSequence);
|
||||
|
||||
// 分页
|
||||
const total = allDirectReferrals.length;
|
||||
const paginatedReferrals = allDirectReferrals.slice(offset, offset + limit);
|
||||
|
||||
// 获取每个直推成员的详细信息
|
||||
const referrals: TeamMemberDto[] = await Promise.all(
|
||||
paginatedReferrals.map(async (ref) => {
|
||||
return this.getTeamMemberInfo(ref.accountSequence);
|
||||
}),
|
||||
);
|
||||
|
||||
return {
|
||||
referrals,
|
||||
total,
|
||||
hasMore: offset + limit < total,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取团队成员信息
|
||||
*/
|
||||
private async getTeamMemberInfo(accountSequence: string): Promise<TeamMemberDto> {
|
||||
// 获取个人认种棵数
|
||||
const personalPlantingCount = await this.syncedDataRepository.getTotalTreesByAccountSequence(accountSequence);
|
||||
|
||||
// 获取直推数量
|
||||
const directReferrals = await this.syncedDataRepository.findDirectReferrals(accountSequence);
|
||||
|
||||
// 获取团队认种棵数
|
||||
const teamTreesByLevel = await this.syncedDataRepository.getTeamTreesByLevel(accountSequence, 15);
|
||||
let teamPlantingCount = 0;
|
||||
teamTreesByLevel.forEach((count) => {
|
||||
teamPlantingCount += count;
|
||||
});
|
||||
|
||||
return {
|
||||
accountSequence,
|
||||
personalPlantingCount,
|
||||
teamPlantingCount,
|
||||
directReferralCount: directReferrals.length,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
@ -2,10 +2,14 @@ import { Injectable, Logger, OnModuleInit } from '@nestjs/common';
|
|||
import { Cron, CronExpression } from '@nestjs/schedule';
|
||||
import { ContributionCalculationService } from '../services/contribution-calculation.service';
|
||||
import { SnapshotService } from '../services/snapshot.service';
|
||||
import { BonusClaimService } from '../services/bonus-claim.service';
|
||||
import { ContributionRecordRepository } from '../../infrastructure/persistence/repositories/contribution-record.repository';
|
||||
import { ContributionAccountRepository } from '../../infrastructure/persistence/repositories/contribution-account.repository';
|
||||
import { OutboxRepository } from '../../infrastructure/persistence/repositories/outbox.repository';
|
||||
import { KafkaProducerService } from '../../infrastructure/kafka/kafka-producer.service';
|
||||
import { RedisService } from '../../infrastructure/redis/redis.service';
|
||||
import { CDCConsumerService } from '../../infrastructure/kafka/cdc-consumer.service';
|
||||
import { ContributionAccountUpdatedEvent } from '../../domain/events';
|
||||
|
||||
/**
|
||||
* 算力相关定时任务
|
||||
|
|
@ -18,14 +22,24 @@ export class ContributionScheduler implements OnModuleInit {
|
|||
constructor(
|
||||
private readonly calculationService: ContributionCalculationService,
|
||||
private readonly snapshotService: SnapshotService,
|
||||
private readonly bonusClaimService: BonusClaimService,
|
||||
private readonly contributionRecordRepository: ContributionRecordRepository,
|
||||
private readonly contributionAccountRepository: ContributionAccountRepository,
|
||||
private readonly outboxRepository: OutboxRepository,
|
||||
private readonly kafkaProducer: KafkaProducerService,
|
||||
private readonly redis: RedisService,
|
||||
private readonly cdcConsumer: CDCConsumerService,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* CDC 初始同步是否完成(用户、推荐、认种三阶段全部完成)
|
||||
*/
|
||||
private isCdcReady(): boolean {
|
||||
return this.cdcConsumer.getSyncStatus().allPhasesCompleted;
|
||||
}
|
||||
|
||||
async onModuleInit() {
|
||||
this.logger.log('Contribution scheduler initialized');
|
||||
this.logger.log('Contribution scheduler initialized, waiting for CDC initial sync to complete...');
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -33,6 +47,11 @@ export class ContributionScheduler implements OnModuleInit {
|
|||
*/
|
||||
@Cron(CronExpression.EVERY_MINUTE)
|
||||
async processUnprocessedAdoptions(): Promise<void> {
|
||||
if (!this.isCdcReady()) {
|
||||
this.logger.debug('[CDC-Gate] processUnprocessedAdoptions skipped: CDC initial sync not yet completed');
|
||||
return;
|
||||
}
|
||||
|
||||
const lockValue = await this.redis.acquireLock(`${this.LOCK_KEY}:process`, 55);
|
||||
if (!lockValue) {
|
||||
return; // 其他实例正在处理
|
||||
|
|
@ -174,4 +193,220 @@ export class ContributionScheduler implements OnModuleInit {
|
|||
await this.redis.releaseLock(`${this.LOCK_KEY}:cleanup`, lockValue);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 每10分钟增量发布最近更新的贡献值账户事件
|
||||
* 只同步过去15分钟内有变更的账户,作为实时同步的补充
|
||||
*/
|
||||
@Cron('*/10 * * * *')
|
||||
async publishRecentlyUpdatedAccounts(): Promise<void> {
|
||||
if (!this.isCdcReady()) {
|
||||
this.logger.debug('[CDC-Gate] publishRecentlyUpdatedAccounts skipped: CDC initial sync not yet completed');
|
||||
return;
|
||||
}
|
||||
|
||||
const lockValue = await this.redis.acquireLock(`${this.LOCK_KEY}:incremental-sync`, 540); // 9分钟锁
|
||||
if (!lockValue) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
// 查找过去15分钟内更新的账户(比10分钟多5分钟余量,避免遗漏边界情况)
|
||||
const fifteenMinutesAgo = new Date(Date.now() - 15 * 60 * 1000);
|
||||
|
||||
const accounts = await this.contributionAccountRepository.findRecentlyUpdated(fifteenMinutesAgo, 500);
|
||||
|
||||
if (accounts.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const events = accounts.map((account) => {
|
||||
const event = new ContributionAccountUpdatedEvent(
|
||||
account.accountSequence,
|
||||
account.personalContribution.value.toString(),
|
||||
account.totalLevelPending.value.toString(),
|
||||
account.totalBonusPending.value.toString(),
|
||||
account.effectiveContribution.value.toString(),
|
||||
account.effectiveContribution.value.toString(),
|
||||
account.hasAdopted,
|
||||
account.directReferralAdoptedCount,
|
||||
account.unlockedLevelDepth,
|
||||
account.unlockedBonusTiers,
|
||||
account.createdAt,
|
||||
);
|
||||
|
||||
return {
|
||||
aggregateType: ContributionAccountUpdatedEvent.AGGREGATE_TYPE,
|
||||
aggregateId: account.accountSequence,
|
||||
eventType: ContributionAccountUpdatedEvent.EVENT_TYPE,
|
||||
payload: event.toPayload(),
|
||||
};
|
||||
});
|
||||
|
||||
await this.outboxRepository.saveMany(events);
|
||||
|
||||
this.logger.log(`Incremental sync: published ${accounts.length} recently updated accounts`);
|
||||
} catch (error) {
|
||||
this.logger.error('Failed to publish recently updated accounts', error);
|
||||
} finally {
|
||||
await this.redis.releaseLock(`${this.LOCK_KEY}:incremental-sync`, lockValue);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 每10分钟回收已解锁层级的 LEVEL_OVERFLOW 记录
|
||||
* 处理场景:下级认种时上级 unlocked_level_depth 不足导致 overflow,
|
||||
* 上级后续解锁后这些 PENDING 记录需要被回收
|
||||
*/
|
||||
@Cron('*/10 * * * *')
|
||||
async processLevelOverflowReclaim(): Promise<void> {
|
||||
if (!this.isCdcReady()) {
|
||||
this.logger.debug('[CDC-Gate] processLevelOverflowReclaim skipped: CDC initial sync not yet completed');
|
||||
return;
|
||||
}
|
||||
|
||||
const lockValue = await this.redis.acquireLock(`${this.LOCK_KEY}:overflow-reclaim`, 540); // 9分钟锁
|
||||
if (!lockValue) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const reclaimed = await this.bonusClaimService.reclaimLevelOverflows();
|
||||
if (reclaimed > 0) {
|
||||
this.logger.log(`Level overflow reclaim: ${reclaimed} records reclaimed`);
|
||||
}
|
||||
} catch (error) {
|
||||
this.logger.error('Failed to process level overflow reclaim', error);
|
||||
} finally {
|
||||
await this.redis.releaseLock(`${this.LOCK_KEY}:overflow-reclaim`, lockValue);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 每10分钟扫描并补发未完全解锁的贡献值
|
||||
* 处理因下级先于上级认种导致的层级/奖励档位未能及时分配的情况
|
||||
*/
|
||||
@Cron('*/10 * * * *')
|
||||
async processContributionBackfill(): Promise<void> {
|
||||
if (!this.isCdcReady()) {
|
||||
this.logger.debug('[CDC-Gate] processContributionBackfill skipped: CDC initial sync not yet completed');
|
||||
return;
|
||||
}
|
||||
|
||||
const lockValue = await this.redis.acquireLock(`${this.LOCK_KEY}:backfill`, 540); // 9分钟锁
|
||||
if (!lockValue) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
this.logger.log('Starting contribution backfill scan...');
|
||||
|
||||
// 查找解锁状态不完整的账户(已认种但层级<15或奖励档位<3)
|
||||
const accounts = await this.contributionAccountRepository.findAccountsWithIncompleteUnlock(100);
|
||||
|
||||
if (accounts.length === 0) {
|
||||
this.logger.debug('No accounts with incomplete unlock status found');
|
||||
return;
|
||||
}
|
||||
|
||||
this.logger.log(`Found ${accounts.length} accounts with incomplete unlock status`);
|
||||
|
||||
let backfilledCount = 0;
|
||||
let errorCount = 0;
|
||||
|
||||
for (const account of accounts) {
|
||||
try {
|
||||
const hasBackfill = await this.bonusClaimService.processBackfillForAccount(account.accountSequence);
|
||||
if (hasBackfill) {
|
||||
backfilledCount++;
|
||||
}
|
||||
} catch (error) {
|
||||
errorCount++;
|
||||
this.logger.error(
|
||||
`Failed to process backfill for account ${account.accountSequence}`,
|
||||
error,
|
||||
);
|
||||
// 继续处理下一个账户
|
||||
}
|
||||
}
|
||||
|
||||
this.logger.log(
|
||||
`Contribution backfill completed: ${backfilledCount} accounts backfilled, ${errorCount} errors`,
|
||||
);
|
||||
} catch (error) {
|
||||
this.logger.error('Failed to process contribution backfill', error);
|
||||
} finally {
|
||||
await this.redis.releaseLock(`${this.LOCK_KEY}:backfill`, lockValue);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 每天凌晨4点全量发布所有贡献值账户更新事件
|
||||
* 作为数据一致性的最终兜底保障
|
||||
*/
|
||||
@Cron('0 4 * * *')
|
||||
async publishAllAccountUpdates(): Promise<void> {
|
||||
const lockValue = await this.redis.acquireLock(`${this.LOCK_KEY}:full-sync`, 3600); // 1小时锁
|
||||
if (!lockValue) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
this.logger.log('Starting daily full sync of contribution accounts...');
|
||||
|
||||
let page = 1;
|
||||
const pageSize = 100;
|
||||
let totalPublished = 0;
|
||||
|
||||
while (true) {
|
||||
const { items: accounts, total } = await this.contributionAccountRepository.findMany({
|
||||
page,
|
||||
limit: pageSize,
|
||||
orderBy: 'effectiveContribution',
|
||||
order: 'desc',
|
||||
});
|
||||
|
||||
if (accounts.length === 0) {
|
||||
break;
|
||||
}
|
||||
|
||||
const events = accounts.map((account) => {
|
||||
const event = new ContributionAccountUpdatedEvent(
|
||||
account.accountSequence,
|
||||
account.personalContribution.value.toString(),
|
||||
account.totalLevelPending.value.toString(),
|
||||
account.totalBonusPending.value.toString(),
|
||||
account.effectiveContribution.value.toString(),
|
||||
account.effectiveContribution.value.toString(),
|
||||
account.hasAdopted,
|
||||
account.directReferralAdoptedCount,
|
||||
account.unlockedLevelDepth,
|
||||
account.unlockedBonusTiers,
|
||||
account.createdAt,
|
||||
);
|
||||
|
||||
return {
|
||||
aggregateType: ContributionAccountUpdatedEvent.AGGREGATE_TYPE,
|
||||
aggregateId: account.accountSequence,
|
||||
eventType: ContributionAccountUpdatedEvent.EVENT_TYPE,
|
||||
payload: event.toPayload(),
|
||||
};
|
||||
});
|
||||
|
||||
await this.outboxRepository.saveMany(events);
|
||||
totalPublished += accounts.length;
|
||||
|
||||
if (accounts.length < pageSize || page * pageSize >= total) {
|
||||
break;
|
||||
}
|
||||
page++;
|
||||
}
|
||||
|
||||
this.logger.log(`Daily full sync completed: published ${totalPublished} contribution account events`);
|
||||
} catch (error) {
|
||||
this.logger.error('Failed to publish all account updates', error);
|
||||
} finally {
|
||||
await this.redis.releaseLock(`${this.LOCK_KEY}:full-sync`, lockValue);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,710 @@
|
|||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { UnallocatedContributionRepository, UnallocatedContribution } from '../../infrastructure/persistence/repositories/unallocated-contribution.repository';
|
||||
import { ContributionAccountRepository } from '../../infrastructure/persistence/repositories/contribution-account.repository';
|
||||
import { ContributionRecordRepository } from '../../infrastructure/persistence/repositories/contribution-record.repository';
|
||||
import { SystemAccountRepository } from '../../infrastructure/persistence/repositories/system-account.repository';
|
||||
import { OutboxRepository } from '../../infrastructure/persistence/repositories/outbox.repository';
|
||||
import { SyncedDataRepository } from '../../infrastructure/persistence/repositories/synced-data.repository';
|
||||
import { UnitOfWork } from '../../infrastructure/persistence/unit-of-work/unit-of-work';
|
||||
import { ContributionRecordAggregate } from '../../domain/aggregates/contribution-record.aggregate';
|
||||
import { ContributionAccountAggregate, ContributionSourceType } from '../../domain/aggregates/contribution-account.aggregate';
|
||||
import { ContributionAmount } from '../../domain/value-objects/contribution-amount.vo';
|
||||
import { DistributionRate } from '../../domain/value-objects/distribution-rate.vo';
|
||||
import { ContributionCalculatorService } from '../../domain/services/contribution-calculator.service';
|
||||
import { ContributionRecordSyncedEvent, SystemAccountSyncedEvent, ContributionAccountUpdatedEvent } from '../../domain/events';
|
||||
|
||||
/**
|
||||
* 奖励补发服务
|
||||
* 当用户解锁新的奖励档位时,补发之前所有认种对应的奖励
|
||||
*/
|
||||
@Injectable()
|
||||
export class BonusClaimService {
|
||||
private readonly logger = new Logger(BonusClaimService.name);
|
||||
|
||||
constructor(
|
||||
private readonly unallocatedContributionRepository: UnallocatedContributionRepository,
|
||||
private readonly contributionAccountRepository: ContributionAccountRepository,
|
||||
private readonly contributionRecordRepository: ContributionRecordRepository,
|
||||
private readonly systemAccountRepository: SystemAccountRepository,
|
||||
private readonly outboxRepository: OutboxRepository,
|
||||
private readonly syncedDataRepository: SyncedDataRepository,
|
||||
private readonly unitOfWork: UnitOfWork,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* 检查并处理奖励补发
|
||||
* 当用户的直推认种人数变化时调用
|
||||
* @param accountSequence 用户账号
|
||||
* @param previousCount 之前的直推认种人数
|
||||
* @param newCount 新的直推认种人数
|
||||
*/
|
||||
async checkAndClaimBonus(
|
||||
accountSequence: string,
|
||||
previousCount: number,
|
||||
newCount: number,
|
||||
): Promise<void> {
|
||||
// 检查是否达到新的解锁条件
|
||||
const tiersToClaimList: number[] = [];
|
||||
|
||||
// T2: 直推≥2人认种时解锁
|
||||
if (previousCount < 2 && newCount >= 2) {
|
||||
tiersToClaimList.push(2);
|
||||
}
|
||||
|
||||
// T3: 直推≥4人认种时解锁
|
||||
if (previousCount < 4 && newCount >= 4) {
|
||||
tiersToClaimList.push(3);
|
||||
}
|
||||
|
||||
if (tiersToClaimList.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.logger.log(
|
||||
`User ${accountSequence} unlocked bonus tiers: ${tiersToClaimList.join(', ')} ` +
|
||||
`(directReferralAdoptedCount: ${previousCount} -> ${newCount})`,
|
||||
);
|
||||
|
||||
// 检查是否已在事务中(被 ContributionCalculationService 调用时)
|
||||
// 如果已在事务中,直接执行,避免嵌套事务导致超时
|
||||
if (this.unitOfWork.isInTransaction()) {
|
||||
for (const tier of tiersToClaimList) {
|
||||
await this.claimBonusTier(accountSequence, tier);
|
||||
}
|
||||
} else {
|
||||
// 独立调用时,开启新事务
|
||||
await this.unitOfWork.executeInTransaction(async () => {
|
||||
for (const tier of tiersToClaimList) {
|
||||
await this.claimBonusTier(accountSequence, tier);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 补发指定档位的奖励
|
||||
*/
|
||||
private async claimBonusTier(accountSequence: string, bonusTier: number): Promise<void> {
|
||||
// 1. 查询待领取的记录
|
||||
const pendingRecords = await this.unallocatedContributionRepository.findPendingBonusByAccountSequence(
|
||||
accountSequence,
|
||||
bonusTier,
|
||||
);
|
||||
|
||||
if (pendingRecords.length === 0) {
|
||||
this.logger.debug(`No pending T${bonusTier} bonus records for ${accountSequence}`);
|
||||
return;
|
||||
}
|
||||
|
||||
this.logger.log(
|
||||
`Claiming ${pendingRecords.length} T${bonusTier} bonus records for ${accountSequence}`,
|
||||
);
|
||||
|
||||
// 2. 查询原始认种数据,获取 treeCount 和 baseContribution
|
||||
const adoptionDataMap = new Map<string, { treeCount: number; baseContribution: ContributionAmount }>();
|
||||
for (const pending of pendingRecords) {
|
||||
const adoptionIdStr = pending.sourceAdoptionId.toString();
|
||||
if (!adoptionDataMap.has(adoptionIdStr)) {
|
||||
const adoption = await this.syncedDataRepository.findSyncedAdoptionByOriginalId(pending.sourceAdoptionId);
|
||||
if (adoption) {
|
||||
adoptionDataMap.set(adoptionIdStr, {
|
||||
treeCount: adoption.treeCount,
|
||||
baseContribution: new ContributionAmount(adoption.contributionPerTree),
|
||||
});
|
||||
} else {
|
||||
// 如果找不到原始认种数据,使用默认值并记录警告
|
||||
this.logger.warn(`Adoption not found for sourceAdoptionId: ${pending.sourceAdoptionId}, using default values`);
|
||||
adoptionDataMap.set(adoptionIdStr, {
|
||||
treeCount: 0,
|
||||
baseContribution: new ContributionAmount(0),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 3. 创建贡献值记录
|
||||
const contributionRecords: ContributionRecordAggregate[] = [];
|
||||
for (const pending of pendingRecords) {
|
||||
const adoptionData = adoptionDataMap.get(pending.sourceAdoptionId.toString())!;
|
||||
const record = new ContributionRecordAggregate({
|
||||
accountSequence: accountSequence,
|
||||
sourceType: ContributionSourceType.TEAM_BONUS,
|
||||
sourceAdoptionId: pending.sourceAdoptionId,
|
||||
sourceAccountSequence: pending.sourceAccountSequence,
|
||||
treeCount: adoptionData.treeCount,
|
||||
baseContribution: adoptionData.baseContribution,
|
||||
distributionRate: DistributionRate.BONUS_PER,
|
||||
bonusTier: bonusTier,
|
||||
amount: pending.amount,
|
||||
effectiveDate: pending.effectiveDate,
|
||||
expireDate: pending.expireDate,
|
||||
});
|
||||
contributionRecords.push(record);
|
||||
}
|
||||
|
||||
// 4. 保存贡献值记录
|
||||
const savedRecords = await this.contributionRecordRepository.saveMany(contributionRecords);
|
||||
|
||||
// 5. 更新用户的贡献值账户
|
||||
let totalAmount = new ContributionAmount(0);
|
||||
for (const pending of pendingRecords) {
|
||||
totalAmount = new ContributionAmount(totalAmount.value.plus(pending.amount.value));
|
||||
}
|
||||
|
||||
await this.contributionAccountRepository.updateContribution(
|
||||
accountSequence,
|
||||
ContributionSourceType.TEAM_BONUS,
|
||||
totalAmount,
|
||||
null,
|
||||
bonusTier,
|
||||
);
|
||||
|
||||
// 6. 标记待领取记录为已分配
|
||||
const pendingIds = pendingRecords.map((r) => r.id);
|
||||
await this.unallocatedContributionRepository.claimBonusRecords(pendingIds, accountSequence);
|
||||
|
||||
// 7. 从 HEADQUARTERS 减少算力并删除明细记录
|
||||
await this.systemAccountRepository.subtractContribution('HEADQUARTERS', null, totalAmount);
|
||||
for (const pending of pendingRecords) {
|
||||
await this.systemAccountRepository.deleteContributionRecordsByAdoption(
|
||||
'HEADQUARTERS',
|
||||
null,
|
||||
pending.sourceAdoptionId,
|
||||
pending.sourceAccountSequence,
|
||||
);
|
||||
}
|
||||
|
||||
// 8. 发布 HEADQUARTERS 账户更新事件
|
||||
const headquartersAccount = await this.systemAccountRepository.findByTypeAndRegion('HEADQUARTERS', null);
|
||||
if (headquartersAccount) {
|
||||
const hqEvent = new SystemAccountSyncedEvent(
|
||||
'HEADQUARTERS',
|
||||
null,
|
||||
headquartersAccount.name,
|
||||
headquartersAccount.contributionBalance.value.toString(),
|
||||
headquartersAccount.createdAt,
|
||||
);
|
||||
await this.outboxRepository.save({
|
||||
aggregateType: SystemAccountSyncedEvent.AGGREGATE_TYPE,
|
||||
aggregateId: 'HEADQUARTERS',
|
||||
eventType: SystemAccountSyncedEvent.EVENT_TYPE,
|
||||
payload: hqEvent.toPayload(),
|
||||
});
|
||||
}
|
||||
|
||||
// 9. 发布事件到 Kafka(通过 Outbox)
|
||||
await this.publishBonusClaimEvents(accountSequence, savedRecords, pendingRecords);
|
||||
|
||||
this.logger.log(
|
||||
`Claimed T${bonusTier} bonus for ${accountSequence}: ` +
|
||||
`${pendingRecords.length} records, total amount: ${totalAmount.value.toString()}`,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 发布补发事件
|
||||
*/
|
||||
private async publishBonusClaimEvents(
|
||||
accountSequence: string,
|
||||
savedRecords: ContributionRecordAggregate[],
|
||||
pendingRecords: UnallocatedContribution[],
|
||||
): Promise<void> {
|
||||
// 1. 发布贡献值记录同步事件(用于 mining-admin-service CDC)
|
||||
for (const record of savedRecords) {
|
||||
const event = new ContributionRecordSyncedEvent(
|
||||
record.id!,
|
||||
record.accountSequence,
|
||||
record.sourceType,
|
||||
record.sourceAdoptionId,
|
||||
record.sourceAccountSequence,
|
||||
record.treeCount,
|
||||
record.baseContribution.value.toString(),
|
||||
record.distributionRate.value.toString(),
|
||||
record.levelDepth,
|
||||
record.bonusTier,
|
||||
record.amount.value.toString(),
|
||||
record.effectiveDate,
|
||||
record.expireDate,
|
||||
record.isExpired,
|
||||
record.createdAt,
|
||||
);
|
||||
|
||||
await this.outboxRepository.save({
|
||||
aggregateType: ContributionRecordSyncedEvent.AGGREGATE_TYPE,
|
||||
aggregateId: record.id!.toString(),
|
||||
eventType: ContributionRecordSyncedEvent.EVENT_TYPE,
|
||||
payload: event.toPayload(),
|
||||
});
|
||||
}
|
||||
|
||||
// 2. 发布补发事件到 mining-wallet-service
|
||||
const userContributions = savedRecords.map((record, index) => ({
|
||||
accountSequence: record.accountSequence,
|
||||
contributionType: 'TEAM_BONUS',
|
||||
amount: record.amount.value.toString(),
|
||||
bonusTier: record.bonusTier,
|
||||
effectiveDate: record.effectiveDate.toISOString(),
|
||||
expireDate: record.expireDate.toISOString(),
|
||||
sourceAdoptionId: record.sourceAdoptionId.toString(),
|
||||
sourceAccountSequence: record.sourceAccountSequence,
|
||||
isBackfill: true, // 标记为补发
|
||||
}));
|
||||
|
||||
const eventId = `bonus-claim-${accountSequence}-${Date.now()}`;
|
||||
const payload = {
|
||||
eventType: 'BonusClaimed',
|
||||
eventId,
|
||||
timestamp: new Date().toISOString(),
|
||||
payload: {
|
||||
accountSequence,
|
||||
bonusTier: savedRecords[0]?.bonusTier,
|
||||
claimedCount: savedRecords.length,
|
||||
userContributions,
|
||||
},
|
||||
};
|
||||
|
||||
await this.outboxRepository.save({
|
||||
eventType: 'BonusClaimed',
|
||||
topic: 'contribution.bonus.claimed',
|
||||
key: accountSequence,
|
||||
payload,
|
||||
aggregateId: accountSequence,
|
||||
aggregateType: 'ContributionAccount',
|
||||
});
|
||||
}
|
||||
|
||||
// ========== LEVEL_OVERFLOW 回收逻辑 ==========
|
||||
|
||||
/**
|
||||
* 回收已解锁层级的 LEVEL_OVERFLOW 记录
|
||||
* 处理场景:下级认种时上级 unlocked_level_depth 不足,产生 LEVEL_OVERFLOW;
|
||||
* 后续上级解锁到足够层级后,这些 PENDING 记录需要被回收分配
|
||||
* @param limit 每次扫描的最大账户数
|
||||
* @returns 回收的记录总数
|
||||
*/
|
||||
async reclaimLevelOverflows(limit: number = 100): Promise<number> {
|
||||
// 1. 查找有 PENDING LEVEL_OVERFLOW 记录的账户
|
||||
const accountSequences = await this.unallocatedContributionRepository
|
||||
.findAccountSequencesWithPendingLevelOverflow(limit);
|
||||
|
||||
if (accountSequences.length === 0) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
this.logger.log(`[OverflowReclaim] Found ${accountSequences.length} accounts with pending LEVEL_OVERFLOW`);
|
||||
|
||||
let totalReclaimed = 0;
|
||||
let errorCount = 0;
|
||||
|
||||
for (const accountSequence of accountSequences) {
|
||||
try {
|
||||
const account = await this.contributionAccountRepository.findByAccountSequence(accountSequence);
|
||||
if (!account || account.unlockedLevelDepth === 0) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// 只回收已解锁层级范围内的 overflow
|
||||
await this.unitOfWork.executeInTransaction(async () => {
|
||||
const claimed = await this.claimLevelContributions(
|
||||
accountSequence,
|
||||
1,
|
||||
account.unlockedLevelDepth,
|
||||
);
|
||||
|
||||
if (claimed > 0) {
|
||||
totalReclaimed += claimed;
|
||||
// 重新读取账户(claimLevelContributions 已更新余额),发布更新事件
|
||||
const updatedAccount = await this.contributionAccountRepository
|
||||
.findByAccountSequence(accountSequence);
|
||||
if (updatedAccount) {
|
||||
await this.publishContributionAccountUpdatedEvent(updatedAccount);
|
||||
}
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
errorCount++;
|
||||
this.logger.error(`[OverflowReclaim] Failed for account ${accountSequence}`, error);
|
||||
}
|
||||
}
|
||||
|
||||
this.logger.log(
|
||||
`[OverflowReclaim] Completed: ${totalReclaimed} records reclaimed, ${errorCount} errors`,
|
||||
);
|
||||
return totalReclaimed;
|
||||
}
|
||||
|
||||
// ========== 定时任务补发逻辑 ==========
|
||||
|
||||
private readonly domainCalculator = new ContributionCalculatorService();
|
||||
|
||||
/**
|
||||
* 处理单个账户的补发逻辑
|
||||
* 检查是否有新解锁的层级或奖励档位,并进行补发
|
||||
* @returns 是否有补发
|
||||
*/
|
||||
async processBackfillForAccount(accountSequence: string): Promise<boolean> {
|
||||
const account = await this.contributionAccountRepository.findByAccountSequence(accountSequence);
|
||||
if (!account) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// 重新计算直推认种用户数
|
||||
const currentDirectReferralAdoptedCount = await this.syncedDataRepository.getDirectReferralAdoptedCount(
|
||||
accountSequence,
|
||||
);
|
||||
|
||||
// 计算应该解锁的层级深度和奖励档位
|
||||
const expectedLevelDepth = this.domainCalculator.calculateUnlockedLevelDepth(currentDirectReferralAdoptedCount);
|
||||
const expectedBonusTiers = this.domainCalculator.calculateUnlockedBonusTiers(
|
||||
account.hasAdopted,
|
||||
currentDirectReferralAdoptedCount,
|
||||
);
|
||||
|
||||
// 保存原始值(level 事务中 updateAccountUnlockStatus 会通过 incrementDirectReferralAdoptedCount
|
||||
// 同时修改 unlockedLevelDepth 和 unlockedBonusTiers,导致 bonus 分支条件失效)
|
||||
const originalDirectReferralAdoptedCount = account.directReferralAdoptedCount;
|
||||
const originalUnlockedBonusTiers = account.unlockedBonusTiers;
|
||||
|
||||
this.logger.log(
|
||||
`[Backfill] Checking account ${accountSequence}: ` +
|
||||
`hasAdopted=${account.hasAdopted}, ` +
|
||||
`directReferralAdoptedCount=${originalDirectReferralAdoptedCount} -> ${currentDirectReferralAdoptedCount}, ` +
|
||||
`unlockedLevelDepth=${account.unlockedLevelDepth} (expected=${expectedLevelDepth}), ` +
|
||||
`unlockedBonusTiers=${originalUnlockedBonusTiers} (expected=${expectedBonusTiers})`,
|
||||
);
|
||||
|
||||
let hasBackfill = false;
|
||||
|
||||
// 检查是否需要补发层级贡献值
|
||||
if (expectedLevelDepth > account.unlockedLevelDepth) {
|
||||
this.logger.log(
|
||||
`[Backfill] Account ${accountSequence} level unlock: ${account.unlockedLevelDepth} -> ${expectedLevelDepth} ` +
|
||||
`(directReferralAdoptedCount: ${originalDirectReferralAdoptedCount} -> ${currentDirectReferralAdoptedCount})`,
|
||||
);
|
||||
|
||||
await this.unitOfWork.executeInTransaction(async () => {
|
||||
// 补发层级贡献值
|
||||
const levelClaimed = await this.claimLevelContributions(
|
||||
accountSequence,
|
||||
account.unlockedLevelDepth + 1,
|
||||
expectedLevelDepth,
|
||||
);
|
||||
|
||||
if (levelClaimed > 0) {
|
||||
hasBackfill = true;
|
||||
}
|
||||
|
||||
// 更新账户的直推认种数和解锁状态
|
||||
await this.updateAccountUnlockStatus(
|
||||
account,
|
||||
currentDirectReferralAdoptedCount,
|
||||
expectedLevelDepth,
|
||||
expectedBonusTiers,
|
||||
);
|
||||
});
|
||||
|
||||
this.logger.log(
|
||||
`[Backfill] Account ${accountSequence} level backfill transaction completed. ` +
|
||||
`After mutation: directReferralAdoptedCount=${account.directReferralAdoptedCount}, ` +
|
||||
`unlockedLevelDepth=${account.unlockedLevelDepth}, unlockedBonusTiers=${account.unlockedBonusTiers}`,
|
||||
);
|
||||
}
|
||||
|
||||
// 检查是否需要补发奖励档位(使用原始值,因为 level 分支的 updateAccountUnlockStatus
|
||||
// 会同时把 unlockedBonusTiers 更新到 expectedBonusTiers,导致此条件永远为 false)
|
||||
this.logger.debug(
|
||||
`[Backfill] Account ${accountSequence} bonus check: ` +
|
||||
`expectedBonusTiers(${expectedBonusTiers}) > originalUnlockedBonusTiers(${originalUnlockedBonusTiers}) = ${expectedBonusTiers > originalUnlockedBonusTiers}`,
|
||||
);
|
||||
if (expectedBonusTiers > originalUnlockedBonusTiers) {
|
||||
this.logger.log(
|
||||
`[Backfill] Account ${accountSequence} bonus unlock: ${originalUnlockedBonusTiers} -> ${expectedBonusTiers} ` +
|
||||
`(directReferralAdoptedCount: ${originalDirectReferralAdoptedCount} -> ${currentDirectReferralAdoptedCount})`,
|
||||
);
|
||||
|
||||
// 使用原始直推认种数,确保 checkAndClaimBonus 能正确判断需要解锁的档位
|
||||
await this.checkAndClaimBonus(
|
||||
accountSequence,
|
||||
originalDirectReferralAdoptedCount,
|
||||
currentDirectReferralAdoptedCount,
|
||||
);
|
||||
hasBackfill = true;
|
||||
|
||||
// 如果只有奖励档位需要补发(层级已经是最新的),也需要更新账户状态
|
||||
if (expectedLevelDepth <= account.unlockedLevelDepth) {
|
||||
await this.unitOfWork.executeInTransaction(async () => {
|
||||
await this.updateAccountUnlockStatus(
|
||||
account,
|
||||
currentDirectReferralAdoptedCount,
|
||||
expectedLevelDepth,
|
||||
expectedBonusTiers,
|
||||
);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
this.logger.log(
|
||||
`[Backfill] Account ${accountSequence} backfill result: hasBackfill=${hasBackfill}`,
|
||||
);
|
||||
return hasBackfill;
|
||||
}
|
||||
|
||||
/**
|
||||
* 补发层级贡献值
|
||||
* @param accountSequence 用户账号
|
||||
* @param minLevel 最小层级(包含)
|
||||
* @param maxLevel 最大层级(包含)
|
||||
* @returns 补发的记录数
|
||||
*/
|
||||
private async claimLevelContributions(
|
||||
accountSequence: string,
|
||||
minLevel: number,
|
||||
maxLevel: number,
|
||||
): Promise<number> {
|
||||
// 1. 查询待领取的层级贡献值记录
|
||||
const pendingRecords = await this.unallocatedContributionRepository.findPendingLevelByAccountSequence(
|
||||
accountSequence,
|
||||
minLevel,
|
||||
maxLevel,
|
||||
);
|
||||
|
||||
if (pendingRecords.length === 0) {
|
||||
this.logger.debug(`[Backfill] No pending level records for ${accountSequence} (levels ${minLevel}-${maxLevel})`);
|
||||
return 0;
|
||||
}
|
||||
|
||||
this.logger.log(
|
||||
`[Backfill] Claiming ${pendingRecords.length} level records for ${accountSequence} (levels ${minLevel}-${maxLevel})`,
|
||||
);
|
||||
|
||||
// 2. 查询原始认种数据,获取 treeCount 和 baseContribution
|
||||
const adoptionDataMap = new Map<string, { treeCount: number; baseContribution: ContributionAmount }>();
|
||||
for (const pending of pendingRecords) {
|
||||
const adoptionIdStr = pending.sourceAdoptionId.toString();
|
||||
if (!adoptionDataMap.has(adoptionIdStr)) {
|
||||
const adoption = await this.syncedDataRepository.findSyncedAdoptionByOriginalId(pending.sourceAdoptionId);
|
||||
if (adoption) {
|
||||
adoptionDataMap.set(adoptionIdStr, {
|
||||
treeCount: adoption.treeCount,
|
||||
baseContribution: new ContributionAmount(adoption.contributionPerTree),
|
||||
});
|
||||
} else {
|
||||
this.logger.warn(`[Backfill] Adoption not found for sourceAdoptionId: ${pending.sourceAdoptionId}`);
|
||||
adoptionDataMap.set(adoptionIdStr, {
|
||||
treeCount: 0,
|
||||
baseContribution: new ContributionAmount(0),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 3. 创建贡献值记录
|
||||
const contributionRecords: ContributionRecordAggregate[] = [];
|
||||
for (const pending of pendingRecords) {
|
||||
const adoptionData = adoptionDataMap.get(pending.sourceAdoptionId.toString())!;
|
||||
const record = new ContributionRecordAggregate({
|
||||
accountSequence: accountSequence,
|
||||
sourceType: ContributionSourceType.TEAM_LEVEL,
|
||||
sourceAdoptionId: pending.sourceAdoptionId,
|
||||
sourceAccountSequence: pending.sourceAccountSequence,
|
||||
treeCount: adoptionData.treeCount,
|
||||
baseContribution: adoptionData.baseContribution,
|
||||
distributionRate: DistributionRate.LEVEL_PER,
|
||||
levelDepth: pending.levelDepth!,
|
||||
amount: pending.amount,
|
||||
effectiveDate: pending.effectiveDate,
|
||||
expireDate: pending.expireDate,
|
||||
});
|
||||
contributionRecords.push(record);
|
||||
}
|
||||
|
||||
// 4. 保存贡献值记录
|
||||
const savedRecords = await this.contributionRecordRepository.saveMany(contributionRecords);
|
||||
|
||||
// 5. 更新用户的贡献值账户(按层级分别更新)
|
||||
for (const pending of pendingRecords) {
|
||||
await this.contributionAccountRepository.updateContribution(
|
||||
accountSequence,
|
||||
ContributionSourceType.TEAM_LEVEL,
|
||||
pending.amount,
|
||||
pending.levelDepth,
|
||||
null,
|
||||
);
|
||||
}
|
||||
|
||||
// 6. 标记待领取记录为已分配
|
||||
const pendingIds = pendingRecords.map((r) => r.id);
|
||||
await this.unallocatedContributionRepository.claimLevelRecords(pendingIds, accountSequence);
|
||||
|
||||
// 7. 计算总金额用于从 HEADQUARTERS 扣除
|
||||
let totalAmount = new ContributionAmount(0);
|
||||
for (const pending of pendingRecords) {
|
||||
totalAmount = new ContributionAmount(totalAmount.value.plus(pending.amount.value));
|
||||
}
|
||||
|
||||
// 8. 从 HEADQUARTERS 减少算力并删除明细记录
|
||||
await this.systemAccountRepository.subtractContribution('HEADQUARTERS', null, totalAmount);
|
||||
for (const pending of pendingRecords) {
|
||||
await this.systemAccountRepository.deleteContributionRecordsByAdoption(
|
||||
'HEADQUARTERS',
|
||||
null,
|
||||
pending.sourceAdoptionId,
|
||||
pending.sourceAccountSequence,
|
||||
);
|
||||
}
|
||||
|
||||
// 9. 发布 HEADQUARTERS 账户更新事件
|
||||
const headquartersAccount = await this.systemAccountRepository.findByTypeAndRegion('HEADQUARTERS', null);
|
||||
if (headquartersAccount) {
|
||||
const hqEvent = new SystemAccountSyncedEvent(
|
||||
'HEADQUARTERS',
|
||||
null,
|
||||
headquartersAccount.name,
|
||||
headquartersAccount.contributionBalance.value.toString(),
|
||||
headquartersAccount.createdAt,
|
||||
);
|
||||
await this.outboxRepository.save({
|
||||
aggregateType: SystemAccountSyncedEvent.AGGREGATE_TYPE,
|
||||
aggregateId: 'HEADQUARTERS',
|
||||
eventType: SystemAccountSyncedEvent.EVENT_TYPE,
|
||||
payload: hqEvent.toPayload(),
|
||||
});
|
||||
}
|
||||
|
||||
// 10. 发布贡献值记录同步事件
|
||||
await this.publishLevelClaimEvents(accountSequence, savedRecords, pendingRecords);
|
||||
|
||||
this.logger.log(
|
||||
`[Backfill] Claimed level contributions for ${accountSequence}: ` +
|
||||
`${pendingRecords.length} records, total amount: ${totalAmount.value.toString()}`,
|
||||
);
|
||||
|
||||
return pendingRecords.length;
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新账户的解锁状态
|
||||
*/
|
||||
private async updateAccountUnlockStatus(
|
||||
account: ContributionAccountAggregate,
|
||||
newDirectReferralAdoptedCount: number,
|
||||
expectedLevelDepth: number,
|
||||
expectedBonusTiers: number,
|
||||
): Promise<void> {
|
||||
// 增量更新直推认种数
|
||||
const previousCount = account.directReferralAdoptedCount;
|
||||
if (newDirectReferralAdoptedCount > previousCount) {
|
||||
for (let i = previousCount; i < newDirectReferralAdoptedCount; i++) {
|
||||
account.incrementDirectReferralAdoptedCount();
|
||||
}
|
||||
}
|
||||
|
||||
await this.contributionAccountRepository.save(account);
|
||||
|
||||
// 发布账户更新事件
|
||||
await this.publishContributionAccountUpdatedEvent(account);
|
||||
}
|
||||
|
||||
/**
|
||||
* 发布层级补发事件
|
||||
*/
|
||||
private async publishLevelClaimEvents(
|
||||
accountSequence: string,
|
||||
savedRecords: ContributionRecordAggregate[],
|
||||
pendingRecords: UnallocatedContribution[],
|
||||
): Promise<void> {
|
||||
// 1. 发布贡献值记录同步事件(用于 mining-admin-service CDC)
|
||||
for (const record of savedRecords) {
|
||||
const event = new ContributionRecordSyncedEvent(
|
||||
record.id!,
|
||||
record.accountSequence,
|
||||
record.sourceType,
|
||||
record.sourceAdoptionId,
|
||||
record.sourceAccountSequence,
|
||||
record.treeCount,
|
||||
record.baseContribution.value.toString(),
|
||||
record.distributionRate.value.toString(),
|
||||
record.levelDepth,
|
||||
record.bonusTier,
|
||||
record.amount.value.toString(),
|
||||
record.effectiveDate,
|
||||
record.expireDate,
|
||||
record.isExpired,
|
||||
record.createdAt,
|
||||
);
|
||||
|
||||
await this.outboxRepository.save({
|
||||
aggregateType: ContributionRecordSyncedEvent.AGGREGATE_TYPE,
|
||||
aggregateId: record.id!.toString(),
|
||||
eventType: ContributionRecordSyncedEvent.EVENT_TYPE,
|
||||
payload: event.toPayload(),
|
||||
});
|
||||
}
|
||||
|
||||
// 2. 发布补发事件到 mining-wallet-service
|
||||
const userContributions = savedRecords.map((record) => ({
|
||||
accountSequence: record.accountSequence,
|
||||
contributionType: 'TEAM_LEVEL',
|
||||
amount: record.amount.value.toString(),
|
||||
levelDepth: record.levelDepth,
|
||||
effectiveDate: record.effectiveDate.toISOString(),
|
||||
expireDate: record.expireDate.toISOString(),
|
||||
sourceAdoptionId: record.sourceAdoptionId.toString(),
|
||||
sourceAccountSequence: record.sourceAccountSequence,
|
||||
isBackfill: true, // 标记为补发
|
||||
}));
|
||||
|
||||
const eventId = `level-claim-${accountSequence}-${Date.now()}`;
|
||||
const payload = {
|
||||
eventType: 'LevelClaimed',
|
||||
eventId,
|
||||
timestamp: new Date().toISOString(),
|
||||
payload: {
|
||||
accountSequence,
|
||||
claimedCount: savedRecords.length,
|
||||
userContributions,
|
||||
},
|
||||
};
|
||||
|
||||
await this.outboxRepository.save({
|
||||
eventType: 'LevelClaimed',
|
||||
topic: 'contribution.level.claimed',
|
||||
key: accountSequence,
|
||||
payload,
|
||||
aggregateId: accountSequence,
|
||||
aggregateType: 'ContributionAccount',
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 发布贡献值账户更新事件
|
||||
*/
|
||||
private async publishContributionAccountUpdatedEvent(
|
||||
account: ContributionAccountAggregate,
|
||||
): Promise<void> {
|
||||
const totalContribution = account.personalContribution.value
|
||||
.plus(account.totalLevelPending.value)
|
||||
.plus(account.totalBonusPending.value);
|
||||
|
||||
const event = new ContributionAccountUpdatedEvent(
|
||||
account.accountSequence,
|
||||
account.personalContribution.value.toString(),
|
||||
account.totalLevelPending.value.toString(),
|
||||
account.totalBonusPending.value.toString(),
|
||||
totalContribution.toString(),
|
||||
account.effectiveContribution.value.toString(),
|
||||
account.hasAdopted,
|
||||
account.directReferralAdoptedCount,
|
||||
account.unlockedLevelDepth,
|
||||
account.unlockedBonusTiers,
|
||||
account.createdAt,
|
||||
);
|
||||
|
||||
await this.outboxRepository.save({
|
||||
aggregateType: ContributionAccountUpdatedEvent.AGGREGATE_TYPE,
|
||||
aggregateId: account.accountSequence,
|
||||
eventType: ContributionAccountUpdatedEvent.EVENT_TYPE,
|
||||
payload: event.toPayload(),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
@ -9,10 +9,12 @@ import { OutboxRepository } from '../../infrastructure/persistence/repositories/
|
|||
import { UnitOfWork } from '../../infrastructure/persistence/unit-of-work/unit-of-work';
|
||||
import { ContributionAccountAggregate, ContributionSourceType } from '../../domain/aggregates/contribution-account.aggregate';
|
||||
import { ContributionRecordAggregate } from '../../domain/aggregates/contribution-record.aggregate';
|
||||
import { ContributionAmount } from '../../domain/value-objects/contribution-amount.vo';
|
||||
import { SyncedReferral } from '../../domain/repositories/synced-data.repository.interface';
|
||||
import { ContributionDistributionPublisherService } from './contribution-distribution-publisher.service';
|
||||
import { ContributionRateService } from './contribution-rate.service';
|
||||
import { ContributionRecordSyncedEvent, NetworkProgressUpdatedEvent } from '../../domain/events';
|
||||
import { BonusClaimService } from './bonus-claim.service';
|
||||
import { ContributionRecordSyncedEvent, NetworkProgressUpdatedEvent, ContributionAccountUpdatedEvent, SystemAccountSyncedEvent, SystemContributionRecordCreatedEvent, UnallocatedContributionSyncedEvent } from '../../domain/events';
|
||||
|
||||
/**
|
||||
* 算力计算应用服务
|
||||
|
|
@ -33,6 +35,7 @@ export class ContributionCalculationService {
|
|||
private readonly unitOfWork: UnitOfWork,
|
||||
private readonly distributionPublisher: ContributionDistributionPublisherService,
|
||||
private readonly contributionRateService: ContributionRateService,
|
||||
private readonly bonusClaimService: BonusClaimService,
|
||||
) {}
|
||||
|
||||
/**
|
||||
|
|
@ -55,6 +58,20 @@ export class ContributionCalculationService {
|
|||
// 获取认种用户的引荐关系
|
||||
const userReferral = await this.syncedDataRepository.findSyncedReferralByAccountSequence(adoption.accountSequence);
|
||||
|
||||
// 推荐数据未同步时跳过(不标记 distributed,调度器下次重试)
|
||||
if (!userReferral) {
|
||||
this.logger.warn(
|
||||
`[Referral-Guard] Deferring adoption ${originalAdoptionId}: ` +
|
||||
`referral for ${adoption.accountSequence} not yet synced, will retry on next scheduler tick`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
this.logger.debug(
|
||||
`[Referral-Guard] Referral found for ${adoption.accountSequence}: ` +
|
||||
`referrer=${userReferral.referrerAccountSequence || 'NONE (root)'}`,
|
||||
);
|
||||
|
||||
// 获取上线链条(最多15级)
|
||||
let ancestorChain: SyncedReferral[] = [];
|
||||
if (userReferral?.referrerAccountSequence) {
|
||||
|
|
@ -111,6 +128,49 @@ export class ContributionCalculationService {
|
|||
`teamBonus=${result.teamBonusRecords.length}, ` +
|
||||
`unallocated=${result.unallocatedContributions.length}`,
|
||||
);
|
||||
|
||||
// 更新全网认种进度(更新 NetworkAdoptionProgress 表)
|
||||
// 判断是否为新认种用户:之前没有账户记录即为新用户
|
||||
const isNewUser = !adopterAccount;
|
||||
await this.contributionRateService.updateNetworkProgress(
|
||||
adoption.treeCount,
|
||||
adoption.adoptionDate,
|
||||
adoption.originalAdoptionId,
|
||||
isNewUser,
|
||||
);
|
||||
|
||||
// 发布全网进度更新事件(用于 mining-service 同步全网理论算力)
|
||||
await this.publishNetworkProgressEvent();
|
||||
}
|
||||
|
||||
/**
|
||||
* 发布全网进度更新事件
|
||||
*/
|
||||
private async publishNetworkProgressEvent(): Promise<void> {
|
||||
try {
|
||||
const progress = await this.contributionRateService.getNetworkProgress();
|
||||
|
||||
const event = new NetworkProgressUpdatedEvent(
|
||||
progress.totalTreeCount,
|
||||
progress.totalAdoptionOrders,
|
||||
progress.totalAdoptedUsers,
|
||||
progress.currentUnit,
|
||||
progress.currentMultiplier.toString(),
|
||||
progress.currentContributionPerTree.toString(),
|
||||
progress.nextUnitTreeCount,
|
||||
);
|
||||
|
||||
await this.outboxRepository.save({
|
||||
aggregateType: NetworkProgressUpdatedEvent.AGGREGATE_TYPE,
|
||||
aggregateId: 'network',
|
||||
eventType: NetworkProgressUpdatedEvent.EVENT_TYPE,
|
||||
payload: event.toPayload(),
|
||||
});
|
||||
|
||||
this.logger.debug(`Published NetworkProgressUpdatedEvent: trees=${progress.totalTreeCount}`);
|
||||
} catch (error) {
|
||||
this.logger.error('Failed to publish NetworkProgressUpdatedEvent', error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -164,6 +224,8 @@ export class ContributionCalculationService {
|
|||
): Promise<void> {
|
||||
// 收集所有保存后的记录(带ID)用于发布事件
|
||||
const savedRecords: ContributionRecordAggregate[] = [];
|
||||
// 收集所有被更新的账户序列号(用于发布账户更新事件)
|
||||
const updatedAccountSequences = new Set<string>();
|
||||
|
||||
// 1. 保存个人算力记录
|
||||
const savedPersonalRecord = await this.contributionRecordRepository.save(result.personalRecord);
|
||||
|
|
@ -178,6 +240,7 @@ export class ContributionCalculationService {
|
|||
}
|
||||
account.addPersonalContribution(result.personalRecord.amount);
|
||||
await this.contributionAccountRepository.save(account);
|
||||
updatedAccountSequences.add(result.personalRecord.accountSequence);
|
||||
|
||||
// 2. 保存团队层级算力记录
|
||||
if (result.teamLevelRecords.length > 0) {
|
||||
|
|
@ -193,6 +256,7 @@ export class ContributionCalculationService {
|
|||
record.levelDepth, // 传递层级深度
|
||||
null,
|
||||
);
|
||||
updatedAccountSequences.add(record.accountSequence);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -210,6 +274,7 @@ export class ContributionCalculationService {
|
|||
null,
|
||||
record.bonusTier, // 传递加成档位
|
||||
);
|
||||
updatedAccountSequences.add(record.accountSequence);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -217,7 +282,7 @@ export class ContributionCalculationService {
|
|||
const effectiveDate = result.personalRecord.effectiveDate;
|
||||
const expireDate = result.personalRecord.expireDate;
|
||||
|
||||
// 4. 保存未分配算力
|
||||
// 4. 保存未分配算力并发布同步事件
|
||||
if (result.unallocatedContributions.length > 0) {
|
||||
await this.unallocatedContributionRepository.saveMany(
|
||||
result.unallocatedContributions.map((u) => ({
|
||||
|
|
@ -228,28 +293,189 @@ export class ContributionCalculationService {
|
|||
expireDate,
|
||||
})),
|
||||
);
|
||||
|
||||
// 汇总未分配算力到 HEADQUARTERS(总部账户)
|
||||
const totalUnallocatedAmount = result.unallocatedContributions.reduce(
|
||||
(sum, u) => sum.add(u.amount),
|
||||
new ContributionAmount(0),
|
||||
);
|
||||
await this.systemAccountRepository.addContribution(
|
||||
'HEADQUARTERS',
|
||||
null,
|
||||
totalUnallocatedAmount,
|
||||
);
|
||||
|
||||
// 为每笔未分配算力创建 HEADQUARTERS 明细记录
|
||||
for (const unallocated of result.unallocatedContributions) {
|
||||
// 确定来源类型和层级深度
|
||||
const sourceType = unallocated.type as string; // LEVEL_OVERFLOW / LEVEL_NO_ANCESTOR / BONUS_TIER_1/2/3
|
||||
const levelDepth = unallocated.levelDepth;
|
||||
|
||||
const savedRecord = await this.systemAccountRepository.saveContributionRecord({
|
||||
accountType: 'HEADQUARTERS',
|
||||
regionCode: null,
|
||||
sourceAdoptionId,
|
||||
sourceAccountSequence,
|
||||
sourceType,
|
||||
levelDepth,
|
||||
distributionRate: 0, // 未分配算力没有固定比例
|
||||
amount: unallocated.amount,
|
||||
effectiveDate,
|
||||
expireDate: null,
|
||||
});
|
||||
|
||||
// 发布 HEADQUARTERS 算力明细事件
|
||||
const recordEvent = new SystemContributionRecordCreatedEvent(
|
||||
savedRecord.id,
|
||||
'HEADQUARTERS',
|
||||
null,
|
||||
sourceAdoptionId,
|
||||
sourceAccountSequence,
|
||||
sourceType as any,
|
||||
levelDepth,
|
||||
0,
|
||||
unallocated.amount.value.toString(),
|
||||
effectiveDate,
|
||||
null,
|
||||
savedRecord.createdAt,
|
||||
);
|
||||
await this.outboxRepository.save({
|
||||
aggregateType: SystemContributionRecordCreatedEvent.AGGREGATE_TYPE,
|
||||
aggregateId: savedRecord.id.toString(),
|
||||
eventType: SystemContributionRecordCreatedEvent.EVENT_TYPE,
|
||||
payload: recordEvent.toPayload(),
|
||||
});
|
||||
}
|
||||
|
||||
// 发布 HEADQUARTERS 账户同步事件
|
||||
const headquartersAccount = await this.systemAccountRepository.findByTypeAndRegion('HEADQUARTERS', null);
|
||||
if (headquartersAccount) {
|
||||
const hqEvent = new SystemAccountSyncedEvent(
|
||||
'HEADQUARTERS',
|
||||
null, // 区域代码(总部没有区域)
|
||||
headquartersAccount.name,
|
||||
headquartersAccount.contributionBalance.value.toString(),
|
||||
headquartersAccount.createdAt,
|
||||
);
|
||||
await this.outboxRepository.save({
|
||||
aggregateType: SystemAccountSyncedEvent.AGGREGATE_TYPE,
|
||||
aggregateId: 'HEADQUARTERS',
|
||||
eventType: SystemAccountSyncedEvent.EVENT_TYPE,
|
||||
payload: hqEvent.toPayload(),
|
||||
});
|
||||
}
|
||||
|
||||
// 发布未分配算力同步事件(用于 mining-service 同步待解锁算力)
|
||||
for (const unallocated of result.unallocatedContributions) {
|
||||
const event = new UnallocatedContributionSyncedEvent(
|
||||
sourceAdoptionId,
|
||||
sourceAccountSequence,
|
||||
unallocated.wouldBeAccountSequence,
|
||||
unallocated.type,
|
||||
unallocated.amount.value.toString(),
|
||||
unallocated.reason,
|
||||
effectiveDate,
|
||||
expireDate,
|
||||
);
|
||||
await this.outboxRepository.save({
|
||||
aggregateType: UnallocatedContributionSyncedEvent.AGGREGATE_TYPE,
|
||||
aggregateId: `${sourceAdoptionId}-${unallocated.type}`,
|
||||
eventType: UnallocatedContributionSyncedEvent.EVENT_TYPE,
|
||||
payload: event.toPayload(),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// 5. 保存系统账户算力
|
||||
// 5. 保存系统账户算力并发布同步事件
|
||||
if (result.systemContributions.length > 0) {
|
||||
await this.systemAccountRepository.ensureSystemAccountsExist();
|
||||
|
||||
for (const sys of result.systemContributions) {
|
||||
await this.systemAccountRepository.addContribution(sys.accountType, sys.amount);
|
||||
await this.systemAccountRepository.saveContributionRecord({
|
||||
systemAccountType: sys.accountType,
|
||||
// 动态创建/更新系统账户
|
||||
await this.systemAccountRepository.addContribution(
|
||||
sys.accountType,
|
||||
sys.regionCode,
|
||||
sys.amount,
|
||||
);
|
||||
|
||||
// 保存算力明细记录
|
||||
const savedRecord = await this.systemAccountRepository.saveContributionRecord({
|
||||
accountType: sys.accountType,
|
||||
regionCode: sys.regionCode,
|
||||
sourceAdoptionId,
|
||||
sourceAccountSequence,
|
||||
sourceType: 'FIXED_RATE', // 固定比例分配
|
||||
levelDepth: null,
|
||||
distributionRate: sys.rate.value.toNumber(),
|
||||
amount: sys.amount,
|
||||
effectiveDate,
|
||||
expireDate: null, // System account contributions never expire based on the schema's contributionNeverExpires field
|
||||
expireDate: null,
|
||||
});
|
||||
|
||||
// 发布系统账户同步事件(用于 mining-service 同步系统账户算力)
|
||||
const systemAccount = await this.systemAccountRepository.findByTypeAndRegion(
|
||||
sys.accountType,
|
||||
sys.regionCode,
|
||||
);
|
||||
if (systemAccount) {
|
||||
const event = new SystemAccountSyncedEvent(
|
||||
sys.accountType,
|
||||
sys.regionCode,
|
||||
systemAccount.name,
|
||||
systemAccount.contributionBalance.value.toString(),
|
||||
systemAccount.createdAt,
|
||||
);
|
||||
await this.outboxRepository.save({
|
||||
aggregateType: SystemAccountSyncedEvent.AGGREGATE_TYPE,
|
||||
aggregateId: `${sys.accountType}:${sys.regionCode || 'null'}`,
|
||||
eventType: SystemAccountSyncedEvent.EVENT_TYPE,
|
||||
payload: event.toPayload(),
|
||||
});
|
||||
|
||||
// 发布系统账户算力明细事件(用于 mining-admin-service 同步明细记录)
|
||||
const recordEvent = new SystemContributionRecordCreatedEvent(
|
||||
savedRecord.id,
|
||||
sys.accountType,
|
||||
sys.regionCode, // 传递区域代码
|
||||
sourceAdoptionId,
|
||||
sourceAccountSequence,
|
||||
'FIXED_RATE', // 固定比例分配
|
||||
null, // 无层级深度
|
||||
sys.rate.value.toNumber(),
|
||||
sys.amount.value.toString(),
|
||||
effectiveDate,
|
||||
null,
|
||||
savedRecord.createdAt,
|
||||
);
|
||||
await this.outboxRepository.save({
|
||||
aggregateType: SystemContributionRecordCreatedEvent.AGGREGATE_TYPE,
|
||||
aggregateId: savedRecord.id.toString(),
|
||||
eventType: SystemContributionRecordCreatedEvent.EVENT_TYPE,
|
||||
payload: recordEvent.toPayload(),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 6. 发布算力记录同步事件(用于 mining-admin-service)- 使用保存后带 ID 的记录
|
||||
await this.publishContributionRecordEvents(savedRecords);
|
||||
|
||||
// 7. 发布所有被更新账户的事件(用于 CDC 同步到 mining-admin-service)
|
||||
await this.publishUpdatedAccountEvents(updatedAccountSequences);
|
||||
}
|
||||
|
||||
/**
|
||||
* 发布被更新账户的事件
|
||||
*/
|
||||
private async publishUpdatedAccountEvents(accountSequences: Set<string>): Promise<void> {
|
||||
if (accountSequences.size === 0) return;
|
||||
|
||||
for (const accountSequence of accountSequences) {
|
||||
const account = await this.contributionAccountRepository.findByAccountSequence(accountSequence);
|
||||
if (account) {
|
||||
await this.publishContributionAccountUpdatedEvent(account);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -300,11 +526,15 @@ export class ContributionCalculationService {
|
|||
if (!account.hasAdopted) {
|
||||
account.markAsAdopted();
|
||||
await this.contributionAccountRepository.save(account);
|
||||
|
||||
// 发布账户更新事件到 outbox(用于 CDC 同步到 mining-admin-service)
|
||||
await this.publishContributionAccountUpdatedEvent(account);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新上线的解锁状态(直推用户认种后)
|
||||
* 如果解锁了新的奖励档位,会触发补发逻辑
|
||||
*/
|
||||
private async updateReferrerUnlockStatus(referrerAccountSequence: string): Promise<void> {
|
||||
const account = await this.contributionAccountRepository.findByAccountSequence(referrerAccountSequence);
|
||||
|
|
@ -316,16 +546,27 @@ export class ContributionCalculationService {
|
|||
);
|
||||
|
||||
// 更新解锁状态
|
||||
const currentCount = account.directReferralAdoptedCount;
|
||||
if (directReferralAdoptedCount > currentCount) {
|
||||
const previousCount = account.directReferralAdoptedCount;
|
||||
if (directReferralAdoptedCount > previousCount) {
|
||||
// 需要增量更新
|
||||
for (let i = currentCount; i < directReferralAdoptedCount; i++) {
|
||||
for (let i = previousCount; i < directReferralAdoptedCount; i++) {
|
||||
account.incrementDirectReferralAdoptedCount();
|
||||
}
|
||||
await this.contributionAccountRepository.save(account);
|
||||
|
||||
// 发布账户更新事件到 outbox(用于 CDC 同步到 mining-admin-service)
|
||||
await this.publishContributionAccountUpdatedEvent(account);
|
||||
|
||||
this.logger.debug(
|
||||
`Updated referrer ${referrerAccountSequence} unlock status: level=${account.unlockedLevelDepth}, bonus=${account.unlockedBonusTiers}`,
|
||||
);
|
||||
|
||||
// 检查并处理奖励补发(T2: 直推≥2人, T3: 直推≥4人)
|
||||
await this.bonusClaimService.checkAndClaimBonus(
|
||||
referrerAccountSequence,
|
||||
previousCount,
|
||||
directReferralAdoptedCount,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -393,4 +634,43 @@ export class ContributionCalculationService {
|
|||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 发布贡献值账户更新事件(用于 CDC 同步到 mining-admin-service)
|
||||
*/
|
||||
private async publishContributionAccountUpdatedEvent(
|
||||
account: ContributionAccountAggregate,
|
||||
): Promise<void> {
|
||||
// 总算力 = 个人算力 + 层级待解锁 + 加成待解锁
|
||||
const totalContribution = account.personalContribution.value
|
||||
.plus(account.totalLevelPending.value)
|
||||
.plus(account.totalBonusPending.value);
|
||||
|
||||
const event = new ContributionAccountUpdatedEvent(
|
||||
account.accountSequence,
|
||||
account.personalContribution.value.toString(),
|
||||
account.totalLevelPending.value.toString(),
|
||||
account.totalBonusPending.value.toString(),
|
||||
totalContribution.toString(),
|
||||
account.effectiveContribution.value.toString(),
|
||||
account.hasAdopted,
|
||||
account.directReferralAdoptedCount,
|
||||
account.unlockedLevelDepth,
|
||||
account.unlockedBonusTiers,
|
||||
account.createdAt,
|
||||
);
|
||||
|
||||
await this.outboxRepository.save({
|
||||
aggregateType: ContributionAccountUpdatedEvent.AGGREGATE_TYPE,
|
||||
aggregateId: account.accountSequence,
|
||||
eventType: ContributionAccountUpdatedEvent.EVENT_TYPE,
|
||||
payload: event.toPayload(),
|
||||
});
|
||||
|
||||
this.logger.debug(
|
||||
`Published ContributionAccountUpdatedEvent for ${account.accountSequence}: ` +
|
||||
`directReferralAdoptedCount=${account.directReferralAdoptedCount}, ` +
|
||||
`hasAdopted=${account.hasAdopted}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -121,11 +121,16 @@ export class ContributionDistributionPublisherService {
|
|||
return result.systemContributions.map((sys) => ({
|
||||
accountType: sys.accountType,
|
||||
amount: sys.amount.value.toString(),
|
||||
// 省份代码:PROVINCE 用自己的 regionCode,CITY 需要传递省份代码用于创建省份
|
||||
provinceCode:
|
||||
sys.accountType === 'PROVINCE' || sys.accountType === 'CITY'
|
||||
? provinceCode
|
||||
: undefined,
|
||||
cityCode: sys.accountType === 'CITY' ? cityCode : undefined,
|
||||
sys.accountType === 'PROVINCE'
|
||||
? sys.regionCode || provinceCode
|
||||
: sys.accountType === 'CITY'
|
||||
? provinceCode // CITY 需要省份代码来创建省份(如果省份不存在)
|
||||
: undefined,
|
||||
// 城市代码:只有 CITY 类型有
|
||||
cityCode:
|
||||
sys.accountType === 'CITY' ? sys.regionCode || cityCode : undefined,
|
||||
neverExpires: sys.accountType === 'OPERATION', // 运营账户永不过期
|
||||
}));
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,36 @@
|
|||
/**
|
||||
* 认种 fUSDT 注入请求事件
|
||||
* 当认种状态变为 MINING_ENABLED 时触发
|
||||
* 通知 mining-blockchain-service 从注入钱包向做市商钱包转入 fUSDT
|
||||
*
|
||||
* 转账金额 = treeCount × 5760
|
||||
*/
|
||||
export class AdoptionFusdtInjectionRequestedEvent {
|
||||
static readonly EVENT_TYPE = 'AdoptionFusdtInjectionRequested';
|
||||
static readonly AGGREGATE_TYPE = 'Adoption';
|
||||
/** 每棵树对应的 fUSDT 注入金额 */
|
||||
static readonly FUSDT_PER_TREE = 5760;
|
||||
|
||||
constructor(
|
||||
public readonly originalAdoptionId: bigint,
|
||||
public readonly accountSequence: string,
|
||||
public readonly treeCount: number,
|
||||
public readonly adoptionDate: Date,
|
||||
) {}
|
||||
|
||||
get amount(): number {
|
||||
return this.treeCount * AdoptionFusdtInjectionRequestedEvent.FUSDT_PER_TREE;
|
||||
}
|
||||
|
||||
toPayload(): Record<string, any> {
|
||||
return {
|
||||
eventType: AdoptionFusdtInjectionRequestedEvent.EVENT_TYPE,
|
||||
originalAdoptionId: this.originalAdoptionId.toString(),
|
||||
accountSequence: this.accountSequence,
|
||||
treeCount: this.treeCount,
|
||||
adoptionDate: this.adoptionDate.toISOString(),
|
||||
amount: this.amount.toString(),
|
||||
fusdtPerTree: AdoptionFusdtInjectionRequestedEvent.FUSDT_PER_TREE,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,40 @@
|
|||
/**
|
||||
* 贡献值账户更新事件
|
||||
* 当账户的 directReferralAdoptedCount, unlockedLevelDepth, unlockedBonusTiers 等字段更新时发布
|
||||
* 用于实时同步到 mining-admin-service
|
||||
*/
|
||||
export class ContributionAccountUpdatedEvent {
|
||||
static readonly EVENT_TYPE = 'ContributionAccountUpdated';
|
||||
static readonly AGGREGATE_TYPE = 'ContributionAccount';
|
||||
|
||||
constructor(
|
||||
public readonly accountSequence: string,
|
||||
public readonly personalContribution: string,
|
||||
public readonly teamLevelContribution: string,
|
||||
public readonly teamBonusContribution: string,
|
||||
public readonly totalContribution: string,
|
||||
public readonly effectiveContribution: string,
|
||||
public readonly hasAdopted: boolean,
|
||||
public readonly directReferralAdoptedCount: number,
|
||||
public readonly unlockedLevelDepth: number,
|
||||
public readonly unlockedBonusTiers: number,
|
||||
public readonly createdAt: Date,
|
||||
) {}
|
||||
|
||||
toPayload(): Record<string, any> {
|
||||
return {
|
||||
eventType: ContributionAccountUpdatedEvent.EVENT_TYPE,
|
||||
accountSequence: this.accountSequence,
|
||||
personalContribution: this.personalContribution,
|
||||
teamLevelContribution: this.teamLevelContribution,
|
||||
teamBonusContribution: this.teamBonusContribution,
|
||||
totalContribution: this.totalContribution,
|
||||
effectiveContribution: this.effectiveContribution,
|
||||
hasAdopted: this.hasAdopted,
|
||||
directReferralAdoptedCount: this.directReferralAdoptedCount,
|
||||
unlockedLevelDepth: this.unlockedLevelDepth,
|
||||
unlockedBonusTiers: this.unlockedBonusTiers,
|
||||
createdAt: this.createdAt.toISOString(),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
@ -1,7 +1,12 @@
|
|||
export * from './contribution-calculated.event';
|
||||
export * from './daily-snapshot-created.event';
|
||||
export * from './contribution-account-synced.event';
|
||||
export * from './contribution-account-updated.event';
|
||||
export * from './referral-synced.event';
|
||||
export * from './adoption-synced.event';
|
||||
export * from './contribution-record-synced.event';
|
||||
export * from './network-progress-updated.event';
|
||||
export * from './system-account-synced.event';
|
||||
export * from './system-contribution-record-created.event';
|
||||
export * from './unallocated-contribution-synced.event';
|
||||
export * from './adoption-fusdt-injection-requested.event';
|
||||
|
|
|
|||
|
|
@ -0,0 +1,27 @@
|
|||
/**
|
||||
* 系统账户算力同步事件
|
||||
* 用于将系统账户(运营、省、市、总部)的算力同步到 mining-service
|
||||
*/
|
||||
export class SystemAccountSyncedEvent {
|
||||
static readonly EVENT_TYPE = 'SystemAccountSynced';
|
||||
static readonly AGGREGATE_TYPE = 'SystemAccount';
|
||||
|
||||
constructor(
|
||||
public readonly accountType: string, // OPERATION / PROVINCE / CITY / HEADQUARTERS
|
||||
public readonly regionCode: string | null, // 省/市代码,如 440000, 440100
|
||||
public readonly name: string,
|
||||
public readonly contributionBalance: string,
|
||||
public readonly createdAt: Date,
|
||||
) {}
|
||||
|
||||
toPayload(): Record<string, any> {
|
||||
return {
|
||||
eventType: SystemAccountSyncedEvent.EVENT_TYPE,
|
||||
accountType: this.accountType,
|
||||
regionCode: this.regionCode,
|
||||
name: this.name,
|
||||
contributionBalance: this.contributionBalance,
|
||||
createdAt: this.createdAt.toISOString(),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,56 @@
|
|||
/**
|
||||
* 来源类型枚举
|
||||
* - FIXED_RATE: 固定比例分配(OPERATION 12%、PROVINCE 1%、CITY 2%)
|
||||
* - LEVEL_OVERFLOW: 层级溢出归总部(上线未解锁该级别)
|
||||
* - LEVEL_NO_ANCESTOR: 无上线归总部(该级无上线)
|
||||
* - BONUS_TIER_1/2/3: 团队奖励未解锁归总部
|
||||
*/
|
||||
export type SystemContributionSourceType =
|
||||
| 'FIXED_RATE'
|
||||
| 'LEVEL_OVERFLOW'
|
||||
| 'LEVEL_NO_ANCESTOR'
|
||||
| 'BONUS_TIER_1'
|
||||
| 'BONUS_TIER_2'
|
||||
| 'BONUS_TIER_3';
|
||||
|
||||
/**
|
||||
* 系统账户算力明细创建事件
|
||||
* 用于将系统账户的每笔算力来源明细同步到 mining-admin-service
|
||||
*/
|
||||
export class SystemContributionRecordCreatedEvent {
|
||||
static readonly EVENT_TYPE = 'SystemContributionRecordCreated';
|
||||
static readonly AGGREGATE_TYPE = 'SystemContributionRecord';
|
||||
|
||||
constructor(
|
||||
public readonly recordId: bigint, // 明细记录ID
|
||||
public readonly accountType: string, // 系统账户类型(OPERATION/PROVINCE/CITY/HEADQUARTERS)
|
||||
public readonly regionCode: string | null, // 区域代码(省/市代码,如 440000, 440100)
|
||||
public readonly sourceAdoptionId: bigint, // 来源认种ID
|
||||
public readonly sourceAccountSequence: string, // 认种人账号
|
||||
public readonly sourceType: SystemContributionSourceType, // 来源类型
|
||||
public readonly levelDepth: number | null, // 层级深度(1-15),仅对 LEVEL_OVERFLOW/LEVEL_NO_ANCESTOR 有效
|
||||
public readonly distributionRate: number, // 分配比例
|
||||
public readonly amount: string, // 算力金额
|
||||
public readonly effectiveDate: Date, // 生效日期
|
||||
public readonly expireDate: Date | null, // 过期日期
|
||||
public readonly createdAt: Date, // 创建时间
|
||||
) {}
|
||||
|
||||
toPayload(): Record<string, any> {
|
||||
return {
|
||||
eventType: SystemContributionRecordCreatedEvent.EVENT_TYPE,
|
||||
recordId: this.recordId.toString(),
|
||||
accountType: this.accountType,
|
||||
regionCode: this.regionCode,
|
||||
sourceAdoptionId: this.sourceAdoptionId.toString(),
|
||||
sourceAccountSequence: this.sourceAccountSequence,
|
||||
sourceType: this.sourceType,
|
||||
levelDepth: this.levelDepth,
|
||||
distributionRate: this.distributionRate,
|
||||
amount: this.amount,
|
||||
effectiveDate: this.effectiveDate.toISOString(),
|
||||
expireDate: this.expireDate?.toISOString() ?? null,
|
||||
createdAt: this.createdAt.toISOString(),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,33 @@
|
|||
/**
|
||||
* 未分配算力同步事件
|
||||
* 用于同步待解锁算力到 mining-service
|
||||
*/
|
||||
export class UnallocatedContributionSyncedEvent {
|
||||
static readonly EVENT_TYPE = 'UnallocatedContributionSynced';
|
||||
static readonly AGGREGATE_TYPE = 'UnallocatedContribution';
|
||||
|
||||
constructor(
|
||||
public readonly sourceAdoptionId: bigint,
|
||||
public readonly sourceAccountSequence: string,
|
||||
public readonly wouldBeAccountSequence: string | null,
|
||||
public readonly contributionType: string, // LEVEL_NO_ANCESTOR, LEVEL_OVERFLOW, BONUS_TIER_1, BONUS_TIER_2, BONUS_TIER_3
|
||||
public readonly amount: string,
|
||||
public readonly reason: string | null,
|
||||
public readonly effectiveDate: Date,
|
||||
public readonly expireDate: Date,
|
||||
) {}
|
||||
|
||||
toPayload(): Record<string, any> {
|
||||
return {
|
||||
eventType: UnallocatedContributionSyncedEvent.EVENT_TYPE,
|
||||
sourceAdoptionId: this.sourceAdoptionId.toString(),
|
||||
sourceAccountSequence: this.sourceAccountSequence,
|
||||
wouldBeAccountSequence: this.wouldBeAccountSequence,
|
||||
contributionType: this.contributionType,
|
||||
amount: this.amount,
|
||||
reason: this.reason,
|
||||
effectiveDate: this.effectiveDate.toISOString(),
|
||||
expireDate: this.expireDate.toISOString(),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
@ -5,6 +5,16 @@ import { ContributionAccountAggregate, ContributionSourceType } from '../aggrega
|
|||
import { ContributionRecordAggregate } from '../aggregates/contribution-record.aggregate';
|
||||
import { SyncedAdoption, SyncedReferral } from '../repositories/synced-data.repository.interface';
|
||||
|
||||
/**
|
||||
* 系统账户贡献值分配
|
||||
*/
|
||||
export interface SystemContributionAllocation {
|
||||
accountType: 'OPERATION' | 'PROVINCE' | 'CITY' | 'HEADQUARTERS';
|
||||
regionCode: string | null; // 省市代码,如 440000、440100
|
||||
rate: DistributionRate;
|
||||
amount: ContributionAmount;
|
||||
}
|
||||
|
||||
/**
|
||||
* 算力分配结果
|
||||
*/
|
||||
|
|
@ -27,12 +37,8 @@ export interface ContributionDistributionResult {
|
|||
reason: string;
|
||||
}[];
|
||||
|
||||
// 系统账户贡献值
|
||||
systemContributions: {
|
||||
accountType: 'OPERATION' | 'PROVINCE' | 'CITY';
|
||||
rate: DistributionRate;
|
||||
amount: ContributionAmount;
|
||||
}[];
|
||||
// 系统账户贡献值(支持按省市细分)
|
||||
systemContributions: SystemContributionAllocation[];
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -85,23 +91,31 @@ export class ContributionCalculatorService {
|
|||
});
|
||||
|
||||
// 2. 系统账户贡献值 (15%)
|
||||
result.systemContributions = [
|
||||
{
|
||||
accountType: 'OPERATION',
|
||||
rate: DistributionRate.OPERATION,
|
||||
amount: totalContribution.multiply(DistributionRate.OPERATION.value),
|
||||
},
|
||||
{
|
||||
accountType: 'PROVINCE',
|
||||
rate: DistributionRate.PROVINCE,
|
||||
amount: totalContribution.multiply(DistributionRate.PROVINCE.value),
|
||||
},
|
||||
{
|
||||
accountType: 'CITY',
|
||||
rate: DistributionRate.CITY,
|
||||
amount: totalContribution.multiply(DistributionRate.CITY.value),
|
||||
},
|
||||
];
|
||||
// 运营账户(全国)- 12%
|
||||
result.systemContributions.push({
|
||||
accountType: 'OPERATION',
|
||||
regionCode: null,
|
||||
rate: DistributionRate.OPERATION,
|
||||
amount: totalContribution.multiply(DistributionRate.OPERATION.value),
|
||||
});
|
||||
|
||||
// 省公司账户 - 1%(按认种选择的省份)
|
||||
const provinceCode = adoption.selectedProvince;
|
||||
result.systemContributions.push({
|
||||
accountType: 'PROVINCE',
|
||||
regionCode: provinceCode || null,
|
||||
rate: DistributionRate.PROVINCE,
|
||||
amount: totalContribution.multiply(DistributionRate.PROVINCE.value),
|
||||
});
|
||||
|
||||
// 市公司账户 - 2%(按认种选择的城市)
|
||||
const cityCode = adoption.selectedCity;
|
||||
result.systemContributions.push({
|
||||
accountType: 'CITY',
|
||||
regionCode: cityCode || null,
|
||||
rate: DistributionRate.CITY,
|
||||
amount: totalContribution.multiply(DistributionRate.CITY.value),
|
||||
});
|
||||
|
||||
// 3. 团队贡献值 (15%)
|
||||
this.distributeTeamContribution(
|
||||
|
|
|
|||
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue