Compare commits
635 Commits
v1.0.0-bef
...
main
| Author | SHA1 | Date |
|---|---|---|
|
|
a52081a94e | |
|
|
e306f346d3 | |
|
|
7aa7a54d9c | |
|
|
e4a2690130 | |
|
|
acc5c2be80 | |
|
|
fbf8b6c2e9 | |
|
|
d16cd9bc66 | |
|
|
424b7fe9d0 | |
|
|
761fc4369c | |
|
|
41cd442ce7 | |
|
|
a2313734b4 | |
|
|
936c3e89c1 | |
|
|
149cf7ea77 | |
|
|
761dcb1115 | |
|
|
ff82bddbc6 | |
|
|
a72be1bbf3 | |
|
|
ad1c889848 | |
|
|
e7d852e25e | |
|
|
d6e4dd58c7 | |
|
|
5050d5cacd | |
|
|
9f73adc999 | |
|
|
991bc77db8 | |
|
|
510a890b33 | |
|
|
92c71b5e97 | |
|
|
c9217a85a9 | |
|
|
a01355aecc | |
|
|
330f8c7681 | |
|
|
38423e7bf0 | |
|
|
7aa81bc5ab | |
|
|
5e6ab25199 | |
|
|
893513ad78 | |
|
|
be415c1eb6 | |
|
|
9e90294d0e | |
|
|
2c312a850b | |
|
|
b06f1272e2 | |
|
|
5eb4afa2f9 | |
|
|
b9cfa67835 | |
|
|
6bca65e434 | |
|
|
482df12f91 | |
|
|
033d1cde42 | |
|
|
a2a318e24c | |
|
|
34603aac8e | |
|
|
9c84be72bc | |
|
|
e8d9cb72a9 | |
|
|
5df9c97794 | |
|
|
405e7e407e | |
|
|
a1d284b6b5 | |
|
|
728b11c2aa | |
|
|
5752f7b545 | |
|
|
37b11a3db6 | |
|
|
3aa2856770 | |
|
|
6f912b1232 | |
|
|
71774f301d | |
|
|
cad7ebe832 | |
|
|
d91ff7b83a | |
|
|
3a84315b64 | |
|
|
41fa6349bd | |
|
|
8de92f2511 | |
|
|
98c898769f | |
|
|
353299fd75 | |
|
|
b67dfa0f4c | |
|
|
1f5bb62805 | |
|
|
94792f56ea | |
|
|
2b07219046 | |
|
|
59097203ae | |
|
|
8b48d80cd4 | |
|
|
979ba379c1 | |
|
|
867d4853ae | |
|
|
4904337e97 | |
|
|
ee734fb7b9 | |
|
|
ac3adfc90a | |
|
|
8fcfec9b65 | |
|
|
728728bee3 | |
|
|
f8f37a2e33 | |
|
|
3f4b22b013 | |
|
|
2565fa8259 | |
|
|
551723fe82 | |
|
|
fb4e52c0de | |
|
|
85c20adb0b | |
|
|
a392f708a7 | |
|
|
54e22b4709 | |
|
|
33ae08c90f | |
|
|
2b2e1efc7a | |
|
|
92c305c749 | |
|
|
2f78899ceb | |
|
|
55f81ff329 | |
|
|
532be9a561 | |
|
|
a8e06e2eda | |
|
|
d7f7d7082d | |
|
|
1e31d6d863 | |
|
|
af2afeda56 | |
|
|
a801a46e76 | |
|
|
0576733579 | |
|
|
a7dd926877 | |
|
|
1621b75a47 | |
|
|
eb425b0f92 | |
|
|
b59d5bda2d | |
|
|
40731c08ea | |
|
|
789a703ec8 | |
|
|
ab78086f1e | |
|
|
dc27fe9e44 | |
|
|
2706eef54f | |
|
|
5e05e336f7 | |
|
|
e68b5aa3d9 | |
|
|
5ee94b3672 | |
|
|
7c781c7d62 | |
|
|
59f7bdc137 | |
|
|
41818eb8e2 | |
|
|
a55201b3b3 | |
|
|
d3969710be | |
|
|
5fad40cec1 | |
|
|
6e3a898801 | |
|
|
1bfeece109 | |
|
|
19753a8639 | |
|
|
7fff665d9b | |
|
|
17df9b6df1 | |
|
|
b3f3349190 | |
|
|
24fe10ee36 | |
|
|
ac15d6682a | |
|
|
d5d61f4f68 | |
|
|
cb9953047f | |
|
|
0cd0bd5694 | |
|
|
5110915aa8 | |
|
|
6efa74aded | |
|
|
0e34896d0b | |
|
|
b3984c861c | |
|
|
28cf0b7769 | |
|
|
fda79304c6 | |
|
|
d223671db7 | |
|
|
e4a2a0e37a | |
|
|
d876dd1591 | |
|
|
31e6f9e15a | |
|
|
eba125901c | |
|
|
b905e8cb23 | |
|
|
ecaaf68a27 | |
|
|
8e52535dd9 | |
|
|
b13d873f64 | |
|
|
d849ca7bc2 | |
|
|
718e70e61a | |
|
|
722c124cc9 | |
|
|
e9b9896317 | |
|
|
4996c1d110 | |
|
|
27cd72fe01 | |
|
|
05e590ef04 | |
|
|
d1be7173be | |
|
|
cf07712a8c | |
|
|
5392c47e47 | |
|
|
e715fd2504 | |
|
|
292c6518ba | |
|
|
0ac131a3b7 | |
|
|
1157760d4d | |
|
|
e32658fc5e | |
|
|
a15a4a97b1 | |
|
|
d880242807 | |
|
|
530aeb2a6f | |
|
|
b9ddda2532 | |
|
|
3be7b47678 | |
|
|
f32748c1d5 | |
|
|
545e897c1f | |
|
|
1c71cda2ec | |
|
|
6a659ca718 | |
|
|
bf50810830 | |
|
|
90fad63fed | |
|
|
299c82fc4f | |
|
|
05aacc0d5b | |
|
|
19fca05a81 | |
|
|
d9c238702e | |
|
|
1d0e4352df | |
|
|
62bbbca609 | |
|
|
4bd40970d0 | |
|
|
724fb08be4 | |
|
|
a904c8bd42 | |
|
|
1431c89684 | |
|
|
cd73b2dec4 | |
|
|
b1e5e6b29f | |
|
|
2ad1936126 | |
|
|
b17bf82443 | |
|
|
7bad0a8935 | |
|
|
b9b23c36d7 | |
|
|
26dcd1d2de | |
|
|
20b8d41212 | |
|
|
5aa17b05c5 | |
|
|
4c6fd424b5 | |
|
|
eea38b2b86 | |
|
|
9b6effe63d | |
|
|
606d3c0b22 | |
|
|
2d7b02aa96 | |
|
|
f4c9535e12 | |
|
|
97f8b7339f | |
|
|
a7f2008bc2 | |
|
|
b747555927 | |
|
|
390e5ccb19 | |
|
|
560674f2e9 | |
|
|
fe9a30df85 | |
|
|
21fc55fb01 | |
|
|
55cfc96464 | |
|
|
1d1c60e2a2 | |
|
|
2684a81383 | |
|
|
ef68b7b9c0 | |
|
|
e328c75fc1 | |
|
|
20a73a8d43 | |
|
|
7c95d1d425 | |
|
|
5131728835 | |
|
|
c0ac63d40a | |
|
|
92054e776e | |
|
|
30a2f739cb | |
|
|
8d7fd68509 | |
|
|
37a5610d74 | |
|
|
63a169abb0 | |
|
|
f270b7cc27 | |
|
|
843f817976 | |
|
|
8bafb0a8d4 | |
|
|
a5cc3fdc5b | |
|
|
cfc03fe523 | |
|
|
12004d1c2e | |
|
|
83ba9b7d54 | |
|
|
81ea35b712 | |
|
|
3a307b5db7 | |
|
|
19f350b8e3 | |
|
|
c293309bdf | |
|
|
a39af93063 | |
|
|
edc81cc55d | |
|
|
74f061cfeb | |
|
|
16da1d20f0 | |
|
|
4a1bf3aafe | |
|
|
acf55b26a7 | |
|
|
5adcd023e6 | |
|
|
ed6b48562a | |
|
|
023d71ac33 | |
|
|
577979bc83 | |
|
|
a68fe5e999 | |
|
|
eacdfddff8 | |
|
|
eda39b982d | |
|
|
825c8a32e4 | |
|
|
e02bcf418c | |
|
|
8f8a9230d0 | |
|
|
a5a69645b4 | |
|
|
27db2a5aa2 | |
|
|
18675f083c | |
|
|
31d3eabcf8 | |
|
|
e32ef9b9ff | |
|
|
716b37041e | |
|
|
b6fb421316 | |
|
|
26e55a649f | |
|
|
ff28615fc3 | |
|
|
de361e24f6 | |
|
|
30f1355bb4 | |
|
|
08161c64d4 | |
|
|
8855491637 | |
|
|
9cbc0ba580 | |
|
|
7b7bfcac93 | |
|
|
669a8a7248 | |
|
|
f14f685ea9 | |
|
|
38efa891b8 | |
|
|
ee94f1420d | |
|
|
9a88fb473a | |
|
|
470dc1ccd0 | |
|
|
a4689d5e8b | |
|
|
b8b4305ea5 | |
|
|
685aced4e4 | |
|
|
18c9f8b389 | |
|
|
ef2f0f67bf | |
|
|
cf07eb03be | |
|
|
e690a55c8e | |
|
|
a11e4d0261 | |
|
|
b14ad94e85 | |
|
|
c3e43b81e9 | |
|
|
b3a3652f21 | |
|
|
765a4f41d3 | |
|
|
63ae7662a4 | |
|
|
03f5c4af28 | |
|
|
d248f92443 | |
|
|
99f5070552 | |
|
|
8a4508fe0d | |
|
|
1f9129d220 | |
|
|
27751731e8 | |
|
|
e1cd8ed7f2 | |
|
|
010b0392fd | |
|
|
875f86c263 | |
|
|
2a725af83e | |
|
|
54eb472faa | |
|
|
ab9212cefa | |
|
|
d27f327f9c | |
|
|
07f7f26948 | |
|
|
bc3d800936 | |
|
|
6082725c80 | |
|
|
7564c1151d | |
|
|
254796b08d | |
|
|
cd1d16fc7f | |
|
|
b2bace1687 | |
|
|
9771a3d69d | |
|
|
91132ec167 | |
|
|
f97eacdc70 | |
|
|
8b7872d205 | |
|
|
15019206c8 | |
|
|
d108d2c693 | |
|
|
80e3fdb7e0 | |
|
|
6845ad4a0f | |
|
|
0e058caa28 | |
|
|
05e2c29f37 | |
|
|
1bb12783db | |
|
|
86461a052d | |
|
|
5bacd21840 | |
|
|
a1aba14ccf | |
|
|
1c621c32ec | |
|
|
d29454fc74 | |
|
|
04d3b2470a | |
|
|
207b522754 | |
|
|
34ba209e44 | |
|
|
cba7ff590a | |
|
|
ef5ac2bf94 | |
|
|
abb0da36a9 | |
|
|
b639b5d499 | |
|
|
2e91686a88 | |
|
|
dcc46c37b6 | |
|
|
f5d25afab8 | |
|
|
deffdab18b | |
|
|
59acea33fe | |
|
|
c24f383501 | |
|
|
f13814e577 | |
|
|
d075853a7f | |
|
|
c7978f6fb5 | |
|
|
17ecc9954f | |
|
|
50dc18a224 | |
|
|
126169c631 | |
|
|
1baed76d8e | |
|
|
c93eeba79a | |
|
|
8980a169ed | |
|
|
000e8f7ef1 | |
|
|
4817d92507 | |
|
|
4a69fdd070 | |
|
|
2192f5e917 | |
|
|
ebe7123583 | |
|
|
38ee808239 | |
|
|
8cfd107a92 | |
|
|
7972163af6 | |
|
|
c89c8769d9 | |
|
|
a8098d801b | |
|
|
a5f6b23a95 | |
|
|
c51539e494 | |
|
|
46f85b13b0 | |
|
|
4f5b18be48 | |
|
|
4d2bcc7568 | |
|
|
6dbb620e82 | |
|
|
999d0389b3 | |
|
|
14e70b56bb | |
|
|
bf11a269a4 | |
|
|
0fe0f3b72a | |
|
|
7ee6d633c6 | |
|
|
2de4baf0af | |
|
|
21f51c5d84 | |
|
|
eab61abace | |
|
|
7a1d438f84 | |
|
|
cb8c69788b | |
|
|
d8ef156b5e | |
|
|
5b3c391340 | |
|
|
d575287713 | |
|
|
6f668d69bd | |
|
|
0f3d03d832 | |
|
|
758babfdf8 | |
|
|
1f434f32fb | |
|
|
fe6c1b3fce | |
|
|
3bce996dd3 | |
|
|
6f43408da5 | |
|
|
e99dc122ad | |
|
|
0b8c76f8b5 | |
|
|
59efdb1f78 | |
|
|
d9d8f69562 | |
|
|
6443acf34a | |
|
|
0204f748e6 | |
|
|
2a5b51aa8d | |
|
|
0156be8d25 | |
|
|
d53c2212a6 | |
|
|
394f2529cd | |
|
|
ad51aa521f | |
|
|
88ad3ab53d | |
|
|
981b11f746 | |
|
|
42cf189749 | |
|
|
ec73541fe1 | |
|
|
c657bf6e2b | |
|
|
3102f3e7fb | |
|
|
1106a40ff1 | |
|
|
0d47fadf59 | |
|
|
ee07b52af9 | |
|
|
f39fd52001 | |
|
|
be52ac979b | |
|
|
8d7e5b17a1 | |
|
|
7549b2b9a9 | |
|
|
48720d1846 | |
|
|
b9e9bb6e4e | |
|
|
3635369a8a | |
|
|
ef663c0c08 | |
|
|
ea3d256647 | |
|
|
5728953b41 | |
|
|
a4f3a8d3ab | |
|
|
b9f803c5c8 | |
|
|
c802519ec2 | |
|
|
251e37f772 | |
|
|
bf772967f5 | |
|
|
338321b3a2 | |
|
|
60d99add2c | |
|
|
49ba2fcb19 | |
|
|
627c3c943c | |
|
|
bcfa5143e3 | |
|
|
33dda98e81 | |
|
|
7f2479d995 | |
|
|
4e4a876341 | |
|
|
73a617b88c | |
|
|
64d998c7b3 | |
|
|
b8f8831516 | |
|
|
83c29f8540 | |
|
|
6ec829a804 | |
|
|
6ccc192bc6 | |
|
|
a1c3657390 | |
|
|
f9835c388e | |
|
|
f1c99949ad | |
|
|
5b1f4c82e6 | |
|
|
6bcc571453 | |
|
|
0ad1136e48 | |
|
|
f60e3751b8 | |
|
|
e783661002 | |
|
|
25ea0bf64e | |
|
|
ce173451f5 | |
|
|
4df23b02b8 | |
|
|
c5126187d2 | |
|
|
286b82c63b | |
|
|
8e63547a3e | |
|
|
3cbb874503 | |
|
|
0ffa875a85 | |
|
|
03cc5bc324 | |
|
|
8c31dee000 | |
|
|
f167f1227c | |
|
|
9b8f720915 | |
|
|
9e83127113 | |
|
|
7c416adecd | |
|
|
7180e2ac27 | |
|
|
7d548dac4e | |
|
|
17f8a61bcf | |
|
|
a31fcaa9b8 | |
|
|
b50091eb1e | |
|
|
a0750fbd42 | |
|
|
d91550f704 | |
|
|
45d038bd4b | |
|
|
a27ed0fa16 | |
|
|
3507805005 | |
|
|
6900905475 | |
|
|
94d4524ee3 | |
|
|
49bcb96c4c | |
|
|
cfa0e2ca40 | |
|
|
bde7f0c53b | |
|
|
53a2e64cad | |
|
|
f0c7cee94e | |
|
|
187b82e9ac | |
|
|
c28ccb6206 | |
|
|
9adef67bb8 | |
|
|
9f94344e8b | |
|
|
b1607666a0 | |
|
|
ca4e5393be | |
|
|
817b7d3a9f | |
|
|
83384acdac | |
|
|
454b379f6c | |
|
|
08cf4681f2 | |
|
|
4a803ea008 | |
|
|
6b92ab0dd8 | |
|
|
a41feb841f | |
|
|
534d4ce70c | |
|
|
830d99a504 | |
|
|
99dbce2053 | |
|
|
5dc37e24d2 | |
|
|
f2b83650b5 | |
|
|
6f01892945 | |
|
|
dc51c19dfd | |
|
|
3ff38ca9c2 | |
|
|
96e1fa4534 | |
|
|
f595c6f26d | |
|
|
35fc957eaf | |
|
|
4112b45b9e | |
|
|
c6137078ff | |
|
|
7b7c9cd9f6 | |
|
|
5ef0992448 | |
|
|
7e289430ae | |
|
|
83fa6bec74 | |
|
|
263f1ecf8e | |
|
|
2b7a30983e | |
|
|
c02d0b4c3c | |
|
|
7560940e14 | |
|
|
776d181ef3 | |
|
|
28c73136a8 | |
|
|
dcc83b9f79 | |
|
|
8b459dd33f | |
|
|
1448435b06 | |
|
|
6ebc1f8767 | |
|
|
8fe38525a2 | |
|
|
40389fcfc7 | |
|
|
6bcfa18b01 | |
|
|
7a8a3a8fd1 | |
|
|
812b127ace | |
|
|
6be4775506 | |
|
|
b4afc4615c | |
|
|
fd64903841 | |
|
|
8314dda670 | |
|
|
4ce43c20cc | |
|
|
edc0ea46c9 | |
|
|
76d566d145 | |
|
|
219fb7bb69 | |
|
|
3b95a8a332 | |
|
|
aa33803d08 | |
|
|
cfdcd9352a | |
|
|
4283a369ae | |
|
|
58feec255d | |
|
|
94f9e7d5b5 | |
|
|
1974c43eba | |
|
|
a2da841d59 | |
|
|
0c0750ce93 | |
|
|
042a52550b | |
|
|
cec98e9d3e | |
|
|
2597d0ef46 | |
|
|
06dbe133c2 | |
|
|
263be15028 | |
|
|
d83c859965 | |
|
|
b4541129aa | |
|
|
8a9a983cbd | |
|
|
1bc42c207a | |
|
|
7b8105d76c | |
|
|
613f85f1c2 | |
|
|
71eea98ea5 | |
|
|
d04f0a08e0 | |
|
|
aeb70a6579 | |
|
|
69de49a000 | |
|
|
4e4d731b44 | |
|
|
3e29b1c23a | |
|
|
f77becbdae | |
|
|
dfb601b274 | |
|
|
bfbd062eb3 | |
|
|
df9f9914a8 | |
|
|
7b95711406 | |
|
|
41e7eed2c1 | |
|
|
003871aded | |
|
|
c2ee9b6daf | |
|
|
20b5593a0b | |
|
|
05c6ab3dc4 | |
|
|
3f3a5b021e | |
|
|
c37c85838b | |
|
|
2d0692a96f | |
|
|
85665fb6d3 | |
|
|
62b2a87e90 | |
|
|
704ee523c9 | |
|
|
26ef03a1bc | |
|
|
bb6febb46b | |
|
|
6dda30c528 | |
|
|
6f38f96b5a | |
|
|
3a985b443f | |
|
|
9f7a5cbb12 | |
|
|
dfc984f536 | |
|
|
f9619b7df1 | |
|
|
514722143f | |
|
|
ad4549e767 | |
|
|
dbeef9f415 | |
|
|
0eea1815ae | |
|
|
0b22928d9a | |
|
|
656f75a4d1 | |
|
|
d974fddda5 | |
|
|
144d28238e | |
|
|
78e105d46d | |
|
|
6e03c1c798 | |
|
|
a516006117 | |
|
|
3727b0e817 | |
|
|
7b3d28c957 | |
|
|
c002640911 | |
|
|
2799eb5a3a | |
|
|
37d3300b17 | |
|
|
e9dea69ee9 | |
|
|
e56c86545c | |
|
|
0009a9358d | |
|
|
f3d460ba09 | |
|
|
ab320083f7 | |
|
|
c7f7c10d59 | |
|
|
623e695353 | |
|
|
b6d723333c | |
|
|
d5dc248a16 | |
|
|
134e45e0bf | |
|
|
8a47659c47 | |
|
|
f44af3a2ed | |
|
|
18e9749ad8 | |
|
|
d47276a460 | |
|
|
0adc4c8c26 | |
|
|
d98e22f151 | |
|
|
c90d88a047 | |
|
|
9e9c791283 | |
|
|
2358b3ea17 | |
|
|
f14ad0b7ad | |
|
|
702fa937e8 | |
|
|
8b8d1f7d16 | |
|
|
4dcbe38309 | |
|
|
97b3a20a7c | |
|
|
e79d42db61 | |
|
|
16daa7403c | |
|
|
ca5de3add1 | |
|
|
390cc3131d | |
|
|
e4c320970f | |
|
|
af95f8da0c | |
|
|
7a5faad665 | |
|
|
8f0fc09a4c | |
|
|
30a82f09f3 | |
|
|
a02813a8ea | |
|
|
7a4f5591b7 | |
|
|
cb9831f2fc | |
|
|
71151eaabf | |
|
|
f7dbe2f62b | |
|
|
21c6c25f7c | |
|
|
e7260be219 | |
|
|
e89c3166bf | |
|
|
7c8ea7a9d7 | |
|
|
63aba087b6 | |
|
|
946978f624 | |
|
|
eeaa43e044 | |
|
|
e0eb734196 | |
|
|
fda022d29c | |
|
|
974b45554d | |
|
|
97e974b6da | |
|
|
495a1445fd | |
|
|
27a045e082 | |
|
|
6de365e707 | |
|
|
96da7518bf | |
|
|
cded4b2134 | |
|
|
86c8ede198 | |
|
|
0a199ae3b5 | |
|
|
fff56e8baa | |
|
|
7e61ac7ff2 | |
|
|
40ac037c03 | |
|
|
9062346650 | |
|
|
81b2e7a4c2 | |
|
|
9c816266ac | |
|
|
5f2f223f7b | |
|
|
09b0bc077e |
|
|
@ -792,7 +792,40 @@
|
||||||
"Bash(where:*)",
|
"Bash(where:*)",
|
||||||
"Bash(npx md-to-pdf:*)",
|
"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(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(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": [],
|
"deny": [],
|
||||||
"ask": []
|
"ask": []
|
||||||
|
|
|
||||||
|
|
@ -120,6 +120,45 @@ cmd_up() {
|
||||||
fi
|
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() {
|
cmd_down() {
|
||||||
log_info "停止 Kong API Gateway..."
|
log_info "停止 Kong API Gateway..."
|
||||||
|
|
@ -268,6 +307,127 @@ cmd_metrics() {
|
||||||
fi
|
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() {
|
show_help() {
|
||||||
echo ""
|
echo ""
|
||||||
|
|
@ -289,6 +449,14 @@ show_help() {
|
||||||
echo " test 测试 API 路由"
|
echo " test 测试 API 路由"
|
||||||
echo " clean 清理容器和数据"
|
echo " clean 清理容器和数据"
|
||||||
echo ""
|
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 "监控命令:"
|
||||||
echo " monitoring install [domain] 一键安装监控 (Nginx+SSL+服务)"
|
echo " monitoring install [domain] 一键安装监控 (Nginx+SSL+服务)"
|
||||||
echo " monitoring up 启动监控栈"
|
echo " monitoring up 启动监控栈"
|
||||||
|
|
@ -343,6 +511,27 @@ main() {
|
||||||
clean)
|
clean)
|
||||||
cmd_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)
|
monitoring)
|
||||||
case "${2:-up}" in
|
case "${2:-up}" in
|
||||||
install)
|
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,405 @@
|
||||||
|
# =============================================================================
|
||||||
|
# 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
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Transfer Service - 树转让服务
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
- name: transfer-service
|
||||||
|
url: http://192.168.1.111:3013
|
||||||
|
routes:
|
||||||
|
- name: transfer-api
|
||||||
|
paths:
|
||||||
|
- /api/v1/transfers
|
||||||
|
strip_path: false
|
||||||
|
- name: transfer-admin-api
|
||||||
|
paths:
|
||||||
|
- /api/v1/admin/transfers
|
||||||
|
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
|
||||||
|
|
@ -97,6 +97,11 @@ services:
|
||||||
paths:
|
paths:
|
||||||
- /api/v1/planting
|
- /api/v1/planting
|
||||||
strip_path: false
|
strip_path: false
|
||||||
|
# [2026-02-27] 新增:3171预种计划路由,预种控制器 @Controller('pre-planting')
|
||||||
|
- name: pre-planting-api
|
||||||
|
paths:
|
||||||
|
- /api/v1/pre-planting
|
||||||
|
strip_path: false
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# Referral Service - 推荐服务
|
# Referral Service - 推荐服务
|
||||||
|
|
@ -228,6 +233,22 @@ services:
|
||||||
paths:
|
paths:
|
||||||
- /api/v1/mobile/system
|
- /api/v1/mobile/system
|
||||||
strip_path: false
|
strip_path: false
|
||||||
|
- name: admin-app-assets-public
|
||||||
|
paths:
|
||||||
|
- /api/v1/app-assets
|
||||||
|
strip_path: false
|
||||||
|
- name: admin-system-config-public
|
||||||
|
paths:
|
||||||
|
- /api/v1/system-config
|
||||||
|
strip_path: false
|
||||||
|
- name: admin-customer-service-contacts-public
|
||||||
|
paths:
|
||||||
|
- /api/v1/customer-service-contacts
|
||||||
|
strip_path: false
|
||||||
|
- name: admin-tree-pricing-public
|
||||||
|
paths:
|
||||||
|
- /api/v1/tree-pricing
|
||||||
|
strip_path: false
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# Presence Service - 在线状态服务
|
# Presence Service - 在线状态服务
|
||||||
|
|
@ -270,6 +291,21 @@ services:
|
||||||
- /api/v1/co-managed
|
- /api/v1/co-managed
|
||||||
strip_path: false
|
strip_path: false
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Transfer Service - 树转让服务
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
- name: transfer-service
|
||||||
|
url: http://192.168.1.111:3013
|
||||||
|
routes:
|
||||||
|
- name: transfer-api
|
||||||
|
paths:
|
||||||
|
- /api/v1/transfers
|
||||||
|
strip_path: false
|
||||||
|
- name: transfer-admin-api
|
||||||
|
paths:
|
||||||
|
- /api/v1/admin/transfers
|
||||||
|
strip_path: false
|
||||||
|
|
||||||
|
|
||||||
# ===========================================================================
|
# ===========================================================================
|
||||||
# RWA 2.0 Services - 新架构微服务
|
# RWA 2.0 Services - 新架构微服务
|
||||||
|
|
@ -355,6 +391,19 @@ services:
|
||||||
- /api/v2/mining-admin/health
|
- /api/v2/mining-admin/health
|
||||||
strip_path: true
|
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 - 用户认证服务
|
# Auth Service 2.0 - 用户认证服务
|
||||||
# 前端路径: /api/v2/auth/...
|
# 前端路径: /api/v2/auth/...
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -65,6 +65,19 @@ server {
|
||||||
client_max_body_size 500M;
|
client_max_body_size 500M;
|
||||||
|
|
||||||
# 反向代理到 Kong API Gateway
|
# 反向代理到 Kong API Gateway
|
||||||
|
|
||||||
|
# Snapshot Service 代理 (admin-web Next.js rewrites 使用)
|
||||||
|
location /snapshot-api/ {
|
||||||
|
proxy_pass http://192.168.1.111:3099/;
|
||||||
|
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;
|
||||||
|
proxy_connect_timeout 30s;
|
||||||
|
proxy_send_timeout 300s;
|
||||||
|
proxy_read_timeout 300s;
|
||||||
|
}
|
||||||
location / {
|
location / {
|
||||||
proxy_pass http://127.0.0.1:8000;
|
proxy_pass http://127.0.0.1:8000;
|
||||||
proxy_http_version 1.1;
|
proxy_http_version 1.1;
|
||||||
|
|
|
||||||
|
|
@ -46,6 +46,7 @@ services:
|
||||||
redis:
|
redis:
|
||||||
image: redis:7-alpine
|
image: redis:7-alpine
|
||||||
container_name: rwa-redis
|
container_name: rwa-redis
|
||||||
|
command: redis-server --databases 20
|
||||||
ports:
|
ports:
|
||||||
- "6379:6379"
|
- "6379:6379"
|
||||||
healthcheck:
|
healthcheck:
|
||||||
|
|
|
||||||
|
|
@ -680,6 +680,9 @@ type SessionEvent struct {
|
||||||
ExpiresAt int64 `protobuf:"varint,10,opt,name=expires_at,json=expiresAt,proto3" json:"expires_at,omitempty"` // Unix timestamp milliseconds
|
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
|
// 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"`
|
DelegateUserShare *DelegateUserShare `protobuf:"bytes,11,opt,name=delegate_user_share,json=delegateUserShare,proto3" json:"delegate_user_share,omitempty"`
|
||||||
|
// 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
|
unknownFields protoimpl.UnknownFields
|
||||||
sizeCache protoimpl.SizeCache
|
sizeCache protoimpl.SizeCache
|
||||||
}
|
}
|
||||||
|
|
@ -791,6 +794,13 @@ func (x *SessionEvent) GetDelegateUserShare() *DelegateUserShare {
|
||||||
return nil
|
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
|
// DelegateUserShare contains user's share for delegate party to use in signing
|
||||||
type DelegateUserShare struct {
|
type DelegateUserShare struct {
|
||||||
state protoimpl.MessageState `protogen:"open.v1"`
|
state protoimpl.MessageState `protogen:"open.v1"`
|
||||||
|
|
@ -2479,7 +2489,7 @@ const file_api_proto_message_router_proto_rawDesc = "" +
|
||||||
"\x1dSubscribeSessionEventsRequest\x12\x19\n" +
|
"\x1dSubscribeSessionEventsRequest\x12\x19\n" +
|
||||||
"\bparty_id\x18\x01 \x01(\tR\apartyId\x12\x1f\n" +
|
"\bparty_id\x18\x01 \x01(\tR\apartyId\x12\x1f\n" +
|
||||||
"\vevent_types\x18\x02 \x03(\tR\n" +
|
"\vevent_types\x18\x02 \x03(\tR\n" +
|
||||||
"eventTypes\"\x94\x04\n" +
|
"eventTypes\"\xd2\x04\n" +
|
||||||
"\fSessionEvent\x12\x19\n" +
|
"\fSessionEvent\x12\x19\n" +
|
||||||
"\bevent_id\x18\x01 \x01(\tR\aeventId\x12\x1d\n" +
|
"\bevent_id\x18\x01 \x01(\tR\aeventId\x12\x1d\n" +
|
||||||
"\n" +
|
"\n" +
|
||||||
|
|
@ -2499,7 +2509,8 @@ const file_api_proto_message_router_proto_rawDesc = "" +
|
||||||
"\n" +
|
"\n" +
|
||||||
"expires_at\x18\n" +
|
"expires_at\x18\n" +
|
||||||
" \x01(\x03R\texpiresAt\x12P\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" +
|
"\x0fJoinTokensEntry\x12\x10\n" +
|
||||||
"\x03key\x18\x01 \x01(\tR\x03key\x12\x14\n" +
|
"\x03key\x18\x01 \x01(\tR\x03key\x12\x14\n" +
|
||||||
"\x05value\x18\x02 \x01(\tR\x05value:\x028\x01\"\x89\x01\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
|
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
|
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
|
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
|
25, // 4: mpc.router.v1.SessionEvent.participants:type_name -> mpc.router.v1.PartyInfo
|
||||||
6, // 5: mpc.router.v1.RegisteredParty.notification:type_name -> mpc.router.v1.NotificationChannel
|
10, // 5: mpc.router.v1.PublishSessionEventRequest.event:type_name -> mpc.router.v1.SessionEvent
|
||||||
15, // 6: mpc.router.v1.GetRegisteredPartiesResponse.parties:type_name -> mpc.router.v1.RegisteredParty
|
6, // 6: mpc.router.v1.RegisteredParty.notification:type_name -> mpc.router.v1.NotificationChannel
|
||||||
20, // 7: mpc.router.v1.GetMessageStatusResponse.deliveries:type_name -> mpc.router.v1.MessageDeliveryStatus
|
15, // 7: mpc.router.v1.GetRegisteredPartiesResponse.parties:type_name -> mpc.router.v1.RegisteredParty
|
||||||
24, // 8: mpc.router.v1.PartyInfo.device_info:type_name -> mpc.router.v1.DeviceInfo
|
20, // 8: mpc.router.v1.GetMessageStatusResponse.deliveries:type_name -> mpc.router.v1.MessageDeliveryStatus
|
||||||
24, // 9: mpc.router.v1.JoinSessionRequest.device_info:type_name -> mpc.router.v1.DeviceInfo
|
24, // 9: mpc.router.v1.PartyInfo.device_info:type_name -> mpc.router.v1.DeviceInfo
|
||||||
26, // 10: mpc.router.v1.JoinSessionResponse.session_info:type_name -> mpc.router.v1.SessionInfo
|
24, // 10: mpc.router.v1.JoinSessionRequest.device_info:type_name -> mpc.router.v1.DeviceInfo
|
||||||
25, // 11: mpc.router.v1.JoinSessionResponse.other_parties:type_name -> mpc.router.v1.PartyInfo
|
26, // 11: mpc.router.v1.JoinSessionResponse.session_info:type_name -> mpc.router.v1.SessionInfo
|
||||||
25, // 12: mpc.router.v1.GetSessionStatusResponse.participants:type_name -> mpc.router.v1.PartyInfo
|
25, // 12: mpc.router.v1.JoinSessionResponse.other_parties:type_name -> mpc.router.v1.PartyInfo
|
||||||
0, // 13: mpc.router.v1.MessageRouter.RouteMessage:input_type -> mpc.router.v1.RouteMessageRequest
|
25, // 13: mpc.router.v1.GetSessionStatusResponse.participants:type_name -> mpc.router.v1.PartyInfo
|
||||||
2, // 14: mpc.router.v1.MessageRouter.SubscribeMessages:input_type -> mpc.router.v1.SubscribeMessagesRequest
|
0, // 14: mpc.router.v1.MessageRouter.RouteMessage:input_type -> mpc.router.v1.RouteMessageRequest
|
||||||
4, // 15: mpc.router.v1.MessageRouter.GetPendingMessages:input_type -> mpc.router.v1.GetPendingMessagesRequest
|
2, // 15: mpc.router.v1.MessageRouter.SubscribeMessages:input_type -> mpc.router.v1.SubscribeMessagesRequest
|
||||||
17, // 16: mpc.router.v1.MessageRouter.AcknowledgeMessage:input_type -> mpc.router.v1.AcknowledgeMessageRequest
|
4, // 16: mpc.router.v1.MessageRouter.GetPendingMessages:input_type -> mpc.router.v1.GetPendingMessagesRequest
|
||||||
19, // 17: mpc.router.v1.MessageRouter.GetMessageStatus:input_type -> mpc.router.v1.GetMessageStatusRequest
|
17, // 17: mpc.router.v1.MessageRouter.AcknowledgeMessage:input_type -> mpc.router.v1.AcknowledgeMessageRequest
|
||||||
7, // 18: mpc.router.v1.MessageRouter.RegisterParty:input_type -> mpc.router.v1.RegisterPartyRequest
|
19, // 18: mpc.router.v1.MessageRouter.GetMessageStatus:input_type -> mpc.router.v1.GetMessageStatusRequest
|
||||||
22, // 19: mpc.router.v1.MessageRouter.Heartbeat:input_type -> mpc.router.v1.HeartbeatRequest
|
7, // 19: mpc.router.v1.MessageRouter.RegisterParty:input_type -> mpc.router.v1.RegisterPartyRequest
|
||||||
9, // 20: mpc.router.v1.MessageRouter.SubscribeSessionEvents:input_type -> mpc.router.v1.SubscribeSessionEventsRequest
|
22, // 20: mpc.router.v1.MessageRouter.Heartbeat:input_type -> mpc.router.v1.HeartbeatRequest
|
||||||
12, // 21: mpc.router.v1.MessageRouter.PublishSessionEvent:input_type -> mpc.router.v1.PublishSessionEventRequest
|
9, // 21: mpc.router.v1.MessageRouter.SubscribeSessionEvents:input_type -> mpc.router.v1.SubscribeSessionEventsRequest
|
||||||
14, // 22: mpc.router.v1.MessageRouter.GetRegisteredParties:input_type -> mpc.router.v1.GetRegisteredPartiesRequest
|
12, // 22: mpc.router.v1.MessageRouter.PublishSessionEvent:input_type -> mpc.router.v1.PublishSessionEventRequest
|
||||||
27, // 23: mpc.router.v1.MessageRouter.JoinSession:input_type -> mpc.router.v1.JoinSessionRequest
|
14, // 23: mpc.router.v1.MessageRouter.GetRegisteredParties:input_type -> mpc.router.v1.GetRegisteredPartiesRequest
|
||||||
29, // 24: mpc.router.v1.MessageRouter.MarkPartyReady:input_type -> mpc.router.v1.MarkPartyReadyRequest
|
27, // 24: mpc.router.v1.MessageRouter.JoinSession:input_type -> mpc.router.v1.JoinSessionRequest
|
||||||
31, // 25: mpc.router.v1.MessageRouter.ReportCompletion:input_type -> mpc.router.v1.ReportCompletionRequest
|
29, // 25: mpc.router.v1.MessageRouter.MarkPartyReady:input_type -> mpc.router.v1.MarkPartyReadyRequest
|
||||||
33, // 26: mpc.router.v1.MessageRouter.GetSessionStatus:input_type -> mpc.router.v1.GetSessionStatusRequest
|
31, // 26: mpc.router.v1.MessageRouter.ReportCompletion:input_type -> mpc.router.v1.ReportCompletionRequest
|
||||||
35, // 27: mpc.router.v1.MessageRouter.SubmitDelegateShare:input_type -> mpc.router.v1.SubmitDelegateShareRequest
|
33, // 27: mpc.router.v1.MessageRouter.GetSessionStatus:input_type -> mpc.router.v1.GetSessionStatusRequest
|
||||||
1, // 28: mpc.router.v1.MessageRouter.RouteMessage:output_type -> mpc.router.v1.RouteMessageResponse
|
35, // 28: mpc.router.v1.MessageRouter.SubmitDelegateShare:input_type -> mpc.router.v1.SubmitDelegateShareRequest
|
||||||
3, // 29: mpc.router.v1.MessageRouter.SubscribeMessages:output_type -> mpc.router.v1.MPCMessage
|
1, // 29: mpc.router.v1.MessageRouter.RouteMessage:output_type -> mpc.router.v1.RouteMessageResponse
|
||||||
5, // 30: mpc.router.v1.MessageRouter.GetPendingMessages:output_type -> mpc.router.v1.GetPendingMessagesResponse
|
3, // 30: mpc.router.v1.MessageRouter.SubscribeMessages:output_type -> mpc.router.v1.MPCMessage
|
||||||
18, // 31: mpc.router.v1.MessageRouter.AcknowledgeMessage:output_type -> mpc.router.v1.AcknowledgeMessageResponse
|
5, // 31: mpc.router.v1.MessageRouter.GetPendingMessages:output_type -> mpc.router.v1.GetPendingMessagesResponse
|
||||||
21, // 32: mpc.router.v1.MessageRouter.GetMessageStatus:output_type -> mpc.router.v1.GetMessageStatusResponse
|
18, // 32: mpc.router.v1.MessageRouter.AcknowledgeMessage:output_type -> mpc.router.v1.AcknowledgeMessageResponse
|
||||||
8, // 33: mpc.router.v1.MessageRouter.RegisterParty:output_type -> mpc.router.v1.RegisterPartyResponse
|
21, // 33: mpc.router.v1.MessageRouter.GetMessageStatus:output_type -> mpc.router.v1.GetMessageStatusResponse
|
||||||
23, // 34: mpc.router.v1.MessageRouter.Heartbeat:output_type -> mpc.router.v1.HeartbeatResponse
|
8, // 34: mpc.router.v1.MessageRouter.RegisterParty:output_type -> mpc.router.v1.RegisterPartyResponse
|
||||||
10, // 35: mpc.router.v1.MessageRouter.SubscribeSessionEvents:output_type -> mpc.router.v1.SessionEvent
|
23, // 35: mpc.router.v1.MessageRouter.Heartbeat:output_type -> mpc.router.v1.HeartbeatResponse
|
||||||
13, // 36: mpc.router.v1.MessageRouter.PublishSessionEvent:output_type -> mpc.router.v1.PublishSessionEventResponse
|
10, // 36: mpc.router.v1.MessageRouter.SubscribeSessionEvents:output_type -> mpc.router.v1.SessionEvent
|
||||||
16, // 37: mpc.router.v1.MessageRouter.GetRegisteredParties:output_type -> mpc.router.v1.GetRegisteredPartiesResponse
|
13, // 37: mpc.router.v1.MessageRouter.PublishSessionEvent:output_type -> mpc.router.v1.PublishSessionEventResponse
|
||||||
28, // 38: mpc.router.v1.MessageRouter.JoinSession:output_type -> mpc.router.v1.JoinSessionResponse
|
16, // 38: mpc.router.v1.MessageRouter.GetRegisteredParties:output_type -> mpc.router.v1.GetRegisteredPartiesResponse
|
||||||
30, // 39: mpc.router.v1.MessageRouter.MarkPartyReady:output_type -> mpc.router.v1.MarkPartyReadyResponse
|
28, // 39: mpc.router.v1.MessageRouter.JoinSession:output_type -> mpc.router.v1.JoinSessionResponse
|
||||||
32, // 40: mpc.router.v1.MessageRouter.ReportCompletion:output_type -> mpc.router.v1.ReportCompletionResponse
|
30, // 40: mpc.router.v1.MessageRouter.MarkPartyReady:output_type -> mpc.router.v1.MarkPartyReadyResponse
|
||||||
34, // 41: mpc.router.v1.MessageRouter.GetSessionStatus:output_type -> mpc.router.v1.GetSessionStatusResponse
|
32, // 41: mpc.router.v1.MessageRouter.ReportCompletion:output_type -> mpc.router.v1.ReportCompletionResponse
|
||||||
36, // 42: mpc.router.v1.MessageRouter.SubmitDelegateShare:output_type -> mpc.router.v1.SubmitDelegateShareResponse
|
34, // 42: mpc.router.v1.MessageRouter.GetSessionStatus:output_type -> mpc.router.v1.GetSessionStatusResponse
|
||||||
28, // [28:43] is the sub-list for method output_type
|
36, // 43: mpc.router.v1.MessageRouter.SubmitDelegateShare:output_type -> mpc.router.v1.SubmitDelegateShareResponse
|
||||||
13, // [13:28] is the sub-list for method input_type
|
29, // [29:44] is the sub-list for method output_type
|
||||||
13, // [13:13] is the sub-list for extension type_name
|
14, // [14:29] is the sub-list for method input_type
|
||||||
13, // [13:13] is the sub-list for extension extendee
|
14, // [14:14] is the sub-list for extension type_name
|
||||||
0, // [0:13] is the sub-list for field 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() }
|
func init() { file_api_proto_message_router_proto_init() }
|
||||||
|
|
|
||||||
|
|
@ -166,6 +166,9 @@ message SessionEvent {
|
||||||
int64 expires_at = 10; // Unix timestamp milliseconds
|
int64 expires_at = 10; // Unix timestamp milliseconds
|
||||||
// For sign sessions with delegate party: user's share for delegate to use
|
// For sign sessions with delegate party: user's share for delegate to use
|
||||||
DelegateUserShare delegate_user_share = 11;
|
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
|
// DelegateUserShare contains user's share for delegate party to use in signing
|
||||||
|
|
|
||||||
|
|
@ -32,9 +32,11 @@ type PendingSession struct {
|
||||||
SessionID uuid.UUID
|
SessionID uuid.UUID
|
||||||
JoinToken string
|
JoinToken string
|
||||||
MessageHash []byte
|
MessageHash []byte
|
||||||
|
KeygenSessionID uuid.UUID // For sign sessions: the keygen session that created the keys
|
||||||
ThresholdN int
|
ThresholdN int
|
||||||
ThresholdT int
|
ThresholdT int
|
||||||
SelectedParties []string
|
SelectedParties []string
|
||||||
|
Participants []use_cases.ParticipantInfo // CRITICAL: Correct PartyIndex from database (via JoinSession)
|
||||||
CreatedAt time.Time
|
CreatedAt time.Time
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -149,6 +151,14 @@ func main() {
|
||||||
cryptoService,
|
cryptoService,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// Initialize signing use case (for co-managed sign sessions)
|
||||||
|
participateSigningUC := use_cases.NewParticipateSigningUseCase(
|
||||||
|
keyShareRepo,
|
||||||
|
messageRouter,
|
||||||
|
messageRouter,
|
||||||
|
cryptoService,
|
||||||
|
)
|
||||||
|
|
||||||
// Create shutdown context
|
// Create shutdown context
|
||||||
ctx, cancel := context.WithCancel(context.Background())
|
ctx, cancel := context.WithCancel(context.Background())
|
||||||
defer cancel()
|
defer cancel()
|
||||||
|
|
@ -186,14 +196,15 @@ func main() {
|
||||||
defer heartbeatCancel()
|
defer heartbeatCancel()
|
||||||
logger.Info("Heartbeat started", zap.String("party_id", partyID), zap.Duration("interval", 30*time.Second))
|
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
|
// 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 only)", zap.String("party_id", partyID))
|
logger.Info("Subscribing to session events (co_managed_keygen and co_managed_sign)", zap.String("party_id", partyID))
|
||||||
|
|
||||||
eventHandler := createCoManagedSessionEventHandler(
|
eventHandler := createCoManagedSessionEventHandler(
|
||||||
ctx,
|
ctx,
|
||||||
partyID,
|
partyID,
|
||||||
messageRouter,
|
messageRouter,
|
||||||
participateKeygenUC,
|
participateKeygenUC,
|
||||||
|
participateSigningUC,
|
||||||
)
|
)
|
||||||
|
|
||||||
if err := messageRouter.SubscribeSessionEvents(ctx, partyID, eventHandler); err != nil {
|
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))
|
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:
|
// Two-phase event handling:
|
||||||
// Phase 1 (session_created): JoinSession immediately + store session info
|
// Phase 1 (session_created): JoinSession immediately + store session info
|
||||||
// Phase 2 (session_started): Execute TSS protocol (same timing as user clients receiving all_joined)
|
// 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(
|
func createCoManagedSessionEventHandler(
|
||||||
ctx context.Context,
|
ctx context.Context,
|
||||||
partyID string,
|
partyID string,
|
||||||
messageRouter *grpcclient.MessageRouterClient,
|
messageRouter *grpcclient.MessageRouterClient,
|
||||||
participateKeygenUC *use_cases.ParticipateKeygenUseCase,
|
participateKeygenUC *use_cases.ParticipateKeygenUseCase,
|
||||||
|
participateSigningUC *use_cases.ParticipateSigningUseCase,
|
||||||
) func(*router.SessionEvent) {
|
) func(*router.SessionEvent) {
|
||||||
return func(event *router.SessionEvent) {
|
return func(event *router.SessionEvent) {
|
||||||
// Check if this party is selected for the session
|
// Check if this party is selected for the session
|
||||||
|
|
@ -348,12 +361,27 @@ func createCoManagedSessionEventHandler(
|
||||||
// Handle different event types
|
// Handle different event types
|
||||||
switch event.EventType {
|
switch event.EventType {
|
||||||
case "session_created":
|
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 {
|
if len(event.MessageHash) > 0 {
|
||||||
logger.Debug("Ignoring sign session (co-managed only handles keygen)",
|
// This is a sign session
|
||||||
zap.String("session_id", event.SessionId))
|
// 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
|
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
|
// Phase 1: Get join token
|
||||||
joinToken, exists := event.JoinTokens[partyID]
|
joinToken, exists := event.JoinTokens[partyID]
|
||||||
|
|
@ -366,7 +394,7 @@ func createCoManagedSessionEventHandler(
|
||||||
|
|
||||||
// Immediately call JoinSession (this is required to trigger session_started)
|
// Immediately call JoinSession (this is required to trigger session_started)
|
||||||
joinCtx, joinCancel := context.WithTimeout(ctx, 30*time.Second)
|
joinCtx, joinCancel := context.WithTimeout(ctx, 30*time.Second)
|
||||||
_, err := messageRouter.JoinSession(joinCtx, sessionID, partyID, joinToken)
|
sessionInfo, err := messageRouter.JoinSession(joinCtx, sessionID, partyID, joinToken)
|
||||||
joinCancel()
|
joinCancel()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
logger.Error("Failed to join session",
|
logger.Error("Failed to join session",
|
||||||
|
|
@ -378,16 +406,19 @@ func createCoManagedSessionEventHandler(
|
||||||
|
|
||||||
logger.Info("Successfully joined session, waiting for session_started",
|
logger.Info("Successfully joined session, waiting for session_started",
|
||||||
zap.String("session_id", event.SessionId),
|
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
|
// Store pending session for later use when session_started arrives
|
||||||
pendingSessionCache.Store(event.SessionId, &PendingSession{
|
pendingSessionCache.Store(event.SessionId, &PendingSession{
|
||||||
SessionID: sessionID,
|
SessionID: sessionID,
|
||||||
JoinToken: joinToken,
|
JoinToken: joinToken,
|
||||||
MessageHash: event.MessageHash,
|
MessageHash: event.MessageHash,
|
||||||
|
KeygenSessionID: sessionInfo.KeygenSessionID, // CRITICAL: Save the correct keygen session ID from JoinSession
|
||||||
ThresholdN: int(event.ThresholdN),
|
ThresholdN: int(event.ThresholdN),
|
||||||
ThresholdT: int(event.ThresholdT),
|
ThresholdT: int(event.ThresholdT),
|
||||||
SelectedParties: event.SelectedParties,
|
SelectedParties: event.SelectedParties,
|
||||||
|
Participants: sessionInfo.Participants, // CRITICAL: Save participants with correct PartyIndex from database
|
||||||
CreatedAt: time.Now(),
|
CreatedAt: time.Now(),
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
@ -401,34 +432,90 @@ func createCoManagedSessionEventHandler(
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 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)))
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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",
|
logger.Info("Session started event received, beginning TSS keygen protocol",
|
||||||
zap.String("session_id", event.SessionId),
|
zap.String("session_id", event.SessionId),
|
||||||
zap.String("party_id", partyID))
|
zap.String("party_id", partyID),
|
||||||
|
zap.Int("participant_count", len(participants)))
|
||||||
|
}
|
||||||
|
|
||||||
// Execute TSS keygen protocol in goroutine
|
// Execute TSS protocol in goroutine
|
||||||
// Timeout starts NOW (when session_started is received), not at session_created
|
// Timeout starts NOW (when session_started is received), not at session_created
|
||||||
go func() {
|
go func() {
|
||||||
// 10 minute timeout for TSS protocol execution
|
// 10 minute timeout for TSS protocol execution
|
||||||
participateCtx, cancel := context.WithTimeout(ctx, 10*time.Minute)
|
participateCtx, cancel := context.WithTimeout(ctx, 10*time.Minute)
|
||||||
defer cancel()
|
defer cancel()
|
||||||
|
|
||||||
|
if isSignSession {
|
||||||
|
// Execute signing protocol
|
||||||
|
logger.Info("Auto-participating in co_managed_sign session",
|
||||||
|
zap.String("session_id", event.SessionId),
|
||||||
|
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",
|
logger.Info("Auto-participating in co_managed_keygen session",
|
||||||
zap.String("session_id", event.SessionId),
|
zap.String("session_id", event.SessionId),
|
||||||
zap.String("party_id", partyID))
|
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{
|
sessionInfo := &use_cases.SessionInfo{
|
||||||
SessionID: pendingSession.SessionID,
|
SessionID: pendingSession.SessionID,
|
||||||
SessionType: "co_managed_keygen",
|
SessionType: "co_managed_keygen",
|
||||||
|
|
@ -453,6 +540,7 @@ func createCoManagedSessionEventHandler(
|
||||||
zap.String("session_id", event.SessionId),
|
zap.String("session_id", event.SessionId),
|
||||||
zap.String("public_key", hex.EncodeToString(result.PublicKey)))
|
zap.String("public_key", hex.EncodeToString(result.PublicKey)))
|
||||||
}
|
}
|
||||||
|
}
|
||||||
}()
|
}()
|
||||||
|
|
||||||
default:
|
default:
|
||||||
|
|
|
||||||
|
|
@ -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
|
// Execute participates in a signing session using real TSS protocol
|
||||||
func (uc *ParticipateSigningUseCase) Execute(
|
func (uc *ParticipateSigningUseCase) Execute(
|
||||||
ctx context.Context,
|
ctx context.Context,
|
||||||
|
|
@ -211,6 +235,123 @@ func (uc *ParticipateSigningUseCase) Execute(
|
||||||
}, nil
|
}, 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
|
// runSigningProtocol runs the TSS signing protocol using tss-lib
|
||||||
func (uc *ParticipateSigningUseCase) runSigningProtocol(
|
func (uc *ParticipateSigningUseCase) runSigningProtocol(
|
||||||
ctx context.Context,
|
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. 测试编译和功能
|
||||||
|
|
@ -13,6 +13,7 @@ import androidx.activity.result.contract.ActivityResultContracts
|
||||||
import androidx.compose.foundation.layout.*
|
import androidx.compose.foundation.layout.*
|
||||||
import androidx.compose.material3.*
|
import androidx.compose.material3.*
|
||||||
import androidx.compose.runtime.*
|
import androidx.compose.runtime.*
|
||||||
|
import androidx.compose.runtime.saveable.rememberSaveable
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.platform.LocalContext
|
import androidx.compose.ui.platform.LocalContext
|
||||||
import androidx.hilt.navigation.compose.hiltViewModel
|
import androidx.hilt.navigation.compose.hiltViewModel
|
||||||
|
|
@ -76,6 +77,7 @@ fun TssPartyApp(
|
||||||
val currentSessionId by viewModel.currentSessionId.collectAsState()
|
val currentSessionId by viewModel.currentSessionId.collectAsState()
|
||||||
val sessionParticipants by viewModel.sessionParticipants.collectAsState()
|
val sessionParticipants by viewModel.sessionParticipants.collectAsState()
|
||||||
val currentRound by viewModel.currentRound.collectAsState()
|
val currentRound by viewModel.currentRound.collectAsState()
|
||||||
|
val totalRounds by viewModel.totalRounds.collectAsState()
|
||||||
val publicKey by viewModel.publicKey.collectAsState()
|
val publicKey by viewModel.publicKey.collectAsState()
|
||||||
val hasEnteredSession by viewModel.hasEnteredSession.collectAsState()
|
val hasEnteredSession by viewModel.hasEnteredSession.collectAsState()
|
||||||
|
|
||||||
|
|
@ -109,69 +111,111 @@ fun TssPartyApp(
|
||||||
val exportResult by viewModel.exportResult.collectAsState()
|
val exportResult by viewModel.exportResult.collectAsState()
|
||||||
val importResult by viewModel.importResult.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
|
// Current transfer wallet
|
||||||
var transferWalletId by remember { mutableStateOf<Long?>(null) }
|
var transferWalletId by remember { mutableStateOf<Long?>(null) }
|
||||||
|
|
||||||
// Export/Import file handling
|
// Export/Import file handling
|
||||||
val context = LocalContext.current
|
val context = LocalContext.current
|
||||||
var pendingExportJson by remember { mutableStateOf<String?>(null) }
|
// Use rememberSaveable to persist across configuration changes (e.g., file picker activity)
|
||||||
var pendingExportAddress by remember { mutableStateOf<String?>(null) }
|
var pendingExportJson by rememberSaveable { mutableStateOf<String?>(null) }
|
||||||
|
var pendingExportAddress by rememberSaveable { mutableStateOf<String?>(null) }
|
||||||
|
|
||||||
// File picker for saving backup
|
// File picker for saving backup
|
||||||
val createDocumentLauncher = rememberLauncherForActivityResult(
|
val createDocumentLauncher = rememberLauncherForActivityResult(
|
||||||
contract = ActivityResultContracts.CreateDocument(ShareBackup.MIME_TYPE)
|
contract = ActivityResultContracts.CreateDocument(ShareBackup.MIME_TYPE)
|
||||||
) { uri: Uri? ->
|
) { 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 ->
|
uri?.let { targetUri ->
|
||||||
pendingExportJson?.let { json ->
|
pendingExportJson?.let { json ->
|
||||||
try {
|
try {
|
||||||
|
android.util.Log.d("MainActivity", "[EXPORT-FILE] Opening output stream to: $targetUri")
|
||||||
context.contentResolver.openOutputStream(targetUri)?.use { outputStream ->
|
context.contentResolver.openOutputStream(targetUri)?.use { outputStream ->
|
||||||
|
android.util.Log.d("MainActivity", "[EXPORT-FILE] Writing ${json.length} bytes...")
|
||||||
outputStream.write(json.toByteArray(Charsets.UTF_8))
|
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()
|
Toast.makeText(context, "备份文件已保存", Toast.LENGTH_SHORT).show()
|
||||||
} catch (e: Exception) {
|
} 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()
|
Toast.makeText(context, "保存失败: ${e.message}", Toast.LENGTH_LONG).show()
|
||||||
}
|
}
|
||||||
|
android.util.Log.d("MainActivity", "[EXPORT-FILE] Clearing pendingExportJson and pendingExportAddress")
|
||||||
pendingExportJson = null
|
pendingExportJson = null
|
||||||
pendingExportAddress = 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
|
// File picker for importing backup
|
||||||
val openDocumentLauncher = rememberLauncherForActivityResult(
|
val openDocumentLauncher = rememberLauncherForActivityResult(
|
||||||
contract = ActivityResultContracts.OpenDocument()
|
contract = ActivityResultContracts.OpenDocument()
|
||||||
) { uri: Uri? ->
|
) { uri: Uri? ->
|
||||||
|
android.util.Log.d("MainActivity", "[IMPORT-FILE] ========== openDocumentLauncher callback ==========")
|
||||||
|
android.util.Log.d("MainActivity", "[IMPORT-FILE] uri: $uri")
|
||||||
uri?.let { sourceUri ->
|
uri?.let { sourceUri ->
|
||||||
try {
|
try {
|
||||||
|
android.util.Log.d("MainActivity", "[IMPORT-FILE] Opening input stream from: $sourceUri")
|
||||||
context.contentResolver.openInputStream(sourceUri)?.use { inputStream ->
|
context.contentResolver.openInputStream(sourceUri)?.use { inputStream ->
|
||||||
val json = inputStream.bufferedReader().readText()
|
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)
|
viewModel.importShareBackup(json)
|
||||||
|
android.util.Log.d("MainActivity", "[IMPORT-FILE] viewModel.importShareBackup called")
|
||||||
}
|
}
|
||||||
} catch (e: Exception) {
|
} 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()
|
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
|
// Handle export result - trigger file save dialog
|
||||||
LaunchedEffect(pendingExportJson) {
|
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 ->
|
pendingExportJson?.let { json ->
|
||||||
val timestamp = SimpleDateFormat("yyyyMMdd_HHmmss", Locale.getDefault()).format(Date())
|
val timestamp = SimpleDateFormat("yyyyMMdd_HHmmss", Locale.getDefault()).format(Date())
|
||||||
val addressSuffix = pendingExportAddress?.take(8) ?: "wallet"
|
val addressSuffix = pendingExportAddress?.take(8) ?: "wallet"
|
||||||
val fileName = "tss_backup_${addressSuffix}_$timestamp.${ShareBackup.FILE_EXTENSION}"
|
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)
|
createDocumentLauncher.launch(fileName)
|
||||||
|
android.util.Log.d("MainActivity", "[EXPORT-EFFECT] File picker launched")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Handle import result - show toast
|
// Handle import result - show toast
|
||||||
LaunchedEffect(importResult) {
|
LaunchedEffect(importResult) {
|
||||||
|
android.util.Log.d("MainActivity", "[IMPORT-EFFECT] LaunchedEffect(importResult) triggered")
|
||||||
|
android.util.Log.d("MainActivity", "[IMPORT-EFFECT] importResult: $importResult")
|
||||||
importResult?.let { result ->
|
importResult?.let { result ->
|
||||||
|
android.util.Log.d("MainActivity", "[IMPORT-EFFECT] isSuccess: ${result.isSuccess}, error: ${result.error}, message: ${result.message}")
|
||||||
when {
|
when {
|
||||||
result.isSuccess -> {
|
result.isSuccess -> {
|
||||||
|
android.util.Log.d("MainActivity", "[IMPORT-EFFECT] Showing success toast")
|
||||||
Toast.makeText(context, result.message ?: "导入成功", Toast.LENGTH_SHORT).show()
|
Toast.makeText(context, result.message ?: "导入成功", Toast.LENGTH_SHORT).show()
|
||||||
viewModel.clearExportImportResult()
|
viewModel.clearExportImportResult()
|
||||||
}
|
}
|
||||||
result.error != null -> {
|
result.error != null -> {
|
||||||
|
android.util.Log.d("MainActivity", "[IMPORT-EFFECT] Showing error toast: ${result.error}")
|
||||||
Toast.makeText(context, result.error, Toast.LENGTH_LONG).show()
|
Toast.makeText(context, result.error, Toast.LENGTH_LONG).show()
|
||||||
viewModel.clearExportImportResult()
|
viewModel.clearExportImportResult()
|
||||||
}
|
}
|
||||||
|
|
@ -180,7 +224,9 @@ fun TssPartyApp(
|
||||||
}
|
}
|
||||||
|
|
||||||
// Track if startup is complete
|
// 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
|
// Handle success messages
|
||||||
LaunchedEffect(uiState.successMessage) {
|
LaunchedEffect(uiState.successMessage) {
|
||||||
|
|
@ -256,18 +302,34 @@ fun TssPartyApp(
|
||||||
transferWalletId = shareId
|
transferWalletId = shareId
|
||||||
navController.navigate("transfer/$shareId")
|
navController.navigate("transfer/$shareId")
|
||||||
},
|
},
|
||||||
|
onHistory = { shareId, address ->
|
||||||
|
navController.navigate("history/$shareId/$address")
|
||||||
|
},
|
||||||
onExportBackup = { shareId, _ ->
|
onExportBackup = { shareId, _ ->
|
||||||
|
android.util.Log.d("MainActivity", "[EXPORT-TRIGGER] ========== onExportBackup called ==========")
|
||||||
|
android.util.Log.d("MainActivity", "[EXPORT-TRIGGER] shareId: $shareId")
|
||||||
// Get address for filename
|
// Get address for filename
|
||||||
val share = shares.find { it.id == shareId }
|
val share = shares.find { it.id == shareId }
|
||||||
|
android.util.Log.d("MainActivity", "[EXPORT-TRIGGER] share found: ${share != null}, address: ${share?.address}")
|
||||||
pendingExportAddress = share?.address
|
pendingExportAddress = share?.address
|
||||||
|
android.util.Log.d("MainActivity", "[EXPORT-TRIGGER] pendingExportAddress set to: $pendingExportAddress")
|
||||||
// Export and save to file
|
// Export and save to file
|
||||||
|
android.util.Log.d("MainActivity", "[EXPORT-TRIGGER] Calling viewModel.exportShareBackup...")
|
||||||
viewModel.exportShareBackup(shareId) { json ->
|
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
|
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 = {
|
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
|
// Open file picker to select backup file
|
||||||
openDocumentLauncher.launch(arrayOf("*/*"))
|
openDocumentLauncher.launch(arrayOf("*/*"))
|
||||||
|
android.util.Log.d("MainActivity", "[IMPORT-TRIGGER] File picker launched")
|
||||||
},
|
},
|
||||||
onCreateWallet = {
|
onCreateWallet = {
|
||||||
navController.navigate(BottomNavItem.Create.route)
|
navController.navigate(BottomNavItem.Create.route)
|
||||||
|
|
@ -288,7 +350,7 @@ fun TssPartyApp(
|
||||||
sessionStatus = sessionStatus,
|
sessionStatus = sessionStatus,
|
||||||
participants = signParticipants,
|
participants = signParticipants,
|
||||||
currentRound = signCurrentRound,
|
currentRound = signCurrentRound,
|
||||||
totalRounds = 9,
|
totalRounds = if (totalRounds > 0) totalRounds else 9, // Default to sign rounds
|
||||||
preparedTx = preparedTx,
|
preparedTx = preparedTx,
|
||||||
signSessionId = signSessionId,
|
signSessionId = signSessionId,
|
||||||
inviteCode = signInviteCode,
|
inviteCode = signInviteCode,
|
||||||
|
|
@ -301,8 +363,19 @@ fun TssPartyApp(
|
||||||
onPrepareTransaction = { toAddress, amount, tokenType ->
|
onPrepareTransaction = { toAddress, amount, tokenType ->
|
||||||
viewModel.prepareTransfer(shareId, toAddress, amount, tokenType)
|
viewModel.prepareTransfer(shareId, toAddress, amount, tokenType)
|
||||||
},
|
},
|
||||||
onConfirmTransaction = {
|
onConfirmTransaction = { includeServerBackup ->
|
||||||
|
// 【新增】根据用户选择调用相应的签名方法
|
||||||
|
// includeServerBackup = true: 使用新方法,包含服务器备份参与方
|
||||||
|
// includeServerBackup = false: 使用现有方法,排除服务器方(默认行为)
|
||||||
|
if (includeServerBackup) {
|
||||||
|
viewModel.initiateSignSessionWithOptions(
|
||||||
|
shareId = shareId,
|
||||||
|
password = "",
|
||||||
|
includeServerBackup = true
|
||||||
|
)
|
||||||
|
} else {
|
||||||
viewModel.initiateSignSession(shareId, "")
|
viewModel.initiateSignSession(shareId, "")
|
||||||
|
}
|
||||||
},
|
},
|
||||||
onCopyInviteCode = {
|
onCopyInviteCode = {
|
||||||
signInviteCode?.let { onCopyToClipboard(it) }
|
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 (创建钱包)
|
// Tab 2: Create Wallet (创建钱包)
|
||||||
composable(BottomNavItem.Create.route) {
|
composable(BottomNavItem.Create.route) {
|
||||||
CreateWalletScreen(
|
CreateWalletScreen(
|
||||||
|
|
@ -336,7 +436,7 @@ fun TssPartyApp(
|
||||||
hasEnteredSession = hasEnteredSession,
|
hasEnteredSession = hasEnteredSession,
|
||||||
participants = sessionParticipants,
|
participants = sessionParticipants,
|
||||||
currentRound = currentRound,
|
currentRound = currentRound,
|
||||||
totalRounds = 9,
|
totalRounds = if (totalRounds > 0) totalRounds else 4, // Default to keygen rounds
|
||||||
publicKey = publicKey,
|
publicKey = publicKey,
|
||||||
countdownSeconds = uiState.countdownSeconds,
|
countdownSeconds = uiState.countdownSeconds,
|
||||||
onCreateSession = { name, t, n, participantName ->
|
onCreateSession = { name, t, n, participantName ->
|
||||||
|
|
@ -387,7 +487,7 @@ fun TssPartyApp(
|
||||||
sessionInfo = screenSessionInfo,
|
sessionInfo = screenSessionInfo,
|
||||||
participants = joinKeygenParticipants,
|
participants = joinKeygenParticipants,
|
||||||
currentRound = joinKeygenRound,
|
currentRound = joinKeygenRound,
|
||||||
totalRounds = 9,
|
totalRounds = if (totalRounds > 0) totalRounds else 4, // Default to keygen rounds
|
||||||
publicKey = joinKeygenPublicKey,
|
publicKey = joinKeygenPublicKey,
|
||||||
countdownSeconds = uiState.countdownSeconds,
|
countdownSeconds = uiState.countdownSeconds,
|
||||||
onValidateInviteCode = { inviteCode ->
|
onValidateInviteCode = { inviteCode ->
|
||||||
|
|
@ -443,7 +543,7 @@ fun TssPartyApp(
|
||||||
signSessionInfo = screenSignSessionInfo,
|
signSessionInfo = screenSignSessionInfo,
|
||||||
participants = coSignParticipants,
|
participants = coSignParticipants,
|
||||||
currentRound = coSignRound,
|
currentRound = coSignRound,
|
||||||
totalRounds = 9,
|
totalRounds = if (totalRounds > 0) totalRounds else 9, // Default to sign rounds
|
||||||
signature = coSignSignature,
|
signature = coSignSignature,
|
||||||
countdownSeconds = uiState.countdownSeconds,
|
countdownSeconds = uiState.countdownSeconds,
|
||||||
onValidateInviteCode = { inviteCode ->
|
onValidateInviteCode = { inviteCode ->
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,87 @@
|
||||||
package com.durian.tssparty
|
package com.durian.tssparty
|
||||||
|
|
||||||
import android.app.Application
|
import android.app.Application
|
||||||
|
import android.util.Log
|
||||||
import dagger.hilt.android.HiltAndroidApp
|
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
|
@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}")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -97,6 +97,11 @@ class GrpcClient @Inject constructor() {
|
||||||
private var registeredPartyId: String? = null
|
private var registeredPartyId: String? = null
|
||||||
private var registeredPartyRole: 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
|
// Heartbeat state
|
||||||
private var heartbeatJob: Job? = null
|
private var heartbeatJob: Job? = null
|
||||||
private val heartbeatFailCount = AtomicInteger(0)
|
private val heartbeatFailCount = AtomicInteger(0)
|
||||||
|
|
@ -123,17 +128,26 @@ class GrpcClient @Inject constructor() {
|
||||||
* Connect to the Message Router server
|
* Connect to the Message Router server
|
||||||
*/
|
*/
|
||||||
fun connect(host: String, port: Int) {
|
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
|
// Save connection params for reconnection
|
||||||
currentHost = host
|
currentHost = host
|
||||||
currentPort = port
|
currentPort = port
|
||||||
shouldReconnect.set(true)
|
shouldReconnect.set(true)
|
||||||
reconnectAttempts.set(0)
|
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)
|
doConnect(host, port)
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun doConnect(host: String, port: Int) {
|
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
|
_connectionState.value = GrpcConnectionState.Connecting
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
|
@ -183,23 +197,38 @@ class GrpcClient @Inject constructor() {
|
||||||
|
|
||||||
when (state) {
|
when (state) {
|
||||||
ConnectivityState.READY -> {
|
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
|
_connectionState.value = GrpcConnectionState.Connected
|
||||||
reconnectAttempts.set(0)
|
reconnectAttempts.set(0)
|
||||||
heartbeatFailCount.set(0)
|
heartbeatFailCount.set(0)
|
||||||
isReconnecting.set(false)
|
|
||||||
|
|
||||||
// Start channel state monitoring
|
// Start channel state monitoring
|
||||||
startChannelStateMonitor()
|
startChannelStateMonitor()
|
||||||
|
|
||||||
|
// 只有重连时才需要恢复注册和订阅
|
||||||
|
// 初次连接时,registerParty() 和 subscribeSessionEvents() 会在外部显式调用
|
||||||
|
if (wasReconnecting) {
|
||||||
|
Log.d(TAG, ">>> RECONNECT: Restoring registration and streams")
|
||||||
// Re-register if we were registered before
|
// Re-register if we were registered before
|
||||||
reRegisterIfNeeded()
|
reRegisterIfNeeded()
|
||||||
|
|
||||||
// Restart heartbeat
|
|
||||||
startHeartbeat()
|
|
||||||
|
|
||||||
// Re-subscribe to streams
|
// Re-subscribe to streams
|
||||||
reSubscribeStreams()
|
reSubscribeStreams()
|
||||||
|
} else {
|
||||||
|
Log.d(TAG, ">>> FIRST CONNECT: Skipping restore (will be done by caller)")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Restart heartbeat (both first connect and reconnect need this)
|
||||||
|
startHeartbeat()
|
||||||
|
|
||||||
return@withTimeout
|
return@withTimeout
|
||||||
}
|
}
|
||||||
|
|
@ -308,18 +337,23 @@ class GrpcClient @Inject constructor() {
|
||||||
* Trigger reconnection with exponential backoff
|
* Trigger reconnection with exponential backoff
|
||||||
*/
|
*/
|
||||||
private fun triggerReconnect(reason: String) {
|
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)) {
|
if (!shouldReconnect.get() || isReconnecting.getAndSet(true)) {
|
||||||
|
Log.d(TAG, "[IDLE_CRASH_DEBUG] triggerReconnect skipped (already reconnecting or disabled)")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
val host = currentHost
|
val host = currentHost
|
||||||
val port = currentPort
|
val port = currentPort
|
||||||
if (host == null || port == null) {
|
if (host == null || port == null) {
|
||||||
|
Log.d(TAG, "[IDLE_CRASH_DEBUG] triggerReconnect skipped (no host/port)")
|
||||||
isReconnecting.set(false)
|
isReconnecting.set(false)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
Log.d(TAG, "Triggering reconnect: $reason")
|
Log.d(TAG, "[IDLE_CRASH_DEBUG] Triggering reconnect to $host:$port")
|
||||||
|
|
||||||
// Emit disconnected event
|
// Emit disconnected event
|
||||||
_connectionEvents.tryEmit(GrpcConnectionEvent.Disconnected(reason))
|
_connectionEvents.tryEmit(GrpcConnectionEvent.Disconnected(reason))
|
||||||
|
|
@ -347,7 +381,10 @@ class GrpcClient @Inject constructor() {
|
||||||
Log.d(TAG, "Reconnecting in ${delay}ms (attempt $attempt/${reconnectConfig.maxRetries})")
|
Log.d(TAG, "Reconnecting in ${delay}ms (attempt $attempt/${reconnectConfig.maxRetries})")
|
||||||
delay(delay)
|
delay(delay)
|
||||||
|
|
||||||
isReconnecting.set(false)
|
// 注意:不要在这里重置 isReconnecting!
|
||||||
|
// isReconnecting 会在 waitForConnection() 的 READY 分支中被重置
|
||||||
|
// 这样 waitForConnection() 才能知道这是重连而非初次连接
|
||||||
|
Log.d(TAG, ">>> Starting reconnect, isReconnecting=$isReconnecting (should be true)")
|
||||||
doConnect(host, port)
|
doConnect(host, port)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -396,15 +433,18 @@ class GrpcClient @Inject constructor() {
|
||||||
|
|
||||||
private fun handleHeartbeatFailure(reason: String) {
|
private fun handleHeartbeatFailure(reason: String) {
|
||||||
val fails = heartbeatFailCount.incrementAndGet()
|
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) {
|
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")
|
triggerReconnect("Heartbeat failed")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun stopHeartbeat() {
|
private fun stopHeartbeat() {
|
||||||
|
Log.d(TAG, "[IDLE_CRASH_DEBUG] stopHeartbeat called")
|
||||||
heartbeatJob?.cancel()
|
heartbeatJob?.cancel()
|
||||||
heartbeatJob = null
|
heartbeatJob = null
|
||||||
heartbeatFailCount.set(0)
|
heartbeatFailCount.set(0)
|
||||||
|
|
@ -425,6 +465,17 @@ class GrpcClient @Inject constructor() {
|
||||||
Log.e(TAG, "Re-registration failed: ${e.message}")
|
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
|
* Notifies the repository layer to re-establish message/event subscriptions
|
||||||
*/
|
*/
|
||||||
private fun reSubscribeStreams() {
|
private fun reSubscribeStreams() {
|
||||||
|
Log.d(TAG, "[IDLE_CRASH_DEBUG] reSubscribeStreams called")
|
||||||
val needsResubscribe = eventStreamSubscribed.get() || activeMessageSubscription != null
|
val needsResubscribe = eventStreamSubscribed.get() || activeMessageSubscription != null
|
||||||
|
|
||||||
if (needsResubscribe) {
|
if (needsResubscribe) {
|
||||||
Log.d(TAG, "Triggering stream re-subscription callback")
|
Log.d(TAG, "[IDLE_CRASH_DEBUG] Triggering stream re-subscription callback")
|
||||||
Log.d(TAG, " - Event stream: ${eventStreamSubscribed.get()}, partyId: $eventStreamPartyId")
|
Log.d(TAG, "[IDLE_CRASH_DEBUG] - Event stream: ${eventStreamSubscribed.get()}, partyId: $eventStreamPartyId")
|
||||||
Log.d(TAG, " - Message stream: ${activeMessageSubscription?.sessionId}")
|
Log.d(TAG, "[IDLE_CRASH_DEBUG] - Message stream: ${activeMessageSubscription?.sessionId}")
|
||||||
|
|
||||||
// Notify repository to re-establish streams
|
// Notify repository to re-establish streams
|
||||||
scope.launch {
|
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
|
// Wait for channel to be fully ready instead of fixed delay
|
||||||
if (waitForChannelReady()) {
|
if (waitForChannelReady()) {
|
||||||
|
Log.d(TAG, "[IDLE_CRASH_DEBUG] Channel ready, invoking reconnect callback")
|
||||||
try {
|
try {
|
||||||
onReconnectedCallback?.invoke()
|
onReconnectedCallback?.invoke()
|
||||||
|
Log.d(TAG, "[IDLE_CRASH_DEBUG] Reconnect callback completed")
|
||||||
// Emit reconnected event
|
// Emit reconnected event
|
||||||
_connectionEvents.tryEmit(GrpcConnectionEvent.Reconnected)
|
_connectionEvents.tryEmit(GrpcConnectionEvent.Reconnected)
|
||||||
} catch (e: Exception) {
|
} 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
|
// Don't let callback failure affect the connection state
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
|
|
@ -566,6 +622,15 @@ class GrpcClient @Inject constructor() {
|
||||||
partyRole: String = "temporary",
|
partyRole: String = "temporary",
|
||||||
version: String = "1.0.0"
|
version: String = "1.0.0"
|
||||||
): Result<Boolean> = withContext(Dispatchers.IO) {
|
): 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
|
// Save for re-registration
|
||||||
registeredPartyId = partyId
|
registeredPartyId = partyId
|
||||||
registeredPartyRole = partyRole
|
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
|
* Join a session
|
||||||
*/
|
*/
|
||||||
|
|
@ -741,15 +829,16 @@ class GrpcClient @Inject constructor() {
|
||||||
override fun onError(t: Throwable) {
|
override fun onError(t: Throwable) {
|
||||||
Log.e(TAG, "Message stream error: ${t.message}")
|
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) {
|
if (messageStreamVersion.get() != streamVersion) {
|
||||||
Log.d(TAG, "Ignoring error from stale message stream")
|
Log.d(TAG, "Ignoring error from stale message stream")
|
||||||
close(t)
|
close()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Don't trigger reconnect for CANCELLED errors
|
// Don't trigger reconnect for CANCELLED or channel shutdown errors
|
||||||
if (!t.message.orEmpty().contains("CANCELLED")) {
|
val errorMessage = t.message.orEmpty()
|
||||||
|
if (!errorMessage.contains("CANCELLED") && !errorMessage.contains("shutdownNow")) {
|
||||||
triggerReconnect("Message stream error: ${t.message}")
|
triggerReconnect("Message stream error: ${t.message}")
|
||||||
}
|
}
|
||||||
close(t)
|
close(t)
|
||||||
|
|
@ -821,15 +910,16 @@ class GrpcClient @Inject constructor() {
|
||||||
override fun onError(t: Throwable) {
|
override fun onError(t: Throwable) {
|
||||||
Log.e(TAG, "Session event stream error: ${t.message}")
|
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) {
|
if (eventStreamVersion.get() != streamVersion) {
|
||||||
Log.d(TAG, "Ignoring error from stale event stream")
|
Log.d(TAG, "Ignoring error from stale event stream")
|
||||||
close(t)
|
close()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Don't trigger reconnect for CANCELLED errors
|
// Don't trigger reconnect for CANCELLED or channel shutdown errors
|
||||||
if (!t.message.orEmpty().contains("CANCELLED")) {
|
val errorMessage = t.message.orEmpty()
|
||||||
|
if (!errorMessage.contains("CANCELLED") && !errorMessage.contains("shutdownNow")) {
|
||||||
triggerReconnect("Event stream error: ${t.message}")
|
triggerReconnect("Event stream error: ${t.message}")
|
||||||
}
|
}
|
||||||
close(t)
|
close(t)
|
||||||
|
|
|
||||||
File diff suppressed because it is too large
Load Diff
|
|
@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -78,7 +78,7 @@ fun TransferScreen(
|
||||||
networkType: NetworkType = NetworkType.MAINNET,
|
networkType: NetworkType = NetworkType.MAINNET,
|
||||||
rpcUrl: String = "https://evm.kava.io",
|
rpcUrl: String = "https://evm.kava.io",
|
||||||
onPrepareTransaction: (toAddress: String, amount: String, tokenType: TokenType) -> Unit,
|
onPrepareTransaction: (toAddress: String, amount: String, tokenType: TokenType) -> Unit,
|
||||||
onConfirmTransaction: () -> Unit,
|
onConfirmTransaction: (includeServerBackup: Boolean) -> Unit, // 新增参数:是否包含服务器备份参与签名
|
||||||
onCopyInviteCode: () -> Unit,
|
onCopyInviteCode: () -> Unit,
|
||||||
onBroadcastTransaction: () -> Unit,
|
onBroadcastTransaction: () -> Unit,
|
||||||
onCancel: () -> Unit,
|
onCancel: () -> Unit,
|
||||||
|
|
@ -196,9 +196,9 @@ fun TransferScreen(
|
||||||
toAddress = toAddress,
|
toAddress = toAddress,
|
||||||
amount = amount,
|
amount = amount,
|
||||||
error = error,
|
error = error,
|
||||||
onConfirm = {
|
onConfirm = { includeServerBackup ->
|
||||||
validationError = null
|
validationError = null
|
||||||
onConfirmTransaction()
|
onConfirmTransaction(includeServerBackup) // 传递服务器备份选项
|
||||||
},
|
},
|
||||||
onBack = onCancel
|
onBack = onCancel
|
||||||
)
|
)
|
||||||
|
|
@ -651,12 +651,15 @@ private fun TransferConfirmScreen(
|
||||||
toAddress: String,
|
toAddress: String,
|
||||||
amount: String,
|
amount: String,
|
||||||
error: String?,
|
error: String?,
|
||||||
onConfirm: () -> Unit,
|
onConfirm: (includeServerBackup: Boolean) -> Unit, // 新增参数:是否包含服务器备份参与签名
|
||||||
onBack: () -> Unit
|
onBack: () -> Unit
|
||||||
) {
|
) {
|
||||||
val gasFee = TransactionUtils.weiToKava(preparedTx.gasPrice.multiply(preparedTx.gasLimit))
|
val gasFee = TransactionUtils.weiToKava(preparedTx.gasPrice.multiply(preparedTx.gasLimit))
|
||||||
val gasGwei = TransactionUtils.weiToGwei(preparedTx.gasPrice)
|
val gasGwei = TransactionUtils.weiToGwei(preparedTx.gasPrice)
|
||||||
|
|
||||||
|
// 【新增】服务器备份选项状态(仅 2-of-3 时使用)
|
||||||
|
var includeServerBackup by remember { mutableStateOf(false) }
|
||||||
|
|
||||||
Column(
|
Column(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxSize()
|
.fillMaxSize()
|
||||||
|
|
@ -733,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 display
|
||||||
error?.let {
|
error?.let {
|
||||||
Spacer(modifier = Modifier.height(16.dp))
|
Spacer(modifier = Modifier.height(16.dp))
|
||||||
|
|
@ -774,7 +819,7 @@ private fun TransferConfirmScreen(
|
||||||
Text("返回")
|
Text("返回")
|
||||||
}
|
}
|
||||||
Button(
|
Button(
|
||||||
onClick = onConfirm,
|
onClick = { onConfirm(includeServerBackup) }, // 传递服务器备份选项
|
||||||
modifier = Modifier.weight(1f)
|
modifier = Modifier.weight(1f)
|
||||||
) {
|
) {
|
||||||
Icon(
|
Icon(
|
||||||
|
|
|
||||||
|
|
@ -57,6 +57,7 @@ fun WalletsScreen(
|
||||||
onDeleteShare: (Long) -> Unit,
|
onDeleteShare: (Long) -> Unit,
|
||||||
onRefreshBalance: ((String) -> Unit)? = null,
|
onRefreshBalance: ((String) -> Unit)? = null,
|
||||||
onTransfer: ((shareId: Long) -> Unit)? = null,
|
onTransfer: ((shareId: Long) -> Unit)? = null,
|
||||||
|
onHistory: ((shareId: Long, address: String) -> Unit)? = null,
|
||||||
onExportBackup: ((shareId: Long, password: String) -> Unit)? = null,
|
onExportBackup: ((shareId: Long, password: String) -> Unit)? = null,
|
||||||
onImportBackup: (() -> Unit)? = null,
|
onImportBackup: (() -> Unit)? = null,
|
||||||
onCreateWallet: (() -> Unit)? = null
|
onCreateWallet: (() -> Unit)? = null
|
||||||
|
|
@ -157,6 +158,9 @@ fun WalletsScreen(
|
||||||
onTransfer = {
|
onTransfer = {
|
||||||
onTransfer?.invoke(share.id)
|
onTransfer?.invoke(share.id)
|
||||||
},
|
},
|
||||||
|
onHistory = {
|
||||||
|
onHistory?.invoke(share.id, share.address)
|
||||||
|
},
|
||||||
onDelete = { onDeleteShare(share.id) }
|
onDelete = { onDeleteShare(share.id) }
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
@ -225,6 +229,7 @@ private fun WalletItemCard(
|
||||||
walletBalance: WalletBalance? = null,
|
walletBalance: WalletBalance? = null,
|
||||||
onViewDetails: () -> Unit,
|
onViewDetails: () -> Unit,
|
||||||
onTransfer: () -> Unit,
|
onTransfer: () -> Unit,
|
||||||
|
onHistory: () -> Unit,
|
||||||
onDelete: () -> Unit
|
onDelete: () -> Unit
|
||||||
) {
|
) {
|
||||||
var showDeleteDialog by remember { mutableStateOf(false) }
|
var showDeleteDialog by remember { mutableStateOf(false) }
|
||||||
|
|
@ -435,6 +440,16 @@ private fun WalletItemCard(
|
||||||
Text("转账")
|
Text("转账")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
TextButton(onClick = onHistory) {
|
||||||
|
Icon(
|
||||||
|
Icons.Default.Receipt,
|
||||||
|
contentDescription = null,
|
||||||
|
modifier = Modifier.size(18.dp)
|
||||||
|
)
|
||||||
|
Spacer(modifier = Modifier.width(4.dp))
|
||||||
|
Text("记录")
|
||||||
|
}
|
||||||
|
|
||||||
TextButton(
|
TextButton(
|
||||||
onClick = { showDeleteDialog = true },
|
onClick = { showDeleteDialog = true },
|
||||||
colors = ButtonDefaults.textButtonColors(
|
colors = ButtonDefaults.textButtonColors(
|
||||||
|
|
|
||||||
|
|
@ -2,8 +2,8 @@ package com.durian.tssparty.presentation.viewmodel
|
||||||
|
|
||||||
import androidx.lifecycle.ViewModel
|
import androidx.lifecycle.ViewModel
|
||||||
import androidx.lifecycle.viewModelScope
|
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
|
||||||
|
import com.durian.tssparty.data.repository.TssRepository.JoinKeygenViaGrpcResult
|
||||||
import com.durian.tssparty.domain.model.*
|
import com.durian.tssparty.domain.model.*
|
||||||
import com.durian.tssparty.util.AddressUtils
|
import com.durian.tssparty.util.AddressUtils
|
||||||
import com.durian.tssparty.util.TransactionUtils
|
import com.durian.tssparty.util.TransactionUtils
|
||||||
|
|
@ -45,6 +45,11 @@ class MainViewModel @Inject constructor(
|
||||||
private val _hasEnteredSession = MutableStateFlow(false)
|
private val _hasEnteredSession = MutableStateFlow(false)
|
||||||
val hasEnteredSession: StateFlow<Boolean> = _hasEnteredSession.asStateFlow()
|
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 {
|
init {
|
||||||
// Start initialization on app launch
|
// Start initialization on app launch
|
||||||
checkAllServices()
|
checkAllServices()
|
||||||
|
|
@ -218,6 +223,9 @@ class MainViewModel @Inject constructor(
|
||||||
private val _currentRound = MutableStateFlow(0)
|
private val _currentRound = MutableStateFlow(0)
|
||||||
val currentRound: StateFlow<Int> = _currentRound.asStateFlow()
|
val currentRound: StateFlow<Int> = _currentRound.asStateFlow()
|
||||||
|
|
||||||
|
private val _totalRounds = MutableStateFlow(0)
|
||||||
|
val totalRounds: StateFlow<Int> = _totalRounds.asStateFlow()
|
||||||
|
|
||||||
private val _publicKey = MutableStateFlow<String?>(null)
|
private val _publicKey = MutableStateFlow<String?>(null)
|
||||||
val publicKey: StateFlow<String?> = _publicKey.asStateFlow()
|
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)
|
// Setup keygen timeout callback (matching Electron's 5-minute timeout in checkAndTriggerKeygen)
|
||||||
repository.setKeygenTimeoutCallback { errorMessage ->
|
repository.setKeygenTimeoutCallback { errorMessage ->
|
||||||
android.util.Log.e("MainViewModel", "Keygen timeout: $errorMessage")
|
android.util.Log.e("MainViewModel", "[IDLE_CRASH_DEBUG] Keygen timeout callback invoked: $errorMessage")
|
||||||
|
try {
|
||||||
_uiState.update { it.copy(isLoading = false, error = errorMessage, countdownSeconds = -1L) }
|
_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
|
// Setup countdown tick callback for UI countdown display
|
||||||
repository.setCountdownTickCallback { remainingSeconds ->
|
repository.setCountdownTickCallback { remainingSeconds ->
|
||||||
android.util.Log.d("MainViewModel", "Countdown tick: $remainingSeconds seconds remaining")
|
try {
|
||||||
_uiState.update { it.copy(countdownSeconds = remainingSeconds) }
|
_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
|
// Setup progress callback for real-time round updates from native TSS bridge
|
||||||
repository.setProgressCallback { round, totalRounds ->
|
repository.setProgressCallback { round, totalRoundsFromGo ->
|
||||||
android.util.Log.d("MainViewModel", "Progress update: $round / $totalRounds")
|
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
|
// Update the appropriate round state based on which session type is active
|
||||||
when {
|
when {
|
||||||
// Initiator keygen (CreateWallet)
|
// Initiator keygen (CreateWallet)
|
||||||
|
|
@ -323,21 +342,32 @@ class MainViewModel @Inject constructor(
|
||||||
}
|
}
|
||||||
|
|
||||||
repository.setSessionEventCallback { event ->
|
repository.setSessionEventCallback { event ->
|
||||||
android.util.Log.d("MainViewModel", "=== MainViewModel received session event ===")
|
try {
|
||||||
android.util.Log.d("MainViewModel", " eventType: ${event.eventType}")
|
android.util.Log.d("MainViewModel", "[IDLE_CRASH_DEBUG] === MainViewModel received session event ===")
|
||||||
android.util.Log.d("MainViewModel", " sessionId: ${event.sessionId}")
|
android.util.Log.d("MainViewModel", "[IDLE_CRASH_DEBUG] eventType: ${event.eventType}")
|
||||||
android.util.Log.d("MainViewModel", " _currentSessionId: ${_currentSessionId.value}")
|
android.util.Log.d("MainViewModel", "[IDLE_CRASH_DEBUG] sessionId: ${event.sessionId}")
|
||||||
android.util.Log.d("MainViewModel", " pendingJoinKeygenInfo?.sessionId: ${pendingJoinKeygenInfo?.sessionId}")
|
android.util.Log.d("MainViewModel", "[IDLE_CRASH_DEBUG] _currentSessionId: ${_currentSessionId.value}")
|
||||||
android.util.Log.d("MainViewModel", " pendingJoinSignInfo?.sessionId: ${pendingJoinSignInfo?.sessionId}")
|
android.util.Log.d("MainViewModel", "[IDLE_CRASH_DEBUG] pendingJoinKeygenInfo?.sessionId: ${pendingJoinKeygenInfo?.sessionId}")
|
||||||
android.util.Log.d("MainViewModel", " _signSessionId: ${_signSessionId.value}")
|
android.util.Log.d("MainViewModel", "[IDLE_CRASH_DEBUG] pendingJoinSignInfo?.sessionId: ${pendingJoinSignInfo?.sessionId}")
|
||||||
android.util.Log.d("MainViewModel", " pendingSignInitiatorInfo?.sessionId: ${pendingSignInitiatorInfo?.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) {
|
when (event.eventType) {
|
||||||
"session_started" -> {
|
"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)
|
// Check if this is for keygen initiator (CreateWallet)
|
||||||
val currentSessionId = _currentSessionId.value
|
val currentSessionId = _currentSessionId.value
|
||||||
if (currentSessionId != null && event.sessionId == currentSessionId) {
|
if (currentSessionId != null && event.sessionId == currentSessionId) {
|
||||||
android.util.Log.d("MainViewModel", "Session started event for keygen initiator, triggering keygen")
|
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 {
|
viewModelScope.launch {
|
||||||
startKeygenAsInitiator(
|
startKeygenAsInitiator(
|
||||||
sessionId = currentSessionId,
|
sessionId = currentSessionId,
|
||||||
|
|
@ -352,6 +382,10 @@ class MainViewModel @Inject constructor(
|
||||||
val joinKeygenInfo = pendingJoinKeygenInfo
|
val joinKeygenInfo = pendingJoinKeygenInfo
|
||||||
if (joinKeygenInfo != null && event.sessionId == joinKeygenInfo.sessionId) {
|
if (joinKeygenInfo != null && event.sessionId == joinKeygenInfo.sessionId) {
|
||||||
android.util.Log.d("MainViewModel", "Session started event for keygen joiner, triggering keygen")
|
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()
|
startKeygenAsJoiner()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -359,6 +393,10 @@ class MainViewModel @Inject constructor(
|
||||||
val joinSignInfo = pendingJoinSignInfo
|
val joinSignInfo = pendingJoinSignInfo
|
||||||
if (joinSignInfo != null && event.sessionId == joinSignInfo.sessionId) {
|
if (joinSignInfo != null && event.sessionId == joinSignInfo.sessionId) {
|
||||||
android.util.Log.d("MainViewModel", "Session started event for sign joiner, triggering sign")
|
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()
|
startSignAsJoiner()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -367,6 +405,10 @@ class MainViewModel @Inject constructor(
|
||||||
android.util.Log.d("MainViewModel", "Checking for sign initiator: signSessionId=$signSessionId, eventSessionId=${event.sessionId}")
|
android.util.Log.d("MainViewModel", "Checking for sign initiator: signSessionId=$signSessionId, eventSessionId=${event.sessionId}")
|
||||||
if (signSessionId != null && event.sessionId == signSessionId) {
|
if (signSessionId != null && event.sessionId == signSessionId) {
|
||||||
android.util.Log.d("MainViewModel", "Session started event for sign initiator, triggering sign")
|
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)
|
startSignAsInitiator(event.selectedParties)
|
||||||
} else {
|
} else {
|
||||||
android.util.Log.d("MainViewModel", "NOT triggering sign initiator: signSessionId=$signSessionId, pendingSignInitiatorInfo=${pendingSignInitiatorInfo?.sessionId}")
|
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" -> {
|
"party_joined", "participant_joined" -> {
|
||||||
android.util.Log.d("MainViewModel", "Processing participant_joined event...")
|
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
|
// Update participant count for initiator's CreateWallet screen
|
||||||
val currentSessionId = _currentSessionId.value
|
val currentSessionId = _currentSessionId.value
|
||||||
android.util.Log.d("MainViewModel", " Checking for initiator: currentSessionId=$currentSessionId, eventSessionId=${event.sessionId}")
|
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
|
_currentSessionId.value = null
|
||||||
_sessionParticipants.value = emptyList()
|
_sessionParticipants.value = emptyList()
|
||||||
_currentRound.value = 0
|
_currentRound.value = 0
|
||||||
|
_totalRounds.value = 0
|
||||||
_publicKey.value = null
|
_publicKey.value = null
|
||||||
_createdInviteCode.value = null
|
_createdInviteCode.value = null
|
||||||
_hasEnteredSession.value = false
|
_hasEnteredSession.value = false
|
||||||
|
// Reset synchronous flag for fresh session
|
||||||
|
sessionStartedForSession = null
|
||||||
// Reset session status to WAITING for fresh start
|
// Reset session status to WAITING for fresh start
|
||||||
repository.resetSessionStatus()
|
repository.resetSessionStatus()
|
||||||
}
|
}
|
||||||
|
|
@ -659,7 +719,11 @@ class MainViewModel @Inject constructor(
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
_uiState.update { it.copy(isLoading = true, error = null) }
|
_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(
|
val result = repository.executeKeygenAsJoiner(
|
||||||
sessionId = joinInfo.sessionId,
|
sessionId = joinInfo.sessionId,
|
||||||
|
|
@ -706,6 +770,8 @@ class MainViewModel @Inject constructor(
|
||||||
pendingJoinToken = ""
|
pendingJoinToken = ""
|
||||||
pendingPassword = ""
|
pendingPassword = ""
|
||||||
pendingJoinKeygenInfo = null
|
pendingJoinKeygenInfo = null
|
||||||
|
// Reset synchronous flag for fresh session
|
||||||
|
sessionStartedForSession = null
|
||||||
// Reset session status to WAITING for fresh start
|
// Reset session status to WAITING for fresh start
|
||||||
repository.resetSessionStatus()
|
repository.resetSessionStatus()
|
||||||
}
|
}
|
||||||
|
|
@ -891,6 +957,8 @@ class MainViewModel @Inject constructor(
|
||||||
pendingCoSignInviteCode = ""
|
pendingCoSignInviteCode = ""
|
||||||
pendingCoSignJoinToken = ""
|
pendingCoSignJoinToken = ""
|
||||||
pendingJoinSignInfo = null
|
pendingJoinSignInfo = null
|
||||||
|
// Reset synchronous flag for fresh session
|
||||||
|
sessionStartedForSession = null
|
||||||
// Reset session status to WAITING for fresh start
|
// Reset session status to WAITING for fresh start
|
||||||
repository.resetSessionStatus()
|
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 ==========
|
// ========== Share Export/Import ==========
|
||||||
|
|
||||||
private val _exportResult = MutableStateFlow<ExportImportResult?>(null)
|
private val _exportResult = MutableStateFlow<ExportImportResult?>(null)
|
||||||
|
|
@ -931,19 +1072,30 @@ class MainViewModel @Inject constructor(
|
||||||
* @return The backup JSON string on success
|
* @return The backup JSON string on success
|
||||||
*/
|
*/
|
||||||
fun exportShareBackup(shareId: Long, onSuccess: (String) -> Unit) {
|
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 {
|
viewModelScope.launch {
|
||||||
|
android.util.Log.d("MainViewModel", "[EXPORT] Setting loading state...")
|
||||||
_exportResult.value = ExportImportResult(isLoading = true)
|
_exportResult.value = ExportImportResult(isLoading = true)
|
||||||
|
|
||||||
|
android.util.Log.d("MainViewModel", "[EXPORT] Calling repository.exportShareBackup...")
|
||||||
val result = repository.exportShareBackup(shareId)
|
val result = repository.exportShareBackup(shareId)
|
||||||
|
android.util.Log.d("MainViewModel", "[EXPORT] Repository returned, isSuccess: ${result.isSuccess}")
|
||||||
result.fold(
|
result.fold(
|
||||||
onSuccess = { json ->
|
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)
|
_exportResult.value = ExportImportResult(isSuccess = true)
|
||||||
|
android.util.Log.d("MainViewModel", "[EXPORT] Calling onSuccess callback with json...")
|
||||||
onSuccess(json)
|
onSuccess(json)
|
||||||
|
android.util.Log.d("MainViewModel", "[EXPORT] onSuccess callback completed")
|
||||||
},
|
},
|
||||||
onFailure = { e ->
|
onFailure = { e ->
|
||||||
|
android.util.Log.e("MainViewModel", "[EXPORT] Export failed: ${e.message}", e)
|
||||||
_exportResult.value = ExportImportResult(error = e.message ?: "导出失败")
|
_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
|
* @param backupJson The backup JSON string to import
|
||||||
*/
|
*/
|
||||||
fun importShareBackup(backupJson: String) {
|
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 {
|
viewModelScope.launch {
|
||||||
|
android.util.Log.d("MainViewModel", "[IMPORT] Setting loading state...")
|
||||||
_importResult.value = ExportImportResult(isLoading = true)
|
_importResult.value = ExportImportResult(isLoading = true)
|
||||||
|
|
||||||
|
android.util.Log.d("MainViewModel", "[IMPORT] Calling repository.importShareBackup...")
|
||||||
val result = repository.importShareBackup(backupJson)
|
val result = repository.importShareBackup(backupJson)
|
||||||
|
android.util.Log.d("MainViewModel", "[IMPORT] Repository returned, isSuccess: ${result.isSuccess}")
|
||||||
result.fold(
|
result.fold(
|
||||||
onSuccess = { share ->
|
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(
|
_importResult.value = ExportImportResult(
|
||||||
isSuccess = true,
|
isSuccess = true,
|
||||||
message = "已成功导入钱包 (${share.address.take(10)}...)"
|
message = "已成功导入钱包 (${share.address.take(10)}...)"
|
||||||
)
|
)
|
||||||
// Update wallet count
|
// Update wallet count
|
||||||
|
android.util.Log.d("MainViewModel", "[IMPORT] Updating wallet count...")
|
||||||
_appState.update { state ->
|
_appState.update { state ->
|
||||||
state.copy(walletCount = state.walletCount + 1)
|
state.copy(walletCount = state.walletCount + 1)
|
||||||
}
|
}
|
||||||
// Fetch balance for the imported wallet
|
// Fetch balance for the imported wallet
|
||||||
|
android.util.Log.d("MainViewModel", "[IMPORT] Fetching balance...")
|
||||||
fetchBalanceForShare(share)
|
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 ->
|
onFailure = { e ->
|
||||||
|
android.util.Log.e("MainViewModel", "[IMPORT] Import failed: ${e.message}", e)
|
||||||
_importResult.value = ExportImportResult(error = e.message ?: "导入失败")
|
_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)
|
* Start sign as initiator (called when session_started event is received)
|
||||||
* Matches Electron's handleCoSignStart for initiator
|
* 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>) {
|
private fun startSignAsInitiator(selectedParties: List<String>) {
|
||||||
val info = pendingSignInitiatorInfo
|
val info = pendingSignInitiatorInfo
|
||||||
|
|
@ -1293,6 +1550,13 @@ class MainViewModel @Inject constructor(
|
||||||
return
|
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")
|
android.util.Log.d("MainViewModel", "Starting sign as initiator: sessionId=${info.sessionId}, selectedParties=$selectedParties")
|
||||||
startSigningProcess(info.sessionId, info.shareId, info.password)
|
startSigningProcess(info.sessionId, info.shareId, info.password)
|
||||||
}
|
}
|
||||||
|
|
@ -1364,7 +1628,30 @@ class MainViewModel @Inject constructor(
|
||||||
onSuccess = { hash ->
|
onSuccess = { hash ->
|
||||||
android.util.Log.d("MainViewModel", "[BROADCAST] SUCCESS! txHash=$hash")
|
android.util.Log.d("MainViewModel", "[BROADCAST] SUCCESS! txHash=$hash")
|
||||||
_txHash.value = hash
|
_txHash.value = hash
|
||||||
|
|
||||||
|
// 保存交易记录到本地数据库
|
||||||
|
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 = "交易已广播!") }
|
_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 ->
|
onFailure = { e ->
|
||||||
android.util.Log.e("MainViewModel", "[BROADCAST] FAILED: ${e.message}", 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
|
* Reset transfer state
|
||||||
*/
|
*/
|
||||||
|
|
@ -1387,6 +1705,8 @@ class MainViewModel @Inject constructor(
|
||||||
_signature.value = null
|
_signature.value = null
|
||||||
_txHash.value = null
|
_txHash.value = null
|
||||||
pendingSignInitiatorInfo = null
|
pendingSignInitiatorInfo = null
|
||||||
|
// Reset synchronous flag for fresh session
|
||||||
|
sessionStartedForSession = null
|
||||||
// Reset session status to WAITING for fresh start
|
// Reset session status to WAITING for fresh start
|
||||||
repository.resetSessionStatus()
|
repository.resetSessionStatus()
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -23,13 +23,50 @@ import java.util.concurrent.TimeUnit
|
||||||
*/
|
*/
|
||||||
object TransactionUtils {
|
object TransactionUtils {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* HTTP client for blockchain RPC calls
|
||||||
|
*
|
||||||
|
* 【架构安全修复 - 配置连接池防止资源泄漏】
|
||||||
|
*
|
||||||
|
* 配置连接池参数限制资源占用:
|
||||||
|
* - maxIdleConnections: 5 (最多保留 5 个空闲连接)
|
||||||
|
* - keepAliveDuration: 5 分钟 (空闲连接保活时间)
|
||||||
|
*
|
||||||
|
* 注意: TransactionUtils 是 object 单例,生命周期与应用一致
|
||||||
|
* 如果应用需要完全清理资源,可调用 cleanup() 方法
|
||||||
|
*/
|
||||||
private val client = OkHttpClient.Builder()
|
private val client = OkHttpClient.Builder()
|
||||||
.connectTimeout(30, TimeUnit.SECONDS)
|
.connectTimeout(30, TimeUnit.SECONDS)
|
||||||
.readTimeout(30, TimeUnit.SECONDS)
|
.readTimeout(30, TimeUnit.SECONDS)
|
||||||
|
.connectionPool(okhttp3.ConnectionPool(
|
||||||
|
maxIdleConnections = 5,
|
||||||
|
keepAliveDuration = 5,
|
||||||
|
timeUnit = TimeUnit.MINUTES
|
||||||
|
))
|
||||||
.build()
|
.build()
|
||||||
|
|
||||||
private val jsonMediaType = "application/json; charset=utf-8".toMediaType()
|
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
|
// Chain IDs
|
||||||
const val KAVA_TESTNET_CHAIN_ID = 2221
|
const val KAVA_TESTNET_CHAIN_ID = 2221
|
||||||
const val KAVA_MAINNET_CHAIN_ID = 2222
|
const val KAVA_MAINNET_CHAIN_ID = 2222
|
||||||
|
|
|
||||||
|
|
@ -75,6 +75,20 @@ echo [INFO] Using SDK from local.properties
|
||||||
type local.properties
|
type local.properties
|
||||||
echo.
|
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
|
:: Check and build tsslib.aar if needed
|
||||||
if not exist "app\libs\tsslib.aar" (
|
if not exist "app\libs\tsslib.aar" (
|
||||||
echo [INFO] tsslib.aar not found, attempting to build TSS library...
|
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"=="debug" set BUILD_TYPE=debug
|
||||||
if "%1"=="release" set BUILD_TYPE=release
|
if "%1"=="release" set BUILD_TYPE=release
|
||||||
if "%1"=="clean" set BUILD_TYPE=clean
|
if "%1"=="clean" set BUILD_TYPE=clean
|
||||||
|
if "%1"=="rebuild" set BUILD_TYPE=rebuild
|
||||||
if "%1"=="help" goto :show_help
|
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
|
:: Show build type
|
||||||
echo Build type: %BUILD_TYPE%
|
echo Build type: %BUILD_TYPE%
|
||||||
echo.
|
echo.
|
||||||
|
|
@ -275,14 +295,16 @@ echo Options:
|
||||||
echo debug - Build debug APK only
|
echo debug - Build debug APK only
|
||||||
echo release - Build release APK only
|
echo release - Build release APK only
|
||||||
echo all - Build both debug and release APKs (default)
|
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 help - Show this help message
|
||||||
echo.
|
echo.
|
||||||
echo Examples:
|
echo Examples:
|
||||||
echo build-apk.bat - Build both APKs
|
echo build-apk.bat - Build both APKs
|
||||||
echo build-apk.bat debug - Build debug APK only
|
echo build-apk.bat debug - Build debug APK only
|
||||||
echo build-apk.bat release - Build release 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.
|
echo.
|
||||||
|
|
||||||
:end
|
: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)
|
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() {
|
go func() {
|
||||||
_, err := session.localParty.Update(parsedMsg)
|
_, err := session.localParty.Update(parsedMsg)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|
|
||||||
|
|
@ -149,6 +149,8 @@ func (c *MessageRouterClient) PublishSessionCreated(
|
||||||
}
|
}
|
||||||
|
|
||||||
// PublishSessionStarted publishes a session_started event when all parties have joined
|
// 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(
|
func (c *MessageRouterClient) PublishSessionStarted(
|
||||||
ctx context.Context,
|
ctx context.Context,
|
||||||
sessionID string,
|
sessionID string,
|
||||||
|
|
@ -157,7 +159,17 @@ func (c *MessageRouterClient) PublishSessionStarted(
|
||||||
selectedParties []string,
|
selectedParties []string,
|
||||||
joinTokens map[string]string,
|
joinTokens map[string]string,
|
||||||
startedAt int64,
|
startedAt int64,
|
||||||
|
participants []use_cases.SessionParticipantInfo,
|
||||||
) error {
|
) 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{
|
event := &router.SessionEvent{
|
||||||
EventId: uuid.New().String(),
|
EventId: uuid.New().String(),
|
||||||
EventType: "session_started",
|
EventType: "session_started",
|
||||||
|
|
@ -167,8 +179,13 @@ func (c *MessageRouterClient) PublishSessionStarted(
|
||||||
SelectedParties: selectedParties,
|
SelectedParties: selectedParties,
|
||||||
JoinTokens: joinTokens,
|
JoinTokens: joinTokens,
|
||||||
CreatedAt: startedAt,
|
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)
|
return c.PublishSessionEvent(ctx, event)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -21,8 +21,16 @@ import (
|
||||||
// Maximum retries for optimistic lock conflicts during join session
|
// Maximum retries for optimistic lock conflicts during join session
|
||||||
const joinSessionMaxRetries = 3
|
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
|
// JoinSessionMessageRouterClient defines the interface for publishing session events via gRPC
|
||||||
type JoinSessionMessageRouterClient interface {
|
type JoinSessionMessageRouterClient interface {
|
||||||
|
// PublishSessionStarted publishes session_started event with complete participants list
|
||||||
|
// CRITICAL: participants contains all parties with their indices for TSS protocol
|
||||||
PublishSessionStarted(
|
PublishSessionStarted(
|
||||||
ctx context.Context,
|
ctx context.Context,
|
||||||
sessionID string,
|
sessionID string,
|
||||||
|
|
@ -31,6 +39,7 @@ type JoinSessionMessageRouterClient interface {
|
||||||
selectedParties []string,
|
selectedParties []string,
|
||||||
joinTokens map[string]string,
|
joinTokens map[string]string,
|
||||||
startedAt int64,
|
startedAt int64,
|
||||||
|
participants []SessionParticipantInfo,
|
||||||
) error
|
) error
|
||||||
|
|
||||||
// PublishParticipantJoined broadcasts a participant_joined event to all parties in the session
|
// 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)
|
// Build join tokens map (empty for session_started, parties already have tokens)
|
||||||
joinTokens := make(map[string]string)
|
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(
|
if err := uc.messageRouterClient.PublishSessionStarted(
|
||||||
ctx,
|
ctx,
|
||||||
session.ID.String(),
|
session.ID.String(),
|
||||||
|
|
@ -256,6 +275,7 @@ func (uc *JoinSessionUseCase) executeWithRetry(
|
||||||
selectedParties,
|
selectedParties,
|
||||||
joinTokens,
|
joinTokens,
|
||||||
startedAt,
|
startedAt,
|
||||||
|
participants,
|
||||||
); err != nil {
|
); err != nil {
|
||||||
logger.Error("failed to publish session started event to message router",
|
logger.Error("failed to publish session started event to message router",
|
||||||
zap.String("session_id", session.ID.String()),
|
zap.String("session_id", session.ID.String()),
|
||||||
|
|
@ -263,7 +283,8 @@ func (uc *JoinSessionUseCase) executeWithRetry(
|
||||||
} else {
|
} else {
|
||||||
logger.Info("published session started event to message router",
|
logger.Info("published session started event to message router",
|
||||||
zap.String("session_id", session.ID.String()),
|
zap.String("session_id", session.ID.String()),
|
||||||
zap.Int("party_count", len(selectedParties)))
|
zap.Int("party_count", len(selectedParties)),
|
||||||
|
zap.Int("participant_count", len(participants)))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -21,6 +21,8 @@
|
||||||
"@prisma/client": "^5.7.0",
|
"@prisma/client": "^5.7.0",
|
||||||
"@types/multer": "^2.0.0",
|
"@types/multer": "^2.0.0",
|
||||||
"adbkit-apkreader": "^3.2.0",
|
"adbkit-apkreader": "^3.2.0",
|
||||||
|
"archiver": "^6.0.1",
|
||||||
|
"axios": "^1.6.2",
|
||||||
"bplist-parser": "^0.3.2",
|
"bplist-parser": "^0.3.2",
|
||||||
"class-transformer": "^0.5.1",
|
"class-transformer": "^0.5.1",
|
||||||
"class-validator": "^0.14.0",
|
"class-validator": "^0.14.0",
|
||||||
|
|
@ -36,6 +38,7 @@
|
||||||
"@nestjs/cli": "^10.0.0",
|
"@nestjs/cli": "^10.0.0",
|
||||||
"@nestjs/schematics": "^10.0.0",
|
"@nestjs/schematics": "^10.0.0",
|
||||||
"@nestjs/testing": "^10.0.0",
|
"@nestjs/testing": "^10.0.0",
|
||||||
|
"@types/archiver": "^6.0.2",
|
||||||
"@types/express": "^4.17.17",
|
"@types/express": "^4.17.17",
|
||||||
"@types/jest": "^29.5.2",
|
"@types/jest": "^29.5.2",
|
||||||
"@types/node": "^20.3.1",
|
"@types/node": "^20.3.1",
|
||||||
|
|
@ -2284,6 +2287,16 @@
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/@types/archiver": {
|
||||||
|
"version": "6.0.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/archiver/-/archiver-6.0.4.tgz",
|
||||||
|
"integrity": "sha512-ULdQpARQ3sz9WH4nb98mJDYA0ft2A8C4f4fovvUcFwINa1cgGjY36JCAYuP5YypRq4mco1lJp1/7jEMS2oR0Hg==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@types/readdir-glob": "*"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@types/babel__core": {
|
"node_modules/@types/babel__core": {
|
||||||
"version": "7.20.5",
|
"version": "7.20.5",
|
||||||
"resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz",
|
"resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz",
|
||||||
|
|
@ -2562,6 +2575,16 @@
|
||||||
"integrity": "sha512-hKormJbkJqzQGhziax5PItDUTMAM9uE2XXQmM37dyd4hVM+5aVl7oVxMVUiVQn2oCQFN/LKCZdvSM0pFRqbSmQ==",
|
"integrity": "sha512-hKormJbkJqzQGhziax5PItDUTMAM9uE2XXQmM37dyd4hVM+5aVl7oVxMVUiVQn2oCQFN/LKCZdvSM0pFRqbSmQ==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/@types/readdir-glob": {
|
||||||
|
"version": "1.1.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/readdir-glob/-/readdir-glob-1.1.5.tgz",
|
||||||
|
"integrity": "sha512-raiuEPUYqXu+nvtY2Pe8s8FEmZ3x5yAH4VkLdihcPdalvsHltomrRC9BzuStrJ9yk06470hS0Crw0f1pXqD+Hg==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@types/node": "*"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@types/semver": {
|
"node_modules/@types/semver": {
|
||||||
"version": "7.7.1",
|
"version": "7.7.1",
|
||||||
"resolved": "https://registry.npmjs.org/@types/semver/-/semver-7.7.1.tgz",
|
"resolved": "https://registry.npmjs.org/@types/semver/-/semver-7.7.1.tgz",
|
||||||
|
|
@ -3284,6 +3307,73 @@
|
||||||
"integrity": "sha512-klpgFSWLW1ZEs8svjfb7g4qWY0YS5imI82dTg+QahUvJ8YqAY0P10Uk8tTyh9ZGuYEZEMaeJYCF5BFuX552hsw==",
|
"integrity": "sha512-klpgFSWLW1ZEs8svjfb7g4qWY0YS5imI82dTg+QahUvJ8YqAY0P10Uk8tTyh9ZGuYEZEMaeJYCF5BFuX552hsw==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/archiver": {
|
||||||
|
"version": "6.0.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/archiver/-/archiver-6.0.2.tgz",
|
||||||
|
"integrity": "sha512-UQ/2nW7NMl1G+1UnrLypQw1VdT9XZg/ECcKPq7l+STzStrSivFIXIp34D8M5zeNGW5NoOupdYCHv6VySCPNNlw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"archiver-utils": "^4.0.1",
|
||||||
|
"async": "^3.2.4",
|
||||||
|
"buffer-crc32": "^0.2.1",
|
||||||
|
"readable-stream": "^3.6.0",
|
||||||
|
"readdir-glob": "^1.1.2",
|
||||||
|
"tar-stream": "^3.0.0",
|
||||||
|
"zip-stream": "^5.0.1"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 12.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/archiver-utils": {
|
||||||
|
"version": "4.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/archiver-utils/-/archiver-utils-4.0.1.tgz",
|
||||||
|
"integrity": "sha512-Q4Q99idbvzmgCTEAAhi32BkOyq8iVI5EwdO0PmBDSGIzzjYNdcFn7Q7k3OzbLy4kLUPXfJtG6fO2RjftXbobBg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"glob": "^8.0.0",
|
||||||
|
"graceful-fs": "^4.2.0",
|
||||||
|
"lazystream": "^1.0.0",
|
||||||
|
"lodash": "^4.17.15",
|
||||||
|
"normalize-path": "^3.0.0",
|
||||||
|
"readable-stream": "^3.6.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 12.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/archiver-utils/node_modules/glob": {
|
||||||
|
"version": "8.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/glob/-/glob-8.1.0.tgz",
|
||||||
|
"integrity": "sha512-r8hpEjiQEYlF2QU0df3dS+nxxSIreXQS1qRhMJM0Q5NDdR386C7jb7Hwwod8Fgiuex+k0GFjgft18yvxm5XoCQ==",
|
||||||
|
"deprecated": "Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me",
|
||||||
|
"license": "ISC",
|
||||||
|
"dependencies": {
|
||||||
|
"fs.realpath": "^1.0.0",
|
||||||
|
"inflight": "^1.0.4",
|
||||||
|
"inherits": "2",
|
||||||
|
"minimatch": "^5.0.1",
|
||||||
|
"once": "^1.3.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/isaacs"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/archiver-utils/node_modules/minimatch": {
|
||||||
|
"version": "5.1.6",
|
||||||
|
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-5.1.6.tgz",
|
||||||
|
"integrity": "sha512-lKwV/1brpG6mBUFHtb7NUmtABCb2WZZmm2wNiOA5hAb8VdCS4B3dtMWyvcoViccwAW/COERjXLt0zP1zXUN26g==",
|
||||||
|
"license": "ISC",
|
||||||
|
"dependencies": {
|
||||||
|
"brace-expansion": "^2.0.1"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=10"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/arg": {
|
"node_modules/arg": {
|
||||||
"version": "4.1.3",
|
"version": "4.1.3",
|
||||||
"resolved": "https://registry.npmjs.org/arg/-/arg-4.1.3.tgz",
|
"resolved": "https://registry.npmjs.org/arg/-/arg-4.1.3.tgz",
|
||||||
|
|
@ -3327,13 +3417,43 @@
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/async": {
|
||||||
|
"version": "3.2.6",
|
||||||
|
"resolved": "https://registry.npmjs.org/async/-/async-3.2.6.tgz",
|
||||||
|
"integrity": "sha512-htCUDlxyyCLMgaM3xXg0C0LW2xqfuQ6p05pCEIsXuyQ+a1koYKTuBMzRNwmybfLgvJDMd0r1LTn4+E0Ti6C2AA==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/asynckit": {
|
"node_modules/asynckit": {
|
||||||
"version": "0.4.0",
|
"version": "0.4.0",
|
||||||
"resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz",
|
"resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz",
|
||||||
"integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==",
|
"integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/axios": {
|
||||||
|
"version": "1.13.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/axios/-/axios-1.13.4.tgz",
|
||||||
|
"integrity": "sha512-1wVkUaAO6WyaYtCkcYCOx12ZgpGf9Zif+qXa4n+oYzK558YryKqiL6UWwd5DqiH3VRW0GYhTZQ/vlgJrCoNQlg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"follow-redirects": "^1.15.6",
|
||||||
|
"form-data": "^4.0.4",
|
||||||
|
"proxy-from-env": "^1.1.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/b4a": {
|
||||||
|
"version": "1.7.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/b4a/-/b4a-1.7.3.tgz",
|
||||||
|
"integrity": "sha512-5Q2mfq2WfGuFp3uS//0s6baOJLMoVduPYVeNmDYxu5OUA1/cBfvr2RIS7vi62LdNj/urk1hfmj867I3qt6uZ7Q==",
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"peerDependencies": {
|
||||||
|
"react-native-b4a": "*"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"react-native-b4a": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/babel-jest": {
|
"node_modules/babel-jest": {
|
||||||
"version": "29.7.0",
|
"version": "29.7.0",
|
||||||
"resolved": "https://registry.npmjs.org/babel-jest/-/babel-jest-29.7.0.tgz",
|
"resolved": "https://registry.npmjs.org/babel-jest/-/babel-jest-29.7.0.tgz",
|
||||||
|
|
@ -3464,9 +3584,22 @@
|
||||||
"version": "1.0.2",
|
"version": "1.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz",
|
||||||
"integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==",
|
"integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/bare-events": {
|
||||||
|
"version": "2.8.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/bare-events/-/bare-events-2.8.2.tgz",
|
||||||
|
"integrity": "sha512-riJjyv1/mHLIPX4RwiK+oW9/4c3TEUeORHKefKAKnZ5kyslbN+HXowtbaVEqt4IMUB7OXlfixcs6gsFeo/jhiQ==",
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"peerDependencies": {
|
||||||
|
"bare-abort-controller": "*"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"bare-abort-controller": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/base64-js": {
|
"node_modules/base64-js": {
|
||||||
"version": "1.5.1",
|
"version": "1.5.1",
|
||||||
"resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz",
|
"resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz",
|
||||||
|
|
@ -3593,7 +3726,6 @@
|
||||||
"version": "2.0.2",
|
"version": "2.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz",
|
||||||
"integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==",
|
"integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"balanced-match": "^1.0.0"
|
"balanced-match": "^1.0.0"
|
||||||
|
|
@ -4079,7 +4211,6 @@
|
||||||
"version": "1.0.8",
|
"version": "1.0.8",
|
||||||
"resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz",
|
"resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz",
|
||||||
"integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==",
|
"integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"delayed-stream": "~1.0.0"
|
"delayed-stream": "~1.0.0"
|
||||||
|
|
@ -4125,6 +4256,21 @@
|
||||||
"url": "https://github.com/sponsors/sindresorhus"
|
"url": "https://github.com/sponsors/sindresorhus"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/compress-commons": {
|
||||||
|
"version": "5.0.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/compress-commons/-/compress-commons-5.0.3.tgz",
|
||||||
|
"integrity": "sha512-/UIcLWvwAQyVibgpQDPtfNM3SvqN7G9elAPAV7GM0L53EbNWwWiCsWtK8Fwed/APEbptPHXs5PuW+y8Bq8lFTA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"crc-32": "^1.2.0",
|
||||||
|
"crc32-stream": "^5.0.0",
|
||||||
|
"normalize-path": "^3.0.0",
|
||||||
|
"readable-stream": "^3.6.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 12.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/concat-map": {
|
"node_modules/concat-map": {
|
||||||
"version": "0.0.1",
|
"version": "0.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz",
|
||||||
|
|
@ -4249,6 +4395,31 @@
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/crc-32": {
|
||||||
|
"version": "1.2.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/crc-32/-/crc-32-1.2.2.tgz",
|
||||||
|
"integrity": "sha512-ROmzCKrTnOwybPcJApAA6WBWij23HVfGVNKqqrZpuyZOHqK2CwHSvpGuyt/UNNvaIjEd8X5IFGp4Mh+Ie1IHJQ==",
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"bin": {
|
||||||
|
"crc32": "bin/crc32.njs"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=0.8"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/crc32-stream": {
|
||||||
|
"version": "5.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/crc32-stream/-/crc32-stream-5.0.1.tgz",
|
||||||
|
"integrity": "sha512-lO1dFui+CEUh/ztYIpgpKItKW9Bb4NWakCRJrnqAbFIYD+OZAwb2VfD5T5eXMw2FNcsDHkQcNl/Wh3iVXYwU6g==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"crc-32": "^1.2.0",
|
||||||
|
"readable-stream": "^3.4.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 12.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/create-jest": {
|
"node_modules/create-jest": {
|
||||||
"version": "29.7.0",
|
"version": "29.7.0",
|
||||||
"resolved": "https://registry.npmjs.org/create-jest/-/create-jest-29.7.0.tgz",
|
"resolved": "https://registry.npmjs.org/create-jest/-/create-jest-29.7.0.tgz",
|
||||||
|
|
@ -4387,7 +4558,6 @@
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz",
|
||||||
"integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==",
|
"integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=0.4.0"
|
"node": ">=0.4.0"
|
||||||
|
|
@ -4685,7 +4855,6 @@
|
||||||
"version": "2.1.0",
|
"version": "2.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz",
|
||||||
"integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==",
|
"integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"es-errors": "^1.3.0",
|
"es-errors": "^1.3.0",
|
||||||
|
|
@ -5017,6 +5186,15 @@
|
||||||
"node": ">=0.8.x"
|
"node": ">=0.8.x"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/events-universal": {
|
||||||
|
"version": "1.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/events-universal/-/events-universal-1.0.1.tgz",
|
||||||
|
"integrity": "sha512-LUd5euvbMLpwOF8m6ivPCbhQeSiYVNb8Vs0fQ8QjXo0JTkEHpz8pxdQf0gStltaPpw0Cca8b39KxvK9cfKRiAw==",
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"dependencies": {
|
||||||
|
"bare-events": "^2.7.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/execa": {
|
"node_modules/execa": {
|
||||||
"version": "5.1.1",
|
"version": "5.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/execa/-/execa-5.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/execa/-/execa-5.1.1.tgz",
|
||||||
|
|
@ -5171,6 +5349,12 @@
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "Apache-2.0"
|
"license": "Apache-2.0"
|
||||||
},
|
},
|
||||||
|
"node_modules/fast-fifo": {
|
||||||
|
"version": "1.3.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/fast-fifo/-/fast-fifo-1.3.2.tgz",
|
||||||
|
"integrity": "sha512-/d9sfos4yxzpwkDkuN7k2SqFKtYNmCTzgfEpz82x34IM9/zc8KGxQoXg1liNC/izpRM/MBdt44Nmx41ZWqk+FQ==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/fast-glob": {
|
"node_modules/fast-glob": {
|
||||||
"version": "3.3.3",
|
"version": "3.3.3",
|
||||||
"resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.3.tgz",
|
"resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.3.tgz",
|
||||||
|
|
@ -5385,6 +5569,26 @@
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "ISC"
|
"license": "ISC"
|
||||||
},
|
},
|
||||||
|
"node_modules/follow-redirects": {
|
||||||
|
"version": "1.15.11",
|
||||||
|
"resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.11.tgz",
|
||||||
|
"integrity": "sha512-deG2P0JfjrTxl50XGCDyfI97ZGVCxIpfKYmfyrQ54n5FO/0gfIES8C/Psl6kWVDolizcaaxZJnTS0QSMxvnsBQ==",
|
||||||
|
"funding": [
|
||||||
|
{
|
||||||
|
"type": "individual",
|
||||||
|
"url": "https://github.com/sponsors/RubenVerborgh"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=4.0"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"debug": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/foreground-child": {
|
"node_modules/foreground-child": {
|
||||||
"version": "3.3.1",
|
"version": "3.3.1",
|
||||||
"resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-3.3.1.tgz",
|
"resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-3.3.1.tgz",
|
||||||
|
|
@ -5459,7 +5663,6 @@
|
||||||
"version": "4.0.5",
|
"version": "4.0.5",
|
||||||
"resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.5.tgz",
|
"resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.5.tgz",
|
||||||
"integrity": "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==",
|
"integrity": "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"asynckit": "^0.4.0",
|
"asynckit": "^0.4.0",
|
||||||
|
|
@ -5532,7 +5735,6 @@
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz",
|
||||||
"integrity": "sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==",
|
"integrity": "sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==",
|
||||||
"dev": true,
|
|
||||||
"license": "ISC"
|
"license": "ISC"
|
||||||
},
|
},
|
||||||
"node_modules/fsevents": {
|
"node_modules/fsevents": {
|
||||||
|
|
@ -5838,7 +6040,6 @@
|
||||||
"version": "1.0.2",
|
"version": "1.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz",
|
||||||
"integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==",
|
"integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"has-symbols": "^1.0.3"
|
"has-symbols": "^1.0.3"
|
||||||
|
|
@ -5989,7 +6190,6 @@
|
||||||
"resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz",
|
"resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz",
|
||||||
"integrity": "sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==",
|
"integrity": "sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==",
|
||||||
"deprecated": "This module is not supported, and leaks memory. Do not use it. Check out lru-cache if you want a good and tested way to coalesce async requests by a key value, which is much more comprehensive and powerful.",
|
"deprecated": "This module is not supported, and leaks memory. Do not use it. Check out lru-cache if you want a good and tested way to coalesce async requests by a key value, which is much more comprehensive and powerful.",
|
||||||
"dev": true,
|
|
||||||
"license": "ISC",
|
"license": "ISC",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"once": "^1.3.0",
|
"once": "^1.3.0",
|
||||||
|
|
@ -7190,6 +7390,48 @@
|
||||||
"node": ">=6"
|
"node": ">=6"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/lazystream": {
|
||||||
|
"version": "1.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/lazystream/-/lazystream-1.0.1.tgz",
|
||||||
|
"integrity": "sha512-b94GiNHQNy6JNTrt5w6zNyffMrNkXZb3KTkCZJb2V1xaEGCk093vkZ2jk3tpaeP33/OiXC+WvK9AxUebnf5nbw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"readable-stream": "^2.0.5"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.6.3"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/lazystream/node_modules/readable-stream": {
|
||||||
|
"version": "2.3.8",
|
||||||
|
"resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.8.tgz",
|
||||||
|
"integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"core-util-is": "~1.0.0",
|
||||||
|
"inherits": "~2.0.3",
|
||||||
|
"isarray": "~1.0.0",
|
||||||
|
"process-nextick-args": "~2.0.0",
|
||||||
|
"safe-buffer": "~5.1.1",
|
||||||
|
"string_decoder": "~1.1.1",
|
||||||
|
"util-deprecate": "~1.0.1"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/lazystream/node_modules/safe-buffer": {
|
||||||
|
"version": "5.1.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz",
|
||||||
|
"integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/lazystream/node_modules/string_decoder": {
|
||||||
|
"version": "1.1.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz",
|
||||||
|
"integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"safe-buffer": "~5.1.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/leven": {
|
"node_modules/leven": {
|
||||||
"version": "3.1.0",
|
"version": "3.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/leven/-/leven-3.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/leven/-/leven-3.1.0.tgz",
|
||||||
|
|
@ -7704,7 +7946,6 @@
|
||||||
"version": "3.0.0",
|
"version": "3.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz",
|
||||||
"integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==",
|
"integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=0.10.0"
|
"node": ">=0.10.0"
|
||||||
|
|
@ -7760,7 +8001,6 @@
|
||||||
"version": "1.4.0",
|
"version": "1.4.0",
|
||||||
"resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz",
|
"resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz",
|
||||||
"integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==",
|
"integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==",
|
||||||
"dev": true,
|
|
||||||
"license": "ISC",
|
"license": "ISC",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"wrappy": "1"
|
"wrappy": "1"
|
||||||
|
|
@ -8280,6 +8520,12 @@
|
||||||
"node": ">= 0.10"
|
"node": ">= 0.10"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/proxy-from-env": {
|
||||||
|
"version": "1.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz",
|
||||||
|
"integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/punycode": {
|
"node_modules/punycode": {
|
||||||
"version": "2.3.1",
|
"version": "2.3.1",
|
||||||
"resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz",
|
"resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz",
|
||||||
|
|
@ -8398,6 +8644,27 @@
|
||||||
"node": ">= 6"
|
"node": ">= 6"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/readdir-glob": {
|
||||||
|
"version": "1.1.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/readdir-glob/-/readdir-glob-1.1.3.tgz",
|
||||||
|
"integrity": "sha512-v05I2k7xN8zXvPD9N+z/uhXPaj0sUFCe2rcWZIpBsqxfP7xXFQ0tipAd/wjj1YxWyWtUS5IDJpOG82JKt2EAVA==",
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"dependencies": {
|
||||||
|
"minimatch": "^5.1.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/readdir-glob/node_modules/minimatch": {
|
||||||
|
"version": "5.1.6",
|
||||||
|
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-5.1.6.tgz",
|
||||||
|
"integrity": "sha512-lKwV/1brpG6mBUFHtb7NUmtABCb2WZZmm2wNiOA5hAb8VdCS4B3dtMWyvcoViccwAW/COERjXLt0zP1zXUN26g==",
|
||||||
|
"license": "ISC",
|
||||||
|
"dependencies": {
|
||||||
|
"brace-expansion": "^2.0.1"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=10"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/readdirp": {
|
"node_modules/readdirp": {
|
||||||
"version": "3.6.0",
|
"version": "3.6.0",
|
||||||
"resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz",
|
"resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz",
|
||||||
|
|
@ -9083,6 +9350,17 @@
|
||||||
"node": ">=10.0.0"
|
"node": ">=10.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/streamx": {
|
||||||
|
"version": "2.23.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/streamx/-/streamx-2.23.0.tgz",
|
||||||
|
"integrity": "sha512-kn+e44esVfn2Fa/O0CPFcex27fjIL6MkVae0Mm6q+E6f0hWv578YCERbv+4m02cjxvDsPKLnmxral/rR6lBMAg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"events-universal": "^1.0.0",
|
||||||
|
"fast-fifo": "^1.3.2",
|
||||||
|
"text-decoder": "^1.1.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/string_decoder": {
|
"node_modules/string_decoder": {
|
||||||
"version": "1.3.0",
|
"version": "1.3.0",
|
||||||
"resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz",
|
"resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz",
|
||||||
|
|
@ -9335,6 +9613,17 @@
|
||||||
"url": "https://opencollective.com/webpack"
|
"url": "https://opencollective.com/webpack"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/tar-stream": {
|
||||||
|
"version": "3.1.7",
|
||||||
|
"resolved": "https://registry.npmjs.org/tar-stream/-/tar-stream-3.1.7.tgz",
|
||||||
|
"integrity": "sha512-qJj60CXt7IU1Ffyc3NJMjh6EkuCFej46zUqJ4J7pqYlThyd9bO0XBTmcOIhSzZJVWfsLks0+nle/j538YAW9RQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"b4a": "^1.6.4",
|
||||||
|
"fast-fifo": "^1.2.0",
|
||||||
|
"streamx": "^2.15.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/terser": {
|
"node_modules/terser": {
|
||||||
"version": "5.44.1",
|
"version": "5.44.1",
|
||||||
"resolved": "https://registry.npmjs.org/terser/-/terser-5.44.1.tgz",
|
"resolved": "https://registry.npmjs.org/terser/-/terser-5.44.1.tgz",
|
||||||
|
|
@ -9508,6 +9797,15 @@
|
||||||
"node": "*"
|
"node": "*"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/text-decoder": {
|
||||||
|
"version": "1.2.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/text-decoder/-/text-decoder-1.2.3.tgz",
|
||||||
|
"integrity": "sha512-3/o9z3X0X0fTupwsYvR03pJ/DjWuqqrfwBgTQzdWDiQSm9KitAyz/9WqsT2JQW7KV2m+bC2ol/zqpW37NHxLaA==",
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"dependencies": {
|
||||||
|
"b4a": "^1.6.4"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/text-table": {
|
"node_modules/text-table": {
|
||||||
"version": "0.2.0",
|
"version": "0.2.0",
|
||||||
"resolved": "https://registry.npmjs.org/text-table/-/text-table-0.2.0.tgz",
|
"resolved": "https://registry.npmjs.org/text-table/-/text-table-0.2.0.tgz",
|
||||||
|
|
@ -10293,7 +10591,6 @@
|
||||||
"version": "1.0.2",
|
"version": "1.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz",
|
||||||
"integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==",
|
"integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==",
|
||||||
"dev": true,
|
|
||||||
"license": "ISC"
|
"license": "ISC"
|
||||||
},
|
},
|
||||||
"node_modules/write-file-atomic": {
|
"node_modules/write-file-atomic": {
|
||||||
|
|
@ -10404,6 +10701,20 @@
|
||||||
"funding": {
|
"funding": {
|
||||||
"url": "https://github.com/sponsors/sindresorhus"
|
"url": "https://github.com/sponsors/sindresorhus"
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"node_modules/zip-stream": {
|
||||||
|
"version": "5.0.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/zip-stream/-/zip-stream-5.0.2.tgz",
|
||||||
|
"integrity": "sha512-LfOdrUvPB8ZoXtvOBz6DlNClfvi//b5d56mSWyJi7XbH/HfhOHfUhOqxhT/rUiR7yiktlunqRo+jY6y/cWC/5g==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"archiver-utils": "^4.0.1",
|
||||||
|
"compress-commons": "^5.0.1",
|
||||||
|
"readable-stream": "^3.6.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 12.0.0"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -41,6 +41,8 @@
|
||||||
"@prisma/client": "^5.7.0",
|
"@prisma/client": "^5.7.0",
|
||||||
"@types/multer": "^2.0.0",
|
"@types/multer": "^2.0.0",
|
||||||
"adbkit-apkreader": "^3.2.0",
|
"adbkit-apkreader": "^3.2.0",
|
||||||
|
"archiver": "^6.0.1",
|
||||||
|
"axios": "^1.6.2",
|
||||||
"bplist-parser": "^0.3.2",
|
"bplist-parser": "^0.3.2",
|
||||||
"class-transformer": "^0.5.1",
|
"class-transformer": "^0.5.1",
|
||||||
"class-validator": "^0.14.0",
|
"class-validator": "^0.14.0",
|
||||||
|
|
@ -56,6 +58,7 @@
|
||||||
"@nestjs/cli": "^10.0.0",
|
"@nestjs/cli": "^10.0.0",
|
||||||
"@nestjs/schematics": "^10.0.0",
|
"@nestjs/schematics": "^10.0.0",
|
||||||
"@nestjs/testing": "^10.0.0",
|
"@nestjs/testing": "^10.0.0",
|
||||||
|
"@types/archiver": "^6.0.2",
|
||||||
"@types/express": "^4.17.17",
|
"@types/express": "^4.17.17",
|
||||||
"@types/jest": "^29.5.2",
|
"@types/jest": "^29.5.2",
|
||||||
"@types/node": "^20.3.1",
|
"@types/node": "^20.3.1",
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,34 @@
|
||||||
|
-- =============================================================================
|
||||||
|
-- App Assets Migration
|
||||||
|
-- 应用资源管理 (开屏图/引导图)
|
||||||
|
-- =============================================================================
|
||||||
|
|
||||||
|
-- -----------------------------------------------------------------------------
|
||||||
|
-- 1. 应用资源类型枚举
|
||||||
|
-- -----------------------------------------------------------------------------
|
||||||
|
|
||||||
|
CREATE TYPE "AppAssetType" AS ENUM ('SPLASH', 'GUIDE');
|
||||||
|
|
||||||
|
-- -----------------------------------------------------------------------------
|
||||||
|
-- 2. 应用资源表
|
||||||
|
-- -----------------------------------------------------------------------------
|
||||||
|
|
||||||
|
CREATE TABLE "app_assets" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"type" "AppAssetType" NOT NULL,
|
||||||
|
"sort_order" INTEGER NOT NULL,
|
||||||
|
"image_url" TEXT NOT NULL,
|
||||||
|
"title" VARCHAR(100),
|
||||||
|
"subtitle" VARCHAR(200),
|
||||||
|
"is_enabled" BOOLEAN NOT NULL DEFAULT true,
|
||||||
|
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"updated_at" TIMESTAMP(3) NOT NULL,
|
||||||
|
|
||||||
|
CONSTRAINT "app_assets_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Unique constraint: 同类型同排序位置只能有一条记录
|
||||||
|
CREATE UNIQUE INDEX "app_assets_type_sort_order_key" ON "app_assets"("type", "sort_order");
|
||||||
|
|
||||||
|
-- 按类型和启用状态查询索引
|
||||||
|
CREATE INDEX "app_assets_type_is_enabled_idx" ON "app_assets"("type", "is_enabled");
|
||||||
|
|
@ -0,0 +1,43 @@
|
||||||
|
-- =============================================================================
|
||||||
|
-- Customer Service Contacts Migration
|
||||||
|
-- 客服联系方式管理
|
||||||
|
-- =============================================================================
|
||||||
|
|
||||||
|
-- -----------------------------------------------------------------------------
|
||||||
|
-- 1. 客服联系方式类型枚举
|
||||||
|
-- -----------------------------------------------------------------------------
|
||||||
|
|
||||||
|
CREATE TYPE "ContactType" AS ENUM ('WECHAT', 'QQ');
|
||||||
|
|
||||||
|
-- -----------------------------------------------------------------------------
|
||||||
|
-- 2. 客服联系方式表
|
||||||
|
-- -----------------------------------------------------------------------------
|
||||||
|
|
||||||
|
CREATE TABLE "customer_service_contacts" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"type" "ContactType" NOT NULL,
|
||||||
|
"label" VARCHAR(100) NOT NULL,
|
||||||
|
"value" VARCHAR(200) NOT NULL,
|
||||||
|
"sort_order" INTEGER NOT NULL,
|
||||||
|
"is_enabled" BOOLEAN NOT NULL DEFAULT true,
|
||||||
|
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"updated_at" TIMESTAMP(3) NOT NULL,
|
||||||
|
|
||||||
|
CONSTRAINT "customer_service_contacts_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- 按类型和启用状态查询索引
|
||||||
|
CREATE INDEX "customer_service_contacts_type_is_enabled_idx" ON "customer_service_contacts"("type", "is_enabled");
|
||||||
|
|
||||||
|
-- 按排序查询索引
|
||||||
|
CREATE INDEX "customer_service_contacts_sort_order_idx" ON "customer_service_contacts"("sort_order");
|
||||||
|
|
||||||
|
-- -----------------------------------------------------------------------------
|
||||||
|
-- 3. 初始数据 (保留现有硬编码的联系方式)
|
||||||
|
-- -----------------------------------------------------------------------------
|
||||||
|
|
||||||
|
INSERT INTO "customer_service_contacts" ("id", "type", "label", "value", "sort_order", "is_enabled", "created_at", "updated_at") VALUES
|
||||||
|
(gen_random_uuid(), 'WECHAT', '客服微信1', 'liulianhuanghou1', 1, true, NOW(), NOW()),
|
||||||
|
(gen_random_uuid(), 'WECHAT', '客服微信2', 'liulianhuanghou2', 2, true, NOW(), NOW()),
|
||||||
|
(gen_random_uuid(), 'QQ', '客服QQ1', '1502109619', 3, true, NOW(), NOW()),
|
||||||
|
(gen_random_uuid(), 'QQ', '客服QQ2', '2171447109', 4, true, NOW(), NOW());
|
||||||
|
|
@ -0,0 +1,40 @@
|
||||||
|
-- 合同批量下载任务表
|
||||||
|
-- [2026-02-05] 新增:用于记录和追踪合同批量下载任务
|
||||||
|
|
||||||
|
-- CreateEnum
|
||||||
|
CREATE TYPE "BatchDownloadStatus" AS ENUM ('PENDING', 'PROCESSING', 'COMPLETED', 'FAILED', 'CANCELLED');
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "contract_batch_download_tasks" (
|
||||||
|
"id" BIGSERIAL NOT NULL,
|
||||||
|
"task_no" VARCHAR(50) NOT NULL,
|
||||||
|
"status" "BatchDownloadStatus" NOT NULL DEFAULT 'PENDING',
|
||||||
|
"total_contracts" INTEGER NOT NULL DEFAULT 0,
|
||||||
|
"downloaded_count" INTEGER NOT NULL DEFAULT 0,
|
||||||
|
"failed_count" INTEGER NOT NULL DEFAULT 0,
|
||||||
|
"progress" INTEGER NOT NULL DEFAULT 0,
|
||||||
|
"last_processed_order_no" VARCHAR(50),
|
||||||
|
"result_file_url" VARCHAR(500),
|
||||||
|
"result_file_size" BIGINT,
|
||||||
|
"errors" JSONB,
|
||||||
|
"operator_id" VARCHAR(50) NOT NULL,
|
||||||
|
"filters" JSONB,
|
||||||
|
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"started_at" TIMESTAMP(3),
|
||||||
|
"completed_at" TIMESTAMP(3),
|
||||||
|
"expires_at" TIMESTAMP(3),
|
||||||
|
|
||||||
|
CONSTRAINT "contract_batch_download_tasks_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX "contract_batch_download_tasks_task_no_key" ON "contract_batch_download_tasks"("task_no");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "contract_batch_download_tasks_status_idx" ON "contract_batch_download_tasks"("status");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "contract_batch_download_tasks_operator_id_idx" ON "contract_batch_download_tasks"("operator_id");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "contract_batch_download_tasks_created_at_idx" ON "contract_batch_download_tasks"("created_at");
|
||||||
|
|
@ -0,0 +1,14 @@
|
||||||
|
-- CreateTable: 预种计划开关配置
|
||||||
|
CREATE TABLE "pre_planting_configs" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"is_active" BOOLEAN NOT NULL DEFAULT false,
|
||||||
|
"activated_at" TIMESTAMP(3),
|
||||||
|
"updated_at" TIMESTAMP(3) NOT NULL,
|
||||||
|
"updated_by" VARCHAR(50),
|
||||||
|
|
||||||
|
CONSTRAINT "pre_planting_configs_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- 插入默认配置(关闭状态)
|
||||||
|
INSERT INTO "pre_planting_configs" ("id", "is_active", "updated_at")
|
||||||
|
VALUES (gen_random_uuid(), false, NOW());
|
||||||
|
|
@ -0,0 +1,41 @@
|
||||||
|
-- CreateTable: 认种树定价配置
|
||||||
|
-- 基础价 15831 USDT 不变,supplement 作为加价全额归总部 (S0000000001)
|
||||||
|
-- 涨价原因:总部运营成本压力
|
||||||
|
CREATE TABLE "tree_pricing_configs" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"current_supplement" INTEGER NOT NULL DEFAULT 0,
|
||||||
|
"auto_increase_enabled" BOOLEAN NOT NULL DEFAULT false,
|
||||||
|
"auto_increase_amount" INTEGER NOT NULL DEFAULT 0,
|
||||||
|
"auto_increase_interval_days" INTEGER NOT NULL DEFAULT 0,
|
||||||
|
"last_auto_increase_at" TIMESTAMP(3),
|
||||||
|
"next_auto_increase_at" TIMESTAMP(3),
|
||||||
|
"updated_at" TIMESTAMP(3) NOT NULL,
|
||||||
|
"updated_by" VARCHAR(50),
|
||||||
|
|
||||||
|
CONSTRAINT "tree_pricing_configs_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- 插入默认配置(加价为0,自动涨价关闭)
|
||||||
|
INSERT INTO "tree_pricing_configs" ("id", "current_supplement", "auto_increase_enabled", "updated_at")
|
||||||
|
VALUES (gen_random_uuid(), 0, false, NOW());
|
||||||
|
|
||||||
|
-- CreateTable: 认种树价格变更审计日志
|
||||||
|
-- 每次价格变更(手动或自动)都会记录一条不可修改的日志,用于审计追踪
|
||||||
|
CREATE TYPE "PriceChangeType" AS ENUM ('MANUAL', 'AUTO');
|
||||||
|
|
||||||
|
CREATE TABLE "tree_price_change_logs" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"change_type" "PriceChangeType" NOT NULL,
|
||||||
|
"previous_supplement" INTEGER NOT NULL,
|
||||||
|
"new_supplement" INTEGER NOT NULL,
|
||||||
|
"change_amount" INTEGER NOT NULL,
|
||||||
|
"reason" VARCHAR(500),
|
||||||
|
"operator_id" VARCHAR(50),
|
||||||
|
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
|
||||||
|
CONSTRAINT "tree_price_change_logs_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "tree_price_change_logs_created_at_idx" ON "tree_price_change_logs"("created_at");
|
||||||
|
CREATE INDEX "tree_price_change_logs_change_type_idx" ON "tree_price_change_logs"("change_type");
|
||||||
|
|
@ -0,0 +1,5 @@
|
||||||
|
-- Migration: add requiresForceRead to notifications
|
||||||
|
-- 为通知表添加"是否需要强制弹窗阅读"字段
|
||||||
|
-- 管理员可在创建通知时配置此字段,标记为 true 的通知将在用户打开 App 时强制弹窗展示
|
||||||
|
|
||||||
|
ALTER TABLE "notifications" ADD COLUMN "requiresForceRead" BOOLEAN NOT NULL DEFAULT false;
|
||||||
|
|
@ -60,6 +60,7 @@ model Notification {
|
||||||
imageUrl String? // 可选的图片URL
|
imageUrl String? // 可选的图片URL
|
||||||
linkUrl String? // 可选的跳转链接
|
linkUrl String? // 可选的跳转链接
|
||||||
isEnabled Boolean @default(true) // 是否启用
|
isEnabled Boolean @default(true) // 是否启用
|
||||||
|
requiresForceRead Boolean @default(false) // 是否需要强制弹窗阅读(管理员可配置)
|
||||||
publishedAt DateTime? // 发布时间(null表示草稿)
|
publishedAt DateTime? // 发布时间(null表示草稿)
|
||||||
expiresAt DateTime? // 过期时间(null表示永不过期)
|
expiresAt DateTime? // 过期时间(null表示永不过期)
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
|
|
@ -795,6 +796,9 @@ model AuthorizationRoleQueryView {
|
||||||
lastAssessmentMonth String? @map("last_assessment_month")
|
lastAssessmentMonth String? @map("last_assessment_month")
|
||||||
monthlyTreesAdded Int @default(0) @map("monthly_trees_added")
|
monthlyTreesAdded Int @default(0) @map("monthly_trees_added")
|
||||||
|
|
||||||
|
// 申请时提供的办公室照片(MinIO URL)
|
||||||
|
officePhotoUrls String[] @default([]) @map("office_photo_urls")
|
||||||
|
|
||||||
// 时间戳
|
// 时间戳
|
||||||
createdAt DateTime @map("created_at")
|
createdAt DateTime @map("created_at")
|
||||||
syncedAt DateTime @default(now()) @map("synced_at")
|
syncedAt DateTime @default(now()) @map("synced_at")
|
||||||
|
|
@ -1150,3 +1154,169 @@ model CoManagedWallet {
|
||||||
@@index([createdAt])
|
@@index([createdAt])
|
||||||
@@map("co_managed_wallets")
|
@@map("co_managed_wallets")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// App Assets (应用资源 - 开屏图/引导图)
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
/// 应用资源类型
|
||||||
|
enum AppAssetType {
|
||||||
|
SPLASH // 开屏图
|
||||||
|
GUIDE // 引导图
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 应用资源 - 管理开屏图和引导图
|
||||||
|
model AppAsset {
|
||||||
|
id String @id @default(uuid())
|
||||||
|
type AppAssetType
|
||||||
|
sortOrder Int @map("sort_order")
|
||||||
|
imageUrl String @map("image_url") @db.Text
|
||||||
|
title String? @db.VarChar(100)
|
||||||
|
subtitle String? @db.VarChar(200)
|
||||||
|
isEnabled Boolean @default(true) @map("is_enabled")
|
||||||
|
createdAt DateTime @default(now()) @map("created_at")
|
||||||
|
updatedAt DateTime @updatedAt @map("updated_at")
|
||||||
|
|
||||||
|
@@unique([type, sortOrder])
|
||||||
|
@@index([type, isEnabled])
|
||||||
|
@@map("app_assets")
|
||||||
|
}
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// Contract Batch Download Tasks (合同批量下载任务)
|
||||||
|
// [2026-02-05] 新增:支持管理后台合同批量下载
|
||||||
|
// 回滚方式:删除此 model 并运行 prisma migrate
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
/// 批量下载任务状态
|
||||||
|
enum BatchDownloadStatus {
|
||||||
|
PENDING // 待处理
|
||||||
|
PROCESSING // 处理中
|
||||||
|
COMPLETED // 已完成
|
||||||
|
FAILED // 失败
|
||||||
|
CANCELLED // 已取消
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 合同批量下载任务 - 记录批量下载请求的执行状态
|
||||||
|
model ContractBatchDownloadTask {
|
||||||
|
id BigInt @id @default(autoincrement())
|
||||||
|
taskNo String @unique @map("task_no") @db.VarChar(50)
|
||||||
|
status BatchDownloadStatus @default(PENDING)
|
||||||
|
|
||||||
|
// 下载统计
|
||||||
|
totalContracts Int @default(0) @map("total_contracts")
|
||||||
|
downloadedCount Int @default(0) @map("downloaded_count")
|
||||||
|
failedCount Int @default(0) @map("failed_count")
|
||||||
|
progress Int @default(0) // 0-100
|
||||||
|
|
||||||
|
// 断点续传支持
|
||||||
|
lastProcessedOrderNo String? @map("last_processed_order_no") @db.VarChar(50)
|
||||||
|
|
||||||
|
// 结果文件
|
||||||
|
resultFileUrl String? @map("result_file_url") @db.VarChar(500)
|
||||||
|
resultFileSize BigInt? @map("result_file_size")
|
||||||
|
|
||||||
|
// 错误信息
|
||||||
|
errors Json? // 失败的合同列表
|
||||||
|
|
||||||
|
// 操作者
|
||||||
|
operatorId String @map("operator_id") @db.VarChar(50)
|
||||||
|
|
||||||
|
// 筛选条件
|
||||||
|
filters Json? // { signedAfter, signedBefore, provinceCode, cityCode }
|
||||||
|
|
||||||
|
// 时间戳
|
||||||
|
createdAt DateTime @default(now()) @map("created_at")
|
||||||
|
startedAt DateTime? @map("started_at")
|
||||||
|
completedAt DateTime? @map("completed_at")
|
||||||
|
expiresAt DateTime? @map("expires_at") // 结果文件过期时间
|
||||||
|
|
||||||
|
@@index([status])
|
||||||
|
@@index([operatorId])
|
||||||
|
@@index([createdAt])
|
||||||
|
@@map("contract_batch_download_tasks")
|
||||||
|
}
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// Customer Service Contacts (客服联系方式)
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
/// 客服联系方式类型
|
||||||
|
enum ContactType {
|
||||||
|
WECHAT // 微信
|
||||||
|
QQ // QQ
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 客服联系方式 - 管理员配置的客服联系信息
|
||||||
|
model CustomerServiceContact {
|
||||||
|
id String @id @default(uuid())
|
||||||
|
type ContactType
|
||||||
|
label String @db.VarChar(100)
|
||||||
|
value String @db.VarChar(200)
|
||||||
|
sortOrder Int @map("sort_order")
|
||||||
|
isEnabled Boolean @default(true) @map("is_enabled")
|
||||||
|
createdAt DateTime @default(now()) @map("created_at")
|
||||||
|
updatedAt DateTime @updatedAt @map("updated_at")
|
||||||
|
|
||||||
|
@@index([type, isEnabled])
|
||||||
|
@@index([sortOrder])
|
||||||
|
@@map("customer_service_contacts")
|
||||||
|
}
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// 预种计划开关配置
|
||||||
|
// 控制预种功能的开启/关闭,不影响已完成的业务流程
|
||||||
|
// =============================================================================
|
||||||
|
model PrePlantingConfig {
|
||||||
|
id String @id @default(uuid())
|
||||||
|
isActive Boolean @default(false) @map("is_active")
|
||||||
|
activatedAt DateTime? @map("activated_at")
|
||||||
|
updatedAt DateTime @updatedAt @map("updated_at")
|
||||||
|
updatedBy String? @map("updated_by") @db.VarChar(50)
|
||||||
|
|
||||||
|
@@map("pre_planting_configs")
|
||||||
|
}
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// 认种树定价配置 (Tree Pricing Supplement)
|
||||||
|
// 基础价 15831 USDT 不变,supplement 作为加价全额归总部 (S0000000001)
|
||||||
|
// 正式认种总价 = 15831 + currentSupplement
|
||||||
|
// 预种总价 = (15831 + currentSupplement) / 5
|
||||||
|
// =============================================================================
|
||||||
|
model TreePricingConfig {
|
||||||
|
id String @id @default(uuid())
|
||||||
|
currentSupplement Int @default(0) @map("current_supplement")
|
||||||
|
autoIncreaseEnabled Boolean @default(false) @map("auto_increase_enabled")
|
||||||
|
autoIncreaseAmount Int @default(0) @map("auto_increase_amount")
|
||||||
|
autoIncreaseIntervalDays Int @default(0) @map("auto_increase_interval_days")
|
||||||
|
lastAutoIncreaseAt DateTime? @map("last_auto_increase_at")
|
||||||
|
nextAutoIncreaseAt DateTime? @map("next_auto_increase_at")
|
||||||
|
updatedAt DateTime @updatedAt @map("updated_at")
|
||||||
|
updatedBy String? @map("updated_by") @db.VarChar(50)
|
||||||
|
|
||||||
|
@@map("tree_pricing_configs")
|
||||||
|
}
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// 认种树价格变更审计日志 (Tree Price Change Audit Log)
|
||||||
|
// 每次价格变更(手动或自动)都会记录一条不可修改的日志
|
||||||
|
// =============================================================================
|
||||||
|
model TreePriceChangeLog {
|
||||||
|
id String @id @default(uuid())
|
||||||
|
changeType PriceChangeType @map("change_type")
|
||||||
|
previousSupplement Int @map("previous_supplement")
|
||||||
|
newSupplement Int @map("new_supplement")
|
||||||
|
changeAmount Int @map("change_amount")
|
||||||
|
reason String? @db.VarChar(500)
|
||||||
|
operatorId String? @map("operator_id") @db.VarChar(50)
|
||||||
|
createdAt DateTime @default(now()) @map("created_at")
|
||||||
|
|
||||||
|
@@index([createdAt])
|
||||||
|
@@index([changeType])
|
||||||
|
@@map("tree_price_change_logs")
|
||||||
|
}
|
||||||
|
|
||||||
|
enum PriceChangeType {
|
||||||
|
MANUAL // 管理员手动调整
|
||||||
|
AUTO // 自动涨价任务
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,262 @@
|
||||||
|
import {
|
||||||
|
Controller,
|
||||||
|
Get,
|
||||||
|
Post,
|
||||||
|
Put,
|
||||||
|
Delete,
|
||||||
|
Body,
|
||||||
|
Param,
|
||||||
|
Query,
|
||||||
|
UploadedFile,
|
||||||
|
HttpCode,
|
||||||
|
HttpStatus,
|
||||||
|
UseInterceptors,
|
||||||
|
ParseFilePipe,
|
||||||
|
MaxFileSizeValidator,
|
||||||
|
FileTypeValidator,
|
||||||
|
NotFoundException,
|
||||||
|
BadRequestException,
|
||||||
|
Logger,
|
||||||
|
} from '@nestjs/common'
|
||||||
|
import { FileInterceptor } from '@nestjs/platform-express'
|
||||||
|
import { ApiTags, ApiOperation, ApiBearerAuth, ApiConsumes, ApiBody, ApiQuery } from '@nestjs/swagger'
|
||||||
|
import { PrismaService } from '../../infrastructure/persistence/prisma/prisma.service'
|
||||||
|
import { FileStorageService } from '../../infrastructure/storage/file-storage.service'
|
||||||
|
import { AppAssetType } from '@prisma/client'
|
||||||
|
|
||||||
|
// 图片最大 10MB
|
||||||
|
const MAX_IMAGE_SIZE = 10 * 1024 * 1024
|
||||||
|
|
||||||
|
// 每种类型的数量上限
|
||||||
|
const ASSET_LIMITS: Record<AppAssetType, number> = {
|
||||||
|
SPLASH: 10,
|
||||||
|
GUIDE: 10,
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== Response DTO =====
|
||||||
|
|
||||||
|
interface AppAssetResponseDto {
|
||||||
|
id: string
|
||||||
|
type: AppAssetType
|
||||||
|
sortOrder: number
|
||||||
|
imageUrl: string
|
||||||
|
title: string | null
|
||||||
|
subtitle: string | null
|
||||||
|
isEnabled: boolean
|
||||||
|
createdAt: Date
|
||||||
|
updatedAt: Date
|
||||||
|
}
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// Admin Controller (需要认证)
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
@ApiTags('App Asset Management')
|
||||||
|
@Controller('admin/app-assets')
|
||||||
|
export class AdminAppAssetController {
|
||||||
|
private readonly logger = new Logger(AdminAppAssetController.name)
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
private readonly prisma: PrismaService,
|
||||||
|
private readonly fileStorage: FileStorageService,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
@Get()
|
||||||
|
@ApiBearerAuth()
|
||||||
|
@ApiOperation({ summary: '查询应用资源列表' })
|
||||||
|
@ApiQuery({ name: 'type', required: false, enum: AppAssetType })
|
||||||
|
async list(@Query('type') type?: AppAssetType): Promise<AppAssetResponseDto[]> {
|
||||||
|
const where = type ? { type } : {}
|
||||||
|
const assets = await this.prisma.appAsset.findMany({
|
||||||
|
where,
|
||||||
|
orderBy: [{ type: 'asc' }, { sortOrder: 'asc' }],
|
||||||
|
})
|
||||||
|
return assets.map(this.toDto)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post('upload')
|
||||||
|
@ApiBearerAuth()
|
||||||
|
@UseInterceptors(FileInterceptor('file'))
|
||||||
|
@ApiConsumes('multipart/form-data')
|
||||||
|
@ApiOperation({ summary: '上传图片并创建/替换资源' })
|
||||||
|
@ApiBody({
|
||||||
|
schema: {
|
||||||
|
type: 'object',
|
||||||
|
required: ['file', 'type', 'sortOrder'],
|
||||||
|
properties: {
|
||||||
|
file: { type: 'string', format: 'binary' },
|
||||||
|
type: { type: 'string', enum: ['SPLASH', 'GUIDE'] },
|
||||||
|
sortOrder: { type: 'integer', minimum: 1 },
|
||||||
|
title: { type: 'string' },
|
||||||
|
subtitle: { type: 'string' },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
async upload(
|
||||||
|
@UploadedFile(
|
||||||
|
new ParseFilePipe({
|
||||||
|
validators: [
|
||||||
|
new MaxFileSizeValidator({ maxSize: MAX_IMAGE_SIZE }),
|
||||||
|
new FileTypeValidator({ fileType: /^image\/(jpeg|jpg|png|webp)$/ }),
|
||||||
|
],
|
||||||
|
fileIsRequired: true,
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
file: Express.Multer.File,
|
||||||
|
@Body('type') type: string,
|
||||||
|
@Body('sortOrder') sortOrderStr: string,
|
||||||
|
@Body('title') title?: string,
|
||||||
|
@Body('subtitle') subtitle?: string,
|
||||||
|
): Promise<AppAssetResponseDto> {
|
||||||
|
// 校验 type
|
||||||
|
const assetType = type as AppAssetType
|
||||||
|
if (!Object.values(AppAssetType).includes(assetType)) {
|
||||||
|
throw new BadRequestException(`Invalid type: ${type}. Must be SPLASH or GUIDE`)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 校验 sortOrder
|
||||||
|
const sortOrder = parseInt(sortOrderStr, 10)
|
||||||
|
if (isNaN(sortOrder) || sortOrder < 1) {
|
||||||
|
throw new BadRequestException('sortOrder must be a positive integer')
|
||||||
|
}
|
||||||
|
|
||||||
|
const limit = ASSET_LIMITS[assetType]
|
||||||
|
if (sortOrder > limit) {
|
||||||
|
throw new BadRequestException(`sortOrder for ${assetType} must be between 1 and ${limit}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 保存文件
|
||||||
|
const uploadResult = await this.fileStorage.saveFile(
|
||||||
|
file.buffer,
|
||||||
|
file.originalname,
|
||||||
|
'app-assets',
|
||||||
|
`${assetType.toLowerCase()}-${sortOrder}`,
|
||||||
|
)
|
||||||
|
|
||||||
|
this.logger.log(
|
||||||
|
`Uploaded app asset: type=${assetType}, sortOrder=${sortOrder}, url=${uploadResult.url}`,
|
||||||
|
)
|
||||||
|
|
||||||
|
// Upsert: 同 type+sortOrder 自动替换
|
||||||
|
const asset = await this.prisma.appAsset.upsert({
|
||||||
|
where: {
|
||||||
|
type_sortOrder: { type: assetType, sortOrder },
|
||||||
|
},
|
||||||
|
create: {
|
||||||
|
type: assetType,
|
||||||
|
sortOrder,
|
||||||
|
imageUrl: uploadResult.url,
|
||||||
|
title: title || null,
|
||||||
|
subtitle: subtitle || null,
|
||||||
|
isEnabled: true,
|
||||||
|
},
|
||||||
|
update: {
|
||||||
|
imageUrl: uploadResult.url,
|
||||||
|
title: title !== undefined ? (title || null) : undefined,
|
||||||
|
subtitle: subtitle !== undefined ? (subtitle || null) : undefined,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
return this.toDto(asset)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Put(':id')
|
||||||
|
@ApiBearerAuth()
|
||||||
|
@HttpCode(HttpStatus.OK)
|
||||||
|
@ApiOperation({ summary: '更新资源元数据 (标题/副标题/启停)' })
|
||||||
|
async update(
|
||||||
|
@Param('id') id: string,
|
||||||
|
@Body() body: { title?: string; subtitle?: string; isEnabled?: boolean },
|
||||||
|
): Promise<AppAssetResponseDto> {
|
||||||
|
const existing = await this.prisma.appAsset.findUnique({ where: { id } })
|
||||||
|
if (!existing) {
|
||||||
|
throw new NotFoundException('App asset not found')
|
||||||
|
}
|
||||||
|
|
||||||
|
const data: Record<string, unknown> = {}
|
||||||
|
if (body.title !== undefined) data.title = body.title || null
|
||||||
|
if (body.subtitle !== undefined) data.subtitle = body.subtitle || null
|
||||||
|
if (body.isEnabled !== undefined) data.isEnabled = body.isEnabled
|
||||||
|
|
||||||
|
const updated = await this.prisma.appAsset.update({
|
||||||
|
where: { id },
|
||||||
|
data,
|
||||||
|
})
|
||||||
|
|
||||||
|
return this.toDto(updated)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Delete(':id')
|
||||||
|
@ApiBearerAuth()
|
||||||
|
@HttpCode(HttpStatus.NO_CONTENT)
|
||||||
|
@ApiOperation({ summary: '删除资源' })
|
||||||
|
async delete(@Param('id') id: string): Promise<void> {
|
||||||
|
const existing = await this.prisma.appAsset.findUnique({ where: { id } })
|
||||||
|
if (!existing) {
|
||||||
|
throw new NotFoundException('App asset not found')
|
||||||
|
}
|
||||||
|
await this.prisma.appAsset.delete({ where: { id } })
|
||||||
|
this.logger.log(`Deleted app asset: id=${id}, type=${existing.type}, sortOrder=${existing.sortOrder}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
private toDto(asset: {
|
||||||
|
id: string
|
||||||
|
type: AppAssetType
|
||||||
|
sortOrder: number
|
||||||
|
imageUrl: string
|
||||||
|
title: string | null
|
||||||
|
subtitle: string | null
|
||||||
|
isEnabled: boolean
|
||||||
|
createdAt: Date
|
||||||
|
updatedAt: Date
|
||||||
|
}): AppAssetResponseDto {
|
||||||
|
return {
|
||||||
|
id: asset.id,
|
||||||
|
type: asset.type,
|
||||||
|
sortOrder: asset.sortOrder,
|
||||||
|
imageUrl: asset.imageUrl,
|
||||||
|
title: asset.title,
|
||||||
|
subtitle: asset.subtitle,
|
||||||
|
isEnabled: asset.isEnabled,
|
||||||
|
createdAt: asset.createdAt,
|
||||||
|
updatedAt: asset.updatedAt,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// Public Controller (移动端调用,无需认证)
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
@ApiTags('App Assets (Public)')
|
||||||
|
@Controller('app-assets')
|
||||||
|
export class PublicAppAssetController {
|
||||||
|
constructor(private readonly prisma: PrismaService) {}
|
||||||
|
|
||||||
|
@Get()
|
||||||
|
@ApiOperation({ summary: '获取已启用的应用资源 (移动端)' })
|
||||||
|
@ApiQuery({ name: 'type', required: false, enum: AppAssetType })
|
||||||
|
async list(@Query('type') type?: AppAssetType): Promise<AppAssetResponseDto[]> {
|
||||||
|
const where: { isEnabled: boolean; type?: AppAssetType } = { isEnabled: true }
|
||||||
|
if (type && Object.values(AppAssetType).includes(type)) {
|
||||||
|
where.type = type
|
||||||
|
}
|
||||||
|
|
||||||
|
const assets = await this.prisma.appAsset.findMany({
|
||||||
|
where,
|
||||||
|
orderBy: [{ type: 'asc' }, { sortOrder: 'asc' }],
|
||||||
|
})
|
||||||
|
|
||||||
|
return assets.map((asset) => ({
|
||||||
|
id: asset.id,
|
||||||
|
type: asset.type,
|
||||||
|
sortOrder: asset.sortOrder,
|
||||||
|
imageUrl: asset.imageUrl,
|
||||||
|
title: asset.title,
|
||||||
|
subtitle: asset.subtitle,
|
||||||
|
isEnabled: asset.isEnabled,
|
||||||
|
createdAt: asset.createdAt,
|
||||||
|
updatedAt: asset.updatedAt,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,42 @@
|
||||||
|
/**
|
||||||
|
* 自助申请照片管理控制器
|
||||||
|
* [2026-03-02] 纯新增:独立页面展示所有自助申请用户的办公室照片
|
||||||
|
*
|
||||||
|
* === 数据流 ===
|
||||||
|
* admin-web → 本控制器 → AuthorizationProxyService → authorization-service 内部 API
|
||||||
|
* 绕过 CDC,直接读源头数据,保证 officePhotoUrls 100% 准确
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { Controller, Get, Query, Logger, HttpCode, HttpStatus } from '@nestjs/common';
|
||||||
|
import { AuthorizationProxyService, SelfApplyPhotosResponse } from '../../authorization/authorization-proxy.service';
|
||||||
|
|
||||||
|
@Controller('admin/authorization-photos')
|
||||||
|
export class AuthorizationPhotosController {
|
||||||
|
private readonly logger = new Logger(AuthorizationPhotosController.name);
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
private readonly authorizationProxyService: AuthorizationProxyService,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取自助申请照片列表
|
||||||
|
* GET /admin/authorization-photos?page=1&limit=20&roleType=COMMUNITY
|
||||||
|
*/
|
||||||
|
@Get()
|
||||||
|
@HttpCode(HttpStatus.OK)
|
||||||
|
async getSelfApplyPhotos(
|
||||||
|
@Query('page') page?: string,
|
||||||
|
@Query('limit') limit?: string,
|
||||||
|
@Query('roleType') roleType?: string,
|
||||||
|
): Promise<SelfApplyPhotosResponse> {
|
||||||
|
this.logger.debug(
|
||||||
|
`[getSelfApplyPhotos] page=${page}, limit=${limit}, roleType=${roleType || 'ALL'}`,
|
||||||
|
);
|
||||||
|
|
||||||
|
return this.authorizationProxyService.getSelfApplyPhotos({
|
||||||
|
page: Number(page) || 1,
|
||||||
|
limit: Number(limit) || 20,
|
||||||
|
roleType: roleType || undefined,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,309 @@
|
||||||
|
/**
|
||||||
|
* 合同管理控制器
|
||||||
|
* [2026-02-05] 新增:提供合同查询、下载、批量下载功能
|
||||||
|
* 回滚方式:删除此文件并从 app.module.ts 中移除引用
|
||||||
|
*/
|
||||||
|
|
||||||
|
import {
|
||||||
|
Controller,
|
||||||
|
Get,
|
||||||
|
Post,
|
||||||
|
Param,
|
||||||
|
Query,
|
||||||
|
Body,
|
||||||
|
Res,
|
||||||
|
Req,
|
||||||
|
NotFoundException,
|
||||||
|
BadRequestException,
|
||||||
|
Logger,
|
||||||
|
StreamableFile,
|
||||||
|
} from '@nestjs/common';
|
||||||
|
import { ApiTags, ApiOperation, ApiQuery, ApiResponse, ApiParam, ApiBody } from '@nestjs/swagger';
|
||||||
|
import { Request, Response } from 'express';
|
||||||
|
import { PrismaService } from '../../infrastructure/persistence/prisma/prisma.service';
|
||||||
|
import { ContractService, ContractQueryParams } from '../../application/services/contract.service';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 批量下载请求 DTO
|
||||||
|
*/
|
||||||
|
interface BatchDownloadRequestDto {
|
||||||
|
filters?: {
|
||||||
|
signedAfter?: string;
|
||||||
|
signedBefore?: string;
|
||||||
|
provinceCode?: string;
|
||||||
|
cityCode?: string;
|
||||||
|
};
|
||||||
|
orderNos?: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 合同管理控制器
|
||||||
|
* 为管理后台提供合同查询和下载功能
|
||||||
|
*/
|
||||||
|
@ApiTags('Admin - Contracts')
|
||||||
|
@Controller('admin/contracts')
|
||||||
|
export class ContractController {
|
||||||
|
private readonly logger = new Logger(ContractController.name);
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
private readonly prisma: PrismaService,
|
||||||
|
private readonly contractService: ContractService,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取合同列表
|
||||||
|
*/
|
||||||
|
@Get()
|
||||||
|
@ApiOperation({ summary: '获取合同列表' })
|
||||||
|
@ApiQuery({ name: 'signedAfter', required: false, description: '签署时间起始(ISO格式)' })
|
||||||
|
@ApiQuery({ name: 'signedBefore', required: false, description: '签署时间结束(ISO格式)' })
|
||||||
|
@ApiQuery({ name: 'provinceCode', required: false, description: '省份代码' })
|
||||||
|
@ApiQuery({ name: 'cityCode', required: false, description: '城市代码' })
|
||||||
|
@ApiQuery({ name: 'status', required: false, description: '合同状态' })
|
||||||
|
@ApiQuery({ name: 'page', required: false, description: '页码,默认1' })
|
||||||
|
@ApiQuery({ name: 'pageSize', required: false, description: '每页条数,默认50' })
|
||||||
|
@ApiQuery({ name: 'orderBy', required: false, description: '排序字段:signedAt/createdAt' })
|
||||||
|
@ApiQuery({ name: 'orderDir', required: false, description: '排序方向:asc/desc' })
|
||||||
|
@ApiResponse({ status: 200, description: '合同列表' })
|
||||||
|
async getContracts(
|
||||||
|
@Query('signedAfter') signedAfter?: string,
|
||||||
|
@Query('signedBefore') signedBefore?: string,
|
||||||
|
@Query('provinceCode') provinceCode?: string,
|
||||||
|
@Query('cityCode') cityCode?: string,
|
||||||
|
@Query('status') status?: string,
|
||||||
|
@Query('page') page?: string,
|
||||||
|
@Query('pageSize') pageSize?: string,
|
||||||
|
@Query('orderBy') orderBy?: string,
|
||||||
|
@Query('orderDir') orderDir?: string,
|
||||||
|
) {
|
||||||
|
this.logger.log(`========== GET /v1/admin/contracts 请求 ==========`);
|
||||||
|
|
||||||
|
const params: ContractQueryParams = {
|
||||||
|
signedAfter,
|
||||||
|
signedBefore,
|
||||||
|
provinceCode,
|
||||||
|
cityCode,
|
||||||
|
status,
|
||||||
|
page: page ? parseInt(page, 10) : undefined,
|
||||||
|
pageSize: pageSize ? parseInt(pageSize, 10) : undefined,
|
||||||
|
orderBy: orderBy as 'signedAt' | 'createdAt',
|
||||||
|
orderDir: orderDir as 'asc' | 'desc',
|
||||||
|
};
|
||||||
|
|
||||||
|
return this.contractService.getContracts(params);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取合同统计信息
|
||||||
|
*/
|
||||||
|
@Get('statistics')
|
||||||
|
@ApiOperation({ summary: '获取合同统计信息' })
|
||||||
|
@ApiQuery({ name: 'provinceCode', required: false, description: '省份代码' })
|
||||||
|
@ApiQuery({ name: 'cityCode', required: false, description: '城市代码' })
|
||||||
|
@ApiResponse({ status: 200, description: '合同统计' })
|
||||||
|
async getStatistics(
|
||||||
|
@Query('provinceCode') provinceCode?: string,
|
||||||
|
@Query('cityCode') cityCode?: string,
|
||||||
|
) {
|
||||||
|
this.logger.log(`========== GET /v1/admin/contracts/statistics 请求 ==========`);
|
||||||
|
return this.contractService.getStatistics({ provinceCode, cityCode });
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 创建批量下载任务
|
||||||
|
*/
|
||||||
|
@Post('batch-download')
|
||||||
|
@ApiOperation({ summary: '创建合同批量下载任务' })
|
||||||
|
@ApiBody({ description: '筛选条件' })
|
||||||
|
@ApiResponse({ status: 201, description: '任务创建成功' })
|
||||||
|
async createBatchDownload(
|
||||||
|
@Body() body: BatchDownloadRequestDto,
|
||||||
|
@Req() req: Request,
|
||||||
|
) {
|
||||||
|
this.logger.log(`========== POST /v1/admin/contracts/batch-download 请求 ==========`);
|
||||||
|
this.logger.log(`筛选条件: ${JSON.stringify(body.filters)}`);
|
||||||
|
|
||||||
|
// 生成任务号
|
||||||
|
const taskNo = `BD${Date.now()}`;
|
||||||
|
|
||||||
|
// 获取操作者 ID(从请求头或默认值)
|
||||||
|
const operatorId = (req.headers['x-operator-id'] as string) || 'system';
|
||||||
|
|
||||||
|
// 创建任务记录
|
||||||
|
const task = await this.prisma.contractBatchDownloadTask.create({
|
||||||
|
data: {
|
||||||
|
taskNo,
|
||||||
|
operatorId,
|
||||||
|
filters: body.filters ? JSON.parse(JSON.stringify(body.filters)) : null,
|
||||||
|
status: 'PENDING',
|
||||||
|
expiresAt: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000), // 7天后过期
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// TODO: 触发异步任务处理(后续实现)
|
||||||
|
// 可以使用 Bull Queue 或 Kafka 消息
|
||||||
|
|
||||||
|
this.logger.log(`批量下载任务创建成功: ${taskNo}`);
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
taskId: task.id.toString(),
|
||||||
|
taskNo: task.taskNo,
|
||||||
|
status: task.status,
|
||||||
|
createdAt: task.createdAt.toISOString(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 查询批量下载任务状态
|
||||||
|
*/
|
||||||
|
@Get('batch-download/:taskNo')
|
||||||
|
@ApiOperation({ summary: '查询批量下载任务状态' })
|
||||||
|
@ApiParam({ name: 'taskNo', description: '任务号' })
|
||||||
|
@ApiResponse({ status: 200, description: '任务状态' })
|
||||||
|
@ApiResponse({ status: 404, description: '任务不存在' })
|
||||||
|
async getBatchDownloadStatus(@Param('taskNo') taskNo: string) {
|
||||||
|
this.logger.log(`========== GET /v1/admin/contracts/batch-download/${taskNo} 请求 ==========`);
|
||||||
|
|
||||||
|
const task = await this.prisma.contractBatchDownloadTask.findUnique({
|
||||||
|
where: { taskNo },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!task) {
|
||||||
|
throw new NotFoundException(`任务不存在: ${taskNo}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
taskId: task.id.toString(),
|
||||||
|
taskNo: task.taskNo,
|
||||||
|
status: task.status,
|
||||||
|
totalContracts: task.totalContracts,
|
||||||
|
downloadedCount: task.downloadedCount,
|
||||||
|
failedCount: task.failedCount,
|
||||||
|
progress: task.progress,
|
||||||
|
resultFileUrl: task.resultFileUrl,
|
||||||
|
resultFileSize: task.resultFileSize?.toString(),
|
||||||
|
errors: task.errors,
|
||||||
|
createdAt: task.createdAt.toISOString(),
|
||||||
|
startedAt: task.startedAt?.toISOString(),
|
||||||
|
completedAt: task.completedAt?.toISOString(),
|
||||||
|
expiresAt: task.expiresAt?.toISOString(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取用户的合同列表
|
||||||
|
*/
|
||||||
|
@Get('users/:accountSequence')
|
||||||
|
@ApiOperation({ summary: '获取用户的合同列表' })
|
||||||
|
@ApiParam({ name: 'accountSequence', description: '用户账户序列号' })
|
||||||
|
@ApiQuery({ name: 'page', required: false, description: '页码' })
|
||||||
|
@ApiQuery({ name: 'pageSize', required: false, description: '每页条数' })
|
||||||
|
@ApiResponse({ status: 200, description: '合同列表' })
|
||||||
|
async getUserContracts(
|
||||||
|
@Param('accountSequence') accountSequence: string,
|
||||||
|
@Query('page') page?: string,
|
||||||
|
@Query('pageSize') pageSize?: string,
|
||||||
|
) {
|
||||||
|
this.logger.log(`========== GET /v1/admin/contracts/users/${accountSequence} 请求 ==========`);
|
||||||
|
|
||||||
|
return this.contractService.getUserContracts(accountSequence, {
|
||||||
|
page: page ? parseInt(page, 10) : undefined,
|
||||||
|
pageSize: pageSize ? parseInt(pageSize, 10) : undefined,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取单个合同详情
|
||||||
|
*/
|
||||||
|
@Get(':orderNo')
|
||||||
|
@ApiOperation({ summary: '获取合同详情' })
|
||||||
|
@ApiParam({ name: 'orderNo', description: '订单号' })
|
||||||
|
@ApiResponse({ status: 200, description: '合同详情' })
|
||||||
|
@ApiResponse({ status: 404, description: '合同不存在' })
|
||||||
|
async getContract(@Param('orderNo') orderNo: string) {
|
||||||
|
this.logger.log(`========== GET /v1/admin/contracts/${orderNo} 请求 ==========`);
|
||||||
|
|
||||||
|
const contract = await this.contractService.getContract(orderNo);
|
||||||
|
if (!contract) {
|
||||||
|
throw new NotFoundException(`合同不存在: ${orderNo}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return contract;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 下载合同 PDF(支持断点续传)
|
||||||
|
*/
|
||||||
|
@Get(':orderNo/download')
|
||||||
|
@ApiOperation({ summary: '下载合同 PDF(支持断点续传)' })
|
||||||
|
@ApiParam({ name: 'orderNo', description: '订单号' })
|
||||||
|
@ApiResponse({ status: 200, description: 'PDF 文件' })
|
||||||
|
@ApiResponse({ status: 206, description: '部分内容(断点续传)' })
|
||||||
|
@ApiResponse({ status: 404, description: '合同不存在' })
|
||||||
|
async downloadContract(
|
||||||
|
@Param('orderNo') orderNo: string,
|
||||||
|
@Req() req: Request,
|
||||||
|
@Res() res: Response,
|
||||||
|
): Promise<void> {
|
||||||
|
this.logger.log(`========== GET /v1/admin/contracts/${orderNo}/download 请求 ==========`);
|
||||||
|
|
||||||
|
// 获取合同详情
|
||||||
|
const contract = await this.contractService.getContract(orderNo);
|
||||||
|
if (!contract) {
|
||||||
|
throw new NotFoundException(`合同不存在: ${orderNo}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!contract.signedPdfUrl) {
|
||||||
|
throw new NotFoundException(`合同PDF不存在: ${orderNo},状态: ${contract.status}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 下载 PDF
|
||||||
|
const pdfBuffer = await this.contractService.downloadContractPdf(orderNo);
|
||||||
|
const fileSize = pdfBuffer.length;
|
||||||
|
|
||||||
|
// 生成文件名
|
||||||
|
const safeRealName = contract.userRealName?.replace(/[\/\\:*?"<>|]/g, '_') || '未知';
|
||||||
|
const fileName = `${contract.contractNo}_${safeRealName}_${contract.treeCount}棵_${contract.provinceName}${contract.cityName}.pdf`;
|
||||||
|
const encodedFileName = encodeURIComponent(fileName);
|
||||||
|
|
||||||
|
// 检查 Range 请求头
|
||||||
|
const range = req.headers.range;
|
||||||
|
|
||||||
|
// 设置通用响应头
|
||||||
|
res.setHeader('Accept-Ranges', 'bytes');
|
||||||
|
res.setHeader('Content-Type', 'application/pdf');
|
||||||
|
res.setHeader('Content-Disposition', `attachment; filename*=UTF-8''${encodedFileName}`);
|
||||||
|
res.setHeader('Cache-Control', 'public, max-age=86400');
|
||||||
|
|
||||||
|
if (range) {
|
||||||
|
// 断点续传
|
||||||
|
const parts = range.replace(/bytes=/, '').split('-');
|
||||||
|
const start = parseInt(parts[0], 10);
|
||||||
|
const end = parts[1] ? parseInt(parts[1], 10) : fileSize - 1;
|
||||||
|
|
||||||
|
if (start >= fileSize || end >= fileSize || start > end) {
|
||||||
|
res.status(416);
|
||||||
|
res.setHeader('Content-Range', `bytes */${fileSize}`);
|
||||||
|
res.end();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const chunkSize = end - start + 1;
|
||||||
|
const chunk = pdfBuffer.slice(start, end + 1);
|
||||||
|
|
||||||
|
this.logger.log(`Range 请求: ${fileName}, bytes ${start}-${end}/${fileSize}`);
|
||||||
|
|
||||||
|
res.status(206);
|
||||||
|
res.setHeader('Content-Range', `bytes ${start}-${end}/${fileSize}`);
|
||||||
|
res.setHeader('Content-Length', chunkSize);
|
||||||
|
res.end(chunk);
|
||||||
|
} else {
|
||||||
|
// 完整文件
|
||||||
|
this.logger.log(`完整下载: ${fileName}, size=${fileSize}`);
|
||||||
|
|
||||||
|
res.setHeader('Content-Length', fileSize);
|
||||||
|
res.end(pdfBuffer);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,196 @@
|
||||||
|
import {
|
||||||
|
Controller,
|
||||||
|
Get,
|
||||||
|
Post,
|
||||||
|
Put,
|
||||||
|
Delete,
|
||||||
|
Body,
|
||||||
|
Param,
|
||||||
|
HttpCode,
|
||||||
|
HttpStatus,
|
||||||
|
NotFoundException,
|
||||||
|
BadRequestException,
|
||||||
|
Logger,
|
||||||
|
} from '@nestjs/common'
|
||||||
|
import { ApiTags, ApiOperation, ApiBearerAuth } from '@nestjs/swagger'
|
||||||
|
import { PrismaService } from '../../infrastructure/persistence/prisma/prisma.service'
|
||||||
|
import { ContactType } from '@prisma/client'
|
||||||
|
|
||||||
|
// ===== DTOs =====
|
||||||
|
|
||||||
|
interface CreateContactDto {
|
||||||
|
type: string
|
||||||
|
label: string
|
||||||
|
value: string
|
||||||
|
sortOrder: number
|
||||||
|
isEnabled?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
interface UpdateContactDto {
|
||||||
|
type?: string
|
||||||
|
label?: string
|
||||||
|
value?: string
|
||||||
|
sortOrder?: number
|
||||||
|
isEnabled?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ContactResponseDto {
|
||||||
|
id: string
|
||||||
|
type: ContactType
|
||||||
|
label: string
|
||||||
|
value: string
|
||||||
|
sortOrder: number
|
||||||
|
isEnabled: boolean
|
||||||
|
createdAt: Date
|
||||||
|
updatedAt: Date
|
||||||
|
}
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// Admin Controller (需要认证)
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
@ApiTags('Customer Service Contact Management')
|
||||||
|
@Controller('admin/customer-service-contacts')
|
||||||
|
export class AdminCustomerServiceContactController {
|
||||||
|
private readonly logger = new Logger(AdminCustomerServiceContactController.name)
|
||||||
|
|
||||||
|
constructor(private readonly prisma: PrismaService) {}
|
||||||
|
|
||||||
|
@Get()
|
||||||
|
@ApiBearerAuth()
|
||||||
|
@ApiOperation({ summary: '查询客服联系方式列表 (全部)' })
|
||||||
|
async list(): Promise<ContactResponseDto[]> {
|
||||||
|
const contacts = await this.prisma.customerServiceContact.findMany({
|
||||||
|
orderBy: [{ sortOrder: 'asc' }],
|
||||||
|
})
|
||||||
|
return contacts.map(this.toDto)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post()
|
||||||
|
@ApiBearerAuth()
|
||||||
|
@ApiOperation({ summary: '新增客服联系方式' })
|
||||||
|
async create(@Body() body: CreateContactDto): Promise<ContactResponseDto> {
|
||||||
|
const contactType = body.type as ContactType
|
||||||
|
if (!Object.values(ContactType).includes(contactType)) {
|
||||||
|
throw new BadRequestException(`Invalid type: ${body.type}. Must be WECHAT or QQ`)
|
||||||
|
}
|
||||||
|
if (!body.label || !body.value) {
|
||||||
|
throw new BadRequestException('label and value are required')
|
||||||
|
}
|
||||||
|
if (body.sortOrder === undefined || body.sortOrder < 0) {
|
||||||
|
throw new BadRequestException('sortOrder must be a non-negative integer')
|
||||||
|
}
|
||||||
|
|
||||||
|
const contact = await this.prisma.customerServiceContact.create({
|
||||||
|
data: {
|
||||||
|
type: contactType,
|
||||||
|
label: body.label,
|
||||||
|
value: body.value,
|
||||||
|
sortOrder: body.sortOrder,
|
||||||
|
isEnabled: body.isEnabled ?? true,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
this.logger.log(`Created customer service contact: id=${contact.id}, type=${contact.type}, label=${contact.label}`)
|
||||||
|
return this.toDto(contact)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Put(':id')
|
||||||
|
@ApiBearerAuth()
|
||||||
|
@HttpCode(HttpStatus.OK)
|
||||||
|
@ApiOperation({ summary: '更新客服联系方式' })
|
||||||
|
async update(
|
||||||
|
@Param('id') id: string,
|
||||||
|
@Body() body: UpdateContactDto,
|
||||||
|
): Promise<ContactResponseDto> {
|
||||||
|
const existing = await this.prisma.customerServiceContact.findUnique({ where: { id } })
|
||||||
|
if (!existing) {
|
||||||
|
throw new NotFoundException('Contact not found')
|
||||||
|
}
|
||||||
|
|
||||||
|
const data: Record<string, unknown> = {}
|
||||||
|
if (body.type !== undefined) {
|
||||||
|
const contactType = body.type as ContactType
|
||||||
|
if (!Object.values(ContactType).includes(contactType)) {
|
||||||
|
throw new BadRequestException(`Invalid type: ${body.type}`)
|
||||||
|
}
|
||||||
|
data.type = contactType
|
||||||
|
}
|
||||||
|
if (body.label !== undefined) data.label = body.label
|
||||||
|
if (body.value !== undefined) data.value = body.value
|
||||||
|
if (body.sortOrder !== undefined) data.sortOrder = body.sortOrder
|
||||||
|
if (body.isEnabled !== undefined) data.isEnabled = body.isEnabled
|
||||||
|
|
||||||
|
const updated = await this.prisma.customerServiceContact.update({
|
||||||
|
where: { id },
|
||||||
|
data,
|
||||||
|
})
|
||||||
|
|
||||||
|
return this.toDto(updated)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Delete(':id')
|
||||||
|
@ApiBearerAuth()
|
||||||
|
@HttpCode(HttpStatus.NO_CONTENT)
|
||||||
|
@ApiOperation({ summary: '删除客服联系方式' })
|
||||||
|
async delete(@Param('id') id: string): Promise<void> {
|
||||||
|
const existing = await this.prisma.customerServiceContact.findUnique({ where: { id } })
|
||||||
|
if (!existing) {
|
||||||
|
throw new NotFoundException('Contact not found')
|
||||||
|
}
|
||||||
|
await this.prisma.customerServiceContact.delete({ where: { id } })
|
||||||
|
this.logger.log(`Deleted customer service contact: id=${id}, type=${existing.type}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
private toDto(contact: {
|
||||||
|
id: string
|
||||||
|
type: ContactType
|
||||||
|
label: string
|
||||||
|
value: string
|
||||||
|
sortOrder: number
|
||||||
|
isEnabled: boolean
|
||||||
|
createdAt: Date
|
||||||
|
updatedAt: Date
|
||||||
|
}): ContactResponseDto {
|
||||||
|
return {
|
||||||
|
id: contact.id,
|
||||||
|
type: contact.type,
|
||||||
|
label: contact.label,
|
||||||
|
value: contact.value,
|
||||||
|
sortOrder: contact.sortOrder,
|
||||||
|
isEnabled: contact.isEnabled,
|
||||||
|
createdAt: contact.createdAt,
|
||||||
|
updatedAt: contact.updatedAt,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// Public Controller (移动端调用,无需认证)
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
@ApiTags('Customer Service Contacts (Public)')
|
||||||
|
@Controller('customer-service-contacts')
|
||||||
|
export class PublicCustomerServiceContactController {
|
||||||
|
constructor(private readonly prisma: PrismaService) {}
|
||||||
|
|
||||||
|
@Get()
|
||||||
|
@ApiOperation({ summary: '获取已启用的客服联系方式 (移动端)' })
|
||||||
|
async list(): Promise<ContactResponseDto[]> {
|
||||||
|
const contacts = await this.prisma.customerServiceContact.findMany({
|
||||||
|
where: { isEnabled: true },
|
||||||
|
orderBy: [{ sortOrder: 'asc' }],
|
||||||
|
})
|
||||||
|
|
||||||
|
return contacts.map((c) => ({
|
||||||
|
id: c.id,
|
||||||
|
type: c.type,
|
||||||
|
label: c.label,
|
||||||
|
value: c.value,
|
||||||
|
sortOrder: c.sortOrder,
|
||||||
|
isEnabled: c.isEnabled,
|
||||||
|
createdAt: c.createdAt,
|
||||||
|
updatedAt: c.updatedAt,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -74,6 +74,7 @@ export class AdminNotificationController {
|
||||||
targetConfig,
|
targetConfig,
|
||||||
imageUrl: dto.imageUrl,
|
imageUrl: dto.imageUrl,
|
||||||
linkUrl: dto.linkUrl,
|
linkUrl: dto.linkUrl,
|
||||||
|
requiresForceRead: dto.requiresForceRead,
|
||||||
publishedAt: dto.publishedAt ? new Date(dto.publishedAt) : null,
|
publishedAt: dto.publishedAt ? new Date(dto.publishedAt) : null,
|
||||||
expiresAt: dto.expiresAt ? new Date(dto.expiresAt) : null,
|
expiresAt: dto.expiresAt ? new Date(dto.expiresAt) : null,
|
||||||
createdBy: 'admin', // TODO: 从认证信息获取
|
createdBy: 'admin', // TODO: 从认证信息获取
|
||||||
|
|
@ -149,6 +150,7 @@ export class AdminNotificationController {
|
||||||
imageUrl: dto.imageUrl,
|
imageUrl: dto.imageUrl,
|
||||||
linkUrl: dto.linkUrl,
|
linkUrl: dto.linkUrl,
|
||||||
isEnabled: dto.isEnabled,
|
isEnabled: dto.isEnabled,
|
||||||
|
requiresForceRead: dto.requiresForceRead,
|
||||||
publishedAt: dto.publishedAt !== undefined
|
publishedAt: dto.publishedAt !== undefined
|
||||||
? dto.publishedAt
|
? dto.publishedAt
|
||||||
? new Date(dto.publishedAt)
|
? new Date(dto.publishedAt)
|
||||||
|
|
|
||||||
|
|
@ -173,7 +173,7 @@ export class AdminSystemConfigController {
|
||||||
* 移动端/公开系统配置控制器
|
* 移动端/公开系统配置控制器
|
||||||
* 用于 mobile-app 获取展示相关的配置
|
* 用于 mobile-app 获取展示相关的配置
|
||||||
*/
|
*/
|
||||||
@Controller('api/v1/system-config')
|
@Controller('system-config')
|
||||||
export class PublicSystemConfigController {
|
export class PublicSystemConfigController {
|
||||||
constructor(
|
constructor(
|
||||||
@Inject(SYSTEM_CONFIG_REPOSITORY)
|
@Inject(SYSTEM_CONFIG_REPOSITORY)
|
||||||
|
|
|
||||||
|
|
@ -26,6 +26,7 @@ import {
|
||||||
IUserDetailQueryRepository,
|
IUserDetailQueryRepository,
|
||||||
USER_DETAIL_QUERY_REPOSITORY,
|
USER_DETAIL_QUERY_REPOSITORY,
|
||||||
} from '../../domain/repositories/user-detail-query.repository';
|
} from '../../domain/repositories/user-detail-query.repository';
|
||||||
|
import { ReferralProxyService } from '../../referral/referral-proxy.service';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 用户详情控制器
|
* 用户详情控制器
|
||||||
|
|
@ -40,6 +41,7 @@ export class UserDetailController {
|
||||||
private readonly userQueryRepository: IUserQueryRepository,
|
private readonly userQueryRepository: IUserQueryRepository,
|
||||||
@Inject(USER_DETAIL_QUERY_REPOSITORY)
|
@Inject(USER_DETAIL_QUERY_REPOSITORY)
|
||||||
private readonly userDetailRepository: IUserDetailQueryRepository,
|
private readonly userDetailRepository: IUserDetailQueryRepository,
|
||||||
|
private readonly referralProxyService: ReferralProxyService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -58,11 +60,12 @@ export class UserDetailController {
|
||||||
}
|
}
|
||||||
|
|
||||||
// 并行获取所有相关数据
|
// 并行获取所有相关数据
|
||||||
const [referralInfo, personalAdoptions, teamStats, directReferralCount] = await Promise.all([
|
const [referralInfo, personalAdoptions, teamStats, directReferralCount, prePlantingStats] = await Promise.all([
|
||||||
this.userDetailRepository.getReferralInfo(accountSequence),
|
this.userDetailRepository.getReferralInfo(accountSequence),
|
||||||
this.userDetailRepository.getPersonalAdoptionCount(accountSequence),
|
this.userDetailRepository.getPersonalAdoptionCount(accountSequence),
|
||||||
this.userDetailRepository.getTeamStats(accountSequence),
|
this.userDetailRepository.getTeamStats(accountSequence),
|
||||||
this.userDetailRepository.getDirectReferralCount(accountSequence),
|
this.userDetailRepository.getDirectReferralCount(accountSequence),
|
||||||
|
this.referralProxyService.getPrePlantingStats(accountSequence),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
// 获取推荐人昵称
|
// 获取推荐人昵称
|
||||||
|
|
@ -87,6 +90,8 @@ export class UserDetailController {
|
||||||
registeredAt: user.registeredAt.toISOString(),
|
registeredAt: user.registeredAt.toISOString(),
|
||||||
lastActiveAt: user.lastActiveAt?.toISOString() || null,
|
lastActiveAt: user.lastActiveAt?.toISOString() || null,
|
||||||
personalAdoptions: personalAdoptions,
|
personalAdoptions: personalAdoptions,
|
||||||
|
selfPrePlantingPortions: prePlantingStats.selfPrePlantingPortions,
|
||||||
|
teamPrePlantingPortions: prePlantingStats.teamPrePlantingPortions,
|
||||||
teamAddresses: teamStats.teamAddressCount,
|
teamAddresses: teamStats.teamAddressCount,
|
||||||
teamAdoptions: teamStats.teamAdoptionCount,
|
teamAdoptions: teamStats.teamAdoptionCount,
|
||||||
provincialAdoptions: {
|
provincialAdoptions: {
|
||||||
|
|
@ -133,11 +138,12 @@ export class UserDetailController {
|
||||||
}
|
}
|
||||||
|
|
||||||
// 获取引荐信息和实时统计
|
// 获取引荐信息和实时统计
|
||||||
const [referralInfo, personalAdoptionCount, directReferralCount, teamStats] = await Promise.all([
|
const [referralInfo, personalAdoptionCount, directReferralCount, teamStats, prePlantingStats] = await Promise.all([
|
||||||
this.userDetailRepository.getReferralInfo(accountSequence),
|
this.userDetailRepository.getReferralInfo(accountSequence),
|
||||||
this.userDetailRepository.getPersonalAdoptionCount(accountSequence),
|
this.userDetailRepository.getPersonalAdoptionCount(accountSequence),
|
||||||
this.userDetailRepository.getDirectReferralCount(accountSequence),
|
this.userDetailRepository.getDirectReferralCount(accountSequence),
|
||||||
this.userDetailRepository.getBatchUserStats([accountSequence]),
|
this.userDetailRepository.getBatchUserStats([accountSequence]),
|
||||||
|
this.referralProxyService.getPrePlantingStats(accountSequence),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const currentUserStats = teamStats.get(accountSequence);
|
const currentUserStats = teamStats.get(accountSequence);
|
||||||
|
|
@ -148,6 +154,8 @@ export class UserDetailController {
|
||||||
avatar: user.avatarUrl,
|
avatar: user.avatarUrl,
|
||||||
personalAdoptions: personalAdoptionCount,
|
personalAdoptions: personalAdoptionCount,
|
||||||
teamAdoptions: currentUserStats?.teamAdoptionCount || 0,
|
teamAdoptions: currentUserStats?.teamAdoptionCount || 0,
|
||||||
|
selfPrePlantingPortions: prePlantingStats.selfPrePlantingPortions,
|
||||||
|
teamPrePlantingPortions: prePlantingStats.teamPrePlantingPortions,
|
||||||
depth: referralInfo?.depth || 0,
|
depth: referralInfo?.depth || 0,
|
||||||
directReferralCount: directReferralCount,
|
directReferralCount: directReferralCount,
|
||||||
isCurrentUser: true,
|
isCurrentUser: true,
|
||||||
|
|
@ -156,34 +164,56 @@ export class UserDetailController {
|
||||||
let ancestors: ReferralNodeDto[] = [];
|
let ancestors: ReferralNodeDto[] = [];
|
||||||
let directReferrals: ReferralNodeDto[] = [];
|
let directReferrals: ReferralNodeDto[] = [];
|
||||||
|
|
||||||
|
// 收集所有需要查预种的 accountSequences
|
||||||
|
const allNodeSeqs: string[] = [];
|
||||||
|
|
||||||
// 向上查询
|
// 向上查询
|
||||||
|
let ancestorNodes: typeof ancestors extends (infer T)[] ? any[] : never = [];
|
||||||
if (query.direction === 'up' || query.direction === 'both') {
|
if (query.direction === 'up' || query.direction === 'both') {
|
||||||
const ancestorNodes = await this.userDetailRepository.getAncestors(
|
ancestorNodes = await this.userDetailRepository.getAncestors(
|
||||||
accountSequence,
|
accountSequence,
|
||||||
query.depth || 1,
|
query.depth || 1,
|
||||||
);
|
);
|
||||||
ancestors = ancestorNodes.map((node) => ({
|
allNodeSeqs.push(...ancestorNodes.map((n: any) => n.accountSequence));
|
||||||
|
}
|
||||||
|
|
||||||
|
// 向下查询
|
||||||
|
let referralNodes: typeof directReferrals extends (infer T)[] ? any[] : never = [];
|
||||||
|
if (query.direction === 'down' || query.direction === 'both') {
|
||||||
|
referralNodes = await this.userDetailRepository.getDirectReferrals(accountSequence);
|
||||||
|
allNodeSeqs.push(...referralNodes.map((n: any) => n.accountSequence));
|
||||||
|
}
|
||||||
|
|
||||||
|
// 批量获取所有节点的预种统计
|
||||||
|
const batchPrePlanting = allNodeSeqs.length > 0
|
||||||
|
? await this.referralProxyService.batchGetPrePlantingStats(allNodeSeqs)
|
||||||
|
: {};
|
||||||
|
|
||||||
|
if (ancestorNodes.length > 0) {
|
||||||
|
ancestors = ancestorNodes.map((node: any) => ({
|
||||||
accountSequence: node.accountSequence,
|
accountSequence: node.accountSequence,
|
||||||
userId: node.userId.toString(),
|
userId: node.userId.toString(),
|
||||||
nickname: node.nickname,
|
nickname: node.nickname,
|
||||||
avatar: node.avatarUrl,
|
avatar: node.avatarUrl,
|
||||||
personalAdoptions: node.personalAdoptionCount,
|
personalAdoptions: node.personalAdoptionCount,
|
||||||
teamAdoptions: node.teamAdoptionCount,
|
teamAdoptions: node.teamAdoptionCount,
|
||||||
|
selfPrePlantingPortions: batchPrePlanting[node.accountSequence]?.selfPrePlantingPortions ?? 0,
|
||||||
|
teamPrePlantingPortions: batchPrePlanting[node.accountSequence]?.teamPrePlantingPortions ?? 0,
|
||||||
depth: node.depth,
|
depth: node.depth,
|
||||||
directReferralCount: node.directReferralCount,
|
directReferralCount: node.directReferralCount,
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
// 向下查询
|
if (referralNodes.length > 0) {
|
||||||
if (query.direction === 'down' || query.direction === 'both') {
|
directReferrals = referralNodes.map((node: any) => ({
|
||||||
const referralNodes = await this.userDetailRepository.getDirectReferrals(accountSequence);
|
|
||||||
directReferrals = referralNodes.map((node) => ({
|
|
||||||
accountSequence: node.accountSequence,
|
accountSequence: node.accountSequence,
|
||||||
userId: node.userId.toString(),
|
userId: node.userId.toString(),
|
||||||
nickname: node.nickname,
|
nickname: node.nickname,
|
||||||
avatar: node.avatarUrl,
|
avatar: node.avatarUrl,
|
||||||
personalAdoptions: node.personalAdoptionCount,
|
personalAdoptions: node.personalAdoptionCount,
|
||||||
teamAdoptions: node.teamAdoptionCount,
|
teamAdoptions: node.teamAdoptionCount,
|
||||||
|
selfPrePlantingPortions: batchPrePlanting[node.accountSequence]?.selfPrePlantingPortions ?? 0,
|
||||||
|
teamPrePlantingPortions: batchPrePlanting[node.accountSequence]?.teamPrePlantingPortions ?? 0,
|
||||||
depth: node.depth,
|
depth: node.depth,
|
||||||
directReferralCount: node.directReferralCount,
|
directReferralCount: node.directReferralCount,
|
||||||
}));
|
}));
|
||||||
|
|
@ -371,6 +401,7 @@ export class UserDetailController {
|
||||||
monthlyTargetType: role.monthlyTargetType,
|
monthlyTargetType: role.monthlyTargetType,
|
||||||
lastAssessmentMonth: role.lastAssessmentMonth,
|
lastAssessmentMonth: role.lastAssessmentMonth,
|
||||||
monthlyTreesAdded: role.monthlyTreesAdded,
|
monthlyTreesAdded: role.monthlyTreesAdded,
|
||||||
|
officePhotoUrls: role.officePhotoUrls,
|
||||||
createdAt: role.createdAt.toISOString(),
|
createdAt: role.createdAt.toISOString(),
|
||||||
})),
|
})),
|
||||||
assessments: assessments.map((assessment) => ({
|
assessments: assessments.map((assessment) => ({
|
||||||
|
|
|
||||||
|
|
@ -21,6 +21,7 @@ import {
|
||||||
IUserDetailQueryRepository,
|
IUserDetailQueryRepository,
|
||||||
USER_DETAIL_QUERY_REPOSITORY,
|
USER_DETAIL_QUERY_REPOSITORY,
|
||||||
} from '../../domain/repositories/user-detail-query.repository';
|
} from '../../domain/repositories/user-detail-query.repository';
|
||||||
|
import { ReferralProxyService } from '../../referral/referral-proxy.service';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 用户管理控制器
|
* 用户管理控制器
|
||||||
|
|
@ -34,6 +35,7 @@ export class UserController {
|
||||||
private readonly userQueryRepository: IUserQueryRepository,
|
private readonly userQueryRepository: IUserQueryRepository,
|
||||||
@Inject(USER_DETAIL_QUERY_REPOSITORY)
|
@Inject(USER_DETAIL_QUERY_REPOSITORY)
|
||||||
private readonly userDetailRepository: IUserDetailQueryRepository,
|
private readonly userDetailRepository: IUserDetailQueryRepository,
|
||||||
|
private readonly referralProxyService: ReferralProxyService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -70,7 +72,10 @@ export class UserController {
|
||||||
|
|
||||||
// 批量获取实时统计数据
|
// 批量获取实时统计数据
|
||||||
const accountSequences = result.items.map(item => item.accountSequence);
|
const accountSequences = result.items.map(item => item.accountSequence);
|
||||||
const statsMap = await this.userDetailRepository.getBatchUserStats(accountSequences);
|
const [statsMap, prePlantingStatsMap] = await Promise.all([
|
||||||
|
this.userDetailRepository.getBatchUserStats(accountSequences),
|
||||||
|
this.referralProxyService.batchGetPrePlantingStats(accountSequences),
|
||||||
|
]);
|
||||||
|
|
||||||
// 获取所有用户的团队总认种数用于计算百分比(使用实时数据)
|
// 获取所有用户的团队总认种数用于计算百分比(使用实时数据)
|
||||||
let totalTeamAdoptions = 0;
|
let totalTeamAdoptions = 0;
|
||||||
|
|
@ -79,7 +84,7 @@ export class UserController {
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
items: result.items.map((item) => this.mapToListItem(item, totalTeamAdoptions, statsMap.get(item.accountSequence))),
|
items: result.items.map((item) => this.mapToListItem(item, totalTeamAdoptions, statsMap.get(item.accountSequence), prePlantingStatsMap[item.accountSequence])),
|
||||||
total: result.total,
|
total: result.total,
|
||||||
page: result.page,
|
page: result.page,
|
||||||
pageSize: result.pageSize,
|
pageSize: result.pageSize,
|
||||||
|
|
@ -157,6 +162,10 @@ export class UserController {
|
||||||
provinceAdoptionCount: number;
|
provinceAdoptionCount: number;
|
||||||
cityAdoptionCount: number;
|
cityAdoptionCount: number;
|
||||||
},
|
},
|
||||||
|
prePlantingStats?: {
|
||||||
|
selfPrePlantingPortions: number;
|
||||||
|
teamPrePlantingPortions: number;
|
||||||
|
},
|
||||||
): UserListItemDto {
|
): UserListItemDto {
|
||||||
// 使用实时统计数据(如果有),否则使用预计算数据
|
// 使用实时统计数据(如果有),否则使用预计算数据
|
||||||
const personalAdoptions = realTimeStats?.personalAdoptionCount ?? item.personalAdoptionCount;
|
const personalAdoptions = realTimeStats?.personalAdoptionCount ?? item.personalAdoptionCount;
|
||||||
|
|
@ -181,6 +190,8 @@ export class UserController {
|
||||||
nickname: item.nickname,
|
nickname: item.nickname,
|
||||||
phoneNumberMasked: item.phoneNumberMasked,
|
phoneNumberMasked: item.phoneNumberMasked,
|
||||||
personalAdoptions,
|
personalAdoptions,
|
||||||
|
selfPrePlantingPortions: prePlantingStats?.selfPrePlantingPortions ?? 0,
|
||||||
|
teamPrePlantingPortions: prePlantingStats?.teamPrePlantingPortions ?? 0,
|
||||||
teamAddresses,
|
teamAddresses,
|
||||||
teamAdoptions,
|
teamAdoptions,
|
||||||
provincialAdoptions: {
|
provincialAdoptions: {
|
||||||
|
|
|
||||||
|
|
@ -70,6 +70,10 @@ export class CreateNotificationDto {
|
||||||
@IsString()
|
@IsString()
|
||||||
linkUrl?: string;
|
linkUrl?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsBoolean()
|
||||||
|
requiresForceRead?: boolean;
|
||||||
|
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsDateString()
|
@IsDateString()
|
||||||
publishedAt?: string;
|
publishedAt?: string;
|
||||||
|
|
@ -120,6 +124,10 @@ export class UpdateNotificationDto {
|
||||||
@IsBoolean()
|
@IsBoolean()
|
||||||
isEnabled?: boolean;
|
isEnabled?: boolean;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsBoolean()
|
||||||
|
requiresForceRead?: boolean;
|
||||||
|
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsDateString()
|
@IsDateString()
|
||||||
publishedAt?: string;
|
publishedAt?: string;
|
||||||
|
|
|
||||||
|
|
@ -31,6 +31,7 @@ export class NotificationResponseDto {
|
||||||
imageUrl: string | null;
|
imageUrl: string | null;
|
||||||
linkUrl: string | null;
|
linkUrl: string | null;
|
||||||
isEnabled: boolean;
|
isEnabled: boolean;
|
||||||
|
requiresForceRead: boolean;
|
||||||
publishedAt: string | null;
|
publishedAt: string | null;
|
||||||
expiresAt: string | null;
|
expiresAt: string | null;
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
|
|
@ -47,6 +48,7 @@ export class NotificationResponseDto {
|
||||||
imageUrl: entity.imageUrl,
|
imageUrl: entity.imageUrl,
|
||||||
linkUrl: entity.linkUrl,
|
linkUrl: entity.linkUrl,
|
||||||
isEnabled: entity.isEnabled,
|
isEnabled: entity.isEnabled,
|
||||||
|
requiresForceRead: entity.requiresForceRead,
|
||||||
publishedAt: entity.publishedAt?.toISOString() ?? null,
|
publishedAt: entity.publishedAt?.toISOString() ?? null,
|
||||||
expiresAt: entity.expiresAt?.toISOString() ?? null,
|
expiresAt: entity.expiresAt?.toISOString() ?? null,
|
||||||
createdAt: entity.createdAt.toISOString(),
|
createdAt: entity.createdAt.toISOString(),
|
||||||
|
|
@ -68,6 +70,7 @@ export class UserNotificationResponseDto {
|
||||||
publishedAt: string | null;
|
publishedAt: string | null;
|
||||||
isRead: boolean;
|
isRead: boolean;
|
||||||
readAt: string | null;
|
readAt: string | null;
|
||||||
|
requiresForceRead: boolean;
|
||||||
|
|
||||||
static fromEntity(item: NotificationWithReadStatus): UserNotificationResponseDto {
|
static fromEntity(item: NotificationWithReadStatus): UserNotificationResponseDto {
|
||||||
return {
|
return {
|
||||||
|
|
@ -81,6 +84,7 @@ export class UserNotificationResponseDto {
|
||||||
publishedAt: item.notification.publishedAt?.toISOString() ?? null,
|
publishedAt: item.notification.publishedAt?.toISOString() ?? null,
|
||||||
isRead: item.isRead,
|
isRead: item.isRead,
|
||||||
readAt: item.readAt?.toISOString() ?? null,
|
readAt: item.readAt?.toISOString() ?? null,
|
||||||
|
requiresForceRead: item.notification.requiresForceRead,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -49,6 +49,10 @@ export class UserFullDetailDto {
|
||||||
percentage: number;
|
percentage: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// 预种统计
|
||||||
|
selfPrePlantingPortions!: number;
|
||||||
|
teamPrePlantingPortions!: number;
|
||||||
|
|
||||||
// 排名
|
// 排名
|
||||||
ranking!: number | null;
|
ranking!: number | null;
|
||||||
|
|
||||||
|
|
@ -70,6 +74,8 @@ export class ReferralNodeDto {
|
||||||
avatar!: string | null;
|
avatar!: string | null;
|
||||||
personalAdoptions!: number;
|
personalAdoptions!: number;
|
||||||
teamAdoptions!: number; // 团队认种量
|
teamAdoptions!: number; // 团队认种量
|
||||||
|
selfPrePlantingPortions!: number; // 个人预种份数
|
||||||
|
teamPrePlantingPortions!: number; // 团队预种份数
|
||||||
depth!: number;
|
depth!: number;
|
||||||
directReferralCount!: number;
|
directReferralCount!: number;
|
||||||
isCurrentUser?: boolean;
|
isCurrentUser?: boolean;
|
||||||
|
|
@ -215,6 +221,7 @@ export class AuthorizationRoleDto {
|
||||||
monthlyTargetType!: string;
|
monthlyTargetType!: string;
|
||||||
lastAssessmentMonth!: string | null;
|
lastAssessmentMonth!: string | null;
|
||||||
monthlyTreesAdded!: number;
|
monthlyTreesAdded!: number;
|
||||||
|
officePhotoUrls!: string[];
|
||||||
createdAt!: string;
|
createdAt!: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,8 @@ export class UserListItemDto {
|
||||||
nickname!: string | null;
|
nickname!: string | null;
|
||||||
phoneNumberMasked!: string | null;
|
phoneNumberMasked!: string | null;
|
||||||
personalAdoptions!: number;
|
personalAdoptions!: number;
|
||||||
|
selfPrePlantingPortions!: number;
|
||||||
|
teamPrePlantingPortions!: number;
|
||||||
teamAddresses!: number;
|
teamAddresses!: number;
|
||||||
teamAdoptions!: number;
|
teamAdoptions!: number;
|
||||||
provincialAdoptions!: {
|
provincialAdoptions!: {
|
||||||
|
|
|
||||||
|
|
@ -61,6 +61,7 @@ import { UserTagController } from './api/controllers/user-tag.controller';
|
||||||
import { ClassificationRuleController } from './api/controllers/classification-rule.controller';
|
import { ClassificationRuleController } from './api/controllers/classification-rule.controller';
|
||||||
import { AudienceSegmentController } from './api/controllers/audience-segment.controller';
|
import { AudienceSegmentController } from './api/controllers/audience-segment.controller';
|
||||||
import { AutoTagSyncJob } from './infrastructure/jobs/auto-tag-sync.job';
|
import { AutoTagSyncJob } from './infrastructure/jobs/auto-tag-sync.job';
|
||||||
|
import { ContractBatchDownloadJob } from './infrastructure/jobs/contract-batch-download.job';
|
||||||
// Co-Managed Wallet imports
|
// Co-Managed Wallet imports
|
||||||
import { CoManagedWalletController } from './api/controllers/co-managed-wallet.controller';
|
import { CoManagedWalletController } from './api/controllers/co-managed-wallet.controller';
|
||||||
import { CoManagedWalletService } from './application/services/co-managed-wallet.service';
|
import { CoManagedWalletService } from './application/services/co-managed-wallet.service';
|
||||||
|
|
@ -76,6 +77,27 @@ import { SYSTEM_MAINTENANCE_REPOSITORY } from './domain/repositories/system-main
|
||||||
import { SystemMaintenanceRepositoryImpl } from './infrastructure/persistence/repositories/system-maintenance.repository.impl';
|
import { SystemMaintenanceRepositoryImpl } from './infrastructure/persistence/repositories/system-maintenance.repository.impl';
|
||||||
import { AdminMaintenanceController, MobileMaintenanceController } from './api/controllers/system-maintenance.controller';
|
import { AdminMaintenanceController, MobileMaintenanceController } from './api/controllers/system-maintenance.controller';
|
||||||
import { MaintenanceInterceptor } from './api/interceptors/maintenance.interceptor';
|
import { MaintenanceInterceptor } from './api/interceptors/maintenance.interceptor';
|
||||||
|
// App Asset imports
|
||||||
|
import { AdminAppAssetController, PublicAppAssetController } from './api/controllers/app-asset.controller'
|
||||||
|
// Customer Service Contact imports
|
||||||
|
import { AdminCustomerServiceContactController, PublicCustomerServiceContactController } from './api/controllers/customer-service-contact.controller';
|
||||||
|
// [2026-02-05] 新增:合同管理模块
|
||||||
|
import { ContractController } from './api/controllers/contract.controller';
|
||||||
|
import { ContractService } from './application/services/contract.service';
|
||||||
|
// [2026-02-17] 新增:预种计划开关管理
|
||||||
|
import { PrePlantingConfigController, PublicPrePlantingConfigController } from './pre-planting/pre-planting-config.controller';
|
||||||
|
import { PrePlantingConfigService } from './pre-planting/pre-planting-config.service';
|
||||||
|
// [2026-02-27] 新增:预种计划数据代理(admin-service → planting-service 内部 HTTP)
|
||||||
|
import { PrePlantingProxyService } from './pre-planting/pre-planting-proxy.service';
|
||||||
|
// [2026-03-02] 新增:推荐链预种统计代理(admin-service → referral-service 内部 HTTP)
|
||||||
|
import { ReferralProxyService } from './referral/referral-proxy.service';
|
||||||
|
// [2026-03-02] 纯新增:授权自助申请照片代理(admin-service → authorization-service 内部 HTTP)
|
||||||
|
import { AuthorizationProxyService } from './authorization/authorization-proxy.service';
|
||||||
|
import { AuthorizationPhotosController } from './api/controllers/authorization-photos.controller';
|
||||||
|
// [2026-02-26] 新增:认种树定价配置(总部运营成本压力涨价)
|
||||||
|
import { AdminTreePricingController, PublicTreePricingController } from './pricing/tree-pricing.controller';
|
||||||
|
import { TreePricingService } from './pricing/tree-pricing.service';
|
||||||
|
import { AutoPriceIncreaseJob } from './infrastructure/jobs/auto-price-increase.job';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [
|
imports: [
|
||||||
|
|
@ -111,6 +133,22 @@ import { MaintenanceInterceptor } from './api/interceptors/maintenance.intercept
|
||||||
// System Maintenance Controllers
|
// System Maintenance Controllers
|
||||||
AdminMaintenanceController,
|
AdminMaintenanceController,
|
||||||
MobileMaintenanceController,
|
MobileMaintenanceController,
|
||||||
|
// App Asset Controllers
|
||||||
|
AdminAppAssetController,
|
||||||
|
PublicAppAssetController,
|
||||||
|
// Customer Service Contact Controllers
|
||||||
|
AdminCustomerServiceContactController,
|
||||||
|
PublicCustomerServiceContactController,
|
||||||
|
// [2026-02-05] 新增:合同管理控制器
|
||||||
|
ContractController,
|
||||||
|
// [2026-02-17] 新增:预种计划开关管理
|
||||||
|
PrePlantingConfigController,
|
||||||
|
PublicPrePlantingConfigController,
|
||||||
|
// [2026-02-26] 新增:认种树定价配置(总部运营成本压力涨价)
|
||||||
|
AdminTreePricingController,
|
||||||
|
PublicTreePricingController,
|
||||||
|
// [2026-03-02] 纯新增:自助申请照片管理
|
||||||
|
AuthorizationPhotosController,
|
||||||
],
|
],
|
||||||
providers: [
|
providers: [
|
||||||
PrismaService,
|
PrismaService,
|
||||||
|
|
@ -176,6 +214,7 @@ import { MaintenanceInterceptor } from './api/interceptors/maintenance.intercept
|
||||||
AudienceSegmentService,
|
AudienceSegmentService,
|
||||||
// Scheduled Jobs
|
// Scheduled Jobs
|
||||||
AutoTagSyncJob,
|
AutoTagSyncJob,
|
||||||
|
ContractBatchDownloadJob,
|
||||||
// Co-Managed Wallet
|
// Co-Managed Wallet
|
||||||
CoManagedWalletMapper,
|
CoManagedWalletMapper,
|
||||||
CoManagedWalletService,
|
CoManagedWalletService,
|
||||||
|
|
@ -197,6 +236,19 @@ import { MaintenanceInterceptor } from './api/interceptors/maintenance.intercept
|
||||||
provide: APP_INTERCEPTOR,
|
provide: APP_INTERCEPTOR,
|
||||||
useClass: MaintenanceInterceptor,
|
useClass: MaintenanceInterceptor,
|
||||||
},
|
},
|
||||||
|
// [2026-02-05] 新增:合同管理服务
|
||||||
|
ContractService,
|
||||||
|
// [2026-02-17] 新增:预种计划开关管理
|
||||||
|
PrePlantingConfigService,
|
||||||
|
// [2026-02-27] 新增:预种计划数据代理
|
||||||
|
PrePlantingProxyService,
|
||||||
|
// [2026-03-02] 新增:推荐链预种统计代理
|
||||||
|
ReferralProxyService,
|
||||||
|
// [2026-03-02] 纯新增:授权自助申请照片代理
|
||||||
|
AuthorizationProxyService,
|
||||||
|
// [2026-02-26] 新增:认种树定价配置(总部运营成本压力涨价)
|
||||||
|
TreePricingService,
|
||||||
|
AutoPriceIncreaseJob,
|
||||||
],
|
],
|
||||||
})
|
})
|
||||||
export class AppModule {}
|
export class AppModule {}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,203 @@
|
||||||
|
/**
|
||||||
|
* 合同管理服务
|
||||||
|
* [2026-02-05] 新增:调用 planting-service 内部 API 获取合同数据
|
||||||
|
* 回滚方式:删除此文件并从 app.module.ts 中移除引用
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { Injectable, Logger } from '@nestjs/common';
|
||||||
|
import { ConfigService } from '@nestjs/config';
|
||||||
|
import axios, { AxiosInstance } from 'axios';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 合同 DTO
|
||||||
|
*/
|
||||||
|
export interface ContractDto {
|
||||||
|
orderNo: string;
|
||||||
|
contractNo: string;
|
||||||
|
userId: string;
|
||||||
|
accountSequence: string;
|
||||||
|
userRealName: string | null;
|
||||||
|
userPhoneNumber: string | null;
|
||||||
|
treeCount: number;
|
||||||
|
totalAmount: number;
|
||||||
|
provinceCode: string;
|
||||||
|
provinceName: string;
|
||||||
|
cityCode: string;
|
||||||
|
cityName: string;
|
||||||
|
status: string;
|
||||||
|
signedAt: string | null;
|
||||||
|
signedPdfUrl: string | null;
|
||||||
|
createdAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 合同列表响应
|
||||||
|
*/
|
||||||
|
export interface ContractsListResponse {
|
||||||
|
items: ContractDto[];
|
||||||
|
total: number;
|
||||||
|
page: number;
|
||||||
|
pageSize: number;
|
||||||
|
totalPages: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 合同统计响应
|
||||||
|
*/
|
||||||
|
export interface ContractStatisticsResponse {
|
||||||
|
totalContracts: number;
|
||||||
|
signedContracts: number;
|
||||||
|
pendingContracts: number;
|
||||||
|
expiredContracts: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 合同查询参数
|
||||||
|
*/
|
||||||
|
export interface ContractQueryParams {
|
||||||
|
accountSequences?: string[];
|
||||||
|
signedAfter?: string;
|
||||||
|
signedBefore?: string;
|
||||||
|
provinceCode?: string;
|
||||||
|
cityCode?: string;
|
||||||
|
status?: string;
|
||||||
|
page?: number;
|
||||||
|
pageSize?: number;
|
||||||
|
orderBy?: 'signedAt' | 'createdAt';
|
||||||
|
orderDir?: 'asc' | 'desc';
|
||||||
|
}
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class ContractService {
|
||||||
|
private readonly logger = new Logger(ContractService.name);
|
||||||
|
private readonly httpClient: AxiosInstance;
|
||||||
|
private readonly plantingServiceUrl: string;
|
||||||
|
|
||||||
|
constructor(private readonly configService: ConfigService) {
|
||||||
|
this.plantingServiceUrl = this.configService.get<string>(
|
||||||
|
'PLANTING_SERVICE_URL',
|
||||||
|
'http://rwa-planting-service:3003',
|
||||||
|
);
|
||||||
|
|
||||||
|
this.httpClient = axios.create({
|
||||||
|
baseURL: this.plantingServiceUrl,
|
||||||
|
timeout: 30000,
|
||||||
|
});
|
||||||
|
|
||||||
|
this.logger.log(`ContractService initialized, planting-service URL: ${this.plantingServiceUrl}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取合同列表
|
||||||
|
*/
|
||||||
|
async getContracts(params: ContractQueryParams): Promise<ContractsListResponse> {
|
||||||
|
try {
|
||||||
|
const queryParams = new URLSearchParams();
|
||||||
|
|
||||||
|
if (params.accountSequences?.length) {
|
||||||
|
queryParams.append('accountSequences', params.accountSequences.join(','));
|
||||||
|
}
|
||||||
|
if (params.signedAfter) queryParams.append('signedAfter', params.signedAfter);
|
||||||
|
if (params.signedBefore) queryParams.append('signedBefore', params.signedBefore);
|
||||||
|
if (params.provinceCode) queryParams.append('provinceCode', params.provinceCode);
|
||||||
|
if (params.cityCode) queryParams.append('cityCode', params.cityCode);
|
||||||
|
if (params.status) queryParams.append('status', params.status);
|
||||||
|
if (params.page) queryParams.append('page', params.page.toString());
|
||||||
|
if (params.pageSize) queryParams.append('pageSize', params.pageSize.toString());
|
||||||
|
if (params.orderBy) queryParams.append('orderBy', params.orderBy);
|
||||||
|
if (params.orderDir) queryParams.append('orderDir', params.orderDir);
|
||||||
|
|
||||||
|
const url = `/api/v1/planting/internal/contracts?${queryParams.toString()}`;
|
||||||
|
this.logger.debug(`[getContracts] 请求: ${url}`);
|
||||||
|
|
||||||
|
const response = await this.httpClient.get<ContractsListResponse>(url);
|
||||||
|
return response.data;
|
||||||
|
} catch (error) {
|
||||||
|
this.logger.error(`[getContracts] 失败: ${error.message}`);
|
||||||
|
return {
|
||||||
|
items: [],
|
||||||
|
total: 0,
|
||||||
|
page: params.page ?? 1,
|
||||||
|
pageSize: params.pageSize ?? 50,
|
||||||
|
totalPages: 0,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取用户的合同列表
|
||||||
|
*/
|
||||||
|
async getUserContracts(accountSequence: string, params?: {
|
||||||
|
page?: number;
|
||||||
|
pageSize?: number;
|
||||||
|
}): Promise<ContractsListResponse> {
|
||||||
|
return this.getContracts({
|
||||||
|
accountSequences: [accountSequence],
|
||||||
|
page: params?.page,
|
||||||
|
pageSize: params?.pageSize,
|
||||||
|
status: undefined, // 查询所有状态
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取单个合同详情
|
||||||
|
*/
|
||||||
|
async getContract(orderNo: string): Promise<ContractDto | null> {
|
||||||
|
try {
|
||||||
|
const url = `/api/v1/planting/internal/contracts/${orderNo}`;
|
||||||
|
this.logger.debug(`[getContract] 请求: ${url}`);
|
||||||
|
|
||||||
|
const response = await this.httpClient.get<ContractDto>(url);
|
||||||
|
return response.data;
|
||||||
|
} catch (error) {
|
||||||
|
if (error.response?.status === 404) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
this.logger.error(`[getContract] 失败: ${error.message}`);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 下载合同 PDF
|
||||||
|
* @returns PDF Buffer
|
||||||
|
*/
|
||||||
|
async downloadContractPdf(orderNo: string): Promise<Buffer> {
|
||||||
|
const url = `/api/v1/planting/internal/contracts/${orderNo}/pdf`;
|
||||||
|
this.logger.debug(`[downloadContractPdf] 请求: ${url}`);
|
||||||
|
|
||||||
|
const response = await this.httpClient.get(url, {
|
||||||
|
responseType: 'arraybuffer',
|
||||||
|
});
|
||||||
|
|
||||||
|
return Buffer.from(response.data);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取合同统计信息
|
||||||
|
*/
|
||||||
|
async getStatistics(params?: {
|
||||||
|
provinceCode?: string;
|
||||||
|
cityCode?: string;
|
||||||
|
}): Promise<ContractStatisticsResponse> {
|
||||||
|
try {
|
||||||
|
const queryParams = new URLSearchParams();
|
||||||
|
if (params?.provinceCode) queryParams.append('provinceCode', params.provinceCode);
|
||||||
|
if (params?.cityCode) queryParams.append('cityCode', params.cityCode);
|
||||||
|
|
||||||
|
const url = `/api/v1/planting/internal/contracts/statistics?${queryParams.toString()}`;
|
||||||
|
this.logger.debug(`[getStatistics] 请求: ${url}`);
|
||||||
|
|
||||||
|
const response = await this.httpClient.get<ContractStatisticsResponse>(url);
|
||||||
|
return response.data;
|
||||||
|
} catch (error) {
|
||||||
|
this.logger.error(`[getStatistics] 失败: ${error.message}`);
|
||||||
|
return {
|
||||||
|
totalContracts: 0,
|
||||||
|
signedContracts: 0,
|
||||||
|
pendingContracts: 0,
|
||||||
|
expiredContracts: 0,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,122 @@
|
||||||
|
/**
|
||||||
|
* 授权服务代理 — 自助申请照片查询
|
||||||
|
* [2026-03-02] 纯新增:通过内部 HTTP 调用 authorization-service 获取自助申请照片
|
||||||
|
*
|
||||||
|
* === 架构 ===
|
||||||
|
* admin-web → admin-service (本服务) → authorization-service /authorization/self-apply-photos
|
||||||
|
* 复用 ReferralProxyService 的 axios 代理模式
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { Injectable, Logger } from '@nestjs/common';
|
||||||
|
import { ConfigService } from '@nestjs/config';
|
||||||
|
import axios, { AxiosInstance } from 'axios';
|
||||||
|
import { PrismaService } from '../infrastructure/persistence/prisma/prisma.service';
|
||||||
|
|
||||||
|
export interface SelfApplyPhotoItem {
|
||||||
|
id: string;
|
||||||
|
accountSequence: string;
|
||||||
|
nickname: string;
|
||||||
|
avatar: string | null;
|
||||||
|
roleType: string;
|
||||||
|
regionName: string;
|
||||||
|
status: string;
|
||||||
|
officePhotoUrls: string[];
|
||||||
|
createdAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SelfApplyPhotosResponse {
|
||||||
|
items: SelfApplyPhotoItem[];
|
||||||
|
total: number;
|
||||||
|
page: number;
|
||||||
|
limit: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class AuthorizationProxyService {
|
||||||
|
private readonly logger = new Logger(AuthorizationProxyService.name);
|
||||||
|
private readonly httpClient: AxiosInstance;
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
private readonly configService: ConfigService,
|
||||||
|
private readonly prisma: PrismaService,
|
||||||
|
) {
|
||||||
|
const authorizationServiceUrl = this.configService.get<string>(
|
||||||
|
'AUTHORIZATION_SERVICE_URL',
|
||||||
|
'http://rwa-authorization-service:3009',
|
||||||
|
);
|
||||||
|
|
||||||
|
this.httpClient = axios.create({
|
||||||
|
baseURL: authorizationServiceUrl,
|
||||||
|
timeout: 30000,
|
||||||
|
});
|
||||||
|
|
||||||
|
this.logger.log(
|
||||||
|
`AuthorizationProxyService initialized, authorization-service URL: ${authorizationServiceUrl}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取自助申请照片列表(含用户昵称/头像补充)
|
||||||
|
*/
|
||||||
|
async getSelfApplyPhotos(params: {
|
||||||
|
page?: number;
|
||||||
|
limit?: number;
|
||||||
|
roleType?: string;
|
||||||
|
}): Promise<SelfApplyPhotosResponse> {
|
||||||
|
const { page = 1, limit = 20, roleType } = params;
|
||||||
|
|
||||||
|
try {
|
||||||
|
// 1. 从 authorization-service 获取有照片的授权记录
|
||||||
|
const queryParams = new URLSearchParams();
|
||||||
|
queryParams.set('page', String(page));
|
||||||
|
queryParams.set('limit', String(limit));
|
||||||
|
if (roleType) {
|
||||||
|
queryParams.set('roleType', roleType);
|
||||||
|
}
|
||||||
|
|
||||||
|
const url = `/api/v1/authorization/self-apply-photos?${queryParams.toString()}`;
|
||||||
|
this.logger.debug(`[getSelfApplyPhotos] 请求: ${url}`);
|
||||||
|
const response = await this.httpClient.get(url);
|
||||||
|
// authorization-service 全局拦截器将响应包装为 {success, data, timestamp}
|
||||||
|
// 实际数据在 response.data.data 中
|
||||||
|
const raw = response.data;
|
||||||
|
const data = raw?.data ?? raw;
|
||||||
|
|
||||||
|
if (!data?.items?.length) {
|
||||||
|
return { items: [], total: data?.total ?? 0, page, limit };
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. 批量查 user_query_view 补充 nickname + avatarUrl
|
||||||
|
const accountSequences = data.items.map((item: any) => item.accountSequence);
|
||||||
|
const users = await this.prisma.userQueryView.findMany({
|
||||||
|
where: { accountSequence: { in: accountSequences } },
|
||||||
|
select: { accountSequence: true, nickname: true, avatarUrl: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
const userMap = new Map(
|
||||||
|
users.map((u) => [u.accountSequence, { nickname: u.nickname, avatar: u.avatarUrl }]),
|
||||||
|
);
|
||||||
|
|
||||||
|
// 3. 合并数据
|
||||||
|
const items: SelfApplyPhotoItem[] = data.items.map((item: any) => {
|
||||||
|
const user = userMap.get(item.accountSequence);
|
||||||
|
return {
|
||||||
|
id: item.id,
|
||||||
|
accountSequence: item.accountSequence,
|
||||||
|
nickname: user?.nickname ?? item.accountSequence,
|
||||||
|
avatar: user?.avatar ?? null,
|
||||||
|
roleType: item.roleType,
|
||||||
|
regionName: item.regionName,
|
||||||
|
status: item.status,
|
||||||
|
officePhotoUrls: item.officePhotoUrls ?? [],
|
||||||
|
createdAt: item.createdAt,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
return { items, total: data.total, page, limit };
|
||||||
|
} catch (error) {
|
||||||
|
this.logger.error(`[getSelfApplyPhotos] 失败: ${error.message}`);
|
||||||
|
return { items: [], total: 0, page, limit };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -23,6 +23,8 @@ export class NotificationEntity {
|
||||||
public readonly imageUrl: string | null,
|
public readonly imageUrl: string | null,
|
||||||
public readonly linkUrl: string | null,
|
public readonly linkUrl: string | null,
|
||||||
public readonly isEnabled: boolean,
|
public readonly isEnabled: boolean,
|
||||||
|
/** 是否需要强制弹窗阅读(由管理员创建时配置) */
|
||||||
|
public readonly requiresForceRead: boolean,
|
||||||
public readonly publishedAt: Date | null,
|
public readonly publishedAt: Date | null,
|
||||||
public readonly expiresAt: Date | null,
|
public readonly expiresAt: Date | null,
|
||||||
public readonly createdAt: Date,
|
public readonly createdAt: Date,
|
||||||
|
|
@ -99,6 +101,7 @@ export class NotificationEntity {
|
||||||
targetConfig?: NotificationTarget | null;
|
targetConfig?: NotificationTarget | null;
|
||||||
imageUrl?: string | null;
|
imageUrl?: string | null;
|
||||||
linkUrl?: string | null;
|
linkUrl?: string | null;
|
||||||
|
requiresForceRead?: boolean;
|
||||||
publishedAt?: Date | null;
|
publishedAt?: Date | null;
|
||||||
expiresAt?: Date | null;
|
expiresAt?: Date | null;
|
||||||
createdBy: string;
|
createdBy: string;
|
||||||
|
|
@ -128,6 +131,7 @@ export class NotificationEntity {
|
||||||
params.imageUrl ?? null,
|
params.imageUrl ?? null,
|
||||||
params.linkUrl ?? null,
|
params.linkUrl ?? null,
|
||||||
true,
|
true,
|
||||||
|
params.requiresForceRead ?? false,
|
||||||
params.publishedAt ?? null,
|
params.publishedAt ?? null,
|
||||||
params.expiresAt ?? null,
|
params.expiresAt ?? null,
|
||||||
now,
|
now,
|
||||||
|
|
@ -149,6 +153,7 @@ export class NotificationEntity {
|
||||||
imageUrl?: string | null;
|
imageUrl?: string | null;
|
||||||
linkUrl?: string | null;
|
linkUrl?: string | null;
|
||||||
isEnabled?: boolean;
|
isEnabled?: boolean;
|
||||||
|
requiresForceRead?: boolean;
|
||||||
publishedAt?: Date | null;
|
publishedAt?: Date | null;
|
||||||
expiresAt?: Date | null;
|
expiresAt?: Date | null;
|
||||||
}): NotificationEntity {
|
}): NotificationEntity {
|
||||||
|
|
@ -163,6 +168,7 @@ export class NotificationEntity {
|
||||||
params.imageUrl !== undefined ? params.imageUrl : this.imageUrl,
|
params.imageUrl !== undefined ? params.imageUrl : this.imageUrl,
|
||||||
params.linkUrl !== undefined ? params.linkUrl : this.linkUrl,
|
params.linkUrl !== undefined ? params.linkUrl : this.linkUrl,
|
||||||
params.isEnabled ?? this.isEnabled,
|
params.isEnabled ?? this.isEnabled,
|
||||||
|
params.requiresForceRead ?? this.requiresForceRead,
|
||||||
params.publishedAt !== undefined ? params.publishedAt : this.publishedAt,
|
params.publishedAt !== undefined ? params.publishedAt : this.publishedAt,
|
||||||
params.expiresAt !== undefined ? params.expiresAt : this.expiresAt,
|
params.expiresAt !== undefined ? params.expiresAt : this.expiresAt,
|
||||||
this.createdAt,
|
this.createdAt,
|
||||||
|
|
|
||||||
|
|
@ -138,6 +138,7 @@ export interface AuthorizationRole {
|
||||||
monthlyTargetType: string;
|
monthlyTargetType: string;
|
||||||
lastAssessmentMonth: string | null;
|
lastAssessmentMonth: string | null;
|
||||||
monthlyTreesAdded: number;
|
monthlyTreesAdded: number;
|
||||||
|
officePhotoUrls: string[];
|
||||||
createdAt: Date;
|
createdAt: Date;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,42 @@
|
||||||
|
import { Injectable, Logger, OnModuleInit } from '@nestjs/common';
|
||||||
|
import { Cron } from '@nestjs/schedule';
|
||||||
|
import { TreePricingService } from '../../pricing/tree-pricing.service';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 自动涨价定时任务
|
||||||
|
*
|
||||||
|
* 每小时检查一次是否到达自动涨价时间。
|
||||||
|
* 涨价间隔以天为单位,小时级精度足够。
|
||||||
|
* 涨价原因:总部运营成本压力。
|
||||||
|
*/
|
||||||
|
@Injectable()
|
||||||
|
export class AutoPriceIncreaseJob implements OnModuleInit {
|
||||||
|
private readonly logger = new Logger(AutoPriceIncreaseJob.name);
|
||||||
|
private isRunning = false;
|
||||||
|
|
||||||
|
constructor(private readonly pricingService: TreePricingService) {}
|
||||||
|
|
||||||
|
onModuleInit() {
|
||||||
|
this.logger.log('AutoPriceIncreaseJob initialized');
|
||||||
|
}
|
||||||
|
|
||||||
|
@Cron('0 * * * *') // 每小时第0分钟执行
|
||||||
|
async checkAndExecuteAutoIncrease(): Promise<void> {
|
||||||
|
if (this.isRunning) {
|
||||||
|
this.logger.warn('Auto price increase check already running, skipping...');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.isRunning = true;
|
||||||
|
try {
|
||||||
|
const executed = await this.pricingService.executeAutoIncrease();
|
||||||
|
if (executed) {
|
||||||
|
this.logger.log('Auto price increase executed successfully');
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
this.logger.error(`Auto price increase failed: ${error}`);
|
||||||
|
} finally {
|
||||||
|
this.isRunning = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,343 @@
|
||||||
|
/**
|
||||||
|
* 合同批量下载任务处理器
|
||||||
|
* [2026-02-05] 新增:定时处理批量下载任务
|
||||||
|
* 回滚方式:删除此文件并从 app.module.ts 中移除引用
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { Injectable, Logger, OnModuleInit } from '@nestjs/common';
|
||||||
|
import { Cron } from '@nestjs/schedule';
|
||||||
|
import { ConfigService } from '@nestjs/config';
|
||||||
|
import * as fs from 'fs/promises';
|
||||||
|
import * as path from 'path';
|
||||||
|
import * as archiver from 'archiver';
|
||||||
|
import { createWriteStream, existsSync, mkdirSync } from 'fs';
|
||||||
|
import { PrismaService } from '../persistence/prisma/prisma.service';
|
||||||
|
import { ContractService, ContractDto } from '../../application/services/contract.service';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 筛选条件类型
|
||||||
|
*/
|
||||||
|
interface BatchDownloadFilters {
|
||||||
|
signedAfter?: string;
|
||||||
|
signedBefore?: string;
|
||||||
|
provinceCode?: string;
|
||||||
|
cityCode?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 合同批量下载任务处理 Job
|
||||||
|
* 每分钟检查是否有待处理的批量下载任务
|
||||||
|
*/
|
||||||
|
@Injectable()
|
||||||
|
export class ContractBatchDownloadJob implements OnModuleInit {
|
||||||
|
private readonly logger = new Logger(ContractBatchDownloadJob.name);
|
||||||
|
private isRunning = false;
|
||||||
|
private readonly downloadDir: string;
|
||||||
|
private readonly baseUrl: string;
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
private readonly prisma: PrismaService,
|
||||||
|
private readonly contractService: ContractService,
|
||||||
|
private readonly configService: ConfigService,
|
||||||
|
) {
|
||||||
|
this.downloadDir = this.configService.get<string>('UPLOAD_DIR') || './uploads';
|
||||||
|
this.baseUrl = this.configService.get<string>('BASE_URL') || 'http://localhost:3005';
|
||||||
|
}
|
||||||
|
|
||||||
|
onModuleInit() {
|
||||||
|
this.logger.log('ContractBatchDownloadJob initialized');
|
||||||
|
// 确保下载目录存在
|
||||||
|
const contractsDir = path.join(this.downloadDir, 'contracts');
|
||||||
|
if (!existsSync(contractsDir)) {
|
||||||
|
mkdirSync(contractsDir, { recursive: true });
|
||||||
|
this.logger.log(`Created contracts download directory: ${contractsDir}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 每分钟检查并处理待处理的批量下载任务
|
||||||
|
*/
|
||||||
|
@Cron('0 * * * * *') // 每分钟的第0秒
|
||||||
|
async processPendingTasks(): Promise<void> {
|
||||||
|
if (this.isRunning) {
|
||||||
|
this.logger.debug('Batch download job is already running, skipping...');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.isRunning = true;
|
||||||
|
|
||||||
|
try {
|
||||||
|
// 查找待处理的任务
|
||||||
|
const pendingTask = await this.prisma.contractBatchDownloadTask.findFirst({
|
||||||
|
where: { status: 'PENDING' },
|
||||||
|
orderBy: { createdAt: 'asc' },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!pendingTask) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.logger.log(`开始处理批量下载任务: ${pendingTask.taskNo}`);
|
||||||
|
|
||||||
|
// 更新状态为处理中
|
||||||
|
await this.prisma.contractBatchDownloadTask.update({
|
||||||
|
where: { id: pendingTask.id },
|
||||||
|
data: {
|
||||||
|
status: 'PROCESSING',
|
||||||
|
startedAt: new Date(),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
await this.processTask(pendingTask.id, pendingTask.taskNo, pendingTask.filters as BatchDownloadFilters);
|
||||||
|
} catch (error) {
|
||||||
|
this.logger.error(`任务处理失败: ${pendingTask.taskNo}`, error);
|
||||||
|
await this.prisma.contractBatchDownloadTask.update({
|
||||||
|
where: { id: pendingTask.id },
|
||||||
|
data: {
|
||||||
|
status: 'FAILED',
|
||||||
|
errors: { message: error.message, stack: error.stack },
|
||||||
|
completedAt: new Date(),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
this.logger.error('批量下载任务检查失败', error);
|
||||||
|
} finally {
|
||||||
|
this.isRunning = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 处理单个批量下载任务
|
||||||
|
*/
|
||||||
|
private async processTask(
|
||||||
|
taskId: bigint,
|
||||||
|
taskNo: string,
|
||||||
|
filters: BatchDownloadFilters | null,
|
||||||
|
): Promise<void> {
|
||||||
|
const errors: Array<{ orderNo: string; error: string }> = [];
|
||||||
|
let downloadedCount = 0;
|
||||||
|
let failedCount = 0;
|
||||||
|
|
||||||
|
// 1. 获取符合条件的合同列表(只获取已签署的)
|
||||||
|
this.logger.log(`获取合同列表, 筛选条件: ${JSON.stringify(filters)}`);
|
||||||
|
|
||||||
|
const contractsResult = await this.contractService.getContracts({
|
||||||
|
signedAfter: filters?.signedAfter,
|
||||||
|
signedBefore: filters?.signedBefore,
|
||||||
|
provinceCode: filters?.provinceCode,
|
||||||
|
cityCode: filters?.cityCode,
|
||||||
|
status: 'SIGNED',
|
||||||
|
pageSize: 10000, // 最大获取1万份
|
||||||
|
orderBy: 'signedAt',
|
||||||
|
orderDir: 'asc',
|
||||||
|
});
|
||||||
|
|
||||||
|
const contracts = contractsResult.items;
|
||||||
|
const totalContracts = contracts.length;
|
||||||
|
|
||||||
|
this.logger.log(`共找到 ${totalContracts} 份已签署合同`);
|
||||||
|
|
||||||
|
if (totalContracts === 0) {
|
||||||
|
// 没有合同需要下载
|
||||||
|
await this.prisma.contractBatchDownloadTask.update({
|
||||||
|
where: { id: taskId },
|
||||||
|
data: {
|
||||||
|
status: 'COMPLETED',
|
||||||
|
totalContracts: 0,
|
||||||
|
downloadedCount: 0,
|
||||||
|
failedCount: 0,
|
||||||
|
progress: 100,
|
||||||
|
completedAt: new Date(),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 更新总数
|
||||||
|
await this.prisma.contractBatchDownloadTask.update({
|
||||||
|
where: { id: taskId },
|
||||||
|
data: { totalContracts },
|
||||||
|
});
|
||||||
|
|
||||||
|
// 2. 创建临时目录
|
||||||
|
const tempDir = path.join(this.downloadDir, 'temp', taskNo);
|
||||||
|
if (!existsSync(tempDir)) {
|
||||||
|
mkdirSync(tempDir, { recursive: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. 逐个下载合同 PDF
|
||||||
|
for (let i = 0; i < contracts.length; i++) {
|
||||||
|
const contract = contracts[i];
|
||||||
|
|
||||||
|
try {
|
||||||
|
// 下载 PDF
|
||||||
|
const pdfBuffer = await this.contractService.downloadContractPdf(contract.orderNo);
|
||||||
|
|
||||||
|
// 生成文件路径(按省市分组)
|
||||||
|
const safeProvince = this.sanitizeFileName(contract.provinceName || '未知省份');
|
||||||
|
const safeCity = this.sanitizeFileName(contract.cityName || '未知城市');
|
||||||
|
const subDir = path.join(tempDir, safeProvince, safeCity);
|
||||||
|
|
||||||
|
if (!existsSync(subDir)) {
|
||||||
|
mkdirSync(subDir, { recursive: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
// 生成文件名
|
||||||
|
const safeRealName = this.sanitizeFileName(contract.userRealName || '未知');
|
||||||
|
const fileName = `${contract.contractNo}_${safeRealName}_${contract.treeCount}棵.pdf`;
|
||||||
|
const filePath = path.join(subDir, fileName);
|
||||||
|
|
||||||
|
// 保存文件
|
||||||
|
await fs.writeFile(filePath, pdfBuffer);
|
||||||
|
|
||||||
|
downloadedCount++;
|
||||||
|
this.logger.debug(`下载成功: ${contract.orderNo} -> ${fileName}`);
|
||||||
|
} catch (error) {
|
||||||
|
failedCount++;
|
||||||
|
errors.push({ orderNo: contract.orderNo, error: error.message });
|
||||||
|
this.logger.warn(`下载失败: ${contract.orderNo} - ${error.message}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 更新进度
|
||||||
|
const progress = Math.floor(((i + 1) / totalContracts) * 100);
|
||||||
|
if (progress % 10 === 0 || i === totalContracts - 1) {
|
||||||
|
await this.prisma.contractBatchDownloadTask.update({
|
||||||
|
where: { id: taskId },
|
||||||
|
data: {
|
||||||
|
downloadedCount,
|
||||||
|
failedCount,
|
||||||
|
progress,
|
||||||
|
lastProcessedOrderNo: contract.orderNo,
|
||||||
|
errors: errors.length > 0 ? errors : undefined,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
this.logger.log(`进度: ${progress}% (${downloadedCount}/${totalContracts})`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. 打包成 ZIP
|
||||||
|
this.logger.log('开始打包 ZIP...');
|
||||||
|
|
||||||
|
const zipFileName = this.generateZipFileName(filters, downloadedCount);
|
||||||
|
const zipDir = path.join(this.downloadDir, 'contracts');
|
||||||
|
const zipPath = path.join(zipDir, zipFileName);
|
||||||
|
|
||||||
|
await this.createZipArchive(tempDir, zipPath);
|
||||||
|
|
||||||
|
// 获取 ZIP 文件大小
|
||||||
|
const zipStats = await fs.stat(zipPath);
|
||||||
|
const resultFileUrl = `${this.baseUrl}/uploads/contracts/${zipFileName}`;
|
||||||
|
|
||||||
|
this.logger.log(`ZIP 打包完成: ${zipFileName}, 大小: ${zipStats.size} bytes`);
|
||||||
|
|
||||||
|
// 5. 清理临时文件
|
||||||
|
await this.cleanupTempDir(tempDir);
|
||||||
|
|
||||||
|
// 6. 更新任务状态为完成
|
||||||
|
await this.prisma.contractBatchDownloadTask.update({
|
||||||
|
where: { id: taskId },
|
||||||
|
data: {
|
||||||
|
status: 'COMPLETED',
|
||||||
|
downloadedCount,
|
||||||
|
failedCount,
|
||||||
|
progress: 100,
|
||||||
|
resultFileUrl,
|
||||||
|
resultFileSize: BigInt(zipStats.size),
|
||||||
|
errors: errors.length > 0 ? errors : undefined,
|
||||||
|
completedAt: new Date(),
|
||||||
|
expiresAt: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000), // 7天后过期
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
this.logger.log(`任务完成: ${taskNo}, 成功: ${downloadedCount}, 失败: ${failedCount}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 生成 ZIP 文件名
|
||||||
|
*/
|
||||||
|
private generateZipFileName(filters: BatchDownloadFilters | null, count: number): string {
|
||||||
|
const dateStr = new Date().toISOString().slice(0, 10).replace(/-/g, '');
|
||||||
|
let rangeStr = '';
|
||||||
|
|
||||||
|
if (filters?.signedAfter || filters?.signedBefore) {
|
||||||
|
const start = filters.signedAfter
|
||||||
|
? new Date(filters.signedAfter).toISOString().slice(0, 10).replace(/-/g, '')
|
||||||
|
: 'all';
|
||||||
|
const end = filters.signedBefore
|
||||||
|
? new Date(filters.signedBefore).toISOString().slice(0, 10).replace(/-/g, '')
|
||||||
|
: 'now';
|
||||||
|
rangeStr = `_${start}-${end}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return `contracts_${dateStr}${rangeStr}_${count}份.zip`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 创建 ZIP 压缩包
|
||||||
|
*/
|
||||||
|
private async createZipArchive(sourceDir: string, zipPath: string): Promise<void> {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const output = createWriteStream(zipPath);
|
||||||
|
const archive = archiver('zip', {
|
||||||
|
zlib: { level: 6 }, // 压缩级别
|
||||||
|
});
|
||||||
|
|
||||||
|
output.on('close', () => {
|
||||||
|
this.logger.log(`ZIP 文件大小: ${archive.pointer()} bytes`);
|
||||||
|
resolve();
|
||||||
|
});
|
||||||
|
|
||||||
|
archive.on('error', (err: Error) => {
|
||||||
|
reject(err);
|
||||||
|
});
|
||||||
|
|
||||||
|
archive.pipe(output);
|
||||||
|
|
||||||
|
// 添加目录下所有文件
|
||||||
|
archive.directory(sourceDir, false);
|
||||||
|
|
||||||
|
archive.finalize();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 清理临时目录
|
||||||
|
*/
|
||||||
|
private async cleanupTempDir(tempDir: string): Promise<void> {
|
||||||
|
try {
|
||||||
|
await fs.rm(tempDir, { recursive: true, force: true });
|
||||||
|
this.logger.debug(`清理临时目录: ${tempDir}`);
|
||||||
|
} catch (error) {
|
||||||
|
this.logger.warn(`清理临时目录失败: ${tempDir}`, error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 清理文件名中的非法字符
|
||||||
|
*/
|
||||||
|
private sanitizeFileName(name: string): string {
|
||||||
|
return name.replace(/[\/\\:*?"<>|]/g, '_').trim() || '未知';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 手动触发任务处理(供 API 调用)
|
||||||
|
*/
|
||||||
|
async triggerProcessing(): Promise<{ processed: boolean; taskNo?: string }> {
|
||||||
|
if (this.isRunning) {
|
||||||
|
return { processed: false };
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.processPendingTasks();
|
||||||
|
|
||||||
|
return { processed: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取处理状态
|
||||||
|
*/
|
||||||
|
getProcessingStatus(): { isRunning: boolean } {
|
||||||
|
return { isRunning: this.isRunning };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -56,6 +56,7 @@ export class NotificationMapper {
|
||||||
prisma.imageUrl,
|
prisma.imageUrl,
|
||||||
prisma.linkUrl,
|
prisma.linkUrl,
|
||||||
prisma.isEnabled,
|
prisma.isEnabled,
|
||||||
|
prisma.requiresForceRead,
|
||||||
prisma.publishedAt,
|
prisma.publishedAt,
|
||||||
prisma.expiresAt,
|
prisma.expiresAt,
|
||||||
prisma.createdAt,
|
prisma.createdAt,
|
||||||
|
|
@ -78,6 +79,7 @@ export class NotificationMapper {
|
||||||
imageUrl: entity.imageUrl,
|
imageUrl: entity.imageUrl,
|
||||||
linkUrl: entity.linkUrl,
|
linkUrl: entity.linkUrl,
|
||||||
isEnabled: entity.isEnabled,
|
isEnabled: entity.isEnabled,
|
||||||
|
requiresForceRead: entity.requiresForceRead,
|
||||||
publishedAt: entity.publishedAt,
|
publishedAt: entity.publishedAt,
|
||||||
expiresAt: entity.expiresAt,
|
expiresAt: entity.expiresAt,
|
||||||
createdAt: entity.createdAt,
|
createdAt: entity.createdAt,
|
||||||
|
|
|
||||||
|
|
@ -86,14 +86,16 @@ export class UserDetailQueryRepositoryImpl implements IUserDetailQueryRepository
|
||||||
// 注意:优先从 referrals 获取 accountSequences,因为用户可能不存在于 user_query_view
|
// 注意:优先从 referrals 获取 accountSequences,因为用户可能不存在于 user_query_view
|
||||||
const referralAccountSequences = referrals.map(r => r.accountSequence);
|
const referralAccountSequences = referrals.map(r => r.accountSequence);
|
||||||
const [adoptionCounts, directReferralCounts, teamStats] = await Promise.all([
|
const [adoptionCounts, directReferralCounts, teamStats] = await Promise.all([
|
||||||
// 统计每个用户的认种订单数量(状态为 MINING_ENABLED)
|
// 统计每个用户的认种棵数(状态为 MINING_ENABLED)
|
||||||
|
// 注意:使用 _sum.treeCount 而非 _count.id,因为一笔订单可以认种多棵,
|
||||||
|
// 显示的是棵数(认种数量),不是订单条数。
|
||||||
this.prisma.plantingOrderQueryView.groupBy({
|
this.prisma.plantingOrderQueryView.groupBy({
|
||||||
by: ['accountSequence'],
|
by: ['accountSequence'],
|
||||||
where: {
|
where: {
|
||||||
accountSequence: { in: referralAccountSequences },
|
accountSequence: { in: referralAccountSequences },
|
||||||
status: 'MINING_ENABLED',
|
status: 'MINING_ENABLED',
|
||||||
},
|
},
|
||||||
_count: { id: true },
|
_sum: { treeCount: true },
|
||||||
}),
|
}),
|
||||||
// 统计每个用户的直推数量
|
// 统计每个用户的直推数量
|
||||||
this.prisma.referralQueryView.groupBy({
|
this.prisma.referralQueryView.groupBy({
|
||||||
|
|
@ -105,7 +107,7 @@ export class UserDetailQueryRepositoryImpl implements IUserDetailQueryRepository
|
||||||
this.getBatchUserStats(referralAccountSequences),
|
this.getBatchUserStats(referralAccountSequences),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const adoptionCountMap = new Map(adoptionCounts.map(a => [a.accountSequence, a._count.id]));
|
const adoptionCountMap = new Map(adoptionCounts.map(a => [a.accountSequence, a._sum.treeCount ?? 0]));
|
||||||
const directCountMap = new Map(
|
const directCountMap = new Map(
|
||||||
directReferralCounts
|
directReferralCounts
|
||||||
.filter(d => d.referrerId !== null)
|
.filter(d => d.referrerId !== null)
|
||||||
|
|
@ -169,14 +171,16 @@ export class UserDetailQueryRepositoryImpl implements IUserDetailQueryRepository
|
||||||
// 实时统计:获取每个用户的认种数量、团队认种量和直推数量
|
// 实时统计:获取每个用户的认种数量、团队认种量和直推数量
|
||||||
const userAccountSequences = directReferrals.map(r => r.accountSequence);
|
const userAccountSequences = directReferrals.map(r => r.accountSequence);
|
||||||
const [adoptionCounts, directReferralCounts, teamStats] = await Promise.all([
|
const [adoptionCounts, directReferralCounts, teamStats] = await Promise.all([
|
||||||
// 统计每个用户的认种订单数量(状态为 MINING_ENABLED)
|
// 统计每个用户的认种棵数(状态为 MINING_ENABLED)
|
||||||
|
// 注意:使用 _sum.treeCount 而非 _count.id,因为一笔订单可以认种多棵,
|
||||||
|
// 显示的是棵数(认种数量),不是订单条数。
|
||||||
this.prisma.plantingOrderQueryView.groupBy({
|
this.prisma.plantingOrderQueryView.groupBy({
|
||||||
by: ['accountSequence'],
|
by: ['accountSequence'],
|
||||||
where: {
|
where: {
|
||||||
accountSequence: { in: userAccountSequences },
|
accountSequence: { in: userAccountSequences },
|
||||||
status: 'MINING_ENABLED',
|
status: 'MINING_ENABLED',
|
||||||
},
|
},
|
||||||
_count: { id: true },
|
_sum: { treeCount: true },
|
||||||
}),
|
}),
|
||||||
// 统计每个用户的直推数量
|
// 统计每个用户的直推数量
|
||||||
this.prisma.referralQueryView.groupBy({
|
this.prisma.referralQueryView.groupBy({
|
||||||
|
|
@ -188,7 +192,7 @@ export class UserDetailQueryRepositoryImpl implements IUserDetailQueryRepository
|
||||||
this.getBatchUserStats(userAccountSequences),
|
this.getBatchUserStats(userAccountSequences),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const adoptionCountMap = new Map(adoptionCounts.map(a => [a.accountSequence, a._count.id]));
|
const adoptionCountMap = new Map(adoptionCounts.map(a => [a.accountSequence, a._sum.treeCount ?? 0]));
|
||||||
const directCountMap = new Map(
|
const directCountMap = new Map(
|
||||||
directReferralCounts
|
directReferralCounts
|
||||||
.filter(d => d.referrerId !== null)
|
.filter(d => d.referrerId !== null)
|
||||||
|
|
@ -448,6 +452,7 @@ export class UserDetailQueryRepositoryImpl implements IUserDetailQueryRepository
|
||||||
monthlyTargetType: role.monthlyTargetType,
|
monthlyTargetType: role.monthlyTargetType,
|
||||||
lastAssessmentMonth: role.lastAssessmentMonth,
|
lastAssessmentMonth: role.lastAssessmentMonth,
|
||||||
monthlyTreesAdded: role.monthlyTreesAdded,
|
monthlyTreesAdded: role.monthlyTreesAdded,
|
||||||
|
officePhotoUrls: role.officePhotoUrls ?? [],
|
||||||
createdAt: role.createdAt,
|
createdAt: role.createdAt,
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
@ -527,15 +532,18 @@ export class UserDetailQueryRepositoryImpl implements IUserDetailQueryRepository
|
||||||
}
|
}
|
||||||
|
|
||||||
async getPersonalAdoptionCount(accountSequence: string): Promise<number> {
|
async getPersonalAdoptionCount(accountSequence: string): Promise<number> {
|
||||||
// 统计用户的认种订单数量(状态为 MINING_ENABLED)
|
// 统计用户的认种棵数(状态为 MINING_ENABLED)
|
||||||
const count = await this.prisma.plantingOrderQueryView.count({
|
// 注意:使用 aggregate._sum.treeCount 而非 count(),因为一笔订单可以认种多棵,
|
||||||
|
// 返回的是总棵数(认种数量),不是订单条数。
|
||||||
|
const result = await this.prisma.plantingOrderQueryView.aggregate({
|
||||||
where: {
|
where: {
|
||||||
accountSequence,
|
accountSequence,
|
||||||
status: 'MINING_ENABLED',
|
status: 'MINING_ENABLED',
|
||||||
},
|
},
|
||||||
|
_sum: { treeCount: true },
|
||||||
});
|
});
|
||||||
|
|
||||||
return count;
|
return result._sum.treeCount ?? 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
async getDirectReferralCount(accountSequence: string): Promise<number> {
|
async getDirectReferralCount(accountSequence: string): Promise<number> {
|
||||||
|
|
@ -577,17 +585,19 @@ export class UserDetailQueryRepositoryImpl implements IUserDetailQueryRepository
|
||||||
|
|
||||||
const teamAddressCount = teamMembers.length;
|
const teamAddressCount = teamMembers.length;
|
||||||
|
|
||||||
// 2. 获取团队认种量:汇总所有团队成员的有效认种订单数
|
// 2. 获取团队认种量:汇总所有团队成员的有效认种棵数
|
||||||
|
// 注意:使用 aggregate._sum.treeCount 而非 count(),因为一笔订单可以认种多棵。
|
||||||
let teamAdoptionCount = 0;
|
let teamAdoptionCount = 0;
|
||||||
if (teamMembers.length > 0) {
|
if (teamMembers.length > 0) {
|
||||||
const count = await this.prisma.plantingOrderQueryView.count({
|
const result = await this.prisma.plantingOrderQueryView.aggregate({
|
||||||
where: {
|
where: {
|
||||||
accountSequence: { in: teamMembers.map((m) => m.accountSequence) },
|
accountSequence: { in: teamMembers.map((m) => m.accountSequence) },
|
||||||
status: 'MINING_ENABLED',
|
status: 'MINING_ENABLED',
|
||||||
},
|
},
|
||||||
|
_sum: { treeCount: true },
|
||||||
});
|
});
|
||||||
|
|
||||||
teamAdoptionCount = count;
|
teamAdoptionCount = result._sum.treeCount ?? 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
return { teamAddressCount, teamAdoptionCount };
|
return { teamAddressCount, teamAdoptionCount };
|
||||||
|
|
@ -610,17 +620,19 @@ export class UserDetailQueryRepositoryImpl implements IUserDetailQueryRepository
|
||||||
|
|
||||||
if (accountSequences.length === 0) return result;
|
if (accountSequences.length === 0) return result;
|
||||||
|
|
||||||
// 1. 批量获取个人认种量
|
// 1. 批量获取个人认种棵数
|
||||||
|
// 注意:使用 _sum.treeCount 而非 _count.id,因为一笔订单可以认种多棵,
|
||||||
|
// 显示的是棵数(认种数量),不是订单条数。
|
||||||
const personalAdoptionCounts = await this.prisma.plantingOrderQueryView.groupBy({
|
const personalAdoptionCounts = await this.prisma.plantingOrderQueryView.groupBy({
|
||||||
by: ['accountSequence'],
|
by: ['accountSequence'],
|
||||||
where: {
|
where: {
|
||||||
accountSequence: { in: accountSequences },
|
accountSequence: { in: accountSequences },
|
||||||
status: 'MINING_ENABLED',
|
status: 'MINING_ENABLED',
|
||||||
},
|
},
|
||||||
_count: { id: true },
|
_sum: { treeCount: true },
|
||||||
});
|
});
|
||||||
const personalAdoptionMap = new Map(
|
const personalAdoptionMap = new Map(
|
||||||
personalAdoptionCounts.map(p => [p.accountSequence, p._count.id])
|
personalAdoptionCounts.map(p => [p.accountSequence, p._sum.treeCount ?? 0])
|
||||||
);
|
);
|
||||||
|
|
||||||
// 2. 批量获取用户的省市信息(从认种订单中获取第一个订单的省市)
|
// 2. 批量获取用户的省市信息(从认种订单中获取第一个订单的省市)
|
||||||
|
|
@ -673,35 +685,41 @@ export class UserDetailQueryRepositoryImpl implements IUserDetailQueryRepository
|
||||||
if (teamMembers.length > 0) {
|
if (teamMembers.length > 0) {
|
||||||
const teamAccountSequences = teamMembers.map(m => m.accountSequence);
|
const teamAccountSequences = teamMembers.map(m => m.accountSequence);
|
||||||
|
|
||||||
// 团队总认种量
|
// 团队总认种棵数(使用 sum treeCount,不是 count orders)
|
||||||
teamAdoptionCount = await this.prisma.plantingOrderQueryView.count({
|
const teamResult = await this.prisma.plantingOrderQueryView.aggregate({
|
||||||
where: {
|
where: {
|
||||||
accountSequence: { in: teamAccountSequences },
|
accountSequence: { in: teamAccountSequences },
|
||||||
status: 'MINING_ENABLED',
|
status: 'MINING_ENABLED',
|
||||||
},
|
},
|
||||||
|
_sum: { treeCount: true },
|
||||||
});
|
});
|
||||||
|
teamAdoptionCount = teamResult._sum.treeCount ?? 0;
|
||||||
|
|
||||||
// 如果用户有省市信息,统计同省同市的认种量
|
// 如果用户有省市信息,统计同省同市的认种棵数
|
||||||
if (userLocation?.province) {
|
if (userLocation?.province) {
|
||||||
// 同省认种量
|
// 同省认种棵数
|
||||||
provinceAdoptionCount = await this.prisma.plantingOrderQueryView.count({
|
const provinceResult = await this.prisma.plantingOrderQueryView.aggregate({
|
||||||
where: {
|
where: {
|
||||||
accountSequence: { in: teamAccountSequences },
|
accountSequence: { in: teamAccountSequences },
|
||||||
status: 'MINING_ENABLED',
|
status: 'MINING_ENABLED',
|
||||||
selectedProvince: userLocation.province,
|
selectedProvince: userLocation.province,
|
||||||
},
|
},
|
||||||
|
_sum: { treeCount: true },
|
||||||
});
|
});
|
||||||
|
provinceAdoptionCount = provinceResult._sum.treeCount ?? 0;
|
||||||
|
|
||||||
// 同市认种量
|
// 同市认种棵数
|
||||||
if (userLocation.city) {
|
if (userLocation.city) {
|
||||||
cityAdoptionCount = await this.prisma.plantingOrderQueryView.count({
|
const cityResult = await this.prisma.plantingOrderQueryView.aggregate({
|
||||||
where: {
|
where: {
|
||||||
accountSequence: { in: teamAccountSequences },
|
accountSequence: { in: teamAccountSequences },
|
||||||
status: 'MINING_ENABLED',
|
status: 'MINING_ENABLED',
|
||||||
selectedProvince: userLocation.province,
|
selectedProvince: userLocation.province,
|
||||||
selectedCity: userLocation.city,
|
selectedCity: userLocation.city,
|
||||||
},
|
},
|
||||||
|
_sum: { treeCount: true },
|
||||||
});
|
});
|
||||||
|
cityAdoptionCount = cityResult._sum.treeCount ?? 0;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -24,6 +24,32 @@ export class UserQueryRepositoryImpl implements IUserQueryRepository {
|
||||||
const where = this.buildWhereClause(filters);
|
const where = this.buildWhereClause(filters);
|
||||||
const orderBy = this.buildOrderBy(sort);
|
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([
|
const [items, total] = await Promise.all([
|
||||||
this.prisma.userQueryView.findMany({
|
this.prisma.userQueryView.findMany({
|
||||||
where,
|
where,
|
||||||
|
|
@ -264,16 +290,8 @@ export class UserQueryRepositoryImpl implements IUserQueryRepository {
|
||||||
where.inviterSequence = filters.hasInviter ? { not: null } : null;
|
where.inviterSequence = filters.hasInviter ? { not: null } : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 认种数范围
|
// 认种数范围:不再使用 personalAdoptionCount(预计算字段可能未同步),
|
||||||
if (filters.minAdoptions !== undefined || filters.maxAdoptions !== undefined) {
|
// 改为在 findMany 中实时查询 PlantingOrderQueryView 处理
|
||||||
where.personalAdoptionCount = {};
|
|
||||||
if (filters.minAdoptions !== undefined) {
|
|
||||||
where.personalAdoptionCount.gte = filters.minAdoptions;
|
|
||||||
}
|
|
||||||
if (filters.maxAdoptions !== undefined) {
|
|
||||||
where.personalAdoptionCount.lte = filters.maxAdoptions;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 注册时间范围
|
// 注册时间范围
|
||||||
if (filters.registeredAfter || filters.registeredBefore) {
|
if (filters.registeredAfter || filters.registeredBefore) {
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,230 @@
|
||||||
|
import {
|
||||||
|
Controller,
|
||||||
|
Get,
|
||||||
|
Post,
|
||||||
|
Put,
|
||||||
|
Body,
|
||||||
|
Query,
|
||||||
|
HttpCode,
|
||||||
|
HttpStatus,
|
||||||
|
Logger,
|
||||||
|
} from '@nestjs/common';
|
||||||
|
import { ApiTags, ApiOperation, ApiResponse, ApiQuery } from '@nestjs/swagger';
|
||||||
|
import { IsBoolean, IsOptional, IsString } from 'class-validator';
|
||||||
|
import { PrePlantingConfigService } from './pre-planting-config.service';
|
||||||
|
import { PrePlantingProxyService } from './pre-planting-proxy.service';
|
||||||
|
import { PrismaService } from '../infrastructure/persistence/prisma/prisma.service';
|
||||||
|
|
||||||
|
class UpdatePrePlantingConfigDto {
|
||||||
|
@IsBoolean()
|
||||||
|
isActive: boolean;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
updatedBy?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
class TogglePrePlantingConfigDto {
|
||||||
|
@IsBoolean()
|
||||||
|
isActive: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
class UpdatePrePlantingAgreementDto {
|
||||||
|
@IsString()
|
||||||
|
text: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
@ApiTags('预种计划配置')
|
||||||
|
@Controller('admin/pre-planting')
|
||||||
|
export class PrePlantingConfigController {
|
||||||
|
private readonly logger = new Logger(PrePlantingConfigController.name);
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
private readonly configService: PrePlantingConfigService,
|
||||||
|
private readonly proxyService: PrePlantingProxyService,
|
||||||
|
private readonly prisma: PrismaService,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 根据 accountSequence 查找该用户的团队成员 accountSequence 列表
|
||||||
|
* 逻辑:找到该用户的 userId,然后查找 ancestorPath 包含该 userId 的所有用户
|
||||||
|
* 返回的列表包含该用户本人
|
||||||
|
*/
|
||||||
|
private async resolveTeamAccountSequences(teamOfAccountSeq: string): Promise<string[]> {
|
||||||
|
// 1. 找到 teamOf 用户的 userId
|
||||||
|
const leader = await this.prisma.referralQueryView.findUnique({
|
||||||
|
where: { accountSequence: teamOfAccountSeq },
|
||||||
|
select: { userId: true, accountSequence: true },
|
||||||
|
});
|
||||||
|
if (!leader) {
|
||||||
|
this.logger.warn(`[resolveTeamAccountSequences] 未找到用户: ${teamOfAccountSeq}`);
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. 查找 ancestorPath 包含该 userId 的所有下级用户(PostgreSQL array contains)
|
||||||
|
const teamMembers = await this.prisma.referralQueryView.findMany({
|
||||||
|
where: {
|
||||||
|
ancestorPath: { has: leader.userId },
|
||||||
|
},
|
||||||
|
select: { accountSequence: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
// 3. 包含团队领导本人
|
||||||
|
const sequences = [leader.accountSequence, ...teamMembers.map((m) => m.accountSequence)];
|
||||||
|
this.logger.debug(`[resolveTeamAccountSequences] ${teamOfAccountSeq} 团队成员数: ${sequences.length}`);
|
||||||
|
return sequences;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get('config')
|
||||||
|
@ApiOperation({ summary: '获取预种计划开关状态(含协议文本)' })
|
||||||
|
@ApiResponse({ status: HttpStatus.OK, description: '开关状态' })
|
||||||
|
async getConfig() {
|
||||||
|
const config = await this.configService.getConfig();
|
||||||
|
const agreementText = await this.configService.getAgreement();
|
||||||
|
return { ...config, agreementText };
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post('config')
|
||||||
|
@HttpCode(HttpStatus.OK)
|
||||||
|
@ApiOperation({ summary: '更新预种计划开关状态' })
|
||||||
|
@ApiResponse({ status: HttpStatus.OK, description: '更新成功' })
|
||||||
|
async updateConfig(@Body() dto: UpdatePrePlantingConfigDto) {
|
||||||
|
return this.configService.updateConfig(dto.isActive, dto.updatedBy);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// [2026-02-27] 新增:预种管理端点(toggle + 数据查询代理)
|
||||||
|
// ============================================
|
||||||
|
|
||||||
|
@Put('config/toggle')
|
||||||
|
@HttpCode(HttpStatus.OK)
|
||||||
|
@ApiOperation({ summary: '切换预种计划开关' })
|
||||||
|
@ApiResponse({ status: HttpStatus.OK, description: '切换成功' })
|
||||||
|
async toggleConfig(@Body() dto: TogglePrePlantingConfigDto) {
|
||||||
|
return this.configService.updateConfig(dto.isActive);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get('orders')
|
||||||
|
@ApiOperation({ summary: '预种订单列表(管理员视角)' })
|
||||||
|
@ApiQuery({ name: 'page', required: false })
|
||||||
|
@ApiQuery({ name: 'pageSize', required: false })
|
||||||
|
@ApiQuery({ name: 'keyword', required: false })
|
||||||
|
@ApiQuery({ name: 'status', required: false })
|
||||||
|
@ApiQuery({ name: 'teamOf', required: false, description: '团队筛选:指定用户 accountSequence,只显示其团队成员的订单' })
|
||||||
|
async getOrders(
|
||||||
|
@Query('page') page?: string,
|
||||||
|
@Query('pageSize') pageSize?: string,
|
||||||
|
@Query('keyword') keyword?: string,
|
||||||
|
@Query('status') status?: string,
|
||||||
|
@Query('teamOf') teamOf?: string,
|
||||||
|
) {
|
||||||
|
let accountSequences: string[] | undefined;
|
||||||
|
if (teamOf) {
|
||||||
|
accountSequences = await this.resolveTeamAccountSequences(teamOf);
|
||||||
|
if (accountSequences.length === 0) {
|
||||||
|
return { items: [], total: 0, page: page ? parseInt(page, 10) : 1, pageSize: pageSize ? parseInt(pageSize, 10) : 20 };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.proxyService.getOrders({
|
||||||
|
page: page ? parseInt(page, 10) : undefined,
|
||||||
|
pageSize: pageSize ? parseInt(pageSize, 10) : undefined,
|
||||||
|
keyword: keyword || undefined,
|
||||||
|
status: status || undefined,
|
||||||
|
accountSequences,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get('positions')
|
||||||
|
@ApiOperation({ summary: '预种持仓列表(管理员视角)' })
|
||||||
|
@ApiQuery({ name: 'page', required: false })
|
||||||
|
@ApiQuery({ name: 'pageSize', required: false })
|
||||||
|
@ApiQuery({ name: 'keyword', required: false })
|
||||||
|
@ApiQuery({ name: 'teamOf', required: false, description: '团队筛选:指定用户 accountSequence,只显示其团队成员的持仓' })
|
||||||
|
async getPositions(
|
||||||
|
@Query('page') page?: string,
|
||||||
|
@Query('pageSize') pageSize?: string,
|
||||||
|
@Query('keyword') keyword?: string,
|
||||||
|
@Query('teamOf') teamOf?: string,
|
||||||
|
) {
|
||||||
|
let accountSequences: string[] | undefined;
|
||||||
|
if (teamOf) {
|
||||||
|
accountSequences = await this.resolveTeamAccountSequences(teamOf);
|
||||||
|
if (accountSequences.length === 0) {
|
||||||
|
return { items: [], total: 0, page: page ? parseInt(page, 10) : 1, pageSize: pageSize ? parseInt(pageSize, 10) : 20 };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.proxyService.getPositions({
|
||||||
|
page: page ? parseInt(page, 10) : undefined,
|
||||||
|
pageSize: pageSize ? parseInt(pageSize, 10) : undefined,
|
||||||
|
keyword: keyword || undefined,
|
||||||
|
accountSequences,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get('merges')
|
||||||
|
@ApiOperation({ summary: '预种合并记录列表(管理员视角)' })
|
||||||
|
@ApiQuery({ name: 'page', required: false })
|
||||||
|
@ApiQuery({ name: 'pageSize', required: false })
|
||||||
|
@ApiQuery({ name: 'keyword', required: false })
|
||||||
|
@ApiQuery({ name: 'status', required: false })
|
||||||
|
async getMerges(
|
||||||
|
@Query('page') page?: string,
|
||||||
|
@Query('pageSize') pageSize?: string,
|
||||||
|
@Query('keyword') keyword?: string,
|
||||||
|
@Query('status') status?: string,
|
||||||
|
) {
|
||||||
|
return this.proxyService.getMerges({
|
||||||
|
page: page ? parseInt(page, 10) : undefined,
|
||||||
|
pageSize: pageSize ? parseInt(pageSize, 10) : undefined,
|
||||||
|
keyword: keyword || undefined,
|
||||||
|
status: status || undefined,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get('stats')
|
||||||
|
@ApiOperation({ summary: '预种统计汇总' })
|
||||||
|
async getStats() {
|
||||||
|
return this.proxyService.getStats();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// [2026-02-28] 新增:预种协议管理
|
||||||
|
// ============================================
|
||||||
|
|
||||||
|
@Get('agreement')
|
||||||
|
@ApiOperation({ summary: '获取预种协议文本' })
|
||||||
|
@ApiResponse({ status: HttpStatus.OK, description: '协议文本' })
|
||||||
|
async getAgreement() {
|
||||||
|
const text = await this.configService.getAgreement();
|
||||||
|
return { text };
|
||||||
|
}
|
||||||
|
|
||||||
|
@Put('agreement')
|
||||||
|
@HttpCode(HttpStatus.OK)
|
||||||
|
@ApiOperation({ summary: '更新预种协议文本' })
|
||||||
|
@ApiResponse({ status: HttpStatus.OK, description: '更新成功' })
|
||||||
|
async updateAgreement(@Body() dto: UpdatePrePlantingAgreementDto) {
|
||||||
|
return this.configService.updateAgreement(dto.text);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 公开 API(供 planting-service 调用)
|
||||||
|
*/
|
||||||
|
@ApiTags('预种计划配置-内部API')
|
||||||
|
@Controller('api/v1/admin/pre-planting')
|
||||||
|
export class PublicPrePlantingConfigController {
|
||||||
|
constructor(
|
||||||
|
private readonly configService: PrePlantingConfigService,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
@Get('config')
|
||||||
|
@ApiOperation({ summary: '获取预种计划开关状态(内部API,含协议文本)' })
|
||||||
|
async getConfig() {
|
||||||
|
const config = await this.configService.getConfig();
|
||||||
|
const agreementText = await this.configService.getAgreement();
|
||||||
|
return { ...config, agreementText };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,110 @@
|
||||||
|
import { Injectable, Logger } from '@nestjs/common';
|
||||||
|
import { PrismaService } from '../infrastructure/persistence/prisma/prisma.service';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class PrePlantingConfigService {
|
||||||
|
private readonly logger = new Logger(PrePlantingConfigService.name);
|
||||||
|
|
||||||
|
constructor(private readonly prisma: PrismaService) {}
|
||||||
|
|
||||||
|
async getConfig(): Promise<{
|
||||||
|
isActive: boolean;
|
||||||
|
activatedAt: Date | null;
|
||||||
|
}> {
|
||||||
|
const config = await this.prisma.prePlantingConfig.findFirst({
|
||||||
|
orderBy: { updatedAt: 'desc' },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!config) {
|
||||||
|
return { isActive: false, activatedAt: null };
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
isActive: config.isActive,
|
||||||
|
activatedAt: config.activatedAt,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取预种协议文本(从 system_configs 表读取)
|
||||||
|
*/
|
||||||
|
async getAgreement(): Promise<string | null> {
|
||||||
|
const config = await this.prisma.systemConfig.findUnique({
|
||||||
|
where: { key: 'pre_planting_agreement' },
|
||||||
|
});
|
||||||
|
return config?.value ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 更新预种协议文本(upsert 到 system_configs 表)
|
||||||
|
*/
|
||||||
|
async updateAgreement(text: string, updatedBy?: string): Promise<{ text: string }> {
|
||||||
|
await this.prisma.systemConfig.upsert({
|
||||||
|
where: { key: 'pre_planting_agreement' },
|
||||||
|
create: {
|
||||||
|
key: 'pre_planting_agreement',
|
||||||
|
value: text,
|
||||||
|
description: '预种计划购买协议文本',
|
||||||
|
updatedBy: updatedBy || null,
|
||||||
|
},
|
||||||
|
update: {
|
||||||
|
value: text,
|
||||||
|
updatedBy: updatedBy || null,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
this.logger.log(`[PRE-PLANTING] Agreement text updated by ${updatedBy || 'unknown'}`);
|
||||||
|
return { text };
|
||||||
|
}
|
||||||
|
|
||||||
|
async updateConfig(
|
||||||
|
isActive: boolean,
|
||||||
|
updatedBy?: string,
|
||||||
|
): Promise<{
|
||||||
|
isActive: boolean;
|
||||||
|
activatedAt: Date | null;
|
||||||
|
}> {
|
||||||
|
const existing = await this.prisma.prePlantingConfig.findFirst({
|
||||||
|
orderBy: { updatedAt: 'desc' },
|
||||||
|
});
|
||||||
|
|
||||||
|
const activatedAt = isActive ? new Date() : null;
|
||||||
|
|
||||||
|
if (existing) {
|
||||||
|
const updated = await this.prisma.prePlantingConfig.update({
|
||||||
|
where: { id: existing.id },
|
||||||
|
data: {
|
||||||
|
isActive,
|
||||||
|
activatedAt: isActive ? (existing.activatedAt || activatedAt) : existing.activatedAt,
|
||||||
|
updatedBy: updatedBy || null,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
this.logger.log(
|
||||||
|
`[PRE-PLANTING] Config updated: isActive=${updated.isActive} by ${updatedBy || 'unknown'}`,
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
isActive: updated.isActive,
|
||||||
|
activatedAt: updated.activatedAt,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const created = await this.prisma.prePlantingConfig.create({
|
||||||
|
data: {
|
||||||
|
isActive,
|
||||||
|
activatedAt,
|
||||||
|
updatedBy: updatedBy || null,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
this.logger.log(
|
||||||
|
`[PRE-PLANTING] Config created: isActive=${created.isActive} by ${updatedBy || 'unknown'}`,
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
isActive: created.isActive,
|
||||||
|
activatedAt: created.activatedAt,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,125 @@
|
||||||
|
/**
|
||||||
|
* 预种计划数据代理服务
|
||||||
|
* [2026-02-27] 新增:通过内部 HTTP 调用 planting-service 获取预种管理数据
|
||||||
|
*
|
||||||
|
* === 架构 ===
|
||||||
|
* admin-web → admin-service (本服务) → planting-service /internal/pre-planting/admin/*
|
||||||
|
* 复用现有 ContractService 的 axios 代理模式
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { Injectable, Logger } from '@nestjs/common';
|
||||||
|
import { ConfigService } from '@nestjs/config';
|
||||||
|
import axios, { AxiosInstance } from 'axios';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class PrePlantingProxyService {
|
||||||
|
private readonly logger = new Logger(PrePlantingProxyService.name);
|
||||||
|
private readonly httpClient: AxiosInstance;
|
||||||
|
|
||||||
|
constructor(private readonly configService: ConfigService) {
|
||||||
|
const plantingServiceUrl = this.configService.get<string>(
|
||||||
|
'PLANTING_SERVICE_URL',
|
||||||
|
'http://rwa-planting-service:3003',
|
||||||
|
);
|
||||||
|
|
||||||
|
this.httpClient = axios.create({
|
||||||
|
baseURL: plantingServiceUrl,
|
||||||
|
timeout: 30000,
|
||||||
|
});
|
||||||
|
|
||||||
|
this.logger.log(
|
||||||
|
`PrePlantingProxyService initialized, planting-service URL: ${plantingServiceUrl}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async getOrders(params: {
|
||||||
|
page?: number;
|
||||||
|
pageSize?: number;
|
||||||
|
keyword?: string;
|
||||||
|
status?: string;
|
||||||
|
accountSequences?: string[];
|
||||||
|
}) {
|
||||||
|
try {
|
||||||
|
const qp = new URLSearchParams();
|
||||||
|
if (params.page) qp.append('page', params.page.toString());
|
||||||
|
if (params.pageSize) qp.append('pageSize', params.pageSize.toString());
|
||||||
|
if (params.keyword) qp.append('keyword', params.keyword);
|
||||||
|
if (params.status) qp.append('status', params.status);
|
||||||
|
if (params.accountSequences?.length) qp.append('accountSequences', params.accountSequences.join(','));
|
||||||
|
|
||||||
|
const url = `/api/v1/internal/pre-planting/admin/orders?${qp.toString()}`;
|
||||||
|
this.logger.debug(`[getOrders] 请求: ${url}`);
|
||||||
|
const response = await this.httpClient.get(url);
|
||||||
|
return response.data;
|
||||||
|
} catch (error) {
|
||||||
|
this.logger.error(`[getOrders] 失败: ${error.message}`);
|
||||||
|
return { items: [], total: 0, page: params.page ?? 1, pageSize: params.pageSize ?? 20 };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async getPositions(params: {
|
||||||
|
page?: number;
|
||||||
|
pageSize?: number;
|
||||||
|
keyword?: string;
|
||||||
|
accountSequences?: string[];
|
||||||
|
}) {
|
||||||
|
try {
|
||||||
|
const qp = new URLSearchParams();
|
||||||
|
if (params.page) qp.append('page', params.page.toString());
|
||||||
|
if (params.pageSize) qp.append('pageSize', params.pageSize.toString());
|
||||||
|
if (params.keyword) qp.append('keyword', params.keyword);
|
||||||
|
if (params.accountSequences?.length) qp.append('accountSequences', params.accountSequences.join(','));
|
||||||
|
|
||||||
|
const url = `/api/v1/internal/pre-planting/admin/positions?${qp.toString()}`;
|
||||||
|
this.logger.debug(`[getPositions] 请求: ${url}`);
|
||||||
|
const response = await this.httpClient.get(url);
|
||||||
|
return response.data;
|
||||||
|
} catch (error) {
|
||||||
|
this.logger.error(`[getPositions] 失败: ${error.message}`);
|
||||||
|
return { items: [], total: 0, page: params.page ?? 1, pageSize: params.pageSize ?? 20 };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async getMerges(params: {
|
||||||
|
page?: number;
|
||||||
|
pageSize?: number;
|
||||||
|
keyword?: string;
|
||||||
|
status?: string;
|
||||||
|
}) {
|
||||||
|
try {
|
||||||
|
const qp = new URLSearchParams();
|
||||||
|
if (params.page) qp.append('page', params.page.toString());
|
||||||
|
if (params.pageSize) qp.append('pageSize', params.pageSize.toString());
|
||||||
|
if (params.keyword) qp.append('keyword', params.keyword);
|
||||||
|
if (params.status) qp.append('status', params.status);
|
||||||
|
|
||||||
|
const url = `/api/v1/internal/pre-planting/admin/merges?${qp.toString()}`;
|
||||||
|
this.logger.debug(`[getMerges] 请求: ${url}`);
|
||||||
|
const response = await this.httpClient.get(url);
|
||||||
|
return response.data;
|
||||||
|
} catch (error) {
|
||||||
|
this.logger.error(`[getMerges] 失败: ${error.message}`);
|
||||||
|
return { items: [], total: 0, page: params.page ?? 1, pageSize: params.pageSize ?? 20 };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async getStats() {
|
||||||
|
try {
|
||||||
|
const url = '/api/v1/internal/pre-planting/admin/stats';
|
||||||
|
this.logger.debug(`[getStats] 请求: ${url}`);
|
||||||
|
const response = await this.httpClient.get(url);
|
||||||
|
return response.data;
|
||||||
|
} catch (error) {
|
||||||
|
this.logger.error(`[getStats] 失败: ${error.message}`);
|
||||||
|
return {
|
||||||
|
totalOrders: 0,
|
||||||
|
totalPortions: 0,
|
||||||
|
totalAmount: 0,
|
||||||
|
totalMerges: 0,
|
||||||
|
totalTreesMerged: 0,
|
||||||
|
totalUsers: 0,
|
||||||
|
pendingContracts: 0,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,126 @@
|
||||||
|
import {
|
||||||
|
Controller,
|
||||||
|
Get,
|
||||||
|
Post,
|
||||||
|
Put,
|
||||||
|
Body,
|
||||||
|
Query,
|
||||||
|
HttpCode,
|
||||||
|
HttpStatus,
|
||||||
|
} from '@nestjs/common';
|
||||||
|
import { ApiTags, ApiOperation, ApiResponse } from '@nestjs/swagger';
|
||||||
|
import { IsNumber, IsString, IsBoolean, IsOptional } from 'class-validator';
|
||||||
|
import { TreePricingService } from './tree-pricing.service';
|
||||||
|
|
||||||
|
// ======================== DTO ========================
|
||||||
|
|
||||||
|
class UpdateSupplementDto {
|
||||||
|
/** 新的加价金额(整数 USDT) */
|
||||||
|
@IsNumber()
|
||||||
|
newSupplement: number;
|
||||||
|
/** 变更原因 */
|
||||||
|
@IsString()
|
||||||
|
reason: string;
|
||||||
|
/** 操作人ID */
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
operatorId?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
class UpdateAutoIncreaseDto {
|
||||||
|
/** 是否启用自动涨价 */
|
||||||
|
@IsBoolean()
|
||||||
|
enabled: boolean;
|
||||||
|
/** 每次自动涨价金额(整数 USDT) */
|
||||||
|
@IsOptional()
|
||||||
|
@IsNumber()
|
||||||
|
amount?: number;
|
||||||
|
/** 自动涨价间隔天数 */
|
||||||
|
@IsOptional()
|
||||||
|
@IsNumber()
|
||||||
|
intervalDays?: number;
|
||||||
|
/** 操作人ID */
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
operatorId?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
class ChangeLogQueryDto {
|
||||||
|
@IsOptional()
|
||||||
|
@IsNumber()
|
||||||
|
page?: number;
|
||||||
|
@IsOptional()
|
||||||
|
@IsNumber()
|
||||||
|
pageSize?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ======================== Admin Controller ========================
|
||||||
|
|
||||||
|
@ApiTags('认种定价配置')
|
||||||
|
@Controller('admin/tree-pricing')
|
||||||
|
export class AdminTreePricingController {
|
||||||
|
constructor(
|
||||||
|
private readonly pricingService: TreePricingService,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
@Get('config')
|
||||||
|
@ApiOperation({ summary: '获取当前定价配置' })
|
||||||
|
@ApiResponse({ status: HttpStatus.OK, description: '定价配置信息' })
|
||||||
|
async getConfig() {
|
||||||
|
return this.pricingService.getConfig();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post('supplement')
|
||||||
|
@HttpCode(HttpStatus.OK)
|
||||||
|
@ApiOperation({ summary: '手动修改加价金额(总部运营成本压力涨价)' })
|
||||||
|
@ApiResponse({ status: HttpStatus.OK, description: '更新成功,返回最新配置' })
|
||||||
|
async updateSupplement(@Body() dto: UpdateSupplementDto) {
|
||||||
|
return this.pricingService.updateSupplement(
|
||||||
|
dto.newSupplement,
|
||||||
|
dto.reason,
|
||||||
|
dto.operatorId || 'admin',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Put('auto-increase')
|
||||||
|
@ApiOperation({ summary: '设置自动涨价(总部运营成本压力自动涨价)' })
|
||||||
|
@ApiResponse({ status: HttpStatus.OK, description: '设置成功,返回最新配置' })
|
||||||
|
async updateAutoIncrease(@Body() dto: UpdateAutoIncreaseDto) {
|
||||||
|
return this.pricingService.updateAutoIncreaseSettings(
|
||||||
|
dto.enabled,
|
||||||
|
dto.amount,
|
||||||
|
dto.intervalDays,
|
||||||
|
dto.operatorId || 'admin',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get('change-log')
|
||||||
|
@ApiOperation({ summary: '获取价格变更审计日志' })
|
||||||
|
@ApiResponse({ status: HttpStatus.OK, description: '分页审计日志' })
|
||||||
|
async getChangeLog(@Query() query: ChangeLogQueryDto) {
|
||||||
|
return this.pricingService.getChangeLog(
|
||||||
|
Number(query.page) || 1,
|
||||||
|
Number(query.pageSize) || 20,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ======================== Public Controller ========================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 公开 API(供 planting-service 和 mobile-app 调用)
|
||||||
|
* 不需要管理员认证
|
||||||
|
*/
|
||||||
|
@ApiTags('认种定价配置-公开API')
|
||||||
|
@Controller('tree-pricing')
|
||||||
|
export class PublicTreePricingController {
|
||||||
|
constructor(
|
||||||
|
private readonly pricingService: TreePricingService,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
@Get('config')
|
||||||
|
@ApiOperation({ summary: '获取当前定价配置(公开接口)' })
|
||||||
|
async getConfig() {
|
||||||
|
return this.pricingService.getConfig();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,256 @@
|
||||||
|
import { Injectable, Logger } from '@nestjs/common';
|
||||||
|
import { PrismaService } from '../infrastructure/persistence/prisma/prisma.service';
|
||||||
|
|
||||||
|
/** 基础价常量 */
|
||||||
|
const BASE_PRICE = 15831; // 正式认种基础价(不变)
|
||||||
|
const BASE_PORTION_PRICE = 1887; // 预种基础价 [2026-03-01 调整] 9项 floor(18870/10) 取整 + 总部吸收余额
|
||||||
|
const PORTIONS_PER_TREE = 10; // [2026-03-01 调整] 5 → 10 份/棵
|
||||||
|
|
||||||
|
export interface TreePricingConfigResponse {
|
||||||
|
basePrice: number;
|
||||||
|
basePortionPrice: number;
|
||||||
|
currentSupplement: number;
|
||||||
|
totalPrice: number;
|
||||||
|
totalPortionPrice: number;
|
||||||
|
autoIncreaseEnabled: boolean;
|
||||||
|
autoIncreaseAmount: number;
|
||||||
|
autoIncreaseIntervalDays: number;
|
||||||
|
lastAutoIncreaseAt: Date | null;
|
||||||
|
nextAutoIncreaseAt: Date | null;
|
||||||
|
updatedAt: Date;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TreePriceChangeLogItem {
|
||||||
|
id: string;
|
||||||
|
changeType: string;
|
||||||
|
previousSupplement: number;
|
||||||
|
newSupplement: number;
|
||||||
|
changeAmount: number;
|
||||||
|
reason: string | null;
|
||||||
|
operatorId: string | null;
|
||||||
|
createdAt: Date;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class TreePricingService {
|
||||||
|
private readonly logger = new Logger(TreePricingService.name);
|
||||||
|
|
||||||
|
constructor(private readonly prisma: PrismaService) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取当前定价配置,不存在则创建默认配置
|
||||||
|
*/
|
||||||
|
async getConfig(): Promise<TreePricingConfigResponse> {
|
||||||
|
let config = await this.prisma.treePricingConfig.findFirst({
|
||||||
|
orderBy: { updatedAt: 'desc' },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!config) {
|
||||||
|
config = await this.prisma.treePricingConfig.create({
|
||||||
|
data: { currentSupplement: 0, autoIncreaseEnabled: false },
|
||||||
|
});
|
||||||
|
this.logger.log('[TREE-PRICING] Default config created');
|
||||||
|
}
|
||||||
|
|
||||||
|
const totalPrice = BASE_PRICE + config.currentSupplement;
|
||||||
|
// 预种价格 = 基础预种价(1887) + floor(加价部分/10)
|
||||||
|
const totalPortionPrice = BASE_PORTION_PRICE + Math.floor(config.currentSupplement / PORTIONS_PER_TREE);
|
||||||
|
return {
|
||||||
|
basePrice: BASE_PRICE,
|
||||||
|
basePortionPrice: BASE_PORTION_PRICE,
|
||||||
|
currentSupplement: config.currentSupplement,
|
||||||
|
totalPrice,
|
||||||
|
totalPortionPrice,
|
||||||
|
autoIncreaseEnabled: config.autoIncreaseEnabled,
|
||||||
|
autoIncreaseAmount: config.autoIncreaseAmount,
|
||||||
|
autoIncreaseIntervalDays: config.autoIncreaseIntervalDays,
|
||||||
|
lastAutoIncreaseAt: config.lastAutoIncreaseAt,
|
||||||
|
nextAutoIncreaseAt: config.nextAutoIncreaseAt,
|
||||||
|
updatedAt: config.updatedAt,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 手动修改加价金额(事务:同时更新配置 + 写入审计日志)
|
||||||
|
* 涨价原因:总部运营成本压力
|
||||||
|
*/
|
||||||
|
async updateSupplement(
|
||||||
|
newSupplement: number,
|
||||||
|
reason: string,
|
||||||
|
operatorId: string,
|
||||||
|
): Promise<TreePricingConfigResponse> {
|
||||||
|
// 允许负数(降价对冲),但总价不能低于 0
|
||||||
|
if (BASE_PRICE + newSupplement < 0) {
|
||||||
|
throw new Error(`调价金额不能低于 -${BASE_PRICE},否则总价为负`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const config = await this.getOrCreateConfig();
|
||||||
|
const previousSupplement = config.currentSupplement;
|
||||||
|
const changeAmount = newSupplement - previousSupplement;
|
||||||
|
|
||||||
|
if (changeAmount === 0) {
|
||||||
|
return this.getConfig();
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.prisma.$transaction([
|
||||||
|
this.prisma.treePricingConfig.update({
|
||||||
|
where: { id: config.id },
|
||||||
|
data: {
|
||||||
|
currentSupplement: newSupplement,
|
||||||
|
updatedBy: operatorId,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
this.prisma.treePriceChangeLog.create({
|
||||||
|
data: {
|
||||||
|
changeType: 'MANUAL',
|
||||||
|
previousSupplement,
|
||||||
|
newSupplement,
|
||||||
|
changeAmount,
|
||||||
|
reason: reason || '总部运营成本压力调价',
|
||||||
|
operatorId,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
|
||||||
|
this.logger.log(
|
||||||
|
`[TREE-PRICING] Manual supplement update: ${previousSupplement} → ${newSupplement} (${changeAmount > 0 ? '+' : ''}${changeAmount}) by ${operatorId}, reason: ${reason}`,
|
||||||
|
);
|
||||||
|
|
||||||
|
return this.getConfig();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 更新自动涨价设置
|
||||||
|
*/
|
||||||
|
async updateAutoIncreaseSettings(
|
||||||
|
enabled: boolean,
|
||||||
|
amount?: number,
|
||||||
|
intervalDays?: number,
|
||||||
|
operatorId?: string,
|
||||||
|
): Promise<TreePricingConfigResponse> {
|
||||||
|
const config = await this.getOrCreateConfig();
|
||||||
|
|
||||||
|
const data: Record<string, unknown> = {
|
||||||
|
autoIncreaseEnabled: enabled,
|
||||||
|
updatedBy: operatorId || null,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (amount !== undefined) {
|
||||||
|
if (amount < 0) throw new Error('自动涨价金额不能为负数');
|
||||||
|
data.autoIncreaseAmount = amount;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (intervalDays !== undefined) {
|
||||||
|
if (intervalDays < 1) throw new Error('自动涨价间隔天数不能小于1');
|
||||||
|
data.autoIncreaseIntervalDays = intervalDays;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 启用时计算下次涨价时间
|
||||||
|
if (enabled) {
|
||||||
|
const interval = intervalDays ?? config.autoIncreaseIntervalDays;
|
||||||
|
if (interval > 0) {
|
||||||
|
const nextDate = new Date();
|
||||||
|
nextDate.setDate(nextDate.getDate() + interval);
|
||||||
|
data.nextAutoIncreaseAt = nextDate;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
data.nextAutoIncreaseAt = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.prisma.treePricingConfig.update({
|
||||||
|
where: { id: config.id },
|
||||||
|
data,
|
||||||
|
});
|
||||||
|
|
||||||
|
this.logger.log(
|
||||||
|
`[TREE-PRICING] Auto-increase settings updated: enabled=${enabled}, amount=${amount ?? config.autoIncreaseAmount}, intervalDays=${intervalDays ?? config.autoIncreaseIntervalDays} by ${operatorId || 'unknown'}`,
|
||||||
|
);
|
||||||
|
|
||||||
|
return this.getConfig();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取变更审计日志(分页)
|
||||||
|
*/
|
||||||
|
async getChangeLog(
|
||||||
|
page: number = 1,
|
||||||
|
pageSize: number = 20,
|
||||||
|
): Promise<{ items: TreePriceChangeLogItem[]; total: number }> {
|
||||||
|
const [items, total] = await this.prisma.$transaction([
|
||||||
|
this.prisma.treePriceChangeLog.findMany({
|
||||||
|
orderBy: { createdAt: 'desc' },
|
||||||
|
skip: (page - 1) * pageSize,
|
||||||
|
take: pageSize,
|
||||||
|
}),
|
||||||
|
this.prisma.treePriceChangeLog.count(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
return { items, total };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 执行自动涨价(由定时任务调用)
|
||||||
|
* 涨价原因:系统自动涨价(总部运营成本压力)
|
||||||
|
* @returns true 如果执行了涨价,false 如果未到时间或未启用
|
||||||
|
*/
|
||||||
|
async executeAutoIncrease(): Promise<boolean> {
|
||||||
|
const config = await this.prisma.treePricingConfig.findFirst({
|
||||||
|
orderBy: { updatedAt: 'desc' },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!config) return false;
|
||||||
|
if (!config.autoIncreaseEnabled) return false;
|
||||||
|
if (!config.nextAutoIncreaseAt) return false;
|
||||||
|
if (config.autoIncreaseAmount <= 0) return false;
|
||||||
|
|
||||||
|
const now = new Date();
|
||||||
|
if (now < config.nextAutoIncreaseAt) return false;
|
||||||
|
|
||||||
|
const previousSupplement = config.currentSupplement;
|
||||||
|
const newSupplement = previousSupplement + config.autoIncreaseAmount;
|
||||||
|
const nextDate = new Date(now);
|
||||||
|
nextDate.setDate(nextDate.getDate() + config.autoIncreaseIntervalDays);
|
||||||
|
|
||||||
|
await this.prisma.$transaction([
|
||||||
|
this.prisma.treePricingConfig.update({
|
||||||
|
where: { id: config.id },
|
||||||
|
data: {
|
||||||
|
currentSupplement: newSupplement,
|
||||||
|
lastAutoIncreaseAt: now,
|
||||||
|
nextAutoIncreaseAt: nextDate,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
this.prisma.treePriceChangeLog.create({
|
||||||
|
data: {
|
||||||
|
changeType: 'AUTO',
|
||||||
|
previousSupplement,
|
||||||
|
newSupplement,
|
||||||
|
changeAmount: config.autoIncreaseAmount,
|
||||||
|
reason: '系统自动涨价(总部运营成本压力)',
|
||||||
|
operatorId: 'SYSTEM',
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
|
||||||
|
this.logger.log(
|
||||||
|
`[TREE-PRICING] Auto-increase executed: ${previousSupplement} → ${newSupplement} (+${config.autoIncreaseAmount}), next: ${nextDate.toISOString()}`,
|
||||||
|
);
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 获取或创建配置(内部方法) */
|
||||||
|
private async getOrCreateConfig() {
|
||||||
|
let config = await this.prisma.treePricingConfig.findFirst({
|
||||||
|
orderBy: { updatedAt: 'desc' },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!config) {
|
||||||
|
config = await this.prisma.treePricingConfig.create({
|
||||||
|
data: { currentSupplement: 0, autoIncreaseEnabled: false },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return config;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,83 @@
|
||||||
|
/**
|
||||||
|
* 推荐链预种统计代理服务
|
||||||
|
* [2026-03-02] 新增:通过内部 HTTP 调用 referral-service 获取预种统计数据
|
||||||
|
*
|
||||||
|
* === 架构 ===
|
||||||
|
* admin-web → admin-service (本服务) → referral-service /internal/referral/pre-planting-stats/*
|
||||||
|
* 复用现有 PrePlantingProxyService 的 axios 代理模式
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { Injectable, Logger } from '@nestjs/common';
|
||||||
|
import { ConfigService } from '@nestjs/config';
|
||||||
|
import axios, { AxiosInstance } from 'axios';
|
||||||
|
|
||||||
|
export interface PrePlantingStats {
|
||||||
|
selfPrePlantingPortions: number;
|
||||||
|
teamPrePlantingPortions: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class ReferralProxyService {
|
||||||
|
private readonly logger = new Logger(ReferralProxyService.name);
|
||||||
|
private readonly httpClient: AxiosInstance;
|
||||||
|
|
||||||
|
constructor(private readonly configService: ConfigService) {
|
||||||
|
const referralServiceUrl = this.configService.get<string>(
|
||||||
|
'REFERRAL_SERVICE_URL',
|
||||||
|
'http://rwa-referral-service:3004',
|
||||||
|
);
|
||||||
|
|
||||||
|
this.httpClient = axios.create({
|
||||||
|
baseURL: referralServiceUrl,
|
||||||
|
timeout: 30000,
|
||||||
|
});
|
||||||
|
|
||||||
|
this.logger.log(
|
||||||
|
`ReferralProxyService initialized, referral-service URL: ${referralServiceUrl}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取单个用户的预种统计(个人 + 团队预种份数)
|
||||||
|
*/
|
||||||
|
async getPrePlantingStats(accountSequence: string): Promise<PrePlantingStats> {
|
||||||
|
try {
|
||||||
|
const url = `/api/v1/internal/referral/pre-planting-stats/${accountSequence}`;
|
||||||
|
this.logger.debug(`[getPrePlantingStats] 请求: ${url}`);
|
||||||
|
const response = await this.httpClient.get(url);
|
||||||
|
return {
|
||||||
|
selfPrePlantingPortions: response.data?.selfPrePlantingPortions ?? 0,
|
||||||
|
teamPrePlantingPortions: response.data?.teamPrePlantingPortions ?? 0,
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
this.logger.error(`[getPrePlantingStats] 失败 (${accountSequence}): ${error.message}`);
|
||||||
|
return { selfPrePlantingPortions: 0, teamPrePlantingPortions: 0 };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 批量获取多个用户的预种统计
|
||||||
|
*/
|
||||||
|
async batchGetPrePlantingStats(
|
||||||
|
accountSequences: string[],
|
||||||
|
): Promise<Record<string, PrePlantingStats>> {
|
||||||
|
if (accountSequences.length === 0) {
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const url = '/api/v1/internal/referral/pre-planting-stats/batch';
|
||||||
|
this.logger.debug(`[batchGetPrePlantingStats] 请求: ${url}, 数量: ${accountSequences.length}`);
|
||||||
|
const response = await this.httpClient.post(url, { accountSequences });
|
||||||
|
return response.data ?? {};
|
||||||
|
} catch (error) {
|
||||||
|
this.logger.error(`[batchGetPrePlantingStats] 失败: ${error.message}`);
|
||||||
|
// 返回所有用户的零值默认
|
||||||
|
const defaults: Record<string, PrePlantingStats> = {};
|
||||||
|
for (const seq of accountSequences) {
|
||||||
|
defaults[seq] = { selfPrePlantingPortions: 0, teamPrePlantingPortions: 0 };
|
||||||
|
}
|
||||||
|
return defaults;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -9,6 +9,8 @@
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"license": "UNLICENSED",
|
"license": "UNLICENSED",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@alicloud/dysmsapi20170525": "^4.5.0",
|
||||||
|
"@alicloud/openapi-client": "^0.4.15",
|
||||||
"@nestjs/common": "^10.0.0",
|
"@nestjs/common": "^10.0.0",
|
||||||
"@nestjs/config": "^3.1.1",
|
"@nestjs/config": "^3.1.1",
|
||||||
"@nestjs/core": "^10.0.0",
|
"@nestjs/core": "^10.0.0",
|
||||||
|
|
@ -58,6 +60,198 @@
|
||||||
"typescript": "^5.1.3"
|
"typescript": "^5.1.3"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@alicloud/credentials": {
|
||||||
|
"version": "2.4.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/@alicloud/credentials/-/credentials-2.4.4.tgz",
|
||||||
|
"integrity": "sha512-/eRAGSKcniLIFQ1UCpDhB/IrHUZisQ1sc65ws/c2avxUMpXwH1rWAohb76SVAUJhiF4mwvLzLJM1Mn1XL4Xe/Q==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@alicloud/tea-typescript": "^1.8.0",
|
||||||
|
"httpx": "^2.3.3",
|
||||||
|
"ini": "^1.3.5",
|
||||||
|
"kitx": "^2.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@alicloud/darabonba-array": {
|
||||||
|
"version": "0.1.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@alicloud/darabonba-array/-/darabonba-array-0.1.2.tgz",
|
||||||
|
"integrity": "sha512-ZPuQ+bJyjrd8XVVm55kl+ypk7OQoi1ZH/DiToaAEQaGvgEjrTcvQkg71//vUX/6cvbLIF5piQDvhrLb+lUEIPQ==",
|
||||||
|
"license": "ISC",
|
||||||
|
"dependencies": {
|
||||||
|
"@alicloud/tea-typescript": "^1.7.1"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@alicloud/darabonba-encode-util": {
|
||||||
|
"version": "0.0.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@alicloud/darabonba-encode-util/-/darabonba-encode-util-0.0.2.tgz",
|
||||||
|
"integrity": "sha512-mlsNctkeqmR0RtgE1Rngyeadi5snLOAHBCWEtYf68d7tyKskosXDTNeZ6VCD/UfrUu4N51ItO8zlpfXiOgeg3A==",
|
||||||
|
"license": "ISC",
|
||||||
|
"dependencies": {
|
||||||
|
"moment": "^2.29.1"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@alicloud/darabonba-map": {
|
||||||
|
"version": "0.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@alicloud/darabonba-map/-/darabonba-map-0.0.1.tgz",
|
||||||
|
"integrity": "sha512-2ep+G3YDvuI+dRYVlmER1LVUQDhf9kEItmVB/bbEu1pgKzelcocCwAc79XZQjTcQGFgjDycf3vH87WLDGLFMlw==",
|
||||||
|
"license": "ISC",
|
||||||
|
"dependencies": {
|
||||||
|
"@alicloud/tea-typescript": "^1.7.1"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@alicloud/darabonba-signature-util": {
|
||||||
|
"version": "0.0.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/@alicloud/darabonba-signature-util/-/darabonba-signature-util-0.0.4.tgz",
|
||||||
|
"integrity": "sha512-I1TtwtAnzLamgqnAaOkN0IGjwkiti//0a7/auyVThdqiC/3kyafSAn6znysWOmzub4mrzac2WiqblZKFcN5NWg==",
|
||||||
|
"license": "ISC",
|
||||||
|
"dependencies": {
|
||||||
|
"@alicloud/darabonba-encode-util": "^0.0.1"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@alicloud/darabonba-signature-util/node_modules/@alicloud/darabonba-encode-util": {
|
||||||
|
"version": "0.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@alicloud/darabonba-encode-util/-/darabonba-encode-util-0.0.1.tgz",
|
||||||
|
"integrity": "sha512-Sl5vCRVAYMqwmvXpJLM9hYoCHOMsQlGxaWSGhGWulpKk/NaUBArtoO1B0yHruJf1C5uHhEJIaylYcM48icFHgw==",
|
||||||
|
"license": "ISC",
|
||||||
|
"dependencies": {
|
||||||
|
"@alicloud/tea-typescript": "^1.7.1",
|
||||||
|
"moment": "^2.29.1"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@alicloud/darabonba-string": {
|
||||||
|
"version": "1.0.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/@alicloud/darabonba-string/-/darabonba-string-1.0.3.tgz",
|
||||||
|
"integrity": "sha512-NyWwrU8cAIesWk3uHL1Q7pTDTqLkCI/0PmJXC4/4A0MFNAZ9Ouq0iFBsRqvfyUujSSM+WhYLuTfakQXiVLkTMA==",
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"dependencies": {
|
||||||
|
"@alicloud/tea-typescript": "^1.5.1"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@alicloud/dysmsapi20170525": {
|
||||||
|
"version": "4.5.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@alicloud/dysmsapi20170525/-/dysmsapi20170525-4.5.0.tgz",
|
||||||
|
"integrity": "sha512-nhKdRDLRDhTVxr7VbMbBi6UtJWmVFgwySU2ohkJ1zL7jd98DEGGy8CE/n7W44ZP9+yTBBmLhM8qW1C12kHDEIg==",
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"dependencies": {
|
||||||
|
"@alicloud/openapi-core": "^1.0.0",
|
||||||
|
"@darabonba/typescript": "^1.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@alicloud/endpoint-util": {
|
||||||
|
"version": "0.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@alicloud/endpoint-util/-/endpoint-util-0.0.1.tgz",
|
||||||
|
"integrity": "sha512-+pH7/KEXup84cHzIL6UJAaPqETvln4yXlD9JzlrqioyCSaWxbug5FUobsiI6fuUOpw5WwoB3fWAtGbFnJ1K3Yg==",
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"dependencies": {
|
||||||
|
"@alicloud/tea-typescript": "^1.5.1",
|
||||||
|
"kitx": "^2.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@alicloud/gateway-pop": {
|
||||||
|
"version": "0.0.6",
|
||||||
|
"resolved": "https://registry.npmjs.org/@alicloud/gateway-pop/-/gateway-pop-0.0.6.tgz",
|
||||||
|
"integrity": "sha512-KF4I+JvfYuLKc3fWeWYIZ7lOVJ9jRW0sQXdXidZn1DKZ978ncfGf7i0LBfONGk4OxvNb/HD3/0yYhkgZgPbKtA==",
|
||||||
|
"license": "ISC",
|
||||||
|
"dependencies": {
|
||||||
|
"@alicloud/credentials": "^2",
|
||||||
|
"@alicloud/darabonba-array": "^0.1.0",
|
||||||
|
"@alicloud/darabonba-encode-util": "^0.0.2",
|
||||||
|
"@alicloud/darabonba-map": "^0.0.1",
|
||||||
|
"@alicloud/darabonba-signature-util": "^0.0.4",
|
||||||
|
"@alicloud/darabonba-string": "^1.0.2",
|
||||||
|
"@alicloud/endpoint-util": "^0.0.1",
|
||||||
|
"@alicloud/gateway-spi": "^0.0.8",
|
||||||
|
"@alicloud/openapi-util": "^0.3.2",
|
||||||
|
"@alicloud/tea-typescript": "^1.7.1",
|
||||||
|
"@alicloud/tea-util": "^1.4.8"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@alicloud/gateway-spi": {
|
||||||
|
"version": "0.0.8",
|
||||||
|
"resolved": "https://registry.npmjs.org/@alicloud/gateway-spi/-/gateway-spi-0.0.8.tgz",
|
||||||
|
"integrity": "sha512-KM7fu5asjxZPmrz9sJGHJeSU+cNQNOxW+SFmgmAIrITui5hXL2LB+KNRuzWmlwPjnuA2X3/keq9h6++S9jcV5g==",
|
||||||
|
"license": "ISC",
|
||||||
|
"dependencies": {
|
||||||
|
"@alicloud/credentials": "^2",
|
||||||
|
"@alicloud/tea-typescript": "^1.7.1"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@alicloud/openapi-client": {
|
||||||
|
"version": "0.4.15",
|
||||||
|
"resolved": "https://registry.npmjs.org/@alicloud/openapi-client/-/openapi-client-0.4.15.tgz",
|
||||||
|
"integrity": "sha512-4VE0/k5ZdQbAhOSTqniVhuX1k5DUeUMZv74degn3wIWjLY6Bq+hxjaGsaHYlLZ2gA5wUrs8NcI5TE+lIQS3iiA==",
|
||||||
|
"license": "ISC",
|
||||||
|
"dependencies": {
|
||||||
|
"@alicloud/credentials": "^2.4.2",
|
||||||
|
"@alicloud/gateway-spi": "^0.0.8",
|
||||||
|
"@alicloud/openapi-util": "^0.3.2",
|
||||||
|
"@alicloud/tea-typescript": "^1.7.1",
|
||||||
|
"@alicloud/tea-util": "1.4.9",
|
||||||
|
"@alicloud/tea-xml": "0.0.3"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@alicloud/openapi-core": {
|
||||||
|
"version": "1.0.7",
|
||||||
|
"resolved": "https://registry.npmjs.org/@alicloud/openapi-core/-/openapi-core-1.0.7.tgz",
|
||||||
|
"integrity": "sha512-I80PQVfmlzRiXGHwutMp2zTpiqUVv8ts30nWAfksfHUSTIapk3nj9IXaPbULMPGNV6xqEyshO2bj2a+pmwc2tQ==",
|
||||||
|
"hasInstallScript": true,
|
||||||
|
"license": "ISC",
|
||||||
|
"dependencies": {
|
||||||
|
"@alicloud/credentials": "^2.4.2",
|
||||||
|
"@alicloud/gateway-pop": "0.0.6",
|
||||||
|
"@alicloud/gateway-spi": "^0.0.8",
|
||||||
|
"@darabonba/typescript": "^1.0.2"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@alicloud/openapi-util": {
|
||||||
|
"version": "0.3.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/@alicloud/openapi-util/-/openapi-util-0.3.3.tgz",
|
||||||
|
"integrity": "sha512-vf0cQ/q8R2U7ZO88X5hDiu1yV3t/WexRj+YycWxRutkH/xVXfkmpRgps8lmNEk7Ar+0xnY8+daN2T+2OyB9F4A==",
|
||||||
|
"license": "ISC",
|
||||||
|
"dependencies": {
|
||||||
|
"@alicloud/tea-typescript": "^1.7.1",
|
||||||
|
"@alicloud/tea-util": "^1.3.0",
|
||||||
|
"kitx": "^2.1.0",
|
||||||
|
"sm3": "^1.0.3"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@alicloud/tea-typescript": {
|
||||||
|
"version": "1.8.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@alicloud/tea-typescript/-/tea-typescript-1.8.0.tgz",
|
||||||
|
"integrity": "sha512-CWXWaquauJf0sW30mgJRVu9aaXyBth5uMBCUc+5vKTK1zlgf3hIqRUjJZbjlwHwQ5y9anwcu18r48nOZb7l2QQ==",
|
||||||
|
"license": "ISC",
|
||||||
|
"dependencies": {
|
||||||
|
"@types/node": "^12.0.2",
|
||||||
|
"httpx": "^2.2.6"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@alicloud/tea-typescript/node_modules/@types/node": {
|
||||||
|
"version": "12.20.55",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/node/-/node-12.20.55.tgz",
|
||||||
|
"integrity": "sha512-J8xLz7q2OFulZ2cyGTLE1TbbZcjpno7FaN6zdJNrgAdrJ+DZzh/uFR6YrTb4C+nXakvud8Q4+rbhoIWlYQbUFQ==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/@alicloud/tea-util": {
|
||||||
|
"version": "1.4.9",
|
||||||
|
"resolved": "https://registry.npmjs.org/@alicloud/tea-util/-/tea-util-1.4.9.tgz",
|
||||||
|
"integrity": "sha512-S0wz76rGtoPKskQtRTGqeuqBHFj8BqUn0Vh+glXKun2/9UpaaaWmuJwcmtImk6bJZfLYEShDF/kxDmDJoNYiTw==",
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"dependencies": {
|
||||||
|
"@alicloud/tea-typescript": "^1.5.1",
|
||||||
|
"kitx": "^2.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@alicloud/tea-xml": {
|
||||||
|
"version": "0.0.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/@alicloud/tea-xml/-/tea-xml-0.0.3.tgz",
|
||||||
|
"integrity": "sha512-+/9GliugjrLglsXVrd1D80EqqKgGpyA0eQ6+1ZdUOYCaRguaSwz44trX3PaxPu/HhIPJg9PsGQQ3cSLXWZjbAA==",
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"dependencies": {
|
||||||
|
"@alicloud/tea-typescript": "^1",
|
||||||
|
"@types/xml2js": "^0.4.5",
|
||||||
|
"xml2js": "^0.6.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@angular-devkit/core": {
|
"node_modules/@angular-devkit/core": {
|
||||||
"version": "17.3.11",
|
"version": "17.3.11",
|
||||||
"resolved": "https://registry.npmjs.org/@angular-devkit/core/-/core-17.3.11.tgz",
|
"resolved": "https://registry.npmjs.org/@angular-devkit/core/-/core-17.3.11.tgz",
|
||||||
|
|
@ -780,6 +974,20 @@
|
||||||
"@jridgewell/sourcemap-codec": "^1.4.10"
|
"@jridgewell/sourcemap-codec": "^1.4.10"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@darabonba/typescript": {
|
||||||
|
"version": "1.0.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/@darabonba/typescript/-/typescript-1.0.4.tgz",
|
||||||
|
"integrity": "sha512-icl8RGTw4DiWRpco6dVh21RS0IqrH4s/eEV36TZvz/e1+paogSZjaAgox7ByrlEuvG+bo5d8miq/dRlqiUaL/w==",
|
||||||
|
"license": "Apache License 2.0",
|
||||||
|
"dependencies": {
|
||||||
|
"@alicloud/tea-typescript": "^1.5.1",
|
||||||
|
"httpx": "^2.3.2",
|
||||||
|
"lodash": "^4.17.21",
|
||||||
|
"moment": "^2.30.1",
|
||||||
|
"moment-timezone": "^0.5.45",
|
||||||
|
"xml2js": "^0.6.2"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@eslint-community/eslint-utils": {
|
"node_modules/@eslint-community/eslint-utils": {
|
||||||
"version": "4.9.1",
|
"version": "4.9.1",
|
||||||
"resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.9.1.tgz",
|
"resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.9.1.tgz",
|
||||||
|
|
@ -2665,6 +2873,15 @@
|
||||||
"integrity": "sha512-T8L6i7wCuyoK8A/ZeLYt1+q0ty3Zb9+qbSSvrIVitzT3YjZqkTZ40IbRsPanlB4h1QB3JVL1SYCdR6ngtFYcuA==",
|
"integrity": "sha512-T8L6i7wCuyoK8A/ZeLYt1+q0ty3Zb9+qbSSvrIVitzT3YjZqkTZ40IbRsPanlB4h1QB3JVL1SYCdR6ngtFYcuA==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/@types/xml2js": {
|
||||||
|
"version": "0.4.14",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/xml2js/-/xml2js-0.4.14.tgz",
|
||||||
|
"integrity": "sha512-4YnrRemBShWRO2QjvUin8ESA41rH+9nQGLUGZV/1IDhi3SL9OhdpNC/MrulTWuptXKwhx/aDxE7toV0f/ypIXQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@types/node": "*"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@types/yargs": {
|
"node_modules/@types/yargs": {
|
||||||
"version": "17.0.35",
|
"version": "17.0.35",
|
||||||
"resolved": "https://registry.npmjs.org/@types/yargs/-/yargs-17.0.35.tgz",
|
"resolved": "https://registry.npmjs.org/@types/yargs/-/yargs-17.0.35.tgz",
|
||||||
|
|
@ -5826,6 +6043,16 @@
|
||||||
"node": ">= 6"
|
"node": ">= 6"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/httpx": {
|
||||||
|
"version": "2.3.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/httpx/-/httpx-2.3.3.tgz",
|
||||||
|
"integrity": "sha512-k1qv94u1b6e+XKCxVbLgYlOypVP9MPGpnN5G/vxFf6tDO4V3xpz3d6FUOY/s8NtPgaq5RBVVgSB+7IHpVxMYzw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@types/node": "^20",
|
||||||
|
"debug": "^4.1.1"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/human-signals": {
|
"node_modules/human-signals": {
|
||||||
"version": "2.1.0",
|
"version": "2.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/human-signals/-/human-signals-2.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/human-signals/-/human-signals-2.1.0.tgz",
|
||||||
|
|
@ -5942,6 +6169,12 @@
|
||||||
"integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==",
|
"integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==",
|
||||||
"license": "ISC"
|
"license": "ISC"
|
||||||
},
|
},
|
||||||
|
"node_modules/ini": {
|
||||||
|
"version": "1.3.8",
|
||||||
|
"resolved": "https://registry.npmjs.org/ini/-/ini-1.3.8.tgz",
|
||||||
|
"integrity": "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==",
|
||||||
|
"license": "ISC"
|
||||||
|
},
|
||||||
"node_modules/inquirer": {
|
"node_modules/inquirer": {
|
||||||
"version": "8.2.6",
|
"version": "8.2.6",
|
||||||
"resolved": "https://registry.npmjs.org/inquirer/-/inquirer-8.2.6.tgz",
|
"resolved": "https://registry.npmjs.org/inquirer/-/inquirer-8.2.6.tgz",
|
||||||
|
|
@ -7132,6 +7365,24 @@
|
||||||
"json-buffer": "3.0.1"
|
"json-buffer": "3.0.1"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/kitx": {
|
||||||
|
"version": "2.2.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/kitx/-/kitx-2.2.0.tgz",
|
||||||
|
"integrity": "sha512-tBMwe6AALTBQJb0woQDD40734NKzb0Kzi3k7wQj9ar3AbP9oqhoVrdXPh7rk2r00/glIgd0YbToIUJsnxWMiIg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@types/node": "^22.5.4"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/kitx/node_modules/@types/node": {
|
||||||
|
"version": "22.19.15",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.15.tgz",
|
||||||
|
"integrity": "sha512-F0R/h2+dsy5wJAUe3tAU6oqa2qbWY5TpNfL/RGmo1y38hiyO1w3x2jPtt76wmuaJI4DQnOBu21cNXQ2STIUUWg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"undici-types": "~6.21.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/kleur": {
|
"node_modules/kleur": {
|
||||||
"version": "3.0.3",
|
"version": "3.0.3",
|
||||||
"resolved": "https://registry.npmjs.org/kleur/-/kleur-3.0.3.tgz",
|
"resolved": "https://registry.npmjs.org/kleur/-/kleur-3.0.3.tgz",
|
||||||
|
|
@ -7587,6 +7838,27 @@
|
||||||
"mkdirp": "bin/cmd.js"
|
"mkdirp": "bin/cmd.js"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/moment": {
|
||||||
|
"version": "2.30.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/moment/-/moment-2.30.1.tgz",
|
||||||
|
"integrity": "sha512-uEmtNhbDOrWPFS+hdjFCBfy9f2YoyzRpwcl+DqpC6taX21FzsTLQVbMV/W7PzNSX6x/bhC1zA3c2UQ5NzH6how==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": "*"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/moment-timezone": {
|
||||||
|
"version": "0.5.48",
|
||||||
|
"resolved": "https://registry.npmjs.org/moment-timezone/-/moment-timezone-0.5.48.tgz",
|
||||||
|
"integrity": "sha512-f22b8LV1gbTO2ms2j2z13MuPogNoh5UzxL3nzNAYKGraILnbGc9NEE6dyiiiLv46DGRb8A4kg8UKWLjPthxBHw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"moment": "^2.29.4"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": "*"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/ms": {
|
"node_modules/ms": {
|
||||||
"version": "2.1.3",
|
"version": "2.1.3",
|
||||||
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
|
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
|
||||||
|
|
@ -8719,6 +8991,15 @@
|
||||||
"integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==",
|
"integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/sax": {
|
||||||
|
"version": "1.5.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/sax/-/sax-1.5.0.tgz",
|
||||||
|
"integrity": "sha512-21IYA3Q5cQf089Z6tgaUTr7lDAyzoTPx5HRtbhsME8Udispad8dC/+sziTNugOEx54ilvatQ9YCzl4KQLPcRHA==",
|
||||||
|
"license": "BlueOak-1.0.0",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=11.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/schema-utils": {
|
"node_modules/schema-utils": {
|
||||||
"version": "3.3.0",
|
"version": "3.3.0",
|
||||||
"resolved": "https://registry.npmjs.org/schema-utils/-/schema-utils-3.3.0.tgz",
|
"resolved": "https://registry.npmjs.org/schema-utils/-/schema-utils-3.3.0.tgz",
|
||||||
|
|
@ -9004,6 +9285,12 @@
|
||||||
"node": ">=8"
|
"node": ">=8"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/sm3": {
|
||||||
|
"version": "1.0.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/sm3/-/sm3-1.0.3.tgz",
|
||||||
|
"integrity": "sha512-KyFkIfr8QBlFG3uc3NaljaXdYcsbRy1KrSfc4tsQV8jW68jAktGeOcifu530Vx/5LC+PULHT0Rv8LiI8Gw+c1g==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/source-map": {
|
"node_modules/source-map": {
|
||||||
"version": "0.7.4",
|
"version": "0.7.4",
|
||||||
"resolved": "https://registry.npmjs.org/source-map/-/source-map-0.7.4.tgz",
|
"resolved": "https://registry.npmjs.org/source-map/-/source-map-0.7.4.tgz",
|
||||||
|
|
@ -10295,6 +10582,28 @@
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "ISC"
|
"license": "ISC"
|
||||||
},
|
},
|
||||||
|
"node_modules/xml2js": {
|
||||||
|
"version": "0.6.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/xml2js/-/xml2js-0.6.2.tgz",
|
||||||
|
"integrity": "sha512-T4rieHaC1EXcES0Kxxj4JWgaUQHDk+qwHcYOCFHfiwKz7tOVPLq7Hjq9dM1WCMhylqMEfP7hMcOIChvotiZegA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"sax": ">=0.6.0",
|
||||||
|
"xmlbuilder": "~11.0.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=4.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/xmlbuilder": {
|
||||||
|
"version": "11.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/xmlbuilder/-/xmlbuilder-11.0.1.tgz",
|
||||||
|
"integrity": "sha512-fDlsI/kFEx7gLvbecc0/ohLG50fugQp8ryHzMTuW9vSa1GJ0XYWKnhsUx7oie3G98+r56aTQIUB4kht42R3JvA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=4.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/xtend": {
|
"node_modules/xtend": {
|
||||||
"version": "4.0.2",
|
"version": "4.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz",
|
||||||
|
|
|
||||||
|
|
@ -25,6 +25,8 @@
|
||||||
"prisma:studio": "prisma studio"
|
"prisma:studio": "prisma studio"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@alicloud/dysmsapi20170525": "^4.5.0",
|
||||||
|
"@alicloud/openapi-client": "^0.4.15",
|
||||||
"@nestjs/common": "^10.0.0",
|
"@nestjs/common": "^10.0.0",
|
||||||
"@nestjs/config": "^3.1.1",
|
"@nestjs/config": "^3.1.1",
|
||||||
"@nestjs/core": "^10.0.0",
|
"@nestjs/core": "^10.0.0",
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,6 @@
|
||||||
-- ============================================================================
|
-- ============================================================================
|
||||||
-- auth-service 初始化 migration
|
-- auth-service 初始化 migration
|
||||||
-- 合并自: 20260111000000_init, 20260111083500_allow_nullable_phone_password,
|
-- 合并自: 0001_init, 0002_add_transactional_idempotency
|
||||||
-- 20260112110000_add_nickname_to_synced_legacy_users
|
|
||||||
-- ============================================================================
|
-- ============================================================================
|
||||||
|
|
||||||
-- CreateEnum
|
-- CreateEnum
|
||||||
|
|
@ -241,3 +240,26 @@ ALTER TABLE "sms_logs" ADD CONSTRAINT "sms_logs_user_id_fkey" FOREIGN KEY ("user
|
||||||
|
|
||||||
-- AddForeignKey
|
-- 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;
|
ALTER TABLE "login_logs" ADD CONSTRAINT "login_logs_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- ============================================================================
|
||||||
|
-- 事务性幂等消费支持 (从 0002_add_transactional_idempotency 合并)
|
||||||
|
-- 用于 1.0 -> 2.0 CDC 同步的 100% exactly-once 语义
|
||||||
|
-- ============================================================================
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "processed_cdc_events" (
|
||||||
|
"id" BIGSERIAL NOT NULL,
|
||||||
|
"source_topic" TEXT NOT NULL,
|
||||||
|
"offset" BIGINT NOT NULL,
|
||||||
|
"table_name" TEXT NOT NULL,
|
||||||
|
"operation" TEXT NOT NULL,
|
||||||
|
"processed_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
|
||||||
|
CONSTRAINT "processed_cdc_events_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateIndex (复合唯一索引保证幂等性)
|
||||||
|
CREATE UNIQUE INDEX "processed_cdc_events_source_topic_offset_key" ON "processed_cdc_events"("source_topic", "offset");
|
||||||
|
|
||||||
|
-- CreateIndex (时间索引用于清理旧数据)
|
||||||
|
CREATE INDEX "processed_cdc_events_processed_at_idx" ON "processed_cdc_events"("processed_at");
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,27 @@
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "synced_wallet_addresses" (
|
||||||
|
"id" BIGSERIAL NOT NULL,
|
||||||
|
"legacy_address_id" BIGINT NOT NULL,
|
||||||
|
"legacy_user_id" BIGINT NOT NULL,
|
||||||
|
"chain_type" TEXT NOT NULL,
|
||||||
|
"address" TEXT NOT NULL,
|
||||||
|
"public_key" TEXT NOT NULL,
|
||||||
|
"status" TEXT NOT NULL DEFAULT 'ACTIVE',
|
||||||
|
"legacy_bound_at" TIMESTAMP(3) NOT NULL,
|
||||||
|
"source_sequence_num" BIGINT NOT NULL,
|
||||||
|
"synced_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
|
||||||
|
CONSTRAINT "synced_wallet_addresses_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX "synced_wallet_addresses_legacy_address_id_key" ON "synced_wallet_addresses"("legacy_address_id");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX "synced_wallet_addresses_legacy_user_id_chain_type_key" ON "synced_wallet_addresses"("legacy_user_id", "chain_type");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "synced_wallet_addresses_legacy_user_id_idx" ON "synced_wallet_addresses"("legacy_user_id");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "synced_wallet_addresses_chain_type_address_idx" ON "synced_wallet_addresses"("chain_type", "address");
|
||||||
|
|
@ -1,25 +0,0 @@
|
||||||
-- ============================================================================
|
|
||||||
-- 添加事务性幂等消费支持
|
|
||||||
-- 用于 1.0 -> 2.0 CDC 同步的 100% exactly-once 语义
|
|
||||||
-- ============================================================================
|
|
||||||
|
|
||||||
-- 创建 processed_cdc_events 表(用于 CDC 事件幂等)
|
|
||||||
-- 唯一键: (source_topic, offset) - Kafka topic 名称 + 消息偏移量
|
|
||||||
-- 用于保证每个 CDC 事件只处理一次(exactly-once 语义)
|
|
||||||
CREATE TABLE IF NOT EXISTS "processed_cdc_events" (
|
|
||||||
"id" BIGSERIAL NOT NULL,
|
|
||||||
"source_topic" VARCHAR(200) NOT NULL, -- Kafka topic 名称(如 cdc.identity.public.user_accounts)
|
|
||||||
"offset" BIGINT NOT NULL, -- Kafka 消息偏移量(在 partition 内唯一)
|
|
||||||
"table_name" VARCHAR(100) NOT NULL, -- 源表名
|
|
||||||
"operation" VARCHAR(10) NOT NULL, -- CDC 操作类型: c(create), u(update), d(delete), r(snapshot read)
|
|
||||||
"processed_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
|
||||||
|
|
||||||
CONSTRAINT "processed_cdc_events_pkey" PRIMARY KEY ("id")
|
|
||||||
);
|
|
||||||
|
|
||||||
-- 复合唯一索引:(source_topic, offset) 保证幂等性
|
|
||||||
-- 注意:这不是数据库自增 ID,而是 Kafka 消息的唯一标识
|
|
||||||
CREATE UNIQUE INDEX "processed_cdc_events_source_topic_offset_key" ON "processed_cdc_events"("source_topic", "offset");
|
|
||||||
|
|
||||||
-- 时间索引用于清理旧数据
|
|
||||||
CREATE INDEX "processed_cdc_events_processed_at_idx" ON "processed_cdc_events"("processed_at");
|
|
||||||
|
|
@ -0,0 +1,8 @@
|
||||||
|
-- AlterTable
|
||||||
|
-- 添加支付密码字段
|
||||||
|
-- 支付密码独立于登录密码,用于交易时的二次验证
|
||||||
|
-- 存储的是bcrypt哈希值,不是明文密码
|
||||||
|
ALTER TABLE "users" ADD COLUMN "trade_password_hash" TEXT;
|
||||||
|
|
||||||
|
-- 添加注释说明该字段用途
|
||||||
|
COMMENT ON COLUMN "users"."trade_password_hash" IS '支付密码哈希值 - 用于交易时的二次安全验证,独立于登录密码';
|
||||||
|
|
@ -20,6 +20,7 @@ model User {
|
||||||
// 基本信息
|
// 基本信息
|
||||||
phone String @unique
|
phone String @unique
|
||||||
passwordHash String @map("password_hash")
|
passwordHash String @map("password_hash")
|
||||||
|
tradePasswordHash String? @map("trade_password_hash") // 支付密码(独立于登录密码)
|
||||||
|
|
||||||
// 统一关联键 (跨所有服务)
|
// 统一关联键 (跨所有服务)
|
||||||
// V1: 12位 (D + 6位日期 + 5位序号), 如 D2512110008
|
// V1: 12位 (D + 6位日期 + 5位序号), 如 D2512110008
|
||||||
|
|
@ -104,6 +105,33 @@ model SyncedLegacyUser {
|
||||||
@@map("synced_legacy_users")
|
@@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")
|
||||||
|
}
|
||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
// 刷新令牌
|
// 刷新令牌
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
|
|
@ -257,6 +285,44 @@ enum OutboxStatus {
|
||||||
FAILED
|
FAILED
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// 用户能力控制 (Capability-based permissions)
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
model UserCapability {
|
||||||
|
id BigInt @id @default(autoincrement())
|
||||||
|
accountSequence String @map("account_sequence")
|
||||||
|
capability String // LOGIN, TRADING, C2C, TRANSFER_IN, TRANSFER_OUT, P2P_SEND, P2P_RECEIVE, MINING_CLAIM, KYC, PROFILE_EDIT, VIEW_ASSET, VIEW_TEAM, VIEW_RECORDS
|
||||||
|
enabled Boolean @default(true)
|
||||||
|
reason String? // 禁用原因
|
||||||
|
disabledBy String? @map("disabled_by") // 操作人
|
||||||
|
disabledAt DateTime? @map("disabled_at")
|
||||||
|
expiresAt DateTime? @map("expires_at") // null=永久
|
||||||
|
createdAt DateTime @default(now()) @map("created_at")
|
||||||
|
updatedAt DateTime @updatedAt @map("updated_at")
|
||||||
|
|
||||||
|
@@unique([accountSequence, capability])
|
||||||
|
@@index([accountSequence])
|
||||||
|
@@index([expiresAt])
|
||||||
|
@@map("user_capabilities")
|
||||||
|
}
|
||||||
|
|
||||||
|
model CapabilityLog {
|
||||||
|
id BigInt @id @default(autoincrement())
|
||||||
|
accountSequence String @map("account_sequence")
|
||||||
|
capability String
|
||||||
|
action String // DISABLE, ENABLE, EXPIRE
|
||||||
|
reason String?
|
||||||
|
operatorId String? @map("operator_id")
|
||||||
|
previousValue Boolean @map("previous_value")
|
||||||
|
newValue Boolean @map("new_value")
|
||||||
|
expiresAt DateTime? @map("expires_at")
|
||||||
|
createdAt DateTime @default(now()) @map("created_at")
|
||||||
|
|
||||||
|
@@index([accountSequence, createdAt(sort: Desc)])
|
||||||
|
@@map("capability_logs")
|
||||||
|
}
|
||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
// CDC 幂等消费追踪
|
// CDC 幂等消费追踪
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
|
|
|
||||||
|
|
@ -5,13 +5,17 @@ import {
|
||||||
AuthController,
|
AuthController,
|
||||||
SmsController,
|
SmsController,
|
||||||
PasswordController,
|
PasswordController,
|
||||||
|
TradePasswordController,
|
||||||
KycController,
|
KycController,
|
||||||
UserController,
|
UserController,
|
||||||
HealthController,
|
HealthController,
|
||||||
AdminController,
|
AdminController,
|
||||||
|
InternalController,
|
||||||
|
CapabilityController,
|
||||||
} from './controllers';
|
} from './controllers';
|
||||||
import { ApplicationModule } from '@/application';
|
import { ApplicationModule } from '@/application';
|
||||||
import { JwtAuthGuard } from '@/shared/guards/jwt-auth.guard';
|
import { JwtAuthGuard } from '@/shared/guards/jwt-auth.guard';
|
||||||
|
import { CapabilityGuard } from '@/shared/guards/capability.guard';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [
|
imports: [
|
||||||
|
|
@ -31,11 +35,14 @@ import { JwtAuthGuard } from '@/shared/guards/jwt-auth.guard';
|
||||||
AuthController,
|
AuthController,
|
||||||
SmsController,
|
SmsController,
|
||||||
PasswordController,
|
PasswordController,
|
||||||
|
TradePasswordController,
|
||||||
KycController,
|
KycController,
|
||||||
UserController,
|
UserController,
|
||||||
HealthController,
|
HealthController,
|
||||||
AdminController,
|
AdminController,
|
||||||
|
InternalController,
|
||||||
|
CapabilityController,
|
||||||
],
|
],
|
||||||
providers: [JwtAuthGuard],
|
providers: [JwtAuthGuard, CapabilityGuard],
|
||||||
})
|
})
|
||||||
export class ApiModule {}
|
export class ApiModule {}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,24 @@
|
||||||
|
import { Controller, Get, UseGuards } from '@nestjs/common';
|
||||||
|
import { JwtAuthGuard } from '@/shared/guards/jwt-auth.guard';
|
||||||
|
import { CurrentUser } from '@/shared/decorators/current-user.decorator';
|
||||||
|
import { CapabilityService } from '@/application/services/capability.service';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 用户端能力权限 API
|
||||||
|
*/
|
||||||
|
@Controller('auth/user')
|
||||||
|
@UseGuards(JwtAuthGuard)
|
||||||
|
export class CapabilityController {
|
||||||
|
constructor(private readonly capabilityService: CapabilityService) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取当前用户的能力权限列表
|
||||||
|
* mining-app 登录后调用此接口获取能力状态
|
||||||
|
*/
|
||||||
|
@Get('capabilities')
|
||||||
|
async getCapabilities(
|
||||||
|
@CurrentUser('accountSequence') accountSequence: string,
|
||||||
|
) {
|
||||||
|
return this.capabilityService.getCapabilities(accountSequence);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,7 +1,10 @@
|
||||||
export * from './auth.controller';
|
export * from './auth.controller';
|
||||||
export * from './sms.controller';
|
export * from './sms.controller';
|
||||||
export * from './password.controller';
|
export * from './password.controller';
|
||||||
|
export * from './trade-password.controller';
|
||||||
export * from './kyc.controller';
|
export * from './kyc.controller';
|
||||||
export * from './user.controller';
|
export * from './user.controller';
|
||||||
export * from './health.controller';
|
export * from './health.controller';
|
||||||
export * from './admin.controller';
|
export * from './admin.controller';
|
||||||
|
export * from './internal.controller';
|
||||||
|
export * from './capability.controller';
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,149 @@
|
||||||
|
import { Controller, Get, Put, Param, Body, Query, NotFoundException, BadRequestException, Logger } from '@nestjs/common';
|
||||||
|
import { PrismaService } from '@/infrastructure/persistence/prisma/prisma.service';
|
||||||
|
import { CapabilityService } from '@/application/services/capability.service';
|
||||||
|
import { Capability, ALL_CAPABILITIES } from '@/domain/value-objects/capability.vo';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 内部 API - 供 2.0 服务间调用,不需要 JWT 认证
|
||||||
|
*/
|
||||||
|
@Controller('internal')
|
||||||
|
export class InternalController {
|
||||||
|
private readonly logger = new Logger(InternalController.name);
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
private readonly prisma: PrismaService,
|
||||||
|
private readonly capabilityService: CapabilityService,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 根据 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 };
|
||||||
|
}
|
||||||
|
|
||||||
|
// =========================================================================
|
||||||
|
// 能力权限管理 (供 mining-admin-service 调用)
|
||||||
|
// =========================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取用户能力权限列表
|
||||||
|
*/
|
||||||
|
@Get('capabilities/:accountSequence')
|
||||||
|
async getUserCapabilities(
|
||||||
|
@Param('accountSequence') accountSequence: string,
|
||||||
|
) {
|
||||||
|
return this.capabilityService.getCapabilities(accountSequence);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 设置用户单个能力
|
||||||
|
*/
|
||||||
|
@Put('capabilities/:accountSequence')
|
||||||
|
async setCapability(
|
||||||
|
@Param('accountSequence') accountSequence: string,
|
||||||
|
@Body() body: {
|
||||||
|
capability: string;
|
||||||
|
enabled: boolean;
|
||||||
|
reason?: string;
|
||||||
|
operatorId?: string;
|
||||||
|
expiresAt?: string;
|
||||||
|
},
|
||||||
|
) {
|
||||||
|
this.validateCapability(body.capability);
|
||||||
|
return this.capabilityService.setCapability({
|
||||||
|
accountSequence,
|
||||||
|
capability: body.capability as Capability,
|
||||||
|
enabled: body.enabled,
|
||||||
|
reason: body.reason,
|
||||||
|
operatorId: body.operatorId,
|
||||||
|
expiresAt: body.expiresAt ? new Date(body.expiresAt) : undefined,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 批量设置用户能力
|
||||||
|
*/
|
||||||
|
@Put('capabilities/:accountSequence/bulk')
|
||||||
|
async bulkSetCapabilities(
|
||||||
|
@Param('accountSequence') accountSequence: string,
|
||||||
|
@Body() body: {
|
||||||
|
capabilities: Array<{
|
||||||
|
capability: string;
|
||||||
|
enabled: boolean;
|
||||||
|
reason?: string;
|
||||||
|
expiresAt?: string;
|
||||||
|
}>;
|
||||||
|
operatorId?: string;
|
||||||
|
},
|
||||||
|
) {
|
||||||
|
for (const c of body.capabilities) {
|
||||||
|
this.validateCapability(c.capability);
|
||||||
|
}
|
||||||
|
return this.capabilityService.setCapabilities({
|
||||||
|
accountSequence,
|
||||||
|
capabilities: body.capabilities.map((c) => ({
|
||||||
|
capability: c.capability as Capability,
|
||||||
|
enabled: c.enabled,
|
||||||
|
reason: c.reason,
|
||||||
|
expiresAt: c.expiresAt ? new Date(c.expiresAt) : undefined,
|
||||||
|
})),
|
||||||
|
operatorId: body.operatorId,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 查询用户能力变更日志
|
||||||
|
*/
|
||||||
|
@Get('capabilities/:accountSequence/logs')
|
||||||
|
async getCapabilityLogs(
|
||||||
|
@Param('accountSequence') accountSequence: string,
|
||||||
|
@Query('page') page?: string,
|
||||||
|
@Query('pageSize') pageSize?: string,
|
||||||
|
) {
|
||||||
|
return this.capabilityService.getCapabilityLogs(
|
||||||
|
accountSequence,
|
||||||
|
parseInt(page || '1', 10),
|
||||||
|
parseInt(pageSize || '20', 10),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private validateCapability(capability: string): void {
|
||||||
|
if (!ALL_CAPABILITIES.includes(capability as Capability)) {
|
||||||
|
throw new BadRequestException(
|
||||||
|
`无效的能力类型: ${capability},有效值: ${ALL_CAPABILITIES.join(', ')}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -12,7 +12,9 @@ import {
|
||||||
import { FilesInterceptor } from '@nestjs/platform-express';
|
import { FilesInterceptor } from '@nestjs/platform-express';
|
||||||
import { KycService, KycStatusResult } from '@/application/services';
|
import { KycService, KycStatusResult } from '@/application/services';
|
||||||
import { JwtAuthGuard } from '@/shared/guards/jwt-auth.guard';
|
import { JwtAuthGuard } from '@/shared/guards/jwt-auth.guard';
|
||||||
|
import { CapabilityGuard } from '@/shared/guards/capability.guard';
|
||||||
import { CurrentUser } from '@/shared/decorators/current-user.decorator';
|
import { CurrentUser } from '@/shared/decorators/current-user.decorator';
|
||||||
|
import { RequireCapability } from '@/shared/decorators/require-capability.decorator';
|
||||||
|
|
||||||
class SubmitKycDto {
|
class SubmitKycDto {
|
||||||
realName: string;
|
realName: string;
|
||||||
|
|
@ -20,7 +22,7 @@ class SubmitKycDto {
|
||||||
}
|
}
|
||||||
|
|
||||||
@Controller('kyc')
|
@Controller('kyc')
|
||||||
@UseGuards(JwtAuthGuard)
|
@UseGuards(JwtAuthGuard, CapabilityGuard)
|
||||||
export class KycController {
|
export class KycController {
|
||||||
constructor(private readonly kycService: KycService) {}
|
constructor(private readonly kycService: KycService) {}
|
||||||
|
|
||||||
|
|
@ -41,6 +43,7 @@ export class KycController {
|
||||||
* POST /kyc/submit
|
* POST /kyc/submit
|
||||||
*/
|
*/
|
||||||
@Post('submit')
|
@Post('submit')
|
||||||
|
@RequireCapability('KYC')
|
||||||
@HttpCode(HttpStatus.OK)
|
@HttpCode(HttpStatus.OK)
|
||||||
@UseInterceptors(FilesInterceptor('files', 2))
|
@UseInterceptors(FilesInterceptor('files', 2))
|
||||||
async submitKyc(
|
async submitKyc(
|
||||||
|
|
|
||||||
|
|
@ -7,18 +7,38 @@ import {
|
||||||
UseGuards,
|
UseGuards,
|
||||||
} from '@nestjs/common';
|
} from '@nestjs/common';
|
||||||
import { ThrottlerGuard } from '@nestjs/throttler';
|
import { ThrottlerGuard } from '@nestjs/throttler';
|
||||||
|
import { IsString, IsNotEmpty, Matches, MinLength } from 'class-validator';
|
||||||
import { PasswordService } from '@/application/services';
|
import { PasswordService } from '@/application/services';
|
||||||
import { JwtAuthGuard } from '@/shared/guards/jwt-auth.guard';
|
import { JwtAuthGuard } from '@/shared/guards/jwt-auth.guard';
|
||||||
|
import { CapabilityGuard } from '@/shared/guards/capability.guard';
|
||||||
import { CurrentUser } from '@/shared/decorators/current-user.decorator';
|
import { CurrentUser } from '@/shared/decorators/current-user.decorator';
|
||||||
|
import { RequireCapability } from '@/shared/decorators/require-capability.decorator';
|
||||||
|
|
||||||
class ResetPasswordDto {
|
class ResetPasswordDto {
|
||||||
|
@IsString()
|
||||||
|
@IsNotEmpty()
|
||||||
|
@Matches(/^1[3-9]\d{9}$/, { message: '手机号格式不正确' })
|
||||||
phone: string;
|
phone: string;
|
||||||
|
|
||||||
|
@IsString()
|
||||||
|
@IsNotEmpty()
|
||||||
|
@Matches(/^\d{6}$/, { message: '验证码格式不正确' })
|
||||||
smsCode: string;
|
smsCode: string;
|
||||||
|
|
||||||
|
@IsString()
|
||||||
|
@IsNotEmpty()
|
||||||
|
@MinLength(6, { message: '密码至少6位' })
|
||||||
newPassword: string;
|
newPassword: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
class ChangePasswordDto {
|
class ChangePasswordDto {
|
||||||
|
@IsString()
|
||||||
|
@IsNotEmpty()
|
||||||
oldPassword: string;
|
oldPassword: string;
|
||||||
|
|
||||||
|
@IsString()
|
||||||
|
@IsNotEmpty()
|
||||||
|
@MinLength(6, { message: '密码至少6位' })
|
||||||
newPassword: string;
|
newPassword: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -46,7 +66,8 @@ export class PasswordController {
|
||||||
*/
|
*/
|
||||||
@Post('change')
|
@Post('change')
|
||||||
@HttpCode(HttpStatus.OK)
|
@HttpCode(HttpStatus.OK)
|
||||||
@UseGuards(JwtAuthGuard)
|
@UseGuards(JwtAuthGuard, CapabilityGuard)
|
||||||
|
@RequireCapability('PROFILE_EDIT')
|
||||||
async changePassword(
|
async changePassword(
|
||||||
@CurrentUser() user: { accountSequence: string },
|
@CurrentUser() user: { accountSequence: string },
|
||||||
@Body() dto: ChangePasswordDto,
|
@Body() dto: ChangePasswordDto,
|
||||||
|
|
|
||||||
|
|
@ -7,18 +7,33 @@ import {
|
||||||
UseGuards,
|
UseGuards,
|
||||||
} from '@nestjs/common';
|
} from '@nestjs/common';
|
||||||
import { ThrottlerGuard } from '@nestjs/throttler';
|
import { ThrottlerGuard } from '@nestjs/throttler';
|
||||||
|
import { IsString, IsNotEmpty, IsEnum, Matches } from 'class-validator';
|
||||||
import { SmsService } from '@/application/services';
|
import { SmsService } from '@/application/services';
|
||||||
import { SmsVerificationType } from '@/domain';
|
import { SmsVerificationType } from '@/domain';
|
||||||
|
|
||||||
class SendSmsDto {
|
class SendSmsDto {
|
||||||
|
@IsString()
|
||||||
|
@IsNotEmpty({ message: '手机号不能为空' })
|
||||||
|
@Matches(/^1[3-9]\d{9}$/, { message: '手机号格式不正确' })
|
||||||
phone: string;
|
phone: string;
|
||||||
type: 'REGISTER' | 'LOGIN' | 'RESET_PASSWORD' | 'CHANGE_PHONE';
|
|
||||||
|
@IsEnum(SmsVerificationType, { message: '验证码类型无效' })
|
||||||
|
type: SmsVerificationType;
|
||||||
}
|
}
|
||||||
|
|
||||||
class VerifySmsDto {
|
class VerifySmsDto {
|
||||||
|
@IsString()
|
||||||
|
@IsNotEmpty({ message: '手机号不能为空' })
|
||||||
|
@Matches(/^1[3-9]\d{9}$/, { message: '手机号格式不正确' })
|
||||||
phone: string;
|
phone: string;
|
||||||
|
|
||||||
|
@IsString()
|
||||||
|
@IsNotEmpty({ message: '验证码不能为空' })
|
||||||
|
@Matches(/^\d{6}$/, { message: '验证码格式不正确' })
|
||||||
code: string;
|
code: string;
|
||||||
type: 'REGISTER' | 'LOGIN' | 'RESET_PASSWORD' | 'CHANGE_PHONE';
|
|
||||||
|
@IsEnum(SmsVerificationType, { message: '验证码类型无效' })
|
||||||
|
type: SmsVerificationType;
|
||||||
}
|
}
|
||||||
|
|
||||||
@Controller('auth/sms')
|
@Controller('auth/sms')
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,122 @@
|
||||||
|
import {
|
||||||
|
Controller,
|
||||||
|
Post,
|
||||||
|
Get,
|
||||||
|
Body,
|
||||||
|
HttpCode,
|
||||||
|
HttpStatus,
|
||||||
|
UseGuards,
|
||||||
|
} from '@nestjs/common';
|
||||||
|
import { IsString, IsNotEmpty } from 'class-validator';
|
||||||
|
import { ThrottlerGuard } from '@nestjs/throttler';
|
||||||
|
import { TradePasswordService } from '@/application/services/trade-password.service';
|
||||||
|
import { JwtAuthGuard } from '@/shared/guards/jwt-auth.guard';
|
||||||
|
import { CapabilityGuard } from '@/shared/guards/capability.guard';
|
||||||
|
import { CurrentUser } from '@/shared/decorators/current-user.decorator';
|
||||||
|
import { RequireCapability } from '@/shared/decorators/require-capability.decorator';
|
||||||
|
|
||||||
|
class SetTradePasswordDto {
|
||||||
|
@IsString()
|
||||||
|
@IsNotEmpty()
|
||||||
|
loginPassword: string;
|
||||||
|
|
||||||
|
@IsString()
|
||||||
|
@IsNotEmpty()
|
||||||
|
tradePassword: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
class ChangeTradePasswordDto {
|
||||||
|
@IsString()
|
||||||
|
@IsNotEmpty()
|
||||||
|
oldTradePassword: string;
|
||||||
|
|
||||||
|
@IsString()
|
||||||
|
@IsNotEmpty()
|
||||||
|
newTradePassword: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
class VerifyTradePasswordDto {
|
||||||
|
@IsString()
|
||||||
|
@IsNotEmpty()
|
||||||
|
tradePassword: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Controller('auth/trade-password')
|
||||||
|
@UseGuards(ThrottlerGuard)
|
||||||
|
export class TradePasswordController {
|
||||||
|
constructor(private readonly tradePasswordService: TradePasswordService) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取支付密码状态
|
||||||
|
* GET /trade-password/status
|
||||||
|
*/
|
||||||
|
@Get('status')
|
||||||
|
@UseGuards(JwtAuthGuard)
|
||||||
|
async getStatus(
|
||||||
|
@CurrentUser() user: { accountSequence: string },
|
||||||
|
): Promise<{ hasTradePassword: boolean }> {
|
||||||
|
return this.tradePasswordService.getStatus(user.accountSequence);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 设置支付密码(需要验证登录密码)
|
||||||
|
* POST /trade-password/set
|
||||||
|
*/
|
||||||
|
@Post('set')
|
||||||
|
@HttpCode(HttpStatus.OK)
|
||||||
|
@UseGuards(JwtAuthGuard, CapabilityGuard)
|
||||||
|
@RequireCapability('PROFILE_EDIT')
|
||||||
|
async setTradePassword(
|
||||||
|
@CurrentUser() user: { accountSequence: string },
|
||||||
|
@Body() dto: SetTradePasswordDto,
|
||||||
|
): Promise<{ success: boolean }> {
|
||||||
|
await this.tradePasswordService.setTradePassword({
|
||||||
|
accountSequence: user.accountSequence,
|
||||||
|
loginPassword: dto.loginPassword,
|
||||||
|
tradePassword: dto.tradePassword,
|
||||||
|
});
|
||||||
|
|
||||||
|
return { success: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 修改支付密码
|
||||||
|
* POST /trade-password/change
|
||||||
|
*/
|
||||||
|
@Post('change')
|
||||||
|
@HttpCode(HttpStatus.OK)
|
||||||
|
@UseGuards(JwtAuthGuard, CapabilityGuard)
|
||||||
|
@RequireCapability('PROFILE_EDIT')
|
||||||
|
async changeTradePassword(
|
||||||
|
@CurrentUser() user: { accountSequence: string },
|
||||||
|
@Body() dto: ChangeTradePasswordDto,
|
||||||
|
): Promise<{ success: boolean }> {
|
||||||
|
await this.tradePasswordService.changeTradePassword({
|
||||||
|
accountSequence: user.accountSequence,
|
||||||
|
oldTradePassword: dto.oldTradePassword,
|
||||||
|
newTradePassword: dto.newTradePassword,
|
||||||
|
});
|
||||||
|
|
||||||
|
return { success: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 验证支付密码
|
||||||
|
* POST /trade-password/verify
|
||||||
|
*/
|
||||||
|
@Post('verify')
|
||||||
|
@HttpCode(HttpStatus.OK)
|
||||||
|
@UseGuards(JwtAuthGuard, CapabilityGuard)
|
||||||
|
@RequireCapability('TRADING')
|
||||||
|
async verifyTradePassword(
|
||||||
|
@CurrentUser() user: { accountSequence: string },
|
||||||
|
@Body() dto: VerifyTradePasswordDto,
|
||||||
|
): Promise<{ valid: boolean }> {
|
||||||
|
const valid = await this.tradePasswordService.verifyTradePassword({
|
||||||
|
accountSequence: user.accountSequence,
|
||||||
|
tradePassword: dto.tradePassword,
|
||||||
|
});
|
||||||
|
|
||||||
|
return { valid };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,7 +1,9 @@
|
||||||
import {
|
import {
|
||||||
Controller,
|
Controller,
|
||||||
Get,
|
Get,
|
||||||
|
Query,
|
||||||
UseGuards,
|
UseGuards,
|
||||||
|
BadRequestException,
|
||||||
} from '@nestjs/common';
|
} from '@nestjs/common';
|
||||||
import { UserService, UserProfileResult } from '@/application/services';
|
import { UserService, UserProfileResult } from '@/application/services';
|
||||||
import { JwtAuthGuard } from '@/shared/guards/jwt-auth.guard';
|
import { JwtAuthGuard } from '@/shared/guards/jwt-auth.guard';
|
||||||
|
|
@ -23,4 +25,21 @@ export class UserController {
|
||||||
const result = await this.userService.getProfile(user.accountSequence);
|
const result = await this.userService.getProfile(user.accountSequence);
|
||||||
return { success: true, data: result };
|
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 };
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -5,13 +5,15 @@ import { ScheduleModule } from '@nestjs/schedule';
|
||||||
import {
|
import {
|
||||||
AuthService,
|
AuthService,
|
||||||
PasswordService,
|
PasswordService,
|
||||||
|
TradePasswordService,
|
||||||
SmsService,
|
SmsService,
|
||||||
KycService,
|
KycService,
|
||||||
UserService,
|
UserService,
|
||||||
OutboxService,
|
OutboxService,
|
||||||
AdminSyncService,
|
AdminSyncService,
|
||||||
|
CapabilityService,
|
||||||
} from './services';
|
} from './services';
|
||||||
import { OutboxScheduler } from './schedulers';
|
import { OutboxScheduler, CapabilityExpiryScheduler } from './schedulers';
|
||||||
import { InfrastructureModule } from '@/infrastructure/infrastructure.module';
|
import { InfrastructureModule } from '@/infrastructure/infrastructure.module';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
|
|
@ -32,21 +34,26 @@ import { InfrastructureModule } from '@/infrastructure/infrastructure.module';
|
||||||
providers: [
|
providers: [
|
||||||
AuthService,
|
AuthService,
|
||||||
PasswordService,
|
PasswordService,
|
||||||
|
TradePasswordService,
|
||||||
SmsService,
|
SmsService,
|
||||||
KycService,
|
KycService,
|
||||||
UserService,
|
UserService,
|
||||||
OutboxService,
|
OutboxService,
|
||||||
AdminSyncService,
|
AdminSyncService,
|
||||||
|
CapabilityService,
|
||||||
OutboxScheduler,
|
OutboxScheduler,
|
||||||
|
CapabilityExpiryScheduler,
|
||||||
],
|
],
|
||||||
exports: [
|
exports: [
|
||||||
AuthService,
|
AuthService,
|
||||||
PasswordService,
|
PasswordService,
|
||||||
|
TradePasswordService,
|
||||||
SmsService,
|
SmsService,
|
||||||
KycService,
|
KycService,
|
||||||
UserService,
|
UserService,
|
||||||
AdminSyncService,
|
AdminSyncService,
|
||||||
OutboxService,
|
OutboxService,
|
||||||
|
CapabilityService,
|
||||||
],
|
],
|
||||||
})
|
})
|
||||||
export class ApplicationModule {}
|
export class ApplicationModule {}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,35 @@
|
||||||
|
import { Injectable, Logger } from '@nestjs/common';
|
||||||
|
import { Cron } from '@nestjs/schedule';
|
||||||
|
import { CapabilityService } from '../services/capability.service';
|
||||||
|
import { RedisService } from '@/infrastructure/redis';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class CapabilityExpiryScheduler {
|
||||||
|
private readonly logger = new Logger(CapabilityExpiryScheduler.name);
|
||||||
|
private readonly LOCK_KEY = 'auth:capability:expiry:lock';
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
private readonly capabilityService: CapabilityService,
|
||||||
|
private readonly redis: RedisService,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 每 60 秒检查到期的临时限制并自动恢复
|
||||||
|
*/
|
||||||
|
@Cron('*/60 * * * * *')
|
||||||
|
async processExpiredRestrictions(): Promise<void> {
|
||||||
|
const lockValue = await this.redis.acquireLock(this.LOCK_KEY, 55);
|
||||||
|
if (!lockValue) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const count = await this.capabilityService.processExpiredRestrictions();
|
||||||
|
if (count > 0) {
|
||||||
|
this.logger.log(`已恢复 ${count} 个到期的能力限制`);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
this.logger.error('处理到期限制失败', error);
|
||||||
|
} finally {
|
||||||
|
await this.redis.releaseLock(this.LOCK_KEY, lockValue);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1 +1,2 @@
|
||||||
export * from './outbox.scheduler';
|
export * from './outbox.scheduler';
|
||||||
|
export * from './capability-expiry.scheduler';
|
||||||
|
|
|
||||||
|
|
@ -1,10 +1,11 @@
|
||||||
import { Injectable, Inject, UnauthorizedException, ConflictException, BadRequestException } from '@nestjs/common';
|
import { Injectable, Inject, UnauthorizedException, ForbiddenException, ConflictException, BadRequestException } from '@nestjs/common';
|
||||||
import { JwtService } from '@nestjs/jwt';
|
import { JwtService } from '@nestjs/jwt';
|
||||||
import { ConfigService } from '@nestjs/config';
|
import { ConfigService } from '@nestjs/config';
|
||||||
import {
|
import {
|
||||||
UserAggregate,
|
UserAggregate,
|
||||||
Phone,
|
Phone,
|
||||||
AccountSequence,
|
AccountSequence,
|
||||||
|
Capability,
|
||||||
USER_REPOSITORY,
|
USER_REPOSITORY,
|
||||||
UserRepository,
|
UserRepository,
|
||||||
SYNCED_LEGACY_USER_REPOSITORY,
|
SYNCED_LEGACY_USER_REPOSITORY,
|
||||||
|
|
@ -18,6 +19,7 @@ import {
|
||||||
LegacyUserMigratedEvent,
|
LegacyUserMigratedEvent,
|
||||||
} from '@/domain';
|
} from '@/domain';
|
||||||
import { OutboxService } from './outbox.service';
|
import { OutboxService } from './outbox.service';
|
||||||
|
import { CapabilityService } from './capability.service';
|
||||||
|
|
||||||
export interface LoginResult {
|
export interface LoginResult {
|
||||||
accessToken: string;
|
accessToken: string;
|
||||||
|
|
@ -65,6 +67,7 @@ export class AuthService {
|
||||||
private readonly jwtService: JwtService,
|
private readonly jwtService: JwtService,
|
||||||
private readonly configService: ConfigService,
|
private readonly configService: ConfigService,
|
||||||
private readonly outboxService: OutboxService,
|
private readonly outboxService: OutboxService,
|
||||||
|
private readonly capabilityService: CapabilityService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -149,6 +152,16 @@ export class AuthService {
|
||||||
}
|
}
|
||||||
throw new UnauthorizedException('账户已被禁用');
|
throw new UnauthorizedException('账户已被禁用');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 检查 LOGIN 能力是否被限制
|
||||||
|
const loginEnabled = await this.capabilityService.isCapabilityEnabled(
|
||||||
|
user.accountSequence.value,
|
||||||
|
Capability.LOGIN,
|
||||||
|
);
|
||||||
|
if (!loginEnabled) {
|
||||||
|
throw new ForbiddenException('您的登录功能已被限制,请联系客服');
|
||||||
|
}
|
||||||
|
|
||||||
user.recordLoginSuccess(dto.ipAddress);
|
user.recordLoginSuccess(dto.ipAddress);
|
||||||
await this.userRepository.save(user);
|
await this.userRepository.save(user);
|
||||||
return this.generateTokens(user, dto.deviceInfo, dto.ipAddress);
|
return this.generateTokens(user, dto.deviceInfo, dto.ipAddress);
|
||||||
|
|
@ -200,6 +213,15 @@ export class AuthService {
|
||||||
throw new UnauthorizedException('账户已被禁用');
|
throw new UnauthorizedException('账户已被禁用');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 检查 LOGIN 能力是否被限制
|
||||||
|
const loginEnabled = await this.capabilityService.isCapabilityEnabled(
|
||||||
|
user.accountSequence.value,
|
||||||
|
Capability.LOGIN,
|
||||||
|
);
|
||||||
|
if (!loginEnabled) {
|
||||||
|
throw new ForbiddenException('您的登录功能已被限制,请联系客服');
|
||||||
|
}
|
||||||
|
|
||||||
const isValid = await user.verifyPassword(password);
|
const isValid = await user.verifyPassword(password);
|
||||||
if (!isValid) {
|
if (!isValid) {
|
||||||
const result = user.recordLoginFailure();
|
const result = user.recordLoginFailure();
|
||||||
|
|
@ -309,6 +331,15 @@ export class AuthService {
|
||||||
throw new UnauthorizedException('账户不可用');
|
throw new UnauthorizedException('账户不可用');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 检查 LOGIN 能力是否被限制
|
||||||
|
const loginEnabled = await this.capabilityService.isCapabilityEnabled(
|
||||||
|
user.accountSequence.value,
|
||||||
|
Capability.LOGIN,
|
||||||
|
);
|
||||||
|
if (!loginEnabled) {
|
||||||
|
throw new ForbiddenException('您的登录功能已被限制,请联系客服');
|
||||||
|
}
|
||||||
|
|
||||||
const accessToken = this.generateAccessToken(user);
|
const accessToken = this.generateAccessToken(user);
|
||||||
const expiresIn = this.configService.get<number>('JWT_EXPIRES_IN_SECONDS', 3600);
|
const expiresIn = this.configService.get<number>('JWT_EXPIRES_IN_SECONDS', 3600);
|
||||||
|
|
||||||
|
|
@ -347,7 +378,7 @@ export class AuthService {
|
||||||
expiresIn,
|
expiresIn,
|
||||||
user: {
|
user: {
|
||||||
accountSequence: user.accountSequence.value,
|
accountSequence: user.accountSequence.value,
|
||||||
phone: user.phone.masked,
|
phone: user.phone.value,
|
||||||
source: user.source,
|
source: user.source,
|
||||||
kycStatus: user.kycStatus,
|
kycStatus: user.kycStatus,
|
||||||
},
|
},
|
||||||
|
|
@ -360,6 +391,9 @@ export class AuthService {
|
||||||
private generateAccessToken(user: UserAggregate): string {
|
private generateAccessToken(user: UserAggregate): string {
|
||||||
const payload = {
|
const payload = {
|
||||||
sub: user.accountSequence.value,
|
sub: user.accountSequence.value,
|
||||||
|
type: 'access',
|
||||||
|
userId: user.accountSequence.value,
|
||||||
|
accountSequence: user.accountSequence.value,
|
||||||
phone: user.phone.value,
|
phone: user.phone.value,
|
||||||
source: user.source,
|
source: user.source,
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,201 @@
|
||||||
|
import { Injectable, Inject, Logger } from '@nestjs/common';
|
||||||
|
import { RedisService } from '@/infrastructure/redis';
|
||||||
|
import {
|
||||||
|
CAPABILITY_REPOSITORY,
|
||||||
|
CapabilityRepository,
|
||||||
|
} from '@/domain/repositories/capability.repository.interface';
|
||||||
|
import {
|
||||||
|
Capability,
|
||||||
|
CapabilityMap,
|
||||||
|
defaultCapabilityMap,
|
||||||
|
} from '@/domain/value-objects/capability.vo';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class CapabilityService {
|
||||||
|
private readonly logger = new Logger(CapabilityService.name);
|
||||||
|
private readonly REDIS_PREFIX = 'cap:';
|
||||||
|
private readonly REDIS_TTL = 3600; // 1 hour
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
@Inject(CAPABILITY_REPOSITORY)
|
||||||
|
private readonly capabilityRepo: CapabilityRepository,
|
||||||
|
private readonly redis: RedisService,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取用户的完整能力映射
|
||||||
|
* Redis 缓存优先 → DB fallback → 写回 Redis
|
||||||
|
* 默认行为:无记录 = 全部开启
|
||||||
|
*/
|
||||||
|
async getCapabilities(accountSequence: string): Promise<CapabilityMap> {
|
||||||
|
const cached = await this.redis.getJson<CapabilityMap>(
|
||||||
|
`${this.REDIS_PREFIX}${accountSequence}`,
|
||||||
|
);
|
||||||
|
if (cached) return cached;
|
||||||
|
|
||||||
|
const map = await this.buildCapabilityMap(accountSequence);
|
||||||
|
|
||||||
|
await this.redis.setJson(
|
||||||
|
`${this.REDIS_PREFIX}${accountSequence}`,
|
||||||
|
map,
|
||||||
|
this.REDIS_TTL,
|
||||||
|
);
|
||||||
|
|
||||||
|
return map;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 检查单个能力是否开启
|
||||||
|
*/
|
||||||
|
async isCapabilityEnabled(
|
||||||
|
accountSequence: string,
|
||||||
|
capability: Capability,
|
||||||
|
): Promise<boolean> {
|
||||||
|
const map = await this.getCapabilities(accountSequence);
|
||||||
|
return map[capability] ?? true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 设置单个能力
|
||||||
|
*/
|
||||||
|
async setCapability(params: {
|
||||||
|
accountSequence: string;
|
||||||
|
capability: Capability;
|
||||||
|
enabled: boolean;
|
||||||
|
reason?: string;
|
||||||
|
operatorId?: string;
|
||||||
|
expiresAt?: Date;
|
||||||
|
}): Promise<CapabilityMap> {
|
||||||
|
const current = await this.capabilityRepo.findByAccountSequence(params.accountSequence);
|
||||||
|
const existing = current.find((c) => c.capability === params.capability);
|
||||||
|
const previousValue = existing ? existing.enabled : true;
|
||||||
|
|
||||||
|
await this.capabilityRepo.upsertWithLog(
|
||||||
|
{
|
||||||
|
accountSequence: params.accountSequence,
|
||||||
|
capability: params.capability,
|
||||||
|
enabled: params.enabled,
|
||||||
|
reason: params.reason,
|
||||||
|
disabledBy: params.enabled ? undefined : params.operatorId,
|
||||||
|
expiresAt: params.expiresAt,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
accountSequence: params.accountSequence,
|
||||||
|
capability: params.capability,
|
||||||
|
action: params.enabled ? 'ENABLE' : 'DISABLE',
|
||||||
|
reason: params.reason,
|
||||||
|
operatorId: params.operatorId,
|
||||||
|
previousValue,
|
||||||
|
newValue: params.enabled,
|
||||||
|
expiresAt: params.expiresAt,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
return this.refreshCache(params.accountSequence);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 批量设置能力
|
||||||
|
*/
|
||||||
|
async setCapabilities(params: {
|
||||||
|
accountSequence: string;
|
||||||
|
capabilities: Array<{
|
||||||
|
capability: Capability;
|
||||||
|
enabled: boolean;
|
||||||
|
reason?: string;
|
||||||
|
expiresAt?: Date;
|
||||||
|
}>;
|
||||||
|
operatorId?: string;
|
||||||
|
}): Promise<CapabilityMap> {
|
||||||
|
for (const cap of params.capabilities) {
|
||||||
|
await this.setCapability({
|
||||||
|
accountSequence: params.accountSequence,
|
||||||
|
capability: cap.capability,
|
||||||
|
enabled: cap.enabled,
|
||||||
|
reason: cap.reason,
|
||||||
|
operatorId: params.operatorId,
|
||||||
|
expiresAt: cap.expiresAt,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return this.refreshCache(params.accountSequence);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 处理到期的临时限制(由 cron 调用)
|
||||||
|
*/
|
||||||
|
async processExpiredRestrictions(): Promise<number> {
|
||||||
|
const expired = await this.capabilityRepo.findExpired();
|
||||||
|
let count = 0;
|
||||||
|
|
||||||
|
for (const record of expired) {
|
||||||
|
await this.capabilityRepo.upsertWithLog(
|
||||||
|
{
|
||||||
|
accountSequence: record.accountSequence,
|
||||||
|
capability: record.capability,
|
||||||
|
enabled: true,
|
||||||
|
reason: '临时限制已到期,自动恢复',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
accountSequence: record.accountSequence,
|
||||||
|
capability: record.capability,
|
||||||
|
action: 'EXPIRE',
|
||||||
|
reason: '临时限制到期自动恢复',
|
||||||
|
previousValue: false,
|
||||||
|
newValue: true,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
await this.refreshCache(record.accountSequence);
|
||||||
|
count++;
|
||||||
|
}
|
||||||
|
|
||||||
|
return count;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 查询能力变更日志
|
||||||
|
*/
|
||||||
|
async getCapabilityLogs(
|
||||||
|
accountSequence: string,
|
||||||
|
page: number,
|
||||||
|
pageSize: number,
|
||||||
|
) {
|
||||||
|
return this.capabilityRepo.findLogsByAccountSequence(
|
||||||
|
accountSequence,
|
||||||
|
page,
|
||||||
|
pageSize,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async buildCapabilityMap(accountSequence: string): Promise<CapabilityMap> {
|
||||||
|
const records = await this.capabilityRepo.findByAccountSequence(accountSequence);
|
||||||
|
const map = defaultCapabilityMap();
|
||||||
|
|
||||||
|
for (const record of records) {
|
||||||
|
if (record.capability in Capability) {
|
||||||
|
// 已过期的限制视为开启
|
||||||
|
if (!record.enabled && record.expiresAt && record.expiresAt <= new Date()) {
|
||||||
|
map[record.capability as Capability] = true;
|
||||||
|
} else {
|
||||||
|
map[record.capability as Capability] = record.enabled;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return map;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async refreshCache(accountSequence: string): Promise<CapabilityMap> {
|
||||||
|
const map = await this.buildCapabilityMap(accountSequence);
|
||||||
|
try {
|
||||||
|
await this.redis.setJson(
|
||||||
|
`${this.REDIS_PREFIX}${accountSequence}`,
|
||||||
|
map,
|
||||||
|
this.REDIS_TTL,
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
this.logger.warn(`Redis 缓存刷新失败 (${accountSequence}): ${error?.message}`);
|
||||||
|
}
|
||||||
|
return map;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,7 +1,9 @@
|
||||||
export * from './auth.service';
|
export * from './auth.service';
|
||||||
export * from './password.service';
|
export * from './password.service';
|
||||||
|
export * from './trade-password.service';
|
||||||
export * from './sms.service';
|
export * from './sms.service';
|
||||||
export * from './kyc.service';
|
export * from './kyc.service';
|
||||||
export * from './user.service';
|
export * from './user.service';
|
||||||
export * from './outbox.service';
|
export * from './outbox.service';
|
||||||
export * from './admin-sync.service';
|
export * from './admin-sync.service';
|
||||||
|
export * from './capability.service';
|
||||||
|
|
|
||||||
|
|
@ -45,8 +45,15 @@ export class PasswordService {
|
||||||
SmsVerificationType.RESET_PASSWORD,
|
SmsVerificationType.RESET_PASSWORD,
|
||||||
);
|
);
|
||||||
|
|
||||||
if (!verification || verification.code !== dto.smsCode) {
|
if (!verification) {
|
||||||
throw new BadRequestException('验证码错误或已过期');
|
throw new BadRequestException('验证码已过期或不存在');
|
||||||
|
}
|
||||||
|
if (verification.attempts >= 5) {
|
||||||
|
throw new BadRequestException('验证码尝试次数过多,请重新获取');
|
||||||
|
}
|
||||||
|
if (verification.code !== dto.smsCode) {
|
||||||
|
await this.smsVerificationRepository.incrementAttempts(verification.id);
|
||||||
|
throw new BadRequestException('验证码错误');
|
||||||
}
|
}
|
||||||
|
|
||||||
// 标记验证码已使用
|
// 标记验证码已使用
|
||||||
|
|
@ -63,8 +70,9 @@ export class PasswordService {
|
||||||
throw new NotFoundException('用户不存在');
|
throw new NotFoundException('用户不存在');
|
||||||
}
|
}
|
||||||
|
|
||||||
// 修改密码
|
// 修改密码,同时解除锁定(短信验证身份已通过,清除失败计数)
|
||||||
await user.changePassword(dto.newPassword);
|
await user.changePassword(dto.newPassword);
|
||||||
|
user.unlock();
|
||||||
await this.userRepository.save(user);
|
await this.userRepository.save(user);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,7 @@
|
||||||
import { Injectable, Inject, BadRequestException } from '@nestjs/common';
|
import { Injectable, Inject, BadRequestException, Logger } from '@nestjs/common';
|
||||||
import { ConfigService } from '@nestjs/config';
|
import { ConfigService } from '@nestjs/config';
|
||||||
|
import Dysmsapi20170525, * as $Dysmsapi20170525 from '@alicloud/dysmsapi20170525';
|
||||||
|
import * as $OpenApi from '@alicloud/openapi-client';
|
||||||
import {
|
import {
|
||||||
Phone,
|
Phone,
|
||||||
SmsCode,
|
SmsCode,
|
||||||
|
|
@ -25,6 +27,7 @@ export interface VerifySmsDto {
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class SmsService {
|
export class SmsService {
|
||||||
|
private readonly logger = new Logger(SmsService.name);
|
||||||
private readonly codeExpireSeconds: number;
|
private readonly codeExpireSeconds: number;
|
||||||
private readonly dailyLimit: number;
|
private readonly dailyLimit: number;
|
||||||
private readonly maxAttempts = 5;
|
private readonly maxAttempts = 5;
|
||||||
|
|
@ -135,22 +138,42 @@ export class SmsService {
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 调用短信服务商发送(阿里云)
|
* 调用阿里云短信发送验证码
|
||||||
*/
|
*/
|
||||||
private async sendSmsToProvider(
|
private async sendSmsToProvider(
|
||||||
phone: string,
|
phone: string,
|
||||||
code: string,
|
code: string,
|
||||||
type: SmsVerificationType,
|
type: SmsVerificationType,
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
// TODO: 实现阿里云短信发送
|
const smsEnabled = this.configService.get<string>('SMS_ENABLED');
|
||||||
// const accessKeyId = this.configService.get<string>('SMS_ACCESS_KEY_ID');
|
if (smsEnabled !== 'true') {
|
||||||
// const accessKeySecret = this.configService.get<string>('SMS_ACCESS_KEY_SECRET');
|
this.logger.log(`[SMS DISABLED] Phone: ${phone}, Code: ${code}, Type: ${type}`);
|
||||||
// const signName = this.configService.get<string>('SMS_SIGN_NAME');
|
return;
|
||||||
// const templateCode = this.configService.get<string>('SMS_TEMPLATE_CODE');
|
|
||||||
|
|
||||||
// 开发环境打印验证码
|
|
||||||
if (this.configService.get('NODE_ENV') === 'development') {
|
|
||||||
console.log(`[SMS] Phone: ${phone}, Code: ${code}, Type: ${type}`);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const accessKeyId = this.configService.get<string>('ALIYUN_ACCESS_KEY_ID');
|
||||||
|
const accessKeySecret = this.configService.get<string>('ALIYUN_ACCESS_KEY_SECRET');
|
||||||
|
const signName = this.configService.get<string>('ALIYUN_SMS_SIGN_NAME');
|
||||||
|
const templateCode = this.configService.get<string>('ALIYUN_SMS_TEMPLATE_CODE');
|
||||||
|
const endpoint = this.configService.get<string>('ALIYUN_SMS_ENDPOINT', 'dysmsapi.aliyuncs.com');
|
||||||
|
|
||||||
|
const config = new $OpenApi.Config({ accessKeyId, accessKeySecret, endpoint });
|
||||||
|
const client = new Dysmsapi20170525(config);
|
||||||
|
|
||||||
|
const request = new $Dysmsapi20170525.SendSmsRequest({
|
||||||
|
phoneNumbers: phone,
|
||||||
|
signName,
|
||||||
|
templateCode,
|
||||||
|
templateParam: JSON.stringify({ code }),
|
||||||
|
});
|
||||||
|
|
||||||
|
const response = await client.sendSms(request);
|
||||||
|
const body = response.body;
|
||||||
|
if (!body || body.code !== 'OK') {
|
||||||
|
this.logger.error(`阿里云短信发送失败: ${body?.code} - ${body?.message}`);
|
||||||
|
throw new BadRequestException('短信发送失败,请稍后重试');
|
||||||
|
}
|
||||||
|
|
||||||
|
this.logger.log(`短信发送成功: ${phone}, BizId: ${body.bizId}`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,144 @@
|
||||||
|
import { Injectable, Inject, BadRequestException, NotFoundException } from '@nestjs/common';
|
||||||
|
import {
|
||||||
|
USER_REPOSITORY,
|
||||||
|
UserRepository,
|
||||||
|
AccountSequence,
|
||||||
|
} from '@/domain';
|
||||||
|
|
||||||
|
export interface SetTradePasswordDto {
|
||||||
|
accountSequence: string;
|
||||||
|
loginPassword: string; // 需要验证登录密码
|
||||||
|
tradePassword: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ChangeTradePasswordDto {
|
||||||
|
accountSequence: string;
|
||||||
|
oldTradePassword: string;
|
||||||
|
newTradePassword: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface VerifyTradePasswordDto {
|
||||||
|
accountSequence: string;
|
||||||
|
tradePassword: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TradePasswordStatusDto {
|
||||||
|
hasTradePassword: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class TradePasswordService {
|
||||||
|
constructor(
|
||||||
|
@Inject(USER_REPOSITORY)
|
||||||
|
private readonly userRepository: UserRepository,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取支付密码状态
|
||||||
|
*/
|
||||||
|
async getStatus(accountSequence: string): Promise<TradePasswordStatusDto> {
|
||||||
|
const user = await this.userRepository.findByAccountSequence(
|
||||||
|
AccountSequence.create(accountSequence),
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!user) {
|
||||||
|
throw new NotFoundException('用户不存在');
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
hasTradePassword: user.hasTradePassword,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 设置支付密码(首次设置或修改)
|
||||||
|
* 首次设置需要验证登录密码
|
||||||
|
*/
|
||||||
|
async setTradePassword(dto: SetTradePasswordDto): Promise<void> {
|
||||||
|
const user = await this.userRepository.findByAccountSequence(
|
||||||
|
AccountSequence.create(dto.accountSequence),
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!user) {
|
||||||
|
throw new NotFoundException('用户不存在');
|
||||||
|
}
|
||||||
|
|
||||||
|
// 验证登录密码
|
||||||
|
const isLoginPasswordValid = await user.verifyPassword(dto.loginPassword);
|
||||||
|
if (!isLoginPasswordValid) {
|
||||||
|
throw new BadRequestException('登录密码错误');
|
||||||
|
}
|
||||||
|
|
||||||
|
// 支付密码不能与登录密码相同
|
||||||
|
const isSameAsLogin = await user.verifyPassword(dto.tradePassword);
|
||||||
|
if (isSameAsLogin) {
|
||||||
|
throw new BadRequestException('支付密码不能与登录密码相同');
|
||||||
|
}
|
||||||
|
|
||||||
|
// 验证密码格式(6位数字)
|
||||||
|
if (!/^\d{6}$/.test(dto.tradePassword)) {
|
||||||
|
throw new BadRequestException('支付密码必须是6位数字');
|
||||||
|
}
|
||||||
|
|
||||||
|
// 设置支付密码
|
||||||
|
await user.setTradePassword(dto.tradePassword);
|
||||||
|
await this.userRepository.save(user);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 修改支付密码(需要验证旧密码)
|
||||||
|
*/
|
||||||
|
async changeTradePassword(dto: ChangeTradePasswordDto): Promise<void> {
|
||||||
|
const user = await this.userRepository.findByAccountSequence(
|
||||||
|
AccountSequence.create(dto.accountSequence),
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!user) {
|
||||||
|
throw new NotFoundException('用户不存在');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!user.hasTradePassword) {
|
||||||
|
throw new BadRequestException('尚未设置支付密码');
|
||||||
|
}
|
||||||
|
|
||||||
|
// 验证旧支付密码
|
||||||
|
const isOldPasswordValid = await user.verifyTradePassword(dto.oldTradePassword);
|
||||||
|
if (!isOldPasswordValid) {
|
||||||
|
throw new BadRequestException('原支付密码错误');
|
||||||
|
}
|
||||||
|
|
||||||
|
// 新密码不能与旧密码相同
|
||||||
|
if (dto.oldTradePassword === dto.newTradePassword) {
|
||||||
|
throw new BadRequestException('新密码不能与原密码相同');
|
||||||
|
}
|
||||||
|
|
||||||
|
// 验证新密码格式(6位数字)
|
||||||
|
if (!/^\d{6}$/.test(dto.newTradePassword)) {
|
||||||
|
throw new BadRequestException('支付密码必须是6位数字');
|
||||||
|
}
|
||||||
|
|
||||||
|
// 设置新支付密码
|
||||||
|
await user.setTradePassword(dto.newTradePassword);
|
||||||
|
await this.userRepository.save(user);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 验证支付密码
|
||||||
|
*/
|
||||||
|
async verifyTradePassword(dto: VerifyTradePasswordDto): Promise<boolean> {
|
||||||
|
const user = await this.userRepository.findByAccountSequence(
|
||||||
|
AccountSequence.create(dto.accountSequence),
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!user) {
|
||||||
|
throw new NotFoundException('用户不存在');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!user.hasTradePassword) {
|
||||||
|
// 未设置支付密码,视为验证通过(允许交易)
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return user.verifyTradePassword(dto.tradePassword);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -4,6 +4,8 @@ import {
|
||||||
Phone,
|
Phone,
|
||||||
USER_REPOSITORY,
|
USER_REPOSITORY,
|
||||||
UserRepository,
|
UserRepository,
|
||||||
|
SYNCED_LEGACY_USER_REPOSITORY,
|
||||||
|
SyncedLegacyUserRepository,
|
||||||
} from '@/domain';
|
} from '@/domain';
|
||||||
|
|
||||||
export interface UserProfileResult {
|
export interface UserProfileResult {
|
||||||
|
|
@ -22,6 +24,8 @@ export class UserService {
|
||||||
constructor(
|
constructor(
|
||||||
@Inject(USER_REPOSITORY)
|
@Inject(USER_REPOSITORY)
|
||||||
private readonly userRepository: UserRepository,
|
private readonly userRepository: UserRepository,
|
||||||
|
@Inject(SYNCED_LEGACY_USER_REPOSITORY)
|
||||||
|
private readonly syncedLegacyUserRepository: SyncedLegacyUserRepository,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -38,7 +42,7 @@ export class UserService {
|
||||||
|
|
||||||
return {
|
return {
|
||||||
accountSequence: user.accountSequence.value,
|
accountSequence: user.accountSequence.value,
|
||||||
phone: user.phone.masked,
|
phone: user.phone.value,
|
||||||
source: user.source,
|
source: user.source,
|
||||||
status: user.status,
|
status: user.status,
|
||||||
kycStatus: user.kycStatus,
|
kycStatus: user.kycStatus,
|
||||||
|
|
@ -48,6 +52,36 @@ export class UserService {
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 根据手机号查找用户(用于P2P转账验证)
|
||||||
|
* 先查 V2 users 表,未找到再查 synced_legacy_users 表
|
||||||
|
*/
|
||||||
|
async lookupByPhone(phone: string): Promise<{ exists: boolean; accountSequence?: string; nickname?: string }> {
|
||||||
|
const phoneVO = Phone.create(phone);
|
||||||
|
|
||||||
|
// 1. 先查 V2 用户表
|
||||||
|
const user = await this.userRepository.findByPhone(phoneVO);
|
||||||
|
if (user && user.status === 'ACTIVE') {
|
||||||
|
return {
|
||||||
|
exists: true,
|
||||||
|
accountSequence: user.accountSequence.value,
|
||||||
|
nickname: user.isKycVerified ? this.maskName(user.realName!) : user.phone.masked,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. 查 1.0 同步用户表(未迁移的老用户)
|
||||||
|
const legacyUser = await this.syncedLegacyUserRepository.findByPhone(phoneVO);
|
||||||
|
if (legacyUser && legacyUser.status === 'ACTIVE' && !legacyUser.migratedToV2) {
|
||||||
|
return {
|
||||||
|
exists: true,
|
||||||
|
accountSequence: legacyUser.accountSequence.value,
|
||||||
|
nickname: legacyUser.nickname || legacyUser.phone.masked,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return { exists: false };
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 更换手机号
|
* 更换手机号
|
||||||
*/
|
*/
|
||||||
|
|
|
||||||
|
|
@ -17,6 +17,7 @@ export interface UserProps {
|
||||||
id?: bigint;
|
id?: bigint;
|
||||||
phone: Phone;
|
phone: Phone;
|
||||||
passwordHash: string;
|
passwordHash: string;
|
||||||
|
tradePasswordHash?: string; // 支付密码(独立于登录密码)
|
||||||
accountSequence: AccountSequence;
|
accountSequence: AccountSequence;
|
||||||
status: UserStatus;
|
status: UserStatus;
|
||||||
kycStatus: KycStatus;
|
kycStatus: KycStatus;
|
||||||
|
|
@ -42,6 +43,7 @@ export class UserAggregate {
|
||||||
private _id?: bigint;
|
private _id?: bigint;
|
||||||
private _phone: Phone;
|
private _phone: Phone;
|
||||||
private _passwordHash: string;
|
private _passwordHash: string;
|
||||||
|
private _tradePasswordHash?: string; // 支付密码哈希
|
||||||
private _accountSequence: AccountSequence;
|
private _accountSequence: AccountSequence;
|
||||||
private _status: UserStatus;
|
private _status: UserStatus;
|
||||||
private _kycStatus: KycStatus;
|
private _kycStatus: KycStatus;
|
||||||
|
|
@ -63,6 +65,7 @@ export class UserAggregate {
|
||||||
this._id = props.id;
|
this._id = props.id;
|
||||||
this._phone = props.phone;
|
this._phone = props.phone;
|
||||||
this._passwordHash = props.passwordHash;
|
this._passwordHash = props.passwordHash;
|
||||||
|
this._tradePasswordHash = props.tradePasswordHash;
|
||||||
this._accountSequence = props.accountSequence;
|
this._accountSequence = props.accountSequence;
|
||||||
this._status = props.status;
|
this._status = props.status;
|
||||||
this._kycStatus = props.kycStatus;
|
this._kycStatus = props.kycStatus;
|
||||||
|
|
@ -120,6 +123,17 @@ export class UserAggregate {
|
||||||
return this._passwordHash;
|
return this._passwordHash;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
get tradePasswordHash(): string | undefined {
|
||||||
|
return this._tradePasswordHash;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 是否已设置支付密码
|
||||||
|
*/
|
||||||
|
get hasTradePassword(): boolean {
|
||||||
|
return this._tradePasswordHash !== undefined && this._tradePasswordHash !== null;
|
||||||
|
}
|
||||||
|
|
||||||
get accountSequence(): AccountSequence {
|
get accountSequence(): AccountSequence {
|
||||||
return this._accountSequence;
|
return this._accountSequence;
|
||||||
}
|
}
|
||||||
|
|
@ -236,6 +250,34 @@ export class UserAggregate {
|
||||||
this._updatedAt = new Date();
|
this._updatedAt = new Date();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 设置支付密码(6位数字,使用独立的哈希逻辑,不走登录密码的格式验证)
|
||||||
|
*/
|
||||||
|
async setTradePassword(newPlainPassword: string): Promise<void> {
|
||||||
|
const password = await Password.createWithoutValidation(newPlainPassword);
|
||||||
|
this._tradePasswordHash = password.hash;
|
||||||
|
this._updatedAt = new Date();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 验证支付密码
|
||||||
|
*/
|
||||||
|
async verifyTradePassword(plainPassword: string): Promise<boolean> {
|
||||||
|
if (!this._tradePasswordHash) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
const password = Password.fromHash(this._tradePasswordHash);
|
||||||
|
return password.verify(plainPassword);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 清除支付密码
|
||||||
|
*/
|
||||||
|
clearTradePassword(): void {
|
||||||
|
this._tradePasswordHash = undefined;
|
||||||
|
this._updatedAt = new Date();
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 记录登录成功
|
* 记录登录成功
|
||||||
*/
|
*/
|
||||||
|
|
@ -398,6 +440,7 @@ export class UserAggregate {
|
||||||
id: this._id,
|
id: this._id,
|
||||||
phone: this._phone,
|
phone: this._phone,
|
||||||
passwordHash: this._passwordHash,
|
passwordHash: this._passwordHash,
|
||||||
|
tradePasswordHash: this._tradePasswordHash,
|
||||||
accountSequence: this._accountSequence,
|
accountSequence: this._accountSequence,
|
||||||
status: this._status,
|
status: this._status,
|
||||||
kycStatus: this._kycStatus,
|
kycStatus: this._kycStatus,
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,78 @@
|
||||||
|
export const CAPABILITY_REPOSITORY = Symbol('CAPABILITY_REPOSITORY');
|
||||||
|
|
||||||
|
export interface UserCapabilityRecord {
|
||||||
|
id: bigint;
|
||||||
|
accountSequence: string;
|
||||||
|
capability: string;
|
||||||
|
enabled: boolean;
|
||||||
|
reason: string | null;
|
||||||
|
disabledBy: string | null;
|
||||||
|
disabledAt: Date | null;
|
||||||
|
expiresAt: Date | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CapabilityLogRecord {
|
||||||
|
id: bigint;
|
||||||
|
accountSequence: string;
|
||||||
|
capability: string;
|
||||||
|
action: string;
|
||||||
|
reason: string | null;
|
||||||
|
operatorId: string | null;
|
||||||
|
previousValue: boolean;
|
||||||
|
newValue: boolean;
|
||||||
|
expiresAt: Date | null;
|
||||||
|
createdAt: Date;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CapabilityRepository {
|
||||||
|
findByAccountSequence(accountSequence: string): Promise<UserCapabilityRecord[]>;
|
||||||
|
|
||||||
|
upsert(data: {
|
||||||
|
accountSequence: string;
|
||||||
|
capability: string;
|
||||||
|
enabled: boolean;
|
||||||
|
reason?: string;
|
||||||
|
disabledBy?: string;
|
||||||
|
expiresAt?: Date;
|
||||||
|
}): Promise<UserCapabilityRecord>;
|
||||||
|
|
||||||
|
upsertWithLog(
|
||||||
|
upsertData: {
|
||||||
|
accountSequence: string;
|
||||||
|
capability: string;
|
||||||
|
enabled: boolean;
|
||||||
|
reason?: string;
|
||||||
|
disabledBy?: string;
|
||||||
|
expiresAt?: Date;
|
||||||
|
},
|
||||||
|
logData: {
|
||||||
|
accountSequence: string;
|
||||||
|
capability: string;
|
||||||
|
action: string;
|
||||||
|
reason?: string;
|
||||||
|
operatorId?: string;
|
||||||
|
previousValue: boolean;
|
||||||
|
newValue: boolean;
|
||||||
|
expiresAt?: Date;
|
||||||
|
},
|
||||||
|
): Promise<UserCapabilityRecord>;
|
||||||
|
|
||||||
|
findExpired(): Promise<UserCapabilityRecord[]>;
|
||||||
|
|
||||||
|
createLog(data: {
|
||||||
|
accountSequence: string;
|
||||||
|
capability: string;
|
||||||
|
action: string;
|
||||||
|
reason?: string;
|
||||||
|
operatorId?: string;
|
||||||
|
previousValue: boolean;
|
||||||
|
newValue: boolean;
|
||||||
|
expiresAt?: Date;
|
||||||
|
}): Promise<void>;
|
||||||
|
|
||||||
|
findLogsByAccountSequence(
|
||||||
|
accountSequence: string,
|
||||||
|
page: number,
|
||||||
|
pageSize: number,
|
||||||
|
): Promise<{ data: CapabilityLogRecord[]; total: number }>;
|
||||||
|
}
|
||||||
|
|
@ -2,3 +2,4 @@ export * from './user.repository.interface';
|
||||||
export * from './synced-legacy-user.repository.interface';
|
export * from './synced-legacy-user.repository.interface';
|
||||||
export * from './refresh-token.repository.interface';
|
export * from './refresh-token.repository.interface';
|
||||||
export * from './sms-verification.repository.interface';
|
export * from './sms-verification.repository.interface';
|
||||||
|
export * from './capability.repository.interface';
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,47 @@
|
||||||
|
/**
|
||||||
|
* 用户能力权限枚举
|
||||||
|
* 借鉴 Stripe Capability 模型,每项能力可独立开关
|
||||||
|
*/
|
||||||
|
export enum Capability {
|
||||||
|
LOGIN = 'LOGIN',
|
||||||
|
TRADING = 'TRADING',
|
||||||
|
C2C = 'C2C',
|
||||||
|
TRANSFER_IN = 'TRANSFER_IN',
|
||||||
|
TRANSFER_OUT = 'TRANSFER_OUT',
|
||||||
|
P2P_SEND = 'P2P_SEND',
|
||||||
|
P2P_RECEIVE = 'P2P_RECEIVE',
|
||||||
|
MINING_CLAIM = 'MINING_CLAIM',
|
||||||
|
KYC = 'KYC',
|
||||||
|
PROFILE_EDIT = 'PROFILE_EDIT',
|
||||||
|
VIEW_ASSET = 'VIEW_ASSET',
|
||||||
|
VIEW_TEAM = 'VIEW_TEAM',
|
||||||
|
VIEW_RECORDS = 'VIEW_RECORDS',
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ALL_CAPABILITIES = Object.values(Capability);
|
||||||
|
|
||||||
|
export type CapabilityMap = Record<Capability, boolean>;
|
||||||
|
|
||||||
|
export function defaultCapabilityMap(): CapabilityMap {
|
||||||
|
const map = {} as CapabilityMap;
|
||||||
|
for (const cap of ALL_CAPABILITIES) {
|
||||||
|
map[cap] = true;
|
||||||
|
}
|
||||||
|
return map;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const CAPABILITY_LABELS: Record<Capability, string> = {
|
||||||
|
[Capability.LOGIN]: '登录',
|
||||||
|
[Capability.TRADING]: '交易',
|
||||||
|
[Capability.C2C]: 'C2C交易',
|
||||||
|
[Capability.TRANSFER_IN]: '划入',
|
||||||
|
[Capability.TRANSFER_OUT]: '划出',
|
||||||
|
[Capability.P2P_SEND]: 'P2P转出',
|
||||||
|
[Capability.P2P_RECEIVE]: 'P2P收款',
|
||||||
|
[Capability.MINING_CLAIM]: '挖矿领取',
|
||||||
|
[Capability.KYC]: '实名认证',
|
||||||
|
[Capability.PROFILE_EDIT]: '编辑资料',
|
||||||
|
[Capability.VIEW_ASSET]: '查看资产',
|
||||||
|
[Capability.VIEW_TEAM]: '查看团队',
|
||||||
|
[Capability.VIEW_RECORDS]: '查看记录',
|
||||||
|
};
|
||||||
|
|
@ -2,3 +2,4 @@ export * from './account-sequence.vo';
|
||||||
export * from './phone.vo';
|
export * from './phone.vo';
|
||||||
export * from './password.vo';
|
export * from './password.vo';
|
||||||
export * from './sms-code.vo';
|
export * from './sms-code.vo';
|
||||||
|
export * from './capability.vo';
|
||||||
|
|
|
||||||
|
|
@ -20,6 +20,14 @@ export class Password {
|
||||||
return new Password(hash);
|
return new Password(hash);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 从明文密码创建(跳过格式验证,用于支付密码等有独立验证规则的场景)
|
||||||
|
*/
|
||||||
|
static async createWithoutValidation(plainPassword: string): Promise<Password> {
|
||||||
|
const hash = await bcrypt.hash(plainPassword, Password.SALT_ROUNDS);
|
||||||
|
return new Password(hash);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 从已加密的 hash 重建
|
* 从已加密的 hash 重建
|
||||||
*/
|
*/
|
||||||
|
|
|
||||||
|
|
@ -6,8 +6,9 @@ import {
|
||||||
PrismaSyncedLegacyUserRepository,
|
PrismaSyncedLegacyUserRepository,
|
||||||
PrismaRefreshTokenRepository,
|
PrismaRefreshTokenRepository,
|
||||||
PrismaSmsVerificationRepository,
|
PrismaSmsVerificationRepository,
|
||||||
|
PrismaCapabilityRepository,
|
||||||
} from './persistence/repositories';
|
} from './persistence/repositories';
|
||||||
import { LegacyUserCdcConsumer } from './messaging/cdc';
|
import { LegacyUserCdcConsumer, WalletAddressCdcConsumer } from './messaging/cdc';
|
||||||
import { KafkaModule, KafkaProducerService } from './kafka';
|
import { KafkaModule, KafkaProducerService } from './kafka';
|
||||||
import { RedisService } from './redis';
|
import { RedisService } from './redis';
|
||||||
import {
|
import {
|
||||||
|
|
@ -15,6 +16,7 @@ import {
|
||||||
SYNCED_LEGACY_USER_REPOSITORY,
|
SYNCED_LEGACY_USER_REPOSITORY,
|
||||||
REFRESH_TOKEN_REPOSITORY,
|
REFRESH_TOKEN_REPOSITORY,
|
||||||
SMS_VERIFICATION_REPOSITORY,
|
SMS_VERIFICATION_REPOSITORY,
|
||||||
|
CAPABILITY_REPOSITORY,
|
||||||
} from '@/domain';
|
} from '@/domain';
|
||||||
import { ApplicationModule } from '@/application/application.module';
|
import { ApplicationModule } from '@/application/application.module';
|
||||||
|
|
||||||
|
|
@ -24,6 +26,7 @@ import { ApplicationModule } from '@/application/application.module';
|
||||||
providers: [
|
providers: [
|
||||||
// CDC
|
// CDC
|
||||||
LegacyUserCdcConsumer,
|
LegacyUserCdcConsumer,
|
||||||
|
WalletAddressCdcConsumer,
|
||||||
|
|
||||||
// Kafka Producer
|
// Kafka Producer
|
||||||
KafkaProducerService,
|
KafkaProducerService,
|
||||||
|
|
@ -58,6 +61,10 @@ import { ApplicationModule } from '@/application/application.module';
|
||||||
provide: SMS_VERIFICATION_REPOSITORY,
|
provide: SMS_VERIFICATION_REPOSITORY,
|
||||||
useClass: PrismaSmsVerificationRepository,
|
useClass: PrismaSmsVerificationRepository,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
provide: CAPABILITY_REPOSITORY,
|
||||||
|
useClass: PrismaCapabilityRepository,
|
||||||
|
},
|
||||||
],
|
],
|
||||||
exports: [
|
exports: [
|
||||||
PrismaModule,
|
PrismaModule,
|
||||||
|
|
@ -67,6 +74,7 @@ import { ApplicationModule } from '@/application/application.module';
|
||||||
SYNCED_LEGACY_USER_REPOSITORY,
|
SYNCED_LEGACY_USER_REPOSITORY,
|
||||||
REFRESH_TOKEN_REPOSITORY,
|
REFRESH_TOKEN_REPOSITORY,
|
||||||
SMS_VERIFICATION_REPOSITORY,
|
SMS_VERIFICATION_REPOSITORY,
|
||||||
|
CAPABILITY_REPOSITORY,
|
||||||
],
|
],
|
||||||
})
|
})
|
||||||
export class InfrastructureModule {}
|
export class InfrastructureModule {}
|
||||||
|
|
|
||||||
|
|
@ -1 +1,2 @@
|
||||||
export * from './legacy-user-cdc.consumer';
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue