Compare commits
799 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 2613d42b57 | |||
| a06d55af38 | |||
| bf28617d99 | |||
| 878a28cb11 | |||
| ade062c7cd | |||
| 5d774bd1aa | |||
| 8d81bfcf94 | |||
| 095c3a8490 | |||
| f7c8c82775 | |||
| 3d75543cf0 | |||
| 1223948596 | |||
| f12964ecc8 | |||
| 693dce2e28 | |||
| 166050c581 | |||
| d499574c4d | |||
| 98363e2c28 | |||
| d87622417a | |||
| 8be1af5adb | |||
| 72a2bb7d04 | |||
| a847d4c1f6 | |||
| b860752d80 | |||
| 0a466b973b | |||
| 9ca0ca2db2 | |||
| af3f746ae1 | |||
| c64c53a26b | |||
| 765e912dd1 | |||
| f205acfb0a | |||
| 3c424143dc | |||
| ba87ffa0be | |||
| f211c93794 | |||
| 8994e3e9ba | |||
| 6018c07c0d | |||
| 41bfffdf12 | |||
| 1a06b88db9 | |||
| bd6d9013ae | |||
| 093d178bf8 | |||
| 6169f34911 | |||
| 3aca29777c | |||
| c7f2b0752b | |||
| e113b7e842 | |||
| 359bbaa31c | |||
| 862aeee558 | |||
| 4c3a3b91a6 | |||
| 199e78cb25 | |||
| 87845e2691 | |||
| 0c30d1ea30 | |||
| 2f64aa0961 | |||
| 2266609111 | |||
| 28e9cf6ab7 | |||
| 7c55c54f23 | |||
| 060d0a6970 | |||
| f0465a9ea4 | |||
| 29db1f4e56 | |||
| 755ef03f98 | |||
| 458b71ea28 | |||
| f1f409dc63 | |||
| 64d0f9fc2d | |||
| 01db9036f5 | |||
| 674e1b73f6 | |||
| 9bfb9bc711 | |||
| 96f905563f | |||
| 6b278de538 | |||
| c6fc770fc1 | |||
| e50f208064 | |||
| 637af5c216 | |||
| 08373be215 | |||
| 5e4c7e339a | |||
| 476dbcca93 | |||
| e8e4330b1c | |||
| d5c0146d48 | |||
| 83c461230c | |||
| f27a51639c | |||
| ab8d4f0340 | |||
| 1ee15bf6d3 | |||
| 56238ad93c | |||
| 2fa2c6278c | |||
| 519978f634 | |||
| 7095d8f015 | |||
| e8dcfa0687 | |||
| a0e12fa89a | |||
| 097be9aa0f | |||
| f50b5296f4 | |||
| 14e94053ee | |||
| 3c27fa4d90 | |||
| 2a78ace675 | |||
| 81774f87d8 | |||
| 6735c2b38f | |||
| 8bdc5a2332 | |||
| d26262bfbf | |||
| 48f2f01bdd | |||
| ef68a43589 | |||
| 733c1b72ec | |||
| c9ca360286 | |||
| 5aad1e025b | |||
| e811e7c877 | |||
| 932e9f292b | |||
| 03749574d6 | |||
| 1059a51688 | |||
| a1963d5b17 | |||
| 426e736cd5 | |||
| fb03cff403 | |||
| 40780f1184 | |||
| c76290b51e | |||
| 5b6dd3058e | |||
| c8d91a1ec1 | |||
| 21952f84eb | |||
| 7bdfaa312b | |||
| 1829a469e7 | |||
| ddd37a4e98 | |||
| 2804c701c0 | |||
| b3c85b2db5 | |||
| 987b8170c6 | |||
| ab49f3cd1e | |||
| 732e8f7b8b | |||
| 9273a4e15b | |||
| d89293f76a | |||
| 174462183d | |||
| 783927be37 | |||
| 98c68dd8dc | |||
| 13585eab54 | |||
| 169336da43 | |||
| 9ef875f6bf | |||
| 3f54706a72 | |||
| 835e5e6f7f | |||
| b292442e8d | |||
| 9faaf5c963 | |||
| 117e88fe4a | |||
| 96bf08a07c | |||
| 4523403de5 | |||
| 02682e88c1 | |||
| 323b9a7e18 | |||
| 100dc3730f | |||
| 919047317a | |||
| 6ae5b59ed1 | |||
| b3123e2e85 | |||
| fbe0ca790e | |||
| 785648ba7a | |||
| 06f8728efa | |||
| 99f102a06e | |||
| d759dd3a66 | |||
| 73dc22b0b8 | |||
| 5a082f0eb8 | |||
| 073d5a119b | |||
| 2dab106bef | |||
| ec8c7b7242 | |||
| a4b96a089f | |||
| 269f8f6220 | |||
| f1beda2b89 | |||
| 6a00db32ae | |||
| 276bc5eb40 | |||
| 8678afd055 | |||
| 31340ad4a5 | |||
| 98ce47ce71 | |||
| 3462f5b3b1 | |||
| 7ec606f93c | |||
| 7d13bf47aa | |||
| 87773806fc | |||
| dad6200ad8 | |||
| c3991162c2 | |||
| 08feb1f876 | |||
| 6f15070052 | |||
| 0c4e73ab92 | |||
| 48902c4968 | |||
| 3c715577a6 | |||
| 8e67c7a97a | |||
| c938d48026 | |||
| 99a4927f8a | |||
| df5b8dcdcd | |||
| d4a1a2e64d | |||
| 5e2df269a3 | |||
| a2056e0441 | |||
| a6bbd8f8fb | |||
| 9f4e0cf5f8 | |||
| c7e8365d3f | |||
| 49b10b490e | |||
| f26bca8627 | |||
| df139b3d34 | |||
| 05d1edf278 | |||
| a518df8ff0 | |||
| 0a13fe4f40 | |||
| 6410bd1085 | |||
| 55c4549cff | |||
| e898948073 | |||
| 5f1c8d8ac0 | |||
| 8d1f8f2355 | |||
| fbbbad608b | |||
| 03fc4abd2a | |||
| f280a37818 | |||
| f100ec62d4 | |||
| cd827612d9 | |||
| 50da2f82c8 | |||
| 4c814ec5f4 | |||
| fb8c02d5ab | |||
| 465ad3d1ad | |||
| 2bfaab7d16 | |||
| e356542df7 | |||
| a3dfbdce3b | |||
| a666e8d248 | |||
| 9ef8df22a8 | |||
| a1471c8b9a | |||
| a99a14922c | |||
| ce954ad775 | |||
| efba7c10d0 | |||
| cd10c131b2 | |||
| 3e4cb8dfa2 | |||
| 6471ab547c | |||
| c10bf07124 | |||
| 4e05882522 | |||
| 061b292d92 | |||
| 779a6c309a | |||
| c79c71f209 | |||
| d4dbce5606 | |||
| e53d99223d | |||
| ec7274e0c9 | |||
| 8fe631a98e | |||
| bd831bd92b | |||
| 9b31ba8f3d | |||
| 3759be333a | |||
| 555894b26c | |||
| bb636c003c | |||
| 40c7b4aebd | |||
| 028bd4888b | |||
| 27d64e361a | |||
| 6fc7dd49dd | |||
| b835ba1d65 | |||
| 811e985087 | |||
| c16733b93d | |||
| 23ab3c5198 | |||
| 9b8f121e69 | |||
| 86b1b95fb9 | |||
| 9418681a00 | |||
| 7ebd937e2c | |||
| 25c404f999 | |||
| 6df92bde2a | |||
| 60ff006fec | |||
| 0181f5e10c | |||
| 4bbcc60bbb | |||
| 8e2138e2aa | |||
| 70b9e8c10d | |||
| dc1eddb0df | |||
| 630551cc4b | |||
| 4f94b25182 | |||
| d3b240eae3 | |||
| e2883d6727 | |||
| 21757b37ab | |||
| 9190857ffa | |||
| a607443aa7 | |||
| 41969bc0c2 | |||
| 0d443afb87 | |||
| 3d85294d41 | |||
| 86896771a6 | |||
| 42d52990fa | |||
| 6296c8257f | |||
| 14a40cb1b6 | |||
| d0e3650de5 | |||
| df8ed666ae | |||
| 2217b7f800 | |||
| 924febc2f3 | |||
| 729ffb7952 | |||
| 1568ca7633 | |||
| 7d5e476580 | |||
| 9bcd187358 | |||
| a9cbb6272f | |||
| b524decfa7 | |||
| 5ee01f7735 | |||
| f46f2d6523 | |||
| 435dbf6cee | |||
| 94d38eeb71 | |||
| 0e7f6f90ed | |||
| f3789287dc | |||
| a8312d7c10 | |||
| 612f4a7978 | |||
| 2462347b5f | |||
| 3ec3c0d76b | |||
| 8224aa98f4 | |||
| bb9d9422fb | |||
| f3a184ff00 | |||
| 866e4586df | |||
| a30647893d | |||
| 73826312e5 | |||
| dae34dc0ef | |||
| 17d0c8c26f | |||
| 67831dc162 | |||
| ae84fbe141 | |||
| 8c9bd890e8 | |||
| e8c1956de5 | |||
| 1540a41d3a | |||
| 9cb61cb696 | |||
| 3511325285 | |||
| 62507c5d28 | |||
| 387df29a05 | |||
| 75dca7e1ad | |||
| 2d5e54d5ad | |||
| d62b2b0d6f | |||
| e273719ad1 | |||
| f6726f1c94 | |||
| 92ff4f661f | |||
| dfebc5e608 | |||
| 8e6121cd0b | |||
| 6c0d5714bd | |||
| ed29697434 | |||
| 07aebef82f | |||
| 7ce390fd50 | |||
| cebe392d1c | |||
| fd160f585a | |||
| 5c0516fbc0 | |||
| ba075326c1 | |||
| c32425341e | |||
| cc8df1eff3 | |||
| 49cda0791c | |||
| 60ea363a62 | |||
| f66619b225 | |||
| 5999402e1a | |||
| 3aebccc090 | |||
| ed577dd51a | |||
| e517487596 | |||
| f9441909a4 | |||
| f13a015bfa | |||
| 18bf946464 | |||
| e4c46b2488 | |||
| e0539b116c | |||
| acdf7af1d9 | |||
| d5b7828079 | |||
| 03be071edc | |||
| e8742f30a6 | |||
| 3d7727dbf2 | |||
| 5bff67860f | |||
| 429e967da2 | |||
| c3df3beeae | |||
| 4d664c15d7 | |||
| 53be365e38 | |||
| 3c3ffed32e | |||
| 015726b3a6 | |||
| 4a3267712b | |||
| 9b45aae410 | |||
| f43b391d0e | |||
| b09b7a0c84 | |||
| 541b44b933 | |||
| b21fce0eff | |||
| 017aa27c0f | |||
| 496978e8a2 | |||
| b3711a590d | |||
| 631a0f2c4c | |||
| 503f7ca455 | |||
| 80c771b7bc | |||
| 6e344d350b | |||
| 7738bc83af | |||
| 6f7a1eecf6 | |||
| bf147a736a | |||
| ea3decf1ac | |||
| b1c0315a6e | |||
| f6fa7e8f41 | |||
| 33d4d7994c | |||
| 0ce4ce2b08 | |||
| 3a9163e919 | |||
| 376cff748b | |||
| d5cef080da | |||
| 33e31213b3 | |||
| 8077ba0c35 | |||
| 67ad3847f8 | |||
| 1eda042b34 | |||
| 961ff46f43 | |||
| 535ec34a1b | |||
| fe474fd0bf | |||
| bfc737c1d3 | |||
| ce0eb32d4e | |||
| 090df2e3ea | |||
| 01d9df2867 | |||
| 334a14c80c | |||
| 3d952570af | |||
| bf9567fe6f | |||
| 6c4c39b454 | |||
| d39eb3f79f | |||
| 7f0ce61126 | |||
| 775056da6f | |||
| a15ecb375b | |||
| 3dea941c51 | |||
| 410f0aba95 | |||
| 1ec13fbf63 | |||
| 012408b7cd | |||
| fdca75b153 | |||
| 40c235cdac | |||
| 7ea8fa735c | |||
| 758141dbc4 | |||
| 3c37fa45e6 | |||
| 35a80f4585 | |||
| 57e033c7c6 | |||
| d05d070faa | |||
| 3f3829a745 | |||
| 21bb89e5ea | |||
| fad5ecbb89 | |||
| 2c64c57edd | |||
| 117c637756 | |||
| 488f161322 | |||
| a55ef684e6 | |||
| 90234ba01e | |||
| cd83361050 | |||
| 08cda481ba | |||
| 45f998d134 | |||
| 33ee5b4ec4 | |||
| 545bbaafa5 | |||
| cc957460bb | |||
| cd51dab845 | |||
| 74923515cc | |||
| b3f3c415df | |||
| 0b9de65cbc | |||
| 1c26171b52 | |||
| a6bc0afdd3 | |||
| 966cfe6565 | |||
| d5b1c5805e | |||
| a4ce5ff9ca | |||
| 8e4b70578b | |||
| 5367874062 | |||
| 321696b5b5 | |||
| b642a5550c | |||
| 0e97632099 | |||
| 4a3c6c4124 | |||
| 3bf9c143d4 | |||
| 285e5e256e | |||
| 5b06b89d7a | |||
| 7e749fa096 | |||
| 13ac3f0e74 | |||
| 32c551999c | |||
| ff2c09d3be | |||
| 4c97ac4497 | |||
| 86516186d9 | |||
| e0581bdc3c | |||
| 0b7d8d41f6 | |||
| 4ca46537f9 | |||
| 75f2d0f1ce | |||
| 51424bf256 | |||
| 4cce2f1efb | |||
| 5102286963 | |||
| 24c8f25e6e | |||
| de4796ac1c | |||
| 95a8da5f19 | |||
| 76f1313550 | |||
| 58d22200cf | |||
| eb8b09e658 | |||
| 25cf635b6f | |||
| a54c1001f9 | |||
| 6140d7042a | |||
| d0135a2328 | |||
| 1048734548 | |||
| 62116fd5ba | |||
| 37a905f056 | |||
| bce9061401 | |||
| 118b282a2b | |||
| 6c250ed36f | |||
| 3842aeacca | |||
| 1d1afbe1da | |||
| d4db55ea66 | |||
| 354964060c | |||
| 5bc8d8e52c | |||
| 8f3e8bacc4 | |||
| 61c94e17a4 | |||
| 77c52c1197 | |||
| c65c703e00 | |||
| 7560e802cb | |||
| a5a90a806b | |||
| aa0a271d14 | |||
| 5d4e51c862 | |||
| e6057bb93c | |||
| 714bdd0d7c | |||
| 41f3eb76c1 | |||
| e9c58b3f76 | |||
| 3d6976ff6e | |||
| ee978dff7b | |||
| 0bcf5c5f43 | |||
| 2ceedd6587 | |||
| 2ded1a4a9e | |||
| d551a7267f | |||
| 3ddf6b35c6 | |||
| c0d3c70683 | |||
| 7267433d99 | |||
| 09f3c4d27f | |||
| 3083915bc8 | |||
| 5c72c04d27 | |||
| 826149da3d | |||
| f090be328f | |||
| ecbfcd2b54 | |||
| 2d21b1c542 | |||
| c63438af5e | |||
| 8e47013ae2 | |||
| 92fa0ebcc1 | |||
| f1d23616f1 | |||
| d554f2d801 | |||
| 1fe4608604 | |||
| bea534b352 | |||
| e993c19a34 | |||
| 2deb5d1bc1 | |||
| fb1b0bbcd5 | |||
| 0308dd6c58 | |||
| 860bf7fc7a | |||
| c24024db18 | |||
| 7d330838e0 | |||
| feed3fff0d | |||
| 4ddaafd052 | |||
| 140194a0ad | |||
| 7c6adae87f | |||
| 03deb91a25 | |||
| ea2caa98ae | |||
| dab69b9229 | |||
| 0ffa86ff36 | |||
| 3b08b71472 | |||
| 6c7b1353df | |||
| 9e009d2ded | |||
| dc6a829f0f | |||
| 3a3a4aa9aa | |||
| 99cd22d76b | |||
| 0a920bffe6 | |||
| 55fbc65231 | |||
| f508ac151b | |||
| abec12eed1 | |||
| 9b50fbbe05 | |||
| 9311e7c11d | |||
| 625972da87 | |||
| d3293615b0 | |||
| eef1fcc705 | |||
| ffe8259025 | |||
| bac42b51e1 | |||
| 510ec2990d | |||
| dc06056cc7 | |||
| 103d6e8854 | |||
| 466f1e66cc | |||
| 5de90b20f6 | |||
| 28a26b61ac | |||
| 2182b47666 | |||
| 347c62ec8b | |||
| 736e4c6cae | |||
| 92a07e2acf | |||
| 83af139d01 | |||
| e76e297d22 | |||
| b317368df2 | |||
| 572170b1cc | |||
| 1adeb53a0d | |||
| 3e5811ec11 | |||
| e26a76c8cf | |||
| 1d5755e415 | |||
| 32beec7515 | |||
| feb2250f37 | |||
| da36db39c5 | |||
| fd0fa71f85 | |||
| 04250b38bb | |||
| deb6978572 | |||
| d2a26ead1e | |||
| 9e40f406ee | |||
| 2cdfaf9356 | |||
| 66af399bc0 | |||
| 5fa69dec3e | |||
| 3d5f3303d1 | |||
| 10883e92b1 | |||
| e2d28333ec | |||
| 6d55d4832f | |||
| 46c563873f | |||
| 10c5d3c408 | |||
| 942080c460 | |||
| 6a0869ca0a | |||
| d9992dd2f2 | |||
| c8c9631518 | |||
| 943f29f227 | |||
| 80a069077c | |||
| e344e55e6a | |||
| a69947b69a | |||
| 926f593f50 | |||
| f13734a4e9 | |||
| 2107166926 | |||
| 60e5601690 | |||
| 0957d9ef88 | |||
| eb481c57ec | |||
| e0b1666f5d | |||
| f2afc088af | |||
| 971d59b2c6 | |||
| 83fe58056f | |||
| 5f107922d0 | |||
| a2ea6b7bf7 | |||
| 1e0e728840 | |||
| cea6ed3f95 | |||
| f15705ed47 | |||
| 0b9c13ae4b | |||
| d0c8ae02f0 | |||
| 4eda7401b9 | |||
| 0c2f3c9399 | |||
| 541ecae48c | |||
| 82a551f1d4 | |||
| fd2253135d | |||
| d0820aa47e | |||
| 09c5800f49 | |||
| 52aa2dc6a8 | |||
| 6418ee7735 | |||
| 25aef89b4f | |||
| 77d17f97d3 | |||
| d5c1ec3aea | |||
| 97a7384381 | |||
| 27b7bfccba | |||
| ab15624a93 | |||
| a9c5d8ed69 | |||
| 234b90796e | |||
| 5f018ba22f | |||
| e11cbbcc18 | |||
| ba4d30d8db | |||
| d11431769c | |||
| d1d1bbb3e4 | |||
| 823f30882a | |||
| d36f389469 | |||
| add686a4d5 | |||
| 59be2d8f02 | |||
| aa3db49f65 | |||
| c101cb3d3f | |||
| 63912af43b | |||
| d19263dd79 | |||
| f8c9c59a03 | |||
| 5595f7a954 | |||
| bac4527945 | |||
| 51da8bcb72 | |||
| 8aa6e7e245 | |||
| 5029b35d14 | |||
| 2412027d9b | |||
| ddff5219e5 | |||
| 346bc18b58 | |||
| c256ae3664 | |||
| 2b47358a9e | |||
| e0b041ce44 | |||
| 3f64dee927 | |||
| 67c5ebeabd | |||
| 84bc8e96cc | |||
| 2d30cec7c6 | |||
| 5f5f66178b | |||
| e9fadec0ac | |||
| 10008e51ff | |||
| 3098610d17 | |||
| 7a2566d320 | |||
| 01ba8ca53e | |||
| 2a0a2dccff | |||
| 66a6f32d4c | |||
| 02b3f61953 | |||
| dc08526255 | |||
| 2ca339610b | |||
| ffe65937c1 | |||
| 4b6f03eef5 | |||
| d2f530a7fe | |||
| 44ea4b6b13 | |||
| ebc8421a70 | |||
| 526f142dea | |||
| ca431d36e6 | |||
| 68a81bdd89 | |||
| 68a3d713ef | |||
| 50d3d08e38 | |||
| 9a2fec4d8f | |||
| e68b1d2092 | |||
| 6dfedef81e | |||
| 317e39c823 | |||
| 0072a3cb71 | |||
| 8d021c1530 | |||
| 23606dc2d4 | |||
| 0838c4196d | |||
| 65605a0de2 | |||
| a20ac80281 | |||
| 02e83fb0d0 | |||
| eddad0db6e | |||
| ffad0afb61 | |||
| 83eed8e1cd | |||
| 0c7c8abade | |||
| a064890ed8 | |||
| f310f4a35e | |||
| 0268e09882 | |||
| 449814d0df | |||
| a3d3577103 | |||
| a3086fdd2c | |||
| daafc6a41e | |||
| c27ee116f7 | |||
| 5167cde443 | |||
| 34542d3a9c | |||
| e73d1df4cc | |||
| f9a95c3d48 | |||
| 2dd2b5fdbf | |||
| 896f1db9d4 | |||
| a1bd2d775f | |||
| 8cbe23f9ed | |||
| edc891c93d | |||
| 2d706a4be0 | |||
| 32cb81ef34 | |||
| 88aa3c4a3d | |||
| e813d3ccb7 | |||
| a24d7a4e24 | |||
| 39987b9717 | |||
| c77a1ea4f8 | |||
| 64b5d90d66 | |||
| 5a1fb32e1d | |||
| c4d8108e8c | |||
| 1350d80083 | |||
| e0cc218007 | |||
| bcaf8f2247 | |||
| d1190ed9dc | |||
| 513d6037d4 | |||
| e86012b2ab | |||
| 214e381dc5 | |||
| aa79cb670b | |||
| f0106e15fa | |||
| 1d194c6be2 | |||
| 6917890007 | |||
| 1c69de7ecd | |||
| 62188793d9 | |||
| 585ee2d42b | |||
| 2a58cfd45c | |||
| 93003c5fe0 | |||
| 91b0787d72 | |||
| 315277d4b0 | |||
| 1821c0d89d | |||
| 5650da0c5f | |||
| 0ca8b73cef | |||
| 6bab26ab27 | |||
| a72243e7fa | |||
| 5bd86d9cf7 | |||
| 00fd3fbadb | |||
| b806f40797 | |||
| c82aa1d253 | |||
| 071919f0db | |||
| ab41e96ba5 | |||
| 95a470ba7b | |||
| 1e0653ed33 | |||
| 3f0df08031 | |||
| 8c3bc8a5ba | |||
| 3a5a8f48e4 | |||
| f4b99ffb09 | |||
| 0c6afdebc3 | |||
| 98b382ba2f | |||
| 54a24385b1 | |||
| 3eeb84cdae | |||
| 204a10ae14 | |||
| ba1dd7547e | |||
| 238585c257 | |||
| d8ebe1ad13 | |||
| 819df3e93a | |||
| 99e46feafa | |||
| a432c8ab54 | |||
| d740b7560c | |||
| 91c4c73d7e | |||
| 4ce2384f27 | |||
| 02a6289d56 | |||
| 52ae6eba81 | |||
| 080b6e74a2 | |||
| ad7d32023c | |||
| 1baf8fbe37 | |||
| 758ca6a373 | |||
| 65504d0a23 | |||
| 0409f4e2e2 | |||
| 8ca63e939a | |||
| 25a621fd51 | |||
| 3f71a8aff7 | |||
| 6ec81c37b9 | |||
| 8a9ff98da8 | |||
| bb7e29eeac | |||
| 27d8783104 | |||
| 9a01e6db43 | |||
| b341ddf9d9 | |||
| 3c8d0ac859 | |||
| 3f529c0dee | |||
| a6734e542d | |||
| 2287a67dfd | |||
| 31dbaa80d7 | |||
| b1846a3ea9 | |||
| e1a7e9e622 | |||
| 0ae48faedb | |||
| b8122222f8 | |||
| 5e5a39c22e | |||
| 1e7cf214e9 | |||
| d7e7a2df9a | |||
| 584aa35894 | |||
| 652ae8057a | |||
| 0bc0a1354a | |||
| 7d30838813 | |||
| 3da178ed02 | |||
| e6ac3e3812 | |||
| 62087e20b5 | |||
| b7a47c6eb6 | |||
| 9b14baca51 | |||
| eb1a75c9f5 | |||
| 5e79106134 | |||
| ee45ec356e | |||
| 9275bff828 | |||
| 9150c70618 | |||
| b792e749d6 | |||
| 9871572740 | |||
| 52c1cb3cbc | |||
| d5b2b371c8 | |||
| d5cb022b02 | |||
| 021f62fd30 | |||
| 684e398798 | |||
| 12b64e9978 | |||
| 273df23f20 | |||
| a9b0dbf3eb | |||
| 5f56e068d3 | |||
| 92b82132d8 | |||
| 79ae9d3a1c | |||
| 86714a93f9 | |||
| 553f6f8cf6 | |||
| b561f67c12 | |||
| d03f9b80c7 |
+200
-20
@@ -1,29 +1,209 @@
|
||||
# Bot Configuration
|
||||
# ===============================================
|
||||
# 🤖 REMNAWAVE BEDOLAGA BOT CONFIGURATION
|
||||
# ===============================================
|
||||
|
||||
# ===== TELEGRAM BOT =====
|
||||
BOT_TOKEN=
|
||||
ADMIN_IDS=
|
||||
SUPPORT_USERNAME=@support
|
||||
|
||||
# RemnaWave API Configuration
|
||||
REMNAWAVE_URL=https://example.com
|
||||
REMNAWAVE_MODE=remote
|
||||
REMNAWAVE_TOKEN=
|
||||
SUBSCRIPTION_BASE_URL=https://example.com
|
||||
# ===== DATABASE =====
|
||||
# Для Docker используйте PostgreSQL:
|
||||
DATABASE_URL=postgresql+asyncpg://remnawave_user:secure_password_123@postgres:5432/remnawave_bot
|
||||
# Для локального запуска без Docker используйте SQLite: sqlite+aiosqlite:///./bot.db
|
||||
# DATABASE_URL=postgresql+asyncpg://remnawave_user:secure_password_123@postgres:5432/remnawave_bot
|
||||
|
||||
# Admin Configuration (comma-separated list of Telegram user IDs)
|
||||
ADMIN_IDS=123445567,12342123
|
||||
REDIS_URL=redis://redis:6379/0
|
||||
|
||||
# Support Configuration
|
||||
DEFAULT_LANGUAGE=ru
|
||||
SUPPORT_USERNAME=support
|
||||
# Пароли для Docker (PostgreSQL/Redis)
|
||||
POSTGRES_DB=remnawave_bot
|
||||
POSTGRES_USER=remnawave_user
|
||||
POSTGRES_PASSWORD=secure_password_123
|
||||
|
||||
# Database Configuration (optional, defaults to SQLite)
|
||||
# DATABASE_URL=sqlite+aiosqlite:///bot.db
|
||||
# ===== REMNAWAVE API =====
|
||||
REMNAWAVE_API_URL=https://panel.example.com
|
||||
REMNAWAVE_API_KEY=
|
||||
# Для панелей установленных скриптом eGames прописывать ключ в формате XXXXXXX:DDDDDDDD - https://panel.example.com/auth/login?XXXXXXX=DDDDDDDD
|
||||
REMNAWAVE_SECRET_KEY=your_secret_key_here
|
||||
|
||||
TRIAL_ENABLED=true
|
||||
# ========= ПОДПИСКИ =========
|
||||
# ===== ТРИАЛ ПОДПИСКА =====
|
||||
TRIAL_DURATION_DAYS=3
|
||||
TRIAL_TRAFFIC_GB=2
|
||||
TRIAL_TRAFFIC_LIMIT_GB=10
|
||||
TRIAL_DEVICE_LIMIT=1
|
||||
TRIAL_SQUAD_UUID=
|
||||
TRIAL_PRICE=0.0
|
||||
|
||||
# Monitor Service Settings (дополнительные настройки)
|
||||
MONITOR_CHECK_INTERVAL=3600
|
||||
MONITOR_DAILY_CHECK_HOUR=10
|
||||
MONITOR_WARNING_DAYS=2
|
||||
# ===== ПЛАТНАЯ ПОДПИСКА =====
|
||||
# Сколько устройств доступно по дефолту при покупке платной подписки
|
||||
DEFAULT_DEVICE_LIMIT=3
|
||||
|
||||
# Максимум устройств достопных к покупке (0 = Нет лимита)
|
||||
MAX_DEVICES_LIMIT=15
|
||||
|
||||
# Дефолт параметры для подписок выданных через админку
|
||||
DEFAULT_TRAFFIC_LIMIT_GB=100
|
||||
|
||||
# ===== ГЛОБАЛЬНЫЙ ПАРАМЕТР ДЛЯ ВСЕХ ПОДПИСОК =====
|
||||
DEFAULT_TRAFFIC_RESET_STRATEGY=MONTH
|
||||
|
||||
# ===== НАСТРОЙКИ ТРАФИКА =====
|
||||
# Режим выбора трафика:
|
||||
# "selectable" - пользователи выбирают пакеты трафика (по умолчанию)
|
||||
# "fixed" - фиксированный лимит трафика для всех подписок
|
||||
TRAFFIC_SELECTION_MODE=selectable
|
||||
|
||||
# Фиксированный лимит трафика в ГБ (используется только в режиме "fixed")
|
||||
# 0 = безлимит
|
||||
FIXED_TRAFFIC_LIMIT_GB=100
|
||||
|
||||
# ===== ПЕРИОДЫ ПОДПИСКИ =====
|
||||
# Доступные периоды подписки (через запятую)
|
||||
# Возможные значения: 14,30,60,90,180,360
|
||||
AVAILABLE_SUBSCRIPTION_PERIODS=30,90,180
|
||||
AVAILABLE_RENEWAL_PERIODS=30,90,180
|
||||
|
||||
# ===== ЦЕНЫ (в копейках) =====
|
||||
BASE_SUBSCRIPTION_PRICE=0
|
||||
|
||||
# Цены за периоды
|
||||
PRICE_14_DAYS=7000
|
||||
PRICE_30_DAYS=9900
|
||||
PRICE_60_DAYS=25900
|
||||
PRICE_90_DAYS=36900
|
||||
PRICE_180_DAYS=69900
|
||||
PRICE_360_DAYS=109900
|
||||
|
||||
# Выводимые пакеты трафика и их цены в копейках
|
||||
TRAFFIC_PACKAGES_CONFIG="5:2000:false,10:3500:false,25:7000:false,50:11000:true,100:15000:true,250:17000:false,500:19000:false,1000:19500:true,0:20000:true"
|
||||
|
||||
# Цена за дополнительное устройство (DEFAULT_DEVICE_LIMIT идет бесплатно!)
|
||||
PRICE_PER_DEVICE=5000
|
||||
|
||||
# ===== РЕФЕРАЛЬНАЯ СИСТЕМА =====
|
||||
REFERRAL_MINIMUM_TOPUP_KOPEKS=10000
|
||||
REFERRAL_FIRST_TOPUP_BONUS_KOPEKS=10000
|
||||
REFERRAL_INVITER_BONUS_KOPEKS=10000
|
||||
REFERRAL_COMMISSION_PERCENT=25
|
||||
|
||||
# ===== АВТОПРОДЛЕНИЕ =====
|
||||
AUTOPAY_WARNING_DAYS=3,1
|
||||
DEFAULT_AUTOPAY_DAYS_BEFORE=3
|
||||
MIN_BALANCE_FOR_AUTOPAY_KOPEKS=10000
|
||||
|
||||
# ===== ПЛАТЕЖНЫЕ СИСТЕМЫ =====
|
||||
|
||||
# Telegram Stars (работает автоматически)
|
||||
TELEGRAM_STARS_ENABLED=true
|
||||
TELEGRAM_STARS_RATE_RUB=1.3
|
||||
|
||||
# Tribute (https://tribute.app)
|
||||
TRIBUTE_ENABLED=false
|
||||
TRIBUTE_API_KEY=
|
||||
TRIBUTE_WEBHOOK_SECRET=your_webhook_secret
|
||||
TRIBUTE_DONATE_LINK=
|
||||
TRIBUTE_WEBHOOK_PATH=/tribute-webhook
|
||||
TRIBUTE_WEBHOOK_PORT=8081
|
||||
|
||||
# YooKassa (https://yookassa.ru)
|
||||
YOOKASSA_ENABLED=false
|
||||
YOOKASSA_SHOP_ID=
|
||||
YOOKASSA_SECRET_KEY=
|
||||
YOOKASSA_RETURN_URL=
|
||||
YOOKASSA_DEFAULT_RECEIPT_EMAIL=receipts@yourdomain.com
|
||||
|
||||
# Настройки чеков для налоговой
|
||||
YOOKASSA_VAT_CODE=1
|
||||
# Коды НДС:
|
||||
# 1 - НДС не облагается
|
||||
# 2 - НДС 0%
|
||||
# 3 - НДС 10%
|
||||
# 4 - НДС 20%
|
||||
# 5 - НДС 10/110
|
||||
# 6 - НДС 20/120
|
||||
|
||||
YOOKASSA_PAYMENT_MODE=full_payment
|
||||
# Способы расчета:
|
||||
# full_payment - полная оплата
|
||||
# partial_payment - частичная оплата
|
||||
# advance - аванс
|
||||
# full_prepayment - полная предоплата
|
||||
# partial_prepayment - частичная предоплата
|
||||
# credit - передача в кредит
|
||||
# credit_payment - оплата кредита
|
||||
|
||||
YOOKASSA_PAYMENT_SUBJECT=service
|
||||
# Предметы расчета:
|
||||
# commodity - товар
|
||||
# excise - подакцизный товар
|
||||
# job - работа
|
||||
# service - услуга
|
||||
# gambling_bet - ставка в азартной игре
|
||||
# gambling_prize - выигрыш в азартной игре
|
||||
# lottery - лотерейный билет
|
||||
# lottery_prize - выигрыш в лотерее
|
||||
# intellectual_activity - результат интеллектуальной деятельности
|
||||
# payment - платеж
|
||||
# agent_commission - агентское вознаграждение
|
||||
# composite - составной предмет расчета
|
||||
# another - другое
|
||||
|
||||
# Webhook настройки
|
||||
YOOKASSA_WEBHOOK_PATH=/yookassa-webhook
|
||||
YOOKASSA_WEBHOOK_PORT=8082
|
||||
YOOKASSA_WEBHOOK_SECRET=your_webhook_secret
|
||||
|
||||
# ===== НАСТРОЙКИ ОПИСАНИЙ ПЛАТЕЖЕЙ =====
|
||||
# Эти настройки позволяют изменить описания платежей,
|
||||
# чтобы избежать блокировок платежных систем
|
||||
PAYMENT_SERVICE_NAME=Интернет-сервис
|
||||
PAYMENT_BALANCE_DESCRIPTION=Пополнение баланса
|
||||
PAYMENT_SUBSCRIPTION_DESCRIPTION=Оплата подписки
|
||||
PAYMENT_BALANCE_TEMPLATE={service_name} - {description}
|
||||
PAYMENT_SUBSCRIPTION_TEMPLATE={service_name} - {description}
|
||||
|
||||
# ===== ИНТЕРФЕЙС И UX =====
|
||||
|
||||
# Режим работы кнопки "Подключиться"
|
||||
# guide - открывает гайд подключения (режим 1)
|
||||
# miniapp_subscription - открывает ссылку подписки в мини-приложении (режим 2)
|
||||
# miniapp_custom - открывает заданную ссылку в мини-приложении (режим 3)
|
||||
CONNECT_BUTTON_MODE=guide
|
||||
|
||||
# URL для режима miniapp_custom (обязателен при CONNECT_BUTTON_MODE=miniapp_custom)
|
||||
MINIAPP_CUSTOM_URL=
|
||||
|
||||
# ===== МОНИТОРИНГ И УВЕДОМЛЕНИЯ =====
|
||||
MONITORING_INTERVAL=60
|
||||
INACTIVE_USER_DELETE_MONTHS=3
|
||||
|
||||
# Уведомления
|
||||
TRIAL_WARNING_HOURS=2
|
||||
ENABLE_NOTIFICATIONS=true
|
||||
NOTIFICATION_RETRY_ATTEMPTS=3
|
||||
MONITORING_LOGS_RETENTION_DAYS=30
|
||||
NOTIFICATION_CACHE_HOURS=24
|
||||
|
||||
# ===== РЕЖИМ ТЕХНИЧЕСКИХ РАБОТ =====
|
||||
MAINTENANCE_MODE=false
|
||||
MAINTENANCE_CHECK_INTERVAL=30
|
||||
MAINTENANCE_AUTO_ENABLE=true
|
||||
MAINTENANCE_MESSAGE=Ведутся технические работы. Сервис временно недоступен. Попробуйте позже.
|
||||
|
||||
# ===== ЛОКАЛИЗАЦИЯ =====
|
||||
DEFAULT_LANGUAGE=ru
|
||||
AVAILABLE_LANGUAGES=ru,en
|
||||
|
||||
# ===== ЛОГИРОВАНИЕ =====
|
||||
LOG_LEVEL=INFO
|
||||
LOG_FILE=logs/bot.log
|
||||
|
||||
# ===== РАЗРАБОТКА =====
|
||||
DEBUG=false
|
||||
WEBHOOK_URL=
|
||||
WEBHOOK_PATH=/webhook
|
||||
|
||||
# ===== ДОПОЛНИТЕЛЬНЫЕ НАСТРОЙКИ =====
|
||||
# Конфигурация приложений для гайда подключения
|
||||
APP_CONFIG_PATH=app-config.json
|
||||
ENABLE_DEEP_LINKS=true
|
||||
APP_CONFIG_CACHE_TTL=3600
|
||||
|
||||
@@ -1,31 +1,28 @@
|
||||
name: BedolagaBot
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v3
|
||||
|
||||
uses: actions/checkout@v4
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v2
|
||||
|
||||
uses: docker/setup-buildx-action@v3
|
||||
- name: Login to Docker Hub
|
||||
uses: docker/login-action@v2
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
username: ${{ secrets.DOCKER_USERNAME }}
|
||||
password: ${{ secrets.DOCKER_PASSWORD }}
|
||||
|
||||
- name: Build and push Docker image
|
||||
uses: docker/build-push-action@v4
|
||||
uses: docker/build-push-action@v5
|
||||
with:
|
||||
context: .
|
||||
file: ./Dockerfile
|
||||
platforms: linux/amd64,linux/arm64
|
||||
push: true
|
||||
tags: fr1ngg/remnawave-bedolaga-telegram-bot:latest
|
||||
cache-from: type=gha
|
||||
cache-to: type=gha,mode=max
|
||||
@@ -0,0 +1,69 @@
|
||||
name: Build and Publish Docker Image
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
- master
|
||||
- dev
|
||||
tags:
|
||||
- 'v*'
|
||||
pull_request:
|
||||
branches:
|
||||
- main
|
||||
- master
|
||||
- dev
|
||||
env:
|
||||
REGISTRY: ghcr.io
|
||||
IMAGE_NAME: fr1ngg/remnawave-bedolaga-telegram-bot
|
||||
jobs:
|
||||
build-and-push:
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
packages: write
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
- name: Log in to Container Registry
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: ${{ env.REGISTRY }}
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
- name: Extract metadata
|
||||
id: meta
|
||||
uses: docker/metadata-action@v5
|
||||
with:
|
||||
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
|
||||
tags: |
|
||||
type=ref,event=branch
|
||||
type=ref,event=pr
|
||||
type=semver,pattern={{version}}
|
||||
type=semver,pattern={{major}}.{{minor}}
|
||||
type=semver,pattern={{major}}
|
||||
type=raw,value=latest,enable={{is_default_branch}}
|
||||
type=raw,value=dev,enable=${{ github.ref == 'refs/heads/dev' }}
|
||||
- name: Build and push Docker image
|
||||
uses: docker/build-push-action@v5
|
||||
with:
|
||||
context: .
|
||||
file: ./Dockerfile
|
||||
platforms: linux/amd64,linux/arm64
|
||||
push: true
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
cache-from: type=gha
|
||||
cache-to: type=gha,mode=max
|
||||
- name: Generate security report
|
||||
uses: docker/scout-action@v1
|
||||
if: github.event_name == 'pull_request'
|
||||
with:
|
||||
command: quickview,compare
|
||||
image: ${{ steps.meta.outputs.tags }}
|
||||
to: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:latest
|
||||
ignore-unchanged: true
|
||||
only-severities: critical,high
|
||||
write-comment: true
|
||||
github-token: ${{ secrets.GITHUB_TOKEN }}
|
||||
+30
@@ -0,0 +1,30 @@
|
||||
# Игнорируем все файлы и папки по умолчанию
|
||||
*
|
||||
|
||||
# Исключения: разрешаем только нужные файлы
|
||||
!.env.example
|
||||
!Dockerfile
|
||||
!app-config.json
|
||||
!main.py
|
||||
!requirements.txt
|
||||
|
||||
# Разрешаем папку app/ и все её содержимое рекурсивно
|
||||
!app/
|
||||
!app/**
|
||||
|
||||
# Дополнительно разрешаем README и лицензию (опционально)
|
||||
!README.md
|
||||
!LICENSE
|
||||
|
||||
# Разрешаем .gitignore чтобы он попал в репозиторий
|
||||
!.gitignore
|
||||
|
||||
# Внутри разрешенных папок игнорируем служебные файлы
|
||||
app/__pycache__/
|
||||
app/**/__pycache__/
|
||||
app/**/*.pyc
|
||||
app/**/*.pyo
|
||||
app/**/*.pyd
|
||||
*.pyc
|
||||
*.pyo
|
||||
*.pyd
|
||||
+22
-25
@@ -1,46 +1,43 @@
|
||||
# Use Python 3.11 slim image
|
||||
FROM python:3.11-slim
|
||||
|
||||
# Set working directory
|
||||
WORKDIR /app
|
||||
|
||||
# Set environment variables
|
||||
ENV PYTHONDONTWRITEBYTECODE=1 \
|
||||
PYTHONUNBUFFERED=1 \
|
||||
PYTHONPATH=/app
|
||||
|
||||
# Install system dependencies
|
||||
RUN apt-get update && apt-get install -y \
|
||||
gcc \
|
||||
g++ \
|
||||
libpq-dev \
|
||||
curl \
|
||||
&& apt-get clean \
|
||||
wget \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# Create non-root user
|
||||
RUN groupadd -r botuser && useradd -r -g botuser botuser
|
||||
|
||||
# Copy requirements first for better caching
|
||||
COPY requirements.txt .
|
||||
RUN pip install --no-cache-dir -r requirements.txt
|
||||
|
||||
# Install Python dependencies
|
||||
RUN pip install --no-cache-dir --upgrade pip setuptools wheel && \
|
||||
pip install --no-cache-dir -r requirements.txt --prefer-binary
|
||||
# Create non-root user with specific UID/GID for consistency
|
||||
RUN groupadd -g 1000 app && \
|
||||
useradd -u 1000 -g 1000 -m -s /bin/bash app
|
||||
|
||||
# Copy application code
|
||||
COPY . .
|
||||
|
||||
# Create necessary directories
|
||||
RUN mkdir -p /app/logs /app/data && \
|
||||
chown -R botuser:botuser /app
|
||||
# Create necessary directories and set permissions
|
||||
RUN mkdir -p logs data && \
|
||||
chown -R app:app /app && \
|
||||
chmod -R 755 /app && \
|
||||
chmod -R 777 logs
|
||||
|
||||
# Switch to non-root user
|
||||
USER botuser
|
||||
USER app
|
||||
|
||||
# Set environment variables
|
||||
ENV PYTHONPATH=/app
|
||||
ENV PYTHONUNBUFFERED=1
|
||||
|
||||
# Expose webhook ports
|
||||
EXPOSE 8081
|
||||
EXPOSE 8082
|
||||
|
||||
# Health check
|
||||
HEALTHCHECK --interval=30s --timeout=10s --start-period=30s --retries=3 \
|
||||
CMD python -c "import asyncio; import sys; sys.exit(0)"
|
||||
CMD wget --no-verbose --tries=1 --spider http://localhost:8081/health || exit 1
|
||||
|
||||
# Default command
|
||||
CMD ["python3", "main.py"]
|
||||
# Run the application
|
||||
CMD ["python", "main.py"]
|
||||
|
||||
@@ -1,96 +0,0 @@
|
||||
# RemnaWave Bot Docker Management
|
||||
|
||||
.PHONY: help build up down restart logs clean db-backup db-restore
|
||||
|
||||
# Default target
|
||||
help:
|
||||
@echo "Available commands:"
|
||||
@echo " build - Build all Docker images"
|
||||
@echo " up - Start all services"
|
||||
@echo " up-min - Start only bot and database (minimal setup)"
|
||||
@echo " up-full - Start all services including nginx and redis"
|
||||
@echo " down - Stop all services"
|
||||
@echo " restart - Restart all services"
|
||||
@echo " logs - Show logs for all services"
|
||||
@echo " logs-bot - Show logs for bot service only"
|
||||
@echo " logs-db - Show logs for database service only"
|
||||
@echo " clean - Remove all containers, networks, and volumes"
|
||||
@echo " db-backup - Create database backup"
|
||||
@echo " db-restore - Restore database from backup"
|
||||
@echo " shell-bot - Open shell in bot container"
|
||||
@echo " shell-db - Open shell in database container"
|
||||
|
||||
# Build all images
|
||||
build:
|
||||
docker compose build
|
||||
|
||||
# Start minimal services (bot + database)
|
||||
up-min: setup-dirs
|
||||
docker compose up -d postgres bot
|
||||
|
||||
# Start all services including optional ones
|
||||
up-full: setup-dirs
|
||||
docker compose --profile with-nginx up -d
|
||||
|
||||
# Start main services (default)
|
||||
up: setup-dirs
|
||||
docker compose up -d postgres redis bot
|
||||
|
||||
# Setup required directories
|
||||
setup-dirs:
|
||||
mkdir -p logs data backups
|
||||
|
||||
# Stop all services
|
||||
down:
|
||||
docker compose down
|
||||
|
||||
# Restart all services
|
||||
restart:
|
||||
docker compose restart
|
||||
|
||||
# Show logs for all services
|
||||
logs:
|
||||
docker compose logs -f
|
||||
|
||||
# Show logs for bot only
|
||||
logs-bot:
|
||||
docker compose logs -f bot
|
||||
|
||||
# Show logs for database only
|
||||
logs-db:
|
||||
docker compose logs -f postgres
|
||||
|
||||
# Clean up everything (DANGEROUS - removes all data)
|
||||
clean:
|
||||
@echo "This will remove all containers, networks, and volumes. Are you sure? [y/N]"
|
||||
@read answer && [ "$$answer" = "y" ] || [ "$$answer" = "Y" ]
|
||||
docker compose down -v --remove-orphans
|
||||
docker system prune -f
|
||||
|
||||
# Database backup
|
||||
db-backup:
|
||||
@mkdir -p backups
|
||||
docker compose exec postgres pg_dump -U remnawave_user remnawave_bot > backups/backup_$(shell date +%Y%m%d_%H%M%S).sql
|
||||
@echo "Backup created in backups/ directory"
|
||||
|
||||
# Database restore (use: make db-restore BACKUP=backup_20231201_120000.sql)
|
||||
db-restore:
|
||||
@if [ -z "$(BACKUP)" ]; then echo "Usage: make db-restore BACKUP=backup_file.sql"; exit 1; fi
|
||||
docker compose exec -T postgres psql -U remnawave_user -d remnawave_bot < backups/$(BACKUP)
|
||||
@echo "Database restored from $(BACKUP)"
|
||||
|
||||
# Open shell in bot container
|
||||
shell-bot:
|
||||
docker compose exec bot /bin/bash
|
||||
|
||||
# Open shell in database container
|
||||
shell-db:
|
||||
docker compose exec postgres psql -U remnawave_user -d remnawave_bot
|
||||
|
||||
# Check services status
|
||||
status:
|
||||
docker compose ps
|
||||
|
||||
# View service resource usage
|
||||
stats:
|
||||
docker stats --format "table {{.Container}}\t{{.CPUPerc}}\t{{.MemUsage}}\t{{.NetIO}}\t{{.BlockIO}}"
|
||||
-5640
File diff suppressed because it is too large
Load Diff
+41
@@ -0,0 +1,41 @@
|
||||
[alembic]
|
||||
script_location = migrations/alembic
|
||||
prepend_sys_path = .
|
||||
version_path_separator = os
|
||||
sqlalchemy.url = postgresql+asyncpg://vpn_user:your_password@localhost:5432/vpn_bot
|
||||
|
||||
[post_write_hooks]
|
||||
|
||||
[loggers]
|
||||
keys = root,sqlalchemy,alembic
|
||||
|
||||
[handlers]
|
||||
keys = console
|
||||
|
||||
[formatters]
|
||||
keys = generic
|
||||
|
||||
[logger_root]
|
||||
level = WARN
|
||||
handlers = console
|
||||
qualname =
|
||||
|
||||
[logger_sqlalchemy]
|
||||
level = WARN
|
||||
handlers =
|
||||
qualname = sqlalchemy.engine
|
||||
|
||||
[logger_alembic]
|
||||
level = INFO
|
||||
handlers =
|
||||
qualname = alembic
|
||||
|
||||
[handler_console]
|
||||
class = StreamHandler
|
||||
args = (sys.stderr,)
|
||||
level = NOTSET
|
||||
formatter = generic
|
||||
|
||||
[formatter_generic]
|
||||
format = %(levelname)-5.5s [%(name)s] %(message)s
|
||||
datefmt = %H:%M:%S
|
||||
@@ -1,233 +0,0 @@
|
||||
# api_error_handlers.py - Дополнительные утилиты для обработки ошибок API
|
||||
|
||||
import logging
|
||||
from typing import Optional, Dict, Any, Callable
|
||||
from aiogram.types import CallbackQuery
|
||||
from aiogram import Router
|
||||
from remnawave_api import RemnaWaveAPI
|
||||
from translations import t
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
class APIErrorHandler:
|
||||
"""Класс для обработки ошибок API и предоставления пользователю понятной информации"""
|
||||
|
||||
@staticmethod
|
||||
async def handle_api_error(callback: CallbackQuery, error: Exception,
|
||||
operation: str, user_language: str = 'ru',
|
||||
fallback_keyboard=None) -> bool:
|
||||
"""
|
||||
Обработка ошибок API с отправкой понятного сообщения пользователю
|
||||
|
||||
Returns:
|
||||
bool: True если ошибка была обработана, False если нужно перепробросить
|
||||
"""
|
||||
error_message = str(error).lower()
|
||||
|
||||
if "timeout" in error_message or "connection" in error_message:
|
||||
text = "⏱ Таймаут подключения к API\n\n"
|
||||
text += "Возможные причины:\n"
|
||||
text += "• Медленный интернет\n"
|
||||
text += "• Перегрузка сервера RemnaWave\n"
|
||||
text += "• Временные проблемы с сетью\n\n"
|
||||
text += "🔄 Попробуйте повторить операцию через несколько секунд"
|
||||
|
||||
elif "401" in error_message or "unauthorized" in error_message:
|
||||
text = "🔐 Ошибка авторизации API\n\n"
|
||||
text += "Токен доступа недействителен или истек.\n"
|
||||
text += "Обратитесь к администратору для обновления токена."
|
||||
|
||||
elif "404" in error_message or "not found" in error_message:
|
||||
text = f"❌ Ресурс не найден\n\n"
|
||||
text += f"Операция: {operation}\n"
|
||||
text += "Возможно, запрашиваемый объект был удален или не существует."
|
||||
|
||||
elif "500" in error_message or "internal server error" in error_message:
|
||||
text = "🔥 Внутренняя ошибка сервера RemnaWave\n\n"
|
||||
text += "Сервер временно недоступен.\n"
|
||||
text += "Попробуйте повторить операцию позже."
|
||||
|
||||
else:
|
||||
text = f"❌ Ошибка API операции: {operation}\n\n"
|
||||
text += f"Детали: {str(error)[:100]}{'...' if len(str(error)) > 100 else ''}\n\n"
|
||||
text += "Обратитесь к администратору если проблема повторяется."
|
||||
|
||||
try:
|
||||
await callback.message.edit_text(
|
||||
text,
|
||||
reply_markup=fallback_keyboard or error_recovery_keyboard(operation, user_language)
|
||||
)
|
||||
return True
|
||||
except Exception as edit_error:
|
||||
logger.error(f"Failed to edit message with error info: {edit_error}")
|
||||
try:
|
||||
await callback.answer(f"❌ Ошибка: {operation}", show_alert=True)
|
||||
return True
|
||||
except:
|
||||
return False
|
||||
|
||||
@staticmethod
|
||||
async def safe_api_call(api_method: Callable, *args, **kwargs) -> tuple[bool, Any]:
|
||||
"""
|
||||
Безопасный вызов метода API с обработкой ошибок
|
||||
|
||||
Returns:
|
||||
tuple: (success: bool, result: Any)
|
||||
"""
|
||||
try:
|
||||
result = await api_method(*args, **kwargs)
|
||||
return True, result
|
||||
except Exception as e:
|
||||
logger.error(f"API call failed: {api_method.__name__} - {e}")
|
||||
return False, str(e)
|
||||
|
||||
# Дополнительные обработчики для исправления конкретных проблем
|
||||
def create_error_recovery_keyboard(error_context: str, language: str = 'ru'):
|
||||
"""Создание клавиатуры для восстановления после ошибки"""
|
||||
from keyboards import error_recovery_keyboard
|
||||
return error_recovery_keyboard(error_context, language)
|
||||
|
||||
# Улучшенные функции для работы с RemnaWave API
|
||||
async def safe_get_nodes(api: RemnaWaveAPI) -> tuple[bool, list]:
|
||||
"""Безопасное получение списка нод"""
|
||||
try:
|
||||
logger.info("Attempting to fetch nodes from API...")
|
||||
nodes = await api.get_all_nodes()
|
||||
|
||||
if nodes is None:
|
||||
logger.warning("API returned None for nodes")
|
||||
return False, []
|
||||
|
||||
if not isinstance(nodes, list):
|
||||
logger.warning(f"API returned non-list for nodes: {type(nodes)}")
|
||||
return False, []
|
||||
|
||||
logger.info(f"Successfully fetched {len(nodes)} nodes")
|
||||
return True, nodes
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error fetching nodes: {e}")
|
||||
return False, []
|
||||
|
||||
async def safe_get_system_users(api: RemnaWaveAPI) -> tuple[bool, list]:
|
||||
"""Безопасное получение списка пользователей системы"""
|
||||
try:
|
||||
logger.info("Attempting to fetch system users from API...")
|
||||
users = await api.get_all_system_users_full()
|
||||
|
||||
if users is None:
|
||||
logger.warning("API returned None for users")
|
||||
return False, []
|
||||
|
||||
if not isinstance(users, list):
|
||||
logger.warning(f"API returned non-list for users: {type(users)}")
|
||||
return False, []
|
||||
|
||||
logger.info(f"Successfully fetched {len(users)} users")
|
||||
return True, users
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error fetching system users: {e}")
|
||||
return False, []
|
||||
|
||||
async def safe_restart_nodes(api: RemnaWaveAPI, all_nodes: bool = True, node_id: str = None) -> tuple[bool, str]:
|
||||
"""Безопасная перезагрузка нод"""
|
||||
try:
|
||||
if all_nodes:
|
||||
logger.info("Attempting to restart all nodes...")
|
||||
result = await api.restart_all_nodes()
|
||||
else:
|
||||
logger.info(f"Attempting to restart node {node_id}...")
|
||||
result = await api.restart_node(node_id)
|
||||
|
||||
if result:
|
||||
message = "Команда перезагрузки отправлена успешно"
|
||||
logger.info(f"Restart command sent successfully")
|
||||
return True, message
|
||||
else:
|
||||
message = "API вернул отрицательный результат"
|
||||
logger.warning("API returned negative result for restart")
|
||||
return False, message
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error restarting nodes: {e}")
|
||||
return False, str(e)
|
||||
|
||||
# Функции для проверки состояния API
|
||||
async def check_api_health(api: RemnaWaveAPI) -> Dict[str, Any]:
|
||||
"""Проверка состояния API"""
|
||||
health_info = {
|
||||
'api_available': False,
|
||||
'nodes_accessible': False,
|
||||
'users_accessible': False,
|
||||
'system_stats_accessible': False,
|
||||
'errors': []
|
||||
}
|
||||
|
||||
if api is None:
|
||||
health_info['errors'].append("API instance is None")
|
||||
return health_info
|
||||
|
||||
# Проверяем доступность API
|
||||
try:
|
||||
# Простая проверка через получение нод (обычно быстрая операция)
|
||||
success, nodes = await safe_get_nodes(api)
|
||||
if success:
|
||||
health_info['api_available'] = True
|
||||
health_info['nodes_accessible'] = True
|
||||
else:
|
||||
health_info['errors'].append("Cannot fetch nodes")
|
||||
except Exception as e:
|
||||
health_info['errors'].append(f"Nodes check failed: {e}")
|
||||
|
||||
# Проверяем доступность пользователей
|
||||
try:
|
||||
success, users = await safe_get_system_users(api)
|
||||
if success:
|
||||
health_info['users_accessible'] = True
|
||||
else:
|
||||
health_info['errors'].append("Cannot fetch users")
|
||||
except Exception as e:
|
||||
health_info['errors'].append(f"Users check failed: {e}")
|
||||
|
||||
# Проверяем системную статистику
|
||||
try:
|
||||
stats = await api.get_system_stats()
|
||||
if stats:
|
||||
health_info['system_stats_accessible'] = True
|
||||
else:
|
||||
health_info['errors'].append("Cannot fetch system stats")
|
||||
except Exception as e:
|
||||
health_info['errors'].append(f"System stats check failed: {e}")
|
||||
|
||||
return health_info
|
||||
|
||||
# Декоратор для автоматической обработки ошибок API
|
||||
def handle_api_errors(operation_name: str):
|
||||
"""Декоратор для автоматической обработки ошибок API в handler'ах"""
|
||||
def decorator(func):
|
||||
async def wrapper(callback: CallbackQuery, user, *args, **kwargs):
|
||||
try:
|
||||
return await func(callback, user, *args, **kwargs)
|
||||
except Exception as e:
|
||||
logger.error(f"Error in {func.__name__}: {e}")
|
||||
|
||||
# Получаем API из kwargs если есть
|
||||
api = kwargs.get('api')
|
||||
fallback_keyboard = None
|
||||
|
||||
# Создаем fallback клавиатуру в зависимости от операции
|
||||
if 'nodes' in operation_name.lower():
|
||||
from keyboards import admin_system_keyboard
|
||||
fallback_keyboard = admin_system_keyboard(user.language)
|
||||
elif 'users' in operation_name.lower():
|
||||
from keyboards import system_users_keyboard
|
||||
fallback_keyboard = system_users_keyboard(user.language)
|
||||
|
||||
# Обрабатываем ошибку
|
||||
await APIErrorHandler.handle_api_error(
|
||||
callback, e, operation_name, user.language, fallback_keyboard
|
||||
)
|
||||
|
||||
return wrapper
|
||||
return decorator
|
||||
+163
@@ -0,0 +1,163 @@
|
||||
{
|
||||
"ios": [
|
||||
{
|
||||
"id": "happ",
|
||||
"name": "Happ",
|
||||
"isFeatured": true,
|
||||
"urlScheme": "happ://add/",
|
||||
"installationStep": {
|
||||
"buttons": [
|
||||
{
|
||||
"buttonLink": "https://apps.apple.com/us/app/happ-proxy-utility/id6504287215",
|
||||
"buttonText": {
|
||||
"en": "App Store [EU]",
|
||||
"ru": "App Store [EU]"
|
||||
}
|
||||
},
|
||||
{
|
||||
"buttonLink": "https://apps.apple.com/ru/app/happ-proxy-utility-plus/id6746188973",
|
||||
"buttonText": {
|
||||
"en": "App Store [RU]",
|
||||
"ru": "App Store [RU]"
|
||||
}
|
||||
}
|
||||
],
|
||||
"description": {
|
||||
"en": "Open the page in App Store and install the app. Launch it, in the VPN configuration permission window click Allow and enter your passcode.",
|
||||
"ru": "Откройте страницу в App Store и установите приложение. Вернись на страницу с подпиской"
|
||||
}
|
||||
},
|
||||
"addSubscriptionStep": {
|
||||
"description": {
|
||||
"en": "Click the button below — the app will open and the subscription will be added automatically",
|
||||
"ru": "Нажмите кнопку выше — (Подключить подписку) приложение откроется, и подписка добавится автоматически."
|
||||
}
|
||||
},
|
||||
"connectAndUseStep": {
|
||||
"description": {
|
||||
"en": "In the main section, click the large power button in the center to connect to VPN. Don't forget to select a server from the server list. If needed, choose another server from the server list.",
|
||||
"ru": "В главном разделе нажмите большую кнопку включения в центре для подключения к VPN. Не забудьте выбрать сервер в списке серверов. При необходимости выберите другой сервер из списка серверов."
|
||||
}
|
||||
}
|
||||
}
|
||||
],
|
||||
"android": [
|
||||
{
|
||||
"id": "happ",
|
||||
"name": "Happ",
|
||||
"isFeatured": true,
|
||||
"urlScheme": "happ://add/",
|
||||
"installationStep": {
|
||||
"buttons": [
|
||||
{
|
||||
"buttonLink": "https://play.google.com/store/apps/details?id=com.happproxy",
|
||||
"buttonText": {
|
||||
"en": "Google Play",
|
||||
"ru": "Google Play"
|
||||
}
|
||||
},
|
||||
{
|
||||
"buttonLink": "https://github.com/Happ-proxy/happ-android/releases/latest/download/Happ.apk",
|
||||
"buttonText": {
|
||||
"en": "Download APK",
|
||||
"ru": "Скачать APK"
|
||||
}
|
||||
}
|
||||
],
|
||||
"description": {
|
||||
"en": "Open the page in Google Play and install the app. Or install the app directly from the APK file if Google Play is not working.",
|
||||
"ru": "Откройте страницу в Google Play и установите приложение. Или установите приложение из APK файла напрямую, если Google Play не работает."
|
||||
}
|
||||
},
|
||||
"addSubscriptionStep": {
|
||||
"description": {
|
||||
"en": "Click the button below to add subscription",
|
||||
"ru": "Нажмите кнопку выше — (Подключить подписку) приложение откроется, и подписка добавится автоматически."
|
||||
}
|
||||
},
|
||||
"connectAndUseStep": {
|
||||
"description": {
|
||||
"en": "Open the app and connect to the server",
|
||||
"ru": "Откройте приложение и подключитесь к серверу"
|
||||
}
|
||||
}
|
||||
}
|
||||
],
|
||||
"pc": [
|
||||
{
|
||||
"id": "happ",
|
||||
"name": "Happ",
|
||||
"isFeatured": false,
|
||||
"urlScheme": "happ://add/",
|
||||
"installationStep": {
|
||||
"buttons": [
|
||||
{
|
||||
"buttonLink": "https://apps.apple.com/us/app/happ-proxy-utility/id6504287215?l=ru",
|
||||
"buttonText": {
|
||||
"en": "Mac Os",
|
||||
"ru": "Mac Os"
|
||||
}
|
||||
},
|
||||
{
|
||||
"buttonLink": "https://github.com/Happ-proxy/happ-desktop/releases/latest/download/setup-Happ.x86.exe",
|
||||
"buttonText": {
|
||||
"en": "Windows",
|
||||
"ru": "Windows"
|
||||
}
|
||||
}
|
||||
],
|
||||
"description": {
|
||||
"en": "Choose the version for your device, click the button below and install the app.",
|
||||
"ru": "Выберите подходящую версию для вашего устройства, нажмите на кнопку ниже и установите приложение"
|
||||
}
|
||||
},
|
||||
"addSubscriptionStep": {
|
||||
"description": {
|
||||
"en": "Click the button below to add subscription",
|
||||
"ru": "Нажмите кнопку выше — (Подключить подписку) приложение откроется, и подписка добавится автоматически."
|
||||
}
|
||||
},
|
||||
"connectAndUseStep": {
|
||||
"description": {
|
||||
"en": "You can select a server",
|
||||
"ru": "Выберете локацию, включить VPN"
|
||||
}
|
||||
}
|
||||
}
|
||||
],
|
||||
"tv": [
|
||||
{
|
||||
"id": "vpn4tv",
|
||||
"name": "VPN4TV",
|
||||
"isFeatured": true,
|
||||
"urlScheme": "",
|
||||
"installationStep": {
|
||||
"buttons": [
|
||||
{
|
||||
"buttonLink": "https://play.google.com/store/apps/details?id=com.vpn4tv.hiddify",
|
||||
"buttonText": {
|
||||
"en": "Google Play",
|
||||
"ru": "Google Play"
|
||||
}
|
||||
}
|
||||
],
|
||||
"description": {
|
||||
"en": "Open the page in Google Play and install the app. Or install the app directly from the APK file if Google Play is not working.",
|
||||
"ru": "Откройте страницу в Google Play и установите приложение"
|
||||
}
|
||||
},
|
||||
"addSubscriptionStep": {
|
||||
"description": {
|
||||
"en": "Click the button below to add subscription",
|
||||
"ru": "Нажмите кнопку выше — (Скопировать ссылку подписки) ты скопируешь свою подписку, далее на телевизоре открой VPN4TV, следуя инструкция передай telegram боту ссылку, которую ты скопировал"
|
||||
}
|
||||
},
|
||||
"connectAndUseStep": {
|
||||
"description": {
|
||||
"en": "Open the app and connect to the server",
|
||||
"ru": "Приложение автоматически обновится и загрузит нужные конфиги на твой телевизор, подключай VPN"
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
+128
@@ -0,0 +1,128 @@
|
||||
import logging
|
||||
from aiogram import Bot, Dispatcher, types
|
||||
from aiogram.fsm.storage.redis import RedisStorage
|
||||
from aiogram.fsm.storage.memory import MemoryStorage
|
||||
import redis.asyncio as redis
|
||||
|
||||
from app.config import settings
|
||||
from app.middlewares.auth import AuthMiddleware
|
||||
from app.middlewares.logging import LoggingMiddleware
|
||||
from app.middlewares.throttling import ThrottlingMiddleware
|
||||
from app.middlewares.subscription_checker import SubscriptionStatusMiddleware
|
||||
from app.middlewares.maintenance import MaintenanceMiddleware
|
||||
from app.services.maintenance_service import maintenance_service
|
||||
from app.utils.cache import cache
|
||||
|
||||
from app.handlers import (
|
||||
start, menu, subscription, balance, promocode,
|
||||
referral, support, common
|
||||
)
|
||||
from app.handlers.admin import (
|
||||
main as admin_main, users as admin_users, subscriptions as admin_subscriptions,
|
||||
promocodes as admin_promocodes, messages as admin_messages,
|
||||
monitoring as admin_monitoring, referrals as admin_referrals,
|
||||
rules as admin_rules, remnawave as admin_remnawave,
|
||||
statistics as admin_statistics, servers as admin_servers,
|
||||
maintenance as admin_maintenance
|
||||
)
|
||||
from app.handlers.stars_payments import register_stars_handlers
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
async def debug_callback_handler(callback: types.CallbackQuery):
|
||||
logger.info(f"🔍 DEBUG CALLBACK:")
|
||||
logger.info(f" - Data: {callback.data}")
|
||||
logger.info(f" - User: {callback.from_user.id}")
|
||||
logger.info(f" - Username: {callback.from_user.username}")
|
||||
|
||||
|
||||
async def setup_bot() -> tuple[Bot, Dispatcher]:
|
||||
|
||||
try:
|
||||
await cache.connect()
|
||||
logger.info("Кеш инициализирован")
|
||||
except Exception as e:
|
||||
logger.warning(f"Кеш не инициализирован: {e}")
|
||||
|
||||
from aiogram.client.default import DefaultBotProperties
|
||||
from aiogram.enums import ParseMode
|
||||
|
||||
bot = Bot(
|
||||
token=settings.BOT_TOKEN,
|
||||
default=DefaultBotProperties(parse_mode=ParseMode.HTML)
|
||||
)
|
||||
|
||||
maintenance_service.set_bot(bot)
|
||||
logger.info("Бот установлен в maintenance_service")
|
||||
|
||||
try:
|
||||
redis_client = redis.from_url(settings.REDIS_URL)
|
||||
await redis_client.ping()
|
||||
storage = RedisStorage(redis_client)
|
||||
logger.info("Подключено к Redis для FSM storage")
|
||||
except Exception as e:
|
||||
logger.warning(f"Не удалось подключиться к Redis: {e}")
|
||||
logger.info("Используется MemoryStorage для FSM")
|
||||
storage = MemoryStorage()
|
||||
|
||||
dp = Dispatcher(storage=storage)
|
||||
dp.message.middleware(LoggingMiddleware())
|
||||
dp.callback_query.middleware(LoggingMiddleware())
|
||||
dp.message.middleware(AuthMiddleware())
|
||||
dp.callback_query.middleware(AuthMiddleware())
|
||||
dp.pre_checkout_query.middleware(AuthMiddleware())
|
||||
dp.message.middleware(MaintenanceMiddleware())
|
||||
dp.callback_query.middleware(MaintenanceMiddleware())
|
||||
dp.message.middleware(ThrottlingMiddleware())
|
||||
dp.callback_query.middleware(ThrottlingMiddleware())
|
||||
dp.message.middleware(SubscriptionStatusMiddleware())
|
||||
dp.callback_query.middleware(SubscriptionStatusMiddleware())
|
||||
start.register_handlers(dp)
|
||||
menu.register_handlers(dp)
|
||||
subscription.register_handlers(dp)
|
||||
balance.register_handlers(dp)
|
||||
promocode.register_handlers(dp)
|
||||
referral.register_handlers(dp)
|
||||
support.register_handlers(dp)
|
||||
admin_main.register_handlers(dp)
|
||||
admin_users.register_handlers(dp)
|
||||
admin_subscriptions.register_handlers(dp)
|
||||
admin_servers.register_handlers(dp)
|
||||
admin_promocodes.register_handlers(dp)
|
||||
admin_messages.register_handlers(dp)
|
||||
admin_monitoring.register_handlers(dp)
|
||||
admin_referrals.register_handlers(dp)
|
||||
admin_rules.register_handlers(dp)
|
||||
admin_remnawave.register_handlers(dp)
|
||||
admin_statistics.register_handlers(dp)
|
||||
admin_maintenance.register_handlers(dp)
|
||||
|
||||
common.register_handlers(dp)
|
||||
|
||||
register_stars_handlers(dp)
|
||||
logger.info("🌟 Зарегистрированы обработчики Telegram Stars платежей")
|
||||
|
||||
try:
|
||||
await maintenance_service.start_monitoring()
|
||||
logger.info("Мониторинг техработ запущен")
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка запуска мониторинга техработ: {e}")
|
||||
|
||||
logger.info("Бот успешно настроен")
|
||||
|
||||
return bot, dp
|
||||
|
||||
|
||||
async def shutdown_bot():
|
||||
try:
|
||||
await maintenance_service.stop_monitoring()
|
||||
logger.info("Мониторинг техработ остановлен")
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка остановки мониторинга: {e}")
|
||||
|
||||
try:
|
||||
await cache.close()
|
||||
logger.info("Соединения с кешем закрыты")
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка закрытия кеша: {e}")
|
||||
+468
@@ -0,0 +1,468 @@
|
||||
import os
|
||||
from typing import List, Optional, Union, Dict
|
||||
from pydantic_settings import BaseSettings
|
||||
from pydantic import field_validator, Field
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
class Settings(BaseSettings):
|
||||
|
||||
BOT_TOKEN: str
|
||||
ADMIN_IDS: str = ""
|
||||
SUPPORT_USERNAME: str = "@support"
|
||||
|
||||
DATABASE_URL: str
|
||||
REDIS_URL: str = "redis://localhost:6379/0"
|
||||
|
||||
REMNAWAVE_API_URL: str
|
||||
REMNAWAVE_API_KEY: str
|
||||
REMNAWAVE_SECRET_KEY: Optional[str] = None
|
||||
|
||||
TRIAL_DURATION_DAYS: int = 3
|
||||
TRIAL_TRAFFIC_LIMIT_GB: int = 10
|
||||
TRIAL_DEVICE_LIMIT: int = 2
|
||||
DEFAULT_TRAFFIC_LIMIT_GB: int = 100
|
||||
DEFAULT_DEVICE_LIMIT: int = 1
|
||||
TRIAL_SQUAD_UUID: str
|
||||
DEFAULT_TRAFFIC_RESET_STRATEGY: str = "MONTH"
|
||||
MAX_DEVICES_LIMIT: int = 20
|
||||
|
||||
TRIAL_WARNING_HOURS: int = 2
|
||||
ENABLE_NOTIFICATIONS: bool = True
|
||||
NOTIFICATION_RETRY_ATTEMPTS: int = 3
|
||||
|
||||
MONITORING_LOGS_RETENTION_DAYS: int = 30
|
||||
NOTIFICATION_CACHE_HOURS: int = 24
|
||||
|
||||
BASE_SUBSCRIPTION_PRICE: int = 50000
|
||||
|
||||
AVAILABLE_SUBSCRIPTION_PERIODS: str = "14,30,60,90,180,360"
|
||||
AVAILABLE_RENEWAL_PERIODS: str = "30,90,180"
|
||||
PRICE_14_DAYS: int = 50000
|
||||
PRICE_30_DAYS: int = 99000
|
||||
PRICE_60_DAYS: int = 189000
|
||||
PRICE_90_DAYS: int = 269000
|
||||
PRICE_180_DAYS: int = 499000
|
||||
PRICE_360_DAYS: int = 899000
|
||||
|
||||
PRICE_TRAFFIC_5GB: int = 2000
|
||||
PRICE_TRAFFIC_10GB: int = 3500
|
||||
PRICE_TRAFFIC_25GB: int = 7000
|
||||
PRICE_TRAFFIC_50GB: int = 11000
|
||||
PRICE_TRAFFIC_100GB: int = 15000
|
||||
PRICE_TRAFFIC_250GB: int = 17000
|
||||
PRICE_TRAFFIC_500GB: int = 19000
|
||||
PRICE_TRAFFIC_1000GB: int = 19500
|
||||
PRICE_TRAFFIC_UNLIMITED: int = 20000
|
||||
|
||||
TRAFFIC_PACKAGES_CONFIG: str = ""
|
||||
|
||||
PRICE_PER_DEVICE: int = 5000
|
||||
|
||||
TRAFFIC_SELECTION_MODE: str = "selectable"
|
||||
FIXED_TRAFFIC_LIMIT_GB: int = 100
|
||||
|
||||
REFERRAL_MINIMUM_TOPUP_KOPEKS: int = 10000
|
||||
REFERRAL_FIRST_TOPUP_BONUS_KOPEKS: int = 10000
|
||||
REFERRAL_INVITER_BONUS_KOPEKS: int = 10000
|
||||
REFERRAL_COMMISSION_PERCENT: int = 25
|
||||
|
||||
REFERRAL_NOTIFICATIONS_ENABLED: bool = True
|
||||
REFERRAL_NOTIFICATION_RETRY_ATTEMPTS: int = 3
|
||||
REFERRED_USER_REWARD: int = 0
|
||||
|
||||
AUTOPAY_WARNING_DAYS: str = "3,1"
|
||||
|
||||
DEFAULT_AUTOPAY_DAYS_BEFORE: int = 3
|
||||
MIN_BALANCE_FOR_AUTOPAY_KOPEKS: int = 10000
|
||||
|
||||
MONITORING_INTERVAL: int = 60
|
||||
INACTIVE_USER_DELETE_MONTHS: int = 3
|
||||
|
||||
MAINTENANCE_MODE: bool = False
|
||||
MAINTENANCE_CHECK_INTERVAL: int = 30
|
||||
MAINTENANCE_AUTO_ENABLE: bool = True
|
||||
MAINTENANCE_MESSAGE: str = "🔧 Ведутся технические работы. Сервис временно недоступен. Попробуйте позже."
|
||||
|
||||
TELEGRAM_STARS_ENABLED: bool = True
|
||||
TELEGRAM_STARS_RATE_RUB: float = 1.3
|
||||
|
||||
TRIBUTE_ENABLED: bool = False
|
||||
TRIBUTE_API_KEY: Optional[str] = None
|
||||
TRIBUTE_WEBHOOK_SECRET: Optional[str] = None
|
||||
TRIBUTE_DONATE_LINK: Optional[str] = None
|
||||
TRIBUTE_WEBHOOK_PATH: str = "/tribute-webhook"
|
||||
TRIBUTE_WEBHOOK_PORT: int = 8081
|
||||
|
||||
YOOKASSA_ENABLED: bool = False
|
||||
YOOKASSA_SHOP_ID: Optional[str] = None
|
||||
YOOKASSA_SECRET_KEY: Optional[str] = None
|
||||
YOOKASSA_RETURN_URL: Optional[str] = None
|
||||
YOOKASSA_DEFAULT_RECEIPT_EMAIL: Optional[str] = None
|
||||
YOOKASSA_VAT_CODE: int = 1
|
||||
YOOKASSA_PAYMENT_MODE: str = "full_payment"
|
||||
YOOKASSA_PAYMENT_SUBJECT: str = "service"
|
||||
YOOKASSA_WEBHOOK_PATH: str = "/yookassa-webhook"
|
||||
YOOKASSA_WEBHOOK_PORT: int = 8082
|
||||
YOOKASSA_WEBHOOK_SECRET: Optional[str] = None
|
||||
PAYMENT_BALANCE_DESCRIPTION: str = "Пополнение баланса"
|
||||
PAYMENT_SUBSCRIPTION_DESCRIPTION: str = "Оплата подписки"
|
||||
PAYMENT_SERVICE_NAME: str = "Интернет-сервис"
|
||||
PAYMENT_BALANCE_TEMPLATE: str = "{service_name} - {description}"
|
||||
PAYMENT_SUBSCRIPTION_TEMPLATE: str = "{service_name} - {description}"
|
||||
|
||||
|
||||
CONNECT_BUTTON_MODE: str = "guide"
|
||||
MINIAPP_CUSTOM_URL: str = ""
|
||||
|
||||
DEFAULT_LANGUAGE: str = "ru"
|
||||
AVAILABLE_LANGUAGES: str = "ru,en"
|
||||
|
||||
LOG_LEVEL: str = "INFO"
|
||||
LOG_FILE: str = "logs/bot.log"
|
||||
|
||||
DEBUG: bool = False
|
||||
WEBHOOK_URL: Optional[str] = None
|
||||
WEBHOOK_PATH: str = "/webhook"
|
||||
|
||||
APP_CONFIG_PATH: str = "app-config.json"
|
||||
ENABLE_DEEP_LINKS: bool = True
|
||||
APP_CONFIG_CACHE_TTL: int = 3600
|
||||
|
||||
@field_validator('LOG_FILE', mode='before')
|
||||
@classmethod
|
||||
def ensure_log_dir(cls, v):
|
||||
log_path = Path(v)
|
||||
log_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
return str(log_path)
|
||||
|
||||
def is_admin(self, user_id: int) -> bool:
|
||||
return user_id in self.get_admin_ids()
|
||||
|
||||
def get_admin_ids(self) -> List[int]:
|
||||
try:
|
||||
admin_ids = self.ADMIN_IDS
|
||||
|
||||
if isinstance(admin_ids, str):
|
||||
if not admin_ids.strip():
|
||||
return []
|
||||
return [int(x.strip()) for x in admin_ids.split(',') if x.strip()]
|
||||
|
||||
return []
|
||||
|
||||
except (ValueError, AttributeError):
|
||||
return []
|
||||
|
||||
def get_autopay_warning_days(self) -> List[int]:
|
||||
try:
|
||||
days = self.AUTOPAY_WARNING_DAYS
|
||||
if isinstance(days, str):
|
||||
if not days.strip():
|
||||
return [3, 1]
|
||||
return [int(x.strip()) for x in days.split(',') if x.strip()]
|
||||
return [3, 1]
|
||||
except (ValueError, AttributeError):
|
||||
return [3, 1]
|
||||
|
||||
def get_available_languages(self) -> List[str]:
|
||||
try:
|
||||
langs = self.AVAILABLE_LANGUAGES
|
||||
if isinstance(langs, str):
|
||||
if not langs.strip():
|
||||
return ["ru", "en"]
|
||||
return [x.strip() for x in langs.split(',') if x.strip()]
|
||||
return ["ru", "en"]
|
||||
except AttributeError:
|
||||
return ["ru", "en"]
|
||||
|
||||
def format_price(self, price_kopeks: int) -> str:
|
||||
rubles = price_kopeks / 100
|
||||
return f"{rubles:.2f} ₽"
|
||||
|
||||
def kopeks_to_rubles(self, kopeks: int) -> float:
|
||||
return kopeks / 100
|
||||
|
||||
def rubles_to_kopeks(self, rubles: float) -> int:
|
||||
return int(rubles * 100)
|
||||
|
||||
def get_trial_warning_hours(self) -> int:
|
||||
return self.TRIAL_WARNING_HOURS
|
||||
|
||||
def is_notifications_enabled(self) -> bool:
|
||||
return self.ENABLE_NOTIFICATIONS
|
||||
|
||||
def get_app_config_path(self) -> str:
|
||||
if os.path.isabs(self.APP_CONFIG_PATH):
|
||||
return self.APP_CONFIG_PATH
|
||||
|
||||
project_root = Path(__file__).parent.parent
|
||||
return str(project_root / self.APP_CONFIG_PATH)
|
||||
|
||||
def is_deep_links_enabled(self) -> bool:
|
||||
return self.ENABLE_DEEP_LINKS
|
||||
|
||||
def get_app_config_cache_ttl(self) -> int:
|
||||
return self.APP_CONFIG_CACHE_TTL
|
||||
|
||||
def is_traffic_selectable(self) -> bool:
|
||||
return self.TRAFFIC_SELECTION_MODE.lower() == "selectable"
|
||||
|
||||
def is_traffic_fixed(self) -> bool:
|
||||
return self.TRAFFIC_SELECTION_MODE.lower() == "fixed"
|
||||
|
||||
def get_fixed_traffic_limit(self) -> int:
|
||||
return self.FIXED_TRAFFIC_LIMIT_GB
|
||||
|
||||
def is_yookassa_enabled(self) -> bool:
|
||||
return (self.YOOKASSA_ENABLED and
|
||||
self.YOOKASSA_SHOP_ID is not None and
|
||||
self.YOOKASSA_SECRET_KEY is not None)
|
||||
|
||||
def get_yookassa_return_url(self) -> str:
|
||||
if self.YOOKASSA_RETURN_URL:
|
||||
return self.YOOKASSA_RETURN_URL
|
||||
elif self.WEBHOOK_URL:
|
||||
return f"{self.WEBHOOK_URL}/payment-success"
|
||||
return "https://t.me/"
|
||||
|
||||
def is_maintenance_mode(self) -> bool:
|
||||
return self.MAINTENANCE_MODE
|
||||
|
||||
def get_maintenance_message(self) -> str:
|
||||
return self.MAINTENANCE_MESSAGE
|
||||
|
||||
def get_maintenance_check_interval(self) -> int:
|
||||
return self.MAINTENANCE_CHECK_INTERVAL
|
||||
|
||||
def is_maintenance_auto_enable(self) -> bool:
|
||||
return self.MAINTENANCE_AUTO_ENABLE
|
||||
|
||||
def get_available_subscription_periods(self) -> List[int]:
|
||||
try:
|
||||
periods_str = self.AVAILABLE_SUBSCRIPTION_PERIODS
|
||||
if not periods_str.strip():
|
||||
return [30, 90, 180]
|
||||
|
||||
periods = []
|
||||
for period_str in periods_str.split(','):
|
||||
period_str = period_str.strip()
|
||||
if period_str:
|
||||
period = int(period_str)
|
||||
if period in PERIOD_PRICES:
|
||||
periods.append(period)
|
||||
|
||||
return periods if periods else [30, 90, 180]
|
||||
|
||||
except (ValueError, AttributeError):
|
||||
return [30, 90, 180]
|
||||
|
||||
def get_available_renewal_periods(self) -> List[int]:
|
||||
try:
|
||||
periods_str = self.AVAILABLE_RENEWAL_PERIODS
|
||||
if not periods_str.strip():
|
||||
return [30, 90, 180]
|
||||
|
||||
periods = []
|
||||
for period_str in periods_str.split(','):
|
||||
period_str = period_str.strip()
|
||||
if period_str:
|
||||
period = int(period_str)
|
||||
if period in PERIOD_PRICES:
|
||||
periods.append(period)
|
||||
|
||||
return periods if periods else [30, 90, 180]
|
||||
|
||||
except (ValueError, AttributeError):
|
||||
return [30, 90, 180]
|
||||
|
||||
def get_balance_payment_description(self, amount_kopeks: int) -> str:
|
||||
return self.PAYMENT_BALANCE_TEMPLATE.format(
|
||||
service_name=self.PAYMENT_SERVICE_NAME,
|
||||
description=f"{self.PAYMENT_BALANCE_DESCRIPTION} на {self.format_price(amount_kopeks)}"
|
||||
)
|
||||
|
||||
def get_subscription_payment_description(self, period_days: int, amount_kopeks: int) -> str:
|
||||
return self.PAYMENT_SUBSCRIPTION_TEMPLATE.format(
|
||||
service_name=self.PAYMENT_SERVICE_NAME,
|
||||
description=f"{self.PAYMENT_SUBSCRIPTION_DESCRIPTION} на {period_days} дней"
|
||||
)
|
||||
|
||||
def get_custom_payment_description(self, description: str) -> str:
|
||||
return self.PAYMENT_BALANCE_TEMPLATE.format(
|
||||
service_name=self.PAYMENT_SERVICE_NAME,
|
||||
description=description
|
||||
)
|
||||
|
||||
def get_stars_rate(self) -> float:
|
||||
return self.TELEGRAM_STARS_RATE_RUB
|
||||
|
||||
def stars_to_rubles(self, stars: int) -> float:
|
||||
return stars * self.get_stars_rate()
|
||||
|
||||
def rubles_to_stars(self, rubles: float) -> int:
|
||||
return max(1, int(rubles / self.get_stars_rate()))
|
||||
|
||||
def get_referral_settings(self) -> Dict:
|
||||
return {
|
||||
"minimum_topup_kopeks": self.REFERRAL_MINIMUM_TOPUP_KOPEKS,
|
||||
"first_topup_bonus_kopeks": self.REFERRAL_FIRST_TOPUP_BONUS_KOPEKS,
|
||||
"inviter_bonus_kopeks": self.REFERRAL_INVITER_BONUS_KOPEKS,
|
||||
"commission_percent": self.REFERRAL_COMMISSION_PERCENT,
|
||||
"notifications_enabled": self.REFERRAL_NOTIFICATIONS_ENABLED,
|
||||
"referred_user_reward": self.REFERRED_USER_REWARD
|
||||
}
|
||||
|
||||
def is_referral_notifications_enabled(self) -> bool:
|
||||
return self.REFERRAL_NOTIFICATIONS_ENABLED
|
||||
|
||||
def get_traffic_packages(self) -> List[Dict]:
|
||||
import logging
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
try:
|
||||
packages = []
|
||||
config_str = self.TRAFFIC_PACKAGES_CONFIG.strip()
|
||||
|
||||
logger.info(f"CONFIG STRING: '{config_str}'")
|
||||
|
||||
if not config_str:
|
||||
logger.info("CONFIG EMPTY, USING FALLBACK")
|
||||
return self._get_fallback_traffic_packages()
|
||||
|
||||
logger.info("PARSING CONFIG...")
|
||||
|
||||
for package_config in config_str.split(','):
|
||||
package_config = package_config.strip()
|
||||
if not package_config:
|
||||
continue
|
||||
|
||||
parts = package_config.split(':')
|
||||
if len(parts) != 3:
|
||||
continue
|
||||
|
||||
try:
|
||||
gb = int(parts[0])
|
||||
price = int(parts[1])
|
||||
enabled = parts[2].lower() == 'true'
|
||||
|
||||
packages.append({
|
||||
"gb": gb,
|
||||
"price": price,
|
||||
"enabled": enabled
|
||||
})
|
||||
except ValueError:
|
||||
continue
|
||||
|
||||
logger.info(f"PARSED {len(packages)} packages from config")
|
||||
return packages if packages else self._get_fallback_traffic_packages()
|
||||
|
||||
except Exception as e:
|
||||
logger.info(f"ERROR PARSING CONFIG: {e}")
|
||||
return self._get_fallback_traffic_packages()
|
||||
|
||||
def _get_fallback_traffic_packages(self) -> List[Dict]:
|
||||
try:
|
||||
if self.TRAFFIC_PACKAGES_CONFIG.strip():
|
||||
packages = []
|
||||
for package_config in self.TRAFFIC_PACKAGES_CONFIG.split(','):
|
||||
package_config = package_config.strip()
|
||||
if not package_config:
|
||||
continue
|
||||
|
||||
parts = package_config.split(':')
|
||||
if len(parts) != 3:
|
||||
continue
|
||||
|
||||
try:
|
||||
gb = int(parts[0])
|
||||
price = int(parts[1])
|
||||
enabled = parts[2].lower() == 'true'
|
||||
|
||||
packages.append({
|
||||
"gb": gb,
|
||||
"price": price,
|
||||
"enabled": enabled
|
||||
})
|
||||
except ValueError:
|
||||
continue
|
||||
|
||||
if packages:
|
||||
return packages
|
||||
except Exception as e:
|
||||
pass
|
||||
|
||||
return [
|
||||
{"gb": 5, "price": self.PRICE_TRAFFIC_5GB, "enabled": True},
|
||||
{"gb": 10, "price": self.PRICE_TRAFFIC_10GB, "enabled": True},
|
||||
{"gb": 25, "price": self.PRICE_TRAFFIC_25GB, "enabled": True},
|
||||
{"gb": 50, "price": self.PRICE_TRAFFIC_50GB, "enabled": True},
|
||||
{"gb": 100, "price": self.PRICE_TRAFFIC_100GB, "enabled": True},
|
||||
{"gb": 250, "price": self.PRICE_TRAFFIC_250GB, "enabled": True},
|
||||
{"gb": 500, "price": self.PRICE_TRAFFIC_500GB, "enabled": True},
|
||||
{"gb": 1000, "price": self.PRICE_TRAFFIC_1000GB, "enabled": True},
|
||||
{"gb": 0, "price": self.PRICE_TRAFFIC_UNLIMITED, "enabled": True},
|
||||
]
|
||||
|
||||
def get_traffic_price(self, gb: int) -> int:
|
||||
packages = self.get_traffic_packages()
|
||||
|
||||
for package in packages:
|
||||
if package["gb"] == gb and package["enabled"]:
|
||||
return package["price"]
|
||||
|
||||
|
||||
enabled_packages = [pkg for pkg in packages if pkg["enabled"]]
|
||||
if not enabled_packages:
|
||||
return 0
|
||||
|
||||
unlimited_package = next((pkg for pkg in enabled_packages if pkg["gb"] == 0), None)
|
||||
|
||||
finite_packages = [pkg for pkg in enabled_packages if pkg["gb"] > 0]
|
||||
if finite_packages:
|
||||
max_package = max(finite_packages, key=lambda x: x["gb"])
|
||||
|
||||
if gb > max_package["gb"]:
|
||||
if unlimited_package:
|
||||
return unlimited_package["price"]
|
||||
else:
|
||||
return max_package["price"]
|
||||
|
||||
suitable_packages = [pkg for pkg in finite_packages if pkg["gb"] >= gb]
|
||||
if suitable_packages:
|
||||
nearest_package = min(suitable_packages, key=lambda x: x["gb"])
|
||||
return nearest_package["price"]
|
||||
|
||||
if unlimited_package:
|
||||
return unlimited_package["price"]
|
||||
|
||||
return 0
|
||||
|
||||
model_config = {
|
||||
"env_file": ".env",
|
||||
"env_file_encoding": "utf-8"
|
||||
}
|
||||
|
||||
|
||||
settings = Settings()
|
||||
|
||||
|
||||
PERIOD_PRICES = {
|
||||
14: settings.PRICE_14_DAYS,
|
||||
30: settings.PRICE_30_DAYS,
|
||||
60: settings.PRICE_60_DAYS,
|
||||
90: settings.PRICE_90_DAYS,
|
||||
180: settings.PRICE_180_DAYS,
|
||||
360: settings.PRICE_360_DAYS,
|
||||
}
|
||||
|
||||
def get_traffic_prices() -> Dict[int, int]:
|
||||
packages = settings.get_traffic_packages()
|
||||
return {package["gb"]: package["price"] for package in packages}
|
||||
|
||||
TRAFFIC_PRICES = get_traffic_prices()
|
||||
|
||||
def refresh_traffic_prices():
|
||||
global TRAFFIC_PRICES
|
||||
TRAFFIC_PRICES = get_traffic_prices()
|
||||
|
||||
refresh_traffic_prices()
|
||||
@@ -0,0 +1,250 @@
|
||||
import logging
|
||||
from datetime import datetime
|
||||
from typing import Optional, List
|
||||
from sqlalchemy import select, and_, func
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy.orm import selectinload
|
||||
|
||||
from app.database.models import PromoCode, PromoCodeUse, PromoCodeType, User
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
async def get_promocode_by_code(db: AsyncSession, code: str) -> Optional[PromoCode]:
|
||||
result = await db.execute(
|
||||
select(PromoCode)
|
||||
.options(selectinload(PromoCode.uses))
|
||||
.where(PromoCode.code == code.upper())
|
||||
)
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
|
||||
async def create_promocode(
|
||||
db: AsyncSession,
|
||||
code: str,
|
||||
type: PromoCodeType,
|
||||
balance_bonus_kopeks: int = 0,
|
||||
subscription_days: int = 0,
|
||||
max_uses: int = 1,
|
||||
valid_until: Optional[datetime] = None,
|
||||
created_by: Optional[int] = None
|
||||
) -> PromoCode:
|
||||
|
||||
promocode = PromoCode(
|
||||
code=code.upper(),
|
||||
type=type.value,
|
||||
balance_bonus_kopeks=balance_bonus_kopeks,
|
||||
subscription_days=subscription_days,
|
||||
max_uses=max_uses,
|
||||
valid_until=valid_until,
|
||||
created_by=created_by
|
||||
)
|
||||
|
||||
db.add(promocode)
|
||||
await db.commit()
|
||||
await db.refresh(promocode)
|
||||
|
||||
logger.info(f"✅ Создан промокод: {code}")
|
||||
return promocode
|
||||
|
||||
|
||||
async def use_promocode(
|
||||
db: AsyncSession,
|
||||
promocode_id: int,
|
||||
user_id: int
|
||||
) -> bool:
|
||||
|
||||
try:
|
||||
promocode = await db.get(PromoCode, promocode_id)
|
||||
if not promocode:
|
||||
return False
|
||||
|
||||
usage = PromoCodeUse(
|
||||
promocode_id=promocode_id,
|
||||
user_id=user_id
|
||||
)
|
||||
db.add(usage)
|
||||
|
||||
promocode.current_uses += 1
|
||||
|
||||
await db.commit()
|
||||
|
||||
logger.info(f"✅ Промокод {promocode.code} использован пользователем {user_id}")
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка использования промокода: {e}")
|
||||
await db.rollback()
|
||||
return False
|
||||
|
||||
|
||||
async def check_user_promocode_usage(
|
||||
db: AsyncSession,
|
||||
user_id: int,
|
||||
promocode_id: int
|
||||
) -> bool:
|
||||
|
||||
result = await db.execute(
|
||||
select(PromoCodeUse).where(
|
||||
and_(
|
||||
PromoCodeUse.user_id == user_id,
|
||||
PromoCodeUse.promocode_id == promocode_id
|
||||
)
|
||||
)
|
||||
)
|
||||
return result.scalar_one_or_none() is not None
|
||||
|
||||
|
||||
|
||||
async def create_promocode_use(db: AsyncSession, promocode_id: int, user_id: int) -> PromoCodeUse:
|
||||
promocode_use = PromoCodeUse(
|
||||
promocode_id=promocode_id,
|
||||
user_id=user_id,
|
||||
used_at=datetime.utcnow()
|
||||
)
|
||||
|
||||
db.add(promocode_use)
|
||||
await db.commit()
|
||||
await db.refresh(promocode_use)
|
||||
|
||||
logger.info(f"📝 Записано использование промокода {promocode_id} пользователем {user_id}")
|
||||
return promocode_use
|
||||
|
||||
|
||||
async def get_promocode_use_by_user_and_code(
|
||||
db: AsyncSession,
|
||||
user_id: int,
|
||||
promocode_id: int
|
||||
) -> Optional[PromoCodeUse]:
|
||||
result = await db.execute(
|
||||
select(PromoCodeUse).where(
|
||||
and_(
|
||||
PromoCodeUse.user_id == user_id,
|
||||
PromoCodeUse.promocode_id == promocode_id
|
||||
)
|
||||
)
|
||||
)
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
|
||||
async def get_user_promocodes(db: AsyncSession, user_id: int) -> List[PromoCodeUse]:
|
||||
result = await db.execute(
|
||||
select(PromoCodeUse)
|
||||
.where(PromoCodeUse.user_id == user_id)
|
||||
.order_by(PromoCodeUse.used_at.desc())
|
||||
)
|
||||
return result.scalars().all()
|
||||
|
||||
|
||||
|
||||
async def get_promocodes_list(
|
||||
db: AsyncSession,
|
||||
offset: int = 0,
|
||||
limit: int = 50,
|
||||
is_active: Optional[bool] = None
|
||||
) -> List[PromoCode]:
|
||||
|
||||
query = select(PromoCode).options(selectinload(PromoCode.uses))
|
||||
|
||||
if is_active is not None:
|
||||
query = query.where(PromoCode.is_active == is_active)
|
||||
|
||||
query = query.order_by(PromoCode.created_at.desc()).offset(offset).limit(limit)
|
||||
|
||||
result = await db.execute(query)
|
||||
return result.scalars().all()
|
||||
|
||||
|
||||
async def get_promocodes_count(
|
||||
db: AsyncSession,
|
||||
is_active: Optional[bool] = None
|
||||
) -> int:
|
||||
|
||||
query = select(func.count(PromoCode.id))
|
||||
|
||||
if is_active is not None:
|
||||
query = query.where(PromoCode.is_active == is_active)
|
||||
|
||||
result = await db.execute(query)
|
||||
return result.scalar()
|
||||
|
||||
|
||||
async def update_promocode(
|
||||
db: AsyncSession,
|
||||
promocode: PromoCode,
|
||||
**kwargs
|
||||
) -> PromoCode:
|
||||
|
||||
for field, value in kwargs.items():
|
||||
if hasattr(promocode, field):
|
||||
setattr(promocode, field, value)
|
||||
|
||||
promocode.updated_at = datetime.utcnow()
|
||||
await db.commit()
|
||||
await db.refresh(promocode)
|
||||
|
||||
return promocode
|
||||
|
||||
|
||||
async def delete_promocode(db: AsyncSession, promocode: PromoCode) -> bool:
|
||||
try:
|
||||
from sqlalchemy import delete as sql_delete
|
||||
|
||||
await db.execute(
|
||||
sql_delete(PromoCodeUse).where(PromoCodeUse.promocode_id == promocode.id)
|
||||
)
|
||||
|
||||
await db.delete(promocode)
|
||||
await db.commit()
|
||||
|
||||
logger.info(f"🗑️ Удален промокод: {promocode.code}")
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка удаления промокода: {e}")
|
||||
await db.rollback()
|
||||
return False
|
||||
|
||||
|
||||
async def get_promocode_statistics(db: AsyncSession, promocode_id: int) -> dict:
|
||||
|
||||
total_uses_result = await db.execute(
|
||||
select(func.count(PromoCodeUse.id))
|
||||
.where(PromoCodeUse.promocode_id == promocode_id)
|
||||
)
|
||||
total_uses = total_uses_result.scalar()
|
||||
|
||||
today = datetime.utcnow().date()
|
||||
today_uses_result = await db.execute(
|
||||
select(func.count(PromoCodeUse.id))
|
||||
.where(
|
||||
and_(
|
||||
PromoCodeUse.promocode_id == promocode_id,
|
||||
PromoCodeUse.used_at >= today
|
||||
)
|
||||
)
|
||||
)
|
||||
today_uses = today_uses_result.scalar()
|
||||
|
||||
recent_uses_result = await db.execute(
|
||||
select(PromoCodeUse, User)
|
||||
.join(User, PromoCodeUse.user_id == User.id)
|
||||
.where(PromoCodeUse.promocode_id == promocode_id)
|
||||
.order_by(PromoCodeUse.used_at.desc())
|
||||
.limit(10)
|
||||
)
|
||||
recent_uses_data = recent_uses_result.all()
|
||||
|
||||
recent_uses = []
|
||||
for use, user in recent_uses_data:
|
||||
use.user_username = user.username
|
||||
use.user_full_name = user.full_name
|
||||
use.user_telegram_id = user.telegram_id
|
||||
recent_uses.append(use)
|
||||
|
||||
return {
|
||||
"total_uses": total_uses,
|
||||
"today_uses": today_uses,
|
||||
"recent_uses": recent_uses
|
||||
}
|
||||
|
||||
@@ -0,0 +1,305 @@
|
||||
import logging
|
||||
from datetime import datetime, timedelta
|
||||
from typing import List, Optional
|
||||
from sqlalchemy import select, and_, func
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy.orm import selectinload
|
||||
|
||||
from app.database.models import ReferralEarning, User
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
async def create_referral_earning(
|
||||
db: AsyncSession,
|
||||
user_id: int,
|
||||
referral_id: int,
|
||||
amount_kopeks: int,
|
||||
reason: str,
|
||||
referral_transaction_id: Optional[int] = None
|
||||
) -> ReferralEarning:
|
||||
|
||||
earning = ReferralEarning(
|
||||
user_id=user_id,
|
||||
referral_id=referral_id,
|
||||
amount_kopeks=amount_kopeks,
|
||||
reason=reason,
|
||||
referral_transaction_id=referral_transaction_id
|
||||
)
|
||||
|
||||
db.add(earning)
|
||||
await db.commit()
|
||||
await db.refresh(earning)
|
||||
|
||||
logger.info(f"💰 Создан реферальный заработок: {amount_kopeks/100}₽ для пользователя {user_id}")
|
||||
return earning
|
||||
|
||||
|
||||
async def get_referral_earnings_by_user(
|
||||
db: AsyncSession,
|
||||
user_id: int,
|
||||
limit: int = 50,
|
||||
offset: int = 0
|
||||
) -> List[ReferralEarning]:
|
||||
|
||||
result = await db.execute(
|
||||
select(ReferralEarning)
|
||||
.options(
|
||||
selectinload(ReferralEarning.referral),
|
||||
selectinload(ReferralEarning.referral_transaction)
|
||||
)
|
||||
.where(ReferralEarning.user_id == user_id)
|
||||
.order_by(ReferralEarning.created_at.desc())
|
||||
.offset(offset)
|
||||
.limit(limit)
|
||||
)
|
||||
return result.scalars().all()
|
||||
|
||||
|
||||
async def get_referral_earnings_by_referral(
|
||||
db: AsyncSession,
|
||||
referral_id: int
|
||||
) -> List[ReferralEarning]:
|
||||
|
||||
result = await db.execute(
|
||||
select(ReferralEarning)
|
||||
.where(ReferralEarning.referral_id == referral_id)
|
||||
.order_by(ReferralEarning.created_at.desc())
|
||||
)
|
||||
return result.scalars().all()
|
||||
|
||||
|
||||
async def get_referral_earnings_sum(
|
||||
db: AsyncSession,
|
||||
user_id: int,
|
||||
start_date: Optional[datetime] = None,
|
||||
end_date: Optional[datetime] = None
|
||||
) -> int:
|
||||
|
||||
query = select(func.coalesce(func.sum(ReferralEarning.amount_kopeks), 0)).where(
|
||||
ReferralEarning.user_id == user_id
|
||||
)
|
||||
|
||||
if start_date:
|
||||
query = query.where(ReferralEarning.created_at >= start_date)
|
||||
|
||||
if end_date:
|
||||
query = query.where(ReferralEarning.created_at <= end_date)
|
||||
|
||||
result = await db.execute(query)
|
||||
return result.scalar()
|
||||
|
||||
|
||||
async def get_referral_statistics(db: AsyncSession) -> dict:
|
||||
|
||||
users_with_referrals_result = await db.execute(
|
||||
select(func.count(func.distinct(User.id)))
|
||||
.where(User.referred_by_id.isnot(None))
|
||||
)
|
||||
users_with_referrals = users_with_referrals_result.scalar()
|
||||
|
||||
active_referrers_result = await db.execute(
|
||||
select(func.count(func.distinct(User.referred_by_id)))
|
||||
.where(User.referred_by_id.isnot(None))
|
||||
)
|
||||
active_referrers = active_referrers_result.scalar()
|
||||
|
||||
referral_paid_result = await db.execute(
|
||||
select(func.coalesce(func.sum(ReferralEarning.amount_kopeks), 0))
|
||||
)
|
||||
referral_paid = referral_paid_result.scalar()
|
||||
|
||||
from app.database.models import Transaction, TransactionType
|
||||
transaction_paid_result = await db.execute(
|
||||
select(func.coalesce(func.sum(Transaction.amount_kopeks), 0))
|
||||
.where(Transaction.type == TransactionType.REFERRAL_REWARD.value)
|
||||
)
|
||||
transaction_paid = transaction_paid_result.scalar()
|
||||
|
||||
total_paid = referral_paid + transaction_paid
|
||||
|
||||
referrals_stats_result = await db.execute(
|
||||
select(
|
||||
User.referred_by_id.label('referrer_id'),
|
||||
func.count(User.id).label('referrals_count')
|
||||
)
|
||||
.where(User.referred_by_id.isnot(None))
|
||||
.group_by(User.referred_by_id)
|
||||
)
|
||||
referrals_stats = {row.referrer_id: row.referrals_count for row in referrals_stats_result.all()}
|
||||
|
||||
referral_earnings_result = await db.execute(
|
||||
select(
|
||||
ReferralEarning.user_id.label('referrer_id'),
|
||||
func.sum(ReferralEarning.amount_kopeks).label('referral_earnings')
|
||||
)
|
||||
.group_by(ReferralEarning.user_id)
|
||||
)
|
||||
referral_earnings = {row.referrer_id: row.referral_earnings for row in referral_earnings_result.all()}
|
||||
|
||||
transaction_earnings_result = await db.execute(
|
||||
select(
|
||||
Transaction.user_id.label('referrer_id'),
|
||||
func.sum(Transaction.amount_kopeks).label('transaction_earnings')
|
||||
)
|
||||
.where(Transaction.type == TransactionType.REFERRAL_REWARD.value)
|
||||
.group_by(Transaction.user_id)
|
||||
)
|
||||
transaction_earnings = {row.referrer_id: row.transaction_earnings for row in transaction_earnings_result.all()}
|
||||
|
||||
top_referrers_data = {}
|
||||
|
||||
for referrer_id, count in referrals_stats.items():
|
||||
if referrer_id not in top_referrers_data:
|
||||
top_referrers_data[referrer_id] = {
|
||||
'referrals_count': 0,
|
||||
'total_earned': 0
|
||||
}
|
||||
top_referrers_data[referrer_id]['referrals_count'] = count
|
||||
|
||||
for referrer_id, earnings in referral_earnings.items():
|
||||
if referrer_id not in top_referrers_data:
|
||||
top_referrers_data[referrer_id] = {
|
||||
'referrals_count': 0,
|
||||
'total_earned': 0
|
||||
}
|
||||
top_referrers_data[referrer_id]['total_earned'] += earnings or 0
|
||||
|
||||
for referrer_id, earnings in transaction_earnings.items():
|
||||
if referrer_id not in top_referrers_data:
|
||||
top_referrers_data[referrer_id] = {
|
||||
'referrals_count': 0,
|
||||
'total_earned': 0
|
||||
}
|
||||
top_referrers_data[referrer_id]['total_earned'] += earnings or 0
|
||||
|
||||
sorted_referrers = sorted(
|
||||
top_referrers_data.items(),
|
||||
key=lambda x: (x[1]['total_earned'], x[1]['referrals_count']),
|
||||
reverse=True
|
||||
)
|
||||
|
||||
top_referrers = []
|
||||
for referrer_id, stats in sorted_referrers[:5]:
|
||||
user_result = await db.execute(
|
||||
select(User.id, User.username, User.first_name, User.last_name, User.telegram_id)
|
||||
.where(User.id == referrer_id)
|
||||
)
|
||||
user = user_result.first()
|
||||
|
||||
if user:
|
||||
display_name = ""
|
||||
if user.first_name:
|
||||
display_name = user.first_name
|
||||
if user.last_name:
|
||||
display_name += f" {user.last_name}"
|
||||
elif user.username:
|
||||
display_name = f"@{user.username}"
|
||||
else:
|
||||
display_name = f"ID{user.telegram_id}"
|
||||
|
||||
top_referrers.append({
|
||||
"user_id": user.telegram_id,
|
||||
"display_name": display_name,
|
||||
"username": user.username,
|
||||
"telegram_id": user.telegram_id,
|
||||
"total_earned_kopeks": stats['total_earned'],
|
||||
"referrals_count": stats['referrals_count']
|
||||
})
|
||||
|
||||
today = datetime.utcnow().replace(hour=0, minute=0, second=0, microsecond=0)
|
||||
|
||||
today_referral_earnings_result = await db.execute(
|
||||
select(func.coalesce(func.sum(ReferralEarning.amount_kopeks), 0))
|
||||
.where(ReferralEarning.created_at >= today)
|
||||
)
|
||||
today_transaction_earnings_result = await db.execute(
|
||||
select(func.coalesce(func.sum(Transaction.amount_kopeks), 0))
|
||||
.where(
|
||||
and_(
|
||||
Transaction.type == TransactionType.REFERRAL_REWARD.value,
|
||||
Transaction.created_at >= today
|
||||
)
|
||||
)
|
||||
)
|
||||
today_earnings = today_referral_earnings_result.scalar() + today_transaction_earnings_result.scalar()
|
||||
|
||||
week_ago = datetime.utcnow() - timedelta(days=7)
|
||||
week_referral_earnings_result = await db.execute(
|
||||
select(func.coalesce(func.sum(ReferralEarning.amount_kopeks), 0))
|
||||
.where(ReferralEarning.created_at >= week_ago)
|
||||
)
|
||||
week_transaction_earnings_result = await db.execute(
|
||||
select(func.coalesce(func.sum(Transaction.amount_kopeks), 0))
|
||||
.where(
|
||||
and_(
|
||||
Transaction.type == TransactionType.REFERRAL_REWARD.value,
|
||||
Transaction.created_at >= week_ago
|
||||
)
|
||||
)
|
||||
)
|
||||
week_earnings = week_referral_earnings_result.scalar() + week_transaction_earnings_result.scalar()
|
||||
|
||||
month_ago = datetime.utcnow() - timedelta(days=30)
|
||||
month_referral_earnings_result = await db.execute(
|
||||
select(func.coalesce(func.sum(ReferralEarning.amount_kopeks), 0))
|
||||
.where(ReferralEarning.created_at >= month_ago)
|
||||
)
|
||||
month_transaction_earnings_result = await db.execute(
|
||||
select(func.coalesce(func.sum(Transaction.amount_kopeks), 0))
|
||||
.where(
|
||||
and_(
|
||||
Transaction.type == TransactionType.REFERRAL_REWARD.value,
|
||||
Transaction.created_at >= month_ago
|
||||
)
|
||||
)
|
||||
)
|
||||
month_earnings = month_referral_earnings_result.scalar() + month_transaction_earnings_result.scalar()
|
||||
|
||||
logger.info(f"Реферальная статистика: {users_with_referrals} рефералов, {active_referrers} рефереров, выплачено {total_paid} копеек")
|
||||
|
||||
return {
|
||||
"users_with_referrals": users_with_referrals,
|
||||
"active_referrers": active_referrers,
|
||||
"total_paid_kopeks": total_paid,
|
||||
"today_earnings_kopeks": today_earnings,
|
||||
"week_earnings_kopeks": week_earnings,
|
||||
"month_earnings_kopeks": month_earnings,
|
||||
"top_referrers": top_referrers
|
||||
}
|
||||
|
||||
|
||||
async def get_user_referral_stats(db: AsyncSession, user_id: int) -> dict:
|
||||
|
||||
invited_count_result = await db.execute(
|
||||
select(func.count(User.id)).where(User.referred_by_id == user_id)
|
||||
)
|
||||
invited_count = invited_count_result.scalar()
|
||||
|
||||
total_earned = await get_referral_earnings_sum(db, user_id)
|
||||
|
||||
month_ago = datetime.utcnow() - timedelta(days=30)
|
||||
month_earned = await get_referral_earnings_sum(db, user_id, start_date=month_ago)
|
||||
|
||||
from app.database.models import Subscription, SubscriptionStatus
|
||||
current_time = datetime.utcnow()
|
||||
|
||||
active_referrals_result = await db.execute(
|
||||
select(func.count(User.id))
|
||||
.join(Subscription, User.id == Subscription.user_id)
|
||||
.where(
|
||||
and_(
|
||||
User.referred_by_id == user_id,
|
||||
Subscription.status == SubscriptionStatus.ACTIVE.value,
|
||||
Subscription.end_date > current_time
|
||||
)
|
||||
)
|
||||
)
|
||||
active_referrals = active_referrals_result.scalar()
|
||||
|
||||
return {
|
||||
"invited_count": invited_count,
|
||||
"active_referrals": active_referrals,
|
||||
"total_earned_kopeks": total_earned,
|
||||
"month_earned_kopeks": month_earned
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
import logging
|
||||
from typing import Optional
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from datetime import datetime
|
||||
|
||||
from app.database.models import ServiceRule
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
async def get_rules_by_language(db: AsyncSession, language: str = "ru") -> Optional[ServiceRule]:
|
||||
result = await db.execute(
|
||||
select(ServiceRule)
|
||||
.where(
|
||||
ServiceRule.language == language,
|
||||
ServiceRule.is_active == True
|
||||
)
|
||||
.order_by(ServiceRule.order, ServiceRule.created_at.desc())
|
||||
.limit(1)
|
||||
)
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
|
||||
async def create_or_update_rules(
|
||||
db: AsyncSession,
|
||||
content: str,
|
||||
language: str = "ru",
|
||||
title: str = "Правила сервиса"
|
||||
) -> ServiceRule:
|
||||
|
||||
existing_rules_result = await db.execute(
|
||||
select(ServiceRule).where(
|
||||
ServiceRule.language == language,
|
||||
ServiceRule.is_active == True
|
||||
)
|
||||
)
|
||||
existing_rules = existing_rules_result.scalars().all()
|
||||
|
||||
for rule in existing_rules:
|
||||
rule.is_active = False
|
||||
rule.updated_at = datetime.utcnow()
|
||||
|
||||
new_rules = ServiceRule(
|
||||
title=title,
|
||||
content=content,
|
||||
language=language,
|
||||
is_active=True,
|
||||
order=0
|
||||
)
|
||||
|
||||
db.add(new_rules)
|
||||
await db.commit()
|
||||
await db.refresh(new_rules)
|
||||
|
||||
logger.info(f"✅ Правила для языка {language} обновлены")
|
||||
return new_rules
|
||||
|
||||
|
||||
async def get_current_rules_content(db: AsyncSession, language: str = "ru") -> str:
|
||||
rules = await get_rules_by_language(db, language)
|
||||
|
||||
if rules:
|
||||
return rules.content
|
||||
else:
|
||||
return """
|
||||
🔒 <b>Правила использования сервиса</b>
|
||||
|
||||
1. Сервис предоставляется "как есть" без каких-либо гарантий.
|
||||
|
||||
2. Запрещается использование сервиса для незаконных действий.
|
||||
|
||||
3. Администрация оставляет за собой право заблокировать доступ пользователя при нарушении правил.
|
||||
|
||||
4. Возврат средств осуществляется в соответствии с политикой возврата.
|
||||
|
||||
5. Пользователь несет полную ответственность за безопасность своего аккаунта.
|
||||
|
||||
6. При возникновении вопросов обращайтесь в техническую поддержку.
|
||||
|
||||
Используя сервис, вы соглашаетесь с данными правилами.
|
||||
"""
|
||||
@@ -0,0 +1,355 @@
|
||||
import logging
|
||||
from typing import List, Optional, Tuple
|
||||
from sqlalchemy import select, and_, func, update, delete
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy.orm import selectinload
|
||||
|
||||
from app.database.models import ServerSquad, SubscriptionServer
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
async def create_server_squad(
|
||||
db: AsyncSession,
|
||||
squad_uuid: str,
|
||||
display_name: str,
|
||||
original_name: str = None,
|
||||
country_code: str = None,
|
||||
price_kopeks: int = 0,
|
||||
description: str = None,
|
||||
max_users: int = None,
|
||||
is_available: bool = True
|
||||
) -> ServerSquad:
|
||||
|
||||
server_squad = ServerSquad(
|
||||
squad_uuid=squad_uuid,
|
||||
display_name=display_name,
|
||||
original_name=original_name,
|
||||
country_code=country_code,
|
||||
price_kopeks=price_kopeks,
|
||||
description=description,
|
||||
max_users=max_users,
|
||||
is_available=is_available
|
||||
)
|
||||
|
||||
db.add(server_squad)
|
||||
await db.commit()
|
||||
await db.refresh(server_squad)
|
||||
|
||||
logger.info(f"✅ Создан сервер {display_name} (UUID: {squad_uuid})")
|
||||
return server_squad
|
||||
|
||||
|
||||
async def get_server_squad_by_uuid(
|
||||
db: AsyncSession,
|
||||
squad_uuid: str
|
||||
) -> Optional[ServerSquad]:
|
||||
|
||||
result = await db.execute(
|
||||
select(ServerSquad).where(ServerSquad.squad_uuid == squad_uuid)
|
||||
)
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
|
||||
async def get_server_squad_by_id(
|
||||
db: AsyncSession,
|
||||
server_id: int
|
||||
) -> Optional[ServerSquad]:
|
||||
|
||||
result = await db.execute(
|
||||
select(ServerSquad).where(ServerSquad.id == server_id)
|
||||
)
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
|
||||
async def get_all_server_squads(
|
||||
db: AsyncSession,
|
||||
available_only: bool = False,
|
||||
page: int = 1,
|
||||
limit: int = 50
|
||||
) -> Tuple[List[ServerSquad], int]:
|
||||
|
||||
query = select(ServerSquad)
|
||||
|
||||
if available_only:
|
||||
query = query.where(ServerSquad.is_available == True)
|
||||
|
||||
count_query = select(func.count(ServerSquad.id))
|
||||
if available_only:
|
||||
count_query = count_query.where(ServerSquad.is_available == True)
|
||||
|
||||
count_result = await db.execute(count_query)
|
||||
total_count = count_result.scalar()
|
||||
|
||||
offset = (page - 1) * limit
|
||||
query = query.order_by(ServerSquad.sort_order, ServerSquad.display_name)
|
||||
query = query.offset(offset).limit(limit)
|
||||
|
||||
result = await db.execute(query)
|
||||
servers = result.scalars().all()
|
||||
|
||||
return servers, total_count
|
||||
|
||||
|
||||
async def get_available_server_squads(db: AsyncSession) -> List[ServerSquad]:
|
||||
|
||||
result = await db.execute(
|
||||
select(ServerSquad)
|
||||
.where(ServerSquad.is_available == True)
|
||||
.order_by(ServerSquad.sort_order, ServerSquad.display_name)
|
||||
)
|
||||
return result.scalars().all()
|
||||
|
||||
|
||||
async def update_server_squad(
|
||||
db: AsyncSession,
|
||||
server_id: int,
|
||||
**updates
|
||||
) -> Optional[ServerSquad]:
|
||||
|
||||
valid_fields = {
|
||||
'display_name', 'country_code', 'price_kopeks', 'description',
|
||||
'max_users', 'is_available', 'sort_order'
|
||||
}
|
||||
|
||||
filtered_updates = {k: v for k, v in updates.items() if k in valid_fields}
|
||||
|
||||
if not filtered_updates:
|
||||
return None
|
||||
|
||||
await db.execute(
|
||||
update(ServerSquad)
|
||||
.where(ServerSquad.id == server_id)
|
||||
.values(**filtered_updates)
|
||||
)
|
||||
|
||||
await db.commit()
|
||||
|
||||
return await get_server_squad_by_id(db, server_id)
|
||||
|
||||
|
||||
async def delete_server_squad(db: AsyncSession, server_id: int) -> bool:
|
||||
|
||||
connections_result = await db.execute(
|
||||
select(func.count(SubscriptionServer.id))
|
||||
.where(SubscriptionServer.server_squad_id == server_id)
|
||||
)
|
||||
connections_count = connections_result.scalar()
|
||||
|
||||
if connections_count > 0:
|
||||
logger.warning(f"❌ Нельзя удалить сервер {server_id}: есть активные подключения ({connections_count})")
|
||||
return False
|
||||
|
||||
await db.execute(
|
||||
delete(ServerSquad).where(ServerSquad.id == server_id)
|
||||
)
|
||||
await db.commit()
|
||||
|
||||
logger.info(f"🗑️ Удален сервер (ID: {server_id})")
|
||||
return True
|
||||
|
||||
|
||||
async def sync_with_remnawave(
|
||||
db: AsyncSession,
|
||||
remnawave_squads: List[dict]
|
||||
) -> Tuple[int, int, int]:
|
||||
|
||||
created = 0
|
||||
updated = 0
|
||||
disabled = 0
|
||||
|
||||
existing_servers = {}
|
||||
result = await db.execute(select(ServerSquad))
|
||||
for server in result.scalars().all():
|
||||
existing_servers[server.squad_uuid] = server
|
||||
|
||||
remnawave_uuids = {squad['uuid'] for squad in remnawave_squads}
|
||||
|
||||
for squad in remnawave_squads:
|
||||
squad_uuid = squad['uuid']
|
||||
original_name = squad.get('name', f'Squad {squad_uuid[:8]}')
|
||||
|
||||
if squad_uuid in existing_servers:
|
||||
server = existing_servers[squad_uuid]
|
||||
if server.original_name != original_name:
|
||||
server.original_name = original_name
|
||||
updated += 1
|
||||
else:
|
||||
await create_server_squad(
|
||||
db=db,
|
||||
squad_uuid=squad_uuid,
|
||||
display_name=_generate_display_name(original_name),
|
||||
original_name=original_name,
|
||||
country_code=_extract_country_code(original_name),
|
||||
price_kopeks=1000,
|
||||
is_available=False
|
||||
)
|
||||
created += 1
|
||||
|
||||
for uuid, server in existing_servers.items():
|
||||
if uuid not in remnawave_uuids and server.is_available:
|
||||
server.is_available = False
|
||||
disabled += 1
|
||||
|
||||
await db.commit()
|
||||
|
||||
logger.info(f"🔄 Синхронизация завершена: +{created} ~{updated} -{disabled}")
|
||||
return created, updated, disabled
|
||||
|
||||
|
||||
def _generate_display_name(original_name: str) -> str:
|
||||
|
||||
country_names = {
|
||||
'NL': '🇳🇱 Нидерланды',
|
||||
'DE': '🇩🇪 Германия',
|
||||
'US': '🇺🇸 США',
|
||||
'FR': '🇫🇷 Франция',
|
||||
'GB': '🇬🇧 Великобритания',
|
||||
'IT': '🇮🇹 Италия',
|
||||
'ES': '🇪🇸 Испания',
|
||||
'CA': '🇨🇦 Канада',
|
||||
'JP': '🇯🇵 Япония',
|
||||
'SG': '🇸🇬 Сингапур',
|
||||
'AU': '🇦🇺 Австралия',
|
||||
}
|
||||
|
||||
name_upper = original_name.upper()
|
||||
for code, display_name in country_names.items():
|
||||
if code in name_upper:
|
||||
return display_name
|
||||
|
||||
return f"🌍 {original_name}"
|
||||
|
||||
|
||||
def _extract_country_code(original_name: str) -> Optional[str]:
|
||||
|
||||
codes = ['NL', 'DE', 'US', 'FR', 'GB', 'IT', 'ES', 'CA', 'JP', 'SG', 'AU']
|
||||
name_upper = original_name.upper()
|
||||
|
||||
for code in codes:
|
||||
if code in name_upper:
|
||||
return code
|
||||
|
||||
return None
|
||||
|
||||
|
||||
async def get_server_statistics(db: AsyncSession) -> dict:
|
||||
|
||||
total_result = await db.execute(select(func.count(ServerSquad.id)))
|
||||
total_servers = total_result.scalar()
|
||||
|
||||
available_result = await db.execute(
|
||||
select(func.count(ServerSquad.id))
|
||||
.where(ServerSquad.is_available == True)
|
||||
)
|
||||
available_servers = available_result.scalar()
|
||||
|
||||
with_connections_result = await db.execute(
|
||||
select(func.count(func.distinct(SubscriptionServer.server_squad_id)))
|
||||
)
|
||||
servers_with_connections = with_connections_result.scalar()
|
||||
|
||||
revenue_result = await db.execute(
|
||||
select(func.coalesce(func.sum(SubscriptionServer.paid_price_kopeks), 0))
|
||||
)
|
||||
total_revenue_kopeks = revenue_result.scalar()
|
||||
|
||||
return {
|
||||
'total_servers': total_servers,
|
||||
'available_servers': available_servers,
|
||||
'unavailable_servers': total_servers - available_servers,
|
||||
'servers_with_connections': servers_with_connections,
|
||||
'total_revenue_kopeks': total_revenue_kopeks,
|
||||
'total_revenue_rubles': total_revenue_kopeks / 100
|
||||
}
|
||||
|
||||
async def add_user_to_servers(
|
||||
db: AsyncSession,
|
||||
server_squad_ids: List[int]
|
||||
) -> bool:
|
||||
|
||||
try:
|
||||
for server_id in server_squad_ids:
|
||||
await db.execute(
|
||||
update(ServerSquad)
|
||||
.where(ServerSquad.id == server_id)
|
||||
.values(current_users=ServerSquad.current_users + 1)
|
||||
)
|
||||
|
||||
await db.commit()
|
||||
logger.info(f"✅ Увеличен счетчик пользователей для серверов: {server_squad_ids}")
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка увеличения счетчика пользователей: {e}")
|
||||
await db.rollback()
|
||||
return False
|
||||
|
||||
|
||||
async def remove_user_from_servers(
|
||||
db: AsyncSession,
|
||||
server_squad_ids: List[int]
|
||||
) -> bool:
|
||||
|
||||
try:
|
||||
for server_id in server_squad_ids:
|
||||
await db.execute(
|
||||
update(ServerSquad)
|
||||
.where(ServerSquad.id == server_id)
|
||||
.values(current_users=func.greatest(ServerSquad.current_users - 1, 0))
|
||||
)
|
||||
|
||||
await db.commit()
|
||||
logger.info(f"✅ Уменьшен счетчик пользователей для серверов: {server_squad_ids}")
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка уменьшения счетчика пользователей: {e}")
|
||||
await db.rollback()
|
||||
return False
|
||||
|
||||
|
||||
async def get_server_ids_by_uuids(
|
||||
db: AsyncSession,
|
||||
squad_uuids: List[str]
|
||||
) -> List[int]:
|
||||
|
||||
result = await db.execute(
|
||||
select(ServerSquad.id)
|
||||
.where(ServerSquad.squad_uuid.in_(squad_uuids))
|
||||
)
|
||||
return [row[0] for row in result.fetchall()]
|
||||
|
||||
|
||||
async def sync_server_user_counts(db: AsyncSession) -> int:
|
||||
|
||||
try:
|
||||
result = await db.execute(
|
||||
select(
|
||||
ServerSquad.id,
|
||||
ServerSquad.squad_uuid,
|
||||
func.count(SubscriptionServer.id).label('actual_users')
|
||||
)
|
||||
.outerjoin(SubscriptionServer, ServerSquad.id == SubscriptionServer.server_squad_id)
|
||||
.join(Subscription, SubscriptionServer.subscription_id == Subscription.id)
|
||||
.where(Subscription.status == 'active')
|
||||
.group_by(ServerSquad.id, ServerSquad.squad_uuid)
|
||||
)
|
||||
|
||||
updated_count = 0
|
||||
for server_id, squad_uuid, actual_users in result.fetchall():
|
||||
await db.execute(
|
||||
update(ServerSquad)
|
||||
.where(ServerSquad.id == server_id)
|
||||
.values(current_users=actual_users)
|
||||
)
|
||||
updated_count += 1
|
||||
|
||||
await db.commit()
|
||||
logger.info(f"✅ Синхронизированы счетчики для {updated_count} серверов")
|
||||
return updated_count
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка синхронизации счетчиков пользователей: {e}")
|
||||
await db.rollback()
|
||||
return 0
|
||||
@@ -0,0 +1,61 @@
|
||||
import logging
|
||||
from typing import Optional, List
|
||||
from sqlalchemy import select, and_
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.database.models import Squad
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
async def get_squad_by_uuid(db: AsyncSession, uuid: str) -> Optional[Squad]:
|
||||
result = await db.execute(
|
||||
select(Squad).where(Squad.uuid == uuid)
|
||||
)
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
|
||||
async def get_available_squads(db: AsyncSession) -> List[Squad]:
|
||||
result = await db.execute(
|
||||
select(Squad).where(Squad.is_available == True)
|
||||
)
|
||||
return result.scalars().all()
|
||||
|
||||
|
||||
async def create_squad(
|
||||
db: AsyncSession,
|
||||
uuid: str,
|
||||
name: str,
|
||||
country_code: str = None,
|
||||
price_kopeks: int = 0,
|
||||
description: str = None
|
||||
) -> Squad:
|
||||
squad = Squad(
|
||||
uuid=uuid,
|
||||
name=name,
|
||||
country_code=country_code,
|
||||
price_kopeks=price_kopeks,
|
||||
description=description
|
||||
)
|
||||
|
||||
db.add(squad)
|
||||
await db.commit()
|
||||
await db.refresh(squad)
|
||||
|
||||
logger.info(f"✅ Создан сквад: {name}")
|
||||
return squad
|
||||
|
||||
|
||||
async def update_squad(
|
||||
db: AsyncSession,
|
||||
squad: Squad,
|
||||
**kwargs
|
||||
) -> Squad:
|
||||
for field, value in kwargs.items():
|
||||
if hasattr(squad, field):
|
||||
setattr(squad, field, value)
|
||||
|
||||
await db.commit()
|
||||
await db.refresh(squad)
|
||||
|
||||
return squad
|
||||
@@ -0,0 +1,775 @@
|
||||
import logging
|
||||
from datetime import datetime, timedelta
|
||||
from typing import Optional, List, Tuple
|
||||
from sqlalchemy import select, and_, func
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy.orm import selectinload
|
||||
|
||||
from app.database.models import (
|
||||
Subscription, SubscriptionStatus, User,
|
||||
SubscriptionServer
|
||||
)
|
||||
from app.utils.pricing_utils import calculate_months_from_days, get_remaining_months
|
||||
from app.config import settings
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
async def get_subscription_by_user_id(db: AsyncSession, user_id: int) -> Optional[Subscription]:
|
||||
result = await db.execute(
|
||||
select(Subscription)
|
||||
.options(selectinload(Subscription.user))
|
||||
.where(Subscription.user_id == user_id)
|
||||
.order_by(Subscription.created_at.desc())
|
||||
.limit(1)
|
||||
)
|
||||
subscription = result.scalar_one_or_none()
|
||||
|
||||
if subscription:
|
||||
subscription = await check_and_update_subscription_status(db, subscription)
|
||||
|
||||
return subscription
|
||||
|
||||
|
||||
async def create_trial_subscription(
|
||||
db: AsyncSession,
|
||||
user_id: int,
|
||||
duration_days: int = None,
|
||||
traffic_limit_gb: int = None,
|
||||
device_limit: int = None,
|
||||
squad_uuid: str = None
|
||||
) -> Subscription:
|
||||
|
||||
duration_days = duration_days or settings.TRIAL_DURATION_DAYS
|
||||
traffic_limit_gb = traffic_limit_gb or settings.TRIAL_TRAFFIC_LIMIT_GB
|
||||
device_limit = device_limit or settings.TRIAL_DEVICE_LIMIT
|
||||
squad_uuid = squad_uuid or settings.TRIAL_SQUAD_UUID
|
||||
|
||||
end_date = datetime.utcnow() + timedelta(days=duration_days)
|
||||
|
||||
subscription = Subscription(
|
||||
user_id=user_id,
|
||||
status=SubscriptionStatus.ACTIVE.value,
|
||||
is_trial=True,
|
||||
start_date=datetime.utcnow(),
|
||||
end_date=end_date,
|
||||
traffic_limit_gb=traffic_limit_gb,
|
||||
device_limit=device_limit,
|
||||
connected_squads=[squad_uuid] if squad_uuid else []
|
||||
)
|
||||
|
||||
db.add(subscription)
|
||||
await db.commit()
|
||||
await db.refresh(subscription)
|
||||
|
||||
logger.info(f"🎁 Создана триальная подписка для пользователя {user_id}")
|
||||
return subscription
|
||||
|
||||
|
||||
async def create_paid_subscription(
|
||||
db: AsyncSession,
|
||||
user_id: int,
|
||||
duration_days: int,
|
||||
traffic_limit_gb: int = 0,
|
||||
device_limit: int = 1,
|
||||
connected_squads: List[str] = None
|
||||
) -> Subscription:
|
||||
|
||||
end_date = datetime.utcnow() + timedelta(days=duration_days)
|
||||
|
||||
subscription = Subscription(
|
||||
user_id=user_id,
|
||||
status=SubscriptionStatus.ACTIVE.value,
|
||||
is_trial=False,
|
||||
start_date=datetime.utcnow(),
|
||||
end_date=end_date,
|
||||
traffic_limit_gb=traffic_limit_gb,
|
||||
device_limit=device_limit,
|
||||
connected_squads=connected_squads or []
|
||||
)
|
||||
|
||||
db.add(subscription)
|
||||
await db.commit()
|
||||
await db.refresh(subscription)
|
||||
|
||||
logger.info(f"💎 Создана платная подписка для пользователя {user_id}")
|
||||
return subscription
|
||||
|
||||
|
||||
async def extend_subscription(
|
||||
db: AsyncSession,
|
||||
subscription: Subscription,
|
||||
days: int
|
||||
) -> Subscription:
|
||||
current_time = datetime.utcnow()
|
||||
|
||||
logger.info(f"🔄 Продление подписки {subscription.id} на {days} дней")
|
||||
logger.info(f"📊 Текущие параметры: статус={subscription.status}, окончание={subscription.end_date}")
|
||||
|
||||
if subscription.end_date > current_time:
|
||||
subscription.end_date = subscription.end_date + timedelta(days=days)
|
||||
logger.info(f"📅 Подписка активна, добавляем {days} дней к текущей дате окончания")
|
||||
else:
|
||||
subscription.end_date = current_time + timedelta(days=days)
|
||||
logger.info(f"📅 Подписка истекла, устанавливаем новую дату окончания")
|
||||
|
||||
if subscription.status == SubscriptionStatus.EXPIRED.value:
|
||||
subscription.status = SubscriptionStatus.ACTIVE.value
|
||||
logger.info(f"🔄 Статус изменён с EXPIRED на ACTIVE")
|
||||
|
||||
subscription.updated_at = current_time
|
||||
|
||||
await db.commit()
|
||||
await db.refresh(subscription)
|
||||
|
||||
logger.info(f"✅ Подписка продлена до: {subscription.end_date}")
|
||||
logger.info(f"📊 Новые параметры: статус={subscription.status}, окончание={subscription.end_date}")
|
||||
|
||||
return subscription
|
||||
|
||||
|
||||
async def add_subscription_traffic(
|
||||
db: AsyncSession,
|
||||
subscription: Subscription,
|
||||
gb: int
|
||||
) -> Subscription:
|
||||
|
||||
subscription.add_traffic(gb)
|
||||
subscription.updated_at = datetime.utcnow()
|
||||
|
||||
await db.commit()
|
||||
await db.refresh(subscription)
|
||||
|
||||
logger.info(f"📈 К подписке пользователя {subscription.user_id} добавлено {gb} ГБ трафика")
|
||||
return subscription
|
||||
|
||||
|
||||
async def add_subscription_devices(
|
||||
db: AsyncSession,
|
||||
subscription: Subscription,
|
||||
devices: int
|
||||
) -> Subscription:
|
||||
|
||||
subscription.device_limit += devices
|
||||
subscription.updated_at = datetime.utcnow()
|
||||
|
||||
await db.commit()
|
||||
await db.refresh(subscription)
|
||||
|
||||
logger.info(f"📱 К подписке пользователя {subscription.user_id} добавлено {devices} устройств")
|
||||
return subscription
|
||||
|
||||
|
||||
async def add_subscription_squad(
|
||||
db: AsyncSession,
|
||||
subscription: Subscription,
|
||||
squad_uuid: str
|
||||
) -> Subscription:
|
||||
|
||||
if squad_uuid not in subscription.connected_squads:
|
||||
subscription.connected_squads = subscription.connected_squads + [squad_uuid]
|
||||
subscription.updated_at = datetime.utcnow()
|
||||
|
||||
await db.commit()
|
||||
await db.refresh(subscription)
|
||||
|
||||
logger.info(f"🌍 К подписке пользователя {subscription.user_id} добавлен сквад {squad_uuid}")
|
||||
|
||||
return subscription
|
||||
|
||||
|
||||
async def remove_subscription_squad(
|
||||
db: AsyncSession,
|
||||
subscription: Subscription,
|
||||
squad_uuid: str
|
||||
) -> Subscription:
|
||||
|
||||
if squad_uuid in subscription.connected_squads:
|
||||
squads = subscription.connected_squads.copy()
|
||||
squads.remove(squad_uuid)
|
||||
subscription.connected_squads = squads
|
||||
subscription.updated_at = datetime.utcnow()
|
||||
|
||||
await db.commit()
|
||||
await db.refresh(subscription)
|
||||
|
||||
logger.info(f"🚫 Из подписки пользователя {subscription.user_id} удален сквад {squad_uuid}")
|
||||
|
||||
return subscription
|
||||
|
||||
|
||||
async def update_subscription_autopay(
|
||||
db: AsyncSession,
|
||||
subscription: Subscription,
|
||||
enabled: bool,
|
||||
days_before: int = 3
|
||||
) -> Subscription:
|
||||
|
||||
subscription.autopay_enabled = enabled
|
||||
subscription.autopay_days_before = days_before
|
||||
subscription.updated_at = datetime.utcnow()
|
||||
|
||||
await db.commit()
|
||||
await db.refresh(subscription)
|
||||
|
||||
status = "включен" if enabled else "выключен"
|
||||
logger.info(f"💳 Автоплатеж для подписки пользователя {subscription.user_id} {status}")
|
||||
return subscription
|
||||
|
||||
|
||||
async def deactivate_subscription(
|
||||
db: AsyncSession,
|
||||
subscription: Subscription
|
||||
) -> Subscription:
|
||||
|
||||
subscription.status = SubscriptionStatus.DISABLED.value
|
||||
subscription.updated_at = datetime.utcnow()
|
||||
|
||||
await db.commit()
|
||||
await db.refresh(subscription)
|
||||
|
||||
logger.info(f"❌ Подписка пользователя {subscription.user_id} деактивирована")
|
||||
return subscription
|
||||
|
||||
|
||||
async def get_expiring_subscriptions(
|
||||
db: AsyncSession,
|
||||
days_before: int = 3
|
||||
) -> List[Subscription]:
|
||||
|
||||
threshold_date = datetime.utcnow() + timedelta(days=days_before)
|
||||
|
||||
result = await db.execute(
|
||||
select(Subscription)
|
||||
.options(selectinload(Subscription.user))
|
||||
.where(
|
||||
and_(
|
||||
Subscription.status == SubscriptionStatus.ACTIVE.value,
|
||||
Subscription.end_date <= threshold_date,
|
||||
Subscription.end_date > datetime.utcnow()
|
||||
)
|
||||
)
|
||||
)
|
||||
return result.scalars().all()
|
||||
|
||||
|
||||
async def get_expired_subscriptions(db: AsyncSession) -> List[Subscription]:
|
||||
|
||||
result = await db.execute(
|
||||
select(Subscription)
|
||||
.options(selectinload(Subscription.user))
|
||||
.where(
|
||||
and_(
|
||||
Subscription.status == SubscriptionStatus.ACTIVE.value,
|
||||
Subscription.end_date <= datetime.utcnow()
|
||||
)
|
||||
)
|
||||
)
|
||||
return result.scalars().all()
|
||||
|
||||
|
||||
async def get_subscriptions_for_autopay(db: AsyncSession) -> List[Subscription]:
|
||||
current_time = datetime.utcnow()
|
||||
|
||||
result = await db.execute(
|
||||
select(Subscription)
|
||||
.options(selectinload(Subscription.user))
|
||||
.where(
|
||||
and_(
|
||||
Subscription.status == SubscriptionStatus.ACTIVE.value,
|
||||
Subscription.autopay_enabled == True,
|
||||
Subscription.is_trial == False
|
||||
)
|
||||
)
|
||||
)
|
||||
all_autopay_subscriptions = result.scalars().all()
|
||||
|
||||
ready_for_autopay = []
|
||||
for subscription in all_autopay_subscriptions:
|
||||
days_until_expiry = (subscription.end_date - current_time).days
|
||||
|
||||
if days_until_expiry <= subscription.autopay_days_before and subscription.end_date > current_time:
|
||||
ready_for_autopay.append(subscription)
|
||||
|
||||
return ready_for_autopay
|
||||
|
||||
|
||||
async def get_subscriptions_statistics(db: AsyncSession) -> dict:
|
||||
|
||||
total_result = await db.execute(select(func.count(Subscription.id)))
|
||||
total_subscriptions = total_result.scalar()
|
||||
|
||||
active_result = await db.execute(
|
||||
select(func.count(Subscription.id))
|
||||
.where(Subscription.status == SubscriptionStatus.ACTIVE.value)
|
||||
)
|
||||
active_subscriptions = active_result.scalar()
|
||||
|
||||
trial_result = await db.execute(
|
||||
select(func.count(Subscription.id))
|
||||
.where(
|
||||
and_(
|
||||
Subscription.is_trial == True,
|
||||
Subscription.status == SubscriptionStatus.ACTIVE.value
|
||||
)
|
||||
)
|
||||
)
|
||||
trial_subscriptions = trial_result.scalar()
|
||||
|
||||
paid_subscriptions = active_subscriptions - trial_subscriptions
|
||||
|
||||
today = datetime.utcnow().date()
|
||||
today_result = await db.execute(
|
||||
select(func.count(Subscription.id))
|
||||
.where(
|
||||
and_(
|
||||
Subscription.created_at >= today,
|
||||
Subscription.is_trial == False
|
||||
)
|
||||
)
|
||||
)
|
||||
purchased_today = today_result.scalar()
|
||||
|
||||
week_ago = datetime.utcnow() - timedelta(days=7)
|
||||
week_result = await db.execute(
|
||||
select(func.count(Subscription.id))
|
||||
.where(
|
||||
and_(
|
||||
Subscription.created_at >= week_ago,
|
||||
Subscription.is_trial == False
|
||||
)
|
||||
)
|
||||
)
|
||||
purchased_week = week_result.scalar()
|
||||
|
||||
month_ago = datetime.utcnow() - timedelta(days=30)
|
||||
month_result = await db.execute(
|
||||
select(func.count(Subscription.id))
|
||||
.where(
|
||||
and_(
|
||||
Subscription.created_at >= month_ago,
|
||||
Subscription.is_trial == False
|
||||
)
|
||||
)
|
||||
)
|
||||
purchased_month = month_result.scalar()
|
||||
|
||||
try:
|
||||
from app.database.crud.subscription_conversion import get_conversion_statistics
|
||||
conversion_stats = await get_conversion_statistics(db)
|
||||
|
||||
trial_to_paid_conversion = conversion_stats.get("conversion_rate", 0)
|
||||
renewals_count = conversion_stats.get("month_conversions", 0)
|
||||
|
||||
logger.info(f"📊 Статистика конверсии из таблицы conversions:")
|
||||
logger.info(f" Общее количество конверсий: {conversion_stats.get('total_conversions', 0)}")
|
||||
logger.info(f" Процент конверсии: {trial_to_paid_conversion}%")
|
||||
logger.info(f" Конверсий за месяц: {renewals_count}")
|
||||
|
||||
except ImportError:
|
||||
logger.warning("⚠️ Таблица subscription_conversions не найдена, используем старую логику")
|
||||
|
||||
users_with_paid_result = await db.execute(
|
||||
select(func.count(User.id))
|
||||
.where(User.has_had_paid_subscription == True)
|
||||
)
|
||||
users_with_paid = users_with_paid_result.scalar()
|
||||
|
||||
total_users_result = await db.execute(select(func.count(User.id)))
|
||||
total_users = total_users_result.scalar()
|
||||
|
||||
if total_users > 0:
|
||||
trial_to_paid_conversion = round((users_with_paid / total_users) * 100, 1)
|
||||
else:
|
||||
trial_to_paid_conversion = 0
|
||||
|
||||
renewals_count = 0
|
||||
|
||||
return {
|
||||
"total_subscriptions": total_subscriptions,
|
||||
"active_subscriptions": active_subscriptions,
|
||||
"trial_subscriptions": trial_subscriptions,
|
||||
"paid_subscriptions": paid_subscriptions,
|
||||
"purchased_today": purchased_today,
|
||||
"purchased_week": purchased_week,
|
||||
"purchased_month": purchased_month,
|
||||
"trial_to_paid_conversion": trial_to_paid_conversion,
|
||||
"renewals_count": renewals_count
|
||||
}
|
||||
|
||||
async def update_subscription_usage(
|
||||
db: AsyncSession,
|
||||
subscription: Subscription,
|
||||
used_gb: float
|
||||
) -> Subscription:
|
||||
subscription.traffic_used_gb = used_gb
|
||||
subscription.updated_at = datetime.utcnow()
|
||||
|
||||
await db.commit()
|
||||
await db.refresh(subscription)
|
||||
|
||||
return subscription
|
||||
|
||||
async def get_all_subscriptions(
|
||||
db: AsyncSession,
|
||||
page: int = 1,
|
||||
limit: int = 10
|
||||
) -> Tuple[List[Subscription], int]:
|
||||
count_result = await db.execute(
|
||||
select(func.count(Subscription.id))
|
||||
)
|
||||
total_count = count_result.scalar()
|
||||
|
||||
offset = (page - 1) * limit
|
||||
|
||||
result = await db.execute(
|
||||
select(Subscription)
|
||||
.options(selectinload(Subscription.user))
|
||||
.order_by(Subscription.created_at.desc())
|
||||
.offset(offset)
|
||||
.limit(limit)
|
||||
)
|
||||
|
||||
subscriptions = result.scalars().all()
|
||||
|
||||
return subscriptions, total_count
|
||||
|
||||
async def add_subscription_servers(
|
||||
db: AsyncSession,
|
||||
subscription: Subscription,
|
||||
server_squad_ids: List[int],
|
||||
paid_prices: List[int] = None
|
||||
) -> Subscription:
|
||||
if paid_prices is None:
|
||||
months_remaining = get_remaining_months(subscription.end_date)
|
||||
paid_prices = []
|
||||
|
||||
from app.database.models import ServerSquad
|
||||
for server_id in server_squad_ids:
|
||||
result = await db.execute(
|
||||
select(ServerSquad.price_kopeks)
|
||||
.where(ServerSquad.id == server_id)
|
||||
)
|
||||
server_price_per_month = result.scalar() or 0
|
||||
total_price_for_period = server_price_per_month * months_remaining
|
||||
paid_prices.append(total_price_for_period)
|
||||
|
||||
for i, server_id in enumerate(server_squad_ids):
|
||||
subscription_server = SubscriptionServer(
|
||||
subscription_id=subscription.id,
|
||||
server_squad_id=server_id,
|
||||
paid_price_kopeks=paid_prices[i] if i < len(paid_prices) else 0
|
||||
)
|
||||
db.add(subscription_server)
|
||||
|
||||
await db.commit()
|
||||
await db.refresh(subscription)
|
||||
|
||||
logger.info(f"🌍 К подписке {subscription.id} добавлено {len(server_squad_ids)} серверов с ценами: {paid_prices}")
|
||||
return subscription
|
||||
|
||||
async def get_server_monthly_price(
|
||||
db: AsyncSession,
|
||||
server_squad_id: int
|
||||
) -> int:
|
||||
from app.database.models import ServerSquad
|
||||
|
||||
result = await db.execute(
|
||||
select(ServerSquad.price_kopeks)
|
||||
.where(ServerSquad.id == server_squad_id)
|
||||
)
|
||||
return result.scalar() or 0
|
||||
|
||||
|
||||
async def get_servers_monthly_prices(
|
||||
db: AsyncSession,
|
||||
server_squad_ids: List[int]
|
||||
) -> List[int]:
|
||||
prices = []
|
||||
for server_id in server_squad_ids:
|
||||
price = await get_server_monthly_price(db, server_id)
|
||||
prices.append(price)
|
||||
return prices
|
||||
|
||||
async def calculate_subscription_total_cost(
|
||||
db: AsyncSession,
|
||||
period_days: int,
|
||||
traffic_gb: int,
|
||||
server_squad_ids: List[int],
|
||||
devices: int
|
||||
) -> Tuple[int, dict]:
|
||||
from app.config import PERIOD_PRICES
|
||||
|
||||
months_in_period = calculate_months_from_days(period_days)
|
||||
|
||||
base_price = PERIOD_PRICES.get(period_days, 0)
|
||||
|
||||
traffic_price_per_month = settings.get_traffic_price(traffic_gb)
|
||||
total_traffic_price = traffic_price_per_month * months_in_period
|
||||
|
||||
servers_prices = await get_servers_monthly_prices(db, server_squad_ids)
|
||||
servers_price_per_month = sum(servers_prices)
|
||||
total_servers_price = servers_price_per_month * months_in_period
|
||||
|
||||
additional_devices = max(0, devices - settings.DEFAULT_DEVICE_LIMIT)
|
||||
devices_price_per_month = additional_devices * settings.PRICE_PER_DEVICE
|
||||
total_devices_price = devices_price_per_month * months_in_period
|
||||
|
||||
total_cost = base_price + total_traffic_price + total_servers_price + total_devices_price
|
||||
|
||||
details = {
|
||||
'base_price': base_price,
|
||||
'traffic_price_per_month': traffic_price_per_month,
|
||||
'total_traffic_price': total_traffic_price,
|
||||
'servers_price_per_month': servers_price_per_month,
|
||||
'total_servers_price': total_servers_price,
|
||||
'devices_price_per_month': devices_price_per_month,
|
||||
'total_devices_price': total_devices_price,
|
||||
'months_in_period': months_in_period,
|
||||
'servers_individual_prices': [price * months_in_period for price in servers_prices]
|
||||
}
|
||||
|
||||
logger.info(f"📊 Расчет стоимости подписки на {period_days} дней ({months_in_period} мес):")
|
||||
logger.info(f" Базовый период: {base_price/100}₽")
|
||||
logger.info(f" Трафик: {traffic_price_per_month/100}₽/мес × {months_in_period} = {total_traffic_price/100}₽")
|
||||
logger.info(f" Серверы: {servers_price_per_month/100}₽/мес × {months_in_period} = {total_servers_price/100}₽")
|
||||
logger.info(f" Устройства: {devices_price_per_month/100}₽/мес × {months_in_period} = {total_devices_price/100}₽")
|
||||
logger.info(f" ИТОГО: {total_cost/100}₽")
|
||||
|
||||
return total_cost, details
|
||||
|
||||
async def get_subscription_server_ids(
|
||||
db: AsyncSession,
|
||||
subscription_id: int
|
||||
) -> List[int]:
|
||||
|
||||
result = await db.execute(
|
||||
select(SubscriptionServer.server_squad_id)
|
||||
.where(SubscriptionServer.subscription_id == subscription_id)
|
||||
)
|
||||
return [row[0] for row in result.fetchall()]
|
||||
|
||||
|
||||
async def get_subscription_servers(
|
||||
db: AsyncSession,
|
||||
subscription_id: int
|
||||
) -> List[dict]:
|
||||
|
||||
from app.database.models import ServerSquad
|
||||
|
||||
result = await db.execute(
|
||||
select(SubscriptionServer, ServerSquad)
|
||||
.join(ServerSquad, SubscriptionServer.server_squad_id == ServerSquad.id)
|
||||
.where(SubscriptionServer.subscription_id == subscription_id)
|
||||
)
|
||||
|
||||
servers_info = []
|
||||
for sub_server, server_squad in result.fetchall():
|
||||
servers_info.append({
|
||||
'server_id': server_squad.id,
|
||||
'squad_uuid': server_squad.squad_uuid,
|
||||
'display_name': server_squad.display_name,
|
||||
'country_code': server_squad.country_code,
|
||||
'paid_price_kopeks': sub_server.paid_price_kopeks,
|
||||
'connected_at': sub_server.connected_at,
|
||||
'is_available': server_squad.is_available
|
||||
})
|
||||
|
||||
return servers_info
|
||||
|
||||
async def remove_subscription_servers(
|
||||
db: AsyncSession,
|
||||
subscription_id: int,
|
||||
server_squad_ids: List[int]
|
||||
) -> bool:
|
||||
try:
|
||||
from app.database.models import SubscriptionServer
|
||||
from sqlalchemy import delete
|
||||
|
||||
await db.execute(
|
||||
delete(SubscriptionServer)
|
||||
.where(
|
||||
SubscriptionServer.subscription_id == subscription_id,
|
||||
SubscriptionServer.server_squad_id.in_(server_squad_ids)
|
||||
)
|
||||
)
|
||||
|
||||
await db.commit()
|
||||
logger.info(f"🗑️ Удалены серверы {server_squad_ids} из подписки {subscription_id}")
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка удаления серверов из подписки: {e}")
|
||||
await db.rollback()
|
||||
return False
|
||||
|
||||
|
||||
async def get_subscription_renewal_cost(
|
||||
db: AsyncSession,
|
||||
subscription_id: int,
|
||||
period_days: int
|
||||
) -> int:
|
||||
try:
|
||||
from app.config import PERIOD_PRICES
|
||||
|
||||
months_in_period = calculate_months_from_days(period_days)
|
||||
|
||||
base_price = PERIOD_PRICES.get(period_days, 0)
|
||||
|
||||
subscription = await db.get(Subscription, subscription_id)
|
||||
if not subscription:
|
||||
return base_price
|
||||
|
||||
servers_info = await get_subscription_servers(db, subscription_id)
|
||||
servers_price_per_month = 0
|
||||
for server_info in servers_info:
|
||||
from app.database.models import ServerSquad
|
||||
result = await db.execute(
|
||||
select(ServerSquad.price_kopeks)
|
||||
.where(ServerSquad.id == server_info['server_id'])
|
||||
)
|
||||
current_server_price = result.scalar() or 0
|
||||
servers_price_per_month += current_server_price
|
||||
|
||||
total_servers_cost = servers_price_per_month * months_in_period
|
||||
|
||||
traffic_price_per_month = settings.get_traffic_price(subscription.traffic_limit_gb)
|
||||
total_traffic_cost = traffic_price_per_month * months_in_period
|
||||
|
||||
additional_devices = max(0, subscription.device_limit - settings.DEFAULT_DEVICE_LIMIT)
|
||||
devices_price_per_month = additional_devices * settings.PRICE_PER_DEVICE
|
||||
total_devices_cost = devices_price_per_month * months_in_period
|
||||
|
||||
total_cost = base_price + total_servers_cost + total_traffic_cost + total_devices_cost
|
||||
|
||||
logger.info(f"💰 Расчет продления подписки {subscription_id} на {period_days} дней ({months_in_period} мес):")
|
||||
logger.info(f" 📅 Период: {base_price/100}₽")
|
||||
logger.info(f" 🌍 Серверы: {servers_price_per_month/100}₽/мес × {months_in_period} = {total_servers_cost/100}₽")
|
||||
logger.info(f" 📊 Трафик: {traffic_price_per_month/100}₽/мес × {months_in_period} = {total_traffic_cost/100}₽")
|
||||
logger.info(f" 📱 Устройства: {devices_price_per_month/100}₽/мес × {months_in_period} = {total_devices_cost/100}₽")
|
||||
logger.info(f" 💎 ИТОГО: {total_cost/100}₽")
|
||||
|
||||
return total_cost
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка расчета стоимости продления: {e}")
|
||||
from app.config import PERIOD_PRICES
|
||||
return PERIOD_PRICES.get(period_days, 0)
|
||||
|
||||
async def calculate_addon_cost_for_remaining_period(
|
||||
db: AsyncSession,
|
||||
subscription: Subscription,
|
||||
additional_traffic_gb: int = 0,
|
||||
additional_devices: int = 0,
|
||||
additional_server_ids: List[int] = None
|
||||
) -> int:
|
||||
if additional_server_ids is None:
|
||||
additional_server_ids = []
|
||||
|
||||
months_to_pay = get_remaining_months(subscription.end_date)
|
||||
|
||||
total_cost = 0
|
||||
|
||||
if additional_traffic_gb > 0:
|
||||
traffic_price_per_month = settings.get_traffic_price(additional_traffic_gb)
|
||||
traffic_total_cost = traffic_price_per_month * months_to_pay
|
||||
total_cost += traffic_total_cost
|
||||
logger.info(f"Трафик +{additional_traffic_gb}ГБ: {traffic_price_per_month/100}₽/мес × {months_to_pay} = {traffic_total_cost/100}₽")
|
||||
|
||||
if additional_devices > 0:
|
||||
devices_price_per_month = additional_devices * settings.PRICE_PER_DEVICE
|
||||
devices_total_cost = devices_price_per_month * months_to_pay
|
||||
total_cost += devices_total_cost
|
||||
logger.info(f"Устройства +{additional_devices}: {devices_price_per_month/100}₽/мес × {months_to_pay} = {devices_total_cost/100}₽")
|
||||
|
||||
if additional_server_ids:
|
||||
from app.database.models import ServerSquad
|
||||
for server_id in additional_server_ids:
|
||||
result = await db.execute(
|
||||
select(ServerSquad.price_kopeks, ServerSquad.display_name)
|
||||
.where(ServerSquad.id == server_id)
|
||||
)
|
||||
server_data = result.first()
|
||||
if server_data:
|
||||
server_price_per_month, server_name = server_data
|
||||
server_total_cost = server_price_per_month * months_to_pay
|
||||
total_cost += server_total_cost
|
||||
logger.info(f"Сервер {server_name}: {server_price_per_month/100}₽/мес × {months_to_pay} = {server_total_cost/100}₽")
|
||||
|
||||
logger.info(f"💰 Итого доплата за {months_to_pay} мес: {total_cost/100}₽")
|
||||
return total_cost
|
||||
|
||||
async def expire_subscription(
|
||||
db: AsyncSession,
|
||||
subscription: Subscription
|
||||
) -> Subscription:
|
||||
|
||||
subscription.status = SubscriptionStatus.EXPIRED.value
|
||||
subscription.updated_at = datetime.utcnow()
|
||||
|
||||
await db.commit()
|
||||
await db.refresh(subscription)
|
||||
|
||||
logger.info(f"⏰ Подписка пользователя {subscription.user_id} помечена как истёкшая")
|
||||
return subscription
|
||||
|
||||
|
||||
async def check_and_update_subscription_status(
|
||||
db: AsyncSession,
|
||||
subscription: Subscription
|
||||
) -> Subscription:
|
||||
|
||||
current_time = datetime.utcnow()
|
||||
|
||||
if (subscription.status == SubscriptionStatus.ACTIVE.value and
|
||||
subscription.end_date <= current_time):
|
||||
|
||||
subscription.status = SubscriptionStatus.EXPIRED.value
|
||||
subscription.updated_at = current_time
|
||||
|
||||
await db.commit()
|
||||
await db.refresh(subscription)
|
||||
|
||||
logger.info(f"⏰ Статус подписки пользователя {subscription.user_id} изменен на 'expired'")
|
||||
|
||||
return subscription
|
||||
|
||||
async def create_subscription(
|
||||
db: AsyncSession,
|
||||
user_id: int,
|
||||
status: str = "trial",
|
||||
is_trial: bool = True,
|
||||
end_date: datetime = None,
|
||||
traffic_limit_gb: int = 10,
|
||||
traffic_used_gb: float = 0.0,
|
||||
device_limit: int = 1,
|
||||
connected_squads: list = None,
|
||||
remnawave_short_uuid: str = None,
|
||||
subscription_url: str = ""
|
||||
) -> Subscription:
|
||||
|
||||
if end_date is None:
|
||||
end_date = datetime.utcnow() + timedelta(days=3)
|
||||
|
||||
if connected_squads is None:
|
||||
connected_squads = []
|
||||
|
||||
subscription = Subscription(
|
||||
user_id=user_id,
|
||||
status=status,
|
||||
is_trial=is_trial,
|
||||
end_date=end_date,
|
||||
traffic_limit_gb=traffic_limit_gb,
|
||||
traffic_used_gb=traffic_used_gb,
|
||||
device_limit=device_limit,
|
||||
connected_squads=connected_squads,
|
||||
remnawave_short_uuid=remnawave_short_uuid,
|
||||
subscription_url=subscription_url
|
||||
)
|
||||
|
||||
db.add(subscription)
|
||||
await db.commit()
|
||||
await db.refresh(subscription)
|
||||
|
||||
logger.info(f"✅ Создана подписка для пользователя {user_id}")
|
||||
return subscription
|
||||
@@ -0,0 +1,118 @@
|
||||
import logging
|
||||
from datetime import datetime, timedelta
|
||||
from typing import Optional, List
|
||||
from sqlalchemy import select, func
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.database.models import SubscriptionConversion, User
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
async def create_subscription_conversion(
|
||||
db: AsyncSession,
|
||||
user_id: int,
|
||||
trial_duration_days: int,
|
||||
payment_method: str,
|
||||
first_payment_amount_kopeks: int,
|
||||
first_paid_period_days: int
|
||||
) -> SubscriptionConversion:
|
||||
|
||||
conversion = SubscriptionConversion(
|
||||
user_id=user_id,
|
||||
converted_at=datetime.utcnow(),
|
||||
trial_duration_days=trial_duration_days,
|
||||
payment_method=payment_method,
|
||||
first_payment_amount_kopeks=first_payment_amount_kopeks,
|
||||
first_paid_period_days=first_paid_period_days
|
||||
)
|
||||
|
||||
db.add(conversion)
|
||||
await db.commit()
|
||||
await db.refresh(conversion)
|
||||
|
||||
logger.info(f"✅ Создана запись о конверсии для пользователя {user_id}: {trial_duration_days} дн. → {first_paid_period_days} дн. за {first_payment_amount_kopeks/100}₽")
|
||||
|
||||
return conversion
|
||||
|
||||
|
||||
async def get_conversion_by_user_id(
|
||||
db: AsyncSession,
|
||||
user_id: int
|
||||
) -> Optional[SubscriptionConversion]:
|
||||
|
||||
result = await db.execute(
|
||||
select(SubscriptionConversion)
|
||||
.where(SubscriptionConversion.user_id == user_id)
|
||||
.order_by(SubscriptionConversion.converted_at.desc())
|
||||
.limit(1)
|
||||
)
|
||||
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
|
||||
async def get_conversion_statistics(db: AsyncSession) -> dict:
|
||||
|
||||
total_conversions_result = await db.execute(
|
||||
select(func.count(SubscriptionConversion.id))
|
||||
)
|
||||
total_conversions = total_conversions_result.scalar()
|
||||
|
||||
users_with_paid_result = await db.execute(
|
||||
select(func.count(User.id))
|
||||
.where(User.has_had_paid_subscription == True)
|
||||
)
|
||||
users_with_paid = users_with_paid_result.scalar()
|
||||
|
||||
if total_conversions > 0:
|
||||
conversion_rate = round((total_conversions / max(total_conversions, users_with_paid)) * 100, 1)
|
||||
elif users_with_paid > 0:
|
||||
conversion_rate = 100.0
|
||||
else:
|
||||
conversion_rate = 0.0
|
||||
|
||||
avg_trial_duration_result = await db.execute(
|
||||
select(func.avg(SubscriptionConversion.trial_duration_days))
|
||||
)
|
||||
avg_trial_duration = avg_trial_duration_result.scalar() or 0
|
||||
|
||||
avg_first_payment_result = await db.execute(
|
||||
select(func.avg(SubscriptionConversion.first_payment_amount_kopeks))
|
||||
)
|
||||
avg_first_payment = avg_first_payment_result.scalar() or 0
|
||||
|
||||
month_ago = datetime.utcnow() - timedelta(days=30)
|
||||
month_conversions_result = await db.execute(
|
||||
select(func.count(SubscriptionConversion.id))
|
||||
.where(SubscriptionConversion.converted_at >= month_ago)
|
||||
)
|
||||
month_conversions = month_conversions_result.scalar()
|
||||
|
||||
logger.info(f"📊 Статистика конверсий:")
|
||||
logger.info(f" Всего записей о конверсиях: {total_conversions}")
|
||||
logger.info(f" Пользователей с платными подписками: {users_with_paid}")
|
||||
logger.info(f" Рассчитанная конверсия: {conversion_rate}%")
|
||||
|
||||
return {
|
||||
"total_conversions": total_conversions,
|
||||
"conversion_rate": conversion_rate,
|
||||
"avg_trial_duration_days": round(avg_trial_duration, 1),
|
||||
"avg_first_payment_rubles": round((avg_first_payment or 0) / 100, 2),
|
||||
"month_conversions": month_conversions
|
||||
}
|
||||
|
||||
|
||||
async def get_users_had_trial_count(db: AsyncSession) -> int:
|
||||
|
||||
conversions_count_result = await db.execute(
|
||||
select(func.count(func.distinct(SubscriptionConversion.user_id)))
|
||||
)
|
||||
conversions_count = conversions_count_result.scalar()
|
||||
|
||||
paid_users_result = await db.execute(
|
||||
select(func.count(User.id))
|
||||
.where(User.has_had_paid_subscription == True)
|
||||
)
|
||||
paid_users_count = paid_users_result.scalar()
|
||||
|
||||
return max(conversions_count, paid_users_count)
|
||||
@@ -0,0 +1,358 @@
|
||||
import logging
|
||||
from datetime import datetime, timedelta
|
||||
from typing import Optional, List
|
||||
from sqlalchemy import select, and_, or_, func, desc
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy.orm import selectinload
|
||||
|
||||
from app.database.models import Transaction, TransactionType, PaymentMethod, User
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
async def create_transaction(
|
||||
db: AsyncSession,
|
||||
user_id: int,
|
||||
type: TransactionType,
|
||||
amount_kopeks: int,
|
||||
description: str,
|
||||
payment_method: Optional[PaymentMethod] = None,
|
||||
external_id: Optional[str] = None,
|
||||
is_completed: bool = True
|
||||
) -> Transaction:
|
||||
|
||||
transaction = Transaction(
|
||||
user_id=user_id,
|
||||
type=type.value,
|
||||
amount_kopeks=amount_kopeks,
|
||||
description=description,
|
||||
payment_method=payment_method.value if payment_method else None,
|
||||
external_id=external_id,
|
||||
is_completed=is_completed,
|
||||
completed_at=datetime.utcnow() if is_completed else None
|
||||
)
|
||||
|
||||
db.add(transaction)
|
||||
await db.commit()
|
||||
await db.refresh(transaction)
|
||||
|
||||
logger.info(f"💳 Создана транзакция: {type.value} на {amount_kopeks/100}₽ для пользователя {user_id}")
|
||||
return transaction
|
||||
|
||||
|
||||
async def get_transaction_by_id(db: AsyncSession, transaction_id: int) -> Optional[Transaction]:
|
||||
result = await db.execute(
|
||||
select(Transaction)
|
||||
.options(selectinload(Transaction.user))
|
||||
.where(Transaction.id == transaction_id)
|
||||
)
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
|
||||
async def get_transaction_by_external_id(
|
||||
db: AsyncSession,
|
||||
external_id: str,
|
||||
payment_method: PaymentMethod
|
||||
) -> Optional[Transaction]:
|
||||
result = await db.execute(
|
||||
select(Transaction)
|
||||
.where(
|
||||
and_(
|
||||
Transaction.external_id == external_id,
|
||||
Transaction.payment_method == payment_method.value
|
||||
)
|
||||
)
|
||||
)
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
|
||||
async def get_user_transactions(
|
||||
db: AsyncSession,
|
||||
user_id: int,
|
||||
limit: int = 50,
|
||||
offset: int = 0
|
||||
) -> List[Transaction]:
|
||||
|
||||
result = await db.execute(
|
||||
select(Transaction)
|
||||
.where(Transaction.user_id == user_id)
|
||||
.order_by(Transaction.created_at.desc())
|
||||
.offset(offset)
|
||||
.limit(limit)
|
||||
)
|
||||
return result.scalars().all()
|
||||
|
||||
|
||||
async def get_user_transactions_count(
|
||||
db: AsyncSession,
|
||||
user_id: int,
|
||||
transaction_type: Optional[TransactionType] = None
|
||||
) -> int:
|
||||
|
||||
query = select(func.count(Transaction.id)).where(Transaction.user_id == user_id)
|
||||
|
||||
if transaction_type:
|
||||
query = query.where(Transaction.type == transaction_type.value)
|
||||
|
||||
result = await db.execute(query)
|
||||
return result.scalar()
|
||||
|
||||
|
||||
async def complete_transaction(db: AsyncSession, transaction: Transaction) -> Transaction:
|
||||
|
||||
transaction.is_completed = True
|
||||
transaction.completed_at = datetime.utcnow()
|
||||
|
||||
await db.commit()
|
||||
await db.refresh(transaction)
|
||||
|
||||
logger.info(f"✅ Транзакция {transaction.id} завершена")
|
||||
return transaction
|
||||
|
||||
|
||||
async def get_pending_transactions(db: AsyncSession) -> List[Transaction]:
|
||||
|
||||
result = await db.execute(
|
||||
select(Transaction)
|
||||
.options(selectinload(Transaction.user))
|
||||
.where(Transaction.is_completed == False)
|
||||
.order_by(Transaction.created_at)
|
||||
)
|
||||
return result.scalars().all()
|
||||
|
||||
|
||||
async def get_transactions_statistics(
|
||||
db: AsyncSession,
|
||||
start_date: Optional[datetime] = None,
|
||||
end_date: Optional[datetime] = None
|
||||
) -> dict:
|
||||
|
||||
if not start_date:
|
||||
start_date = datetime.utcnow().replace(day=1, hour=0, minute=0, second=0, microsecond=0)
|
||||
if not end_date:
|
||||
end_date = datetime.utcnow()
|
||||
|
||||
income_result = await db.execute(
|
||||
select(func.coalesce(func.sum(Transaction.amount_kopeks), 0))
|
||||
.where(
|
||||
and_(
|
||||
Transaction.type == TransactionType.DEPOSIT.value,
|
||||
Transaction.is_completed == True,
|
||||
Transaction.created_at >= start_date,
|
||||
Transaction.created_at <= end_date
|
||||
)
|
||||
)
|
||||
)
|
||||
total_income = income_result.scalar()
|
||||
|
||||
expenses_result = await db.execute(
|
||||
select(func.coalesce(func.sum(Transaction.amount_kopeks), 0))
|
||||
.where(
|
||||
and_(
|
||||
Transaction.type == TransactionType.WITHDRAWAL.value,
|
||||
Transaction.is_completed == True,
|
||||
Transaction.created_at >= start_date,
|
||||
Transaction.created_at <= end_date
|
||||
)
|
||||
)
|
||||
)
|
||||
total_expenses = expenses_result.scalar()
|
||||
|
||||
subscription_income_result = await db.execute(
|
||||
select(func.coalesce(func.sum(Transaction.amount_kopeks), 0))
|
||||
.where(
|
||||
and_(
|
||||
Transaction.type == TransactionType.SUBSCRIPTION_PAYMENT.value,
|
||||
Transaction.is_completed == True,
|
||||
Transaction.created_at >= start_date,
|
||||
Transaction.created_at <= end_date
|
||||
)
|
||||
)
|
||||
)
|
||||
subscription_income = subscription_income_result.scalar()
|
||||
|
||||
transactions_count_result = await db.execute(
|
||||
select(
|
||||
Transaction.type,
|
||||
func.count(Transaction.id).label('count'),
|
||||
func.coalesce(func.sum(Transaction.amount_kopeks), 0).label('total_amount')
|
||||
)
|
||||
.where(
|
||||
and_(
|
||||
Transaction.is_completed == True,
|
||||
Transaction.created_at >= start_date,
|
||||
Transaction.created_at <= end_date
|
||||
)
|
||||
)
|
||||
.group_by(Transaction.type)
|
||||
)
|
||||
transactions_by_type = {row.type: {"count": row.count, "amount": row.total_amount}
|
||||
for row in transactions_count_result}
|
||||
|
||||
payment_methods_result = await db.execute(
|
||||
select(
|
||||
Transaction.payment_method,
|
||||
func.count(Transaction.id).label('count'),
|
||||
func.coalesce(func.sum(Transaction.amount_kopeks), 0).label('total_amount')
|
||||
)
|
||||
.where(
|
||||
and_(
|
||||
Transaction.type == TransactionType.DEPOSIT.value,
|
||||
Transaction.is_completed == True,
|
||||
Transaction.created_at >= start_date,
|
||||
Transaction.created_at <= end_date
|
||||
)
|
||||
)
|
||||
.group_by(Transaction.payment_method)
|
||||
)
|
||||
payment_methods = {row.payment_method: {"count": row.count, "amount": row.total_amount}
|
||||
for row in payment_methods_result}
|
||||
|
||||
today = datetime.utcnow().date()
|
||||
today_result = await db.execute(
|
||||
select(func.count(Transaction.id))
|
||||
.where(
|
||||
and_(
|
||||
Transaction.is_completed == True,
|
||||
Transaction.created_at >= today
|
||||
)
|
||||
)
|
||||
)
|
||||
transactions_today = today_result.scalar()
|
||||
|
||||
today_income_result = await db.execute(
|
||||
select(func.coalesce(func.sum(Transaction.amount_kopeks), 0))
|
||||
.where(
|
||||
and_(
|
||||
Transaction.type == TransactionType.DEPOSIT.value,
|
||||
Transaction.is_completed == True,
|
||||
Transaction.created_at >= today
|
||||
)
|
||||
)
|
||||
)
|
||||
income_today = today_income_result.scalar()
|
||||
|
||||
return {
|
||||
"period": {
|
||||
"start_date": start_date,
|
||||
"end_date": end_date
|
||||
},
|
||||
"totals": {
|
||||
"income_kopeks": total_income,
|
||||
"expenses_kopeks": total_expenses,
|
||||
"profit_kopeks": total_income - total_expenses,
|
||||
"subscription_income_kopeks": subscription_income
|
||||
},
|
||||
"today": {
|
||||
"transactions_count": transactions_today,
|
||||
"income_kopeks": income_today
|
||||
},
|
||||
"by_type": transactions_by_type,
|
||||
"by_payment_method": payment_methods
|
||||
}
|
||||
|
||||
|
||||
async def get_revenue_by_period(
|
||||
db: AsyncSession,
|
||||
days: int = 30
|
||||
) -> List[dict]:
|
||||
|
||||
start_date = datetime.utcnow() - timedelta(days=days)
|
||||
|
||||
result = await db.execute(
|
||||
select(
|
||||
func.date(Transaction.created_at).label('date'),
|
||||
func.coalesce(func.sum(Transaction.amount_kopeks), 0).label('amount')
|
||||
)
|
||||
.where(
|
||||
and_(
|
||||
Transaction.type == TransactionType.DEPOSIT.value,
|
||||
Transaction.is_completed == True,
|
||||
Transaction.created_at >= start_date
|
||||
)
|
||||
)
|
||||
.group_by(func.date(Transaction.created_at))
|
||||
.order_by(func.date(Transaction.created_at))
|
||||
)
|
||||
|
||||
return [{"date": row.date, "amount_kopeks": row.amount} for row in result]
|
||||
|
||||
async def find_tribute_transactions_by_payment_id(
|
||||
db: AsyncSession,
|
||||
payment_id: str,
|
||||
user_telegram_id: Optional[int] = None
|
||||
) -> List[Transaction]:
|
||||
"""Найти все Tribute транзакции по payment_id"""
|
||||
|
||||
query = select(Transaction).options(selectinload(Transaction.user))
|
||||
|
||||
conditions = [
|
||||
Transaction.external_id == f"donation_{payment_id}",
|
||||
Transaction.external_id == payment_id,
|
||||
Transaction.external_id.like(f"%{payment_id}%")
|
||||
]
|
||||
|
||||
query = query.where(
|
||||
and_(
|
||||
Transaction.payment_method == PaymentMethod.TRIBUTE.value,
|
||||
or_(*conditions)
|
||||
)
|
||||
)
|
||||
|
||||
if user_telegram_id:
|
||||
from app.database.models import User
|
||||
query = query.join(User).where(User.telegram_id == user_telegram_id)
|
||||
|
||||
result = await db.execute(query.order_by(Transaction.created_at.desc()))
|
||||
return result.scalars().all()
|
||||
|
||||
|
||||
async def check_tribute_payment_duplicate(
|
||||
db: AsyncSession,
|
||||
payment_id: str,
|
||||
amount_kopeks: int,
|
||||
user_telegram_id: int
|
||||
) -> Optional[Transaction]:
|
||||
|
||||
transactions = await find_tribute_transactions_by_payment_id(
|
||||
db, payment_id, user_telegram_id
|
||||
)
|
||||
|
||||
for transaction in transactions:
|
||||
if (transaction.amount_kopeks == amount_kopeks and
|
||||
transaction.is_completed and
|
||||
transaction.user.telegram_id == user_telegram_id):
|
||||
return transaction
|
||||
|
||||
return None
|
||||
|
||||
|
||||
async def create_unique_tribute_transaction(
|
||||
db: AsyncSession,
|
||||
user_id: int,
|
||||
payment_id: str,
|
||||
amount_kopeks: int,
|
||||
description: str
|
||||
) -> Transaction:
|
||||
"""Создать уникальную Tribute транзакцию с защитой от дубликатов"""
|
||||
|
||||
external_id = f"donation_{payment_id}"
|
||||
|
||||
existing = await get_transaction_by_external_id(db, external_id, PaymentMethod.TRIBUTE)
|
||||
|
||||
if existing:
|
||||
timestamp = int(datetime.utcnow().timestamp())
|
||||
external_id = f"donation_{payment_id}_{amount_kopeks}_{timestamp}"
|
||||
|
||||
logger.info(f"Создан уникальный external_id для избежания дубликатов: {external_id}")
|
||||
|
||||
return await create_transaction(
|
||||
db=db,
|
||||
user_id=user_id,
|
||||
type=TransactionType.DEPOSIT,
|
||||
amount_kopeks=amount_kopeks,
|
||||
description=description,
|
||||
payment_method=PaymentMethod.TRIBUTE,
|
||||
external_id=external_id,
|
||||
is_completed=True
|
||||
)
|
||||
@@ -0,0 +1,374 @@
|
||||
import logging
|
||||
import secrets
|
||||
import string
|
||||
from datetime import datetime, timedelta
|
||||
from typing import Optional, List
|
||||
from sqlalchemy import select, and_, or_, func
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy.orm import selectinload
|
||||
|
||||
from app.database.models import User, UserStatus, Subscription, Transaction
|
||||
from app.config import settings
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def generate_referral_code() -> str:
|
||||
alphabet = string.ascii_letters + string.digits
|
||||
code_suffix = ''.join(secrets.choice(alphabet) for _ in range(8))
|
||||
return f"ref{code_suffix}"
|
||||
|
||||
|
||||
async def get_user_by_id(db: AsyncSession, user_id: int) -> Optional[User]:
|
||||
result = await db.execute(
|
||||
select(User)
|
||||
.options(selectinload(User.subscription))
|
||||
.where(User.id == user_id)
|
||||
)
|
||||
user = result.scalar_one_or_none()
|
||||
|
||||
if user and user.subscription:
|
||||
_ = user.subscription.is_active
|
||||
|
||||
return user
|
||||
|
||||
|
||||
async def get_user_by_telegram_id(db: AsyncSession, telegram_id: int) -> Optional[User]:
|
||||
result = await db.execute(
|
||||
select(User)
|
||||
.options(selectinload(User.subscription))
|
||||
.where(User.telegram_id == telegram_id)
|
||||
)
|
||||
user = result.scalar_one_or_none()
|
||||
|
||||
if user and user.subscription:
|
||||
_ = user.subscription.is_active
|
||||
|
||||
return user
|
||||
|
||||
|
||||
async def get_user_by_referral_code(db: AsyncSession, referral_code: str) -> Optional[User]:
|
||||
|
||||
result = await db.execute(
|
||||
select(User).where(User.referral_code == referral_code)
|
||||
)
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
|
||||
async def create_unique_referral_code(db: AsyncSession) -> str:
|
||||
max_attempts = 10
|
||||
|
||||
for _ in range(max_attempts):
|
||||
code = generate_referral_code()
|
||||
existing_user = await get_user_by_referral_code(db, code)
|
||||
if not existing_user:
|
||||
return code
|
||||
|
||||
timestamp = str(int(datetime.utcnow().timestamp()))[-6:]
|
||||
return f"ref{timestamp}"
|
||||
|
||||
|
||||
async def create_user(
|
||||
db: AsyncSession,
|
||||
telegram_id: int,
|
||||
username: str = None,
|
||||
first_name: str = None,
|
||||
last_name: str = None,
|
||||
language: str = "ru",
|
||||
referred_by_id: int = None,
|
||||
referral_code: str = None
|
||||
) -> User:
|
||||
|
||||
if not referral_code:
|
||||
from app.utils.user_utils import generate_unique_referral_code
|
||||
referral_code = await generate_unique_referral_code(db, telegram_id)
|
||||
|
||||
user = User(
|
||||
telegram_id=telegram_id,
|
||||
username=username,
|
||||
first_name=first_name,
|
||||
last_name=last_name,
|
||||
language=language,
|
||||
referred_by_id=referred_by_id,
|
||||
referral_code=referral_code,
|
||||
balance_kopeks=0,
|
||||
has_had_paid_subscription=False
|
||||
)
|
||||
|
||||
db.add(user)
|
||||
await db.commit()
|
||||
await db.refresh(user)
|
||||
|
||||
logger.info(f"✅ Создан пользователь {telegram_id} с реферальным кодом {referral_code}")
|
||||
|
||||
return user
|
||||
|
||||
|
||||
async def update_user(
|
||||
db: AsyncSession,
|
||||
user: User,
|
||||
**kwargs
|
||||
) -> User:
|
||||
|
||||
for field, value in kwargs.items():
|
||||
if hasattr(user, field):
|
||||
setattr(user, field, value)
|
||||
|
||||
user.updated_at = datetime.utcnow()
|
||||
await db.commit()
|
||||
await db.refresh(user)
|
||||
|
||||
return user
|
||||
|
||||
|
||||
async def add_user_balance(
|
||||
db: AsyncSession,
|
||||
user: User,
|
||||
amount_kopeks: int,
|
||||
description: str = "Пополнение баланса",
|
||||
create_transaction: bool = True,
|
||||
bot = None
|
||||
) -> bool:
|
||||
try:
|
||||
old_balance = user.balance_kopeks
|
||||
user.balance_kopeks += amount_kopeks
|
||||
user.updated_at = datetime.utcnow()
|
||||
|
||||
if create_transaction:
|
||||
from app.database.crud.transaction import create_transaction as create_trans
|
||||
from app.database.models import TransactionType
|
||||
|
||||
await create_trans(
|
||||
db=db,
|
||||
user_id=user.id,
|
||||
type=TransactionType.DEPOSIT,
|
||||
amount_kopeks=amount_kopeks,
|
||||
description=description
|
||||
)
|
||||
|
||||
await db.commit()
|
||||
await db.refresh(user)
|
||||
|
||||
topup_keywords = ["пополнение", "stars", "yookassa", "topup"]
|
||||
exclude_keywords = ["комиссия", "бонус", "реферальн", "выплата", "вознаграждение"]
|
||||
|
||||
has_topup_keywords = any(word in description.lower() for word in topup_keywords)
|
||||
has_exclude_keywords = any(word in description.lower() for word in exclude_keywords)
|
||||
|
||||
if has_topup_keywords and not has_exclude_keywords:
|
||||
try:
|
||||
from app.services.referral_service import process_referral_topup
|
||||
await process_referral_topup(db, user.id, amount_kopeks, bot)
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка обработки реферального пополнения: {e}")
|
||||
|
||||
logger.info(f"💰 Баланс пользователя {user.telegram_id} изменен: {old_balance} → {user.balance_kopeks} (изменение: +{amount_kopeks})")
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка изменения баланса пользователя {user.id}: {e}")
|
||||
await db.rollback()
|
||||
return False
|
||||
|
||||
async def add_user_balance_by_id(
|
||||
db: AsyncSession,
|
||||
telegram_id: int,
|
||||
amount_kopeks: int,
|
||||
description: str = "Пополнение баланса"
|
||||
) -> bool:
|
||||
try:
|
||||
user = await get_user_by_telegram_id(db, telegram_id)
|
||||
if not user:
|
||||
logger.error(f"Пользователь с telegram_id {telegram_id} не найден")
|
||||
return False
|
||||
|
||||
return await add_user_balance(db, user, amount_kopeks, description)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка пополнения баланса пользователя {telegram_id}: {e}")
|
||||
return False
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"❌ Ошибка пополнения баланса пользователя {user_id}: {e}")
|
||||
await db.rollback()
|
||||
return False
|
||||
|
||||
async def subtract_user_balance(
|
||||
db: AsyncSession,
|
||||
user: User,
|
||||
amount_kopeks: int,
|
||||
description: str
|
||||
) -> bool:
|
||||
logger.error(f"💸 ОТЛАДКА subtract_user_balance:")
|
||||
logger.error(f" 👤 User ID: {user.id} (TG: {user.telegram_id})")
|
||||
logger.error(f" 💰 Баланс до списания: {user.balance_kopeks} копеек")
|
||||
logger.error(f" 💸 Сумма к списанию: {amount_kopeks} копеек")
|
||||
logger.error(f" 📝 Описание: {description}")
|
||||
|
||||
if user.balance_kopeks < amount_kopeks:
|
||||
logger.error(f" ❌ НЕДОСТАТОЧНО СРЕДСТВ!")
|
||||
return False
|
||||
|
||||
try:
|
||||
old_balance = user.balance_kopeks
|
||||
user.balance_kopeks -= amount_kopeks
|
||||
user.updated_at = datetime.utcnow()
|
||||
|
||||
await db.commit()
|
||||
await db.refresh(user)
|
||||
|
||||
logger.error(f" ✅ Средства списаны: {old_balance} → {user.balance_kopeks}")
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f" ❌ ОШИБКА СПИСАНИЯ: {e}")
|
||||
await db.rollback()
|
||||
return False
|
||||
|
||||
|
||||
async def get_users_list(
|
||||
db: AsyncSession,
|
||||
offset: int = 0,
|
||||
limit: int = 50,
|
||||
search: Optional[str] = None,
|
||||
status: Optional[UserStatus] = None
|
||||
) -> List[User]:
|
||||
|
||||
query = select(User).options(selectinload(User.subscription))
|
||||
|
||||
if status:
|
||||
query = query.where(User.status == status.value)
|
||||
|
||||
if search:
|
||||
search_term = f"%{search}%"
|
||||
conditions = [
|
||||
User.first_name.ilike(search_term),
|
||||
User.last_name.ilike(search_term),
|
||||
User.username.ilike(search_term)
|
||||
]
|
||||
|
||||
if search.isdigit():
|
||||
conditions.append(User.telegram_id == int(search))
|
||||
|
||||
query = query.where(or_(*conditions))
|
||||
|
||||
query = query.order_by(User.created_at.desc()).offset(offset).limit(limit)
|
||||
|
||||
result = await db.execute(query)
|
||||
return result.scalars().all()
|
||||
|
||||
|
||||
async def get_users_count(
|
||||
db: AsyncSession,
|
||||
status: Optional[UserStatus] = None,
|
||||
search: Optional[str] = None
|
||||
) -> int:
|
||||
|
||||
query = select(func.count(User.id))
|
||||
|
||||
if status:
|
||||
query = query.where(User.status == status.value)
|
||||
|
||||
if search:
|
||||
search_term = f"%{search}%"
|
||||
conditions = [
|
||||
User.first_name.ilike(search_term),
|
||||
User.last_name.ilike(search_term),
|
||||
User.username.ilike(search_term)
|
||||
]
|
||||
|
||||
if search.isdigit():
|
||||
conditions.append(User.telegram_id == int(search))
|
||||
|
||||
query = query.where(or_(*conditions))
|
||||
|
||||
result = await db.execute(query)
|
||||
return result.scalar()
|
||||
|
||||
|
||||
async def get_referrals(db: AsyncSession, user_id: int) -> List[User]:
|
||||
result = await db.execute(
|
||||
select(User)
|
||||
.options(selectinload(User.subscription))
|
||||
.where(User.referred_by_id == user_id)
|
||||
.order_by(User.created_at.desc())
|
||||
)
|
||||
return result.scalars().all()
|
||||
|
||||
|
||||
async def get_inactive_users(db: AsyncSession, months: int = 3) -> List[User]:
|
||||
threshold_date = datetime.utcnow() - timedelta(days=months * 30)
|
||||
|
||||
result = await db.execute(
|
||||
select(User)
|
||||
.options(selectinload(User.subscription))
|
||||
.where(
|
||||
and_(
|
||||
User.last_activity < threshold_date,
|
||||
User.status == UserStatus.ACTIVE.value
|
||||
)
|
||||
)
|
||||
)
|
||||
return result.scalars().all()
|
||||
|
||||
|
||||
async def delete_user(db: AsyncSession, user: User) -> bool:
|
||||
user.status = UserStatus.DELETED.value
|
||||
user.updated_at = datetime.utcnow()
|
||||
|
||||
await db.commit()
|
||||
logger.info(f"🗑️ Пользователь {user.telegram_id} помечен как удаленный")
|
||||
return True
|
||||
|
||||
|
||||
async def get_users_statistics(db: AsyncSession) -> dict:
|
||||
|
||||
total_result = await db.execute(select(func.count(User.id)))
|
||||
total_users = total_result.scalar()
|
||||
|
||||
active_result = await db.execute(
|
||||
select(func.count(User.id)).where(User.status == UserStatus.ACTIVE.value)
|
||||
)
|
||||
active_users = active_result.scalar()
|
||||
|
||||
today = datetime.utcnow().date()
|
||||
today_result = await db.execute(
|
||||
select(func.count(User.id)).where(
|
||||
and_(
|
||||
User.created_at >= today,
|
||||
User.status == UserStatus.ACTIVE.value
|
||||
)
|
||||
)
|
||||
)
|
||||
new_today = today_result.scalar()
|
||||
|
||||
week_ago = datetime.utcnow() - timedelta(days=7)
|
||||
week_result = await db.execute(
|
||||
select(func.count(User.id)).where(
|
||||
and_(
|
||||
User.created_at >= week_ago,
|
||||
User.status == UserStatus.ACTIVE.value
|
||||
)
|
||||
)
|
||||
)
|
||||
new_week = week_result.scalar()
|
||||
|
||||
month_ago = datetime.utcnow() - timedelta(days=30)
|
||||
month_result = await db.execute(
|
||||
select(func.count(User.id)).where(
|
||||
and_(
|
||||
User.created_at >= month_ago,
|
||||
User.status == UserStatus.ACTIVE.value
|
||||
)
|
||||
)
|
||||
)
|
||||
new_month = month_result.scalar()
|
||||
|
||||
return {
|
||||
"total_users": total_users,
|
||||
"active_users": active_users,
|
||||
"blocked_users": total_users - active_users,
|
||||
"new_today": new_today,
|
||||
"new_week": new_week,
|
||||
"new_month": new_month
|
||||
}
|
||||
@@ -0,0 +1,276 @@
|
||||
import logging
|
||||
from typing import Optional, List
|
||||
from datetime import datetime
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy import select, update, and_
|
||||
from sqlalchemy.orm import selectinload
|
||||
|
||||
from app.database.models import YooKassaPayment, User, Transaction
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
async def create_yookassa_payment(
|
||||
db: AsyncSession,
|
||||
user_id: int,
|
||||
yookassa_payment_id: str,
|
||||
amount_kopeks: int,
|
||||
currency: str,
|
||||
description: str,
|
||||
status: str,
|
||||
confirmation_url: Optional[str] = None,
|
||||
metadata_json: Optional[dict] = None,
|
||||
payment_method_type: Optional[str] = None,
|
||||
yookassa_created_at: Optional[datetime] = None,
|
||||
test_mode: bool = False
|
||||
) -> YooKassaPayment:
|
||||
|
||||
payment = YooKassaPayment(
|
||||
user_id=user_id,
|
||||
yookassa_payment_id=yookassa_payment_id,
|
||||
amount_kopeks=amount_kopeks,
|
||||
currency=currency,
|
||||
description=description,
|
||||
status=status,
|
||||
confirmation_url=confirmation_url,
|
||||
metadata_json=metadata_json,
|
||||
payment_method_type=payment_method_type,
|
||||
yookassa_created_at=yookassa_created_at,
|
||||
test_mode=test_mode
|
||||
)
|
||||
|
||||
db.add(payment)
|
||||
await db.commit()
|
||||
await db.refresh(payment)
|
||||
|
||||
logger.info(f"Создан платеж YooKassa: {yookassa_payment_id} на {amount_kopeks/100}₽ для пользователя {user_id}")
|
||||
return payment
|
||||
|
||||
|
||||
async def get_yookassa_payment_by_id(
|
||||
db: AsyncSession,
|
||||
yookassa_payment_id: str
|
||||
) -> Optional[YooKassaPayment]:
|
||||
|
||||
result = await db.execute(
|
||||
select(YooKassaPayment)
|
||||
.options(selectinload(YooKassaPayment.user))
|
||||
.where(YooKassaPayment.yookassa_payment_id == yookassa_payment_id)
|
||||
)
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
|
||||
async def get_yookassa_payment_by_local_id(
|
||||
db: AsyncSession,
|
||||
local_id: int
|
||||
) -> Optional[YooKassaPayment]:
|
||||
|
||||
result = await db.execute(
|
||||
select(YooKassaPayment)
|
||||
.options(selectinload(YooKassaPayment.user))
|
||||
.where(YooKassaPayment.id == local_id)
|
||||
)
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
|
||||
async def update_yookassa_payment_status(
|
||||
db: AsyncSession,
|
||||
yookassa_payment_id: str,
|
||||
status: str,
|
||||
is_paid: bool = False,
|
||||
is_captured: bool = False,
|
||||
captured_at: Optional[datetime] = None,
|
||||
payment_method_type: Optional[str] = None
|
||||
) -> Optional[YooKassaPayment]:
|
||||
|
||||
update_data = {
|
||||
"status": status,
|
||||
"is_paid": is_paid,
|
||||
"is_captured": is_captured,
|
||||
"updated_at": datetime.utcnow()
|
||||
}
|
||||
|
||||
if captured_at:
|
||||
update_data["captured_at"] = captured_at
|
||||
|
||||
if payment_method_type:
|
||||
update_data["payment_method_type"] = payment_method_type
|
||||
|
||||
await db.execute(
|
||||
update(YooKassaPayment)
|
||||
.where(YooKassaPayment.yookassa_payment_id == yookassa_payment_id)
|
||||
.values(**update_data)
|
||||
)
|
||||
await db.commit()
|
||||
|
||||
result = await db.execute(
|
||||
select(YooKassaPayment)
|
||||
.options(selectinload(YooKassaPayment.user))
|
||||
.where(YooKassaPayment.yookassa_payment_id == yookassa_payment_id)
|
||||
)
|
||||
payment = result.scalar_one_or_none()
|
||||
|
||||
if payment:
|
||||
logger.info(f"Обновлен статус платежа YooKassa {yookassa_payment_id}: {status}, paid={is_paid}")
|
||||
|
||||
return payment
|
||||
|
||||
|
||||
async def link_yookassa_payment_to_transaction(
|
||||
db: AsyncSession,
|
||||
yookassa_payment_id: str,
|
||||
transaction_id: int
|
||||
) -> Optional[YooKassaPayment]:
|
||||
|
||||
await db.execute(
|
||||
update(YooKassaPayment)
|
||||
.where(YooKassaPayment.yookassa_payment_id == yookassa_payment_id)
|
||||
.values(transaction_id=transaction_id, updated_at=datetime.utcnow())
|
||||
)
|
||||
await db.commit()
|
||||
|
||||
result = await db.execute(
|
||||
select(YooKassaPayment)
|
||||
.options(selectinload(YooKassaPayment.user), selectinload(YooKassaPayment.transaction))
|
||||
.where(YooKassaPayment.yookassa_payment_id == yookassa_payment_id)
|
||||
)
|
||||
payment = result.scalar_one_or_none()
|
||||
|
||||
if payment:
|
||||
logger.info(f"Платеж YooKassa {yookassa_payment_id} связан с транзакцией {transaction_id}")
|
||||
|
||||
return payment
|
||||
|
||||
|
||||
async def get_user_yookassa_payments(
|
||||
db: AsyncSession,
|
||||
user_id: int,
|
||||
limit: int = 50,
|
||||
offset: int = 0
|
||||
) -> List[YooKassaPayment]:
|
||||
|
||||
result = await db.execute(
|
||||
select(YooKassaPayment)
|
||||
.options(selectinload(YooKassaPayment.transaction))
|
||||
.where(YooKassaPayment.user_id == user_id)
|
||||
.order_by(YooKassaPayment.created_at.desc())
|
||||
.limit(limit)
|
||||
.offset(offset)
|
||||
)
|
||||
return result.scalars().all()
|
||||
|
||||
|
||||
async def get_pending_yookassa_payments(
|
||||
db: AsyncSession,
|
||||
user_id: Optional[int] = None,
|
||||
limit: int = 100
|
||||
) -> List[YooKassaPayment]:
|
||||
|
||||
query = select(YooKassaPayment).options(selectinload(YooKassaPayment.user))
|
||||
|
||||
conditions = [YooKassaPayment.status.in_(["pending", "waiting_for_capture"])]
|
||||
if user_id:
|
||||
conditions.append(YooKassaPayment.user_id == user_id)
|
||||
|
||||
result = await db.execute(
|
||||
query.where(and_(*conditions))
|
||||
.order_by(YooKassaPayment.created_at.desc())
|
||||
.limit(limit)
|
||||
)
|
||||
return result.scalars().all()
|
||||
|
||||
|
||||
async def get_succeeded_yookassa_payments_without_transaction(
|
||||
db: AsyncSession,
|
||||
limit: int = 50
|
||||
) -> List[YooKassaPayment]:
|
||||
|
||||
result = await db.execute(
|
||||
select(YooKassaPayment)
|
||||
.options(selectinload(YooKassaPayment.user))
|
||||
.where(
|
||||
and_(
|
||||
YooKassaPayment.status == "succeeded",
|
||||
YooKassaPayment.is_paid == True,
|
||||
YooKassaPayment.transaction_id == None
|
||||
)
|
||||
)
|
||||
.order_by(YooKassaPayment.captured_at.desc())
|
||||
.limit(limit)
|
||||
)
|
||||
return result.scalars().all()
|
||||
|
||||
|
||||
async def delete_yookassa_payment(
|
||||
db: AsyncSession,
|
||||
yookassa_payment_id: str
|
||||
) -> bool:
|
||||
|
||||
result = await db.execute(
|
||||
select(YooKassaPayment)
|
||||
.where(YooKassaPayment.yookassa_payment_id == yookassa_payment_id)
|
||||
)
|
||||
payment = result.scalar_one_or_none()
|
||||
|
||||
if payment:
|
||||
await db.delete(payment)
|
||||
await db.commit()
|
||||
logger.info(f"Удален платеж YooKassa: {yookassa_payment_id}")
|
||||
return True
|
||||
|
||||
return False
|
||||
|
||||
|
||||
async def get_yookassa_payments_stats(
|
||||
db: AsyncSession,
|
||||
user_id: Optional[int] = None
|
||||
) -> dict:
|
||||
|
||||
from sqlalchemy import func, case
|
||||
|
||||
query = select(
|
||||
func.count(YooKassaPayment.id).label('total_payments'),
|
||||
func.sum(YooKassaPayment.amount_kopeks).label('total_amount_kopeks'),
|
||||
func.sum(
|
||||
case(
|
||||
(YooKassaPayment.status == 'succeeded', YooKassaPayment.amount_kopeks),
|
||||
else_=0
|
||||
)
|
||||
).label('succeeded_amount_kopeks'),
|
||||
func.count(
|
||||
case(
|
||||
(YooKassaPayment.status == 'succeeded', 1),
|
||||
else_=None
|
||||
)
|
||||
).label('succeeded_count'),
|
||||
func.count(
|
||||
case(
|
||||
(YooKassaPayment.status == 'pending', 1),
|
||||
else_=None
|
||||
)
|
||||
).label('pending_count'),
|
||||
func.count(
|
||||
case(
|
||||
(YooKassaPayment.status.in_(['canceled', 'failed']), 1),
|
||||
else_=None
|
||||
)
|
||||
).label('failed_count')
|
||||
).select_from(YooKassaPayment)
|
||||
|
||||
if user_id:
|
||||
query = query.where(YooKassaPayment.user_id == user_id)
|
||||
|
||||
result = await db.execute(query)
|
||||
stats = result.first()
|
||||
|
||||
return {
|
||||
'total_payments': stats.total_payments or 0,
|
||||
'total_amount_kopeks': stats.total_amount_kopeks or 0,
|
||||
'total_amount_rubles': (stats.total_amount_kopeks or 0) / 100,
|
||||
'succeeded_amount_kopeks': stats.succeeded_amount_kopeks or 0,
|
||||
'succeeded_amount_rubles': (stats.succeeded_amount_kopeks or 0) / 100,
|
||||
'succeeded_count': stats.succeeded_count or 0,
|
||||
'pending_count': stats.pending_count or 0,
|
||||
'failed_count': stats.failed_count or 0,
|
||||
'success_rate': (stats.succeeded_count / stats.total_payments * 100) if stats.total_payments > 0 else 0
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
import logging
|
||||
from typing import AsyncGenerator
|
||||
|
||||
from sqlalchemy.ext.asyncio import AsyncSession, create_async_engine, async_sessionmaker
|
||||
from sqlalchemy.pool import NullPool
|
||||
|
||||
from app.config import settings
|
||||
from app.database.models import Base
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
engine = create_async_engine(
|
||||
settings.DATABASE_URL,
|
||||
poolclass=NullPool,
|
||||
echo=settings.DEBUG,
|
||||
future=True
|
||||
)
|
||||
|
||||
AsyncSessionLocal = async_sessionmaker(
|
||||
bind=engine,
|
||||
class_=AsyncSession,
|
||||
expire_on_commit=False,
|
||||
autoflush=True,
|
||||
autocommit=False
|
||||
)
|
||||
|
||||
|
||||
async def get_db() -> AsyncGenerator[AsyncSession, None]:
|
||||
async with AsyncSessionLocal() as session:
|
||||
try:
|
||||
yield session
|
||||
await session.commit()
|
||||
except Exception:
|
||||
await session.rollback()
|
||||
raise
|
||||
finally:
|
||||
await session.close()
|
||||
|
||||
|
||||
async def init_db():
|
||||
logger.info("Создание таблиц базы данных...")
|
||||
|
||||
async with engine.begin() as conn:
|
||||
await conn.run_sync(Base.metadata.create_all)
|
||||
|
||||
logger.info("✅ База данных успешно инициализирована")
|
||||
|
||||
|
||||
async def close_db():
|
||||
await engine.dispose()
|
||||
logger.info("✅ Подключение к базе данных закрыто")
|
||||
@@ -0,0 +1,584 @@
|
||||
from datetime import datetime, timedelta
|
||||
from typing import Optional, List
|
||||
from enum import Enum
|
||||
|
||||
from sqlalchemy import (
|
||||
Column, Integer, String, DateTime, Boolean, Text,
|
||||
ForeignKey, Float, JSON, BigInteger
|
||||
)
|
||||
from sqlalchemy.ext.declarative import declarative_base
|
||||
from sqlalchemy.orm import relationship, Mapped, mapped_column
|
||||
from sqlalchemy.sql import func
|
||||
|
||||
|
||||
Base = declarative_base()
|
||||
|
||||
|
||||
class UserStatus(Enum):
|
||||
ACTIVE = "active"
|
||||
BLOCKED = "blocked"
|
||||
DELETED = "deleted"
|
||||
|
||||
|
||||
class SubscriptionStatus(Enum):
|
||||
TRIAL = "trial"
|
||||
ACTIVE = "active"
|
||||
EXPIRED = "expired"
|
||||
DISABLED = "disabled"
|
||||
|
||||
|
||||
class TransactionType(Enum):
|
||||
DEPOSIT = "deposit"
|
||||
WITHDRAWAL = "withdrawal"
|
||||
SUBSCRIPTION_PAYMENT = "subscription_payment"
|
||||
REFUND = "refund"
|
||||
REFERRAL_REWARD = "referral_reward"
|
||||
|
||||
|
||||
class PromoCodeType(Enum):
|
||||
BALANCE = "balance"
|
||||
SUBSCRIPTION_DAYS = "subscription_days"
|
||||
TRIAL_SUBSCRIPTION = "trial_subscription"
|
||||
|
||||
|
||||
class PaymentMethod(Enum):
|
||||
TELEGRAM_STARS = "telegram_stars"
|
||||
TRIBUTE = "tribute"
|
||||
YOOKASSA = "yookassa"
|
||||
MANUAL = "manual"
|
||||
|
||||
class YooKassaPayment(Base):
|
||||
__tablename__ = "yookassa_payments"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
user_id = Column(Integer, ForeignKey("users.id"), nullable=False)
|
||||
yookassa_payment_id = Column(String(255), unique=True, nullable=False, index=True)
|
||||
amount_kopeks = Column(Integer, nullable=False)
|
||||
currency = Column(String(3), default="RUB", nullable=False)
|
||||
description = Column(Text, nullable=True)
|
||||
status = Column(String(50), nullable=False)
|
||||
is_paid = Column(Boolean, default=False)
|
||||
is_captured = Column(Boolean, default=False)
|
||||
confirmation_url = Column(Text, nullable=True)
|
||||
metadata_json = Column(JSON, nullable=True)
|
||||
transaction_id = Column(Integer, ForeignKey("transactions.id"), nullable=True)
|
||||
payment_method_type = Column(String(50), nullable=True)
|
||||
refundable = Column(Boolean, default=False)
|
||||
test_mode = Column(Boolean, default=False)
|
||||
created_at = Column(DateTime, default=func.now())
|
||||
updated_at = Column(DateTime, default=func.now(), onupdate=func.now())
|
||||
yookassa_created_at = Column(DateTime, nullable=True)
|
||||
captured_at = Column(DateTime, nullable=True)
|
||||
user = relationship("User", backref="yookassa_payments")
|
||||
transaction = relationship("Transaction", backref="yookassa_payment")
|
||||
|
||||
@property
|
||||
def amount_rubles(self) -> float:
|
||||
return self.amount_kopeks / 100
|
||||
|
||||
@property
|
||||
def is_pending(self) -> bool:
|
||||
return self.status == "pending"
|
||||
|
||||
@property
|
||||
def is_succeeded(self) -> bool:
|
||||
return self.status == "succeeded" and self.is_paid
|
||||
|
||||
@property
|
||||
def is_failed(self) -> bool:
|
||||
return self.status in ["canceled", "failed"]
|
||||
|
||||
@property
|
||||
def can_be_captured(self) -> bool:
|
||||
return self.status == "waiting_for_capture"
|
||||
|
||||
def __repr__(self):
|
||||
return f"<YooKassaPayment(id={self.id}, yookassa_id={self.yookassa_payment_id}, amount={self.amount_rubles}₽, status={self.status})>"
|
||||
|
||||
|
||||
class User(Base):
|
||||
__tablename__ = "users"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
telegram_id = Column(BigInteger, unique=True, index=True, nullable=False)
|
||||
username = Column(String(255), nullable=True)
|
||||
first_name = Column(String(255), nullable=True)
|
||||
last_name = Column(String(255), nullable=True)
|
||||
status = Column(String(20), default=UserStatus.ACTIVE.value)
|
||||
language = Column(String(5), default="ru")
|
||||
balance_kopeks = Column(Integer, default=0)
|
||||
used_promocodes = Column(Integer, default=0)
|
||||
has_had_paid_subscription = Column(Boolean, default=False, nullable=False)
|
||||
referred_by_id = Column(Integer, ForeignKey("users.id"), nullable=True)
|
||||
referral_code = Column(String(20), unique=True, nullable=True)
|
||||
created_at = Column(DateTime, default=func.now())
|
||||
updated_at = Column(DateTime, default=func.now(), onupdate=func.now())
|
||||
last_activity = Column(DateTime, default=func.now())
|
||||
remnawave_uuid = Column(String(255), nullable=True, unique=True)
|
||||
broadcasts = relationship("BroadcastHistory", back_populates="admin")
|
||||
referrals = relationship("User", backref="referrer", remote_side=[id], foreign_keys="User.referred_by_id")
|
||||
subscription = relationship("Subscription", back_populates="user", uselist=False)
|
||||
transactions = relationship("Transaction", back_populates="user")
|
||||
referral_earnings = relationship("ReferralEarning", foreign_keys="ReferralEarning.user_id", back_populates="user")
|
||||
lifetime_used_traffic_bytes = Column(BigInteger, default=0)
|
||||
last_remnawave_sync = Column(DateTime, nullable=True)
|
||||
trojan_password = Column(String(255), nullable=True)
|
||||
vless_uuid = Column(String(255), nullable=True)
|
||||
ss_password = Column(String(255), nullable=True)
|
||||
has_made_first_topup: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
|
||||
|
||||
@property
|
||||
def balance_rubles(self) -> float:
|
||||
return self.balance_kopeks / 100
|
||||
|
||||
@property
|
||||
def full_name(self) -> str:
|
||||
parts = [self.first_name, self.last_name]
|
||||
return " ".join(filter(None, parts)) or self.username or f"ID{self.telegram_id}"
|
||||
|
||||
def add_balance(self, kopeks: int) -> None:
|
||||
self.balance_kopeks += kopeks
|
||||
|
||||
def subtract_balance(self, kopeks: int) -> bool:
|
||||
if self.balance_kopeks >= kopeks:
|
||||
self.balance_kopeks -= kopeks
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
class Subscription(Base):
|
||||
__tablename__ = "subscriptions"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
user_id = Column(Integer, ForeignKey("users.id"), nullable=False, unique=True)
|
||||
|
||||
status = Column(String(20), default=SubscriptionStatus.TRIAL.value)
|
||||
is_trial = Column(Boolean, default=True)
|
||||
|
||||
start_date = Column(DateTime, default=func.now())
|
||||
end_date = Column(DateTime, nullable=False)
|
||||
|
||||
traffic_limit_gb = Column(Integer, default=0)
|
||||
traffic_used_gb = Column(Float, default=0.0)
|
||||
|
||||
subscription_url = Column(String, nullable=True)
|
||||
|
||||
device_limit = Column(Integer, default=1)
|
||||
|
||||
connected_squads = Column(JSON, default=list)
|
||||
|
||||
autopay_enabled = Column(Boolean, default=False)
|
||||
autopay_days_before = Column(Integer, default=3)
|
||||
|
||||
created_at = Column(DateTime, default=func.now())
|
||||
updated_at = Column(DateTime, default=func.now(), onupdate=func.now())
|
||||
|
||||
remnawave_short_uuid = Column(String(255), nullable=True)
|
||||
|
||||
user = relationship("User", back_populates="subscription")
|
||||
|
||||
@property
|
||||
def is_active(self) -> bool:
|
||||
current_time = datetime.utcnow()
|
||||
return (
|
||||
self.status == SubscriptionStatus.ACTIVE.value and
|
||||
self.end_date > current_time
|
||||
)
|
||||
|
||||
@property
|
||||
def is_expired(self) -> bool:
|
||||
"""Проверяет, истёк ли срок подписки"""
|
||||
return self.end_date <= datetime.utcnow()
|
||||
|
||||
@property
|
||||
def should_be_expired(self) -> bool:
|
||||
current_time = datetime.utcnow()
|
||||
return (
|
||||
self.status == SubscriptionStatus.ACTIVE.value and
|
||||
self.end_date <= current_time
|
||||
)
|
||||
|
||||
@property
|
||||
def actual_status(self) -> str:
|
||||
current_time = datetime.utcnow()
|
||||
|
||||
if self.status == SubscriptionStatus.EXPIRED.value:
|
||||
return "expired"
|
||||
|
||||
if self.status == SubscriptionStatus.DISABLED.value:
|
||||
return "disabled"
|
||||
|
||||
if self.status == SubscriptionStatus.ACTIVE.value:
|
||||
if self.end_date <= current_time:
|
||||
return "expired"
|
||||
else:
|
||||
return "active"
|
||||
|
||||
if self.status == SubscriptionStatus.TRIAL.value:
|
||||
if self.end_date <= current_time:
|
||||
return "expired"
|
||||
else:
|
||||
return "trial"
|
||||
|
||||
return self.status
|
||||
|
||||
@property
|
||||
def status_display(self) -> str:
|
||||
actual_status = self.actual_status
|
||||
current_time = datetime.utcnow()
|
||||
|
||||
if actual_status == "expired":
|
||||
return "🔴 Истекла"
|
||||
elif actual_status == "active":
|
||||
if self.is_trial:
|
||||
return "🎯 Тестовая"
|
||||
else:
|
||||
return "🟢 Активна"
|
||||
elif actual_status == "disabled":
|
||||
return "⚫ Отключена"
|
||||
elif actual_status == "trial":
|
||||
return "🎯 Тестовая"
|
||||
|
||||
return "❓ Неизвестно"
|
||||
|
||||
@property
|
||||
def status_emoji(self) -> str:
|
||||
actual_status = self.actual_status
|
||||
|
||||
if actual_status == "expired":
|
||||
return "🔴"
|
||||
elif actual_status == "active":
|
||||
if self.is_trial:
|
||||
return "🎁"
|
||||
else:
|
||||
return "💎"
|
||||
elif actual_status == "disabled":
|
||||
return "⚫"
|
||||
elif actual_status == "trial":
|
||||
return "🎁"
|
||||
|
||||
return "❓"
|
||||
|
||||
@property
|
||||
def days_left(self) -> int:
|
||||
current_time = datetime.utcnow()
|
||||
if self.end_date <= current_time:
|
||||
return 0
|
||||
delta = self.end_date - current_time
|
||||
return max(0, delta.days)
|
||||
|
||||
@property
|
||||
def time_left_display(self) -> str:
|
||||
current_time = datetime.utcnow()
|
||||
if self.end_date <= current_time:
|
||||
return "истёк"
|
||||
|
||||
delta = self.end_date - current_time
|
||||
days = delta.days
|
||||
hours = delta.seconds // 3600
|
||||
minutes = (delta.seconds % 3600) // 60
|
||||
|
||||
if days > 0:
|
||||
return f"{days} дн."
|
||||
elif hours > 0:
|
||||
return f"{hours} ч."
|
||||
else:
|
||||
return f"{minutes} мин."
|
||||
|
||||
@property
|
||||
def traffic_used_percent(self) -> float:
|
||||
if self.traffic_limit_gb == 0:
|
||||
return 0.0
|
||||
if self.traffic_limit_gb > 0:
|
||||
return min((self.traffic_used_gb / self.traffic_limit_gb) * 100, 100.0)
|
||||
return 0.0
|
||||
|
||||
def extend_subscription(self, days: int):
|
||||
from datetime import timedelta, datetime
|
||||
|
||||
if self.end_date > datetime.utcnow():
|
||||
self.end_date = self.end_date + timedelta(days=days)
|
||||
else:
|
||||
self.end_date = datetime.utcnow() + timedelta(days=days)
|
||||
|
||||
if self.status == SubscriptionStatus.EXPIRED.value:
|
||||
self.status = SubscriptionStatus.ACTIVE.value
|
||||
|
||||
def add_traffic(self, gb: int):
|
||||
if self.traffic_limit_gb == 0:
|
||||
return
|
||||
self.traffic_limit_gb += gb
|
||||
|
||||
|
||||
class Transaction(Base):
|
||||
__tablename__ = "transactions"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
user_id = Column(Integer, ForeignKey("users.id"), nullable=False)
|
||||
|
||||
type = Column(String(50), nullable=False)
|
||||
amount_kopeks = Column(Integer, nullable=False)
|
||||
description = Column(Text, nullable=True)
|
||||
|
||||
payment_method = Column(String(50), nullable=True)
|
||||
external_id = Column(String(255), nullable=True)
|
||||
|
||||
is_completed = Column(Boolean, default=True)
|
||||
|
||||
created_at = Column(DateTime, default=func.now())
|
||||
completed_at = Column(DateTime, nullable=True)
|
||||
|
||||
user = relationship("User", back_populates="transactions")
|
||||
|
||||
@property
|
||||
def amount_rubles(self) -> float:
|
||||
return self.amount_kopeks / 100
|
||||
|
||||
class SubscriptionConversion(Base):
|
||||
__tablename__ = "subscription_conversions"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
user_id = Column(Integer, ForeignKey("users.id"), nullable=False)
|
||||
|
||||
converted_at = Column(DateTime, default=func.now())
|
||||
|
||||
trial_duration_days = Column(Integer, nullable=True)
|
||||
|
||||
payment_method = Column(String(50), nullable=True)
|
||||
|
||||
first_payment_amount_kopeks = Column(Integer, nullable=True)
|
||||
|
||||
first_paid_period_days = Column(Integer, nullable=True)
|
||||
|
||||
created_at = Column(DateTime, default=func.now())
|
||||
|
||||
user = relationship("User", backref="subscription_conversions")
|
||||
|
||||
@property
|
||||
def first_payment_amount_rubles(self) -> float:
|
||||
return (self.first_payment_amount_kopeks or 0) / 100
|
||||
|
||||
def __repr__(self):
|
||||
return f"<SubscriptionConversion(user_id={self.user_id}, converted_at={self.converted_at})>"
|
||||
|
||||
|
||||
class PromoCode(Base):
|
||||
__tablename__ = "promocodes"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
|
||||
code = Column(String(50), unique=True, nullable=False, index=True)
|
||||
type = Column(String(50), nullable=False)
|
||||
|
||||
balance_bonus_kopeks = Column(Integer, default=0)
|
||||
subscription_days = Column(Integer, default=0)
|
||||
|
||||
max_uses = Column(Integer, default=1)
|
||||
current_uses = Column(Integer, default=0)
|
||||
|
||||
valid_from = Column(DateTime, default=func.now())
|
||||
valid_until = Column(DateTime, nullable=True)
|
||||
|
||||
is_active = Column(Boolean, default=True)
|
||||
|
||||
created_by = Column(Integer, ForeignKey("users.id"), nullable=True)
|
||||
|
||||
created_at = Column(DateTime, default=func.now())
|
||||
updated_at = Column(DateTime, default=func.now(), onupdate=func.now())
|
||||
|
||||
uses = relationship("PromoCodeUse", back_populates="promocode")
|
||||
|
||||
@property
|
||||
def is_valid(self) -> bool:
|
||||
now = datetime.utcnow()
|
||||
return (
|
||||
self.is_active and
|
||||
self.current_uses < self.max_uses and
|
||||
self.valid_from <= now and
|
||||
(self.valid_until is None or self.valid_until >= now)
|
||||
)
|
||||
|
||||
@property
|
||||
def uses_left(self) -> int:
|
||||
return max(0, self.max_uses - self.current_uses)
|
||||
|
||||
|
||||
class PromoCodeUse(Base):
|
||||
__tablename__ = "promocode_uses"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
promocode_id = Column(Integer, ForeignKey("promocodes.id"), nullable=False)
|
||||
user_id = Column(Integer, ForeignKey("users.id"), nullable=False)
|
||||
|
||||
used_at = Column(DateTime, default=func.now())
|
||||
|
||||
promocode = relationship("PromoCode", back_populates="uses")
|
||||
user = relationship("User")
|
||||
|
||||
|
||||
class ReferralEarning(Base):
|
||||
__tablename__ = "referral_earnings"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
user_id = Column(Integer, ForeignKey("users.id"), nullable=False)
|
||||
referral_id = Column(Integer, ForeignKey("users.id"), nullable=False)
|
||||
|
||||
amount_kopeks = Column(Integer, nullable=False)
|
||||
reason = Column(String(100), nullable=False)
|
||||
|
||||
referral_transaction_id = Column(Integer, ForeignKey("transactions.id"), nullable=True)
|
||||
|
||||
created_at = Column(DateTime, default=func.now())
|
||||
|
||||
user = relationship("User", foreign_keys=[user_id], back_populates="referral_earnings")
|
||||
referral = relationship("User", foreign_keys=[referral_id])
|
||||
referral_transaction = relationship("Transaction")
|
||||
|
||||
@property
|
||||
def amount_rubles(self) -> float:
|
||||
return self.amount_kopeks / 100
|
||||
|
||||
|
||||
class Squad(Base):
|
||||
__tablename__ = "squads"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
|
||||
uuid = Column(String(255), unique=True, nullable=False)
|
||||
name = Column(String(255), nullable=False)
|
||||
country_code = Column(String(5), nullable=True)
|
||||
|
||||
is_available = Column(Boolean, default=True)
|
||||
price_kopeks = Column(Integer, default=0)
|
||||
|
||||
description = Column(Text, nullable=True)
|
||||
|
||||
created_at = Column(DateTime, default=func.now())
|
||||
updated_at = Column(DateTime, default=func.now(), onupdate=func.now())
|
||||
|
||||
@property
|
||||
def price_rubles(self) -> float:
|
||||
return self.price_kopeks / 100
|
||||
|
||||
|
||||
class ServiceRule(Base):
|
||||
__tablename__ = "service_rules"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
|
||||
order = Column(Integer, default=0)
|
||||
title = Column(String(255), nullable=False)
|
||||
|
||||
content = Column(Text, nullable=False)
|
||||
|
||||
is_active = Column(Boolean, default=True)
|
||||
|
||||
language = Column(String(5), default="ru")
|
||||
|
||||
created_at = Column(DateTime, default=func.now())
|
||||
updated_at = Column(DateTime, default=func.now(), onupdate=func.now())
|
||||
|
||||
|
||||
class SystemSetting(Base):
|
||||
__tablename__ = "system_settings"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
key = Column(String(255), unique=True, nullable=False)
|
||||
value = Column(Text, nullable=True)
|
||||
description = Column(Text, nullable=True)
|
||||
|
||||
created_at = Column(DateTime, default=func.now())
|
||||
updated_at = Column(DateTime, default=func.now(), onupdate=func.now())
|
||||
|
||||
|
||||
class MonitoringLog(Base):
|
||||
__tablename__ = "monitoring_logs"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
|
||||
event_type = Column(String(100), nullable=False)
|
||||
|
||||
message = Column(Text, nullable=False)
|
||||
data = Column(JSON, nullable=True)
|
||||
|
||||
is_success = Column(Boolean, default=True)
|
||||
|
||||
created_at = Column(DateTime, default=func.now())
|
||||
|
||||
class BroadcastHistory(Base):
|
||||
__tablename__ = "broadcast_history"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
target_type = Column(String(100), nullable=False)
|
||||
message_text = Column(Text, nullable=False)
|
||||
total_count = Column(Integer, default=0)
|
||||
sent_count = Column(Integer, default=0)
|
||||
failed_count = Column(Integer, default=0)
|
||||
status = Column(String(50), default="in_progress")
|
||||
admin_id = Column(Integer, ForeignKey("users.id"))
|
||||
admin_name = Column(String(255))
|
||||
created_at = Column(DateTime(timezone=True), server_default=func.now())
|
||||
completed_at = Column(DateTime(timezone=True), nullable=True)
|
||||
|
||||
admin = relationship("User", back_populates="broadcasts")
|
||||
|
||||
class ServerSquad(Base):
|
||||
__tablename__ = "server_squads"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
|
||||
squad_uuid = Column(String(255), unique=True, nullable=False, index=True)
|
||||
|
||||
display_name = Column(String(255), nullable=False)
|
||||
|
||||
original_name = Column(String(255), nullable=True)
|
||||
|
||||
country_code = Column(String(5), nullable=True)
|
||||
|
||||
is_available = Column(Boolean, default=True)
|
||||
|
||||
price_kopeks = Column(Integer, default=0)
|
||||
|
||||
description = Column(Text, nullable=True)
|
||||
|
||||
sort_order = Column(Integer, default=0)
|
||||
|
||||
max_users = Column(Integer, nullable=True)
|
||||
current_users = Column(Integer, default=0)
|
||||
|
||||
created_at = Column(DateTime, default=func.now())
|
||||
updated_at = Column(DateTime, default=func.now(), onupdate=func.now())
|
||||
|
||||
@property
|
||||
def price_rubles(self) -> float:
|
||||
return self.price_kopeks / 100
|
||||
|
||||
@property
|
||||
def is_full(self) -> bool:
|
||||
if self.max_users is None:
|
||||
return False
|
||||
return self.current_users >= self.max_users
|
||||
|
||||
@property
|
||||
def availability_status(self) -> str:
|
||||
if not self.is_available:
|
||||
return "Недоступен"
|
||||
elif self.is_full:
|
||||
return "Переполнен"
|
||||
else:
|
||||
return "Доступен"
|
||||
|
||||
|
||||
class SubscriptionServer(Base):
|
||||
__tablename__ = "subscription_servers"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
subscription_id = Column(Integer, ForeignKey("subscriptions.id"), nullable=False)
|
||||
server_squad_id = Column(Integer, ForeignKey("server_squads.id"), nullable=False)
|
||||
|
||||
connected_at = Column(DateTime, default=func.now())
|
||||
|
||||
paid_price_kopeks = Column(Integer, default=0)
|
||||
|
||||
subscription = relationship("Subscription", backref="subscription_servers")
|
||||
server_squad = relationship("ServerSquad", backref="subscription_servers")
|
||||
@@ -0,0 +1,594 @@
|
||||
import logging
|
||||
from sqlalchemy import text, inspect
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from app.database.database import engine
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
async def get_database_type():
|
||||
return engine.dialect.name
|
||||
|
||||
async def check_table_exists(table_name: str) -> bool:
|
||||
try:
|
||||
async with engine.begin() as conn:
|
||||
db_type = await get_database_type()
|
||||
|
||||
if db_type == 'sqlite':
|
||||
result = await conn.execute(text(f"""
|
||||
SELECT name FROM sqlite_master
|
||||
WHERE type='table' AND name='{table_name}'
|
||||
"""))
|
||||
return result.fetchone() is not None
|
||||
|
||||
elif db_type == 'postgresql':
|
||||
result = await conn.execute(text("""
|
||||
SELECT table_name FROM information_schema.tables
|
||||
WHERE table_schema = 'public' AND table_name = :table_name
|
||||
"""), {"table_name": table_name})
|
||||
return result.fetchone() is not None
|
||||
|
||||
elif db_type == 'mysql':
|
||||
result = await conn.execute(text("""
|
||||
SELECT table_name FROM information_schema.tables
|
||||
WHERE table_schema = DATABASE() AND table_name = :table_name
|
||||
"""), {"table_name": table_name})
|
||||
return result.fetchone() is not None
|
||||
|
||||
return False
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка проверки существования таблицы {table_name}: {e}")
|
||||
return False
|
||||
|
||||
async def check_column_exists(table_name: str, column_name: str) -> bool:
|
||||
try:
|
||||
async with engine.begin() as conn:
|
||||
db_type = await get_database_type()
|
||||
|
||||
if db_type == 'sqlite':
|
||||
result = await conn.execute(text(f"PRAGMA table_info({table_name})"))
|
||||
columns = result.fetchall()
|
||||
return any(col[1] == column_name for col in columns)
|
||||
|
||||
elif db_type == 'postgresql':
|
||||
result = await conn.execute(text("""
|
||||
SELECT column_name
|
||||
FROM information_schema.columns
|
||||
WHERE table_name = :table_name
|
||||
AND column_name = :column_name
|
||||
"""), {"table_name": table_name, "column_name": column_name})
|
||||
return result.fetchone() is not None
|
||||
|
||||
elif db_type == 'mysql':
|
||||
result = await conn.execute(text("""
|
||||
SELECT COLUMN_NAME
|
||||
FROM information_schema.COLUMNS
|
||||
WHERE TABLE_NAME = :table_name
|
||||
AND COLUMN_NAME = :column_name
|
||||
"""), {"table_name": table_name, "column_name": column_name})
|
||||
return result.fetchone() is not None
|
||||
|
||||
return False
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка проверки существования колонки {column_name}: {e}")
|
||||
return False
|
||||
|
||||
async def create_yookassa_payments_table():
|
||||
|
||||
table_exists = await check_table_exists('yookassa_payments')
|
||||
if table_exists:
|
||||
logger.info("Таблица yookassa_payments уже существует")
|
||||
return True
|
||||
|
||||
try:
|
||||
async with engine.begin() as conn:
|
||||
db_type = await get_database_type()
|
||||
|
||||
if db_type == 'sqlite':
|
||||
create_sql = """
|
||||
CREATE TABLE yookassa_payments (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id INTEGER NOT NULL,
|
||||
yookassa_payment_id VARCHAR(255) UNIQUE NOT NULL,
|
||||
amount_kopeks INTEGER NOT NULL,
|
||||
currency VARCHAR(3) DEFAULT 'RUB' NOT NULL,
|
||||
description TEXT NULL,
|
||||
status VARCHAR(50) NOT NULL,
|
||||
is_paid BOOLEAN DEFAULT 0,
|
||||
is_captured BOOLEAN DEFAULT 0,
|
||||
confirmation_url TEXT NULL,
|
||||
metadata_json TEXT NULL,
|
||||
transaction_id INTEGER NULL,
|
||||
payment_method_type VARCHAR(50) NULL,
|
||||
refundable BOOLEAN DEFAULT 0,
|
||||
test_mode BOOLEAN DEFAULT 0,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
yookassa_created_at DATETIME NULL,
|
||||
captured_at DATETIME NULL,
|
||||
FOREIGN KEY (user_id) REFERENCES users(id),
|
||||
FOREIGN KEY (transaction_id) REFERENCES transactions(id)
|
||||
);
|
||||
|
||||
CREATE INDEX idx_yookassa_payments_user_id ON yookassa_payments(user_id);
|
||||
CREATE INDEX idx_yookassa_payments_yookassa_id ON yookassa_payments(yookassa_payment_id);
|
||||
CREATE INDEX idx_yookassa_payments_status ON yookassa_payments(status);
|
||||
"""
|
||||
|
||||
elif db_type == 'postgresql':
|
||||
create_sql = """
|
||||
CREATE TABLE yookassa_payments (
|
||||
id SERIAL PRIMARY KEY,
|
||||
user_id INTEGER NOT NULL,
|
||||
yookassa_payment_id VARCHAR(255) UNIQUE NOT NULL,
|
||||
amount_kopeks INTEGER NOT NULL,
|
||||
currency VARCHAR(3) DEFAULT 'RUB' NOT NULL,
|
||||
description TEXT NULL,
|
||||
status VARCHAR(50) NOT NULL,
|
||||
is_paid BOOLEAN DEFAULT FALSE,
|
||||
is_captured BOOLEAN DEFAULT FALSE,
|
||||
confirmation_url TEXT NULL,
|
||||
metadata_json JSONB NULL,
|
||||
transaction_id INTEGER NULL,
|
||||
payment_method_type VARCHAR(50) NULL,
|
||||
refundable BOOLEAN DEFAULT FALSE,
|
||||
test_mode BOOLEAN DEFAULT FALSE,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
yookassa_created_at TIMESTAMP NULL,
|
||||
captured_at TIMESTAMP NULL,
|
||||
FOREIGN KEY (user_id) REFERENCES users(id),
|
||||
FOREIGN KEY (transaction_id) REFERENCES transactions(id)
|
||||
);
|
||||
|
||||
CREATE INDEX idx_yookassa_payments_user_id ON yookassa_payments(user_id);
|
||||
CREATE INDEX idx_yookassa_payments_yookassa_id ON yookassa_payments(yookassa_payment_id);
|
||||
CREATE INDEX idx_yookassa_payments_status ON yookassa_payments(status);
|
||||
"""
|
||||
|
||||
elif db_type == 'mysql':
|
||||
create_sql = """
|
||||
CREATE TABLE yookassa_payments (
|
||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||
user_id INT NOT NULL,
|
||||
yookassa_payment_id VARCHAR(255) UNIQUE NOT NULL,
|
||||
amount_kopeks INT NOT NULL,
|
||||
currency VARCHAR(3) DEFAULT 'RUB' NOT NULL,
|
||||
description TEXT NULL,
|
||||
status VARCHAR(50) NOT NULL,
|
||||
is_paid BOOLEAN DEFAULT FALSE,
|
||||
is_captured BOOLEAN DEFAULT FALSE,
|
||||
confirmation_url TEXT NULL,
|
||||
metadata_json JSON NULL,
|
||||
transaction_id INT NULL,
|
||||
payment_method_type VARCHAR(50) NULL,
|
||||
refundable BOOLEAN DEFAULT FALSE,
|
||||
test_mode BOOLEAN DEFAULT FALSE,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
yookassa_created_at DATETIME NULL,
|
||||
captured_at DATETIME NULL,
|
||||
FOREIGN KEY (user_id) REFERENCES users(id),
|
||||
FOREIGN KEY (transaction_id) REFERENCES transactions(id)
|
||||
);
|
||||
|
||||
CREATE INDEX idx_yookassa_payments_user_id ON yookassa_payments(user_id);
|
||||
CREATE INDEX idx_yookassa_payments_yookassa_id ON yookassa_payments(yookassa_payment_id);
|
||||
CREATE INDEX idx_yookassa_payments_status ON yookassa_payments(status);
|
||||
"""
|
||||
else:
|
||||
logger.error(f"Неподдерживаемый тип БД для создания таблицы: {db_type}")
|
||||
return False
|
||||
|
||||
await conn.execute(text(create_sql))
|
||||
logger.info("Таблица yookassa_payments успешно создана")
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка создания таблицы yookassa_payments: {e}")
|
||||
return False
|
||||
|
||||
async def add_remnawave_v2_columns():
|
||||
|
||||
columns_to_add = {
|
||||
'lifetime_used_traffic_bytes': 'BIGINT DEFAULT 0',
|
||||
'last_remnawave_sync': 'TIMESTAMP NULL',
|
||||
'trojan_password': 'VARCHAR(255) NULL',
|
||||
'vless_uuid': 'VARCHAR(255) NULL',
|
||||
'ss_password': 'VARCHAR(255) NULL'
|
||||
}
|
||||
|
||||
logger.info("=== ПРОВЕРКА КОЛОНОК REMNAWAVE V2.1.5 ===")
|
||||
|
||||
try:
|
||||
async with engine.begin() as conn:
|
||||
db_type = await get_database_type()
|
||||
columns_added = 0
|
||||
|
||||
for column_name, column_def in columns_to_add.items():
|
||||
exists = await check_column_exists('users', column_name)
|
||||
|
||||
if not exists:
|
||||
logger.info(f"Добавление колонки {column_name} в таблицу users")
|
||||
|
||||
if db_type == 'sqlite':
|
||||
if column_def.startswith('BIGINT'):
|
||||
column_def = column_def.replace('BIGINT', 'INTEGER')
|
||||
column_def = column_def.replace('TIMESTAMP', 'DATETIME')
|
||||
elif db_type == 'mysql':
|
||||
column_def = column_def.replace('TIMESTAMP', 'DATETIME')
|
||||
|
||||
try:
|
||||
await conn.execute(text(f"ALTER TABLE users ADD COLUMN {column_name} {column_def}"))
|
||||
columns_added += 1
|
||||
logger.info(f"Колонка {column_name} успешно добавлена")
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка добавления колонки {column_name}: {e}")
|
||||
continue
|
||||
|
||||
else:
|
||||
logger.debug(f"Колонка {column_name} уже существует")
|
||||
|
||||
if columns_added > 0:
|
||||
logger.info(f"Добавлено {columns_added} новых колонок для RemnaWave v2.1.5")
|
||||
else:
|
||||
logger.info("Все колонки RemnaWave v2.1.5 уже существуют")
|
||||
|
||||
return columns_added
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка при добавлении колонок RemnaWave v2.1.5: {e}")
|
||||
return 0
|
||||
|
||||
async def add_referral_system_columns():
|
||||
logger.info("=== МИГРАЦИЯ РЕФЕРАЛЬНОЙ СИСТЕМЫ ===")
|
||||
|
||||
try:
|
||||
async with engine.begin() as conn:
|
||||
db_type = await get_database_type()
|
||||
|
||||
column_exists = await check_column_exists('users', 'has_made_first_topup')
|
||||
|
||||
if not column_exists:
|
||||
logger.info("Добавление колонки has_made_first_topup в таблицу users")
|
||||
|
||||
if db_type == 'sqlite':
|
||||
column_def = 'BOOLEAN DEFAULT 0'
|
||||
else:
|
||||
column_def = 'BOOLEAN DEFAULT FALSE'
|
||||
|
||||
await conn.execute(text(f"ALTER TABLE users ADD COLUMN has_made_first_topup {column_def}"))
|
||||
logger.info("Колонка has_made_first_topup успешно добавлена")
|
||||
|
||||
logger.info("Обновление существующих пользователей...")
|
||||
|
||||
if db_type == 'sqlite':
|
||||
update_sql = """
|
||||
UPDATE users
|
||||
SET has_made_first_topup = 1
|
||||
WHERE balance_kopeks > 0 OR has_had_paid_subscription = 1
|
||||
"""
|
||||
else:
|
||||
update_sql = """
|
||||
UPDATE users
|
||||
SET has_made_first_topup = TRUE
|
||||
WHERE balance_kopeks > 0 OR has_had_paid_subscription = TRUE
|
||||
"""
|
||||
|
||||
result = await conn.execute(text(update_sql))
|
||||
updated_count = result.rowcount
|
||||
|
||||
logger.info(f"Обновлено {updated_count} пользователей с has_made_first_topup = TRUE")
|
||||
logger.info("✅ Миграция реферальной системы завершена")
|
||||
|
||||
return True
|
||||
else:
|
||||
logger.info("Колонка has_made_first_topup уже существует")
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка миграции реферальной системы: {e}")
|
||||
return False
|
||||
|
||||
async def create_subscription_conversions_table():
|
||||
|
||||
table_exists = await check_table_exists('subscription_conversions')
|
||||
if table_exists:
|
||||
logger.info("Таблица subscription_conversions уже существует")
|
||||
return True
|
||||
|
||||
try:
|
||||
async with engine.begin() as conn:
|
||||
db_type = await get_database_type()
|
||||
|
||||
if db_type == 'sqlite':
|
||||
create_sql = """
|
||||
CREATE TABLE subscription_conversions (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id INTEGER NOT NULL,
|
||||
converted_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
trial_duration_days INTEGER NULL,
|
||||
payment_method VARCHAR(50) NULL,
|
||||
first_payment_amount_kopeks INTEGER NULL,
|
||||
first_paid_period_days INTEGER NULL,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY (user_id) REFERENCES users(id)
|
||||
);
|
||||
|
||||
CREATE INDEX idx_subscription_conversions_user_id ON subscription_conversions(user_id);
|
||||
CREATE INDEX idx_subscription_conversions_converted_at ON subscription_conversions(converted_at);
|
||||
"""
|
||||
|
||||
elif db_type == 'postgresql':
|
||||
create_sql = """
|
||||
CREATE TABLE subscription_conversions (
|
||||
id SERIAL PRIMARY KEY,
|
||||
user_id INTEGER NOT NULL,
|
||||
converted_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
trial_duration_days INTEGER NULL,
|
||||
payment_method VARCHAR(50) NULL,
|
||||
first_payment_amount_kopeks INTEGER NULL,
|
||||
first_paid_period_days INTEGER NULL,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY (user_id) REFERENCES users(id)
|
||||
);
|
||||
|
||||
CREATE INDEX idx_subscription_conversions_user_id ON subscription_conversions(user_id);
|
||||
CREATE INDEX idx_subscription_conversions_converted_at ON subscription_conversions(converted_at);
|
||||
"""
|
||||
|
||||
elif db_type == 'mysql':
|
||||
create_sql = """
|
||||
CREATE TABLE subscription_conversions (
|
||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||
user_id INT NOT NULL,
|
||||
converted_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
trial_duration_days INT NULL,
|
||||
payment_method VARCHAR(50) NULL,
|
||||
first_payment_amount_kopeks INT NULL,
|
||||
first_paid_period_days INT NULL,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY (user_id) REFERENCES users(id)
|
||||
);
|
||||
|
||||
CREATE INDEX idx_subscription_conversions_user_id ON subscription_conversions(user_id);
|
||||
CREATE INDEX idx_subscription_conversions_converted_at ON subscription_conversions(converted_at);
|
||||
"""
|
||||
else:
|
||||
logger.error(f"Неподдерживаемый тип БД для создания таблицы: {db_type}")
|
||||
return False
|
||||
|
||||
await conn.execute(text(create_sql))
|
||||
logger.info("✅ Таблица subscription_conversions успешно создана")
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка создания таблицы subscription_conversions: {e}")
|
||||
return False
|
||||
|
||||
async def fix_subscription_duplicates_universal():
|
||||
|
||||
async with engine.begin() as conn:
|
||||
db_type = await get_database_type()
|
||||
logger.info(f"Обнаружен тип базы данных: {db_type}")
|
||||
|
||||
try:
|
||||
result = await conn.execute(text("""
|
||||
SELECT user_id, COUNT(*) as count
|
||||
FROM subscriptions
|
||||
GROUP BY user_id
|
||||
HAVING COUNT(*) > 1
|
||||
"""))
|
||||
|
||||
duplicates = result.fetchall()
|
||||
|
||||
if not duplicates:
|
||||
logger.info("Дублирующихся подписок не найдено")
|
||||
return 0
|
||||
|
||||
logger.info(f"Найдено {len(duplicates)} пользователей с дублирующимися подписками")
|
||||
|
||||
total_deleted = 0
|
||||
|
||||
for user_id_row, count in duplicates:
|
||||
user_id = user_id_row
|
||||
|
||||
if db_type == 'sqlite':
|
||||
delete_result = await conn.execute(text("""
|
||||
DELETE FROM subscriptions
|
||||
WHERE user_id = :user_id AND id NOT IN (
|
||||
SELECT MAX(id)
|
||||
FROM subscriptions
|
||||
WHERE user_id = :user_id
|
||||
)
|
||||
"""), {"user_id": user_id})
|
||||
|
||||
elif db_type in ['postgresql', 'mysql']:
|
||||
delete_result = await conn.execute(text("""
|
||||
DELETE FROM subscriptions
|
||||
WHERE user_id = :user_id AND id NOT IN (
|
||||
SELECT max_id FROM (
|
||||
SELECT MAX(id) as max_id
|
||||
FROM subscriptions
|
||||
WHERE user_id = :user_id
|
||||
) as subquery
|
||||
)
|
||||
"""), {"user_id": user_id})
|
||||
|
||||
else:
|
||||
subs_result = await conn.execute(text("""
|
||||
SELECT id FROM subscriptions
|
||||
WHERE user_id = :user_id
|
||||
ORDER BY created_at DESC, id DESC
|
||||
"""), {"user_id": user_id})
|
||||
|
||||
sub_ids = [row[0] for row in subs_result.fetchall()]
|
||||
|
||||
if len(sub_ids) > 1:
|
||||
ids_to_delete = sub_ids[1:]
|
||||
for sub_id in ids_to_delete:
|
||||
await conn.execute(text("""
|
||||
DELETE FROM subscriptions WHERE id = :id
|
||||
"""), {"id": sub_id})
|
||||
delete_result = type('Result', (), {'rowcount': len(ids_to_delete)})()
|
||||
else:
|
||||
delete_result = type('Result', (), {'rowcount': 0})()
|
||||
|
||||
deleted_count = delete_result.rowcount
|
||||
total_deleted += deleted_count
|
||||
logger.info(f"Удалено {deleted_count} дублирующихся подписок для пользователя {user_id}")
|
||||
|
||||
logger.info(f"Всего удалено дублирующихся подписок: {total_deleted}")
|
||||
return total_deleted
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка при очистке дублирующихся подписок: {e}")
|
||||
raise
|
||||
|
||||
async def run_universal_migration():
|
||||
logger.info("=== НАЧАЛО УНИВЕРСАЛЬНОЙ МИГРАЦИИ ===")
|
||||
|
||||
try:
|
||||
db_type = await get_database_type()
|
||||
logger.info(f"Тип базы данных: {db_type}")
|
||||
|
||||
await add_remnawave_v2_columns()
|
||||
|
||||
referral_migration_success = await add_referral_system_columns()
|
||||
if not referral_migration_success:
|
||||
logger.warning("⚠️ Проблемы с миграцией реферальной системы")
|
||||
|
||||
logger.info("=== СОЗДАНИЕ ТАБЛИЦЫ YOOKASSA ===")
|
||||
yookassa_created = await create_yookassa_payments_table()
|
||||
if yookassa_created:
|
||||
logger.info("✅ Таблица YooKassa payments готова")
|
||||
else:
|
||||
logger.warning("⚠️ Проблемы с таблицей YooKassa payments")
|
||||
|
||||
logger.info("=== СОЗДАНИЕ ТАБЛИЦЫ КОНВЕРСИЙ ПОДПИСОК ===")
|
||||
conversions_created = await create_subscription_conversions_table()
|
||||
if conversions_created:
|
||||
logger.info("✅ Таблица subscription_conversions готова")
|
||||
else:
|
||||
logger.warning("⚠️ Проблемы с таблицей subscription_conversions")
|
||||
|
||||
async with engine.begin() as conn:
|
||||
total_subs = await conn.execute(text("SELECT COUNT(*) FROM subscriptions"))
|
||||
unique_users = await conn.execute(text("SELECT COUNT(DISTINCT user_id) FROM subscriptions"))
|
||||
|
||||
total_count = total_subs.fetchone()[0]
|
||||
unique_count = unique_users.fetchone()[0]
|
||||
|
||||
logger.info(f"Всего подписок: {total_count}")
|
||||
logger.info(f"Уникальных пользователей: {unique_count}")
|
||||
|
||||
if total_count == unique_count:
|
||||
logger.info("База данных уже в корректном состоянии")
|
||||
logger.info("=== МИГРАЦИЯ ЗАВЕРШЕНА УСПЕШНО ===")
|
||||
return True
|
||||
|
||||
deleted_count = await fix_subscription_duplicates_universal()
|
||||
|
||||
async with engine.begin() as conn:
|
||||
final_check = await conn.execute(text("""
|
||||
SELECT user_id, COUNT(*) as count
|
||||
FROM subscriptions
|
||||
GROUP BY user_id
|
||||
HAVING COUNT(*) > 1
|
||||
"""))
|
||||
|
||||
remaining_duplicates = final_check.fetchall()
|
||||
|
||||
if remaining_duplicates:
|
||||
logger.warning(f"Остались дубликаты у {len(remaining_duplicates)} пользователей")
|
||||
return False
|
||||
else:
|
||||
logger.info("=== МИГРАЦИЯ ЗАВЕРШЕНА УСПЕШНО ===")
|
||||
logger.info("✅ Реферальная система обновлена")
|
||||
logger.info("✅ RemnaWave v2.1.5 колонки добавлены")
|
||||
logger.info("✅ YooKassa таблица готова")
|
||||
logger.info("✅ Таблица конверсий подписок создана")
|
||||
logger.info("✅ Дубликаты подписок исправлены")
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"=== ОШИБКА ВЫПОЛНЕНИЯ МИГРАЦИИ: {e} ===")
|
||||
return False
|
||||
|
||||
async def check_migration_status():
|
||||
logger.info("=== ПРОВЕРКА СТАТУСА МИГРАЦИЙ ===")
|
||||
|
||||
try:
|
||||
status = {
|
||||
"has_made_first_topup_column": False,
|
||||
"yookassa_table": False,
|
||||
"remnawave_v2_columns": False,
|
||||
"subscription_duplicates": False,
|
||||
"subscription_conversions_table": False
|
||||
}
|
||||
|
||||
status["has_made_first_topup_column"] = await check_column_exists('users', 'has_made_first_topup')
|
||||
|
||||
status["yookassa_table"] = await check_table_exists('yookassa_payments')
|
||||
|
||||
status["subscription_conversions_table"] = await check_table_exists('subscription_conversions')
|
||||
|
||||
remnawave_columns = ['lifetime_used_traffic_bytes', 'last_remnawave_sync', 'trojan_password', 'vless_uuid', 'ss_password']
|
||||
remnawave_status = []
|
||||
for col in remnawave_columns:
|
||||
exists = await check_column_exists('users', col)
|
||||
remnawave_status.append(exists)
|
||||
status["remnawave_v2_columns"] = all(remnawave_status)
|
||||
|
||||
async with engine.begin() as conn:
|
||||
duplicates_check = await conn.execute(text("""
|
||||
SELECT COUNT(*) FROM (
|
||||
SELECT user_id, COUNT(*) as count
|
||||
FROM subscriptions
|
||||
GROUP BY user_id
|
||||
HAVING COUNT(*) > 1
|
||||
) as dups
|
||||
"""))
|
||||
duplicates_count = duplicates_check.fetchone()[0]
|
||||
status["subscription_duplicates"] = (duplicates_count == 0)
|
||||
|
||||
check_names = {
|
||||
"has_made_first_topup_column": "Колонка реферальной системы",
|
||||
"yookassa_table": "Таблица YooKassa payments",
|
||||
"subscription_conversions_table": "Таблица конверсий подписок",
|
||||
"remnawave_v2_columns": "Колонки RemnaWave v2.1.5",
|
||||
"subscription_duplicates": "Отсутствие дубликатов подписок"
|
||||
}
|
||||
|
||||
for check_key, check_status in status.items():
|
||||
check_name = check_names.get(check_key, check_key)
|
||||
icon = "✅" if check_status else "❌"
|
||||
logger.info(f"{icon} {check_name}: {'OK' if check_status else 'ТРЕБУЕТ ВНИМАНИЯ'}")
|
||||
|
||||
all_good = all(status.values())
|
||||
if all_good:
|
||||
logger.info("🎉 Все миграции выполнены успешно!")
|
||||
|
||||
try:
|
||||
async with engine.begin() as conn:
|
||||
conversions_count = await conn.execute(text("SELECT COUNT(*) FROM subscription_conversions"))
|
||||
users_count = await conn.execute(text("SELECT COUNT(*) FROM users"))
|
||||
|
||||
conv_count = conversions_count.fetchone()[0]
|
||||
usr_count = users_count.fetchone()[0]
|
||||
|
||||
logger.info(f"📊 Статистика: {usr_count} пользователей, {conv_count} конверсий записано")
|
||||
except Exception as stats_error:
|
||||
logger.debug(f"Не удалось получить дополнительную статистику: {stats_error}")
|
||||
|
||||
else:
|
||||
logger.warning("⚠️ Некоторые миграции требуют внимания")
|
||||
missing_migrations = [check_names[k] for k, v in status.items() if not v]
|
||||
logger.warning(f"Требуют выполнения: {', '.join(missing_migrations)}")
|
||||
|
||||
return status
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка проверки статуса миграций: {e}")
|
||||
return None
|
||||
Vendored
+620
@@ -0,0 +1,620 @@
|
||||
import asyncio
|
||||
import json
|
||||
import ssl
|
||||
from datetime import datetime, timedelta
|
||||
from typing import Dict, List, Optional, Union, Any
|
||||
from urllib.parse import urlparse
|
||||
import aiohttp
|
||||
import logging
|
||||
from dataclasses import dataclass
|
||||
from enum import Enum
|
||||
from urllib.parse import urlparse, urljoin
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class UserStatus(Enum):
|
||||
ACTIVE = "ACTIVE"
|
||||
DISABLED = "DISABLED"
|
||||
LIMITED = "LIMITED"
|
||||
EXPIRED = "EXPIRED"
|
||||
|
||||
|
||||
class TrafficLimitStrategy(Enum):
|
||||
NO_RESET = "NO_RESET"
|
||||
DAY = "DAY"
|
||||
WEEK = "WEEK"
|
||||
MONTH = "MONTH"
|
||||
|
||||
|
||||
@dataclass
|
||||
class RemnaWaveUser:
|
||||
uuid: str
|
||||
short_uuid: str
|
||||
username: str
|
||||
status: UserStatus
|
||||
used_traffic_bytes: int
|
||||
lifetime_used_traffic_bytes: int
|
||||
traffic_limit_bytes: int
|
||||
traffic_limit_strategy: TrafficLimitStrategy
|
||||
expire_at: datetime
|
||||
telegram_id: Optional[int]
|
||||
email: Optional[str]
|
||||
hwid_device_limit: Optional[int]
|
||||
description: Optional[str]
|
||||
tag: Optional[str]
|
||||
subscription_url: str
|
||||
active_internal_squads: List[Dict[str, str]]
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
sub_last_user_agent: Optional[str] = None
|
||||
sub_last_opened_at: Optional[datetime] = None
|
||||
online_at: Optional[datetime] = None
|
||||
sub_revoked_at: Optional[datetime] = None
|
||||
last_traffic_reset_at: Optional[datetime] = None
|
||||
trojan_password: Optional[str] = None
|
||||
vless_uuid: Optional[str] = None
|
||||
ss_password: Optional[str] = None
|
||||
first_connected_at: Optional[datetime] = None
|
||||
last_triggered_threshold: int = 0
|
||||
|
||||
|
||||
@dataclass
|
||||
class RemnaWaveInternalSquad:
|
||||
uuid: str
|
||||
name: str
|
||||
members_count: int
|
||||
inbounds_count: int
|
||||
inbounds: List[Dict[str, Any]]
|
||||
|
||||
|
||||
@dataclass
|
||||
class RemnaWaveNode:
|
||||
uuid: str
|
||||
name: str
|
||||
address: str
|
||||
country_code: str
|
||||
is_connected: bool
|
||||
is_disabled: bool
|
||||
is_node_online: bool
|
||||
is_xray_running: bool
|
||||
users_online: Optional[int]
|
||||
traffic_used_bytes: Optional[int]
|
||||
traffic_limit_bytes: Optional[int]
|
||||
|
||||
|
||||
class RemnaWaveAPIError(Exception):
|
||||
def __init__(self, message: str, status_code: int = None, response_data: dict = None):
|
||||
self.message = message
|
||||
self.status_code = status_code
|
||||
self.response_data = response_data
|
||||
super().__init__(self.message)
|
||||
|
||||
|
||||
class RemnaWaveAPI:
|
||||
|
||||
def __init__(self, base_url: str, api_key: str, secret_key: Optional[str] = None):
|
||||
self.base_url = base_url.rstrip('/')
|
||||
self.api_key = api_key
|
||||
self.secret_key = secret_key
|
||||
self.session: Optional[aiohttp.ClientSession] = None
|
||||
self.authenticated = False
|
||||
|
||||
def _detect_connection_type(self) -> str:
|
||||
parsed = urlparse(self.base_url)
|
||||
|
||||
local_hosts = [
|
||||
'localhost', '127.0.0.1', 'remnawave',
|
||||
'remnawave-backend', 'app', 'api'
|
||||
]
|
||||
|
||||
if parsed.hostname in local_hosts:
|
||||
return "local"
|
||||
|
||||
if parsed.hostname:
|
||||
if (parsed.hostname.startswith('192.168.') or
|
||||
parsed.hostname.startswith('10.') or
|
||||
parsed.hostname.startswith('172.') or
|
||||
parsed.hostname.endswith('.local')):
|
||||
return "local"
|
||||
|
||||
return "external"
|
||||
|
||||
async def __aenter__(self):
|
||||
conn_type = self._detect_connection_type()
|
||||
|
||||
logger.info(f"🔗 Подключение к RemnaWave: {self.base_url} (тип: {conn_type})")
|
||||
|
||||
headers = {
|
||||
'Authorization': f'Bearer {self.api_key}',
|
||||
'Content-Type': 'application/json',
|
||||
'Accept': 'application/json',
|
||||
'X-Forwarded-Proto': 'https',
|
||||
'X-Forwarded-For': '127.0.0.1',
|
||||
'X-Real-IP': '127.0.0.1'
|
||||
}
|
||||
|
||||
cookies = None
|
||||
if self.secret_key:
|
||||
if ':' in self.secret_key:
|
||||
key_name, key_value = self.secret_key.split(':', 1)
|
||||
cookies = {key_name: key_value}
|
||||
logger.debug(f"🍪 Используем куки: {key_name}=***")
|
||||
else:
|
||||
cookies = {self.secret_key: self.secret_key}
|
||||
logger.debug(f"🍪 Используем куки: {self.secret_key}=***")
|
||||
|
||||
connector_kwargs = {}
|
||||
|
||||
if conn_type == "local":
|
||||
logger.debug("🏠 Использую локальные заголовки proxy")
|
||||
headers.update({
|
||||
'X-Forwarded-Host': 'localhost',
|
||||
'Host': 'localhost'
|
||||
})
|
||||
|
||||
if self.base_url.startswith('https://'):
|
||||
ssl_context = ssl.create_default_context()
|
||||
ssl_context.check_hostname = False
|
||||
ssl_context.verify_mode = ssl.CERT_NONE
|
||||
connector_kwargs['ssl'] = ssl_context
|
||||
logger.debug("🔓 SSL проверка отключена для локального HTTPS")
|
||||
|
||||
elif conn_type == "external":
|
||||
logger.debug("🌐 Использую внешнее подключение с полной SSL проверкой")
|
||||
pass
|
||||
|
||||
connector = aiohttp.TCPConnector(**connector_kwargs)
|
||||
|
||||
session_kwargs = {
|
||||
'timeout': aiohttp.ClientTimeout(total=30),
|
||||
'headers': headers,
|
||||
'connector': connector
|
||||
}
|
||||
|
||||
if cookies:
|
||||
session_kwargs['cookies'] = cookies
|
||||
|
||||
self.session = aiohttp.ClientSession(**session_kwargs)
|
||||
self.authenticated = True
|
||||
|
||||
return self
|
||||
|
||||
async def __aexit__(self, exc_type, exc_val, exc_tb):
|
||||
if self.session:
|
||||
await self.session.close()
|
||||
|
||||
async def _make_request(
|
||||
self,
|
||||
method: str,
|
||||
endpoint: str,
|
||||
data: Optional[Dict] = None,
|
||||
params: Optional[Dict] = None
|
||||
) -> Dict:
|
||||
if not self.session:
|
||||
raise RemnaWaveAPIError("Session not initialized. Use async context manager.")
|
||||
|
||||
url = f"{self.base_url}{endpoint}"
|
||||
|
||||
try:
|
||||
kwargs = {
|
||||
'url': url,
|
||||
'params': params
|
||||
}
|
||||
|
||||
if data:
|
||||
kwargs['json'] = data
|
||||
|
||||
async with self.session.request(method, **kwargs) as response:
|
||||
response_text = await response.text()
|
||||
|
||||
try:
|
||||
response_data = json.loads(response_text) if response_text else {}
|
||||
except json.JSONDecodeError:
|
||||
response_data = {'raw_response': response_text}
|
||||
|
||||
if response.status >= 400:
|
||||
error_message = response_data.get('message', f'HTTP {response.status}')
|
||||
logger.error(f"API Error {response.status}: {error_message}")
|
||||
logger.error(f"Response: {response_text[:500]}")
|
||||
raise RemnaWaveAPIError(
|
||||
error_message,
|
||||
response.status,
|
||||
response_data
|
||||
)
|
||||
|
||||
return response_data
|
||||
|
||||
except aiohttp.ClientError as e:
|
||||
logger.error(f"Request failed: {e}")
|
||||
raise RemnaWaveAPIError(f"Request failed: {str(e)}")
|
||||
|
||||
|
||||
async def create_user(
|
||||
self,
|
||||
username: str,
|
||||
expire_at: datetime,
|
||||
status: UserStatus = UserStatus.ACTIVE,
|
||||
traffic_limit_bytes: int = 0,
|
||||
traffic_limit_strategy: TrafficLimitStrategy = TrafficLimitStrategy.NO_RESET,
|
||||
telegram_id: Optional[int] = None,
|
||||
email: Optional[str] = None,
|
||||
hwid_device_limit: Optional[int] = None,
|
||||
description: Optional[str] = None,
|
||||
tag: Optional[str] = None,
|
||||
active_internal_squads: Optional[List[str]] = None
|
||||
) -> RemnaWaveUser:
|
||||
data = {
|
||||
'username': username,
|
||||
'status': status.value,
|
||||
'expireAt': expire_at.isoformat(),
|
||||
'trafficLimitBytes': traffic_limit_bytes,
|
||||
'trafficLimitStrategy': traffic_limit_strategy.value
|
||||
}
|
||||
|
||||
if telegram_id:
|
||||
data['telegramId'] = telegram_id
|
||||
if email:
|
||||
data['email'] = email
|
||||
if hwid_device_limit:
|
||||
data['hwidDeviceLimit'] = hwid_device_limit
|
||||
if description:
|
||||
data['description'] = description
|
||||
if tag:
|
||||
data['tag'] = tag
|
||||
if active_internal_squads:
|
||||
data['activeInternalSquads'] = active_internal_squads
|
||||
|
||||
response = await self._make_request('POST', '/api/users', data)
|
||||
return self._parse_user(response['response'])
|
||||
|
||||
async def get_user_by_uuid(self, uuid: str) -> Optional[RemnaWaveUser]:
|
||||
try:
|
||||
response = await self._make_request('GET', f'/api/users/{uuid}')
|
||||
return self._parse_user(response['response'])
|
||||
except RemnaWaveAPIError as e:
|
||||
if e.status_code == 404:
|
||||
return None
|
||||
raise
|
||||
|
||||
async def get_user_by_telegram_id(self, telegram_id: int) -> List[RemnaWaveUser]:
|
||||
try:
|
||||
response = await self._make_request('GET', f'/api/users/by-telegram-id/{telegram_id}')
|
||||
return [self._parse_user(user) for user in response['response']]
|
||||
except RemnaWaveAPIError as e:
|
||||
if e.status_code == 404:
|
||||
return []
|
||||
raise
|
||||
|
||||
async def get_user_by_username(self, username: str) -> Optional[RemnaWaveUser]:
|
||||
try:
|
||||
response = await self._make_request('GET', f'/api/users/by-username/{username}')
|
||||
return self._parse_user(response['response'])
|
||||
except RemnaWaveAPIError as e:
|
||||
if e.status_code == 404:
|
||||
return None
|
||||
raise
|
||||
|
||||
async def update_user(
|
||||
self,
|
||||
uuid: str,
|
||||
status: Optional[UserStatus] = None,
|
||||
traffic_limit_bytes: Optional[int] = None,
|
||||
traffic_limit_strategy: Optional[TrafficLimitStrategy] = None,
|
||||
expire_at: Optional[datetime] = None,
|
||||
telegram_id: Optional[int] = None,
|
||||
email: Optional[str] = None,
|
||||
hwid_device_limit: Optional[int] = None,
|
||||
description: Optional[str] = None,
|
||||
tag: Optional[str] = None,
|
||||
active_internal_squads: Optional[List[str]] = None
|
||||
) -> RemnaWaveUser:
|
||||
data = {'uuid': uuid}
|
||||
|
||||
if status:
|
||||
data['status'] = status.value
|
||||
if traffic_limit_bytes is not None:
|
||||
data['trafficLimitBytes'] = traffic_limit_bytes
|
||||
if traffic_limit_strategy:
|
||||
data['trafficLimitStrategy'] = traffic_limit_strategy.value
|
||||
if expire_at:
|
||||
data['expireAt'] = expire_at.isoformat()
|
||||
if telegram_id is not None:
|
||||
data['telegramId'] = telegram_id
|
||||
if email is not None:
|
||||
data['email'] = email
|
||||
if hwid_device_limit is not None:
|
||||
data['hwidDeviceLimit'] = hwid_device_limit
|
||||
if description is not None:
|
||||
data['description'] = description
|
||||
if tag is not None:
|
||||
data['tag'] = tag
|
||||
if active_internal_squads is not None:
|
||||
data['activeInternalSquads'] = active_internal_squads
|
||||
|
||||
response = await self._make_request('PATCH', '/api/users', data)
|
||||
return self._parse_user(response['response'])
|
||||
|
||||
async def delete_user(self, uuid: str) -> bool:
|
||||
response = await self._make_request('DELETE', f'/api/users/{uuid}')
|
||||
return response['response']['isDeleted']
|
||||
|
||||
async def enable_user(self, uuid: str) -> RemnaWaveUser:
|
||||
response = await self._make_request('POST', f'/api/users/{uuid}/actions/enable')
|
||||
return self._parse_user(response['response'])
|
||||
|
||||
async def disable_user(self, uuid: str) -> RemnaWaveUser:
|
||||
response = await self._make_request('POST', f'/api/users/{uuid}/actions/disable')
|
||||
return self._parse_user(response['response'])
|
||||
|
||||
async def reset_user_traffic(self, uuid: str) -> RemnaWaveUser:
|
||||
response = await self._make_request('POST', f'/api/users/{uuid}/actions/reset-traffic')
|
||||
return self._parse_user(response['response'])
|
||||
|
||||
async def revoke_user_subscription(self, uuid: str, new_short_uuid: Optional[str] = None) -> RemnaWaveUser:
|
||||
data = {}
|
||||
if new_short_uuid:
|
||||
data['shortUuid'] = new_short_uuid
|
||||
|
||||
response = await self._make_request('POST', f'/api/users/{uuid}/actions/revoke', data)
|
||||
return self._parse_user(response['response'])
|
||||
|
||||
async def get_all_users(self, start: int = 0, size: int = 100) -> Dict[str, Any]:
|
||||
params = {'start': start, 'size': size}
|
||||
response = await self._make_request('GET', '/api/users', params=params)
|
||||
|
||||
return {
|
||||
'users': [self._parse_user(user) for user in response['response']['users']],
|
||||
'total': response['response']['total']
|
||||
}
|
||||
|
||||
|
||||
async def get_internal_squads(self) -> List[RemnaWaveInternalSquad]:
|
||||
response = await self._make_request('GET', '/api/internal-squads')
|
||||
return [self._parse_internal_squad(squad) for squad in response['response']['internalSquads']]
|
||||
|
||||
async def get_internal_squad_by_uuid(self, uuid: str) -> Optional[RemnaWaveInternalSquad]:
|
||||
try:
|
||||
response = await self._make_request('GET', f'/api/internal-squads/{uuid}')
|
||||
return self._parse_internal_squad(response['response'])
|
||||
except RemnaWaveAPIError as e:
|
||||
if e.status_code == 404:
|
||||
return None
|
||||
raise
|
||||
|
||||
async def create_internal_squad(self, name: str, inbounds: List[str]) -> RemnaWaveInternalSquad:
|
||||
data = {
|
||||
'name': name,
|
||||
'inbounds': inbounds
|
||||
}
|
||||
response = await self._make_request('POST', '/api/internal-squads', data)
|
||||
return self._parse_internal_squad(response['response'])
|
||||
|
||||
async def update_internal_squad(
|
||||
self,
|
||||
uuid: str,
|
||||
name: Optional[str] = None,
|
||||
inbounds: Optional[List[str]] = None
|
||||
) -> RemnaWaveInternalSquad:
|
||||
data = {'uuid': uuid}
|
||||
if name:
|
||||
data['name'] = name
|
||||
if inbounds is not None:
|
||||
data['inbounds'] = inbounds
|
||||
|
||||
response = await self._make_request('PATCH', '/api/internal-squads', data)
|
||||
return self._parse_internal_squad(response['response'])
|
||||
|
||||
async def delete_internal_squad(self, uuid: str) -> bool:
|
||||
response = await self._make_request('DELETE', f'/api/internal-squads/{uuid}')
|
||||
return response['response']['isDeleted']
|
||||
|
||||
|
||||
async def get_all_nodes(self) -> List[RemnaWaveNode]:
|
||||
response = await self._make_request('GET', '/api/nodes')
|
||||
return [self._parse_node(node) for node in response['response']]
|
||||
|
||||
async def get_node_by_uuid(self, uuid: str) -> Optional[RemnaWaveNode]:
|
||||
try:
|
||||
response = await self._make_request('GET', f'/api/nodes/{uuid}')
|
||||
return self._parse_node(response['response'])
|
||||
except RemnaWaveAPIError as e:
|
||||
if e.status_code == 404:
|
||||
return None
|
||||
raise
|
||||
|
||||
async def enable_node(self, uuid: str) -> RemnaWaveNode:
|
||||
response = await self._make_request('POST', f'/api/nodes/{uuid}/actions/enable')
|
||||
return self._parse_node(response['response'])
|
||||
|
||||
async def disable_node(self, uuid: str) -> RemnaWaveNode:
|
||||
response = await self._make_request('POST', f'/api/nodes/{uuid}/actions/disable')
|
||||
return self._parse_node(response['response'])
|
||||
|
||||
async def restart_node(self, uuid: str) -> bool:
|
||||
response = await self._make_request('POST', f'/api/nodes/{uuid}/actions/restart')
|
||||
return response['response']['eventSent']
|
||||
|
||||
async def restart_all_nodes(self) -> bool:
|
||||
response = await self._make_request('POST', '/api/nodes/actions/restart-all')
|
||||
return response['response']['eventSent']
|
||||
|
||||
|
||||
async def get_subscription_info(self, short_uuid: str) -> Dict[str, Any]:
|
||||
response = await self._make_request('GET', f'/api/sub/{short_uuid}/info')
|
||||
return response['response']
|
||||
|
||||
|
||||
async def get_system_stats(self) -> Dict[str, Any]:
|
||||
response = await self._make_request('GET', '/api/system/stats')
|
||||
return response['response']
|
||||
|
||||
async def get_bandwidth_stats(self) -> Dict[str, Any]:
|
||||
response = await self._make_request('GET', '/api/system/stats/bandwidth')
|
||||
return response['response']
|
||||
|
||||
async def get_nodes_statistics(self) -> Dict[str, Any]:
|
||||
response = await self._make_request('GET', '/api/system/stats/nodes')
|
||||
return response['response']
|
||||
|
||||
async def get_nodes_realtime_usage(self) -> List[Dict[str, Any]]:
|
||||
response = await self._make_request('GET', '/api/nodes/usage/realtime')
|
||||
return response['response']
|
||||
|
||||
|
||||
async def get_user_devices(self, user_uuid: str) -> Dict[str, Any]:
|
||||
try:
|
||||
response = await self._make_request('GET', f'/api/hwid/devices/{user_uuid}')
|
||||
return response['response']
|
||||
except RemnaWaveAPIError as e:
|
||||
if e.status_code == 404:
|
||||
return {'total': 0, 'devices': []}
|
||||
raise
|
||||
|
||||
async def reset_user_devices(self, user_uuid: str) -> bool:
|
||||
try:
|
||||
devices_info = await self.get_user_devices(user_uuid)
|
||||
devices = devices_info.get('devices', [])
|
||||
|
||||
if not devices:
|
||||
return True
|
||||
|
||||
failed_count = 0
|
||||
for device in devices:
|
||||
device_hwid = device.get('hwid')
|
||||
if device_hwid:
|
||||
try:
|
||||
delete_data = {
|
||||
"userUuid": user_uuid,
|
||||
"hwid": device_hwid
|
||||
}
|
||||
await self._make_request('POST', '/api/hwid/devices/delete', data=delete_data)
|
||||
except Exception as device_error:
|
||||
logger.error(f"Ошибка удаления устройства {device_hwid}: {device_error}")
|
||||
failed_count += 1
|
||||
|
||||
return failed_count < len(devices) / 2
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка при сбросе устройств: {e}")
|
||||
return False
|
||||
|
||||
async def remove_device(self, user_uuid: str, device_hwid: str) -> bool:
|
||||
try:
|
||||
delete_data = {
|
||||
"userUuid": user_uuid,
|
||||
"hwid": device_hwid
|
||||
}
|
||||
await self._make_request('POST', '/api/hwid/devices/delete', data=delete_data)
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка удаления устройства {device_hwid}: {e}")
|
||||
return False
|
||||
|
||||
|
||||
def _parse_user(self, user_data: Dict) -> RemnaWaveUser:
|
||||
return RemnaWaveUser(
|
||||
uuid=user_data['uuid'],
|
||||
short_uuid=user_data['shortUuid'],
|
||||
username=user_data['username'],
|
||||
status=UserStatus(user_data['status']),
|
||||
used_traffic_bytes=int(user_data['usedTrafficBytes']),
|
||||
lifetime_used_traffic_bytes=int(user_data['lifetimeUsedTrafficBytes']),
|
||||
traffic_limit_bytes=user_data['trafficLimitBytes'],
|
||||
traffic_limit_strategy=TrafficLimitStrategy(user_data['trafficLimitStrategy']),
|
||||
expire_at=datetime.fromisoformat(user_data['expireAt'].replace('Z', '+00:00')),
|
||||
telegram_id=user_data.get('telegramId'),
|
||||
email=user_data.get('email'),
|
||||
hwid_device_limit=user_data.get('hwidDeviceLimit'),
|
||||
description=user_data.get('description'),
|
||||
tag=user_data.get('tag'),
|
||||
subscription_url=user_data['subscriptionUrl'],
|
||||
active_internal_squads=user_data['activeInternalSquads'],
|
||||
created_at=datetime.fromisoformat(user_data['createdAt'].replace('Z', '+00:00')),
|
||||
updated_at=datetime.fromisoformat(user_data['updatedAt'].replace('Z', '+00:00')),
|
||||
sub_last_user_agent=user_data.get('subLastUserAgent'),
|
||||
sub_last_opened_at=self._parse_optional_datetime(user_data.get('subLastOpenedAt')),
|
||||
online_at=self._parse_optional_datetime(user_data.get('onlineAt')),
|
||||
sub_revoked_at=self._parse_optional_datetime(user_data.get('subRevokedAt')),
|
||||
last_traffic_reset_at=self._parse_optional_datetime(user_data.get('lastTrafficResetAt')),
|
||||
trojan_password=user_data.get('trojanPassword'),
|
||||
vless_uuid=user_data.get('vlessUuid'),
|
||||
ss_password=user_data.get('ssPassword'),
|
||||
first_connected_at=self._parse_optional_datetime(user_data.get('firstConnectedAt')),
|
||||
last_triggered_threshold=user_data.get('lastTriggeredThreshold', 0)
|
||||
)
|
||||
|
||||
def _parse_optional_datetime(self, date_str: Optional[str]) -> Optional[datetime]:
|
||||
if date_str:
|
||||
return datetime.fromisoformat(date_str.replace('Z', '+00:00'))
|
||||
return None
|
||||
|
||||
def _parse_internal_squad(self, squad_data: Dict) -> RemnaWaveInternalSquad:
|
||||
return RemnaWaveInternalSquad(
|
||||
uuid=squad_data['uuid'],
|
||||
name=squad_data['name'],
|
||||
members_count=squad_data['info']['membersCount'],
|
||||
inbounds_count=squad_data['info']['inboundsCount'],
|
||||
inbounds=squad_data['inbounds']
|
||||
)
|
||||
|
||||
def _parse_node(self, node_data: Dict) -> RemnaWaveNode:
|
||||
return RemnaWaveNode(
|
||||
uuid=node_data['uuid'],
|
||||
name=node_data['name'],
|
||||
address=node_data['address'],
|
||||
country_code=node_data['countryCode'],
|
||||
is_connected=node_data['isConnected'],
|
||||
is_disabled=node_data['isDisabled'],
|
||||
is_node_online=node_data['isNodeOnline'],
|
||||
is_xray_running=node_data['isXrayRunning'],
|
||||
users_online=node_data.get('usersOnline'),
|
||||
traffic_used_bytes=node_data.get('trafficUsedBytes'),
|
||||
traffic_limit_bytes=node_data.get('trafficLimitBytes')
|
||||
)
|
||||
|
||||
|
||||
def format_bytes(bytes_value: int) -> str:
|
||||
if bytes_value == 0:
|
||||
return "0 B"
|
||||
|
||||
units = ["B", "KB", "MB", "GB", "TB"]
|
||||
size = bytes_value
|
||||
unit_index = 0
|
||||
|
||||
while size >= 1024 and unit_index < len(units) - 1:
|
||||
size /= 1024
|
||||
unit_index += 1
|
||||
|
||||
return f"{size:.1f} {units[unit_index]}"
|
||||
|
||||
|
||||
def parse_bytes(size_str: str) -> int:
|
||||
size_str = size_str.upper().strip()
|
||||
|
||||
units = {
|
||||
'B': 1,
|
||||
'KB': 1024,
|
||||
'MB': 1024 ** 2,
|
||||
'GB': 1024 ** 3,
|
||||
'TB': 1024 ** 4
|
||||
}
|
||||
|
||||
for unit, multiplier in units.items():
|
||||
if size_str.endswith(unit):
|
||||
try:
|
||||
value = float(size_str[:-len(unit)].strip())
|
||||
return int(value * multiplier)
|
||||
except ValueError:
|
||||
break
|
||||
|
||||
return 0
|
||||
|
||||
|
||||
async def test_api_connection(api: RemnaWaveAPI) -> bool:
|
||||
try:
|
||||
await api.get_system_stats()
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.error(f"API connection test failed: {e}")
|
||||
return False
|
||||
Vendored
+112
@@ -0,0 +1,112 @@
|
||||
import logging
|
||||
from typing import Optional, Dict, Any
|
||||
from aiogram import Bot
|
||||
from aiogram.types import LabeledPrice, InlineKeyboardMarkup, InlineKeyboardButton
|
||||
|
||||
from app.config import settings
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class TelegramStarsService:
|
||||
|
||||
def __init__(self, bot: Bot):
|
||||
self.bot = bot
|
||||
|
||||
@staticmethod
|
||||
def calculate_stars_from_rubles(rubles: float) -> int:
|
||||
return settings.rubles_to_stars(rubles)
|
||||
|
||||
@staticmethod
|
||||
def calculate_rubles_from_stars(stars: int) -> float:
|
||||
return settings.stars_to_rubles(stars)
|
||||
|
||||
async def create_invoice(
|
||||
self,
|
||||
chat_id: int,
|
||||
title: str,
|
||||
description: str,
|
||||
amount_kopeks: int,
|
||||
payload: str,
|
||||
start_parameter: Optional[str] = None
|
||||
) -> Optional[str]:
|
||||
try:
|
||||
amount_rubles = amount_kopeks / 100
|
||||
stars_amount = self.calculate_stars_from_rubles(amount_rubles)
|
||||
|
||||
invoice_link = await self.bot.create_invoice_link(
|
||||
title=title,
|
||||
description=description,
|
||||
payload=payload,
|
||||
provider_token="",
|
||||
currency="XTR",
|
||||
prices=[LabeledPrice(label=title, amount=stars_amount)],
|
||||
start_parameter=start_parameter
|
||||
)
|
||||
|
||||
logger.info(
|
||||
f"Создан Stars invoice на {stars_amount} звезд (~{amount_rubles:.2f}₽) "
|
||||
f"для {chat_id}, курс: {settings.get_stars_rate():.2f}₽/⭐"
|
||||
)
|
||||
return invoice_link
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка создания Stars invoice: {e}")
|
||||
return None
|
||||
|
||||
async def send_invoice(
|
||||
self,
|
||||
chat_id: int,
|
||||
title: str,
|
||||
description: str,
|
||||
amount_kopeks: int,
|
||||
payload: str,
|
||||
keyboard: Optional[InlineKeyboardMarkup] = None
|
||||
) -> Optional[Dict[str, Any]]:
|
||||
try:
|
||||
amount_rubles = amount_kopeks / 100
|
||||
stars_amount = self.calculate_stars_from_rubles(amount_rubles)
|
||||
|
||||
message = await self.bot.send_invoice(
|
||||
chat_id=chat_id,
|
||||
title=title,
|
||||
description=description,
|
||||
payload=payload,
|
||||
provider_token="",
|
||||
currency="XTR",
|
||||
prices=[LabeledPrice(label=title, amount=stars_amount)],
|
||||
reply_markup=keyboard
|
||||
)
|
||||
|
||||
logger.info(
|
||||
f"Отправлен Stars invoice {message.message_id} на {stars_amount} звезд "
|
||||
f"(~{amount_rubles:.2f}₽), курс: {settings.get_stars_rate():.2f}₽/⭐"
|
||||
)
|
||||
return {
|
||||
"message_id": message.message_id,
|
||||
"stars_amount": stars_amount,
|
||||
"rubles_amount": amount_rubles,
|
||||
"payload": payload
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка отправки Stars invoice: {e}")
|
||||
return None
|
||||
|
||||
async def answer_pre_checkout_query(
|
||||
self,
|
||||
pre_checkout_query_id: str,
|
||||
ok: bool = True,
|
||||
error_message: Optional[str] = None
|
||||
) -> bool:
|
||||
try:
|
||||
await self.bot.answer_pre_checkout_query(
|
||||
pre_checkout_query_id=pre_checkout_query_id,
|
||||
ok=ok,
|
||||
error_message=error_message
|
||||
)
|
||||
logger.info(f"Ответ на pre_checkout_query: ok={ok}")
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка ответа на pre_checkout_query: {e}")
|
||||
return False
|
||||
Vendored
+162
@@ -0,0 +1,162 @@
|
||||
import logging
|
||||
import hashlib
|
||||
import hmac
|
||||
import json
|
||||
from typing import Optional, Dict, Any
|
||||
|
||||
from app.config import settings
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class TributeService:
|
||||
|
||||
def __init__(self):
|
||||
self.api_key = settings.TRIBUTE_API_KEY
|
||||
self.webhook_secret = settings.TRIBUTE_WEBHOOK_SECRET
|
||||
self.donate_link = settings.TRIBUTE_DONATE_LINK
|
||||
|
||||
async def create_payment_link(
|
||||
self,
|
||||
user_id: int,
|
||||
amount_kopeks: int = 0,
|
||||
description: str = "Пополнение баланса"
|
||||
) -> Optional[str]:
|
||||
|
||||
if not settings.TRIBUTE_ENABLED:
|
||||
logger.warning("Tribute платежи отключены")
|
||||
return None
|
||||
|
||||
try:
|
||||
payment_url = f"{self.donate_link}&user_id={user_id}"
|
||||
|
||||
logger.info(f"Создана ссылка Tribute для пользователя {user_id}")
|
||||
return payment_url
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка создания Tribute ссылки: {e}")
|
||||
return None
|
||||
|
||||
def verify_webhook_signature(self, payload: str, signature: str) -> bool:
|
||||
|
||||
if not self.webhook_secret:
|
||||
logger.warning("Webhook secret не настроен, пропускаем проверку")
|
||||
return True
|
||||
|
||||
try:
|
||||
expected_signature = hmac.new(
|
||||
self.webhook_secret.encode(),
|
||||
payload.encode(),
|
||||
hashlib.sha256
|
||||
).hexdigest()
|
||||
|
||||
is_valid = hmac.compare_digest(signature, expected_signature)
|
||||
|
||||
if is_valid:
|
||||
logger.info("✅ Подпись Tribute webhook проверена успешно")
|
||||
else:
|
||||
logger.error("❌ Неверная подпись Tribute webhook")
|
||||
|
||||
return is_valid
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка проверки подписи webhook: {e}")
|
||||
return False
|
||||
|
||||
async def process_webhook(self, payload_or_data) -> Optional[Dict[str, Any]]:
|
||||
|
||||
try:
|
||||
logger.info(f"🔄 Начинаем обработку Tribute webhook")
|
||||
|
||||
if isinstance(payload_or_data, str):
|
||||
try:
|
||||
webhook_data = json.loads(payload_or_data)
|
||||
logger.info(f"📊 Распарсенные данные: {webhook_data}")
|
||||
except json.JSONDecodeError as e:
|
||||
logger.error(f"❌ Ошибка парсинга JSON: {e}")
|
||||
return None
|
||||
else:
|
||||
webhook_data = payload_or_data
|
||||
|
||||
payment_id = None
|
||||
status = None
|
||||
amount_kopeks = 0
|
||||
telegram_user_id = None
|
||||
|
||||
payment_id = webhook_data.get("id") or webhook_data.get("payment_id")
|
||||
status = webhook_data.get("status")
|
||||
amount_kopeks = webhook_data.get("amount", 0)
|
||||
telegram_user_id = webhook_data.get("telegram_user_id") or webhook_data.get("user_id")
|
||||
|
||||
if not payment_id and "payload" in webhook_data:
|
||||
data = webhook_data["payload"]
|
||||
payment_id = data.get("id") or data.get("payment_id")
|
||||
status = data.get("status")
|
||||
amount_kopeks = data.get("amount", 0)
|
||||
telegram_user_id = data.get("telegram_user_id") or data.get("user_id")
|
||||
|
||||
if not payment_id and "name" in webhook_data:
|
||||
event_name = webhook_data.get("name")
|
||||
data = webhook_data.get("payload", {})
|
||||
payment_id = str(data.get("donation_request_id"))
|
||||
amount_kopeks = data.get("amount", 0)
|
||||
telegram_user_id = data.get("telegram_user_id")
|
||||
|
||||
if event_name == "new_donation":
|
||||
status = "paid"
|
||||
elif event_name == "cancelled_subscription":
|
||||
status = "cancelled"
|
||||
else:
|
||||
status = "unknown"
|
||||
|
||||
logger.info(f"📝 Извлеченные данные: payment_id={payment_id}, status={status}, amount_kopeks={amount_kopeks}, user_id={telegram_user_id}")
|
||||
|
||||
if not telegram_user_id:
|
||||
logger.error("❌ Не найден telegram_user_id в webhook данных")
|
||||
logger.error(f"🔍 Полные данные для отладки: {json.dumps(webhook_data, ensure_ascii=False, indent=2)}")
|
||||
return None
|
||||
|
||||
try:
|
||||
telegram_user_id = int(telegram_user_id)
|
||||
except (ValueError, TypeError):
|
||||
logger.error(f"❌ Некорректный telegram_user_id: {telegram_user_id}")
|
||||
return None
|
||||
|
||||
result = {
|
||||
"event_type": "payment",
|
||||
"payment_id": payment_id or f"tribute_{telegram_user_id}_{amount_kopeks}",
|
||||
"user_id": telegram_user_id,
|
||||
"amount_kopeks": int(amount_kopeks) if amount_kopeks else 0,
|
||||
"status": status or "paid",
|
||||
"external_id": f"donation_{payment_id or 'unknown'}",
|
||||
"payment_system": "tribute"
|
||||
}
|
||||
|
||||
logger.info(f"✅ Tribute webhook обработан успешно: {result}")
|
||||
return result
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"❌ Ошибка обработки Tribute webhook: {e}", exc_info=True)
|
||||
logger.error(f"🔍 Webhook data для отладки: {json.dumps(webhook_data, ensure_ascii=False, indent=2)}")
|
||||
return None
|
||||
|
||||
async def get_payment_status(self, payment_id: str) -> Optional[Dict[str, Any]]:
|
||||
try:
|
||||
logger.info(f"Запрос статуса платежа {payment_id}")
|
||||
return {"status": "unknown", "payment_id": payment_id}
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка получения статуса платежа: {e}")
|
||||
return None
|
||||
|
||||
async def refund_payment(
|
||||
self,
|
||||
payment_id: str,
|
||||
amount_kopeks: Optional[int] = None,
|
||||
reason: str = "Возврат по запросу"
|
||||
) -> Optional[Dict[str, Any]]:
|
||||
try:
|
||||
logger.info(f"Создание возврата для платежа {payment_id}")
|
||||
return {"refund_id": f"refund_{payment_id}", "status": "pending"}
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка создания возврата: {e}")
|
||||
return None
|
||||
Vendored
+154
@@ -0,0 +1,154 @@
|
||||
import logging
|
||||
import json
|
||||
from typing import Optional
|
||||
|
||||
from aiohttp import web
|
||||
from aiogram import Bot
|
||||
|
||||
from app.config import settings
|
||||
from app.services.tribute_service import TributeService
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class WebhookServer:
|
||||
|
||||
def __init__(self, bot: Bot):
|
||||
self.bot = bot
|
||||
self.app = None
|
||||
self.runner = None
|
||||
self.site = None
|
||||
self.tribute_service = TributeService(bot)
|
||||
|
||||
async def create_app(self) -> web.Application:
|
||||
|
||||
self.app = web.Application()
|
||||
|
||||
self.app.router.add_post(settings.TRIBUTE_WEBHOOK_PATH, self._tribute_webhook_handler)
|
||||
self.app.router.add_get('/health', self._health_check)
|
||||
|
||||
self.app.router.add_options(settings.TRIBUTE_WEBHOOK_PATH, self._options_handler)
|
||||
|
||||
logger.info(f"Webhook сервер настроен:")
|
||||
logger.info(f" - Tribute webhook: POST {settings.TRIBUTE_WEBHOOK_PATH}")
|
||||
logger.info(f" - Health check: GET /health")
|
||||
|
||||
return self.app
|
||||
|
||||
async def start(self):
|
||||
|
||||
try:
|
||||
if not self.app:
|
||||
await self.create_app()
|
||||
|
||||
self.runner = web.AppRunner(self.app)
|
||||
await self.runner.setup()
|
||||
|
||||
self.site = web.TCPSite(
|
||||
self.runner,
|
||||
host='0.0.0.0',
|
||||
port=settings.TRIBUTE_WEBHOOK_PORT
|
||||
)
|
||||
|
||||
await self.site.start()
|
||||
|
||||
logger.info(f"✅ Tribute webhook сервер запущен на порту {settings.TRIBUTE_WEBHOOK_PORT}")
|
||||
logger.info(f"🎯 Tribute webhook URL: http://0.0.0.0:{settings.TRIBUTE_WEBHOOK_PORT}{settings.TRIBUTE_WEBHOOK_PATH}")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"❌ Ошибка запуска Tribute webhook сервера: {e}")
|
||||
raise
|
||||
|
||||
async def stop(self):
|
||||
|
||||
try:
|
||||
if self.site:
|
||||
await self.site.stop()
|
||||
logger.info("Tribute webhook сайт остановлен")
|
||||
|
||||
if self.runner:
|
||||
await self.runner.cleanup()
|
||||
logger.info("Tribute webhook runner очищен")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка остановки Tribute webhook сервера: {e}")
|
||||
|
||||
async def _options_handler(self, request: web.Request) -> web.Response:
|
||||
return web.Response(
|
||||
status=200,
|
||||
headers={
|
||||
'Access-Control-Allow-Origin': '*',
|
||||
'Access-Control-Allow-Methods': 'POST, GET, OPTIONS',
|
||||
'Access-Control-Allow-Headers': 'Content-Type, X-Tribute-Signature',
|
||||
}
|
||||
)
|
||||
|
||||
async def _tribute_webhook_handler(self, request: web.Request) -> web.Response:
|
||||
|
||||
try:
|
||||
logger.info(f"📥 Получен Tribute webhook: {request.method} {request.path}")
|
||||
logger.info(f"📋 Headers: {dict(request.headers)}")
|
||||
|
||||
raw_body = await request.read()
|
||||
|
||||
if not raw_body:
|
||||
logger.warning("⚠️ Получен пустой webhook от Tribute")
|
||||
return web.json_response(
|
||||
{"status": "error", "reason": "empty_body"},
|
||||
status=400
|
||||
)
|
||||
|
||||
payload = raw_body.decode('utf-8')
|
||||
logger.info(f"📄 Payload: {payload}")
|
||||
|
||||
try:
|
||||
webhook_data = json.loads(payload)
|
||||
logger.info(f"📊 Распарсенные данные: {webhook_data}")
|
||||
except json.JSONDecodeError as e:
|
||||
logger.error(f"❌ Ошибка парсинга JSON: {e}")
|
||||
return web.json_response(
|
||||
{"status": "error", "reason": "invalid_json"},
|
||||
status=400
|
||||
)
|
||||
|
||||
signature = request.headers.get('X-Tribute-Signature')
|
||||
logger.info(f"🔐 Signature: {signature}")
|
||||
|
||||
if signature and settings.TRIBUTE_WEBHOOK_SECRET:
|
||||
from app.external.tribute import TributeService as TributeAPI
|
||||
tribute_api = TributeAPI()
|
||||
if not tribute_api.verify_webhook_signature(payload, signature):
|
||||
logger.error("❌ Неверная подпись Tribute webhook")
|
||||
return web.json_response(
|
||||
{"status": "error", "reason": "invalid_signature"},
|
||||
status=401
|
||||
)
|
||||
|
||||
result = await self.tribute_service.process_webhook(payload, signature)
|
||||
|
||||
if result:
|
||||
logger.info(f"✅ Tribute webhook обработан успешно: {result}")
|
||||
return web.json_response({"status": "ok", "result": result}, status=200)
|
||||
else:
|
||||
logger.error("❌ Ошибка обработки Tribute webhook")
|
||||
return web.json_response(
|
||||
{"status": "error", "reason": "processing_failed"},
|
||||
status=400
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"❌ Критическая ошибка обработки Tribute webhook: {e}", exc_info=True)
|
||||
return web.json_response(
|
||||
{"status": "error", "reason": "internal_error", "message": str(e)},
|
||||
status=500
|
||||
)
|
||||
|
||||
async def _health_check(self, request: web.Request) -> web.Response:
|
||||
|
||||
return web.json_response({
|
||||
"status": "ok",
|
||||
"service": "tribute-webhooks",
|
||||
"tribute_enabled": settings.TRIBUTE_ENABLED,
|
||||
"port": settings.TRIBUTE_WEBHOOK_PORT,
|
||||
"path": settings.TRIBUTE_WEBHOOK_PATH
|
||||
})
|
||||
Vendored
+243
@@ -0,0 +1,243 @@
|
||||
import asyncio
|
||||
import logging
|
||||
import json
|
||||
import hashlib
|
||||
import hmac
|
||||
import base64
|
||||
from typing import Optional, Dict, Any
|
||||
from aiohttp import web
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.config import settings
|
||||
from app.services.payment_service import PaymentService
|
||||
from app.database.database import get_db
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class YooKassaWebhookHandler:
|
||||
|
||||
@staticmethod
|
||||
def verify_webhook_signature(body: str, signature: str, secret: str) -> bool:
|
||||
try:
|
||||
signature_parts = signature.strip().split(' ')
|
||||
|
||||
if len(signature_parts) < 4:
|
||||
logger.error(f"Неверный формат подписи YooKassa: {signature}")
|
||||
return False
|
||||
|
||||
version = signature_parts[0]
|
||||
payment_id = signature_parts[1]
|
||||
timestamp = signature_parts[2]
|
||||
received_signature = signature_parts[3]
|
||||
|
||||
if version != "v1":
|
||||
logger.error(f"Неподдерживаемая версия подписи: {version}")
|
||||
return False
|
||||
|
||||
logger.info(f"Проверка подписи v1 для платежа {payment_id}, timestamp: {timestamp}")
|
||||
|
||||
|
||||
expected_signature_1 = hmac.new(
|
||||
secret.encode('utf-8'),
|
||||
body.encode('utf-8'),
|
||||
hashlib.sha256
|
||||
).digest()
|
||||
expected_signature_1_b64 = base64.b64encode(expected_signature_1).decode('utf-8')
|
||||
|
||||
signed_payload_2 = f"{payment_id}.{timestamp}.{body}"
|
||||
expected_signature_2 = hmac.new(
|
||||
secret.encode('utf-8'),
|
||||
signed_payload_2.encode('utf-8'),
|
||||
hashlib.sha256
|
||||
).digest()
|
||||
expected_signature_2_b64 = base64.b64encode(expected_signature_2).decode('utf-8')
|
||||
|
||||
signed_payload_3 = f"{timestamp}.{body}"
|
||||
expected_signature_3 = hmac.new(
|
||||
secret.encode('utf-8'),
|
||||
signed_payload_3.encode('utf-8'),
|
||||
hashlib.sha256
|
||||
).digest()
|
||||
expected_signature_3_b64 = base64.b64encode(expected_signature_3).decode('utf-8')
|
||||
|
||||
logger.debug(f"Получена подпись: {received_signature}")
|
||||
logger.debug(f"Ожидаемая подпись (вариант 1): {expected_signature_1_b64}")
|
||||
logger.debug(f"Ожидаемая подпись (вариант 2): {expected_signature_2_b64}")
|
||||
logger.debug(f"Ожидаемая подпись (вариант 3): {expected_signature_3_b64}")
|
||||
|
||||
is_valid = (
|
||||
hmac.compare_digest(received_signature, expected_signature_1_b64) or
|
||||
hmac.compare_digest(received_signature, expected_signature_2_b64) or
|
||||
hmac.compare_digest(received_signature, expected_signature_3_b64)
|
||||
)
|
||||
|
||||
if is_valid:
|
||||
logger.info("✅ Подпись YooKassa webhook проверена успешно")
|
||||
else:
|
||||
logger.warning("⚠️ Подпись YooKassa webhook не совпадает ни с одним вариантом")
|
||||
|
||||
return is_valid
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка проверки подписи YooKassa: {e}")
|
||||
return False
|
||||
|
||||
def __init__(self, payment_service: PaymentService):
|
||||
self.payment_service = payment_service
|
||||
|
||||
async def handle_webhook(self, request: web.Request) -> web.Response:
|
||||
|
||||
try:
|
||||
logger.info(f"📥 Получен YooKassa webhook: {request.method} {request.path}")
|
||||
logger.info(f"📋 Headers: {dict(request.headers)}")
|
||||
|
||||
body = await request.text()
|
||||
|
||||
if not body:
|
||||
logger.warning("⚠️ Получен пустой webhook от YooKassa")
|
||||
return web.Response(status=400, text="Empty body")
|
||||
|
||||
logger.info(f"📄 Body: {body}")
|
||||
|
||||
signature = request.headers.get('Signature') or request.headers.get('X-YooKassa-Signature')
|
||||
|
||||
if settings.YOOKASSA_WEBHOOK_SECRET and signature:
|
||||
logger.info(f"🔐 Получена подпись: {signature}")
|
||||
|
||||
if not YooKassaWebhookHandler.verify_webhook_signature(body, signature, settings.YOOKASSA_WEBHOOK_SECRET):
|
||||
logger.warning("❌ Подпись не совпала, но продолжаем обработку (режим отладки)")
|
||||
else:
|
||||
logger.info("✅ Подпись webhook проверена успешно")
|
||||
|
||||
elif settings.YOOKASSA_WEBHOOK_SECRET and not signature:
|
||||
logger.warning("⚠️ Webhook без подписи, но секрет настроен")
|
||||
|
||||
elif signature and not settings.YOOKASSA_WEBHOOK_SECRET:
|
||||
logger.info("ℹ️ Подпись получена, но проверка отключена (YOOKASSA_WEBHOOK_SECRET не настроен)")
|
||||
|
||||
else:
|
||||
logger.info("ℹ️ Проверка подписи отключена")
|
||||
|
||||
try:
|
||||
webhook_data = json.loads(body)
|
||||
except json.JSONDecodeError as e:
|
||||
logger.error(f"❌ Ошибка парсинга JSON webhook YooKassa: {e}")
|
||||
return web.Response(status=400, text="Invalid JSON")
|
||||
|
||||
logger.info(f"📊 Обработка webhook YooKassa: {webhook_data.get('event', 'unknown_event')}")
|
||||
logger.debug(f"🔍 Полные данные webhook: {webhook_data}")
|
||||
|
||||
event_type = webhook_data.get("event")
|
||||
if not event_type:
|
||||
logger.warning("⚠️ Webhook YooKassa без типа события")
|
||||
return web.Response(status=400, text="No event type")
|
||||
|
||||
if event_type not in ["payment.succeeded", "payment.waiting_for_capture"]:
|
||||
logger.info(f"ℹ️ Игнорируем событие YooKassa: {event_type}")
|
||||
return web.Response(status=200, text="OK")
|
||||
|
||||
async for db in get_db():
|
||||
try:
|
||||
success = await self.payment_service.process_yookassa_webhook(db, webhook_data)
|
||||
|
||||
if success:
|
||||
logger.info(f"✅ Успешно обработан webhook YooKassa: {event_type}")
|
||||
return web.Response(status=200, text="OK")
|
||||
else:
|
||||
logger.error(f"❌ Ошибка обработки webhook YooKassa: {event_type}")
|
||||
return web.Response(status=500, text="Processing error")
|
||||
|
||||
finally:
|
||||
await db.close()
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"❌ Критическая ошибка обработки webhook YooKassa: {e}", exc_info=True)
|
||||
return web.Response(status=500, text="Internal server error")
|
||||
|
||||
def setup_routes(self, app: web.Application) -> None:
|
||||
|
||||
webhook_path = settings.YOOKASSA_WEBHOOK_PATH
|
||||
app.router.add_post(webhook_path, self.handle_webhook)
|
||||
app.router.add_get(webhook_path, self._get_handler)
|
||||
app.router.add_options(webhook_path, self._options_handler)
|
||||
|
||||
logger.info(f"✅ Настроен YooKassa webhook на пути: POST {webhook_path}")
|
||||
|
||||
async def _get_handler(self, request: web.Request) -> web.Response:
|
||||
return web.json_response({
|
||||
"status": "ok",
|
||||
"message": "YooKassa webhook endpoint is working",
|
||||
"method": "GET",
|
||||
"path": request.path,
|
||||
"note": "Use POST method for actual webhooks"
|
||||
})
|
||||
|
||||
async def _options_handler(self, request: web.Request) -> web.Response:
|
||||
return web.Response(
|
||||
status=200,
|
||||
headers={
|
||||
'Access-Control-Allow-Origin': '*',
|
||||
'Access-Control-Allow-Methods': 'POST, GET, OPTIONS',
|
||||
'Access-Control-Allow-Headers': 'Content-Type, X-YooKassa-Signature',
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
def create_yookassa_webhook_app(payment_service: PaymentService) -> web.Application:
|
||||
|
||||
app = web.Application()
|
||||
|
||||
webhook_handler = YooKassaWebhookHandler(payment_service)
|
||||
webhook_handler.setup_routes(app)
|
||||
|
||||
async def health_check(request):
|
||||
return web.json_response({
|
||||
"status": "ok",
|
||||
"service": "yookassa_webhook",
|
||||
"port": settings.YOOKASSA_WEBHOOK_PORT,
|
||||
"path": settings.YOOKASSA_WEBHOOK_PATH,
|
||||
"enabled": settings.is_yookassa_enabled()
|
||||
})
|
||||
|
||||
app.router.add_get("/health", health_check)
|
||||
|
||||
return app
|
||||
|
||||
|
||||
async def start_yookassa_webhook_server(payment_service: PaymentService) -> None:
|
||||
|
||||
if not settings.is_yookassa_enabled():
|
||||
logger.info("ℹ️ YooKassa отключена, webhook сервер не запускается")
|
||||
return
|
||||
|
||||
try:
|
||||
app = create_yookassa_webhook_app(payment_service)
|
||||
|
||||
runner = web.AppRunner(app)
|
||||
await runner.setup()
|
||||
|
||||
site = web.TCPSite(
|
||||
runner,
|
||||
host='0.0.0.0',
|
||||
port=settings.YOOKASSA_WEBHOOK_PORT
|
||||
)
|
||||
|
||||
await site.start()
|
||||
|
||||
logger.info(f"✅ YooKassa webhook сервер запущен на порту {settings.YOOKASSA_WEBHOOK_PORT}")
|
||||
logger.info(f"🎯 YooKassa webhook URL: http://0.0.0.0:{settings.YOOKASSA_WEBHOOK_PORT}{settings.YOOKASSA_WEBHOOK_PATH}")
|
||||
|
||||
try:
|
||||
while True:
|
||||
await asyncio.sleep(1)
|
||||
except asyncio.CancelledError:
|
||||
logger.info("🛑 YooKassa webhook сервер получил сигнал остановки")
|
||||
finally:
|
||||
await site.stop()
|
||||
await runner.cleanup()
|
||||
logger.info("✅ YooKassa webhook сервер остановлен")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"❌ Ошибка запуска YooKassa webhook сервера: {e}", exc_info=True)
|
||||
raise
|
||||
@@ -0,0 +1 @@
|
||||
# Инициализация админских обработчиков
|
||||
@@ -0,0 +1,37 @@
|
||||
import logging
|
||||
from aiogram import Dispatcher, types, F
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.config import settings
|
||||
from app.database.models import User
|
||||
from app.keyboards.admin import get_admin_main_keyboard
|
||||
from app.localization.texts import get_texts
|
||||
from app.utils.decorators import admin_required, error_handler
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def show_admin_panel(
|
||||
callback: types.CallbackQuery,
|
||||
db_user: User,
|
||||
db: AsyncSession
|
||||
):
|
||||
texts = get_texts(db_user.language)
|
||||
|
||||
admin_text = texts.ADMIN_PANEL
|
||||
|
||||
await callback.message.edit_text(
|
||||
admin_text,
|
||||
reply_markup=get_admin_main_keyboard(db_user.language)
|
||||
)
|
||||
await callback.answer()
|
||||
|
||||
|
||||
def register_handlers(dp: Dispatcher):
|
||||
|
||||
dp.callback_query.register(
|
||||
show_admin_panel,
|
||||
F.data == "admin_panel"
|
||||
)
|
||||
@@ -0,0 +1,235 @@
|
||||
import logging
|
||||
from aiogram import Dispatcher, types, F
|
||||
from aiogram.fsm.context import FSMContext
|
||||
from aiogram.fsm.state import State, StatesGroup
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.config import settings
|
||||
from app.database.models import User
|
||||
from app.services.maintenance_service import maintenance_service
|
||||
from app.keyboards.admin import get_maintenance_keyboard, get_admin_main_keyboard
|
||||
from app.localization.texts import get_texts
|
||||
from app.utils.decorators import admin_required, error_handler
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class MaintenanceStates(StatesGroup):
|
||||
waiting_for_reason = State()
|
||||
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def show_maintenance_panel(
|
||||
callback: types.CallbackQuery,
|
||||
db_user: User,
|
||||
db: AsyncSession,
|
||||
state: FSMContext
|
||||
):
|
||||
texts = get_texts(db_user.language)
|
||||
|
||||
status_info = maintenance_service.get_status_info()
|
||||
|
||||
status_emoji = "🔧" if status_info["is_active"] else "✅"
|
||||
status_text = "Включен" if status_info["is_active"] else "Выключен"
|
||||
|
||||
api_emoji = "✅" if status_info["api_status"] else "❌"
|
||||
api_text = "Доступно" if status_info["api_status"] else "Недоступно"
|
||||
|
||||
monitoring_emoji = "🔄" if status_info["monitoring_active"] else "⏹️"
|
||||
monitoring_text = "Запущен" if status_info["monitoring_active"] else "Остановлен"
|
||||
|
||||
enabled_info = ""
|
||||
if status_info["is_active"] and status_info["enabled_at"]:
|
||||
enabled_time = status_info["enabled_at"].strftime("%d.%m.%Y %H:%M:%S")
|
||||
enabled_info = f"\n📅 <b>Включен:</b> {enabled_time}"
|
||||
if status_info["reason"]:
|
||||
enabled_info += f"\n📝 <b>Причина:</b> {status_info['reason']}"
|
||||
|
||||
last_check_info = ""
|
||||
if status_info["last_check"]:
|
||||
last_check_time = status_info["last_check"].strftime("%H:%M:%S")
|
||||
last_check_info = f"\n🕐 <b>Последняя проверка:</b> {last_check_time}"
|
||||
|
||||
failures_info = ""
|
||||
if status_info["consecutive_failures"] > 0:
|
||||
failures_info = f"\n⚠️ <b>Неудачных проверок подряд:</b> {status_info['consecutive_failures']}"
|
||||
|
||||
message_text = f"""
|
||||
🔧 <b>Режим технических работ</b>
|
||||
|
||||
{status_emoji} <b>Статус:</b> {status_text}
|
||||
{api_emoji} <b>API RemnaWave:</b> {api_text}
|
||||
{monitoring_emoji} <b>Мониторинг:</b> {monitoring_text}
|
||||
⏱️ <b>Интервал проверки:</b> {status_info['check_interval']}с
|
||||
🤖 <b>Автовключение:</b> {'Включено' if status_info['auto_enable_configured'] else 'Отключено'}
|
||||
{enabled_info}
|
||||
{last_check_info}
|
||||
{failures_info}
|
||||
|
||||
ℹ️ <i>В режиме техработ обычные пользователи не могут использовать бота. Администраторы имеют полный доступ.</i>
|
||||
"""
|
||||
|
||||
await callback.message.edit_text(
|
||||
message_text,
|
||||
reply_markup=get_maintenance_keyboard(db_user.language, status_info["is_active"], status_info["monitoring_active"])
|
||||
)
|
||||
await callback.answer()
|
||||
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def toggle_maintenance_mode(
|
||||
callback: types.CallbackQuery,
|
||||
db_user: User,
|
||||
db: AsyncSession,
|
||||
state: FSMContext
|
||||
):
|
||||
is_active = maintenance_service.is_maintenance_active()
|
||||
|
||||
if is_active:
|
||||
success = await maintenance_service.disable_maintenance()
|
||||
if success:
|
||||
await callback.answer("Режим техработ выключен", show_alert=True)
|
||||
else:
|
||||
await callback.answer("Ошибка выключения режима техработ", show_alert=True)
|
||||
else:
|
||||
await state.set_state(MaintenanceStates.waiting_for_reason)
|
||||
await callback.message.edit_text(
|
||||
"🔧 <b>Включение режима техработ</b>\n\nВведите причину включения техработ или отправьте /skip для пропуска:",
|
||||
reply_markup=types.InlineKeyboardMarkup(inline_keyboard=[
|
||||
[types.InlineKeyboardButton(text="❌ Отмена", callback_data="maintenance_panel")]
|
||||
])
|
||||
)
|
||||
|
||||
await callback.answer()
|
||||
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def process_maintenance_reason(
|
||||
message: types.Message,
|
||||
db_user: User,
|
||||
db: AsyncSession,
|
||||
state: FSMContext
|
||||
):
|
||||
current_state = await state.get_state()
|
||||
|
||||
if current_state != MaintenanceStates.waiting_for_reason:
|
||||
return
|
||||
|
||||
reason = None
|
||||
if message.text and message.text != "/skip":
|
||||
reason = message.text[:200]
|
||||
|
||||
success = await maintenance_service.enable_maintenance(reason=reason, auto=False)
|
||||
|
||||
if success:
|
||||
response_text = "Режим техработ включен"
|
||||
if reason:
|
||||
response_text += f"\nПричина: {reason}"
|
||||
else:
|
||||
response_text = "Ошибка включения режима техработ"
|
||||
|
||||
await message.answer(response_text)
|
||||
await state.clear()
|
||||
|
||||
status_info = maintenance_service.get_status_info()
|
||||
await message.answer(
|
||||
"Вернуться к панели управления техработами:",
|
||||
reply_markup=types.InlineKeyboardMarkup(inline_keyboard=[
|
||||
[types.InlineKeyboardButton(text="🔧 Панель техработ", callback_data="maintenance_panel")]
|
||||
])
|
||||
)
|
||||
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def toggle_monitoring(
|
||||
callback: types.CallbackQuery,
|
||||
db_user: User,
|
||||
db: AsyncSession
|
||||
):
|
||||
status_info = maintenance_service.get_status_info()
|
||||
|
||||
if status_info["monitoring_active"]:
|
||||
success = await maintenance_service.stop_monitoring()
|
||||
message = "Мониторинг остановлен" if success else "Ошибка остановки мониторинга"
|
||||
else:
|
||||
success = await maintenance_service.start_monitoring()
|
||||
message = "Мониторинг запущен" if success else "Ошибка запуска мониторинга"
|
||||
|
||||
await callback.answer(message, show_alert=True)
|
||||
|
||||
await show_maintenance_panel(callback, db_user, db, None)
|
||||
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def force_api_check(
|
||||
callback: types.CallbackQuery,
|
||||
db_user: User,
|
||||
db: AsyncSession
|
||||
):
|
||||
await callback.answer("Проверка API...", show_alert=False)
|
||||
|
||||
check_result = await maintenance_service.force_api_check()
|
||||
|
||||
if check_result["success"]:
|
||||
status_text = "доступно" if check_result["api_available"] else "недоступно"
|
||||
message = f"API {status_text}\nВремя ответа: {check_result['response_time']}с"
|
||||
else:
|
||||
message = f"Ошибка проверки: {check_result.get('error', 'Неизвестная ошибка')}"
|
||||
|
||||
await callback.message.answer(message)
|
||||
|
||||
await show_maintenance_panel(callback, db_user, db, None)
|
||||
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def back_to_admin_panel(
|
||||
callback: types.CallbackQuery,
|
||||
db_user: User,
|
||||
db: AsyncSession
|
||||
):
|
||||
texts = get_texts(db_user.language)
|
||||
|
||||
await callback.message.edit_text(
|
||||
texts.ADMIN_PANEL,
|
||||
reply_markup=get_admin_main_keyboard(db_user.language)
|
||||
)
|
||||
await callback.answer()
|
||||
|
||||
|
||||
def register_handlers(dp: Dispatcher):
|
||||
|
||||
dp.callback_query.register(
|
||||
show_maintenance_panel,
|
||||
F.data == "maintenance_panel"
|
||||
)
|
||||
|
||||
dp.callback_query.register(
|
||||
toggle_maintenance_mode,
|
||||
F.data == "maintenance_toggle"
|
||||
)
|
||||
|
||||
dp.callback_query.register(
|
||||
toggle_monitoring,
|
||||
F.data == "maintenance_monitoring"
|
||||
)
|
||||
|
||||
dp.callback_query.register(
|
||||
force_api_check,
|
||||
F.data == "maintenance_check_api"
|
||||
)
|
||||
|
||||
dp.callback_query.register(
|
||||
back_to_admin_panel,
|
||||
F.data == "admin_panel"
|
||||
)
|
||||
|
||||
dp.message.register(
|
||||
process_maintenance_reason,
|
||||
MaintenanceStates.waiting_for_reason
|
||||
)
|
||||
@@ -0,0 +1,560 @@
|
||||
import logging
|
||||
import asyncio
|
||||
from datetime import datetime, timedelta
|
||||
from typing import Optional
|
||||
from aiogram import Dispatcher, types, F
|
||||
from aiogram.fsm.context import FSMContext
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy import select, func, and_, or_
|
||||
|
||||
from app.config import settings
|
||||
from app.states import AdminStates
|
||||
from app.database.models import User, UserStatus, Subscription, BroadcastHistory
|
||||
from app.keyboards.admin import (
|
||||
get_admin_messages_keyboard, get_broadcast_target_keyboard,
|
||||
get_custom_criteria_keyboard, get_broadcast_history_keyboard,
|
||||
get_admin_pagination_keyboard
|
||||
)
|
||||
from app.localization.texts import get_texts
|
||||
from app.database.crud.user import get_users_list
|
||||
from app.database.crud.subscription import get_expiring_subscriptions
|
||||
from app.utils.decorators import admin_required, error_handler
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def show_messages_menu(
|
||||
callback: types.CallbackQuery,
|
||||
db_user: User,
|
||||
db: AsyncSession
|
||||
):
|
||||
text = """
|
||||
📨 <b>Управление рассылками</b>
|
||||
|
||||
Выберите тип рассылки:
|
||||
|
||||
- <b>Всем пользователям</b> - рассылка всем активным пользователям
|
||||
- <b>По подпискам</b> - фильтрация по типу подписки
|
||||
- <b>По критериям</b> - настраиваемые фильтры
|
||||
- <b>История</b> - просмотр предыдущих рассылок
|
||||
|
||||
⚠️ Будьте осторожны с массовыми рассылками!
|
||||
"""
|
||||
|
||||
await callback.message.edit_text(
|
||||
text,
|
||||
reply_markup=get_admin_messages_keyboard(db_user.language),
|
||||
parse_mode="HTML"
|
||||
)
|
||||
await callback.answer()
|
||||
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def show_broadcast_targets(
|
||||
callback: types.CallbackQuery,
|
||||
db_user: User,
|
||||
state: FSMContext
|
||||
):
|
||||
await callback.message.edit_text(
|
||||
"🎯 <b>Выбор целевой аудитории</b>\n\n"
|
||||
"Выберите категорию пользователей для рассылки:",
|
||||
reply_markup=get_broadcast_target_keyboard(db_user.language),
|
||||
parse_mode="HTML"
|
||||
)
|
||||
await callback.answer()
|
||||
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def show_messages_history(
|
||||
callback: types.CallbackQuery,
|
||||
db_user: User,
|
||||
db: AsyncSession
|
||||
):
|
||||
page = 1
|
||||
if '_page_' in callback.data:
|
||||
page = int(callback.data.split('_page_')[1])
|
||||
|
||||
limit = 10
|
||||
offset = (page - 1) * limit
|
||||
|
||||
stmt = select(BroadcastHistory).order_by(BroadcastHistory.created_at.desc()).offset(offset).limit(limit)
|
||||
result = await db.execute(stmt)
|
||||
broadcasts = result.scalars().all()
|
||||
|
||||
count_stmt = select(func.count(BroadcastHistory.id))
|
||||
count_result = await db.execute(count_stmt)
|
||||
total_count = count_result.scalar() or 0
|
||||
total_pages = (total_count + limit - 1) // limit
|
||||
|
||||
if not broadcasts:
|
||||
text = """
|
||||
📋 <b>История рассылок</b>
|
||||
|
||||
❌ История рассылок пуста.
|
||||
Отправьте первую рассылку, чтобы увидеть её здесь.
|
||||
"""
|
||||
keyboard = [[types.InlineKeyboardButton(text="⬅️ Назад", callback_data="admin_messages")]]
|
||||
else:
|
||||
text = f"📋 <b>История рассылок</b> (страница {page}/{total_pages})\n\n"
|
||||
|
||||
for broadcast in broadcasts:
|
||||
status_emoji = "✅" if broadcast.status == "completed" else "❌" if broadcast.status == "failed" else "⏳"
|
||||
success_rate = round((broadcast.sent_count / broadcast.total_count * 100), 1) if broadcast.total_count > 0 else 0
|
||||
|
||||
message_preview = broadcast.message_text[:100] + "..." if len(broadcast.message_text) > 100 else broadcast.message_text
|
||||
|
||||
text += f"""
|
||||
{status_emoji} <b>{broadcast.created_at.strftime('%d.%m.%Y %H:%M')}</b>
|
||||
📊 Отправлено: {broadcast.sent_count}/{broadcast.total_count} ({success_rate}%)
|
||||
🎯 Аудитория: {get_target_name(broadcast.target_type)}
|
||||
👤 Админ: {broadcast.admin_name}
|
||||
📝 Сообщение: <i>{message_preview}</i>
|
||||
━━━━━━━━━━━━━━━━━━━━
|
||||
"""
|
||||
|
||||
keyboard = get_broadcast_history_keyboard(page, total_pages, db_user.language).inline_keyboard
|
||||
|
||||
await callback.message.edit_text(
|
||||
text,
|
||||
reply_markup=types.InlineKeyboardMarkup(inline_keyboard=keyboard),
|
||||
parse_mode="HTML"
|
||||
)
|
||||
await callback.answer()
|
||||
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def show_custom_broadcast(
|
||||
callback: types.CallbackQuery,
|
||||
db_user: User,
|
||||
state: FSMContext,
|
||||
db: AsyncSession
|
||||
):
|
||||
|
||||
stats = await get_users_statistics(db)
|
||||
|
||||
text = f"""
|
||||
📝 <b>Рассылка по критериям</b>
|
||||
|
||||
📊 <b>Доступные фильтры:</b>
|
||||
|
||||
👥 <b>По регистрации:</b>
|
||||
• Сегодня: {stats['today']} чел.
|
||||
• За неделю: {stats['week']} чел.
|
||||
• За месяц: {stats['month']} чел.
|
||||
|
||||
💼 <b>По активности:</b>
|
||||
• Активные сегодня: {stats['active_today']} чел.
|
||||
• Неактивные 7+ дней: {stats['inactive_week']} чел.
|
||||
• Неактивные 30+ дней: {stats['inactive_month']} чел.
|
||||
|
||||
🔗 <b>По источнику:</b>
|
||||
• Через рефералов: {stats['referrals']} чел.
|
||||
• Прямая регистрация: {stats['direct']} чел.
|
||||
|
||||
Выберите критерий для фильтрации:
|
||||
"""
|
||||
|
||||
await callback.message.edit_text(
|
||||
text,
|
||||
reply_markup=get_custom_criteria_keyboard(db_user.language),
|
||||
parse_mode="HTML"
|
||||
)
|
||||
await callback.answer()
|
||||
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def select_custom_criteria(
|
||||
callback: types.CallbackQuery,
|
||||
db_user: User,
|
||||
state: FSMContext,
|
||||
db: AsyncSession
|
||||
):
|
||||
criteria = callback.data.replace('criteria_', '')
|
||||
|
||||
criteria_names = {
|
||||
"today": "Зарегистрированные сегодня",
|
||||
"week": "Зарегистрированные за неделю",
|
||||
"month": "Зарегистрированные за месяц",
|
||||
"active_today": "Активные сегодня",
|
||||
"inactive_week": "Неактивные 7+ дней",
|
||||
"inactive_month": "Неактивные 30+ дней",
|
||||
"referrals": "Пришедшие через рефералов",
|
||||
"direct": "Прямая регистрация"
|
||||
}
|
||||
|
||||
user_count = await get_custom_users_count(db, criteria)
|
||||
|
||||
await state.update_data(broadcast_target=f"custom_{criteria}")
|
||||
|
||||
await callback.message.edit_text(
|
||||
f"📨 <b>Создание рассылки</b>\n\n"
|
||||
f"🎯 <b>Критерий:</b> {criteria_names.get(criteria, criteria)}\n"
|
||||
f"👥 <b>Получателей:</b> {user_count}\n\n"
|
||||
f"Введите текст сообщения для рассылки:\n\n"
|
||||
f"<i>Поддерживается HTML разметка</i>",
|
||||
reply_markup=types.InlineKeyboardMarkup(inline_keyboard=[
|
||||
[types.InlineKeyboardButton(text="❌ Отмена", callback_data="admin_messages")]
|
||||
]),
|
||||
parse_mode="HTML"
|
||||
)
|
||||
|
||||
await state.set_state(AdminStates.waiting_for_broadcast_message)
|
||||
await callback.answer()
|
||||
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def select_broadcast_target(
|
||||
callback: types.CallbackQuery,
|
||||
db_user: User,
|
||||
state: FSMContext,
|
||||
db: AsyncSession
|
||||
):
|
||||
target = callback.data.split('_')[-1]
|
||||
|
||||
target_names = {
|
||||
"all": "Всем пользователям",
|
||||
"active": "С активной подпиской",
|
||||
"trial": "С триальной подпиской",
|
||||
"no": "Без подписки",
|
||||
"expiring": "С истекающей подпиской"
|
||||
}
|
||||
|
||||
user_count = await get_target_users_count(db, target)
|
||||
|
||||
await state.update_data(broadcast_target=target)
|
||||
|
||||
await callback.message.edit_text(
|
||||
f"📨 <b>Создание рассылки</b>\n\n"
|
||||
f"🎯 <b>Аудитория:</b> {target_names.get(target, target)}\n"
|
||||
f"👥 <b>Получателей:</b> {user_count}\n\n"
|
||||
f"Введите текст сообщения для рассылки:\n\n"
|
||||
f"<i>Поддерживается HTML разметка</i>",
|
||||
reply_markup=types.InlineKeyboardMarkup(inline_keyboard=[
|
||||
[types.InlineKeyboardButton(text="❌ Отмена", callback_data="admin_messages")]
|
||||
]),
|
||||
parse_mode="HTML"
|
||||
)
|
||||
|
||||
await state.set_state(AdminStates.waiting_for_broadcast_message)
|
||||
await callback.answer()
|
||||
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def process_broadcast_message(
|
||||
message: types.Message,
|
||||
db_user: User,
|
||||
state: FSMContext,
|
||||
db: AsyncSession
|
||||
):
|
||||
broadcast_text = message.text
|
||||
|
||||
if len(broadcast_text) > 4000:
|
||||
await message.answer("❌ Сообщение слишком длинное (максимум 4000 символов)")
|
||||
return
|
||||
|
||||
data = await state.get_data()
|
||||
target = data.get('broadcast_target')
|
||||
|
||||
user_count = await get_target_users_count(db, target) if not target.startswith('custom_') else await get_custom_users_count(db, target.replace('custom_', ''))
|
||||
|
||||
await state.update_data(broadcast_message=broadcast_text)
|
||||
|
||||
target_display = get_target_display_name(target)
|
||||
|
||||
preview_text = f"""
|
||||
📨 <b>Предварительный просмотр рассылки</b>
|
||||
|
||||
🎯 <b>Аудитория:</b> {target_display}
|
||||
👥 <b>Получателей:</b> {user_count}
|
||||
|
||||
📝 <b>Сообщение:</b>
|
||||
{broadcast_text}
|
||||
|
||||
Подтвердить отправку?
|
||||
"""
|
||||
|
||||
keyboard = [
|
||||
[
|
||||
types.InlineKeyboardButton(text="✅ Отправить", callback_data="admin_confirm_broadcast"),
|
||||
types.InlineKeyboardButton(text="❌ Отмена", callback_data="admin_messages")
|
||||
]
|
||||
]
|
||||
|
||||
await message.answer(
|
||||
preview_text,
|
||||
reply_markup=types.InlineKeyboardMarkup(inline_keyboard=keyboard),
|
||||
parse_mode="HTML"
|
||||
)
|
||||
await state.set_state(AdminStates.confirming_broadcast)
|
||||
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def confirm_broadcast(
|
||||
callback: types.CallbackQuery,
|
||||
db_user: User,
|
||||
state: FSMContext,
|
||||
db: AsyncSession
|
||||
):
|
||||
data = await state.get_data()
|
||||
target = data.get('broadcast_target')
|
||||
message_text = data.get('broadcast_message')
|
||||
|
||||
await callback.message.edit_text(
|
||||
"📨 Начинаю рассылку...\n\n"
|
||||
"⏳ Это может занять несколько минут.",
|
||||
reply_markup=None,
|
||||
parse_mode="HTML"
|
||||
)
|
||||
|
||||
if target.startswith('custom_'):
|
||||
users = await get_custom_users(db, target.replace('custom_', ''))
|
||||
else:
|
||||
users = await get_target_users(db, target)
|
||||
|
||||
broadcast_history = BroadcastHistory(
|
||||
target_type=target,
|
||||
message_text=message_text,
|
||||
total_count=len(users),
|
||||
sent_count=0,
|
||||
failed_count=0,
|
||||
admin_id=db_user.id,
|
||||
admin_name=db_user.full_name,
|
||||
status="in_progress"
|
||||
)
|
||||
db.add(broadcast_history)
|
||||
await db.commit()
|
||||
await db.refresh(broadcast_history)
|
||||
|
||||
sent_count = 0
|
||||
failed_count = 0
|
||||
|
||||
for user in users:
|
||||
try:
|
||||
await callback.bot.send_message(
|
||||
chat_id=user.telegram_id,
|
||||
text=message_text,
|
||||
parse_mode="HTML"
|
||||
)
|
||||
sent_count += 1
|
||||
|
||||
if sent_count % 20 == 0:
|
||||
await asyncio.sleep(1)
|
||||
|
||||
except Exception as e:
|
||||
failed_count += 1
|
||||
logger.error(f"Ошибка отправки рассылки пользователю {user.telegram_id}: {e}")
|
||||
|
||||
broadcast_history.sent_count = sent_count
|
||||
broadcast_history.failed_count = failed_count
|
||||
broadcast_history.status = "completed" if failed_count == 0 else "partial"
|
||||
broadcast_history.completed_at = datetime.utcnow()
|
||||
await db.commit()
|
||||
|
||||
result_text = f"""
|
||||
✅ <b>Рассылка завершена!</b>
|
||||
|
||||
📊 <b>Результат:</b>
|
||||
- Отправлено: {sent_count}
|
||||
- Не доставлено: {failed_count}
|
||||
- Всего пользователей: {len(users)}
|
||||
- Успешность: {round(sent_count / len(users) * 100, 1) if users else 0}%
|
||||
|
||||
<b>Администратор:</b> {db_user.full_name}
|
||||
"""
|
||||
|
||||
await callback.message.edit_text(
|
||||
result_text,
|
||||
reply_markup=types.InlineKeyboardMarkup(inline_keyboard=[
|
||||
[types.InlineKeyboardButton(text="📨 К рассылкам", callback_data="admin_messages")]
|
||||
]),
|
||||
parse_mode="HTML"
|
||||
)
|
||||
|
||||
await state.clear()
|
||||
logger.info(f"Рассылка выполнена админом {db_user.telegram_id}: {sent_count}/{len(users)}")
|
||||
|
||||
|
||||
async def get_target_users_count(db: AsyncSession, target: str) -> int:
|
||||
users = await get_target_users(db, target)
|
||||
return len(users)
|
||||
|
||||
|
||||
async def get_target_users(db: AsyncSession, target: str) -> list:
|
||||
if target == "all":
|
||||
return await get_users_list(db, offset=0, limit=10000, status=UserStatus.ACTIVE)
|
||||
elif target == "active":
|
||||
users = await get_users_list(db, offset=0, limit=10000, status=UserStatus.ACTIVE)
|
||||
return [user for user in users if user.subscription and user.subscription.is_active and not user.subscription.is_trial]
|
||||
elif target == "trial":
|
||||
users = await get_users_list(db, offset=0, limit=10000, status=UserStatus.ACTIVE)
|
||||
return [user for user in users if user.subscription and user.subscription.is_trial]
|
||||
elif target == "no":
|
||||
users = await get_users_list(db, offset=0, limit=10000, status=UserStatus.ACTIVE)
|
||||
return [user for user in users if not user.subscription or not user.subscription.is_active]
|
||||
elif target == "expiring":
|
||||
expiring_subs = await get_expiring_subscriptions(db, 3)
|
||||
return [sub.user for sub in expiring_subs if sub.user]
|
||||
else:
|
||||
return []
|
||||
|
||||
|
||||
async def get_custom_users_count(db: AsyncSession, criteria: str) -> int:
|
||||
users = await get_custom_users(db, criteria)
|
||||
return len(users)
|
||||
|
||||
|
||||
async def get_custom_users(db: AsyncSession, criteria: str) -> list:
|
||||
"""Получение пользователей по настраиваемым критериям"""
|
||||
now = datetime.utcnow()
|
||||
today = now.replace(hour=0, minute=0, second=0, microsecond=0)
|
||||
week_ago = now - timedelta(days=7)
|
||||
month_ago = now - timedelta(days=30)
|
||||
|
||||
if criteria == "today":
|
||||
stmt = select(User).where(
|
||||
and_(User.status == "active", User.created_at >= today)
|
||||
)
|
||||
elif criteria == "week":
|
||||
stmt = select(User).where(
|
||||
and_(User.status == "active", User.created_at >= week_ago)
|
||||
)
|
||||
elif criteria == "month":
|
||||
stmt = select(User).where(
|
||||
and_(User.status == "active", User.created_at >= month_ago)
|
||||
)
|
||||
elif criteria == "active_today":
|
||||
stmt = select(User).where(
|
||||
and_(User.status == "active", User.last_activity >= today)
|
||||
)
|
||||
elif criteria == "inactive_week":
|
||||
stmt = select(User).where(
|
||||
and_(User.status == "active", User.last_activity < week_ago)
|
||||
)
|
||||
elif criteria == "inactive_month":
|
||||
stmt = select(User).where(
|
||||
and_(User.status == "active", User.last_activity < month_ago)
|
||||
)
|
||||
elif criteria == "referrals":
|
||||
stmt = select(User).where(
|
||||
and_(User.status == "active", User.referred_by_id.isnot(None))
|
||||
)
|
||||
elif criteria == "direct":
|
||||
stmt = select(User).where(
|
||||
and_(
|
||||
User.status == "active",
|
||||
User.referred_by_id.is_(None)
|
||||
)
|
||||
)
|
||||
else:
|
||||
return []
|
||||
|
||||
result = await db.execute(stmt)
|
||||
return result.scalars().all()
|
||||
|
||||
|
||||
async def get_users_statistics(db: AsyncSession) -> dict:
|
||||
"""Получение статистики пользователей для отображения"""
|
||||
now = datetime.utcnow()
|
||||
today = now.replace(hour=0, minute=0, second=0, microsecond=0)
|
||||
week_ago = now - timedelta(days=7)
|
||||
month_ago = now - timedelta(days=30)
|
||||
|
||||
stats = {}
|
||||
|
||||
stats['today'] = await db.scalar(
|
||||
select(func.count(User.id)).where(
|
||||
and_(User.status == "active", User.created_at >= today)
|
||||
)
|
||||
) or 0
|
||||
|
||||
stats['week'] = await db.scalar(
|
||||
select(func.count(User.id)).where(
|
||||
and_(User.status == "active", User.created_at >= week_ago)
|
||||
)
|
||||
) or 0
|
||||
|
||||
stats['month'] = await db.scalar(
|
||||
select(func.count(User.id)).where(
|
||||
and_(User.status == "active", User.created_at >= month_ago)
|
||||
)
|
||||
) or 0
|
||||
|
||||
stats['active_today'] = await db.scalar(
|
||||
select(func.count(User.id)).where(
|
||||
and_(User.status == "active", User.last_activity >= today)
|
||||
)
|
||||
) or 0
|
||||
|
||||
stats['inactive_week'] = await db.scalar(
|
||||
select(func.count(User.id)).where(
|
||||
and_(User.status == "active", User.last_activity < week_ago)
|
||||
)
|
||||
) or 0
|
||||
|
||||
stats['inactive_month'] = await db.scalar(
|
||||
select(func.count(User.id)).where(
|
||||
and_(User.status == "active", User.last_activity < month_ago)
|
||||
)
|
||||
) or 0
|
||||
|
||||
stats['referrals'] = await db.scalar(
|
||||
select(func.count(User.id)).where(
|
||||
and_(User.status == "active", User.referred_by_id.isnot(None))
|
||||
)
|
||||
) or 0
|
||||
|
||||
stats['direct'] = await db.scalar(
|
||||
select(func.count(User.id)).where(
|
||||
and_(
|
||||
User.status == "active",
|
||||
User.referred_by_id.is_(None)
|
||||
)
|
||||
)
|
||||
) or 0
|
||||
|
||||
return stats
|
||||
|
||||
|
||||
def get_target_name(target_type: str) -> str:
|
||||
names = {
|
||||
"all": "Всем пользователям",
|
||||
"active": "С активной подпиской",
|
||||
"trial": "С триальной подпиской",
|
||||
"no": "Без подписки",
|
||||
"expiring": "С истекающей подпиской",
|
||||
"custom_today": "Зарегистрированные сегодня",
|
||||
"custom_week": "Зарегистрированные за неделю",
|
||||
"custom_month": "Зарегистрированные за месяц",
|
||||
"custom_active_today": "Активные сегодня",
|
||||
"custom_inactive_week": "Неактивные 7+ дней",
|
||||
"custom_inactive_month": "Неактивные 30+ дней",
|
||||
"custom_referrals": "Через рефералов",
|
||||
"custom_direct": "Прямая регистрация"
|
||||
}
|
||||
return names.get(target_type, target_type)
|
||||
|
||||
|
||||
def get_target_display_name(target: str) -> str:
|
||||
return get_target_name(target)
|
||||
|
||||
|
||||
def register_handlers(dp: Dispatcher):
|
||||
dp.callback_query.register(show_messages_menu, F.data == "admin_messages")
|
||||
dp.callback_query.register(show_broadcast_targets, F.data.in_(["admin_msg_all", "admin_msg_by_sub"]))
|
||||
dp.callback_query.register(select_broadcast_target, F.data.startswith("broadcast_"))
|
||||
dp.callback_query.register(confirm_broadcast, F.data == "admin_confirm_broadcast")
|
||||
|
||||
dp.callback_query.register(show_messages_history, F.data.startswith("admin_msg_history"))
|
||||
dp.callback_query.register(show_custom_broadcast, F.data == "admin_msg_custom")
|
||||
dp.callback_query.register(select_custom_criteria, F.data.startswith("criteria_"))
|
||||
|
||||
dp.message.register(process_broadcast_message, AdminStates.waiting_for_broadcast_message)
|
||||
@@ -0,0 +1,313 @@
|
||||
import asyncio
|
||||
import logging
|
||||
from datetime import datetime, timedelta
|
||||
from aiogram import Router, F
|
||||
from aiogram.types import Message, CallbackQuery
|
||||
from aiogram.filters import Command
|
||||
|
||||
from app.config import settings
|
||||
from app.database.database import get_db
|
||||
from app.services.monitoring_service import monitoring_service
|
||||
from app.utils.decorators import admin_required
|
||||
from app.keyboards.admin import get_monitoring_keyboard, get_admin_main_keyboard
|
||||
from app.localization.texts import get_texts
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
router = Router()
|
||||
|
||||
|
||||
@router.callback_query(F.data == "admin_monitoring")
|
||||
@admin_required
|
||||
async def admin_monitoring_menu(callback: CallbackQuery):
|
||||
"""Главное меню мониторинга"""
|
||||
try:
|
||||
async for db in get_db():
|
||||
status = await monitoring_service.get_monitoring_status(db)
|
||||
|
||||
running_status = "🟢 Работает" if status['is_running'] else "🔴 Остановлен"
|
||||
last_update = status['last_update'].strftime('%H:%M:%S') if status['last_update'] else "Никогда"
|
||||
|
||||
text = f"""
|
||||
🔍 <b>Система мониторинга</b>
|
||||
|
||||
📊 <b>Статус:</b> {running_status}
|
||||
🕐 <b>Последнее обновление:</b> {last_update}
|
||||
⚙️ <b>Интервал проверки:</b> {settings.MONITORING_INTERVAL} мин
|
||||
|
||||
📈 <b>Статистика за 24 часа:</b>
|
||||
• Всего событий: {status['stats_24h']['total_events']}
|
||||
• Успешных: {status['stats_24h']['successful']}
|
||||
• Ошибок: {status['stats_24h']['failed']}
|
||||
• Успешность: {status['stats_24h']['success_rate']}%
|
||||
|
||||
🔧 Выберите действие:
|
||||
"""
|
||||
|
||||
keyboard = get_monitoring_keyboard()
|
||||
await callback.message.edit_text(text, parse_mode="HTML", reply_markup=keyboard)
|
||||
break
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка в админ меню мониторинга: {e}")
|
||||
await callback.answer("❌ Ошибка получения данных", show_alert=True)
|
||||
|
||||
|
||||
@router.callback_query(F.data == "admin_mon_start")
|
||||
@admin_required
|
||||
async def start_monitoring_callback(callback: CallbackQuery):
|
||||
try:
|
||||
if monitoring_service.is_running:
|
||||
await callback.answer("ℹ️ Мониторинг уже запущен")
|
||||
return
|
||||
|
||||
if not monitoring_service.bot:
|
||||
monitoring_service.bot = callback.bot
|
||||
|
||||
asyncio.create_task(monitoring_service.start_monitoring())
|
||||
|
||||
await callback.answer("✅ Мониторинг запущен!")
|
||||
|
||||
await admin_monitoring_menu(callback)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка запуска мониторинга: {e}")
|
||||
await callback.answer(f"❌ Ошибка запуска: {str(e)}", show_alert=True)
|
||||
|
||||
|
||||
@router.callback_query(F.data == "admin_mon_stop")
|
||||
@admin_required
|
||||
async def stop_monitoring_callback(callback: CallbackQuery):
|
||||
try:
|
||||
if not monitoring_service.is_running:
|
||||
await callback.answer("ℹ️ Мониторинг уже остановлен")
|
||||
return
|
||||
|
||||
monitoring_service.stop_monitoring()
|
||||
await callback.answer("⏹️ Мониторинг остановлен!")
|
||||
|
||||
await admin_monitoring_menu(callback)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка остановки мониторинга: {e}")
|
||||
await callback.answer(f"❌ Ошибка остановки: {str(e)}", show_alert=True)
|
||||
|
||||
|
||||
@router.callback_query(F.data == "admin_mon_force_check")
|
||||
@admin_required
|
||||
async def force_check_callback(callback: CallbackQuery):
|
||||
try:
|
||||
await callback.answer("⏳ Выполняем проверку подписок...")
|
||||
|
||||
async for db in get_db():
|
||||
results = await monitoring_service.force_check_subscriptions(db)
|
||||
|
||||
text = f"""
|
||||
✅ <b>Принудительная проверка завершена</b>
|
||||
|
||||
📊 <b>Результаты проверки:</b>
|
||||
• Истекших подписок: {results['expired']}
|
||||
• Истекающих подписок: {results['expiring']}
|
||||
• Готовых к автооплате: {results['autopay_ready']}
|
||||
|
||||
🕐 <b>Время проверки:</b> {datetime.now().strftime('%H:%M:%S')}
|
||||
|
||||
Нажмите "Назад" для возврата в меню мониторинга.
|
||||
"""
|
||||
|
||||
from aiogram.types import InlineKeyboardMarkup, InlineKeyboardButton
|
||||
keyboard = InlineKeyboardMarkup(inline_keyboard=[
|
||||
[InlineKeyboardButton(text="⬅️ Назад", callback_data="admin_monitoring")]
|
||||
])
|
||||
|
||||
await callback.message.edit_text(text, parse_mode="HTML", reply_markup=keyboard)
|
||||
break
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка принудительной проверки: {e}")
|
||||
await callback.answer(f"❌ Ошибка проверки: {str(e)}", show_alert=True)
|
||||
|
||||
|
||||
@router.callback_query(F.data == "admin_mon_logs")
|
||||
@admin_required
|
||||
async def monitoring_logs_callback(callback: CallbackQuery):
|
||||
try:
|
||||
async for db in get_db():
|
||||
logs = await monitoring_service.get_monitoring_logs(db, limit=15)
|
||||
|
||||
if not logs:
|
||||
text = "📝 <b>Логи мониторинга пусты</b>\n\nСистема еще не выполняла проверки."
|
||||
else:
|
||||
text = "📝 <b>Последние логи мониторинга:</b>\n\n"
|
||||
|
||||
for log in logs:
|
||||
icon = "✅" if log['is_success'] else "❌"
|
||||
time_str = log['created_at'].strftime('%m-%d %H:%M')
|
||||
event_type = log['event_type'].replace('_', ' ').title()
|
||||
|
||||
text += f"{icon} <code>{time_str}</code> {event_type}\n"
|
||||
|
||||
message = log['message']
|
||||
if len(message) > 60:
|
||||
message = message[:60] + "..."
|
||||
|
||||
text += f" 📄 {message}\n\n"
|
||||
|
||||
if len(text) > 3500:
|
||||
text += "...\n\n<i>Показаны последние записи. Для просмотра всех логов используйте файл логов.</i>"
|
||||
break
|
||||
|
||||
from aiogram.types import InlineKeyboardMarkup, InlineKeyboardButton
|
||||
keyboard = InlineKeyboardMarkup(inline_keyboard=[
|
||||
[
|
||||
InlineKeyboardButton(text="🔄 Обновить", callback_data="admin_mon_logs"),
|
||||
InlineKeyboardButton(text="🗑️ Очистить", callback_data="admin_mon_clear_logs")
|
||||
],
|
||||
[InlineKeyboardButton(text="⬅️ Назад", callback_data="admin_monitoring")]
|
||||
])
|
||||
|
||||
await callback.message.edit_text(text, parse_mode="HTML", reply_markup=keyboard)
|
||||
break
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка получения логов: {e}")
|
||||
await callback.answer(f"❌ Ошибка получения логов: {str(e)}", show_alert=True)
|
||||
|
||||
|
||||
@router.callback_query(F.data == "admin_mon_clear_logs")
|
||||
@admin_required
|
||||
async def clear_logs_callback(callback: CallbackQuery):
|
||||
try:
|
||||
async for db in get_db():
|
||||
deleted_count = await monitoring_service.cleanup_old_logs(db, days=7)
|
||||
|
||||
if deleted_count > 0:
|
||||
await callback.answer(f"🗑️ Удалено {deleted_count} старых записей логов")
|
||||
else:
|
||||
await callback.answer("ℹ️ Нет старых логов для удаления")
|
||||
|
||||
await monitoring_logs_callback(callback)
|
||||
break
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка очистки логов: {e}")
|
||||
await callback.answer(f"❌ Ошибка очистки: {str(e)}", show_alert=True)
|
||||
|
||||
|
||||
@router.callback_query(F.data == "admin_mon_test_notifications")
|
||||
@admin_required
|
||||
async def test_notifications_callback(callback: CallbackQuery):
|
||||
"""Тест системы уведомлений"""
|
||||
try:
|
||||
test_message = f"""
|
||||
🧪 <b>Тестовое уведомление системы мониторинга</b>
|
||||
|
||||
Это тестовое сообщение для проверки работы системы уведомлений.
|
||||
|
||||
📊 <b>Статус системы:</b>
|
||||
• Мониторинг: {'🟢 Работает' if monitoring_service.is_running else '🔴 Остановлен'}
|
||||
• Уведомления: {'🟢 Включены' if settings.ENABLE_NOTIFICATIONS else '🔴 Отключены'}
|
||||
• Время теста: {datetime.now().strftime('%H:%M:%S %d.%m.%Y')}
|
||||
|
||||
✅ Если вы получили это сообщение, система уведомлений работает корректно!
|
||||
"""
|
||||
|
||||
await callback.bot.send_message(
|
||||
callback.from_user.id,
|
||||
test_message,
|
||||
parse_mode="HTML"
|
||||
)
|
||||
|
||||
await callback.answer("✅ Тестовое уведомление отправлено!")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка отправки тестового уведомления: {e}")
|
||||
await callback.answer(f"❌ Ошибка отправки: {str(e)}", show_alert=True)
|
||||
|
||||
|
||||
@router.callback_query(F.data == "admin_mon_statistics")
|
||||
@admin_required
|
||||
async def monitoring_statistics_callback(callback: CallbackQuery):
|
||||
try:
|
||||
async for db in get_db():
|
||||
from app.database.crud.subscription import get_subscriptions_statistics
|
||||
sub_stats = await get_subscriptions_statistics(db)
|
||||
|
||||
mon_status = await monitoring_service.get_monitoring_status(db)
|
||||
|
||||
week_ago = datetime.now() - timedelta(days=7)
|
||||
week_logs = await monitoring_service.get_monitoring_logs(db, limit=1000)
|
||||
week_logs = [log for log in week_logs if log['created_at'] >= week_ago]
|
||||
|
||||
week_success = sum(1 for log in week_logs if log['is_success'])
|
||||
week_errors = len(week_logs) - week_success
|
||||
|
||||
text = f"""
|
||||
📊 <b>Статистика мониторинга</b>
|
||||
|
||||
📱 <b>Подписки:</b>
|
||||
• Всего: {sub_stats['total_subscriptions']}
|
||||
• Активных: {sub_stats['active_subscriptions']}
|
||||
• Тестовых: {sub_stats['trial_subscriptions']}
|
||||
• Платных: {sub_stats['paid_subscriptions']}
|
||||
|
||||
📈 <b>За сегодня:</b>
|
||||
• Успешных операций: {mon_status['stats_24h']['successful']}
|
||||
• Ошибок: {mon_status['stats_24h']['failed']}
|
||||
• Успешность: {mon_status['stats_24h']['success_rate']}%
|
||||
|
||||
📊 <b>За неделю:</b>
|
||||
• Всего событий: {len(week_logs)}
|
||||
• Успешных: {week_success}
|
||||
• Ошибок: {week_errors}
|
||||
• Успешность: {round(week_success/len(week_logs)*100, 1) if week_logs else 0}%
|
||||
|
||||
🔧 <b>Система:</b>
|
||||
• Интервал: {settings.MONITORING_INTERVAL} мин
|
||||
• Уведомления: {'🟢 Вкл' if getattr(settings, 'ENABLE_NOTIFICATIONS', True) else '🔴 Выкл'}
|
||||
• Автооплата: {', '.join(map(str, settings.get_autopay_warning_days()))} дней
|
||||
"""
|
||||
|
||||
from aiogram.types import InlineKeyboardMarkup, InlineKeyboardButton
|
||||
keyboard = InlineKeyboardMarkup(inline_keyboard=[
|
||||
[InlineKeyboardButton(text="⬅️ Назад", callback_data="admin_monitoring")]
|
||||
])
|
||||
|
||||
await callback.message.edit_text(text, parse_mode="HTML", reply_markup=keyboard)
|
||||
break
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка получения статистики: {e}")
|
||||
await callback.answer(f"❌ Ошибка получения статистики: {str(e)}", show_alert=True)
|
||||
|
||||
|
||||
@router.message(Command("monitoring"))
|
||||
@admin_required
|
||||
async def monitoring_command(message: Message):
|
||||
"""Команда /monitoring для быстрого доступа"""
|
||||
try:
|
||||
async for db in get_db():
|
||||
status = await monitoring_service.get_monitoring_status(db)
|
||||
|
||||
running_status = "🟢 Работает" if status['is_running'] else "🔴 Остановлен"
|
||||
|
||||
text = f"""
|
||||
🔍 <b>Быстрый статус мониторинга</b>
|
||||
|
||||
📊 <b>Статус:</b> {running_status}
|
||||
📈 <b>События за 24ч:</b> {status['stats_24h']['total_events']}
|
||||
✅ <b>Успешность:</b> {status['stats_24h']['success_rate']}%
|
||||
|
||||
Для подробного управления используйте админ-панель.
|
||||
"""
|
||||
|
||||
await message.answer(text, parse_mode="HTML")
|
||||
break
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка команды /monitoring: {e}")
|
||||
await message.answer(f"❌ Ошибка: {str(e)}")
|
||||
|
||||
|
||||
def register_handlers(dp):
|
||||
"""Регистрация обработчиков мониторинга"""
|
||||
dp.include_router(router)
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,216 @@
|
||||
import logging
|
||||
from aiogram import Dispatcher, types, F
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
import datetime
|
||||
|
||||
from app.config import settings
|
||||
from app.database.models import User
|
||||
from app.localization.texts import get_texts
|
||||
from app.database.crud.referral import get_referral_statistics, get_user_referral_stats
|
||||
from app.database.crud.user import get_user_by_id
|
||||
from app.utils.decorators import admin_required, error_handler
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def show_referral_statistics(
|
||||
callback: types.CallbackQuery,
|
||||
db_user: User,
|
||||
db: AsyncSession
|
||||
):
|
||||
try:
|
||||
stats = await get_referral_statistics(db)
|
||||
|
||||
avg_per_referrer = 0
|
||||
if stats.get('active_referrers', 0) > 0:
|
||||
avg_per_referrer = stats.get('total_paid_kopeks', 0) / stats['active_referrers']
|
||||
|
||||
current_time = datetime.datetime.now().strftime("%H:%M:%S")
|
||||
|
||||
text = f"""
|
||||
🤝 <b>Реферальная статистика</b>
|
||||
|
||||
<b>Общие показатели:</b>
|
||||
- Пользователей с рефералами: {stats.get('users_with_referrals', 0)}
|
||||
- Активных рефереров: {stats.get('active_referrers', 0)}
|
||||
- Выплачено всего: {settings.format_price(stats.get('total_paid_kopeks', 0))}
|
||||
|
||||
<b>За период:</b>
|
||||
- Сегодня: {settings.format_price(stats.get('today_earnings_kopeks', 0))}
|
||||
- За неделю: {settings.format_price(stats.get('week_earnings_kopeks', 0))}
|
||||
- За месяц: {settings.format_price(stats.get('month_earnings_kopeks', 0))}
|
||||
|
||||
<b>Средние показатели:</b>
|
||||
- На одного реферера: {settings.format_price(int(avg_per_referrer))}
|
||||
|
||||
<b>Топ-5 рефереров:</b>
|
||||
"""
|
||||
|
||||
top_referrers = stats.get('top_referrers', [])
|
||||
if top_referrers:
|
||||
for i, referrer in enumerate(top_referrers[:5], 1):
|
||||
earned = referrer.get('total_earned_kopeks', 0)
|
||||
count = referrer.get('referrals_count', 0)
|
||||
user_id = referrer.get('user_id', 'N/A')
|
||||
|
||||
if count > 0:
|
||||
text += f"{i}. ID {user_id}: {settings.format_price(earned)} ({count} реф.)\n"
|
||||
else:
|
||||
logger.warning(f"Реферер {user_id} имеет {count} рефералов, но есть в топе")
|
||||
else:
|
||||
text += "Нет данных\n"
|
||||
|
||||
text += f"""
|
||||
|
||||
<b>Настройки реферальной системы:</b>
|
||||
- Минимальное пополнение: {settings.format_price(settings.REFERRAL_MINIMUM_TOPUP_KOPEKS)}
|
||||
- Бонус за первое пополнение: {settings.format_price(settings.REFERRAL_FIRST_TOPUP_BONUS_KOPEKS)}
|
||||
- Бонус пригласившему: {settings.format_price(settings.REFERRAL_INVITER_BONUS_KOPEKS)}
|
||||
- Бонус новому пользователю: {settings.format_price(settings.REFERRED_USER_REWARD)}
|
||||
- Комиссия с покупок: {settings.REFERRAL_COMMISSION_PERCENT}%
|
||||
- Уведомления: {'✅ Включены' if settings.REFERRAL_NOTIFICATIONS_ENABLED else '❌ Отключены'}
|
||||
|
||||
<i>🕐 Обновлено: {current_time}</i>
|
||||
"""
|
||||
|
||||
keyboard = types.InlineKeyboardMarkup(inline_keyboard=[
|
||||
[types.InlineKeyboardButton(text="🔄 Обновить", callback_data="admin_referrals")],
|
||||
[types.InlineKeyboardButton(text="👥 Топ рефереров", callback_data="admin_referrals_top")],
|
||||
[types.InlineKeyboardButton(text="⚙️ Настройки", callback_data="admin_referrals_settings")],
|
||||
[types.InlineKeyboardButton(text="⬅️ Назад", callback_data="admin_panel")]
|
||||
])
|
||||
|
||||
try:
|
||||
await callback.message.edit_text(text, reply_markup=keyboard)
|
||||
await callback.answer("Обновлено")
|
||||
except Exception as edit_error:
|
||||
if "message is not modified" in str(edit_error):
|
||||
await callback.answer("Данные актуальны")
|
||||
else:
|
||||
logger.error(f"Ошибка редактирования сообщения: {edit_error}")
|
||||
await callback.answer("Ошибка обновления")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка в show_referral_statistics: {e}", exc_info=True)
|
||||
|
||||
current_time = datetime.datetime.now().strftime("%H:%M:%S")
|
||||
text = f"""
|
||||
🤝 <b>Реферальная статистика</b>
|
||||
|
||||
❌ <b>Ошибка загрузки данных</b>
|
||||
|
||||
<b>Текущие настройки:</b>
|
||||
- Минимальное пополнение: {settings.format_price(settings.REFERRAL_MINIMUM_TOPUP_KOPEKS)}
|
||||
- Бонус за первое пополнение: {settings.format_price(settings.REFERRAL_FIRST_TOPUP_BONUS_KOPEKS)}
|
||||
- Бонус пригласившему: {settings.format_price(settings.REFERRAL_INVITER_BONUS_KOPEKS)}
|
||||
- Бонус новому пользователю: {settings.format_price(settings.REFERRED_USER_REWARD)}
|
||||
- Комиссия с покупок: {settings.REFERRAL_COMMISSION_PERCENT}%
|
||||
|
||||
<i>🕐 Время: {current_time}</i>
|
||||
"""
|
||||
|
||||
keyboard = types.InlineKeyboardMarkup(inline_keyboard=[
|
||||
[types.InlineKeyboardButton(text="🔄 Повторить", callback_data="admin_referrals")],
|
||||
[types.InlineKeyboardButton(text="⬅️ Назад", callback_data="admin_panel")]
|
||||
])
|
||||
|
||||
try:
|
||||
await callback.message.edit_text(text, reply_markup=keyboard)
|
||||
except:
|
||||
pass
|
||||
await callback.answer("Произошла ошибка при загрузке статистики")
|
||||
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def show_top_referrers(
|
||||
callback: types.CallbackQuery,
|
||||
db_user: User,
|
||||
db: AsyncSession
|
||||
):
|
||||
try:
|
||||
stats = await get_referral_statistics(db)
|
||||
top_referrers = stats.get('top_referrers', [])
|
||||
|
||||
text = "🏆 <b>Топ рефереров</b>\n\n"
|
||||
|
||||
if top_referrers:
|
||||
for i, referrer in enumerate(top_referrers[:20], 1):
|
||||
earned = referrer.get('total_earned_kopeks', 0)
|
||||
count = referrer.get('referrals_count', 0)
|
||||
display_name = referrer.get('display_name', 'N/A')
|
||||
username = referrer.get('username', '')
|
||||
telegram_id = referrer.get('telegram_id', 'N/A')
|
||||
|
||||
if username:
|
||||
display_text = f"@{username} (ID{telegram_id})"
|
||||
elif display_name and display_name != f"ID{telegram_id}":
|
||||
display_text = f"{display_name} (ID{telegram_id})"
|
||||
else:
|
||||
display_text = f"ID{telegram_id}"
|
||||
|
||||
emoji = ""
|
||||
if i == 1:
|
||||
emoji = "🥇 "
|
||||
elif i == 2:
|
||||
emoji = "🥈 "
|
||||
elif i == 3:
|
||||
emoji = "🥉 "
|
||||
|
||||
text += f"{emoji}{i}. {display_text}\n"
|
||||
text += f" 💰 {settings.format_price(earned)} | 👥 {count} реф.\n\n"
|
||||
else:
|
||||
text += "Нет данных о рефererах\n"
|
||||
|
||||
keyboard = types.InlineKeyboardMarkup(inline_keyboard=[
|
||||
[types.InlineKeyboardButton(text="⬅️ К статистике", callback_data="admin_referrals")]
|
||||
])
|
||||
|
||||
await callback.message.edit_text(text, reply_markup=keyboard)
|
||||
await callback.answer()
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка в show_top_referrers: {e}", exc_info=True)
|
||||
await callback.answer("Ошибка загрузки топа рефереров")
|
||||
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def show_referral_settings(
|
||||
callback: types.CallbackQuery,
|
||||
db_user: User,
|
||||
db: AsyncSession
|
||||
):
|
||||
text = f"""
|
||||
⚙️ <b>Настройки реферальной системы</b>
|
||||
|
||||
<b>Бонусы и награды:</b>
|
||||
• Минимальная сумма пополнения для участия: {settings.format_price(settings.REFERRAL_MINIMUM_TOPUP_KOPEKS)}
|
||||
• Бонус за первое пополнение реферала: {settings.format_price(settings.REFERRAL_FIRST_TOPUP_BONUS_KOPEKS)}
|
||||
• Бонус пригласившему за первое пополнение: {settings.format_price(settings.REFERRAL_INVITER_BONUS_KOPEKS)}
|
||||
• Бонус новому пользователю при регистрации: {settings.format_price(settings.REFERRED_USER_REWARD)}
|
||||
|
||||
<b>Комиссионные:</b>
|
||||
• Процент с каждой покупки реферала: {settings.REFERRAL_COMMISSION_PERCENT}%
|
||||
|
||||
<b>Уведомления:</b>
|
||||
• Статус: {'✅ Включены' if settings.REFERRAL_NOTIFICATIONS_ENABLED else '❌ Отключены'}
|
||||
• Попытки отправки: {getattr(settings, 'REFERRAL_NOTIFICATION_RETRY_ATTEMPTS', 3)}
|
||||
|
||||
<i>💡 Для изменения настроек отредактируйте файл .env и перезапустите бота</i>
|
||||
"""
|
||||
|
||||
keyboard = types.InlineKeyboardMarkup(inline_keyboard=[
|
||||
[types.InlineKeyboardButton(text="⬅️ К статистике", callback_data="admin_referrals")]
|
||||
])
|
||||
|
||||
await callback.message.edit_text(text, reply_markup=keyboard)
|
||||
await callback.answer()
|
||||
|
||||
|
||||
def register_handlers(dp: Dispatcher):
|
||||
dp.callback_query.register(show_referral_statistics, F.data == "admin_referrals")
|
||||
dp.callback_query.register(show_top_referrers, F.data == "admin_referrals_top")
|
||||
dp.callback_query.register(show_referral_settings, F.data == "admin_referrals_settings")
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,168 @@
|
||||
import logging
|
||||
from aiogram import Dispatcher, types, F
|
||||
from aiogram.fsm.context import FSMContext
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.config import settings
|
||||
from app.states import AdminStates
|
||||
from app.database.models import User
|
||||
from app.localization.texts import get_texts
|
||||
from app.utils.decorators import admin_required, error_handler
|
||||
from app.database.crud.rules import get_current_rules_content, create_or_update_rules
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def show_rules_management(
|
||||
callback: types.CallbackQuery,
|
||||
db_user: User,
|
||||
db: AsyncSession
|
||||
):
|
||||
text = """
|
||||
📋 <b>Управление правилами сервиса</b>
|
||||
|
||||
Текущие правила показываются пользователям при регистрации и в главном меню.
|
||||
|
||||
Выберите действие:
|
||||
"""
|
||||
|
||||
keyboard = [
|
||||
[types.InlineKeyboardButton(text="📝 Редактировать правила", callback_data="admin_edit_rules")],
|
||||
[types.InlineKeyboardButton(text="👀 Просмотр правил", callback_data="admin_view_rules")],
|
||||
[types.InlineKeyboardButton(text="⬅️ Назад", callback_data="admin_panel")]
|
||||
]
|
||||
|
||||
await callback.message.edit_text(
|
||||
text,
|
||||
reply_markup=types.InlineKeyboardMarkup(inline_keyboard=keyboard)
|
||||
)
|
||||
await callback.answer()
|
||||
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def view_current_rules(
|
||||
callback: types.CallbackQuery,
|
||||
db_user: User,
|
||||
db: AsyncSession
|
||||
):
|
||||
current_rules = await get_current_rules_content(db, db_user.language)
|
||||
|
||||
await callback.message.edit_text(
|
||||
f"📋 <b>Текущие правила сервиса</b>\n\n{current_rules}",
|
||||
reply_markup=types.InlineKeyboardMarkup(inline_keyboard=[
|
||||
[types.InlineKeyboardButton(text="✏️ Редактировать", callback_data="admin_edit_rules")],
|
||||
[types.InlineKeyboardButton(text="⬅️ Назад", callback_data="admin_rules")]
|
||||
])
|
||||
)
|
||||
await callback.answer()
|
||||
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def start_edit_rules(
|
||||
callback: types.CallbackQuery,
|
||||
db_user: User,
|
||||
state: FSMContext,
|
||||
db: AsyncSession
|
||||
):
|
||||
current_rules = await get_current_rules_content(db, db_user.language)
|
||||
|
||||
await callback.message.edit_text(
|
||||
"✏️ <b>Редактирование правил</b>\n\n"
|
||||
f"<b>Текущие правила:</b>\n{current_rules[:500]}{'...' if len(current_rules) > 500 else ''}\n\n"
|
||||
"Отправьте новый текст правил сервиса.\n\n"
|
||||
"<i>Поддерживается HTML разметка</i>",
|
||||
reply_markup=types.InlineKeyboardMarkup(inline_keyboard=[
|
||||
[types.InlineKeyboardButton(text="❌ Отмена", callback_data="admin_rules")]
|
||||
])
|
||||
)
|
||||
|
||||
await state.set_state(AdminStates.editing_rules_page)
|
||||
await callback.answer()
|
||||
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def process_rules_edit(
|
||||
message: types.Message,
|
||||
db_user: User,
|
||||
state: FSMContext,
|
||||
db: AsyncSession
|
||||
):
|
||||
new_rules = message.text
|
||||
|
||||
if len(new_rules) > 4000:
|
||||
await message.answer("❌ Текст правил слишком длинный (максимум 4000 символов)")
|
||||
return
|
||||
|
||||
await message.answer(
|
||||
f"📋 <b>Предварительный просмотр новых правил:</b>\n\n{new_rules}\n\n"
|
||||
f"⚠️ <b>Внимание!</b> Новые правила будут показываться всем пользователям.\n\n"
|
||||
f"Сохранить изменения?",
|
||||
reply_markup=types.InlineKeyboardMarkup(inline_keyboard=[
|
||||
[
|
||||
types.InlineKeyboardButton(text="✅ Сохранить", callback_data="admin_save_rules"),
|
||||
types.InlineKeyboardButton(text="❌ Отмена", callback_data="admin_rules")
|
||||
]
|
||||
])
|
||||
)
|
||||
|
||||
await state.update_data(new_rules=new_rules)
|
||||
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def save_rules(
|
||||
callback: types.CallbackQuery,
|
||||
db_user: User,
|
||||
state: FSMContext,
|
||||
db: AsyncSession
|
||||
):
|
||||
data = await state.get_data()
|
||||
new_rules = data.get('new_rules')
|
||||
|
||||
if not new_rules:
|
||||
await callback.answer("❌ Ошибка: текст правил не найден", show_alert=True)
|
||||
return
|
||||
|
||||
try:
|
||||
await create_or_update_rules(
|
||||
db=db,
|
||||
content=new_rules,
|
||||
language=db_user.language
|
||||
)
|
||||
|
||||
from app.localization.texts import clear_rules_cache
|
||||
clear_rules_cache()
|
||||
|
||||
from app.localization.texts import refresh_rules_cache
|
||||
await refresh_rules_cache(db_user.language)
|
||||
|
||||
await callback.message.edit_text(
|
||||
"✅ <b>Правила сервиса обновлены!</b>\n\n"
|
||||
"Новые правила сохранены в базе данных и будут показываться пользователям.\n\n"
|
||||
"Кеш правил очищен и обновлен.",
|
||||
reply_markup=types.InlineKeyboardMarkup(inline_keyboard=[
|
||||
[types.InlineKeyboardButton(text="📋 К правилам", callback_data="admin_rules")]
|
||||
])
|
||||
)
|
||||
|
||||
await state.clear()
|
||||
logger.info(f"Правила сервиса обновлены администратором {db_user.telegram_id}")
|
||||
await callback.answer()
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка сохранения правил: {e}")
|
||||
await callback.answer("❌ Ошибка сохранения правил", show_alert=True)
|
||||
|
||||
|
||||
def register_handlers(dp: Dispatcher):
|
||||
dp.callback_query.register(show_rules_management, F.data == "admin_rules")
|
||||
dp.callback_query.register(view_current_rules, F.data == "admin_view_rules")
|
||||
dp.callback_query.register(start_edit_rules, F.data == "admin_edit_rules")
|
||||
dp.callback_query.register(save_rules, F.data == "admin_save_rules")
|
||||
|
||||
dp.message.register(process_rules_edit, AdminStates.editing_rules_page)
|
||||
@@ -0,0 +1,955 @@
|
||||
import logging
|
||||
from aiogram import Dispatcher, types, F
|
||||
from aiogram.fsm.context import FSMContext
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.states import AdminStates
|
||||
from app.database.models import User
|
||||
from app.database.crud.server_squad import (
|
||||
get_all_server_squads, get_server_squad_by_id, update_server_squad,
|
||||
delete_server_squad, sync_with_remnawave, get_server_statistics,
|
||||
create_server_squad, get_available_server_squads
|
||||
)
|
||||
from app.services.remnawave_service import RemnaWaveService
|
||||
from app.utils.decorators import admin_required, error_handler
|
||||
from app.utils.cache import cache
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def show_servers_menu(
|
||||
callback: types.CallbackQuery,
|
||||
db_user: User,
|
||||
db: AsyncSession
|
||||
):
|
||||
|
||||
stats = await get_server_statistics(db)
|
||||
|
||||
text = f"""
|
||||
🌐 <b>Управление серверами</b>
|
||||
|
||||
📊 <b>Статистика:</b>
|
||||
• Всего серверов: {stats['total_servers']}
|
||||
• Доступные: {stats['available_servers']}
|
||||
• Недоступные: {stats['unavailable_servers']}
|
||||
• С подключениями: {stats['servers_with_connections']}
|
||||
|
||||
💰 <b>Выручка от серверов:</b>
|
||||
• Общая: {stats['total_revenue_rubles']:.2f} ₽
|
||||
|
||||
Выберите действие:
|
||||
"""
|
||||
|
||||
keyboard = [
|
||||
[
|
||||
types.InlineKeyboardButton(text="📋 Список серверов", callback_data="admin_servers_list"),
|
||||
types.InlineKeyboardButton(text="🔄 Синхронизация", callback_data="admin_servers_sync")
|
||||
],
|
||||
[
|
||||
types.InlineKeyboardButton(text="📊 Синхронизировать счетчики", callback_data="admin_servers_sync_counts"),
|
||||
types.InlineKeyboardButton(text="📈 Подробная статистика", callback_data="admin_servers_stats")
|
||||
],
|
||||
[
|
||||
types.InlineKeyboardButton(text="⬅️ Назад", callback_data="admin_subscriptions")
|
||||
]
|
||||
]
|
||||
|
||||
await callback.message.edit_text(
|
||||
text,
|
||||
reply_markup=types.InlineKeyboardMarkup(inline_keyboard=keyboard)
|
||||
)
|
||||
await callback.answer()
|
||||
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def show_servers_list(
|
||||
callback: types.CallbackQuery,
|
||||
db_user: User,
|
||||
db: AsyncSession,
|
||||
page: int = 1
|
||||
):
|
||||
|
||||
servers, total_count = await get_all_server_squads(db, page=page, limit=10)
|
||||
total_pages = (total_count + 9) // 10
|
||||
|
||||
if not servers:
|
||||
text = "🌐 <b>Список серверов</b>\n\n❌ Серверы не найдены."
|
||||
else:
|
||||
text = f"🌐 <b>Список серверов</b>\n\n"
|
||||
text += f"📊 Всего: {total_count} | Страница: {page}/{total_pages}\n\n"
|
||||
|
||||
for i, server in enumerate(servers, 1 + (page - 1) * 10):
|
||||
status_emoji = "✅" if server.is_available else "❌"
|
||||
price_text = f"{server.price_rubles:.2f} ₽" if server.price_kopeks > 0 else "Бесплатно"
|
||||
|
||||
text += f"{i}. {status_emoji} {server.display_name}\n"
|
||||
text += f" 💰 Цена: {price_text}"
|
||||
|
||||
if server.max_users:
|
||||
text += f" | 👥 {server.current_users}/{server.max_users}"
|
||||
|
||||
text += f"\n UUID: <code>{server.squad_uuid}</code>\n\n"
|
||||
|
||||
keyboard = []
|
||||
|
||||
for i, server in enumerate(servers):
|
||||
row_num = i // 2
|
||||
if len(keyboard) <= row_num:
|
||||
keyboard.append([])
|
||||
|
||||
status_emoji = "✅" if server.is_available else "❌"
|
||||
keyboard[row_num].append(
|
||||
types.InlineKeyboardButton(
|
||||
text=f"{status_emoji} {server.display_name[:15]}...",
|
||||
callback_data=f"admin_server_edit_{server.id}"
|
||||
)
|
||||
)
|
||||
|
||||
if total_pages > 1:
|
||||
nav_row = []
|
||||
if page > 1:
|
||||
nav_row.append(types.InlineKeyboardButton(
|
||||
text="⬅️", callback_data=f"admin_servers_list_page_{page-1}"
|
||||
))
|
||||
|
||||
nav_row.append(types.InlineKeyboardButton(
|
||||
text=f"{page}/{total_pages}", callback_data="current_page"
|
||||
))
|
||||
|
||||
if page < total_pages:
|
||||
nav_row.append(types.InlineKeyboardButton(
|
||||
text="➡️", callback_data=f"admin_servers_list_page_{page+1}"
|
||||
))
|
||||
|
||||
keyboard.append(nav_row)
|
||||
|
||||
keyboard.extend([
|
||||
[types.InlineKeyboardButton(text="⬅️ Назад", callback_data="admin_servers")]
|
||||
])
|
||||
|
||||
await callback.message.edit_text(
|
||||
text,
|
||||
reply_markup=types.InlineKeyboardMarkup(inline_keyboard=keyboard),
|
||||
parse_mode="HTML"
|
||||
)
|
||||
await callback.answer()
|
||||
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def sync_servers_with_remnawave(
|
||||
callback: types.CallbackQuery,
|
||||
db_user: User,
|
||||
db: AsyncSession
|
||||
):
|
||||
|
||||
await callback.message.edit_text(
|
||||
"🔄 Синхронизация с RemnaWave...\n\nПодождите, это может занять время.",
|
||||
reply_markup=None
|
||||
)
|
||||
|
||||
try:
|
||||
remnawave_service = RemnaWaveService()
|
||||
squads = await remnawave_service.get_all_squads()
|
||||
|
||||
if not squads:
|
||||
await callback.message.edit_text(
|
||||
"❌ Не удалось получить данные о сквадах из RemnaWave.\n\nПроверьте настройки API.",
|
||||
reply_markup=types.InlineKeyboardMarkup(inline_keyboard=[
|
||||
[types.InlineKeyboardButton(text="⬅️ Назад", callback_data="admin_servers")]
|
||||
])
|
||||
)
|
||||
return
|
||||
|
||||
created, updated, disabled = await sync_with_remnawave(db, squads)
|
||||
|
||||
await cache.delete("available_countries")
|
||||
|
||||
text = f"""
|
||||
✅ <b>Синхронизация завершена</b>
|
||||
|
||||
📊 <b>Результаты:</b>
|
||||
• Создано новых серверов: {created}
|
||||
• Обновлено существующих: {updated}
|
||||
• Отключено неактивных: {disabled}
|
||||
• Всего обработано: {len(squads)}
|
||||
|
||||
ℹ️ Новые серверы созданы как недоступные.
|
||||
Настройте их в списке серверов.
|
||||
"""
|
||||
|
||||
keyboard = [
|
||||
[
|
||||
types.InlineKeyboardButton(text="📋 Список серверов", callback_data="admin_servers_list"),
|
||||
types.InlineKeyboardButton(text="🔄 Повторить", callback_data="admin_servers_sync")
|
||||
],
|
||||
[types.InlineKeyboardButton(text="⬅️ Назад", callback_data="admin_servers")]
|
||||
]
|
||||
|
||||
await callback.message.edit_text(
|
||||
text,
|
||||
reply_markup=types.InlineKeyboardMarkup(inline_keyboard=keyboard)
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка синхронизации серверов: {e}")
|
||||
await callback.message.edit_text(
|
||||
f"❌ Ошибка синхронизации: {str(e)}",
|
||||
reply_markup=types.InlineKeyboardMarkup(inline_keyboard=[
|
||||
[types.InlineKeyboardButton(text="⬅️ Назад", callback_data="admin_servers")]
|
||||
])
|
||||
)
|
||||
|
||||
await callback.answer()
|
||||
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def show_server_edit_menu(
|
||||
callback: types.CallbackQuery,
|
||||
db_user: User,
|
||||
db: AsyncSession
|
||||
):
|
||||
|
||||
server_id = int(callback.data.split('_')[-1])
|
||||
server = await get_server_squad_by_id(db, server_id)
|
||||
|
||||
if not server:
|
||||
await callback.answer("❌ Сервер не найден!", show_alert=True)
|
||||
return
|
||||
|
||||
status_emoji = "✅ Доступен" if server.is_available else "❌ Недоступен"
|
||||
price_text = f"{server.price_rubles:.2f} ₽" if server.price_kopeks > 0 else "Бесплатно"
|
||||
|
||||
text = f"""
|
||||
🌐 <b>Редактирование сервера</b>
|
||||
|
||||
<b>Информация:</b>
|
||||
• ID: {server.id}
|
||||
• UUID: <code>{server.squad_uuid}</code>
|
||||
• Название: {server.display_name}
|
||||
• Оригинальное: {server.original_name or 'Не указано'}
|
||||
• Статус: {status_emoji}
|
||||
|
||||
<b>Настройки:</b>
|
||||
• Цена: {price_text}
|
||||
• Код страны: {server.country_code or 'Не указан'}
|
||||
• Лимит пользователей: {server.max_users or 'Без лимита'}
|
||||
• Текущих пользователей: {server.current_users}
|
||||
|
||||
<b>Описание:</b>
|
||||
{server.description or 'Не указано'}
|
||||
|
||||
Выберите что изменить:
|
||||
"""
|
||||
|
||||
keyboard = [
|
||||
[
|
||||
types.InlineKeyboardButton(text="✏️ Название", callback_data=f"admin_server_edit_name_{server.id}"),
|
||||
types.InlineKeyboardButton(text="💰 Цена", callback_data=f"admin_server_edit_price_{server.id}")
|
||||
],
|
||||
[
|
||||
types.InlineKeyboardButton(text="🌍 Страна", callback_data=f"admin_server_edit_country_{server.id}"),
|
||||
types.InlineKeyboardButton(text="👥 Лимит", callback_data=f"admin_server_edit_limit_{server.id}")
|
||||
],
|
||||
[
|
||||
types.InlineKeyboardButton(text="📝 Описание", callback_data=f"admin_server_edit_desc_{server.id}")
|
||||
],
|
||||
[
|
||||
types.InlineKeyboardButton(
|
||||
text="❌ Отключить" if server.is_available else "✅ Включить",
|
||||
callback_data=f"admin_server_toggle_{server.id}"
|
||||
)
|
||||
],
|
||||
[
|
||||
types.InlineKeyboardButton(text="🗑️ Удалить", callback_data=f"admin_server_delete_{server.id}"),
|
||||
types.InlineKeyboardButton(text="⬅️ Назад", callback_data="admin_servers_list")
|
||||
]
|
||||
]
|
||||
|
||||
await callback.message.edit_text(
|
||||
text,
|
||||
reply_markup=types.InlineKeyboardMarkup(inline_keyboard=keyboard),
|
||||
parse_mode="HTML"
|
||||
)
|
||||
await callback.answer()
|
||||
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def toggle_server_availability(
|
||||
callback: types.CallbackQuery,
|
||||
db_user: User,
|
||||
db: AsyncSession
|
||||
):
|
||||
|
||||
server_id = int(callback.data.split('_')[-1])
|
||||
server = await get_server_squad_by_id(db, server_id)
|
||||
|
||||
if not server:
|
||||
await callback.answer("❌ Сервер не найден!", show_alert=True)
|
||||
return
|
||||
|
||||
new_status = not server.is_available
|
||||
await update_server_squad(db, server_id, is_available=new_status)
|
||||
|
||||
await cache.delete("available_countries")
|
||||
|
||||
status_text = "включен" if new_status else "отключен"
|
||||
await callback.answer(f"✅ Сервер {status_text}!")
|
||||
|
||||
server = await get_server_squad_by_id(db, server_id)
|
||||
|
||||
status_emoji = "✅ Доступен" if server.is_available else "❌ Недоступен"
|
||||
price_text = f"{server.price_rubles:.2f} ₽" if server.price_kopeks > 0 else "Бесплатно"
|
||||
|
||||
text = f"""
|
||||
🌐 <b>Редактирование сервера</b>
|
||||
|
||||
<b>Информация:</b>
|
||||
• ID: {server.id}
|
||||
• UUID: <code>{server.squad_uuid}</code>
|
||||
• Название: {server.display_name}
|
||||
• Оригинальное: {server.original_name or 'Не указано'}
|
||||
• Статус: {status_emoji}
|
||||
|
||||
<b>Настройки:</b>
|
||||
• Цена: {price_text}
|
||||
• Код страны: {server.country_code or 'Не указан'}
|
||||
• Лимит пользователей: {server.max_users or 'Без лимита'}
|
||||
• Текущих пользователей: {server.current_users}
|
||||
|
||||
<b>Описание:</b>
|
||||
{server.description or 'Не указано'}
|
||||
|
||||
Выберите что изменить:
|
||||
"""
|
||||
|
||||
keyboard = [
|
||||
[
|
||||
types.InlineKeyboardButton(text="✏️ Название", callback_data=f"admin_server_edit_name_{server.id}"),
|
||||
types.InlineKeyboardButton(text="💰 Цена", callback_data=f"admin_server_edit_price_{server.id}")
|
||||
],
|
||||
[
|
||||
types.InlineKeyboardButton(text="🌍 Страна", callback_data=f"admin_server_edit_country_{server.id}"),
|
||||
types.InlineKeyboardButton(text="👥 Лимит", callback_data=f"admin_server_edit_limit_{server.id}")
|
||||
],
|
||||
[
|
||||
types.InlineKeyboardButton(text="📝 Описание", callback_data=f"admin_server_edit_desc_{server.id}")
|
||||
],
|
||||
[
|
||||
types.InlineKeyboardButton(
|
||||
text="❌ Отключить" if server.is_available else "✅ Включить",
|
||||
callback_data=f"admin_server_toggle_{server.id}"
|
||||
)
|
||||
],
|
||||
[
|
||||
types.InlineKeyboardButton(text="🗑️ Удалить", callback_data=f"admin_server_delete_{server.id}"),
|
||||
types.InlineKeyboardButton(text="⬅️ Назад", callback_data="admin_servers_list")
|
||||
]
|
||||
]
|
||||
|
||||
await callback.message.edit_text(
|
||||
text,
|
||||
reply_markup=types.InlineKeyboardMarkup(inline_keyboard=keyboard),
|
||||
parse_mode="HTML"
|
||||
)
|
||||
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def start_server_edit_price(
|
||||
callback: types.CallbackQuery,
|
||||
state: FSMContext,
|
||||
db_user: User,
|
||||
db: AsyncSession
|
||||
):
|
||||
|
||||
server_id = int(callback.data.split('_')[-1])
|
||||
server = await get_server_squad_by_id(db, server_id)
|
||||
|
||||
if not server:
|
||||
await callback.answer("❌ Сервер не найден!", show_alert=True)
|
||||
return
|
||||
|
||||
await state.set_data({'server_id': server_id})
|
||||
await state.set_state(AdminStates.editing_server_price)
|
||||
|
||||
current_price = f"{server.price_rubles:.2f} ₽" if server.price_kopeks > 0 else "Бесплатно"
|
||||
|
||||
await callback.message.edit_text(
|
||||
f"💰 <b>Редактирование цены</b>\n\n"
|
||||
f"Текущая цена: <b>{current_price}</b>\n\n"
|
||||
f"Отправьте новую цену в рублях (например: 15.50) или 0 для бесплатного доступа:",
|
||||
reply_markup=types.InlineKeyboardMarkup(inline_keyboard=[
|
||||
[types.InlineKeyboardButton(text="❌ Отмена", callback_data=f"admin_server_edit_{server_id}")]
|
||||
]),
|
||||
parse_mode="HTML"
|
||||
)
|
||||
await callback.answer()
|
||||
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def process_server_price_edit(
|
||||
message: types.Message,
|
||||
state: FSMContext,
|
||||
db_user: User,
|
||||
db: AsyncSession
|
||||
):
|
||||
|
||||
data = await state.get_data()
|
||||
server_id = data.get('server_id')
|
||||
|
||||
try:
|
||||
price_rubles = float(message.text.replace(',', '.'))
|
||||
|
||||
if price_rubles < 0:
|
||||
await message.answer("❌ Цена не может быть отрицательной")
|
||||
return
|
||||
|
||||
if price_rubles > 10000:
|
||||
await message.answer("❌ Слишком высокая цена (максимум 10,000 ₽)")
|
||||
return
|
||||
|
||||
price_kopeks = int(price_rubles * 100)
|
||||
|
||||
server = await update_server_squad(db, server_id, price_kopeks=price_kopeks)
|
||||
|
||||
if server:
|
||||
await state.clear()
|
||||
|
||||
await cache.delete("available_countries")
|
||||
|
||||
price_text = f"{price_rubles:.2f} ₽" if price_kopeks > 0 else "Бесплатно"
|
||||
await message.answer(
|
||||
f"✅ Цена сервера изменена на: <b>{price_text}</b>",
|
||||
reply_markup=types.InlineKeyboardMarkup(inline_keyboard=[
|
||||
[types.InlineKeyboardButton(text="🔙 К серверу", callback_data=f"admin_server_edit_{server_id}")]
|
||||
]),
|
||||
parse_mode="HTML"
|
||||
)
|
||||
else:
|
||||
await message.answer("❌ Ошибка при обновлении сервера")
|
||||
|
||||
except ValueError:
|
||||
await message.answer("❌ Неверный формат цены. Используйте числа (например: 15.50)")
|
||||
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def start_server_edit_name(
|
||||
callback: types.CallbackQuery,
|
||||
state: FSMContext,
|
||||
db_user: User,
|
||||
db: AsyncSession
|
||||
):
|
||||
|
||||
server_id = int(callback.data.split('_')[-1])
|
||||
server = await get_server_squad_by_id(db, server_id)
|
||||
|
||||
if not server:
|
||||
await callback.answer("❌ Сервер не найден!", show_alert=True)
|
||||
return
|
||||
|
||||
await state.set_data({'server_id': server_id})
|
||||
await state.set_state(AdminStates.editing_server_name)
|
||||
|
||||
await callback.message.edit_text(
|
||||
f"✏️ <b>Редактирование названия</b>\n\n"
|
||||
f"Текущее название: <b>{server.display_name}</b>\n\n"
|
||||
f"Отправьте новое название для сервера:",
|
||||
reply_markup=types.InlineKeyboardMarkup(inline_keyboard=[
|
||||
[types.InlineKeyboardButton(text="❌ Отмена", callback_data=f"admin_server_edit_{server_id}")]
|
||||
]),
|
||||
parse_mode="HTML"
|
||||
)
|
||||
await callback.answer()
|
||||
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def process_server_name_edit(
|
||||
message: types.Message,
|
||||
state: FSMContext,
|
||||
db_user: User,
|
||||
db: AsyncSession
|
||||
):
|
||||
|
||||
data = await state.get_data()
|
||||
server_id = data.get('server_id')
|
||||
|
||||
new_name = message.text.strip()
|
||||
|
||||
if len(new_name) > 255:
|
||||
await message.answer("❌ Название слишком длинное (максимум 255 символов)")
|
||||
return
|
||||
|
||||
if len(new_name) < 3:
|
||||
await message.answer("❌ Название слишком короткое (минимум 3 символа)")
|
||||
return
|
||||
|
||||
server = await update_server_squad(db, server_id, display_name=new_name)
|
||||
|
||||
if server:
|
||||
await state.clear()
|
||||
|
||||
await cache.delete("available_countries")
|
||||
|
||||
await message.answer(
|
||||
f"✅ Название сервера изменено на: <b>{new_name}</b>",
|
||||
reply_markup=types.InlineKeyboardMarkup(inline_keyboard=[
|
||||
[types.InlineKeyboardButton(text="🔙 К серверу", callback_data=f"admin_server_edit_{server_id}")]
|
||||
]),
|
||||
parse_mode="HTML"
|
||||
)
|
||||
else:
|
||||
await message.answer("❌ Ошибка при обновлении сервера")
|
||||
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def delete_server_confirm(
|
||||
callback: types.CallbackQuery,
|
||||
db_user: User,
|
||||
db: AsyncSession
|
||||
):
|
||||
|
||||
server_id = int(callback.data.split('_')[-1])
|
||||
server = await get_server_squad_by_id(db, server_id)
|
||||
|
||||
if not server:
|
||||
await callback.answer("❌ Сервер не найден!", show_alert=True)
|
||||
return
|
||||
|
||||
text = f"""
|
||||
🗑️ <b>Удаление сервера</b>
|
||||
|
||||
Вы действительно хотите удалить сервер:
|
||||
<b>{server.display_name}</b>
|
||||
|
||||
⚠️ <b>Внимание!</b>
|
||||
Сервер можно удалить только если к нему нет активных подключений.
|
||||
|
||||
Это действие нельзя отменить!
|
||||
"""
|
||||
|
||||
keyboard = [
|
||||
[
|
||||
types.InlineKeyboardButton(text="🗑️ Да, удалить", callback_data=f"admin_server_delete_confirm_{server_id}"),
|
||||
types.InlineKeyboardButton(text="❌ Отмена", callback_data=f"admin_server_edit_{server_id}")
|
||||
]
|
||||
]
|
||||
|
||||
await callback.message.edit_text(
|
||||
text,
|
||||
reply_markup=types.InlineKeyboardMarkup(inline_keyboard=keyboard),
|
||||
parse_mode="HTML"
|
||||
)
|
||||
await callback.answer()
|
||||
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def delete_server_execute(
|
||||
callback: types.CallbackQuery,
|
||||
db_user: User,
|
||||
db: AsyncSession
|
||||
):
|
||||
|
||||
server_id = int(callback.data.split('_')[-1])
|
||||
server = await get_server_squad_by_id(db, server_id)
|
||||
|
||||
if not server:
|
||||
await callback.answer("❌ Сервер не найден!", show_alert=True)
|
||||
return
|
||||
|
||||
success = await delete_server_squad(db, server_id)
|
||||
|
||||
if success:
|
||||
await cache.delete("available_countries")
|
||||
|
||||
await callback.message.edit_text(
|
||||
f"✅ Сервер <b>{server.display_name}</b> успешно удален!",
|
||||
reply_markup=types.InlineKeyboardMarkup(inline_keyboard=[
|
||||
[types.InlineKeyboardButton(text="📋 К списку серверов", callback_data="admin_servers_list")]
|
||||
]),
|
||||
parse_mode="HTML"
|
||||
)
|
||||
else:
|
||||
await callback.message.edit_text(
|
||||
f"❌ Не удалось удалить сервер <b>{server.display_name}</b>\n\n"
|
||||
f"Возможно, к нему есть активные подключения.",
|
||||
reply_markup=types.InlineKeyboardMarkup(inline_keyboard=[
|
||||
[types.InlineKeyboardButton(text="🔙 К серверу", callback_data=f"admin_server_edit_{server_id}")]
|
||||
]),
|
||||
parse_mode="HTML"
|
||||
)
|
||||
|
||||
await callback.answer()
|
||||
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def show_server_detailed_stats(
|
||||
callback: types.CallbackQuery,
|
||||
db_user: User,
|
||||
db: AsyncSession
|
||||
):
|
||||
|
||||
stats = await get_server_statistics(db)
|
||||
available_servers = await get_available_server_squads(db)
|
||||
|
||||
text = f"""
|
||||
📊 <b>Подробная статистика серверов</b>
|
||||
|
||||
<b>🌐 Общая информация:</b>
|
||||
• Всего серверов: {stats['total_servers']}
|
||||
• Доступные: {stats['available_servers']}
|
||||
• Недоступные: {stats['unavailable_servers']}
|
||||
• С активными подключениями: {stats['servers_with_connections']}
|
||||
|
||||
<b>💰 Финансовая статистика:</b>
|
||||
• Общая выручка: {stats['total_revenue_rubles']:.2f} ₽
|
||||
• Средняя цена за сервер: {(stats['total_revenue_rubles'] / max(stats['servers_with_connections'], 1)):.2f} ₽
|
||||
|
||||
<b>🔥 Топ серверов по цене:</b>
|
||||
"""
|
||||
|
||||
sorted_servers = sorted(available_servers, key=lambda x: x.price_kopeks, reverse=True)
|
||||
|
||||
for i, server in enumerate(sorted_servers[:5], 1):
|
||||
price_text = f"{server.price_rubles:.2f} ₽" if server.price_kopeks > 0 else "Бесплатно"
|
||||
text += f"{i}. {server.display_name} - {price_text}\n"
|
||||
|
||||
if not sorted_servers:
|
||||
text += "Нет доступных серверов\n"
|
||||
|
||||
keyboard = [
|
||||
[
|
||||
types.InlineKeyboardButton(text="🔄 Обновить", callback_data="admin_servers_stats"),
|
||||
types.InlineKeyboardButton(text="📋 Список", callback_data="admin_servers_list")
|
||||
],
|
||||
[types.InlineKeyboardButton(text="⬅️ Назад", callback_data="admin_servers")]
|
||||
]
|
||||
|
||||
await callback.message.edit_text(
|
||||
text,
|
||||
reply_markup=types.InlineKeyboardMarkup(inline_keyboard=keyboard)
|
||||
)
|
||||
await callback.answer()
|
||||
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def start_server_edit_country(
|
||||
callback: types.CallbackQuery,
|
||||
state: FSMContext,
|
||||
db_user: User,
|
||||
db: AsyncSession
|
||||
):
|
||||
|
||||
server_id = int(callback.data.split('_')[-1])
|
||||
server = await get_server_squad_by_id(db, server_id)
|
||||
|
||||
if not server:
|
||||
await callback.answer("❌ Сервер не найден!", show_alert=True)
|
||||
return
|
||||
|
||||
await state.set_data({'server_id': server_id})
|
||||
await state.set_state(AdminStates.editing_server_country)
|
||||
|
||||
current_country = server.country_code or "Не указан"
|
||||
|
||||
await callback.message.edit_text(
|
||||
f"🌍 <b>Редактирование кода страны</b>\n\n"
|
||||
f"Текущий код страны: <b>{current_country}</b>\n\n"
|
||||
f"Отправьте новый код страны (например: RU, US, DE) или '-' для удаления:",
|
||||
reply_markup=types.InlineKeyboardMarkup(inline_keyboard=[
|
||||
[types.InlineKeyboardButton(text="❌ Отмена", callback_data=f"admin_server_edit_{server_id}")]
|
||||
]),
|
||||
parse_mode="HTML"
|
||||
)
|
||||
await callback.answer()
|
||||
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def process_server_country_edit(
|
||||
message: types.Message,
|
||||
state: FSMContext,
|
||||
db_user: User,
|
||||
db: AsyncSession
|
||||
):
|
||||
|
||||
data = await state.get_data()
|
||||
server_id = data.get('server_id')
|
||||
|
||||
new_country = message.text.strip().upper()
|
||||
|
||||
if new_country == "-":
|
||||
new_country = None
|
||||
elif len(new_country) > 5:
|
||||
await message.answer("❌ Код страны слишком длинный (максимум 5 символов)")
|
||||
return
|
||||
|
||||
server = await update_server_squad(db, server_id, country_code=new_country)
|
||||
|
||||
if server:
|
||||
await state.clear()
|
||||
|
||||
await cache.delete("available_countries")
|
||||
|
||||
country_text = new_country or "Удален"
|
||||
await message.answer(
|
||||
f"✅ Код страны изменен на: <b>{country_text}</b>",
|
||||
reply_markup=types.InlineKeyboardMarkup(inline_keyboard=[
|
||||
[types.InlineKeyboardButton(text="🔙 К серверу", callback_data=f"admin_server_edit_{server_id}")]
|
||||
]),
|
||||
parse_mode="HTML"
|
||||
)
|
||||
else:
|
||||
await message.answer("❌ Ошибка при обновлении сервера")
|
||||
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def start_server_edit_limit(
|
||||
callback: types.CallbackQuery,
|
||||
state: FSMContext,
|
||||
db_user: User,
|
||||
db: AsyncSession
|
||||
):
|
||||
|
||||
server_id = int(callback.data.split('_')[-1])
|
||||
server = await get_server_squad_by_id(db, server_id)
|
||||
|
||||
if not server:
|
||||
await callback.answer("❌ Сервер не найден!", show_alert=True)
|
||||
return
|
||||
|
||||
await state.set_data({'server_id': server_id})
|
||||
await state.set_state(AdminStates.editing_server_limit)
|
||||
|
||||
current_limit = server.max_users or "Без лимита"
|
||||
|
||||
await callback.message.edit_text(
|
||||
f"👥 <b>Редактирование лимита пользователей</b>\n\n"
|
||||
f"Текущий лимит: <b>{current_limit}</b>\n\n"
|
||||
f"Отправьте новый лимит пользователей (число) или 0 для безлимитного доступа:",
|
||||
reply_markup=types.InlineKeyboardMarkup(inline_keyboard=[
|
||||
[types.InlineKeyboardButton(text="❌ Отмена", callback_data=f"admin_server_edit_{server_id}")]
|
||||
]),
|
||||
parse_mode="HTML"
|
||||
)
|
||||
await callback.answer()
|
||||
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def process_server_limit_edit(
|
||||
message: types.Message,
|
||||
state: FSMContext,
|
||||
db_user: User,
|
||||
db: AsyncSession
|
||||
):
|
||||
|
||||
data = await state.get_data()
|
||||
server_id = data.get('server_id')
|
||||
|
||||
try:
|
||||
limit = int(message.text.strip())
|
||||
|
||||
if limit < 0:
|
||||
await message.answer("❌ Лимит не может быть отрицательным")
|
||||
return
|
||||
|
||||
if limit > 10000:
|
||||
await message.answer("❌ Слишком большой лимит (максимум 10,000)")
|
||||
return
|
||||
|
||||
max_users = limit if limit > 0 else None
|
||||
|
||||
server = await update_server_squad(db, server_id, max_users=max_users)
|
||||
|
||||
if server:
|
||||
await state.clear()
|
||||
|
||||
limit_text = f"{limit} пользователей" if limit > 0 else "Без лимита"
|
||||
await message.answer(
|
||||
f"✅ Лимит пользователей изменен на: <b>{limit_text}</b>",
|
||||
reply_markup=types.InlineKeyboardMarkup(inline_keyboard=[
|
||||
[types.InlineKeyboardButton(text="🔙 К серверу", callback_data=f"admin_server_edit_{server_id}")]
|
||||
]),
|
||||
parse_mode="HTML"
|
||||
)
|
||||
else:
|
||||
await message.answer("❌ Ошибка при обновлении сервера")
|
||||
|
||||
except ValueError:
|
||||
await message.answer("❌ Неверный формат числа. Введите целое число.")
|
||||
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def start_server_edit_description(
|
||||
callback: types.CallbackQuery,
|
||||
state: FSMContext,
|
||||
db_user: User,
|
||||
db: AsyncSession
|
||||
):
|
||||
|
||||
server_id = int(callback.data.split('_')[-1])
|
||||
server = await get_server_squad_by_id(db, server_id)
|
||||
|
||||
if not server:
|
||||
await callback.answer("❌ Сервер не найден!", show_alert=True)
|
||||
return
|
||||
|
||||
await state.set_data({'server_id': server_id})
|
||||
await state.set_state(AdminStates.editing_server_description)
|
||||
|
||||
current_desc = server.description or "Не указано"
|
||||
|
||||
await callback.message.edit_text(
|
||||
f"📝 <b>Редактирование описания</b>\n\n"
|
||||
f"Текущее описание:\n<i>{current_desc}</i>\n\n"
|
||||
f"Отправьте новое описание сервера или '-' для удаления:",
|
||||
reply_markup=types.InlineKeyboardMarkup(inline_keyboard=[
|
||||
[types.InlineKeyboardButton(text="❌ Отмена", callback_data=f"admin_server_edit_{server_id}")]
|
||||
]),
|
||||
parse_mode="HTML"
|
||||
)
|
||||
await callback.answer()
|
||||
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def process_server_description_edit(
|
||||
message: types.Message,
|
||||
state: FSMContext,
|
||||
db_user: User,
|
||||
db: AsyncSession
|
||||
):
|
||||
|
||||
data = await state.get_data()
|
||||
server_id = data.get('server_id')
|
||||
|
||||
new_description = message.text.strip()
|
||||
|
||||
if new_description == "-":
|
||||
new_description = None
|
||||
elif len(new_description) > 1000:
|
||||
await message.answer("❌ Описание слишком длинное (максимум 1000 символов)")
|
||||
return
|
||||
|
||||
server = await update_server_squad(db, server_id, description=new_description)
|
||||
|
||||
if server:
|
||||
await state.clear()
|
||||
|
||||
desc_text = new_description or "Удалено"
|
||||
await message.answer(
|
||||
f"✅ Описание сервера изменено:\n\n<i>{desc_text}</i>",
|
||||
reply_markup=types.InlineKeyboardMarkup(inline_keyboard=[
|
||||
[types.InlineKeyboardButton(text="🔙 К серверу", callback_data=f"admin_server_edit_{server_id}")]
|
||||
]),
|
||||
parse_mode="HTML"
|
||||
)
|
||||
else:
|
||||
await message.answer("❌ Ошибка при обновлении сервера")
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def sync_server_user_counts_handler(
|
||||
callback: types.CallbackQuery,
|
||||
db_user: User,
|
||||
db: AsyncSession
|
||||
):
|
||||
|
||||
await callback.message.edit_text(
|
||||
"🔄 Синхронизация счетчиков пользователей...",
|
||||
reply_markup=None
|
||||
)
|
||||
|
||||
try:
|
||||
from app.database.crud.server_squad import sync_server_user_counts
|
||||
|
||||
updated_count = await sync_server_user_counts(db)
|
||||
|
||||
text = f"""
|
||||
✅ <b>Синхронизация завершена</b>
|
||||
|
||||
📊 <b>Результат:</b>
|
||||
• Обновлено серверов: {updated_count}
|
||||
|
||||
Счетчики пользователей синхронизированы с реальными данными.
|
||||
"""
|
||||
|
||||
keyboard = [
|
||||
[
|
||||
types.InlineKeyboardButton(text="📋 Список серверов", callback_data="admin_servers_list"),
|
||||
types.InlineKeyboardButton(text="🔄 Повторить", callback_data="admin_servers_sync_counts")
|
||||
],
|
||||
[types.InlineKeyboardButton(text="⬅️ Назад", callback_data="admin_servers")]
|
||||
]
|
||||
|
||||
await callback.message.edit_text(
|
||||
text,
|
||||
reply_markup=types.InlineKeyboardMarkup(inline_keyboard=keyboard)
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка синхронизации счетчиков: {e}")
|
||||
await callback.message.edit_text(
|
||||
f"❌ Ошибка синхронизации: {str(e)}",
|
||||
reply_markup=types.InlineKeyboardMarkup(inline_keyboard=[
|
||||
[types.InlineKeyboardButton(text="⬅️ Назад", callback_data="admin_servers")]
|
||||
])
|
||||
)
|
||||
|
||||
await callback.answer()
|
||||
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def handle_servers_pagination(
|
||||
callback: types.CallbackQuery,
|
||||
db_user: User,
|
||||
db: AsyncSession
|
||||
):
|
||||
|
||||
page = int(callback.data.split('_')[-1])
|
||||
await show_servers_list(callback, db_user, db, page)
|
||||
|
||||
|
||||
def register_handlers(dp: Dispatcher):
|
||||
|
||||
dp.callback_query.register(show_servers_menu, F.data == "admin_servers")
|
||||
dp.callback_query.register(show_servers_list, F.data == "admin_servers_list")
|
||||
dp.callback_query.register(sync_servers_with_remnawave, F.data == "admin_servers_sync")
|
||||
dp.callback_query.register(sync_server_user_counts_handler, F.data == "admin_servers_sync_counts")
|
||||
dp.callback_query.register(show_server_detailed_stats, F.data == "admin_servers_stats")
|
||||
|
||||
dp.callback_query.register(show_server_edit_menu, F.data.startswith("admin_server_edit_") & ~F.data.contains("name") & ~F.data.contains("price") & ~F.data.contains("country") & ~F.data.contains("limit") & ~F.data.contains("desc"))
|
||||
dp.callback_query.register(toggle_server_availability, F.data.startswith("admin_server_toggle_"))
|
||||
|
||||
dp.callback_query.register(start_server_edit_name, F.data.startswith("admin_server_edit_name_"))
|
||||
dp.callback_query.register(start_server_edit_price, F.data.startswith("admin_server_edit_price_"))
|
||||
dp.callback_query.register(start_server_edit_country, F.data.startswith("admin_server_edit_country_"))
|
||||
dp.callback_query.register(start_server_edit_limit, F.data.startswith("admin_server_edit_limit_"))
|
||||
dp.callback_query.register(start_server_edit_description, F.data.startswith("admin_server_edit_desc_"))
|
||||
|
||||
dp.message.register(process_server_name_edit, AdminStates.editing_server_name)
|
||||
dp.message.register(process_server_price_edit, AdminStates.editing_server_price)
|
||||
dp.message.register(process_server_country_edit, AdminStates.editing_server_country)
|
||||
dp.message.register(process_server_limit_edit, AdminStates.editing_server_limit)
|
||||
dp.message.register(process_server_description_edit, AdminStates.editing_server_description)
|
||||
|
||||
dp.callback_query.register(delete_server_confirm, F.data.startswith("admin_server_delete_") & ~F.data.contains("confirm"))
|
||||
dp.callback_query.register(delete_server_execute, F.data.startswith("admin_server_delete_confirm_"))
|
||||
|
||||
dp.callback_query.register(handle_servers_pagination, F.data.startswith("admin_servers_list_page_"))
|
||||
@@ -0,0 +1,396 @@
|
||||
import logging
|
||||
from datetime import datetime, timedelta
|
||||
from aiogram import Dispatcher, types, F
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.config import settings
|
||||
from app.database.models import User
|
||||
from app.keyboards.admin import get_admin_statistics_keyboard, get_period_selection_keyboard
|
||||
from app.localization.texts import get_texts
|
||||
from app.services.user_service import UserService
|
||||
from app.database.crud.subscription import get_subscriptions_statistics
|
||||
from app.database.crud.transaction import get_transactions_statistics, get_revenue_by_period
|
||||
from app.database.crud.referral import get_referral_statistics
|
||||
from app.utils.decorators import admin_required, error_handler
|
||||
from app.utils.formatters import format_datetime, format_percentage
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def show_statistics_menu(
|
||||
callback: types.CallbackQuery,
|
||||
db_user: User,
|
||||
db: AsyncSession
|
||||
):
|
||||
text = """
|
||||
📊 <b>Статистика системы</b>
|
||||
|
||||
Выберите раздел для просмотра статистики:
|
||||
"""
|
||||
|
||||
await callback.message.edit_text(
|
||||
text,
|
||||
reply_markup=get_admin_statistics_keyboard(db_user.language)
|
||||
)
|
||||
await callback.answer()
|
||||
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def show_users_statistics(
|
||||
callback: types.CallbackQuery,
|
||||
db_user: User,
|
||||
db: AsyncSession
|
||||
):
|
||||
user_service = UserService()
|
||||
stats = await user_service.get_user_statistics(db)
|
||||
|
||||
total_users = stats['total_users']
|
||||
active_rate = format_percentage(stats['active_users'] / total_users * 100 if total_users > 0 else 0)
|
||||
|
||||
current_time = format_datetime(datetime.utcnow())
|
||||
|
||||
text = f"""
|
||||
👥 <b>Статистика пользователей</b>
|
||||
|
||||
<b>Общие показатели:</b>
|
||||
- Всего зарегистрировано: {stats['total_users']}
|
||||
- Активных: {stats['active_users']} ({active_rate})
|
||||
- Заблокированных: {stats['blocked_users']}
|
||||
|
||||
<b>Новые регистрации:</b>
|
||||
- Сегодня: {stats['new_today']}
|
||||
- За неделю: {stats['new_week']}
|
||||
- За месяц: {stats['new_month']}
|
||||
|
||||
<b>Активность:</b>
|
||||
- Коэффициент активности: {active_rate}
|
||||
- Рост за месяц: +{stats['new_month']} ({format_percentage(stats['new_month'] / total_users * 100 if total_users > 0 else 0)})
|
||||
|
||||
<b>Обновлено:</b> {current_time}
|
||||
"""
|
||||
|
||||
keyboard = types.InlineKeyboardMarkup(inline_keyboard=[
|
||||
[types.InlineKeyboardButton(text="🔄 Обновить", callback_data="admin_stats_users")],
|
||||
[types.InlineKeyboardButton(text="⬅️ Назад", callback_data="admin_statistics")]
|
||||
])
|
||||
|
||||
try:
|
||||
await callback.message.edit_text(text, reply_markup=keyboard)
|
||||
except Exception as e:
|
||||
if "message is not modified" in str(e):
|
||||
await callback.answer("📊 Данные актуальны", show_alert=False)
|
||||
else:
|
||||
logger.error(f"Ошибка обновления статистики пользователей: {e}")
|
||||
await callback.answer("❌ Ошибка обновления данных", show_alert=True)
|
||||
return
|
||||
|
||||
await callback.answer("✅ Статистика обновлена")
|
||||
|
||||
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def show_subscriptions_statistics(
|
||||
callback: types.CallbackQuery,
|
||||
db_user: User,
|
||||
db: AsyncSession
|
||||
):
|
||||
stats = await get_subscriptions_statistics(db)
|
||||
|
||||
total_subs = stats['total_subscriptions']
|
||||
conversion_rate = format_percentage(stats['paid_subscriptions'] / total_subs * 100 if total_subs > 0 else 0)
|
||||
current_time = format_datetime(datetime.utcnow())
|
||||
|
||||
text = f"""
|
||||
📱 <b>Статистика подписок</b>
|
||||
|
||||
<b>Общие показатели:</b>
|
||||
- Всего подписок: {stats['total_subscriptions']}
|
||||
- Активных: {stats['active_subscriptions']}
|
||||
- Платных: {stats['paid_subscriptions']}
|
||||
- Триальных: {stats['trial_subscriptions']}
|
||||
|
||||
<b>Конверсия:</b>
|
||||
- Из триала в платную: {conversion_rate}
|
||||
- Активных платных: {stats['paid_subscriptions']}
|
||||
|
||||
<b>Продажи:</b>
|
||||
- Сегодня: {stats['purchased_today']}
|
||||
- За неделю: {stats['purchased_week']}
|
||||
- За месяц: {stats['purchased_month']}
|
||||
|
||||
<b>Обновлено:</b> {current_time}
|
||||
"""
|
||||
|
||||
keyboard = types.InlineKeyboardMarkup(inline_keyboard=[
|
||||
[types.InlineKeyboardButton(text="🔄 Обновить", callback_data="admin_stats_subs")],
|
||||
[types.InlineKeyboardButton(text="⬅️ Назад", callback_data="admin_statistics")]
|
||||
])
|
||||
|
||||
try:
|
||||
await callback.message.edit_text(text, reply_markup=keyboard)
|
||||
await callback.answer("✅ Статистика обновлена")
|
||||
except Exception as e:
|
||||
if "message is not modified" in str(e):
|
||||
await callback.answer("📊 Данные актуальны", show_alert=False)
|
||||
else:
|
||||
logger.error(f"Ошибка обновления статистики подписок: {e}")
|
||||
await callback.answer("❌ Ошибка обновления данных", show_alert=True)
|
||||
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def show_revenue_statistics(
|
||||
callback: types.CallbackQuery,
|
||||
db_user: User,
|
||||
db: AsyncSession
|
||||
):
|
||||
now = datetime.utcnow()
|
||||
month_start = now.replace(day=1, hour=0, minute=0, second=0, microsecond=0)
|
||||
|
||||
month_stats = await get_transactions_statistics(db, month_start, now)
|
||||
all_time_stats = await get_transactions_statistics(db)
|
||||
current_time = format_datetime(datetime.utcnow())
|
||||
|
||||
text = f"""
|
||||
💰 <b>Статистика доходов</b>
|
||||
|
||||
<b>За текущий месяц:</b>
|
||||
- Доходы: {settings.format_price(month_stats['totals']['income_kopeks'])}
|
||||
- Расходы: {settings.format_price(month_stats['totals']['expenses_kopeks'])}
|
||||
- Прибыль: {settings.format_price(month_stats['totals']['profit_kopeks'])}
|
||||
- От подписок: {settings.format_price(month_stats['totals']['subscription_income_kopeks'])}
|
||||
|
||||
<b>Сегодня:</b>
|
||||
- Транзакций: {month_stats['today']['transactions_count']}
|
||||
- Доходы: {settings.format_price(month_stats['today']['income_kopeks'])}
|
||||
|
||||
<b>За все время:</b>
|
||||
- Общий доход: {settings.format_price(all_time_stats['totals']['income_kopeks'])}
|
||||
- Общая прибыль: {settings.format_price(all_time_stats['totals']['profit_kopeks'])}
|
||||
|
||||
<b>Способы оплаты:</b>
|
||||
"""
|
||||
|
||||
for method, data in month_stats['by_payment_method'].items():
|
||||
if method and data['count'] > 0:
|
||||
text += f"• {method}: {data['count']} ({settings.format_price(data['amount'])})\n"
|
||||
|
||||
text += f"\n<b>Обновлено:</b> {current_time}"
|
||||
|
||||
keyboard = types.InlineKeyboardMarkup(inline_keyboard=[
|
||||
# [types.InlineKeyboardButton(text="📈 Период", callback_data="admin_revenue_period")],
|
||||
[types.InlineKeyboardButton(text="🔄 Обновить", callback_data="admin_stats_revenue")],
|
||||
[types.InlineKeyboardButton(text="⬅️ Назад", callback_data="admin_statistics")]
|
||||
])
|
||||
|
||||
try:
|
||||
await callback.message.edit_text(text, reply_markup=keyboard)
|
||||
await callback.answer("✅ Статистика обновлена")
|
||||
except Exception as e:
|
||||
if "message is not modified" in str(e):
|
||||
await callback.answer("📊 Данные актуальны", show_alert=False)
|
||||
else:
|
||||
logger.error(f"Ошибка обновления статистики доходов: {e}")
|
||||
await callback.answer("❌ Ошибка обновления данных", show_alert=True)
|
||||
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def show_referral_statistics(
|
||||
callback: types.CallbackQuery,
|
||||
db_user: User,
|
||||
db: AsyncSession
|
||||
):
|
||||
stats = await get_referral_statistics(db)
|
||||
current_time = format_datetime(datetime.utcnow())
|
||||
|
||||
avg_per_referrer = 0
|
||||
if stats['active_referrers'] > 0:
|
||||
avg_per_referrer = stats['total_paid_kopeks'] / stats['active_referrers']
|
||||
|
||||
text = f"""
|
||||
🤝 <b>Реферальная статистика</b>
|
||||
|
||||
<b>Общие показатели:</b>
|
||||
- Пользователей с рефералами: {stats['users_with_referrals']}
|
||||
- Активных рефереров: {stats['active_referrers']}
|
||||
- Выплачено всего: {settings.format_price(stats['total_paid_kopeks'])}
|
||||
|
||||
<b>За период:</b>
|
||||
- Сегодня: {settings.format_price(stats['today_earnings_kopeks'])}
|
||||
- За неделю: {settings.format_price(stats['week_earnings_kopeks'])}
|
||||
- За месяц: {settings.format_price(stats['month_earnings_kopeks'])}
|
||||
|
||||
<b>Средние показатели:</b>
|
||||
- На одного рефререра: {settings.format_price(int(avg_per_referrer))}
|
||||
|
||||
<b>Топ рефереры:</b>
|
||||
"""
|
||||
|
||||
if stats['top_referrers']:
|
||||
for i, referrer in enumerate(stats['top_referrers'][:5], 1):
|
||||
name = referrer['display_name']
|
||||
earned = settings.format_price(referrer['total_earned_kopeks'])
|
||||
count = referrer['referrals_count']
|
||||
text += f"{i}. {name}: {earned} ({count} реф.)\n"
|
||||
else:
|
||||
text += "Пока нет активных рефереров"
|
||||
|
||||
text += f"\n<b>Обновлено:</b> {current_time}"
|
||||
|
||||
keyboard = types.InlineKeyboardMarkup(inline_keyboard=[
|
||||
[types.InlineKeyboardButton(text="🔄 Обновить", callback_data="admin_stats_referrals")],
|
||||
[types.InlineKeyboardButton(text="⬅️ Назад", callback_data="admin_statistics")]
|
||||
])
|
||||
|
||||
try:
|
||||
await callback.message.edit_text(text, reply_markup=keyboard)
|
||||
await callback.answer("✅ Статистика обновлена")
|
||||
except Exception as e:
|
||||
if "message is not modified" in str(e):
|
||||
await callback.answer("📊 Данные актуальны", show_alert=False)
|
||||
else:
|
||||
logger.error(f"Ошибка обновления реферальной статистики: {e}")
|
||||
await callback.answer("❌ Ошибка обновления данных", show_alert=True)
|
||||
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def show_summary_statistics(
|
||||
callback: types.CallbackQuery,
|
||||
db_user: User,
|
||||
db: AsyncSession
|
||||
):
|
||||
user_service = UserService()
|
||||
user_stats = await user_service.get_user_statistics(db)
|
||||
sub_stats = await get_subscriptions_statistics(db)
|
||||
|
||||
now = datetime.utcnow()
|
||||
month_start = now.replace(day=1, hour=0, minute=0, second=0, microsecond=0)
|
||||
revenue_stats = await get_transactions_statistics(db, month_start, now)
|
||||
current_time = format_datetime(datetime.utcnow())
|
||||
|
||||
conversion_rate = 0
|
||||
if user_stats['total_users'] > 0:
|
||||
conversion_rate = sub_stats['paid_subscriptions'] / user_stats['total_users'] * 100
|
||||
|
||||
arpu = 0
|
||||
if user_stats['active_users'] > 0:
|
||||
arpu = revenue_stats['totals']['income_kopeks'] / user_stats['active_users']
|
||||
|
||||
text = f"""
|
||||
📊 <b>Общая сводка системы</b>
|
||||
|
||||
<b>Пользователи:</b>
|
||||
- Всего: {user_stats['total_users']}
|
||||
- Активных: {user_stats['active_users']}
|
||||
- Новых за месяц: {user_stats['new_month']}
|
||||
|
||||
<b>Подписки:</b>
|
||||
- Активных: {sub_stats['active_subscriptions']}
|
||||
- Платных: {sub_stats['paid_subscriptions']}
|
||||
- Конверсия: {format_percentage(conversion_rate)}
|
||||
|
||||
<b>Финансы (месяц):</b>
|
||||
- Доходы: {settings.format_price(revenue_stats['totals']['income_kopeks'])}
|
||||
- ARPU: {settings.format_price(int(arpu))}
|
||||
- Транзакций: {sum(data['count'] for data in revenue_stats['by_type'].values())}
|
||||
|
||||
<b>Рост:</b>
|
||||
- Пользователи: +{user_stats['new_month']} за месяц
|
||||
- Продажи: +{sub_stats['purchased_month']} за месяц
|
||||
|
||||
<b>Обновлено:</b> {current_time}
|
||||
"""
|
||||
|
||||
keyboard = types.InlineKeyboardMarkup(inline_keyboard=[
|
||||
[types.InlineKeyboardButton(text="🔄 Обновить", callback_data="admin_stats_summary")],
|
||||
[types.InlineKeyboardButton(text="⬅️ Назад", callback_data="admin_statistics")]
|
||||
])
|
||||
|
||||
try:
|
||||
await callback.message.edit_text(text, reply_markup=keyboard)
|
||||
await callback.answer("✅ Статистика обновлена")
|
||||
except Exception as e:
|
||||
if "message is not modified" in str(e):
|
||||
await callback.answer("📊 Данные актуальны", show_alert=False)
|
||||
else:
|
||||
logger.error(f"Ошибка обновления общей статистики: {e}")
|
||||
await callback.answer("❌ Ошибка обновления данных", show_alert=True)
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def show_revenue_by_period(
|
||||
callback: types.CallbackQuery,
|
||||
db_user: User,
|
||||
db: AsyncSession
|
||||
):
|
||||
period = callback.data.split('_')[-1]
|
||||
|
||||
period_map = {
|
||||
"today": 1,
|
||||
"yesterday": 1,
|
||||
"week": 7,
|
||||
"month": 30,
|
||||
"all": 365
|
||||
}
|
||||
|
||||
days = period_map.get(period, 30)
|
||||
revenue_data = await get_revenue_by_period(db, days)
|
||||
|
||||
if period == "yesterday":
|
||||
yesterday = datetime.utcnow().date() - timedelta(days=1)
|
||||
revenue_data = [r for r in revenue_data if r['date'] == yesterday]
|
||||
elif period == "today":
|
||||
today = datetime.utcnow().date()
|
||||
revenue_data = [r for r in revenue_data if r['date'] == today]
|
||||
|
||||
total_revenue = sum(r['amount_kopeks'] for r in revenue_data)
|
||||
avg_daily = total_revenue / len(revenue_data) if revenue_data else 0
|
||||
|
||||
text = f"""
|
||||
📈 <b>Доходы за период: {period}</b>
|
||||
|
||||
<b>Сводка:</b>
|
||||
- Общий доход: {settings.format_price(total_revenue)}
|
||||
- Дней с данными: {len(revenue_data)}
|
||||
- Средний доход в день: {settings.format_price(int(avg_daily))}
|
||||
|
||||
<b>По дням:</b>
|
||||
"""
|
||||
|
||||
for revenue in revenue_data[-10:]:
|
||||
text += f"• {revenue['date'].strftime('%d.%m')}: {settings.format_price(revenue['amount_kopeks'])}\n"
|
||||
|
||||
if len(revenue_data) > 10:
|
||||
text += f"... и еще {len(revenue_data) - 10} дней"
|
||||
|
||||
await callback.message.edit_text(
|
||||
text,
|
||||
reply_markup=types.InlineKeyboardMarkup(inline_keyboard=[
|
||||
[types.InlineKeyboardButton(text="📊 Другой период", callback_data="admin_revenue_period")],
|
||||
[types.InlineKeyboardButton(text="⬅️ К доходам", callback_data="admin_stats_revenue")]
|
||||
])
|
||||
)
|
||||
await callback.answer()
|
||||
|
||||
|
||||
def register_handlers(dp: Dispatcher):
|
||||
dp.callback_query.register(show_statistics_menu, F.data == "admin_statistics")
|
||||
dp.callback_query.register(show_users_statistics, F.data == "admin_stats_users")
|
||||
dp.callback_query.register(show_subscriptions_statistics, F.data == "admin_stats_subs")
|
||||
dp.callback_query.register(show_revenue_statistics, F.data == "admin_stats_revenue")
|
||||
dp.callback_query.register(show_referral_statistics, F.data == "admin_stats_referrals")
|
||||
dp.callback_query.register(show_summary_statistics, F.data == "admin_stats_summary")
|
||||
dp.callback_query.register(show_revenue_by_period, F.data.startswith("period_"))
|
||||
|
||||
periods = ["today", "yesterday", "week", "month", "all"]
|
||||
for period in periods:
|
||||
dp.callback_query.register(
|
||||
show_revenue_by_period,
|
||||
F.data == f"period_{period}"
|
||||
)
|
||||
@@ -0,0 +1,497 @@
|
||||
import logging
|
||||
from aiogram import Dispatcher, types, F
|
||||
from aiogram.fsm.context import FSMContext
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy import select, func
|
||||
|
||||
from app.config import settings
|
||||
from app.states import AdminStates
|
||||
from app.database.models import User
|
||||
from app.keyboards.admin import get_admin_subscriptions_keyboard
|
||||
from app.localization.texts import get_texts
|
||||
from app.database.crud.subscription import (
|
||||
get_expiring_subscriptions, get_subscriptions_statistics, get_expired_subscriptions,
|
||||
get_all_subscriptions
|
||||
)
|
||||
from app.services.subscription_service import SubscriptionService
|
||||
from app.utils.decorators import admin_required, error_handler
|
||||
from app.utils.formatters import format_datetime, format_time_ago
|
||||
|
||||
|
||||
def get_country_flag(country_name: str) -> str:
|
||||
flags = {
|
||||
'USA': '🇺🇸', 'United States': '🇺🇸', 'US': '🇺🇸',
|
||||
'Germany': '🇩🇪', 'DE': '🇩🇪', 'Deutschland': '🇩🇪',
|
||||
'Netherlands': '🇳🇱', 'NL': '🇳🇱', 'Holland': '🇳🇱',
|
||||
'United Kingdom': '🇬🇧', 'UK': '🇬🇧', 'GB': '🇬🇧',
|
||||
'Japan': '🇯🇵', 'JP': '🇯🇵',
|
||||
'France': '🇫🇷', 'FR': '🇫🇷',
|
||||
'Canada': '🇨🇦', 'CA': '🇨🇦',
|
||||
'Russia': '🇷🇺', 'RU': '🇷🇺',
|
||||
'Singapore': '🇸🇬', 'SG': '🇸🇬',
|
||||
}
|
||||
return flags.get(country_name, '🌍')
|
||||
|
||||
|
||||
async def get_users_by_countries(db: AsyncSession) -> dict:
|
||||
try:
|
||||
result = await db.execute(
|
||||
select(User.preferred_location, func.count(User.id))
|
||||
.where(User.preferred_location.isnot(None))
|
||||
.group_by(User.preferred_location)
|
||||
)
|
||||
|
||||
stats = {}
|
||||
for location, count in result.fetchall():
|
||||
if location:
|
||||
stats[location] = count
|
||||
|
||||
return stats
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка получения статистики по странам: {e}")
|
||||
return {}
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def show_subscriptions_menu(
|
||||
callback: types.CallbackQuery,
|
||||
db_user: User,
|
||||
db: AsyncSession
|
||||
):
|
||||
stats = await get_subscriptions_statistics(db)
|
||||
|
||||
text = f"""
|
||||
📱 <b>Управление подписками</b>
|
||||
|
||||
📊 <b>Статистика:</b>
|
||||
- Всего: {stats['total_subscriptions']}
|
||||
- Активных: {stats['active_subscriptions']}
|
||||
- Платных: {stats['paid_subscriptions']}
|
||||
- Триальных: {stats['trial_subscriptions']}
|
||||
|
||||
📈 <b>Продажи:</b>
|
||||
- Сегодня: {stats['purchased_today']}
|
||||
- За неделю: {stats['purchased_week']}
|
||||
- За месяц: {stats['purchased_month']}
|
||||
|
||||
Выберите действие:
|
||||
"""
|
||||
|
||||
keyboard = [
|
||||
[
|
||||
types.InlineKeyboardButton(text="📋 Список подписок", callback_data="admin_subs_list"),
|
||||
types.InlineKeyboardButton(text="⏰ Истекающие", callback_data="admin_subs_expiring")
|
||||
],
|
||||
[
|
||||
types.InlineKeyboardButton(text="📊 Статистика", callback_data="admin_subs_stats"),
|
||||
types.InlineKeyboardButton(text="💰 Настройки цен", callback_data="admin_subs_pricing")
|
||||
],
|
||||
[
|
||||
types.InlineKeyboardButton(text="🌐 Управление серверами", callback_data="admin_servers"),
|
||||
types.InlineKeyboardButton(text="🌍 География", callback_data="admin_subs_countries")
|
||||
],
|
||||
[
|
||||
types.InlineKeyboardButton(text="⬅️ Назад", callback_data="admin_panel")
|
||||
]
|
||||
]
|
||||
|
||||
await callback.message.edit_text(
|
||||
text,
|
||||
reply_markup=types.InlineKeyboardMarkup(inline_keyboard=keyboard)
|
||||
)
|
||||
await callback.answer()
|
||||
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def show_subscriptions_list(
|
||||
callback: types.CallbackQuery,
|
||||
db_user: User,
|
||||
db: AsyncSession,
|
||||
page: int = 1
|
||||
):
|
||||
|
||||
subscriptions, total_count = await get_all_subscriptions(db, page=page, limit=10)
|
||||
total_pages = (total_count + 9) // 10
|
||||
|
||||
if not subscriptions:
|
||||
text = "📱 <b>Список подписок</b>\n\n❌ Подписки не найдены."
|
||||
else:
|
||||
text = f"📱 <b>Список подписок</b>\n\n"
|
||||
text += f"📊 Всего: {total_count} | Страница: {page}/{total_pages}\n\n"
|
||||
|
||||
for i, sub in enumerate(subscriptions, 1 + (page - 1) * 10):
|
||||
user_info = f"ID{sub.user.telegram_id}" if sub.user else "Неизвестно"
|
||||
sub_type = "🎁" if sub.is_trial else "💎"
|
||||
status = "✅ Активна" if sub.is_active else "❌ Неактивна"
|
||||
|
||||
text += f"{i}. {sub_type} {user_info}\n"
|
||||
text += f" {status} | До: {format_datetime(sub.end_date)}\n"
|
||||
if sub.device_limit > 0:
|
||||
text += f" 📱 Устройств: {sub.device_limit}\n"
|
||||
text += "\n"
|
||||
|
||||
keyboard = []
|
||||
|
||||
if total_pages > 1:
|
||||
nav_row = []
|
||||
if page > 1:
|
||||
nav_row.append(types.InlineKeyboardButton(
|
||||
text="⬅️", callback_data=f"admin_subs_list_page_{page-1}"
|
||||
))
|
||||
|
||||
nav_row.append(types.InlineKeyboardButton(
|
||||
text=f"{page}/{total_pages}", callback_data="current_page"
|
||||
))
|
||||
|
||||
if page < total_pages:
|
||||
nav_row.append(types.InlineKeyboardButton(
|
||||
text="➡️", callback_data=f"admin_subs_list_page_{page+1}"
|
||||
))
|
||||
|
||||
keyboard.append(nav_row)
|
||||
|
||||
keyboard.extend([
|
||||
[types.InlineKeyboardButton(text="🔄 Обновить", callback_data="admin_subs_list")],
|
||||
[types.InlineKeyboardButton(text="⬅️ Назад", callback_data="admin_subscriptions")]
|
||||
])
|
||||
|
||||
await callback.message.edit_text(
|
||||
text,
|
||||
reply_markup=types.InlineKeyboardMarkup(inline_keyboard=keyboard)
|
||||
)
|
||||
await callback.answer()
|
||||
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def show_expiring_subscriptions(
|
||||
callback: types.CallbackQuery,
|
||||
db_user: User,
|
||||
db: AsyncSession
|
||||
):
|
||||
expiring_3d = await get_expiring_subscriptions(db, 3)
|
||||
expiring_1d = await get_expiring_subscriptions(db, 1)
|
||||
expired = await get_expired_subscriptions(db)
|
||||
|
||||
text = f"""
|
||||
⏰ <b>Истекающие подписки</b>
|
||||
|
||||
📊 <b>Статистика:</b>
|
||||
- Истекают через 3 дня: {len(expiring_3d)}
|
||||
- Истекают завтра: {len(expiring_1d)}
|
||||
- Уже истекли: {len(expired)}
|
||||
|
||||
<b>Истекают через 3 дня:</b>
|
||||
"""
|
||||
|
||||
for sub in expiring_3d[:5]:
|
||||
user_info = f"ID{sub.user.telegram_id}" if sub.user else "Неизвестно"
|
||||
sub_type = "🎁" if sub.is_trial else "💎"
|
||||
text += f"{sub_type} {user_info} - {format_datetime(sub.end_date)}\n"
|
||||
|
||||
if len(expiring_3d) > 5:
|
||||
text += f"... и еще {len(expiring_3d) - 5}\n"
|
||||
|
||||
text += f"\n<b>Истекают завтра:</b>\n"
|
||||
for sub in expiring_1d[:5]:
|
||||
user_info = f"ID{sub.user.telegram_id}" if sub.user else "Неизвестно"
|
||||
sub_type = "🎁" if sub.is_trial else "💎"
|
||||
text += f"{sub_type} {user_info} - {format_datetime(sub.end_date)}\n"
|
||||
|
||||
if len(expiring_1d) > 5:
|
||||
text += f"... и еще {len(expiring_1d) - 5}\n"
|
||||
|
||||
keyboard = [
|
||||
[types.InlineKeyboardButton(text="📨 Отправить напоминания", callback_data="admin_send_expiry_reminders")],
|
||||
[types.InlineKeyboardButton(text="🔄 Обновить", callback_data="admin_subs_expiring")],
|
||||
[types.InlineKeyboardButton(text="⬅️ Назад", callback_data="admin_subscriptions")]
|
||||
]
|
||||
|
||||
await callback.message.edit_text(
|
||||
text,
|
||||
reply_markup=types.InlineKeyboardMarkup(inline_keyboard=keyboard)
|
||||
)
|
||||
await callback.answer()
|
||||
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def show_subscriptions_stats(
|
||||
callback: types.CallbackQuery,
|
||||
db_user: User,
|
||||
db: AsyncSession
|
||||
):
|
||||
|
||||
stats = await get_subscriptions_statistics(db)
|
||||
|
||||
expiring_3d = await get_expiring_subscriptions(db, 3)
|
||||
expiring_7d = await get_expiring_subscriptions(db, 7)
|
||||
expired = await get_expired_subscriptions(db)
|
||||
|
||||
text = f"""
|
||||
📊 <b>Детальная статистика подписок</b>
|
||||
|
||||
<b>📱 Общая информация:</b>
|
||||
• Всего подписок: {stats['total_subscriptions']}
|
||||
• Активных: {stats['active_subscriptions']}
|
||||
• Неактивных: {stats['total_subscriptions'] - stats['active_subscriptions']}
|
||||
|
||||
<b>💎 По типам:</b>
|
||||
• Платных: {stats['paid_subscriptions']}
|
||||
• Триальных: {stats['trial_subscriptions']}
|
||||
|
||||
<b>📈 Продажи:</b>
|
||||
• Сегодня: {stats['purchased_today']}
|
||||
• За неделю: {stats['purchased_week']}
|
||||
• За месяц: {stats['purchased_month']}
|
||||
|
||||
<b>⏰ Истечение:</b>
|
||||
• Истекают через 3 дня: {len(expiring_3d)}
|
||||
• Истекают через 7 дней: {len(expiring_7d)}
|
||||
• Уже истекли: {len(expired)}
|
||||
|
||||
<b>💰 Конверсия:</b>
|
||||
• Из триала в платную: {stats.get('trial_to_paid_conversion', 0)}%
|
||||
• Продлений: {stats.get('renewals_count', 0)}
|
||||
"""
|
||||
|
||||
keyboard = [
|
||||
# [
|
||||
# types.InlineKeyboardButton(text="📊 Экспорт данных", callback_data="admin_subs_export"),
|
||||
# types.InlineKeyboardButton(text="📈 Графики", callback_data="admin_subs_charts")
|
||||
# ],
|
||||
# [types.InlineKeyboardButton(text="🔄 Обновить", callback_data="admin_subs_stats")],
|
||||
[types.InlineKeyboardButton(text="⬅️ Назад", callback_data="admin_subscriptions")]
|
||||
]
|
||||
|
||||
await callback.message.edit_text(
|
||||
text,
|
||||
reply_markup=types.InlineKeyboardMarkup(inline_keyboard=keyboard)
|
||||
)
|
||||
await callback.answer()
|
||||
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def show_pricing_settings(
|
||||
callback: types.CallbackQuery,
|
||||
db_user: User,
|
||||
db: AsyncSession
|
||||
):
|
||||
text = f"""
|
||||
⚙️ <b>Настройки цен</b>
|
||||
|
||||
<b>Периоды подписки:</b>
|
||||
- 14 дней: {settings.format_price(settings.PRICE_14_DAYS)}
|
||||
- 30 дней: {settings.format_price(settings.PRICE_30_DAYS)}
|
||||
- 60 дней: {settings.format_price(settings.PRICE_60_DAYS)}
|
||||
- 90 дней: {settings.format_price(settings.PRICE_90_DAYS)}
|
||||
- 180 дней: {settings.format_price(settings.PRICE_180_DAYS)}
|
||||
- 360 дней: {settings.format_price(settings.PRICE_360_DAYS)}
|
||||
|
||||
<b>Трафик-пакеты:</b>
|
||||
- 5 ГБ: {settings.format_price(settings.PRICE_TRAFFIC_5GB)}
|
||||
- 10 ГБ: {settings.format_price(settings.PRICE_TRAFFIC_10GB)}
|
||||
- 25 ГБ: {settings.format_price(settings.PRICE_TRAFFIC_25GB)}
|
||||
- 50 ГБ: {settings.format_price(settings.PRICE_TRAFFIC_50GB)}
|
||||
- 100 ГБ: {settings.format_price(settings.PRICE_TRAFFIC_100GB)}
|
||||
- 250 ГБ: {settings.format_price(settings.PRICE_TRAFFIC_250GB)}
|
||||
|
||||
<b>Дополнительно:</b>
|
||||
- За устройство: {settings.format_price(settings.PRICE_PER_DEVICE)}
|
||||
"""
|
||||
|
||||
keyboard = [
|
||||
# [
|
||||
# types.InlineKeyboardButton(text="📅 Периоды", callback_data="admin_edit_period_prices"),
|
||||
# types.InlineKeyboardButton(text="📈 Трафик", callback_data="admin_edit_traffic_prices")
|
||||
# ],
|
||||
# [
|
||||
# types.InlineKeyboardButton(text="📱 Устройства", callback_data="admin_edit_device_price")
|
||||
# ],
|
||||
[
|
||||
types.InlineKeyboardButton(text="⬅️ Назад", callback_data="admin_subscriptions")
|
||||
]
|
||||
]
|
||||
|
||||
await callback.message.edit_text(
|
||||
text,
|
||||
reply_markup=types.InlineKeyboardMarkup(inline_keyboard=keyboard)
|
||||
)
|
||||
await callback.answer()
|
||||
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def show_countries_management(
|
||||
callback: types.CallbackQuery,
|
||||
db_user: User,
|
||||
db: AsyncSession
|
||||
):
|
||||
|
||||
try:
|
||||
from app.services.remnawave_service import RemnaWaveService
|
||||
remnawave_service = RemnaWaveService()
|
||||
|
||||
nodes_data = await remnawave_service.get_all_nodes()
|
||||
squads_data = await remnawave_service.get_all_squads()
|
||||
|
||||
text = "🌍 <b>Управление странами</b>\n\n"
|
||||
|
||||
if nodes_data:
|
||||
text += "<b>Доступные серверы:</b>\n"
|
||||
countries = {}
|
||||
|
||||
for node in nodes_data:
|
||||
country_code = node.get('country_code', 'XX')
|
||||
country_name = country_code
|
||||
|
||||
if country_name not in countries:
|
||||
countries[country_name] = []
|
||||
countries[country_name].append(node)
|
||||
|
||||
for country, nodes in countries.items():
|
||||
active_nodes = len([n for n in nodes if n.get('is_connected') and n.get('is_node_online')])
|
||||
total_nodes = len(nodes)
|
||||
|
||||
country_flag = get_country_flag(country)
|
||||
text += f"{country_flag} {country}: {active_nodes}/{total_nodes} серверов\n"
|
||||
|
||||
total_users_online = sum(n.get('users_online', 0) or 0 for n in nodes)
|
||||
if total_users_online > 0:
|
||||
text += f" 👥 Пользователей онлайн: {total_users_online}\n"
|
||||
else:
|
||||
text += "❌ Не удалось загрузить данные о серверах\n"
|
||||
|
||||
if squads_data:
|
||||
text += f"\n<b>Всего сквадов:</b> {len(squads_data)}\n"
|
||||
|
||||
total_members = sum(squad.get('members_count', 0) for squad in squads_data)
|
||||
text += f"<b>Участников в сквадах:</b> {total_members}\n"
|
||||
|
||||
text += "\n<b>Сквады:</b>\n"
|
||||
for squad in squads_data[:5]:
|
||||
name = squad.get('name', 'Неизвестно')
|
||||
members = squad.get('members_count', 0)
|
||||
inbounds = squad.get('inbounds_count', 0)
|
||||
text += f"• {name}: {members} участников, {inbounds} inbound(s)\n"
|
||||
|
||||
if len(squads_data) > 5:
|
||||
text += f"... и еще {len(squads_data) - 5} сквадов\n"
|
||||
|
||||
user_stats = await get_users_by_countries(db)
|
||||
if user_stats:
|
||||
text += "\n<b>Пользователи по регионам:</b>\n"
|
||||
for country, count in user_stats.items():
|
||||
country_flag = get_country_flag(country)
|
||||
text += f"{country_flag} {country}: {count} пользователей\n"
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка получения данных о странах: {e}")
|
||||
text = f"""
|
||||
🌍 <b>Управление странами</b>
|
||||
|
||||
❌ <b>Ошибка загрузки данных</b>
|
||||
Не удалось получить информацию о серверах.
|
||||
|
||||
Проверьте подключение к RemnaWave API.
|
||||
|
||||
<b>Детали ошибки:</b> {str(e)}
|
||||
"""
|
||||
|
||||
keyboard = [
|
||||
[
|
||||
types.InlineKeyboardButton(text="🔄 Обновить", callback_data="admin_subs_countries")
|
||||
],
|
||||
[
|
||||
types.InlineKeyboardButton(text="📊 Статистика нод", callback_data="admin_rw_nodes"),
|
||||
types.InlineKeyboardButton(text="🔧 Сквады", callback_data="admin_rw_squads")
|
||||
],
|
||||
[types.InlineKeyboardButton(text="⬅️ Назад", callback_data="admin_subscriptions")]
|
||||
]
|
||||
|
||||
await callback.message.edit_text(
|
||||
text,
|
||||
reply_markup=types.InlineKeyboardMarkup(inline_keyboard=keyboard)
|
||||
)
|
||||
await callback.answer()
|
||||
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def send_expiry_reminders(
|
||||
callback: types.CallbackQuery,
|
||||
db_user: User,
|
||||
db: AsyncSession
|
||||
):
|
||||
await callback.message.edit_text(
|
||||
"📨 Отправка напоминаний...\n\nПодождите, это может занять время.",
|
||||
reply_markup=None
|
||||
)
|
||||
|
||||
expiring_subs = await get_expiring_subscriptions(db, 1)
|
||||
sent_count = 0
|
||||
|
||||
for subscription in expiring_subs:
|
||||
if subscription.user:
|
||||
try:
|
||||
user = subscription.user
|
||||
days_left = max(1, subscription.days_left)
|
||||
|
||||
reminder_text = f"""
|
||||
⚠️ <b>Подписка истекает!</b>
|
||||
|
||||
Ваша подписка истекает через {days_left} день(а).
|
||||
|
||||
Не забудьте продлить подписку, чтобы не потерять доступ к серверам.
|
||||
|
||||
💎 Продлить подписку можно в главном меню.
|
||||
"""
|
||||
|
||||
await callback.bot.send_message(
|
||||
chat_id=user.telegram_id,
|
||||
text=reminder_text
|
||||
)
|
||||
sent_count += 1
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка отправки напоминания пользователю {subscription.user_id}: {e}")
|
||||
|
||||
await callback.message.edit_text(
|
||||
f"✅ Напоминания отправлены: {sent_count} из {len(expiring_subs)}",
|
||||
reply_markup=types.InlineKeyboardMarkup(inline_keyboard=[
|
||||
[types.InlineKeyboardButton(text="⬅️ Назад", callback_data="admin_subs_expiring")]
|
||||
])
|
||||
)
|
||||
await callback.answer()
|
||||
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def handle_subscriptions_pagination(
|
||||
callback: types.CallbackQuery,
|
||||
db_user: User,
|
||||
db: AsyncSession
|
||||
):
|
||||
|
||||
page = int(callback.data.split('_')[-1])
|
||||
await show_subscriptions_list(callback, db_user, db, page)
|
||||
|
||||
|
||||
def register_handlers(dp: Dispatcher):
|
||||
dp.callback_query.register(show_subscriptions_menu, F.data == "admin_subscriptions")
|
||||
dp.callback_query.register(show_subscriptions_list, F.data == "admin_subs_list")
|
||||
dp.callback_query.register(show_expiring_subscriptions, F.data == "admin_subs_expiring")
|
||||
dp.callback_query.register(show_subscriptions_stats, F.data == "admin_subs_stats")
|
||||
dp.callback_query.register(show_pricing_settings, F.data == "admin_subs_pricing")
|
||||
dp.callback_query.register(show_countries_management, F.data == "admin_subs_countries")
|
||||
dp.callback_query.register(send_expiry_reminders, F.data == "admin_send_expiry_reminders")
|
||||
|
||||
dp.callback_query.register(
|
||||
handle_subscriptions_pagination,
|
||||
F.data.startswith("admin_subs_list_page_")
|
||||
)
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,618 @@
|
||||
import logging
|
||||
from aiogram import Dispatcher, types, F
|
||||
from aiogram.fsm.context import FSMContext
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.config import settings
|
||||
from app.states import BalanceStates
|
||||
from app.database.crud.user import add_user_balance
|
||||
from app.database.crud.transaction import (
|
||||
get_user_transactions, get_user_transactions_count,
|
||||
create_transaction
|
||||
)
|
||||
from app.database.models import User, TransactionType, PaymentMethod
|
||||
from app.keyboards.inline import (
|
||||
get_balance_keyboard, get_payment_methods_keyboard,
|
||||
get_back_keyboard, get_pagination_keyboard
|
||||
)
|
||||
from app.localization.texts import get_texts
|
||||
from app.services.payment_service import PaymentService
|
||||
from app.utils.pagination import paginate_list
|
||||
from app.utils.decorators import error_handler
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
TRANSACTIONS_PER_PAGE = 10
|
||||
|
||||
|
||||
@error_handler
|
||||
async def show_balance_menu(
|
||||
callback: types.CallbackQuery,
|
||||
db_user: User,
|
||||
db: AsyncSession
|
||||
):
|
||||
texts = get_texts(db_user.language)
|
||||
|
||||
balance_text = texts.BALANCE_INFO.format(
|
||||
balance=texts.format_price(db_user.balance_kopeks)
|
||||
)
|
||||
|
||||
await callback.message.edit_text(
|
||||
balance_text,
|
||||
reply_markup=get_balance_keyboard(db_user.language)
|
||||
)
|
||||
await callback.answer()
|
||||
|
||||
|
||||
@error_handler
|
||||
async def show_balance_history(
|
||||
callback: types.CallbackQuery,
|
||||
db_user: User,
|
||||
db: AsyncSession,
|
||||
page: int = 1
|
||||
):
|
||||
texts = get_texts(db_user.language)
|
||||
|
||||
offset = (page - 1) * TRANSACTIONS_PER_PAGE
|
||||
|
||||
raw_transactions = await get_user_transactions(
|
||||
db, db_user.id,
|
||||
limit=TRANSACTIONS_PER_PAGE * 3,
|
||||
offset=offset
|
||||
)
|
||||
|
||||
seen_transactions = set()
|
||||
unique_transactions = []
|
||||
|
||||
for transaction in raw_transactions:
|
||||
rounded_time = transaction.created_at.replace(second=0, microsecond=0)
|
||||
transaction_key = (
|
||||
transaction.amount_kopeks,
|
||||
transaction.description,
|
||||
rounded_time
|
||||
)
|
||||
|
||||
if transaction_key not in seen_transactions:
|
||||
seen_transactions.add(transaction_key)
|
||||
unique_transactions.append(transaction)
|
||||
|
||||
if len(unique_transactions) >= TRANSACTIONS_PER_PAGE:
|
||||
break
|
||||
|
||||
all_transactions = await get_user_transactions(db, db_user.id, limit=1000)
|
||||
seen_all = set()
|
||||
total_unique = 0
|
||||
|
||||
for transaction in all_transactions:
|
||||
rounded_time = transaction.created_at.replace(second=0, microsecond=0)
|
||||
transaction_key = (
|
||||
transaction.amount_kopeks,
|
||||
transaction.description,
|
||||
rounded_time
|
||||
)
|
||||
if transaction_key not in seen_all:
|
||||
seen_all.add(transaction_key)
|
||||
total_unique += 1
|
||||
|
||||
if not unique_transactions:
|
||||
await callback.message.edit_text(
|
||||
"📊 История операций пуста",
|
||||
reply_markup=get_back_keyboard(db_user.language)
|
||||
)
|
||||
await callback.answer()
|
||||
return
|
||||
|
||||
text = "📊 <b>История операций</b>\n\n"
|
||||
|
||||
for transaction in unique_transactions:
|
||||
emoji = "💰" if transaction.type == TransactionType.DEPOSIT.value else "💸"
|
||||
amount_text = f"+{texts.format_price(transaction.amount_kopeks)}" if transaction.type == TransactionType.DEPOSIT.value else f"-{texts.format_price(transaction.amount_kopeks)}"
|
||||
|
||||
text += f"{emoji} {amount_text}\n"
|
||||
text += f"📝 {transaction.description}\n"
|
||||
text += f"📅 {transaction.created_at.strftime('%d.%m.%Y %H:%M')}\n\n"
|
||||
|
||||
keyboard = []
|
||||
total_pages = (total_unique + TRANSACTIONS_PER_PAGE - 1) // TRANSACTIONS_PER_PAGE
|
||||
|
||||
if total_pages > 1:
|
||||
pagination_row = get_pagination_keyboard(
|
||||
page, total_pages, "balance_history", db_user.language
|
||||
)
|
||||
keyboard.extend(pagination_row)
|
||||
|
||||
keyboard.append([
|
||||
types.InlineKeyboardButton(text=texts.BACK, callback_data="menu_balance")
|
||||
])
|
||||
|
||||
await callback.message.edit_text(
|
||||
text,
|
||||
reply_markup=types.InlineKeyboardMarkup(inline_keyboard=keyboard),
|
||||
parse_mode="HTML"
|
||||
)
|
||||
await callback.answer()
|
||||
|
||||
|
||||
@error_handler
|
||||
async def handle_balance_history_pagination(
|
||||
callback: types.CallbackQuery,
|
||||
db_user: User,
|
||||
db: AsyncSession
|
||||
):
|
||||
page = int(callback.data.split('_')[-1])
|
||||
await show_balance_history(callback, db_user, db, page)
|
||||
|
||||
|
||||
@error_handler
|
||||
async def show_payment_methods(
|
||||
callback: types.CallbackQuery,
|
||||
db_user: User,
|
||||
state: FSMContext
|
||||
):
|
||||
texts = get_texts(db_user.language)
|
||||
|
||||
payment_text = """
|
||||
💳 <b>Способы пополнения баланса</b>
|
||||
|
||||
Выберите удобный для вас способ оплаты:
|
||||
|
||||
⭐ <b>Telegram Stars</b> - быстро и удобно
|
||||
💳 <b>Банковская карта</b> - через YooKassa/Tribute
|
||||
🛠️ <b>Через поддержку</b> - другие способы
|
||||
|
||||
Выберите способ пополнения:
|
||||
"""
|
||||
|
||||
await callback.message.edit_text(
|
||||
payment_text,
|
||||
reply_markup=get_payment_methods_keyboard(0, db_user.language),
|
||||
parse_mode="HTML"
|
||||
)
|
||||
await callback.answer()
|
||||
|
||||
|
||||
@error_handler
|
||||
async def start_stars_payment(
|
||||
callback: types.CallbackQuery,
|
||||
db_user: User,
|
||||
state: FSMContext
|
||||
):
|
||||
texts = get_texts(db_user.language)
|
||||
|
||||
if not settings.TELEGRAM_STARS_ENABLED:
|
||||
await callback.answer("❌ Пополнение через Stars временно недоступно", show_alert=True)
|
||||
return
|
||||
|
||||
await callback.message.edit_text(
|
||||
texts.TOP_UP_AMOUNT,
|
||||
reply_markup=get_back_keyboard(db_user.language)
|
||||
)
|
||||
|
||||
await state.set_state(BalanceStates.waiting_for_amount)
|
||||
await state.update_data(payment_method="stars")
|
||||
await callback.answer()
|
||||
|
||||
|
||||
@error_handler
|
||||
async def start_yookassa_payment(
|
||||
callback: types.CallbackQuery,
|
||||
db_user: User,
|
||||
state: FSMContext
|
||||
):
|
||||
texts = get_texts(db_user.language)
|
||||
|
||||
if not settings.is_yookassa_enabled():
|
||||
await callback.answer("❌ Оплата картой через YooKassa временно недоступна", show_alert=True)
|
||||
return
|
||||
|
||||
await callback.message.edit_text(
|
||||
"💳 <b>Оплата банковской картой</b>\n\n"
|
||||
"Введите сумму для пополнения от 100 до 50,000 рублей:",
|
||||
reply_markup=get_back_keyboard(db_user.language),
|
||||
parse_mode="HTML"
|
||||
)
|
||||
|
||||
await state.set_state(BalanceStates.waiting_for_amount)
|
||||
await state.update_data(payment_method="yookassa")
|
||||
await callback.answer()
|
||||
|
||||
|
||||
@error_handler
|
||||
async def start_tribute_payment(
|
||||
callback: types.CallbackQuery,
|
||||
db_user: User
|
||||
):
|
||||
texts = get_texts(db_user.language)
|
||||
|
||||
if not settings.TRIBUTE_ENABLED:
|
||||
await callback.answer("❌ Оплата картой временно недоступна", show_alert=True)
|
||||
return
|
||||
|
||||
try:
|
||||
from app.services.tribute_service import TributeService
|
||||
|
||||
tribute_service = TributeService(callback.bot)
|
||||
payment_url = await tribute_service.create_payment_link(
|
||||
user_id=db_user.telegram_id,
|
||||
amount_kopeks=0,
|
||||
description="Пополнение баланса VPN"
|
||||
)
|
||||
|
||||
if not payment_url:
|
||||
await callback.answer("❌ Ошибка создания платежа", show_alert=True)
|
||||
return
|
||||
|
||||
keyboard = types.InlineKeyboardMarkup(inline_keyboard=[
|
||||
[types.InlineKeyboardButton(text="💳 Перейти к оплате", url=payment_url)],
|
||||
[types.InlineKeyboardButton(text=texts.BACK, callback_data="balance_topup")]
|
||||
])
|
||||
|
||||
await callback.message.edit_text(
|
||||
f"💳 <b>Пополнение банковской картой</b>\n\n"
|
||||
f"• Введите любую сумму от 100₽\n"
|
||||
f"• Безопасная оплата через Tribute\n"
|
||||
f"• Мгновенное зачисление на баланс\n"
|
||||
f"• Принимаем карты Visa, MasterCard, МИР\n\n"
|
||||
f"• 🚨 НЕ ОТПРАВЛЯТЬ ПЛАТЕЖ АНОНИМНО!\n\n"
|
||||
f"Нажмите кнопку для перехода к оплате:",
|
||||
reply_markup=keyboard,
|
||||
parse_mode="HTML"
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка создания Tribute платежа: {e}")
|
||||
await callback.answer("❌ Ошибка создания платежа", show_alert=True)
|
||||
|
||||
await callback.answer()
|
||||
|
||||
|
||||
@error_handler
|
||||
async def request_support_topup(
|
||||
callback: types.CallbackQuery,
|
||||
db_user: User
|
||||
):
|
||||
texts = get_texts(db_user.language)
|
||||
|
||||
support_text = f"""
|
||||
🛠️ <b>Пополнение через поддержку</b>
|
||||
|
||||
Для пополнения баланса обратитесь в техподдержку:
|
||||
{settings.SUPPORT_USERNAME}
|
||||
|
||||
Укажите:
|
||||
• ID: {db_user.telegram_id}
|
||||
• Сумму пополнения
|
||||
• Способ оплаты
|
||||
|
||||
⏰ Время обработки: 1-24 часа
|
||||
|
||||
<b>Доступные способы:</b>
|
||||
• Криптовалюта
|
||||
• Переводы между банками
|
||||
• Другие платежные системы
|
||||
"""
|
||||
|
||||
keyboard = types.InlineKeyboardMarkup(inline_keyboard=[
|
||||
[types.InlineKeyboardButton(
|
||||
text="💬 Написать в поддержку",
|
||||
url=f"https://t.me/{settings.SUPPORT_USERNAME.lstrip('@')}"
|
||||
)],
|
||||
[types.InlineKeyboardButton(text=texts.BACK, callback_data="balance_topup")]
|
||||
])
|
||||
|
||||
await callback.message.edit_text(
|
||||
support_text,
|
||||
reply_markup=keyboard,
|
||||
parse_mode="HTML"
|
||||
)
|
||||
await callback.answer()
|
||||
|
||||
|
||||
@error_handler
|
||||
async def process_topup_amount(
|
||||
message: types.Message,
|
||||
db_user: User,
|
||||
state: FSMContext
|
||||
):
|
||||
texts = get_texts(db_user.language)
|
||||
|
||||
try:
|
||||
amount_rubles = float(message.text.replace(',', '.'))
|
||||
|
||||
if amount_rubles < 1:
|
||||
await message.answer("Минимальная сумма пополнения: 1 ₽")
|
||||
return
|
||||
|
||||
if amount_rubles > 50000:
|
||||
await message.answer("Максимальная сумма пополнения: 50,000 ₽")
|
||||
return
|
||||
|
||||
amount_kopeks = int(amount_rubles * 100)
|
||||
data = await state.get_data()
|
||||
payment_method = data.get("payment_method", "stars")
|
||||
|
||||
if payment_method == "stars":
|
||||
await process_stars_payment_amount(message, db_user, amount_kopeks, state)
|
||||
elif payment_method == "yookassa":
|
||||
from app.database.database import AsyncSessionLocal
|
||||
async with AsyncSessionLocal() as db:
|
||||
await process_yookassa_payment_amount(message, db_user, db, amount_kopeks, state)
|
||||
else:
|
||||
await message.answer("Неизвестный способ оплаты")
|
||||
|
||||
except ValueError:
|
||||
await message.answer(
|
||||
texts.INVALID_AMOUNT,
|
||||
reply_markup=get_back_keyboard(db_user.language)
|
||||
)
|
||||
|
||||
@error_handler
|
||||
async def process_stars_payment_amount(
|
||||
message: types.Message,
|
||||
db_user: User,
|
||||
amount_kopeks: int,
|
||||
state: FSMContext
|
||||
):
|
||||
texts = get_texts(db_user.language)
|
||||
|
||||
if not settings.TELEGRAM_STARS_ENABLED:
|
||||
await message.answer("⚠ Оплата Stars временно недоступна")
|
||||
return
|
||||
|
||||
try:
|
||||
from app.external.telegram_stars import TelegramStarsService
|
||||
|
||||
amount_rubles = amount_kopeks / 100
|
||||
stars_amount = TelegramStarsService.calculate_stars_from_rubles(amount_rubles)
|
||||
|
||||
payment_service = PaymentService(message.bot)
|
||||
invoice_link = await payment_service.create_stars_invoice(
|
||||
amount_kopeks=amount_kopeks,
|
||||
description=f"Пополнение баланса на {texts.format_price(amount_kopeks)}",
|
||||
payload=f"balance_{db_user.id}_{amount_kopeks}"
|
||||
)
|
||||
|
||||
keyboard = types.InlineKeyboardMarkup(inline_keyboard=[
|
||||
[types.InlineKeyboardButton(text="⭐ Оплатить", url=invoice_link)],
|
||||
[types.InlineKeyboardButton(text=texts.BACK, callback_data="balance_topup")]
|
||||
])
|
||||
|
||||
await message.answer(
|
||||
f"⭐ <b>Оплата через Telegram Stars</b>\n\n"
|
||||
f"💰 Сумма: {texts.format_price(amount_kopeks)}\n"
|
||||
f"⭐ К оплате: {stars_amount} звезд\n"
|
||||
f"📊 Курс: {settings.get_stars_rate():.2f}₽ за звезду\n\n"
|
||||
f"Нажмите кнопку ниже для оплаты:",
|
||||
reply_markup=keyboard,
|
||||
parse_mode="HTML"
|
||||
)
|
||||
|
||||
await state.clear()
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка создания Stars invoice: {e}")
|
||||
await message.answer("⚠ Ошибка создания платежа")
|
||||
|
||||
|
||||
@error_handler
|
||||
async def process_yookassa_payment_amount(
|
||||
message: types.Message,
|
||||
db_user: User,
|
||||
db: AsyncSession,
|
||||
amount_kopeks: int,
|
||||
state: FSMContext
|
||||
):
|
||||
texts = get_texts(db_user.language)
|
||||
|
||||
if not settings.is_yookassa_enabled():
|
||||
await message.answer("❌ Оплата через YooKassa временно недоступна")
|
||||
return
|
||||
|
||||
if amount_kopeks < 10000:
|
||||
await message.answer("❌ Минимальная сумма для оплаты картой: 100 ₽")
|
||||
return
|
||||
|
||||
try:
|
||||
payment_service = PaymentService(message.bot)
|
||||
|
||||
payment_result = await payment_service.create_yookassa_payment(
|
||||
db=db,
|
||||
user_id=db_user.id,
|
||||
amount_kopeks=amount_kopeks,
|
||||
description=settings.get_balance_payment_description(amount_kopeks),
|
||||
receipt_email=None,
|
||||
receipt_phone=None,
|
||||
metadata={
|
||||
"user_telegram_id": str(db_user.telegram_id),
|
||||
"user_username": db_user.username or "",
|
||||
"purpose": "balance_topup"
|
||||
}
|
||||
)
|
||||
|
||||
if not payment_result:
|
||||
await message.answer("❌ Ошибка создания платежа. Попробуйте позже или обратитесь в поддержку.")
|
||||
await state.clear()
|
||||
return
|
||||
|
||||
confirmation_url = payment_result.get("confirmation_url")
|
||||
if not confirmation_url:
|
||||
await message.answer("❌ Ошибка получения ссылки для оплаты. Обратитесь в поддержку.")
|
||||
await state.clear()
|
||||
return
|
||||
|
||||
keyboard = types.InlineKeyboardMarkup(inline_keyboard=[
|
||||
[types.InlineKeyboardButton(text="💳 Оплатить картой", url=confirmation_url)],
|
||||
[types.InlineKeyboardButton(text="📊 Проверить статус", callback_data=f"check_yookassa_{payment_result['local_payment_id']}")],
|
||||
[types.InlineKeyboardButton(text=texts.BACK, callback_data="balance_topup")]
|
||||
])
|
||||
|
||||
await message.answer(
|
||||
f"💳 <b>Оплата банковской картой</b>\n\n"
|
||||
f"💰 Сумма: {settings.format_price(amount_kopeks)}\n"
|
||||
f"🆔 ID платежа: {payment_result['yookassa_payment_id'][:8]}...\n\n"
|
||||
f"📱 <b>Инструкция:</b>\n"
|
||||
f"1. Нажмите кнопку 'Оплатить картой'\n"
|
||||
f"2. Введите данные вашей карты\n"
|
||||
f"3. Подтвердите платеж\n"
|
||||
f"4. Деньги поступят на баланс автоматически\n\n"
|
||||
f"🔒 Оплата происходит через защищенную систему YooKassa\n"
|
||||
f"✅ Принимаем карты: Visa, MasterCard, МИР\n\n"
|
||||
f"❓ Если возникнут проблемы, обратитесь в {settings.SUPPORT_USERNAME}",
|
||||
reply_markup=keyboard,
|
||||
parse_mode="HTML"
|
||||
)
|
||||
|
||||
await state.clear()
|
||||
|
||||
logger.info(f"Создан платеж YooKassa для пользователя {db_user.telegram_id}: "
|
||||
f"{amount_kopeks/100}₽, ID: {payment_result['yookassa_payment_id']}")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка создания YooKassa платежа: {e}")
|
||||
await message.answer("❌ Ошибка создания платежа. Попробуйте позже или обратитесь в поддержку.")
|
||||
await state.clear()
|
||||
|
||||
|
||||
@error_handler
|
||||
async def check_yookassa_payment_status(
|
||||
callback: types.CallbackQuery,
|
||||
db: AsyncSession
|
||||
):
|
||||
try:
|
||||
local_payment_id = int(callback.data.split('_')[-1])
|
||||
|
||||
from app.database.crud.yookassa import get_yookassa_payment_by_local_id
|
||||
payment = await get_yookassa_payment_by_local_id(db, local_payment_id)
|
||||
|
||||
if not payment:
|
||||
await callback.answer("❌ Платеж не найден", show_alert=True)
|
||||
return
|
||||
|
||||
status_emoji = {
|
||||
"pending": "⏳",
|
||||
"waiting_for_capture": "⌛",
|
||||
"succeeded": "✅",
|
||||
"canceled": "❌",
|
||||
"failed": "❌"
|
||||
}
|
||||
|
||||
status_text = {
|
||||
"pending": "Ожидает оплаты",
|
||||
"waiting_for_capture": "Ожидает подтверждения",
|
||||
"succeeded": "Оплачен",
|
||||
"canceled": "Отменен",
|
||||
"failed": "Ошибка"
|
||||
}
|
||||
|
||||
emoji = status_emoji.get(payment.status, "❓")
|
||||
status = status_text.get(payment.status, "Неизвестно")
|
||||
|
||||
message_text = (f"💳 Статус платежа:\n\n"
|
||||
f"🆔 ID: {payment.yookassa_payment_id[:8]}...\n"
|
||||
f"💰 Сумма: {settings.format_price(payment.amount_kopeks)}\n"
|
||||
f"📊 Статус: {emoji} {status}\n"
|
||||
f"📅 Создан: {payment.created_at.strftime('%d.%m.%Y %H:%M')}\n")
|
||||
|
||||
if payment.is_succeeded:
|
||||
message_text += "\n✅ Платеж успешно завершен!\n\nСредства зачислены на баланс."
|
||||
elif payment.is_pending:
|
||||
message_text += "\n⏳ Платеж ожидает оплаты. Нажмите кнопку 'Оплатить' выше."
|
||||
elif payment.is_failed:
|
||||
message_text += f"\n❌ Платеж не прошел. Обратитесь в {settings.SUPPORT_USERNAME}"
|
||||
|
||||
await callback.answer(message_text, show_alert=True)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка проверки статуса платежа: {e}")
|
||||
await callback.answer("❌ Ошибка проверки статуса", show_alert=True)
|
||||
|
||||
@error_handler
|
||||
async def add_user_balance(
|
||||
db: AsyncSession,
|
||||
user: User,
|
||||
amount_kopeks: int,
|
||||
description: str = "Пополнение баланса"
|
||||
) -> bool:
|
||||
try:
|
||||
old_balance = user.balance_kopeks
|
||||
user.balance_kopeks += amount_kopeks
|
||||
user.updated_at = datetime.utcnow()
|
||||
|
||||
from app.database.crud.transaction import create_transaction
|
||||
from app.database.models import TransactionType
|
||||
|
||||
await create_transaction(
|
||||
db=db,
|
||||
user_id=user.id,
|
||||
type=TransactionType.DEPOSIT,
|
||||
amount_kopeks=amount_kopeks,
|
||||
description=description
|
||||
)
|
||||
|
||||
await db.commit()
|
||||
await db.refresh(user)
|
||||
|
||||
if "пополнение" in description.lower() or "topup" in description.lower():
|
||||
from app.services.referral_service import process_referral_topup
|
||||
await process_referral_topup(db, user.id, amount_kopeks)
|
||||
|
||||
logger.info(f"💰 Баланс пользователя {user.telegram_id} изменен: {old_balance} → {user.balance_kopeks} (изменение: +{amount_kopeks})")
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка изменения баланса пользователя {user.id}: {e}")
|
||||
await db.rollback()
|
||||
return False
|
||||
|
||||
|
||||
def register_handlers(dp: Dispatcher):
|
||||
|
||||
dp.callback_query.register(
|
||||
show_balance_menu,
|
||||
F.data == "menu_balance"
|
||||
)
|
||||
|
||||
dp.callback_query.register(
|
||||
show_balance_history,
|
||||
F.data == "balance_history"
|
||||
)
|
||||
|
||||
dp.callback_query.register(
|
||||
handle_balance_history_pagination,
|
||||
F.data.startswith("balance_history_page_")
|
||||
)
|
||||
|
||||
dp.callback_query.register(
|
||||
show_payment_methods,
|
||||
F.data == "balance_topup"
|
||||
)
|
||||
|
||||
dp.callback_query.register(
|
||||
start_stars_payment,
|
||||
F.data == "topup_stars"
|
||||
)
|
||||
|
||||
dp.callback_query.register(
|
||||
start_yookassa_payment,
|
||||
F.data == "topup_yookassa"
|
||||
)
|
||||
|
||||
dp.callback_query.register(
|
||||
start_tribute_payment,
|
||||
F.data == "topup_tribute"
|
||||
)
|
||||
|
||||
dp.callback_query.register(
|
||||
request_support_topup,
|
||||
F.data == "topup_support"
|
||||
)
|
||||
|
||||
dp.callback_query.register(
|
||||
check_yookassa_payment_status,
|
||||
F.data.startswith("check_yookassa_")
|
||||
)
|
||||
|
||||
dp.message.register(
|
||||
process_topup_amount,
|
||||
BalanceStates.waiting_for_amount
|
||||
)
|
||||
@@ -0,0 +1,85 @@
|
||||
import logging
|
||||
from aiogram import Dispatcher, types, F
|
||||
from aiogram.fsm.context import FSMContext
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.database.models import User
|
||||
from app.localization.texts import get_texts
|
||||
from app.keyboards.inline import get_back_keyboard
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
async def handle_unknown_callback(
|
||||
callback: types.CallbackQuery,
|
||||
db_user: User
|
||||
):
|
||||
|
||||
texts = get_texts(db_user.language if db_user else "ru")
|
||||
|
||||
await callback.answer(
|
||||
"❓ Неизвестная команда. Попробуйте ещё раз.",
|
||||
show_alert=True
|
||||
)
|
||||
|
||||
logger.warning(f"Неизвестный callback: {callback.data} от пользователя {callback.from_user.id}")
|
||||
|
||||
|
||||
async def handle_cancel(
|
||||
callback: types.CallbackQuery,
|
||||
state: FSMContext,
|
||||
db_user: User
|
||||
):
|
||||
|
||||
texts = get_texts(db_user.language)
|
||||
|
||||
await state.clear()
|
||||
await callback.message.edit_text(
|
||||
texts.OPERATION_CANCELLED,
|
||||
reply_markup=get_back_keyboard(db_user.language)
|
||||
)
|
||||
await callback.answer()
|
||||
|
||||
|
||||
async def handle_unknown_message(
|
||||
message: types.Message,
|
||||
db_user: User
|
||||
):
|
||||
|
||||
texts = get_texts(db_user.language if db_user else "ru")
|
||||
|
||||
await message.answer(
|
||||
"❓ Не понимаю эту команду. Используйте кнопки меню.",
|
||||
reply_markup=get_back_keyboard(db_user.language if db_user else "ru")
|
||||
)
|
||||
|
||||
|
||||
async def show_rules(
|
||||
callback: types.CallbackQuery,
|
||||
db_user: User,
|
||||
db: AsyncSession
|
||||
):
|
||||
|
||||
texts = get_texts(db_user.language)
|
||||
|
||||
rules_text = texts.RULES_TEXT
|
||||
|
||||
await callback.message.edit_text(
|
||||
rules_text,
|
||||
reply_markup=get_back_keyboard(db_user.language)
|
||||
)
|
||||
await callback.answer()
|
||||
|
||||
|
||||
def register_handlers(dp: Dispatcher):
|
||||
|
||||
dp.callback_query.register(
|
||||
show_rules,
|
||||
F.data == "menu_rules"
|
||||
)
|
||||
|
||||
dp.callback_query.register(
|
||||
handle_cancel,
|
||||
F.data.in_(["cancel", "subscription_cancel"])
|
||||
)
|
||||
|
||||
@@ -0,0 +1,150 @@
|
||||
import logging
|
||||
from aiogram import Dispatcher, types, F
|
||||
from aiogram.fsm.context import FSMContext
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
from app.config import settings
|
||||
from app.database.crud.user import get_user_by_telegram_id, update_user
|
||||
from app.keyboards.inline import get_main_menu_keyboard
|
||||
from app.localization.texts import get_texts
|
||||
from app.database.models import User
|
||||
from app.utils.user_utils import mark_user_as_had_paid_subscription
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
async def show_main_menu(
|
||||
callback: types.CallbackQuery,
|
||||
db_user: User,
|
||||
db: AsyncSession
|
||||
):
|
||||
texts = get_texts(db_user.language)
|
||||
|
||||
from datetime import datetime
|
||||
db_user.last_activity = datetime.utcnow()
|
||||
await db.commit()
|
||||
|
||||
has_active_subscription = bool(db_user.subscription)
|
||||
subscription_is_active = False
|
||||
|
||||
if db_user.subscription:
|
||||
subscription_is_active = db_user.subscription.is_active
|
||||
|
||||
menu_text = texts.MAIN_MENU.format(
|
||||
user_name=db_user.full_name,
|
||||
subscription_status=_get_subscription_status(db_user, texts)
|
||||
)
|
||||
|
||||
await callback.message.edit_text(
|
||||
menu_text,
|
||||
reply_markup=get_main_menu_keyboard(
|
||||
language=db_user.language,
|
||||
is_admin=settings.is_admin(db_user.telegram_id),
|
||||
has_had_paid_subscription=db_user.has_had_paid_subscription,
|
||||
has_active_subscription=has_active_subscription,
|
||||
subscription_is_active=subscription_is_active,
|
||||
balance_kopeks=db_user.balance_kopeks
|
||||
)
|
||||
)
|
||||
await callback.answer()
|
||||
|
||||
async def mark_user_as_had_paid_subscription(
|
||||
db: AsyncSession,
|
||||
user: User
|
||||
) -> None:
|
||||
if not user.has_had_paid_subscription:
|
||||
user.has_had_paid_subscription = True
|
||||
user.updated_at = datetime.utcnow()
|
||||
await db.commit()
|
||||
logger.info(f"🎯 Пользователь {user.telegram_id} отмечен как имевший платную подписку")
|
||||
|
||||
|
||||
async def show_service_rules(
|
||||
callback: types.CallbackQuery,
|
||||
db_user: User,
|
||||
db: AsyncSession
|
||||
):
|
||||
from app.database.crud.rules import get_current_rules_content
|
||||
|
||||
rules_text = await get_current_rules_content(db, db_user.language)
|
||||
|
||||
if not rules_text:
|
||||
texts = get_texts(db_user.language)
|
||||
rules_text = texts._get_default_rules(db_user.language) if hasattr(texts, '_get_default_rules') else """
|
||||
📋 <b>Правила использования сервиса</b>
|
||||
|
||||
1. Запрещается использование сервиса для незаконной деятельности
|
||||
2. Запрещается нарушение авторских прав
|
||||
3. Запрещается спам и рассылка вредоносного ПО
|
||||
4. Запрещается использование сервиса для DDoS атак
|
||||
5. Один аккаунт - один пользователь
|
||||
6. Возврат средств производится только в исключительных случаях
|
||||
7. Администрация оставляет за собой право заблокировать аккаунт при нарушении правил
|
||||
|
||||
<b>Принимая правила, вы соглашаетесь соблюдать их.</b>
|
||||
"""
|
||||
|
||||
await callback.message.edit_text(
|
||||
f"📋 <b>Правила сервиса</b>\n\n{rules_text}",
|
||||
reply_markup=types.InlineKeyboardMarkup(inline_keyboard=[
|
||||
[types.InlineKeyboardButton(text="⬅️ Назад", callback_data="back_to_menu")]
|
||||
])
|
||||
)
|
||||
await callback.answer()
|
||||
|
||||
|
||||
async def handle_back_to_menu(
|
||||
callback: types.CallbackQuery,
|
||||
state: FSMContext,
|
||||
db_user: User,
|
||||
db: AsyncSession
|
||||
):
|
||||
|
||||
await state.clear()
|
||||
|
||||
await show_main_menu(callback, db_user, db)
|
||||
|
||||
|
||||
def _get_subscription_status(user: User, texts) -> str:
|
||||
if not user.subscription:
|
||||
return "❌ Отсутствует"
|
||||
|
||||
subscription = user.subscription
|
||||
current_time = datetime.utcnow()
|
||||
|
||||
if subscription.end_date <= current_time:
|
||||
return f"🔴 Истекла\n📅 {subscription.end_date.strftime('%d.%m.%Y')}"
|
||||
|
||||
days_left = (subscription.end_date - current_time).days
|
||||
|
||||
if subscription.is_trial:
|
||||
if days_left > 1:
|
||||
return f"🎁 Тестовая подписка\n📅 до {subscription.end_date.strftime('%d.%m.%Y')} ({days_left} дн.)"
|
||||
elif days_left == 1:
|
||||
return f"🎁 Тестовая подписка\n⚠️ истекает завтра!"
|
||||
else:
|
||||
return f"🎁 Тестовая подписка\n⚠️ истекает сегодня!"
|
||||
|
||||
else:
|
||||
if days_left > 7:
|
||||
return f"💎 Активна\n📅 до {subscription.end_date.strftime('%d.%m.%Y')} ({days_left} дн.)"
|
||||
elif days_left > 1:
|
||||
return f"💎 Активна\n⚠️ истекает через {days_left} дн."
|
||||
elif days_left == 1:
|
||||
return f"💎 Активна\n⚠️ истекает завтра!"
|
||||
else:
|
||||
return f"💎 Активна\n⚠️ истекает сегодня!"
|
||||
|
||||
|
||||
def register_handlers(dp: Dispatcher):
|
||||
|
||||
dp.callback_query.register(
|
||||
handle_back_to_menu,
|
||||
F.data == "back_to_menu"
|
||||
)
|
||||
|
||||
dp.callback_query.register(
|
||||
show_service_rules,
|
||||
F.data == "menu_rules"
|
||||
)
|
||||
@@ -0,0 +1,89 @@
|
||||
import logging
|
||||
from aiogram import Dispatcher, types, F
|
||||
from aiogram.fsm.context import FSMContext
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.config import settings
|
||||
from app.states import PromoCodeStates
|
||||
from app.database.models import User
|
||||
from app.keyboards.inline import get_back_keyboard
|
||||
from app.localization.texts import get_texts
|
||||
from app.services.promocode_service import PromoCodeService
|
||||
from app.utils.decorators import error_handler
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@error_handler
|
||||
async def show_promocode_menu(
|
||||
callback: types.CallbackQuery,
|
||||
db_user: User,
|
||||
state: FSMContext
|
||||
):
|
||||
texts = get_texts(db_user.language)
|
||||
|
||||
await callback.message.edit_text(
|
||||
texts.PROMOCODE_ENTER,
|
||||
reply_markup=get_back_keyboard(db_user.language)
|
||||
)
|
||||
|
||||
await state.set_state(PromoCodeStates.waiting_for_code)
|
||||
await callback.answer()
|
||||
|
||||
|
||||
@error_handler
|
||||
async def process_promocode(
|
||||
message: types.Message,
|
||||
db_user: User,
|
||||
state: FSMContext,
|
||||
db: AsyncSession
|
||||
):
|
||||
texts = get_texts(db_user.language)
|
||||
|
||||
code = message.text.strip()
|
||||
|
||||
if not code:
|
||||
await message.answer(
|
||||
"❌ Введите корректный промокод",
|
||||
reply_markup=get_back_keyboard(db_user.language)
|
||||
)
|
||||
return
|
||||
|
||||
promocode_service = PromoCodeService()
|
||||
result = await promocode_service.activate_promocode(db, db_user.id, code)
|
||||
|
||||
if result["success"]:
|
||||
await message.answer(
|
||||
texts.PROMOCODE_SUCCESS.format(description=result["description"]),
|
||||
reply_markup=get_back_keyboard(db_user.language)
|
||||
)
|
||||
logger.info(f"✅ Пользователь {db_user.telegram_id} активировал промокод {code}")
|
||||
else:
|
||||
error_messages = {
|
||||
"not_found": texts.PROMOCODE_INVALID,
|
||||
"expired": texts.PROMOCODE_EXPIRED,
|
||||
"used": texts.PROMOCODE_USED,
|
||||
"already_used_by_user": texts.PROMOCODE_USED,
|
||||
"server_error": texts.ERROR
|
||||
}
|
||||
|
||||
error_text = error_messages.get(result["error"], texts.PROMOCODE_INVALID)
|
||||
await message.answer(
|
||||
error_text,
|
||||
reply_markup=get_back_keyboard(db_user.language)
|
||||
)
|
||||
|
||||
await state.clear()
|
||||
|
||||
|
||||
def register_handlers(dp: Dispatcher):
|
||||
|
||||
dp.callback_query.register(
|
||||
show_promocode_menu,
|
||||
F.data == "menu_promocode"
|
||||
)
|
||||
|
||||
dp.message.register(
|
||||
process_promocode,
|
||||
PromoCodeStates.waiting_for_code
|
||||
)
|
||||
@@ -0,0 +1,262 @@
|
||||
import logging
|
||||
from aiogram import Dispatcher, types, F
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.config import settings
|
||||
from app.database.models import User
|
||||
from app.keyboards.inline import get_referral_keyboard, get_back_keyboard
|
||||
from app.localization.texts import get_texts
|
||||
from app.utils.user_utils import get_user_referral_summary, get_detailed_referral_list, get_referral_analytics
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
async def show_referral_info(
|
||||
callback: types.CallbackQuery,
|
||||
db_user: User,
|
||||
db: AsyncSession
|
||||
):
|
||||
texts = get_texts(db_user.language)
|
||||
|
||||
summary = await get_user_referral_summary(db, db_user.id)
|
||||
|
||||
bot_username = (await callback.bot.get_me()).username
|
||||
referral_link = f"https://t.me/{bot_username}?start={db_user.referral_code}"
|
||||
|
||||
referral_text = f"👥 <b>Реферальная программа</b>\n\n"
|
||||
|
||||
referral_text += f"📊 <b>Ваша статистика:</b>\n"
|
||||
referral_text += f"• Приглашено пользователей: <b>{summary['invited_count']}</b>\n"
|
||||
referral_text += f"• Сделали первое пополнение: <b>{summary['paid_referrals_count']}</b>\n"
|
||||
referral_text += f"• Активных рефералов: <b>{summary['active_referrals_count']}</b>\n"
|
||||
referral_text += f"• Конверсия: <b>{summary['conversion_rate']}%</b>\n"
|
||||
referral_text += f"• Заработано всего: <b>{texts.format_price(summary['total_earned_kopeks'])}</b>\n"
|
||||
referral_text += f"• За последний месяц: <b>{texts.format_price(summary['month_earned_kopeks'])}</b>\n\n"
|
||||
|
||||
referral_text += f"🎁 <b>Как работают награды:</b>\n"
|
||||
referral_text += f"• Новый пользователь получает: <b>{texts.format_price(settings.REFERRAL_FIRST_TOPUP_BONUS_KOPEKS)}</b> при первом пополнении от <b>{texts.format_price(settings.REFERRAL_MINIMUM_TOPUP_KOPEKS)}</b>\n"
|
||||
referral_text += f"• Вы получаете при первом пополнении реферала: <b>{texts.format_price(settings.REFERRAL_INVITER_BONUS_KOPEKS)}</b>\n"
|
||||
referral_text += f"• Комиссия с каждого пополнения реферала: <b>{settings.REFERRAL_COMMISSION_PERCENT}%</b>\n\n"
|
||||
|
||||
referral_text += f"🔗 <b>Ваша реферальная ссылка:</b>\n"
|
||||
referral_text += f"<code>{referral_link}</code>\n\n"
|
||||
referral_text += f"🆔 <b>Ваш код:</b> <code>{db_user.referral_code}</code>\n\n"
|
||||
|
||||
if summary['recent_earnings']:
|
||||
meaningful_earnings = [
|
||||
earning for earning in summary['recent_earnings'][:5]
|
||||
if earning['amount_kopeks'] > 0
|
||||
]
|
||||
|
||||
if meaningful_earnings:
|
||||
referral_text += f"💰 <b>Последние начисления:</b>\n"
|
||||
for earning in meaningful_earnings[:3]:
|
||||
reason_text = {
|
||||
"referral_first_topup": "🎉 Первое пополнение",
|
||||
"referral_commission_topup": "💰 Комиссия с пополнения",
|
||||
"referral_commission": "💰 Комиссия с покупки"
|
||||
}.get(earning['reason'], earning['reason'])
|
||||
|
||||
referral_text += f"• {reason_text}: <b>{texts.format_price(earning['amount_kopeks'])}</b> от {earning['referral_name']}\n"
|
||||
referral_text += "\n"
|
||||
|
||||
if summary['earnings_by_type']:
|
||||
referral_text += f"📈 <b>Доходы по типам:</b>\n"
|
||||
|
||||
if 'referral_first_topup' in summary['earnings_by_type']:
|
||||
data = summary['earnings_by_type']['referral_first_topup']
|
||||
if data['total_amount_kopeks'] > 0:
|
||||
referral_text += f"• Бонусы за первые пополнения: <b>{data['count']}</b> ({texts.format_price(data['total_amount_kopeks'])})\n"
|
||||
|
||||
if 'referral_commission_topup' in summary['earnings_by_type']:
|
||||
data = summary['earnings_by_type']['referral_commission_topup']
|
||||
if data['total_amount_kopeks'] > 0:
|
||||
referral_text += f"• Комиссии с пополнений: <b>{data['count']}</b> ({texts.format_price(data['total_amount_kopeks'])})\n"
|
||||
|
||||
if 'referral_commission' in summary['earnings_by_type']:
|
||||
data = summary['earnings_by_type']['referral_commission']
|
||||
if data['total_amount_kopeks'] > 0:
|
||||
referral_text += f"• Комиссии с покупок: <b>{data['count']}</b> ({texts.format_price(data['total_amount_kopeks'])})\n"
|
||||
|
||||
referral_text += "\n"
|
||||
|
||||
referral_text += "📢 Приглашайте друзей и зарабатывайте!"
|
||||
|
||||
await callback.message.edit_text(
|
||||
referral_text,
|
||||
reply_markup=get_referral_keyboard(db_user.language),
|
||||
parse_mode="HTML"
|
||||
)
|
||||
await callback.answer()
|
||||
|
||||
|
||||
async def show_detailed_referral_list(
|
||||
callback: types.CallbackQuery,
|
||||
db_user: User,
|
||||
db: AsyncSession,
|
||||
page: int = 1
|
||||
):
|
||||
texts = get_texts(db_user.language)
|
||||
|
||||
referrals_data = await get_detailed_referral_list(db, db_user.id, limit=10, offset=(page - 1) * 10)
|
||||
|
||||
if not referrals_data['referrals']:
|
||||
await callback.message.edit_text(
|
||||
"📋 У вас пока нет рефералов.\n\nПоделитесь своей реферальной ссылкой, чтобы начать зарабатывать!",
|
||||
reply_markup=types.InlineKeyboardMarkup(inline_keyboard=[
|
||||
[types.InlineKeyboardButton(text=texts.BACK, callback_data="menu_referrals")]
|
||||
])
|
||||
)
|
||||
await callback.answer()
|
||||
return
|
||||
|
||||
text = f"👥 <b>Ваши рефералы</b> (стр. {referrals_data['current_page']}/{referrals_data['total_pages']})\n\n"
|
||||
|
||||
for i, referral in enumerate(referrals_data['referrals'], 1):
|
||||
status_emoji = "🟢" if referral['status'] == 'active' else "🔴"
|
||||
|
||||
topup_emoji = "💰" if referral['has_made_first_topup'] else "⏳"
|
||||
|
||||
text += f"{i}. {status_emoji} <b>{referral['full_name']}</b>\n"
|
||||
text += f" {topup_emoji} Пополнений: {referral['topups_count']}\n"
|
||||
text += f" 💎 Заработано с него: {texts.format_price(referral['total_earned_kopeks'])}\n"
|
||||
text += f" 📅 Регистрация: {referral['days_since_registration']} дн. назад\n"
|
||||
|
||||
if referral['days_since_activity'] is not None:
|
||||
text += f" 🕐 Активность: {referral['days_since_activity']} дн. назад\n"
|
||||
else:
|
||||
text += f" 🕐 Активность: давно\n"
|
||||
|
||||
text += "\n"
|
||||
|
||||
keyboard = []
|
||||
nav_buttons = []
|
||||
|
||||
if referrals_data['has_prev']:
|
||||
nav_buttons.append(types.InlineKeyboardButton(
|
||||
text="⬅️ Назад",
|
||||
callback_data=f"referral_list_page_{page - 1}"
|
||||
))
|
||||
|
||||
if referrals_data['has_next']:
|
||||
nav_buttons.append(types.InlineKeyboardButton(
|
||||
text="Вперед ➡️",
|
||||
callback_data=f"referral_list_page_{page + 1}"
|
||||
))
|
||||
|
||||
if nav_buttons:
|
||||
keyboard.append(nav_buttons)
|
||||
|
||||
keyboard.append([types.InlineKeyboardButton(
|
||||
text=texts.BACK,
|
||||
callback_data="menu_referrals"
|
||||
)])
|
||||
|
||||
await callback.message.edit_text(
|
||||
text,
|
||||
reply_markup=types.InlineKeyboardMarkup(inline_keyboard=keyboard),
|
||||
parse_mode="HTML"
|
||||
)
|
||||
await callback.answer()
|
||||
|
||||
|
||||
async def show_referral_analytics(
|
||||
callback: types.CallbackQuery,
|
||||
db_user: User,
|
||||
db: AsyncSession
|
||||
):
|
||||
texts = get_texts(db_user.language)
|
||||
|
||||
analytics = await get_referral_analytics(db, db_user.id)
|
||||
|
||||
text = f"📊 <b>Аналитика рефералов</b>\n\n"
|
||||
|
||||
text += f"💰 <b>Доходы по периодам:</b>\n"
|
||||
text += f"• Сегодня: {texts.format_price(analytics['earnings_by_period']['today'])}\n"
|
||||
text += f"• За неделю: {texts.format_price(analytics['earnings_by_period']['week'])}\n"
|
||||
text += f"• За месяц: {texts.format_price(analytics['earnings_by_period']['month'])}\n"
|
||||
text += f"• За квартал: {texts.format_price(analytics['earnings_by_period']['quarter'])}\n\n"
|
||||
|
||||
if analytics['top_referrals']:
|
||||
text += f"🏆 <b>Топ-{len(analytics['top_referrals'])} рефералов:</b>\n"
|
||||
for i, ref in enumerate(analytics['top_referrals'], 1):
|
||||
text += f"{i}. {ref['referral_name']}: {texts.format_price(ref['total_earned_kopeks'])} ({ref['earnings_count']} начислений)\n"
|
||||
text += "\n"
|
||||
|
||||
text += "📈 Продолжайте развивать свою реферальную сеть!"
|
||||
|
||||
await callback.message.edit_text(
|
||||
text,
|
||||
reply_markup=types.InlineKeyboardMarkup(inline_keyboard=[
|
||||
[types.InlineKeyboardButton(text=texts.BACK, callback_data="menu_referrals")]
|
||||
]),
|
||||
parse_mode="HTML"
|
||||
)
|
||||
await callback.answer()
|
||||
|
||||
|
||||
async def create_invite_message(
|
||||
callback: types.CallbackQuery,
|
||||
db_user: User
|
||||
):
|
||||
texts = get_texts(db_user.language)
|
||||
|
||||
bot_username = (await callback.bot.get_me()).username
|
||||
referral_link = f"https://t.me/{bot_username}?start={db_user.referral_code}"
|
||||
|
||||
invite_text = f"🎉 Присоединяйся к VPN сервису!\n\n"
|
||||
invite_text += f"💎 При первом пополнении от {texts.format_price(settings.REFERRAL_MINIMUM_TOPUP_KOPEKS)} ты получишь {texts.format_price(settings.REFERRAL_FIRST_TOPUP_BONUS_KOPEKS)} бонусом на баланс!\n\n"
|
||||
invite_text += f"🚀 Быстрое подключение\n"
|
||||
invite_text += f"🌍 Серверы по всему миру\n"
|
||||
invite_text += f"🔒 Надежная защита\n\n"
|
||||
invite_text += f"👇 Переходи по ссылке:\n{referral_link}"
|
||||
|
||||
keyboard = types.InlineKeyboardMarkup(inline_keyboard=[
|
||||
[types.InlineKeyboardButton(
|
||||
text="📤 Поделиться",
|
||||
switch_inline_query=invite_text
|
||||
)],
|
||||
[types.InlineKeyboardButton(
|
||||
text=texts.BACK,
|
||||
callback_data="menu_referrals"
|
||||
)]
|
||||
])
|
||||
|
||||
await callback.message.edit_text(
|
||||
f"📝 <b>Приглашение создано!</b>\n\n"
|
||||
f"Нажмите кнопку «📤 Поделиться» чтобы отправить приглашение в любой чат, или скопируйте текст ниже:\n\n"
|
||||
f"<code>{invite_text}</code>",
|
||||
reply_markup=keyboard,
|
||||
parse_mode="HTML"
|
||||
)
|
||||
await callback.answer()
|
||||
|
||||
|
||||
def register_handlers(dp: Dispatcher):
|
||||
|
||||
dp.callback_query.register(
|
||||
show_referral_info,
|
||||
F.data == "menu_referrals"
|
||||
)
|
||||
|
||||
dp.callback_query.register(
|
||||
create_invite_message,
|
||||
F.data == "referral_create_invite"
|
||||
)
|
||||
|
||||
dp.callback_query.register(
|
||||
show_detailed_referral_list,
|
||||
F.data == "referral_list"
|
||||
)
|
||||
|
||||
dp.callback_query.register(
|
||||
show_referral_analytics,
|
||||
F.data == "referral_analytics"
|
||||
)
|
||||
|
||||
dp.callback_query.register(
|
||||
lambda callback, db_user, db: show_detailed_referral_list(
|
||||
callback, db_user, db, int(callback.data.split('_')[-1])
|
||||
),
|
||||
F.data.startswith("referral_list_page_")
|
||||
)
|
||||
@@ -0,0 +1,132 @@
|
||||
import logging
|
||||
from aiogram import Dispatcher, types, F
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.database.models import User
|
||||
from app.services.payment_service import PaymentService
|
||||
from app.external.telegram_stars import TelegramStarsService
|
||||
from app.database.crud.user import get_user_by_telegram_id
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
async def handle_pre_checkout_query(query: types.PreCheckoutQuery):
|
||||
try:
|
||||
logger.info(f"📋 Pre-checkout query от {query.from_user.id}: {query.total_amount} XTR, payload: {query.invoice_payload}")
|
||||
|
||||
if not query.invoice_payload or not query.invoice_payload.startswith("balance_"):
|
||||
logger.warning(f"Невалидный payload: {query.invoice_payload}")
|
||||
await query.answer(
|
||||
ok=False,
|
||||
error_message="Ошибка валидации платежа. Попробуйте еще раз."
|
||||
)
|
||||
return
|
||||
|
||||
try:
|
||||
from app.database.database import get_db
|
||||
async for db in get_db():
|
||||
user = await get_user_by_telegram_id(db, query.from_user.id)
|
||||
if not user:
|
||||
logger.warning(f"Пользователь {query.from_user.id} не найден в БД")
|
||||
await query.answer(
|
||||
ok=False,
|
||||
error_message="Пользователь не найден. Обратитесь в поддержку."
|
||||
)
|
||||
return
|
||||
break
|
||||
except Exception as db_error:
|
||||
logger.error(f"Ошибка подключения к БД в pre_checkout_query: {db_error}")
|
||||
await query.answer(
|
||||
ok=False,
|
||||
error_message="Техническая ошибка. Попробуйте позже."
|
||||
)
|
||||
return
|
||||
|
||||
await query.answer(ok=True)
|
||||
logger.info(f"✅ Pre-checkout одобрен для пользователя {query.from_user.id}")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка в pre_checkout_query: {e}", exc_info=True)
|
||||
await query.answer(
|
||||
ok=False,
|
||||
error_message="Техническая ошибка. Попробуйте позже."
|
||||
)
|
||||
|
||||
|
||||
async def handle_successful_payment(
|
||||
message: types.Message,
|
||||
db: AsyncSession,
|
||||
**kwargs
|
||||
):
|
||||
try:
|
||||
payment = message.successful_payment
|
||||
user_id = message.from_user.id
|
||||
|
||||
logger.info(
|
||||
f"💳 Успешный Stars платеж от {user_id}: "
|
||||
f"{payment.total_amount} XTR, "
|
||||
f"payload: {payment.invoice_payload}, "
|
||||
f"charge_id: {payment.telegram_payment_charge_id}"
|
||||
)
|
||||
|
||||
user = await get_user_by_telegram_id(db, user_id)
|
||||
if not user:
|
||||
logger.error(f"Пользователь {user_id} не найден при обработке Stars платежа")
|
||||
await message.answer(
|
||||
"❌ Ошибка: пользователь не найден. Обратитесь в поддержку."
|
||||
)
|
||||
return
|
||||
|
||||
payment_service = PaymentService(message.bot)
|
||||
success = await payment_service.process_stars_payment(
|
||||
db=db,
|
||||
user_id=user.id,
|
||||
stars_amount=payment.total_amount,
|
||||
payload=payment.invoice_payload,
|
||||
telegram_payment_charge_id=payment.telegram_payment_charge_id
|
||||
)
|
||||
|
||||
if success:
|
||||
rubles_amount = TelegramStarsService.calculate_rubles_from_stars(payment.total_amount)
|
||||
|
||||
await message.answer(
|
||||
f"🎉 <b>Платеж успешно обработан!</b>\n\n"
|
||||
f"⭐ Потрачено звезд: {payment.total_amount}\n"
|
||||
f"💰 Зачислено на баланс: {rubles_amount:.2f} ₽\n"
|
||||
f"🆔 ID транзакции: {payment.telegram_payment_charge_id[:8]}...\n\n"
|
||||
f"Спасибо за пополнение! 🚀",
|
||||
parse_mode="HTML"
|
||||
)
|
||||
|
||||
logger.info(
|
||||
f"✅ Stars платеж успешно обработан: "
|
||||
f"пользователь {user.id}, {payment.total_amount} звезд → {rubles_amount:.2f}₽"
|
||||
)
|
||||
else:
|
||||
logger.error(f"Ошибка обработки Stars платежа для пользователя {user.id}")
|
||||
await message.answer(
|
||||
"❌ Произошла ошибка при зачислении средств. "
|
||||
"Обратитесь в поддержку, платеж будет проверен вручную."
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка в successful_payment: {e}", exc_info=True)
|
||||
await message.answer(
|
||||
"❌ Техническая ошибка при обработке платежа. "
|
||||
"Обратитесь в поддержку для решения проблемы."
|
||||
)
|
||||
|
||||
|
||||
def register_stars_handlers(dp: Dispatcher):
|
||||
|
||||
dp.pre_checkout_query.register(
|
||||
handle_pre_checkout_query,
|
||||
F.currency == "XTR"
|
||||
)
|
||||
|
||||
dp.message.register(
|
||||
handle_successful_payment,
|
||||
F.successful_payment
|
||||
)
|
||||
|
||||
logger.info("🌟 Зарегистрированы обработчики Telegram Stars платежей")
|
||||
@@ -0,0 +1,830 @@
|
||||
import logging
|
||||
from datetime import datetime
|
||||
from aiogram import Dispatcher, types, F, Bot
|
||||
from aiogram.filters import Command, StateFilter
|
||||
from aiogram.fsm.context import FSMContext
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.config import settings
|
||||
from app.states import RegistrationStates
|
||||
from app.database.crud.user import (
|
||||
get_user_by_telegram_id, create_user, get_user_by_referral_code
|
||||
)
|
||||
from app.database.models import UserStatus
|
||||
from app.keyboards.inline import (
|
||||
get_rules_keyboard, get_main_menu_keyboard
|
||||
)
|
||||
from app.localization.texts import get_texts
|
||||
from app.services.referral_service import process_referral_registration
|
||||
from app.utils.user_utils import generate_unique_referral_code
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
async def handle_potential_referral_code(
|
||||
message: types.Message,
|
||||
state: FSMContext,
|
||||
db: AsyncSession
|
||||
):
|
||||
current_state = await state.get_state()
|
||||
logger.info(f"🔍 REFERRAL CHECK: Проверка сообщения '{message.text}' в состоянии {current_state}")
|
||||
|
||||
if current_state not in [
|
||||
RegistrationStates.waiting_for_rules_accept.state,
|
||||
RegistrationStates.waiting_for_referral_code.state,
|
||||
None
|
||||
]:
|
||||
return False
|
||||
|
||||
user = await get_user_by_telegram_id(db, message.from_user.id)
|
||||
if user and user.status == UserStatus.ACTIVE.value:
|
||||
return False
|
||||
|
||||
potential_code = message.text.strip()
|
||||
if len(potential_code) < 4 or len(potential_code) > 20:
|
||||
return False
|
||||
|
||||
referrer = await get_user_by_referral_code(db, potential_code)
|
||||
if not referrer:
|
||||
await message.answer(
|
||||
"❌ Неверный реферальный код.\n\n"
|
||||
"💡 Если у вас есть реферальный код, убедитесь что он введен правильно.\n"
|
||||
"⏭️ Для продолжения регистрации без реферального кода используйте команду /start"
|
||||
)
|
||||
return True
|
||||
|
||||
data = await state.get_data() or {}
|
||||
data['referral_code'] = potential_code
|
||||
data['referrer_id'] = referrer.id
|
||||
await state.set_data(data)
|
||||
|
||||
await message.answer("✅ Реферальный код принят!")
|
||||
logger.info(f"✅ Реферальный код {potential_code} применен для пользователя {message.from_user.id}")
|
||||
|
||||
if current_state != RegistrationStates.waiting_for_referral_code.state:
|
||||
language = data.get('language', 'ru')
|
||||
texts = get_texts(language)
|
||||
|
||||
await message.answer(
|
||||
texts.RULES_TEXT,
|
||||
reply_markup=get_rules_keyboard(language)
|
||||
)
|
||||
await state.set_state(RegistrationStates.waiting_for_rules_accept)
|
||||
logger.info("📋 Правила отправлены после ввода реферального кода")
|
||||
else:
|
||||
await complete_registration(message, state, db)
|
||||
|
||||
return True
|
||||
|
||||
|
||||
async def cmd_start(message: types.Message, state: FSMContext, db: AsyncSession, db_user=None):
|
||||
logger.info(f"🚀 START: Обработка /start от {message.from_user.id}")
|
||||
|
||||
referral_code = None
|
||||
if len(message.text.split()) > 1:
|
||||
potential_code = message.text.split()[1]
|
||||
referral_code = potential_code
|
||||
logger.info(f"🔎 Найден реферальный код: {referral_code}")
|
||||
|
||||
if referral_code:
|
||||
await state.set_data({'referral_code': referral_code})
|
||||
|
||||
user = db_user if db_user else await get_user_by_telegram_id(db, message.from_user.id)
|
||||
|
||||
if user and user.status != UserStatus.DELETED.value:
|
||||
logger.info(f"✅ Активный пользователь найден: {user.telegram_id}")
|
||||
|
||||
profile_updated = False
|
||||
|
||||
if user.username != message.from_user.username:
|
||||
old_username = user.username
|
||||
user.username = message.from_user.username
|
||||
logger.info(f"📝 Username обновлен: '{old_username}' → '{user.username}'")
|
||||
profile_updated = True
|
||||
|
||||
if user.first_name != message.from_user.first_name:
|
||||
old_first_name = user.first_name
|
||||
user.first_name = message.from_user.first_name
|
||||
logger.info(f"📝 Имя обновлено: '{old_first_name}' → '{user.first_name}'")
|
||||
profile_updated = True
|
||||
|
||||
if user.last_name != message.from_user.last_name:
|
||||
old_last_name = user.last_name
|
||||
user.last_name = message.from_user.last_name
|
||||
logger.info(f"📝 Фамилия обновлена: '{old_last_name}' → '{user.last_name}'")
|
||||
profile_updated = True
|
||||
|
||||
user.last_activity = datetime.utcnow()
|
||||
|
||||
if profile_updated:
|
||||
user.updated_at = datetime.utcnow()
|
||||
await db.commit()
|
||||
await db.refresh(user)
|
||||
logger.info(f"💾 Профиль пользователя {user.telegram_id} обновлен")
|
||||
else:
|
||||
await db.commit()
|
||||
|
||||
texts = get_texts(user.language)
|
||||
|
||||
if referral_code and not user.referred_by_id:
|
||||
await message.answer("ℹ️ Вы уже зарегистрированы в системе. Реферальная ссылка не может быть применена.")
|
||||
|
||||
has_active_subscription = user.subscription is not None
|
||||
subscription_is_active = False
|
||||
|
||||
if user.subscription:
|
||||
subscription_is_active = user.subscription.is_active
|
||||
|
||||
await message.answer(
|
||||
texts.MAIN_MENU.format(
|
||||
user_name=user.full_name,
|
||||
subscription_status=_get_subscription_status(user, texts)
|
||||
),
|
||||
reply_markup=get_main_menu_keyboard(
|
||||
language=user.language,
|
||||
is_admin=settings.is_admin(user.telegram_id),
|
||||
has_had_paid_subscription=user.has_had_paid_subscription,
|
||||
has_active_subscription=has_active_subscription,
|
||||
subscription_is_active=subscription_is_active,
|
||||
balance_kopeks=user.balance_kopeks
|
||||
)
|
||||
)
|
||||
await state.clear()
|
||||
return
|
||||
|
||||
if user and user.status == UserStatus.DELETED.value:
|
||||
logger.info(f"🔄 Удаленный пользователь {user.telegram_id} начинает повторную регистрацию")
|
||||
|
||||
try:
|
||||
from app.services.user_service import UserService
|
||||
from app.database.models import (
|
||||
Subscription, Transaction, PromoCodeUse,
|
||||
ReferralEarning, SubscriptionServer
|
||||
)
|
||||
from sqlalchemy import delete
|
||||
|
||||
if user.subscription:
|
||||
await db.execute(
|
||||
delete(SubscriptionServer).where(
|
||||
SubscriptionServer.subscription_id == user.subscription.id
|
||||
)
|
||||
)
|
||||
logger.info(f"🗑️ Удалены записи SubscriptionServer")
|
||||
|
||||
if user.subscription:
|
||||
await db.delete(user.subscription)
|
||||
logger.info(f"🗑️ Удалена подписка пользователя")
|
||||
|
||||
await db.execute(
|
||||
delete(PromoCodeUse).where(PromoCodeUse.user_id == user.id)
|
||||
)
|
||||
|
||||
await db.execute(
|
||||
delete(ReferralEarning).where(ReferralEarning.user_id == user.id)
|
||||
)
|
||||
await db.execute(
|
||||
delete(ReferralEarning).where(ReferralEarning.referral_id == user.id)
|
||||
)
|
||||
|
||||
await db.execute(
|
||||
delete(Transaction).where(Transaction.user_id == user.id)
|
||||
)
|
||||
|
||||
user.status = UserStatus.ACTIVE.value
|
||||
user.balance_kopeks = 0
|
||||
user.remnawave_uuid = None
|
||||
user.has_had_paid_subscription = False
|
||||
user.referred_by_id = None
|
||||
|
||||
user.username = message.from_user.username
|
||||
user.first_name = message.from_user.first_name
|
||||
user.last_name = message.from_user.last_name
|
||||
user.updated_at = datetime.utcnow()
|
||||
user.last_activity = datetime.utcnow()
|
||||
|
||||
from app.utils.user_utils import generate_unique_referral_code
|
||||
user.referral_code = await generate_unique_referral_code(db, user.telegram_id)
|
||||
|
||||
await db.commit()
|
||||
|
||||
logger.info(f"✅ Пользователь {user.telegram_id} подготовлен к восстановлению")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"❌ Ошибка подготовки к восстановлению: {e}")
|
||||
await db.rollback()
|
||||
else:
|
||||
logger.info(f"🆕 Новый пользователь, начинаем регистрацию")
|
||||
|
||||
language = 'ru'
|
||||
texts = get_texts(language)
|
||||
|
||||
data = await state.get_data() or {}
|
||||
data['language'] = language
|
||||
await state.set_data(data)
|
||||
logger.info(f"💾 Установлен русский язык по умолчанию")
|
||||
|
||||
await message.answer(
|
||||
texts.RULES_TEXT,
|
||||
reply_markup=get_rules_keyboard(language)
|
||||
)
|
||||
logger.info(f"📋 Правила отправлены")
|
||||
|
||||
await state.set_state(RegistrationStates.waiting_for_rules_accept)
|
||||
current_state = await state.get_state()
|
||||
logger.info(f"📊 Установлено состояние: {current_state}")
|
||||
|
||||
|
||||
async def process_rules_accept(
|
||||
callback: types.CallbackQuery,
|
||||
state: FSMContext,
|
||||
db: AsyncSession
|
||||
):
|
||||
|
||||
logger.info(f"📋 RULES: Начало обработки правил")
|
||||
logger.info(f"📊 Callback data: {callback.data}")
|
||||
logger.info(f"👤 User: {callback.from_user.id}")
|
||||
|
||||
current_state = await state.get_state()
|
||||
logger.info(f"📊 Текущее состояние: {current_state}")
|
||||
|
||||
try:
|
||||
await callback.answer()
|
||||
|
||||
data = await state.get_data()
|
||||
language = data.get('language', 'ru')
|
||||
texts = get_texts(language)
|
||||
|
||||
if callback.data == 'rules_accept':
|
||||
logger.info(f"✅ Правила приняты пользователем {callback.from_user.id}")
|
||||
|
||||
try:
|
||||
await callback.message.delete()
|
||||
logger.info(f"🗑️ Сообщение с правилами удалено")
|
||||
except Exception as e:
|
||||
logger.warning(f"⚠️ Не удалось удалить сообщение с правилами: {e}")
|
||||
try:
|
||||
await callback.message.edit_text(
|
||||
"✅ Правила приняты! Завершаем регистрацию...",
|
||||
reply_markup=None
|
||||
)
|
||||
except:
|
||||
pass
|
||||
|
||||
if data.get('referral_code'):
|
||||
logger.info(f"🎫 Найден реферальный код из deep link: {data['referral_code']}")
|
||||
|
||||
referrer = await get_user_by_referral_code(db, data['referral_code'])
|
||||
if referrer:
|
||||
data['referrer_id'] = referrer.id
|
||||
await state.set_data(data)
|
||||
logger.info(f"✅ Реферер найден: {referrer.id}")
|
||||
|
||||
await complete_registration_from_callback(callback, state, db)
|
||||
else:
|
||||
try:
|
||||
await callback.message.answer(
|
||||
"У вас есть реферальный код? Введите его или нажмите 'Пропустить'",
|
||||
reply_markup=get_referral_code_keyboard(language)
|
||||
)
|
||||
await state.set_state(RegistrationStates.waiting_for_referral_code)
|
||||
logger.info(f"🔍 Ожидание ввода реферального кода")
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка при показе вопроса о реферальном коде: {e}")
|
||||
await complete_registration_from_callback(callback, state, db)
|
||||
|
||||
else:
|
||||
logger.info(f"❌ Правила отклонены пользователем {callback.from_user.id}")
|
||||
|
||||
try:
|
||||
rules_required_text = getattr(texts, 'RULES_REQUIRED',
|
||||
"Для использования бота необходимо принять правила сервиса.")
|
||||
await callback.message.edit_text(
|
||||
rules_required_text,
|
||||
reply_markup=get_rules_keyboard(language)
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка при показе сообщения об отклонении правил: {e}")
|
||||
await callback.message.edit_text(
|
||||
"Для использования бота необходимо принять правила сервиса.",
|
||||
reply_markup=get_rules_keyboard(language)
|
||||
)
|
||||
|
||||
logger.info(f"✅ Правила обработаны для пользователя {callback.from_user.id}")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"❌ Ошибка обработки правил: {e}", exc_info=True)
|
||||
await callback.answer("❌ Произошла ошибка. Попробуйте еще раз.", show_alert=True)
|
||||
|
||||
try:
|
||||
data = await state.get_data()
|
||||
language = data.get('language', 'ru')
|
||||
await callback.message.answer(
|
||||
"Произошла ошибка. Попробуйте принять правила еще раз:",
|
||||
reply_markup=get_rules_keyboard(language)
|
||||
)
|
||||
await state.set_state(RegistrationStates.waiting_for_rules_accept)
|
||||
except:
|
||||
pass
|
||||
|
||||
|
||||
async def process_referral_code_input(
|
||||
message: types.Message,
|
||||
state: FSMContext,
|
||||
db: AsyncSession
|
||||
):
|
||||
|
||||
logger.info(f"🎫 REFERRAL: Обработка реферального кода: {message.text}")
|
||||
|
||||
data = await state.get_data()
|
||||
language = data.get('language', 'ru')
|
||||
texts = get_texts(language)
|
||||
|
||||
referral_code = message.text.strip()
|
||||
|
||||
referrer = await get_user_by_referral_code(db, referral_code)
|
||||
if referrer:
|
||||
data['referrer_id'] = referrer.id
|
||||
await state.set_data(data)
|
||||
await message.answer("✅ Реферальный код применен!")
|
||||
logger.info(f"✅ Реферальный код применен")
|
||||
else:
|
||||
await message.answer("❌ Неверный реферальный код")
|
||||
logger.info(f"❌ Неверный реферальный код")
|
||||
return
|
||||
|
||||
await complete_registration(message, state, db)
|
||||
|
||||
|
||||
async def process_referral_code_skip(
|
||||
callback: types.CallbackQuery,
|
||||
state: FSMContext,
|
||||
db: AsyncSession
|
||||
):
|
||||
|
||||
logger.info(f"⭐️ SKIP: Пропуск реферального кода от пользователя {callback.from_user.id}")
|
||||
await callback.answer()
|
||||
|
||||
try:
|
||||
await callback.message.delete()
|
||||
logger.info(f"🗑️ Сообщение с вопросом о реферальном коде удалено")
|
||||
except Exception as e:
|
||||
logger.warning(f"⚠️ Не удалось удалить сообщение с вопросом о реферальном коде: {e}")
|
||||
try:
|
||||
await callback.message.edit_text(
|
||||
"✅ Завершаем регистрацию...",
|
||||
reply_markup=None
|
||||
)
|
||||
except:
|
||||
pass
|
||||
|
||||
await complete_registration_from_callback(callback, state, db)
|
||||
|
||||
|
||||
|
||||
async def complete_registration_from_callback(
|
||||
callback: types.CallbackQuery,
|
||||
state: FSMContext,
|
||||
db: AsyncSession
|
||||
):
|
||||
logger.info(f"🏁 COMPLETE: Завершение регистрации для пользователя {callback.from_user.id}")
|
||||
|
||||
existing_user = await get_user_by_telegram_id(db, callback.from_user.id)
|
||||
|
||||
if existing_user and existing_user.status == UserStatus.ACTIVE.value:
|
||||
logger.warning(f"⚠️ Пользователь {callback.from_user.id} уже активен! Показываем главное меню.")
|
||||
texts = get_texts(existing_user.language)
|
||||
|
||||
data = await state.get_data()
|
||||
if data.get('referral_code') and not existing_user.referred_by_id:
|
||||
await callback.message.answer("ℹ️ Вы уже зарегистрированы в системе. Реферальная ссылка не может быть применена.")
|
||||
|
||||
has_active_subscription = existing_user.subscription is not None
|
||||
subscription_is_active = False
|
||||
|
||||
if existing_user.subscription:
|
||||
subscription_is_active = existing_user.subscription.is_active
|
||||
|
||||
user_name = existing_user.full_name
|
||||
user_telegram_id = existing_user.telegram_id
|
||||
user_language = existing_user.language
|
||||
has_had_paid_subscription = existing_user.has_had_paid_subscription
|
||||
balance_kopeks = existing_user.balance_kopeks
|
||||
|
||||
try:
|
||||
await callback.message.answer(
|
||||
texts.MAIN_MENU.format(
|
||||
user_name=user_name,
|
||||
subscription_status=_get_subscription_status(existing_user, texts)
|
||||
),
|
||||
reply_markup=get_main_menu_keyboard(
|
||||
language=user_language,
|
||||
is_admin=settings.is_admin(user_telegram_id),
|
||||
has_had_paid_subscription=has_had_paid_subscription,
|
||||
has_active_subscription=has_active_subscription,
|
||||
subscription_is_active=subscription_is_active,
|
||||
balance_kopeks=balance_kopeks
|
||||
)
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка при показе главного меню существующему пользователю: {e}")
|
||||
await callback.message.answer(f"Добро пожаловать, {user_name}!")
|
||||
|
||||
await state.clear()
|
||||
return
|
||||
|
||||
data = await state.get_data()
|
||||
language = data.get('language', 'ru')
|
||||
texts = get_texts(language)
|
||||
|
||||
referrer_id = data.get('referrer_id')
|
||||
if not referrer_id and data.get('referral_code'):
|
||||
referrer = await get_user_by_referral_code(db, data['referral_code'])
|
||||
if referrer:
|
||||
referrer_id = referrer.id
|
||||
|
||||
if existing_user and existing_user.status == UserStatus.DELETED.value:
|
||||
logger.info(f"🔄 Восстанавливаем удаленного пользователя {callback.from_user.id}")
|
||||
|
||||
existing_user.username = callback.from_user.username
|
||||
existing_user.first_name = callback.from_user.first_name
|
||||
existing_user.last_name = callback.from_user.last_name
|
||||
existing_user.language = language
|
||||
existing_user.referred_by_id = referrer_id
|
||||
existing_user.status = UserStatus.ACTIVE.value
|
||||
existing_user.balance_kopeks = 0
|
||||
existing_user.has_had_paid_subscription = False
|
||||
|
||||
from datetime import datetime
|
||||
existing_user.updated_at = datetime.utcnow()
|
||||
existing_user.last_activity = datetime.utcnow()
|
||||
|
||||
await db.commit()
|
||||
await db.refresh(existing_user)
|
||||
|
||||
user = existing_user
|
||||
logger.info(f"✅ Пользователь {callback.from_user.id} восстановлен")
|
||||
|
||||
elif not existing_user:
|
||||
logger.info(f"🆕 Создаем нового пользователя {callback.from_user.id}")
|
||||
|
||||
referral_code = await generate_unique_referral_code(db, callback.from_user.id)
|
||||
|
||||
user = await create_user(
|
||||
db=db,
|
||||
telegram_id=callback.from_user.id,
|
||||
username=callback.from_user.username,
|
||||
first_name=callback.from_user.first_name,
|
||||
last_name=callback.from_user.last_name,
|
||||
language=language,
|
||||
referred_by_id=referrer_id,
|
||||
referral_code=referral_code
|
||||
)
|
||||
else:
|
||||
logger.info(f"🔄 Обновляем существующего пользователя {callback.from_user.id}")
|
||||
existing_user.status = UserStatus.ACTIVE.value
|
||||
existing_user.language = language
|
||||
if referrer_id and not existing_user.referred_by_id:
|
||||
existing_user.referred_by_id = referrer_id
|
||||
|
||||
from datetime import datetime
|
||||
existing_user.updated_at = datetime.utcnow()
|
||||
existing_user.last_activity = datetime.utcnow()
|
||||
|
||||
await db.commit()
|
||||
await db.refresh(existing_user)
|
||||
user = existing_user
|
||||
|
||||
if referrer_id:
|
||||
try:
|
||||
await process_referral_registration(db, user.id, referrer_id, callback.bot)
|
||||
logger.info(f"✅ Реферальная регистрация обработана для {user.id}")
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка при обработке реферальной регистрации: {e}")
|
||||
|
||||
await state.clear()
|
||||
|
||||
has_active_subscription = False
|
||||
subscription_is_active = False
|
||||
user_name = user.full_name
|
||||
balance_kopeks = user.balance_kopeks
|
||||
user_telegram_id = user.telegram_id
|
||||
user_language = user.language
|
||||
has_had_paid_subscription = user.has_had_paid_subscription
|
||||
|
||||
try:
|
||||
await callback.message.answer(
|
||||
texts.MAIN_MENU.format(
|
||||
user_name=user_name,
|
||||
subscription_status=_get_subscription_status_simple(texts)
|
||||
),
|
||||
reply_markup=get_main_menu_keyboard(
|
||||
language=user_language,
|
||||
is_admin=settings.is_admin(user_telegram_id),
|
||||
has_had_paid_subscription=has_had_paid_subscription,
|
||||
has_active_subscription=has_active_subscription,
|
||||
subscription_is_active=subscription_is_active,
|
||||
balance_kopeks=balance_kopeks
|
||||
)
|
||||
)
|
||||
logger.info(f"✅ Главное меню отправлено для пользователя {user_telegram_id}")
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка при отправке главного меню: {e}")
|
||||
try:
|
||||
balance_rubles = balance_kopeks / 100
|
||||
await callback.message.answer(
|
||||
f"Добро пожаловать, {user_name}!\n"
|
||||
f"Баланс: {balance_rubles:.2f} ₽\n"
|
||||
f"Подписка: Нет активной подписки",
|
||||
reply_markup=get_main_menu_keyboard(
|
||||
language=user_language,
|
||||
is_admin=settings.is_admin(user_telegram_id),
|
||||
has_had_paid_subscription=has_had_paid_subscription,
|
||||
has_active_subscription=has_active_subscription,
|
||||
subscription_is_active=subscription_is_active,
|
||||
balance_kopeks=balance_kopeks
|
||||
)
|
||||
)
|
||||
logger.info(f"✅ Fallback главное меню отправлено для пользователя {user_telegram_id}")
|
||||
except Exception as fallback_error:
|
||||
logger.error(f"⛔ Критическая ошибка при отправке fallback меню: {fallback_error}")
|
||||
try:
|
||||
await callback.message.answer(f"Добро пожаловать, {user_name}! Регистрация завершена.")
|
||||
logger.info(f"✅ Простое приветствие отправлено для пользователя {user_telegram_id}")
|
||||
except Exception as final_error:
|
||||
logger.error(f"⛔ Критическая ошибка при отправке простого сообщения: {final_error}")
|
||||
|
||||
logger.info(f"✅ Регистрация завершена для пользователя: {user_telegram_id}")
|
||||
|
||||
async def complete_registration(
|
||||
message: types.Message,
|
||||
state: FSMContext,
|
||||
db: AsyncSession
|
||||
):
|
||||
logger.info(f"🏁 COMPLETE: Завершение регистрации для пользователя {message.from_user.id}")
|
||||
|
||||
existing_user = await get_user_by_telegram_id(db, message.from_user.id)
|
||||
|
||||
if existing_user and existing_user.status == UserStatus.ACTIVE.value:
|
||||
logger.warning(f"⚠️ Пользователь {message.from_user.id} уже активен! Показываем главное меню.")
|
||||
texts = get_texts(existing_user.language)
|
||||
|
||||
data = await state.get_data()
|
||||
if data.get('referral_code') and not existing_user.referred_by_id:
|
||||
await message.answer("ℹ️ Вы уже зарегистрированы в системе. Реферальная ссылка не может быть применена.")
|
||||
|
||||
has_active_subscription = existing_user.subscription is not None
|
||||
subscription_is_active = False
|
||||
|
||||
if existing_user.subscription:
|
||||
subscription_is_active = existing_user.subscription.is_active
|
||||
|
||||
user_name = existing_user.full_name
|
||||
user_telegram_id = existing_user.telegram_id
|
||||
user_language = existing_user.language
|
||||
has_had_paid_subscription = existing_user.has_had_paid_subscription
|
||||
balance_kopeks = existing_user.balance_kopeks
|
||||
|
||||
try:
|
||||
await message.answer(
|
||||
texts.MAIN_MENU.format(
|
||||
user_name=user_name,
|
||||
subscription_status=_get_subscription_status(existing_user, texts)
|
||||
),
|
||||
reply_markup=get_main_menu_keyboard(
|
||||
language=user_language,
|
||||
is_admin=settings.is_admin(user_telegram_id),
|
||||
has_had_paid_subscription=has_had_paid_subscription,
|
||||
has_active_subscription=has_active_subscription,
|
||||
subscription_is_active=subscription_is_active,
|
||||
balance_kopeks=balance_kopeks
|
||||
)
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка при показе главного меню существующему пользователю: {e}")
|
||||
await message.answer(f"Добро пожаловать, {user_name}!")
|
||||
|
||||
await state.clear()
|
||||
return
|
||||
|
||||
data = await state.get_data()
|
||||
language = data.get('language', 'ru')
|
||||
texts = get_texts(language)
|
||||
|
||||
referrer_id = data.get('referrer_id')
|
||||
if not referrer_id and data.get('referral_code'):
|
||||
referrer = await get_user_by_referral_code(db, data['referral_code'])
|
||||
if referrer:
|
||||
referrer_id = referrer.id
|
||||
|
||||
if existing_user and existing_user.status == UserStatus.DELETED.value:
|
||||
logger.info(f"🔄 Восстанавливаем удаленного пользователя {message.from_user.id}")
|
||||
|
||||
existing_user.username = message.from_user.username
|
||||
existing_user.first_name = message.from_user.first_name
|
||||
existing_user.last_name = message.from_user.last_name
|
||||
existing_user.language = language
|
||||
existing_user.referred_by_id = referrer_id
|
||||
existing_user.status = UserStatus.ACTIVE.value
|
||||
existing_user.balance_kopeks = 0
|
||||
existing_user.has_had_paid_subscription = False
|
||||
|
||||
from datetime import datetime
|
||||
existing_user.updated_at = datetime.utcnow()
|
||||
existing_user.last_activity = datetime.utcnow()
|
||||
|
||||
await db.commit()
|
||||
await db.refresh(existing_user)
|
||||
|
||||
user = existing_user
|
||||
logger.info(f"✅ Пользователь {message.from_user.id} восстановлен")
|
||||
|
||||
elif not existing_user:
|
||||
logger.info(f"🆕 Создаем нового пользователя {message.from_user.id}")
|
||||
|
||||
referral_code = await generate_unique_referral_code(db, message.from_user.id)
|
||||
|
||||
user = await create_user(
|
||||
db=db,
|
||||
telegram_id=message.from_user.id,
|
||||
username=message.from_user.username,
|
||||
first_name=message.from_user.first_name,
|
||||
last_name=message.from_user.last_name,
|
||||
language=language,
|
||||
referred_by_id=referrer_id,
|
||||
referral_code=referral_code
|
||||
)
|
||||
else:
|
||||
logger.info(f"🔄 Обновляем существующего пользователя {message.from_user.id}")
|
||||
existing_user.status = UserStatus.ACTIVE.value
|
||||
existing_user.language = language
|
||||
if referrer_id and not existing_user.referred_by_id:
|
||||
existing_user.referred_by_id = referrer_id
|
||||
|
||||
from datetime import datetime
|
||||
existing_user.updated_at = datetime.utcnow()
|
||||
existing_user.last_activity = datetime.utcnow()
|
||||
|
||||
await db.commit()
|
||||
await db.refresh(existing_user)
|
||||
user = existing_user
|
||||
|
||||
if referrer_id:
|
||||
try:
|
||||
await process_referral_registration(db, user.id, referrer_id, message.bot)
|
||||
logger.info(f"✅ Реферальная регистрация обработана для {user.id}")
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка при обработке реферальной регистрации: {e}")
|
||||
|
||||
await state.clear()
|
||||
|
||||
has_active_subscription = False
|
||||
subscription_is_active = False
|
||||
user_name = user.full_name
|
||||
balance_kopeks = user.balance_kopeks
|
||||
user_telegram_id = user.telegram_id
|
||||
user_language = user.language
|
||||
has_had_paid_subscription = user.has_had_paid_subscription
|
||||
|
||||
try:
|
||||
await message.answer(
|
||||
texts.MAIN_MENU.format(
|
||||
user_name=user_name,
|
||||
subscription_status=_get_subscription_status_simple(texts)
|
||||
),
|
||||
reply_markup=get_main_menu_keyboard(
|
||||
language=user_language,
|
||||
is_admin=settings.is_admin(user_telegram_id),
|
||||
has_had_paid_subscription=has_had_paid_subscription,
|
||||
has_active_subscription=has_active_subscription,
|
||||
subscription_is_active=subscription_is_active,
|
||||
balance_kopeks=balance_kopeks
|
||||
)
|
||||
)
|
||||
logger.info(f"✅ Главное меню отправлено для пользователя {user_telegram_id}")
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка при отправке главного меню: {e}")
|
||||
try:
|
||||
balance_rubles = balance_kopeks / 100
|
||||
await message.answer(
|
||||
f"Добро пожаловать, {user_name}!\n"
|
||||
f"Баланс: {balance_rubles:.2f} ₽\n"
|
||||
f"Подписка: Нет активной подписки",
|
||||
reply_markup=get_main_menu_keyboard(
|
||||
language=user_language,
|
||||
is_admin=settings.is_admin(user_telegram_id),
|
||||
has_had_paid_subscription=has_had_paid_subscription,
|
||||
has_active_subscription=has_active_subscription,
|
||||
subscription_is_active=subscription_is_active,
|
||||
balance_kopeks=balance_kopeks
|
||||
)
|
||||
)
|
||||
logger.info(f"✅ Fallback главное меню отправлено для пользователя {user_telegram_id}")
|
||||
except Exception as fallback_error:
|
||||
logger.error(f"⛔ Критическая ошибка при отправке fallback меню: {fallback_error}")
|
||||
try:
|
||||
await message.answer(f"Добро пожаловать, {user_name}! Регистрация завершена.")
|
||||
logger.info(f"✅ Простое приветствие отправлено для пользователя {user_telegram_id}")
|
||||
except:
|
||||
pass
|
||||
|
||||
logger.info(f"✅ Регистрация завершена для пользователя: {user_telegram_id}")
|
||||
|
||||
|
||||
def _get_subscription_status(user, texts):
|
||||
if not user or not hasattr(user, 'subscription'):
|
||||
return getattr(texts, 'SUBSCRIPTION_NONE', 'Нет активной подписки')
|
||||
|
||||
if not user.subscription:
|
||||
return getattr(texts, 'SUBSCRIPTION_NONE', 'Нет активной подписки')
|
||||
|
||||
subscription = user.subscription
|
||||
|
||||
from datetime import datetime
|
||||
current_time = datetime.utcnow()
|
||||
|
||||
if hasattr(subscription, 'end_date') and subscription.end_date <= current_time:
|
||||
return f"🔴 Истекла\n📅 {subscription.end_date.strftime('%d.%m.%Y')}"
|
||||
|
||||
if hasattr(subscription, 'end_date'):
|
||||
days_left = (subscription.end_date - current_time).days
|
||||
else:
|
||||
days_left = 0
|
||||
|
||||
is_trial = getattr(subscription, 'is_trial', False)
|
||||
|
||||
if is_trial:
|
||||
if days_left > 1:
|
||||
return f"🎁 Тестовая подписка\n📅 до {subscription.end_date.strftime('%d.%m.%Y')} ({days_left} дн.)"
|
||||
elif days_left == 1:
|
||||
return "🎁 Тестовая подписка\n⚠️ истекает завтра!"
|
||||
else:
|
||||
return "🎁 Тестовая подписка\n⚠️ истекает сегодня!"
|
||||
else:
|
||||
if days_left > 7:
|
||||
return f"💎 Активна\n📅 до {subscription.end_date.strftime('%d.%m.%Y')} ({days_left} дн.)"
|
||||
elif days_left > 1:
|
||||
return f"💎 Активна\n⚠️ истекает через {days_left} дн."
|
||||
elif days_left == 1:
|
||||
return "💎 Активна\n⚠️ истекает завтра!"
|
||||
else:
|
||||
return "💎 Активна\n⚠️ истекает сегодня!"
|
||||
|
||||
|
||||
|
||||
def _get_subscription_status_simple(texts):
|
||||
return getattr(texts, 'SUBSCRIPTION_NONE', 'Нет активной подписки')
|
||||
|
||||
|
||||
def get_referral_code_keyboard(language: str):
|
||||
from aiogram.types import InlineKeyboardMarkup, InlineKeyboardButton
|
||||
|
||||
texts = get_texts(language)
|
||||
return InlineKeyboardMarkup(inline_keyboard=[
|
||||
[InlineKeyboardButton(
|
||||
text="⭐️ Пропустить",
|
||||
callback_data="referral_skip"
|
||||
)]
|
||||
])
|
||||
|
||||
|
||||
def register_handlers(dp: Dispatcher):
|
||||
|
||||
logger.info("🔧 === НАЧАЛО регистрации обработчиков start.py ===")
|
||||
|
||||
dp.message.register(
|
||||
cmd_start,
|
||||
Command("start")
|
||||
)
|
||||
logger.info("✅ Зарегистрирован cmd_start")
|
||||
|
||||
dp.callback_query.register(
|
||||
process_rules_accept,
|
||||
F.data.in_(["rules_accept", "rules_decline"]),
|
||||
StateFilter(RegistrationStates.waiting_for_rules_accept)
|
||||
)
|
||||
logger.info("✅ Зарегистрирован process_rules_accept")
|
||||
|
||||
dp.callback_query.register(
|
||||
process_referral_code_skip,
|
||||
F.data == "referral_skip",
|
||||
StateFilter(RegistrationStates.waiting_for_referral_code)
|
||||
)
|
||||
logger.info("✅ Зарегистрирован process_referral_code_skip")
|
||||
|
||||
dp.message.register(
|
||||
process_referral_code_input,
|
||||
StateFilter(RegistrationStates.waiting_for_referral_code)
|
||||
)
|
||||
logger.info("✅ Зарегистрирован process_referral_code_input")
|
||||
|
||||
dp.message.register(
|
||||
handle_potential_referral_code,
|
||||
StateFilter(
|
||||
RegistrationStates.waiting_for_rules_accept,
|
||||
RegistrationStates.waiting_for_referral_code
|
||||
)
|
||||
)
|
||||
logger.info("✅ Зарегистрирован handle_potential_referral_code")
|
||||
|
||||
logger.info("🔧 === КОНЕЦ регистрации обработчиков start.py ===")
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,32 @@
|
||||
import logging
|
||||
from aiogram import Dispatcher, types, F
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.config import settings
|
||||
from app.database.models import User
|
||||
from app.keyboards.inline import get_support_keyboard
|
||||
from app.localization.texts import get_texts
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
async def show_support_info(
|
||||
callback: types.CallbackQuery,
|
||||
db_user: User
|
||||
):
|
||||
|
||||
texts = get_texts(db_user.language)
|
||||
|
||||
await callback.message.edit_text(
|
||||
texts.SUPPORT_INFO,
|
||||
reply_markup=get_support_keyboard(db_user.language)
|
||||
)
|
||||
await callback.answer()
|
||||
|
||||
|
||||
def register_handlers(dp: Dispatcher):
|
||||
|
||||
dp.callback_query.register(
|
||||
show_support_info,
|
||||
F.data == "menu_support"
|
||||
)
|
||||
@@ -0,0 +1,139 @@
|
||||
import logging
|
||||
from aiogram import types
|
||||
from aiohttp import web
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.config import settings
|
||||
from app.database.database import get_db
|
||||
from app.database.crud.user import get_user_by_id, add_user_balance
|
||||
from app.database.crud.transaction import create_transaction, get_transaction_by_external_id
|
||||
from app.database.models import TransactionType, PaymentMethod
|
||||
from app.external.tribute import TributeService
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
async def tribute_webhook(request):
|
||||
try:
|
||||
signature = request.headers.get('X-Signature', '')
|
||||
payload = await request.text()
|
||||
|
||||
tribute_service = TributeService()
|
||||
|
||||
if not tribute_service.verify_webhook_signature(payload, signature):
|
||||
logger.warning("Неверная подпись Tribute webhook")
|
||||
return web.Response(status=400, text="Invalid signature")
|
||||
|
||||
webhook_data = await request.json()
|
||||
processed_data = await tribute_service.process_webhook(webhook_data)
|
||||
|
||||
if not processed_data:
|
||||
logger.error("Ошибка обработки Tribute webhook")
|
||||
return web.Response(status=400, text="Invalid webhook data")
|
||||
|
||||
async for db in get_db():
|
||||
try:
|
||||
existing_transaction = await get_transaction_by_external_id(
|
||||
db, processed_data['payment_id'], PaymentMethod.TRIBUTE
|
||||
)
|
||||
|
||||
if existing_transaction:
|
||||
logger.info(f"Платеж {processed_data['payment_id']} уже обработан")
|
||||
return web.Response(status=200, text="Already processed")
|
||||
|
||||
if processed_data['status'] == 'completed':
|
||||
user = await get_user_by_id(db, processed_data['user_id'])
|
||||
|
||||
if user:
|
||||
await add_user_balance(
|
||||
db, user, processed_data['amount_kopeks'],
|
||||
f"Пополнение через Tribute: {processed_data['payment_id']}"
|
||||
)
|
||||
|
||||
await create_transaction(
|
||||
db=db,
|
||||
user_id=user.id,
|
||||
type=TransactionType.DEPOSIT,
|
||||
amount_kopeks=processed_data['amount_kopeks'],
|
||||
description=f"Пополнение через Tribute",
|
||||
payment_method=PaymentMethod.TRIBUTE,
|
||||
external_id=processed_data['payment_id']
|
||||
)
|
||||
|
||||
logger.info(f"✅ Обработан Tribute платеж: {processed_data['payment_id']}")
|
||||
|
||||
return web.Response(status=200, text="OK")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка обработки Tribute webhook: {e}")
|
||||
await db.rollback()
|
||||
return web.Response(status=500, text="Internal error")
|
||||
finally:
|
||||
break
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка в Tribute webhook: {e}")
|
||||
return web.Response(status=500, text="Internal error")
|
||||
|
||||
|
||||
async def handle_successful_payment(message: types.Message):
|
||||
try:
|
||||
payment = message.successful_payment
|
||||
|
||||
payload_parts = payment.invoice_payload.split('_')
|
||||
if len(payload_parts) >= 3 and payload_parts[0] == 'balance':
|
||||
user_id = int(payload_parts[1])
|
||||
amount_kopeks = int(payload_parts[2])
|
||||
|
||||
async for db in get_db():
|
||||
try:
|
||||
existing_transaction = await get_transaction_by_external_id(
|
||||
db, payment.telegram_payment_charge_id, PaymentMethod.TELEGRAM_STARS
|
||||
)
|
||||
|
||||
if existing_transaction:
|
||||
logger.info(f"Stars платеж {payment.telegram_payment_charge_id} уже обработан")
|
||||
return
|
||||
|
||||
user = await get_user_by_id(db, user_id)
|
||||
|
||||
if user:
|
||||
await add_user_balance(
|
||||
db, user, amount_kopeks,
|
||||
f"Пополнение через Telegram Stars"
|
||||
)
|
||||
|
||||
await create_transaction(
|
||||
db=db,
|
||||
user_id=user.id,
|
||||
type=TransactionType.DEPOSIT,
|
||||
amount_kopeks=amount_kopeks,
|
||||
description=f"Пополнение через Telegram Stars",
|
||||
payment_method=PaymentMethod.TELEGRAM_STARS,
|
||||
external_id=payment.telegram_payment_charge_id
|
||||
)
|
||||
|
||||
await message.answer(
|
||||
f"✅ Баланс успешно пополнен на {settings.format_price(amount_kopeks)}!"
|
||||
)
|
||||
|
||||
logger.info(f"✅ Обработан Stars платеж: {payment.telegram_payment_charge_id}")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка обработки Stars платежа: {e}")
|
||||
await db.rollback()
|
||||
finally:
|
||||
break
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка в обработчике Stars платежа: {e}")
|
||||
|
||||
|
||||
async def handle_pre_checkout_query(pre_checkout_query: types.PreCheckoutQuery):
|
||||
try:
|
||||
await pre_checkout_query.answer(ok=True)
|
||||
logger.info(f"Pre-checkout query принят: {pre_checkout_query.id}")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка в pre-checkout query: {e}")
|
||||
await pre_checkout_query.answer(ok=False, error_message="Ошибка обработки платежа")
|
||||
@@ -0,0 +1,611 @@
|
||||
from typing import List, Optional
|
||||
from aiogram.types import InlineKeyboardMarkup, InlineKeyboardButton
|
||||
|
||||
from app.localization.texts import get_texts
|
||||
|
||||
|
||||
def get_admin_main_keyboard(language: str = "ru") -> InlineKeyboardMarkup:
|
||||
texts = get_texts(language)
|
||||
|
||||
return InlineKeyboardMarkup(inline_keyboard=[
|
||||
[
|
||||
InlineKeyboardButton(text=texts.ADMIN_USERS, callback_data="admin_users"),
|
||||
InlineKeyboardButton(text=texts.ADMIN_SUBSCRIPTIONS, callback_data="admin_subscriptions")
|
||||
],
|
||||
[
|
||||
InlineKeyboardButton(text=texts.ADMIN_PROMOCODES, callback_data="admin_promocodes"),
|
||||
InlineKeyboardButton(text=texts.ADMIN_MESSAGES, callback_data="admin_messages")
|
||||
],
|
||||
[
|
||||
InlineKeyboardButton(text=texts.ADMIN_MONITORING, callback_data="admin_monitoring"),
|
||||
InlineKeyboardButton(text=texts.ADMIN_REFERRALS, callback_data="admin_referrals")
|
||||
],
|
||||
[
|
||||
InlineKeyboardButton(text=texts.ADMIN_RULES, callback_data="admin_rules"),
|
||||
InlineKeyboardButton(text=texts.ADMIN_REMNAWAVE, callback_data="admin_remnawave")
|
||||
],
|
||||
[
|
||||
InlineKeyboardButton(text=texts.ADMIN_STATISTICS, callback_data="admin_statistics"),
|
||||
InlineKeyboardButton(text="🔧 Техработы", callback_data="maintenance_panel")
|
||||
],
|
||||
[
|
||||
InlineKeyboardButton(text=texts.BACK, callback_data="back_to_menu")
|
||||
]
|
||||
])
|
||||
|
||||
|
||||
def get_admin_users_keyboard(language: str = "ru") -> InlineKeyboardMarkup:
|
||||
return InlineKeyboardMarkup(inline_keyboard=[
|
||||
[
|
||||
InlineKeyboardButton(text="👥 Все пользователи", callback_data="admin_users_list"),
|
||||
InlineKeyboardButton(text="🔍 Поиск", callback_data="admin_users_search")
|
||||
],
|
||||
[
|
||||
InlineKeyboardButton(text="📊 Статистика", callback_data="admin_users_stats"),
|
||||
InlineKeyboardButton(text="🗑️ Неактивные", callback_data="admin_users_inactive")
|
||||
],
|
||||
[
|
||||
InlineKeyboardButton(text="⬅️ Назад", callback_data="admin_panel")
|
||||
]
|
||||
])
|
||||
|
||||
|
||||
def get_admin_subscriptions_keyboard(language: str = "ru") -> InlineKeyboardMarkup:
|
||||
return InlineKeyboardMarkup(inline_keyboard=[
|
||||
[
|
||||
InlineKeyboardButton(text="📱 Все подписки", callback_data="admin_subs_list"),
|
||||
InlineKeyboardButton(text="⏰ Истекающие", callback_data="admin_subs_expiring")
|
||||
],
|
||||
[
|
||||
InlineKeyboardButton(text="⚙️ Настройки цен", callback_data="admin_subs_pricing"),
|
||||
InlineKeyboardButton(text="🌍 Управление странами", callback_data="admin_subs_countries")
|
||||
],
|
||||
[
|
||||
InlineKeyboardButton(text="📊 Статистика", callback_data="admin_subs_stats")
|
||||
],
|
||||
[
|
||||
InlineKeyboardButton(text="⬅️ Назад", callback_data="admin_panel")
|
||||
]
|
||||
])
|
||||
|
||||
|
||||
def get_admin_promocodes_keyboard(language: str = "ru") -> InlineKeyboardMarkup:
|
||||
return InlineKeyboardMarkup(inline_keyboard=[
|
||||
[
|
||||
InlineKeyboardButton(text="🎫 Все промокоды", callback_data="admin_promo_list"),
|
||||
InlineKeyboardButton(text="➕ Создать", callback_data="admin_promo_create")
|
||||
],
|
||||
[
|
||||
InlineKeyboardButton(text="📊 Общая статистика", callback_data="admin_promo_general_stats")
|
||||
],
|
||||
[
|
||||
InlineKeyboardButton(text="⬅️ Назад", callback_data="admin_panel")
|
||||
]
|
||||
])
|
||||
|
||||
|
||||
def get_promocode_management_keyboard(promo_id: int, language: str = "ru") -> InlineKeyboardMarkup:
|
||||
return InlineKeyboardMarkup(inline_keyboard=[
|
||||
[
|
||||
InlineKeyboardButton(text="✏️ Редактировать", callback_data=f"promo_edit_{promo_id}"),
|
||||
InlineKeyboardButton(text="🔄 Статус", callback_data=f"promo_toggle_{promo_id}")
|
||||
],
|
||||
[
|
||||
InlineKeyboardButton(text="📊 Статистика", callback_data=f"promo_stats_{promo_id}"),
|
||||
InlineKeyboardButton(text="🗑️ Удалить", callback_data=f"promo_delete_{promo_id}")
|
||||
],
|
||||
[
|
||||
InlineKeyboardButton(text="⬅️ К списку", callback_data="admin_promo_list")
|
||||
]
|
||||
])
|
||||
|
||||
|
||||
def get_admin_messages_keyboard(language: str = "ru") -> InlineKeyboardMarkup:
|
||||
return InlineKeyboardMarkup(inline_keyboard=[
|
||||
[
|
||||
InlineKeyboardButton(text="📨 Всем пользователям", callback_data="admin_msg_all"),
|
||||
InlineKeyboardButton(text="🎯 По подпискам", callback_data="admin_msg_by_sub")
|
||||
],
|
||||
[
|
||||
InlineKeyboardButton(text="🔍 По критериям", callback_data="admin_msg_custom"),
|
||||
InlineKeyboardButton(text="📋 История", callback_data="admin_msg_history")
|
||||
],
|
||||
[
|
||||
InlineKeyboardButton(text="⬅️ Назад", callback_data="admin_panel")
|
||||
]
|
||||
])
|
||||
|
||||
|
||||
def get_admin_monitoring_keyboard(language: str = "ru") -> InlineKeyboardMarkup:
|
||||
return InlineKeyboardMarkup(inline_keyboard=[
|
||||
[
|
||||
InlineKeyboardButton(text="▶️ Запустить", callback_data="admin_mon_start"),
|
||||
InlineKeyboardButton(text="⏸️ Остановить", callback_data="admin_mon_stop")
|
||||
],
|
||||
[
|
||||
InlineKeyboardButton(text="📊 Статус", callback_data="admin_mon_status"),
|
||||
InlineKeyboardButton(text="📋 Логи", callback_data="admin_mon_logs")
|
||||
],
|
||||
[
|
||||
InlineKeyboardButton(text="⚙️ Настройки", callback_data="admin_mon_settings")
|
||||
],
|
||||
[
|
||||
InlineKeyboardButton(text="⬅️ Назад", callback_data="admin_panel")
|
||||
]
|
||||
])
|
||||
|
||||
|
||||
def get_admin_remnawave_keyboard(language: str = "ru") -> InlineKeyboardMarkup:
|
||||
return InlineKeyboardMarkup(inline_keyboard=[
|
||||
[
|
||||
InlineKeyboardButton(text="📊 Системная статистика", callback_data="admin_rw_system"),
|
||||
InlineKeyboardButton(text="🖥️ Управление нодами", callback_data="admin_rw_nodes")
|
||||
],
|
||||
[
|
||||
InlineKeyboardButton(text="🔄 Синхронизация", callback_data="admin_rw_sync"),
|
||||
InlineKeyboardButton(text="🌍 Управление сквадами", callback_data="admin_rw_squads")
|
||||
],
|
||||
[
|
||||
InlineKeyboardButton(text="📈 Трафик", callback_data="admin_rw_traffic")
|
||||
],
|
||||
[
|
||||
InlineKeyboardButton(text="⬅️ Назад", callback_data="admin_panel")
|
||||
]
|
||||
])
|
||||
|
||||
|
||||
def get_admin_statistics_keyboard(language: str = "ru") -> InlineKeyboardMarkup:
|
||||
return InlineKeyboardMarkup(inline_keyboard=[
|
||||
[
|
||||
InlineKeyboardButton(text="👥 Пользователи", callback_data="admin_stats_users"),
|
||||
InlineKeyboardButton(text="📱 Подписки", callback_data="admin_stats_subs")
|
||||
],
|
||||
[
|
||||
InlineKeyboardButton(text="💰 Доходы", callback_data="admin_stats_revenue"),
|
||||
InlineKeyboardButton(text="🤝 Рефералы", callback_data="admin_stats_referrals")
|
||||
],
|
||||
[
|
||||
InlineKeyboardButton(text="📊 Общая сводка", callback_data="admin_stats_summary")
|
||||
],
|
||||
[
|
||||
InlineKeyboardButton(text="⬅️ Назад", callback_data="admin_panel")
|
||||
]
|
||||
])
|
||||
|
||||
|
||||
def get_user_management_keyboard(user_id: int, user_status: str, language: str = "ru") -> InlineKeyboardMarkup:
|
||||
keyboard = [
|
||||
[
|
||||
InlineKeyboardButton(text="💰 Баланс", callback_data=f"admin_user_balance_{user_id}"),
|
||||
InlineKeyboardButton(text="📱 Подписка", callback_data=f"admin_user_subscription_{user_id}")
|
||||
],
|
||||
[
|
||||
InlineKeyboardButton(text="⚙️ Настройка", callback_data=f"admin_user_servers_{user_id}"),
|
||||
InlineKeyboardButton(text="📊 Статистика", callback_data=f"admin_user_statistics_{user_id}")
|
||||
],
|
||||
[
|
||||
InlineKeyboardButton(text="📋 Транзакции", callback_data=f"admin_user_transactions_{user_id}")
|
||||
]
|
||||
]
|
||||
|
||||
if user_status == "active":
|
||||
keyboard.append([
|
||||
InlineKeyboardButton(text="🚫 Заблокировать", callback_data=f"admin_user_block_{user_id}"),
|
||||
InlineKeyboardButton(text="🗑️ Удалить", callback_data=f"admin_user_delete_{user_id}")
|
||||
])
|
||||
elif user_status == "blocked":
|
||||
keyboard.append([
|
||||
InlineKeyboardButton(text="✅ Разблокировать", callback_data=f"admin_user_unblock_{user_id}"),
|
||||
InlineKeyboardButton(text="🗑️ Удалить", callback_data=f"admin_user_delete_{user_id}")
|
||||
])
|
||||
elif user_status == "deleted":
|
||||
keyboard.append([
|
||||
InlineKeyboardButton(text="❌ Пользователь удален", callback_data="noop")
|
||||
])
|
||||
|
||||
keyboard.append([
|
||||
InlineKeyboardButton(text="⬅️ Назад", callback_data="admin_users_list")
|
||||
])
|
||||
|
||||
return InlineKeyboardMarkup(inline_keyboard=keyboard)
|
||||
|
||||
|
||||
def get_confirmation_keyboard(
|
||||
confirm_action: str,
|
||||
cancel_action: str = "admin_panel",
|
||||
language: str = "ru"
|
||||
) -> InlineKeyboardMarkup:
|
||||
texts = get_texts(language)
|
||||
|
||||
return InlineKeyboardMarkup(inline_keyboard=[
|
||||
[
|
||||
InlineKeyboardButton(text=texts.YES, callback_data=confirm_action),
|
||||
InlineKeyboardButton(text=texts.NO, callback_data=cancel_action)
|
||||
]
|
||||
])
|
||||
|
||||
|
||||
def get_promocode_type_keyboard(language: str = "ru") -> InlineKeyboardMarkup:
|
||||
return InlineKeyboardMarkup(inline_keyboard=[
|
||||
[
|
||||
InlineKeyboardButton(text="💰 Баланс", callback_data="promo_type_balance"),
|
||||
InlineKeyboardButton(text="📅 Дни подписки", callback_data="promo_type_days")
|
||||
],
|
||||
[
|
||||
InlineKeyboardButton(text="🎁 Триал", callback_data="promo_type_trial")
|
||||
],
|
||||
[
|
||||
InlineKeyboardButton(text="⬅️ Назад", callback_data="admin_promocodes")
|
||||
]
|
||||
])
|
||||
|
||||
|
||||
def get_promocode_list_keyboard(promocodes: list, page: int, total_pages: int, language: str = "ru") -> InlineKeyboardMarkup:
|
||||
keyboard = []
|
||||
|
||||
for promo in promocodes:
|
||||
status_emoji = "✅" if promo.is_active else "❌"
|
||||
type_emoji = {"balance": "💰", "subscription_days": "📅", "trial_subscription": "🎁"}.get(promo.type, "🎫")
|
||||
|
||||
keyboard.append([
|
||||
InlineKeyboardButton(
|
||||
text=f"{status_emoji} {type_emoji} {promo.code}",
|
||||
callback_data=f"promo_manage_{promo.id}"
|
||||
)
|
||||
])
|
||||
|
||||
if total_pages > 1:
|
||||
pagination_row = []
|
||||
|
||||
if page > 1:
|
||||
pagination_row.append(
|
||||
InlineKeyboardButton(text="⬅️", callback_data=f"admin_promo_list_page_{page - 1}")
|
||||
)
|
||||
|
||||
pagination_row.append(
|
||||
InlineKeyboardButton(text=f"{page}/{total_pages}", callback_data="current_page")
|
||||
)
|
||||
|
||||
if page < total_pages:
|
||||
pagination_row.append(
|
||||
InlineKeyboardButton(text="➡️", callback_data=f"admin_promo_list_page_{page + 1}")
|
||||
)
|
||||
|
||||
keyboard.append(pagination_row)
|
||||
|
||||
keyboard.extend([
|
||||
[InlineKeyboardButton(text="➕ Создать", callback_data="admin_promo_create")],
|
||||
[InlineKeyboardButton(text="⬅️ Назад", callback_data="admin_promocodes")]
|
||||
])
|
||||
|
||||
return InlineKeyboardMarkup(inline_keyboard=keyboard)
|
||||
|
||||
|
||||
def get_broadcast_target_keyboard(language: str = "ru") -> InlineKeyboardMarkup:
|
||||
return InlineKeyboardMarkup(inline_keyboard=[
|
||||
[
|
||||
InlineKeyboardButton(text="👥 Всем", callback_data="broadcast_all"),
|
||||
InlineKeyboardButton(text="📱 С подпиской", callback_data="broadcast_active")
|
||||
],
|
||||
[
|
||||
InlineKeyboardButton(text="🎁 Триал", callback_data="broadcast_trial"),
|
||||
InlineKeyboardButton(text="❌ Без подписки", callback_data="broadcast_no_sub")
|
||||
],
|
||||
[
|
||||
InlineKeyboardButton(text="⏰ Истекающие", callback_data="broadcast_expiring")
|
||||
],
|
||||
[
|
||||
InlineKeyboardButton(text="⬅️ Назад", callback_data="admin_messages")
|
||||
]
|
||||
])
|
||||
|
||||
|
||||
def get_custom_criteria_keyboard(language: str = "ru") -> InlineKeyboardMarkup:
|
||||
return InlineKeyboardMarkup(inline_keyboard=[
|
||||
[
|
||||
InlineKeyboardButton(text="📅 Сегодня", callback_data="criteria_today"),
|
||||
InlineKeyboardButton(text="📅 За неделю", callback_data="criteria_week")
|
||||
],
|
||||
[
|
||||
InlineKeyboardButton(text="📅 За месяц", callback_data="criteria_month"),
|
||||
InlineKeyboardButton(text="⚡ Активные сегодня", callback_data="criteria_active_today")
|
||||
],
|
||||
[
|
||||
InlineKeyboardButton(text="👤 Неактивные 7+ дней", callback_data="criteria_inactive_week"),
|
||||
InlineKeyboardButton(text="👤 Неактивные 30+ дней", callback_data="criteria_inactive_month")
|
||||
],
|
||||
[
|
||||
InlineKeyboardButton(text="🤝 Через рефералов", callback_data="criteria_referrals"),
|
||||
InlineKeyboardButton(text="🎫 Использовали промокоды", callback_data="criteria_promocodes")
|
||||
],
|
||||
[
|
||||
InlineKeyboardButton(text="🎯 Прямая регистрация", callback_data="criteria_direct")
|
||||
],
|
||||
[
|
||||
InlineKeyboardButton(text="⬅️ Назад", callback_data="admin_messages")
|
||||
]
|
||||
])
|
||||
|
||||
|
||||
def get_broadcast_history_keyboard(page: int, total_pages: int, language: str = "ru") -> InlineKeyboardMarkup:
|
||||
keyboard = []
|
||||
|
||||
if total_pages > 1:
|
||||
pagination_row = []
|
||||
|
||||
if page > 1:
|
||||
pagination_row.append(
|
||||
InlineKeyboardButton(text="⬅️", callback_data=f"admin_msg_history_page_{page - 1}")
|
||||
)
|
||||
|
||||
pagination_row.append(
|
||||
InlineKeyboardButton(text=f"{page}/{total_pages}", callback_data="current_page")
|
||||
)
|
||||
|
||||
if page < total_pages:
|
||||
pagination_row.append(
|
||||
InlineKeyboardButton(text="➡️", callback_data=f"admin_msg_history_page_{page + 1}")
|
||||
)
|
||||
|
||||
keyboard.append(pagination_row)
|
||||
|
||||
keyboard.extend([
|
||||
[InlineKeyboardButton(text="🔄 Обновить", callback_data="admin_msg_history")],
|
||||
[InlineKeyboardButton(text="⬅️ Назад", callback_data="admin_messages")]
|
||||
])
|
||||
|
||||
return InlineKeyboardMarkup(inline_keyboard=keyboard)
|
||||
|
||||
def get_sync_options_keyboard(language: str = "ru") -> InlineKeyboardMarkup:
|
||||
keyboard = [
|
||||
[InlineKeyboardButton(text="🔄 Полная синхронизация", callback_data="sync_all_users")],
|
||||
[InlineKeyboardButton(text="🆕 Только новые", callback_data="sync_new_users")],
|
||||
[InlineKeyboardButton(text="📈 Обновить данные", callback_data="sync_update_data")],
|
||||
[
|
||||
InlineKeyboardButton(text="🔍 Валидация", callback_data="sync_validate"),
|
||||
InlineKeyboardButton(text="🧹 Очистка", callback_data="sync_cleanup")
|
||||
],
|
||||
[InlineKeyboardButton(text="💡 Рекомендации", callback_data="sync_recommendations")],
|
||||
[InlineKeyboardButton(text="⬅️ Назад", callback_data="admin_remnawave")]
|
||||
]
|
||||
|
||||
return InlineKeyboardMarkup(inline_keyboard=keyboard)
|
||||
|
||||
def get_sync_confirmation_keyboard(sync_type: str, language: str = "ru") -> InlineKeyboardMarkup:
|
||||
keyboard = [
|
||||
[InlineKeyboardButton(text="✅ Подтвердить", callback_data=f"confirm_{sync_type}")],
|
||||
[InlineKeyboardButton(text="❌ Отмена", callback_data="admin_rw_sync")]
|
||||
]
|
||||
|
||||
return InlineKeyboardMarkup(inline_keyboard=keyboard)
|
||||
|
||||
def get_sync_result_keyboard(sync_type: str, has_errors: bool = False, language: str = "ru") -> InlineKeyboardMarkup:
|
||||
keyboard = []
|
||||
|
||||
if has_errors:
|
||||
keyboard.append([
|
||||
InlineKeyboardButton(text="🔄 Повторить", callback_data=f"sync_{sync_type}")
|
||||
])
|
||||
|
||||
if sync_type != "all_users":
|
||||
keyboard.append([
|
||||
InlineKeyboardButton(text="🔄 Полная синхронизация", callback_data="sync_all_users")
|
||||
])
|
||||
|
||||
keyboard.extend([
|
||||
[
|
||||
InlineKeyboardButton(text="📊 Статистика", callback_data="admin_rw_system"),
|
||||
InlineKeyboardButton(text="🔍 Валидация", callback_data="sync_validate")
|
||||
],
|
||||
[InlineKeyboardButton(text="⬅️ К синхронизации", callback_data="admin_rw_sync")],
|
||||
[InlineKeyboardButton(text="🏠 В главное меню", callback_data="admin_remnawave")]
|
||||
])
|
||||
|
||||
return InlineKeyboardMarkup(inline_keyboard=keyboard)
|
||||
|
||||
|
||||
|
||||
def get_period_selection_keyboard(language: str = "ru") -> InlineKeyboardMarkup:
|
||||
return InlineKeyboardMarkup(inline_keyboard=[
|
||||
[
|
||||
InlineKeyboardButton(text="📅 Сегодня", callback_data="period_today"),
|
||||
InlineKeyboardButton(text="📅 Вчера", callback_data="period_yesterday")
|
||||
],
|
||||
[
|
||||
InlineKeyboardButton(text="📅 Неделя", callback_data="period_week"),
|
||||
InlineKeyboardButton(text="📅 Месяц", callback_data="period_month")
|
||||
],
|
||||
[
|
||||
InlineKeyboardButton(text="📅 Все время", callback_data="period_all")
|
||||
],
|
||||
[
|
||||
InlineKeyboardButton(text="⬅️ Назад", callback_data="admin_statistics")
|
||||
]
|
||||
])
|
||||
|
||||
|
||||
def get_node_management_keyboard(node_uuid: str, language: str = "ru") -> InlineKeyboardMarkup:
|
||||
return InlineKeyboardMarkup(inline_keyboard=[
|
||||
[
|
||||
InlineKeyboardButton(text="▶️ Включить", callback_data=f"node_enable_{node_uuid}"),
|
||||
InlineKeyboardButton(text="⏸️ Отключить", callback_data=f"node_disable_{node_uuid}")
|
||||
],
|
||||
[
|
||||
InlineKeyboardButton(text="🔄 Перезагрузить", callback_data=f"node_restart_{node_uuid}"),
|
||||
InlineKeyboardButton(text="📊 Статистика", callback_data=f"node_stats_{node_uuid}")
|
||||
],
|
||||
[
|
||||
InlineKeyboardButton(text="⬅️ Назад", callback_data="admin_rw_nodes")
|
||||
]
|
||||
])
|
||||
|
||||
def get_squad_management_keyboard(squad_uuid: str, language: str = "ru") -> InlineKeyboardMarkup:
|
||||
return InlineKeyboardMarkup(inline_keyboard=[
|
||||
[
|
||||
InlineKeyboardButton(text="👥 Добавить всех пользователей", callback_data=f"squad_add_users_{squad_uuid}"),
|
||||
],
|
||||
[
|
||||
InlineKeyboardButton(text="❌ Удалить всех пользователей", callback_data=f"squad_remove_users_{squad_uuid}"),
|
||||
],
|
||||
[
|
||||
InlineKeyboardButton(text="✏️ Редактировать", callback_data=f"squad_edit_{squad_uuid}"),
|
||||
InlineKeyboardButton(text="🗑️ Удалить сквад", callback_data=f"squad_delete_{squad_uuid}")
|
||||
],
|
||||
[
|
||||
InlineKeyboardButton(text="⬅️ Назад", callback_data="admin_rw_squads")
|
||||
]
|
||||
])
|
||||
|
||||
def get_squad_edit_keyboard(squad_uuid: str, language: str = "ru") -> InlineKeyboardMarkup:
|
||||
return InlineKeyboardMarkup(inline_keyboard=[
|
||||
[
|
||||
InlineKeyboardButton(text="🔧 Изменить инбаунды", callback_data=f"squad_edit_inbounds_{squad_uuid}"),
|
||||
],
|
||||
[
|
||||
InlineKeyboardButton(text="✏️ Переименовать", callback_data=f"squad_rename_{squad_uuid}"),
|
||||
],
|
||||
[
|
||||
InlineKeyboardButton(text="⬅️ Назад к сквадам", callback_data=f"admin_squad_manage_{squad_uuid}")
|
||||
]
|
||||
])
|
||||
|
||||
def get_monitoring_keyboard() -> InlineKeyboardMarkup:
|
||||
return InlineKeyboardMarkup(inline_keyboard=[
|
||||
[
|
||||
InlineKeyboardButton(text="▶️ Запустить", callback_data="admin_mon_start"),
|
||||
InlineKeyboardButton(text="⏹️ Остановить", callback_data="admin_mon_stop")
|
||||
],
|
||||
[
|
||||
InlineKeyboardButton(text="🔄 Принудительная проверка", callback_data="admin_mon_force_check"),
|
||||
InlineKeyboardButton(text="📝 Логи", callback_data="admin_mon_logs")
|
||||
],
|
||||
[
|
||||
InlineKeyboardButton(text="🧪 Тест уведомлений", callback_data="admin_mon_test_notifications"),
|
||||
InlineKeyboardButton(text="📊 Статистика", callback_data="admin_mon_statistics")
|
||||
],
|
||||
[
|
||||
InlineKeyboardButton(text="⬅️ Назад в админку", callback_data="admin_panel")
|
||||
]
|
||||
])
|
||||
|
||||
def get_monitoring_logs_keyboard() -> InlineKeyboardMarkup:
|
||||
return InlineKeyboardMarkup(inline_keyboard=[
|
||||
[
|
||||
InlineKeyboardButton(text="🔄 Обновить", callback_data="admin_mon_logs"),
|
||||
InlineKeyboardButton(text="🗑️ Очистить старые", callback_data="admin_mon_clear_logs")
|
||||
],
|
||||
[
|
||||
InlineKeyboardButton(text="⬅️ Назад", callback_data="admin_monitoring")
|
||||
]
|
||||
])
|
||||
|
||||
def get_admin_servers_keyboard(language: str = "ru") -> InlineKeyboardMarkup:
|
||||
|
||||
return InlineKeyboardMarkup(inline_keyboard=[
|
||||
[
|
||||
InlineKeyboardButton(text="📋 Список серверов", callback_data="admin_servers_list"),
|
||||
InlineKeyboardButton(text="🔄 Синхронизация", callback_data="admin_servers_sync")
|
||||
],
|
||||
[
|
||||
InlineKeyboardButton(text="➕ Добавить сервер", callback_data="admin_servers_add"),
|
||||
InlineKeyboardButton(text="📊 Статистика", callback_data="admin_servers_stats")
|
||||
],
|
||||
[
|
||||
InlineKeyboardButton(text="⬅️ Назад", callback_data="admin_subscriptions")
|
||||
]
|
||||
])
|
||||
|
||||
|
||||
def get_server_edit_keyboard(server_id: int, is_available: bool, language: str = "ru") -> InlineKeyboardMarkup:
|
||||
|
||||
return InlineKeyboardMarkup(inline_keyboard=[
|
||||
[
|
||||
InlineKeyboardButton(text="✏️ Название", callback_data=f"admin_server_edit_name_{server_id}"),
|
||||
InlineKeyboardButton(text="💰 Цена", callback_data=f"admin_server_edit_price_{server_id}")
|
||||
],
|
||||
[
|
||||
InlineKeyboardButton(text="🌍 Страна", callback_data=f"admin_server_edit_country_{server_id}"),
|
||||
InlineKeyboardButton(text="👥 Лимит", callback_data=f"admin_server_edit_limit_{server_id}")
|
||||
],
|
||||
[
|
||||
InlineKeyboardButton(text="📝 Описание", callback_data=f"admin_server_edit_desc_{server_id}")
|
||||
],
|
||||
[
|
||||
InlineKeyboardButton(
|
||||
text="❌ Отключить" if is_available else "✅ Включить",
|
||||
callback_data=f"admin_server_toggle_{server_id}"
|
||||
)
|
||||
],
|
||||
[
|
||||
InlineKeyboardButton(text="🗑️ Удалить", callback_data=f"admin_server_delete_{server_id}"),
|
||||
InlineKeyboardButton(text="⬅️ Назад", callback_data="admin_servers_list")
|
||||
]
|
||||
])
|
||||
|
||||
|
||||
def get_admin_pagination_keyboard(
|
||||
current_page: int,
|
||||
total_pages: int,
|
||||
callback_prefix: str,
|
||||
back_callback: str = "admin_panel",
|
||||
language: str = "ru"
|
||||
) -> InlineKeyboardMarkup:
|
||||
keyboard = []
|
||||
|
||||
if total_pages > 1:
|
||||
row = []
|
||||
|
||||
if current_page > 1:
|
||||
row.append(InlineKeyboardButton(
|
||||
text="⬅️",
|
||||
callback_data=f"{callback_prefix}_page_{current_page - 1}"
|
||||
))
|
||||
|
||||
row.append(InlineKeyboardButton(
|
||||
text=f"{current_page}/{total_pages}",
|
||||
callback_data="current_page"
|
||||
))
|
||||
|
||||
if current_page < total_pages:
|
||||
row.append(InlineKeyboardButton(
|
||||
text="➡️",
|
||||
callback_data=f"{callback_prefix}_page_{current_page + 1}"
|
||||
))
|
||||
|
||||
keyboard.append(row)
|
||||
|
||||
keyboard.append([
|
||||
InlineKeyboardButton(text="⬅️ Назад", callback_data=back_callback)
|
||||
])
|
||||
|
||||
return InlineKeyboardMarkup(inline_keyboard=keyboard)
|
||||
|
||||
def get_maintenance_keyboard(language: str = "ru", is_active: bool = False, monitoring_active: bool = False) -> InlineKeyboardMarkup:
|
||||
|
||||
if language == "en":
|
||||
toggle_text = "🔴 Disable maintenance" if is_active else "🔧 Enable maintenance"
|
||||
monitoring_text = "⏹️ Stop monitoring" if monitoring_active else "🔄 Start monitoring"
|
||||
check_api_text = "🔍 Check API"
|
||||
back_text = "⬅️ Back to admin"
|
||||
else:
|
||||
toggle_text = "🔴 Выключить техработы" if is_active else "🔧 Включить техработы"
|
||||
monitoring_text = "⏹️ Остановить мониторинг" if monitoring_active else "🔄 Запустить мониторинг"
|
||||
check_api_text = "🔍 Проверить API"
|
||||
back_text = "⬅️ Назад в админку"
|
||||
|
||||
keyboard = [
|
||||
[InlineKeyboardButton(text=toggle_text, callback_data="maintenance_toggle")],
|
||||
[InlineKeyboardButton(text=monitoring_text, callback_data="maintenance_monitoring")],
|
||||
[InlineKeyboardButton(text=check_api_text, callback_data="maintenance_check_api")],
|
||||
[InlineKeyboardButton(text=back_text, callback_data="admin_panel")]
|
||||
]
|
||||
|
||||
return InlineKeyboardMarkup(inline_keyboard=keyboard)
|
||||
|
||||
def get_sync_simplified_keyboard(language: str = "ru") -> InlineKeyboardMarkup:
|
||||
keyboard = [
|
||||
[InlineKeyboardButton(text="🔄 Полная синхронизация", callback_data="sync_all_users")],
|
||||
[InlineKeyboardButton(text="⬅️ Назад", callback_data="admin_remnawave")]
|
||||
]
|
||||
|
||||
return InlineKeyboardMarkup(inline_keyboard=keyboard)
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,116 @@
|
||||
from aiogram.types import ReplyKeyboardMarkup, KeyboardButton, ReplyKeyboardRemove
|
||||
from typing import List
|
||||
|
||||
from app.localization.texts import get_texts
|
||||
|
||||
|
||||
def get_main_reply_keyboard(language: str = "ru") -> ReplyKeyboardMarkup:
|
||||
texts = get_texts(language)
|
||||
|
||||
return ReplyKeyboardMarkup(
|
||||
keyboard=[
|
||||
[
|
||||
KeyboardButton(text=texts.MENU_BALANCE),
|
||||
KeyboardButton(text=texts.MENU_SUBSCRIPTION)
|
||||
],
|
||||
[
|
||||
KeyboardButton(text=texts.MENU_PROMOCODE),
|
||||
KeyboardButton(text=texts.MENU_REFERRALS)
|
||||
],
|
||||
[
|
||||
KeyboardButton(text=texts.MENU_SUPPORT),
|
||||
KeyboardButton(text=texts.MENU_RULES)
|
||||
]
|
||||
],
|
||||
resize_keyboard=True,
|
||||
one_time_keyboard=False
|
||||
)
|
||||
|
||||
|
||||
def get_admin_reply_keyboard(language: str = "ru") -> ReplyKeyboardMarkup:
|
||||
texts = get_texts(language)
|
||||
|
||||
return ReplyKeyboardMarkup(
|
||||
keyboard=[
|
||||
[
|
||||
KeyboardButton(text="👥 Пользователи"),
|
||||
KeyboardButton(text="📱 Подписки")
|
||||
],
|
||||
[
|
||||
KeyboardButton(text="🎫 Промокоды"),
|
||||
KeyboardButton(text="📨 Рассылки")
|
||||
],
|
||||
[
|
||||
KeyboardButton(text="📊 Статистика"),
|
||||
KeyboardButton(text="🔧 Мониторинг")
|
||||
],
|
||||
[
|
||||
KeyboardButton(text="🏠 Главное меню")
|
||||
]
|
||||
],
|
||||
resize_keyboard=True,
|
||||
one_time_keyboard=False
|
||||
)
|
||||
|
||||
|
||||
def get_cancel_keyboard(language: str = "ru") -> ReplyKeyboardMarkup:
|
||||
texts = get_texts(language)
|
||||
|
||||
return ReplyKeyboardMarkup(
|
||||
keyboard=[
|
||||
[KeyboardButton(text=texts.CANCEL)]
|
||||
],
|
||||
resize_keyboard=True,
|
||||
one_time_keyboard=True
|
||||
)
|
||||
|
||||
|
||||
def get_confirmation_reply_keyboard(language: str = "ru") -> ReplyKeyboardMarkup:
|
||||
texts = get_texts(language)
|
||||
|
||||
return ReplyKeyboardMarkup(
|
||||
keyboard=[
|
||||
[
|
||||
KeyboardButton(text=texts.YES),
|
||||
KeyboardButton(text=texts.NO)
|
||||
]
|
||||
],
|
||||
resize_keyboard=True,
|
||||
one_time_keyboard=True
|
||||
)
|
||||
|
||||
|
||||
def get_skip_keyboard(language: str = "ru") -> ReplyKeyboardMarkup:
|
||||
return ReplyKeyboardMarkup(
|
||||
keyboard=[
|
||||
[KeyboardButton(text="⏭️ Пропустить")]
|
||||
],
|
||||
resize_keyboard=True,
|
||||
one_time_keyboard=True
|
||||
)
|
||||
|
||||
|
||||
def remove_keyboard() -> ReplyKeyboardRemove:
|
||||
return ReplyKeyboardRemove()
|
||||
|
||||
|
||||
def get_contact_keyboard(language: str = "ru") -> ReplyKeyboardMarkup:
|
||||
return ReplyKeyboardMarkup(
|
||||
keyboard=[
|
||||
[KeyboardButton(text="📱 Отправить контакт", request_contact=True)],
|
||||
[KeyboardButton(text="❌ Отмена")]
|
||||
],
|
||||
resize_keyboard=True,
|
||||
one_time_keyboard=True
|
||||
)
|
||||
|
||||
|
||||
def get_location_keyboard(language: str = "ru") -> ReplyKeyboardMarkup:
|
||||
return ReplyKeyboardMarkup(
|
||||
keyboard=[
|
||||
[KeyboardButton(text="📍 Отправить геолокацию", request_location=True)],
|
||||
[KeyboardButton(text="❌ Отмена")]
|
||||
],
|
||||
resize_keyboard=True,
|
||||
one_time_keyboard=True
|
||||
)
|
||||
@@ -0,0 +1,509 @@
|
||||
import asyncio
|
||||
from typing import Dict, Any
|
||||
from app.config import settings
|
||||
|
||||
_cached_rules = {}
|
||||
|
||||
def _get_default_rules(language: str = "ru") -> str:
|
||||
if language == "en":
|
||||
return """
|
||||
🔒 <b>Service Usage Rules</b>
|
||||
|
||||
1. It is forbidden to use the service for illegal activities
|
||||
2. Copyright infringement is prohibited
|
||||
3. Spam and malware distribution are prohibited
|
||||
4. Using the service for DDoS attacks is prohibited
|
||||
5. One account - one user
|
||||
6. Refunds are made only in exceptional cases
|
||||
7. Administration reserves the right to block an account for violating the rules
|
||||
|
||||
<b>By accepting the rules, you agree to comply with them.</b>
|
||||
"""
|
||||
else:
|
||||
return """
|
||||
📋 <b>Правила использования сервиса</b>
|
||||
|
||||
1. Запрещается использование сервиса для незаконной деятельности
|
||||
2. Запрещается нарушение авторских прав
|
||||
3. Запрещается спам и рассылка вредоносного ПО
|
||||
4. Запрещается использование сервиса для DDoS атак
|
||||
5. Один аккаунт - один пользователь
|
||||
6. Возврат средств производится только в исключительных случаях
|
||||
7. Администрация оставляет за собой право заблокировать аккаунт при нарушении правил
|
||||
|
||||
<b>Принимая правила, вы соглашаетесь соблюдать их.</b>
|
||||
"""
|
||||
|
||||
class Texts:
|
||||
def __init__(self, language: str = "ru"):
|
||||
self.language = language
|
||||
|
||||
@property
|
||||
def RULES_TEXT(self) -> str:
|
||||
if self.language in _cached_rules:
|
||||
return _cached_rules[self.language]
|
||||
|
||||
return _get_default_rules(self.language)
|
||||
|
||||
BACK = "⬅️ Назад"
|
||||
CANCEL = "❌ Отмена"
|
||||
CONFIRM = "✅ Подтвердить"
|
||||
CONTINUE = "➡️ Продолжить"
|
||||
YES = "✅ Да"
|
||||
NO = "❌ Нет"
|
||||
LOADING = "⏳ Загрузка..."
|
||||
ERROR = "❌ Произошла ошибка"
|
||||
SUCCESS = "✅ Успешно"
|
||||
|
||||
@staticmethod
|
||||
def format_price(kopeks: int) -> str:
|
||||
return f"{kopeks / 100:.2f} ₽"
|
||||
|
||||
@staticmethod
|
||||
def format_traffic(gb: float) -> str:
|
||||
if gb == 0:
|
||||
return "∞ (безлимит)"
|
||||
elif gb >= 1024:
|
||||
return f"{gb/1024:.1f} ТБ"
|
||||
else:
|
||||
return f"{gb:.0f} ГБ"
|
||||
|
||||
|
||||
class RussianTexts(Texts):
|
||||
|
||||
def __init__(self):
|
||||
super().__init__("ru")
|
||||
|
||||
WELCOME = """
|
||||
🎉 <b>Добро пожаловать в VPN сервис!</b>
|
||||
|
||||
Наш сервис предоставляет быстрый и безопасный доступ к интернету без ограничений.
|
||||
|
||||
🔐 <b>Преимущества:</b>
|
||||
• Высокая скорость подключения
|
||||
• Серверы в разных странах
|
||||
• Надежная защита данных
|
||||
• Круглосуточная поддержка
|
||||
|
||||
Для начала работы выберите язык интерфейса:
|
||||
"""
|
||||
|
||||
LANGUAGE_SELECTED = "🌐 Язык интерфейса установлен: <b>Русский</b>"
|
||||
|
||||
RULES_ACCEPT = "✅ Принимаю правила"
|
||||
RULES_DECLINE = "❌ Не принимаю"
|
||||
RULES_REQUIRED = "❗️ Для использования сервиса необходимо принять правила!"
|
||||
|
||||
REFERRAL_CODE_QUESTION = """
|
||||
🤝 <b>У вас есть реферальный код от друга?</b>
|
||||
|
||||
Если у вас есть промокод или реферальная ссылка от друга, введите её сейчас, чтобы получить бонус!
|
||||
|
||||
Введите код или нажмите "Пропустить":
|
||||
"""
|
||||
|
||||
REFERRAL_CODE_APPLIED = "🎁 Реферальный код применен! Вы получите бонус после первой покупки."
|
||||
REFERRAL_CODE_INVALID = "❌ Неверный реферальный код"
|
||||
REFERRAL_CODE_SKIP = "⏭️ Пропустить"
|
||||
|
||||
MAIN_MENU = """👤 <b>{user_name}</b>
|
||||
|
||||
📱 <b>Подписка:</b> {subscription_status}
|
||||
|
||||
Выберите действие:
|
||||
"""
|
||||
|
||||
MENU_BALANCE = "💰 Баланс"
|
||||
MENU_SUBSCRIPTION = "📱 Подписка"
|
||||
MENU_TRIAL = "🧪 Тестовая подписка"
|
||||
MENU_BUY_SUBSCRIPTION = "💎 Купить подписку"
|
||||
MENU_EXTEND_SUBSCRIPTION = "⏰ Продлить подписку"
|
||||
MENU_PROMOCODE = "🎫 Промокод"
|
||||
MENU_REFERRALS = "🤝 Рефералы"
|
||||
MENU_SUPPORT = "🛠️ Техподдержка"
|
||||
MENU_RULES = "📋 Правила сервиса"
|
||||
MENU_LANGUAGE = "🌐 Язык"
|
||||
MENU_ADMIN = "⚙️ Админ-панель"
|
||||
BALANCE_BUTTON = "💰 Баланс: {balance}"
|
||||
BALANCE_BUTTON_ZERO = "💰 Баланс: 0.00 ₽"
|
||||
|
||||
SUBSCRIPTION_NONE = "❌ Нет активной подписки"
|
||||
SUBSCRIPTION_TRIAL = "🧪 Тестовая подписка"
|
||||
SUBSCRIPTION_ACTIVE = "✅ Активна"
|
||||
SUBSCRIPTION_EXPIRED = "⏰ Истекла"
|
||||
|
||||
SUBSCRIPTION_INFO = """
|
||||
📱 <b>Информация о подписке</b>
|
||||
|
||||
📊 <b>Статус:</b> {status}
|
||||
🎭 <b>Тип:</b> {type}
|
||||
📅 <b>Действует до:</b> {end_date}
|
||||
⏰ <b>Осталось дней:</b> {days_left}
|
||||
|
||||
📈 <b>Трафик:</b> {traffic_used} / {traffic_limit}
|
||||
🌍 <b>Серверы:</b> {countries_count} стран
|
||||
📱 <b>Устройства:</b> {devices_used} / {devices_limit}
|
||||
|
||||
💳 <b>Автоплатеж:</b> {autopay_status}
|
||||
"""
|
||||
|
||||
TRIAL_AVAILABLE = """
|
||||
🎁 <b>Тестовая подписка</b>
|
||||
|
||||
Вы можете получить бесплатную тестовую подписку:
|
||||
|
||||
⏰ <b>Период:</b> {days} дней
|
||||
📈 <b>Трафик:</b> {traffic} ГБ
|
||||
📱 <b>Устройства:</b> {devices} шт.
|
||||
🌍 <b>Сервер:</b> 1 страна
|
||||
|
||||
Активировать тестовую подписку?
|
||||
"""
|
||||
|
||||
TRIAL_ACTIVATED = "🎉 Тестовая подписка активирована!"
|
||||
TRIAL_ALREADY_USED = "❌ Тестовая подписка уже была использована"
|
||||
|
||||
BUY_SUBSCRIPTION_START = """
|
||||
💎 <b>Настройка подписки</b>
|
||||
|
||||
Давайте настроим вашу подписку под ваши потребности.
|
||||
|
||||
Сначала выберите период подписки:
|
||||
"""
|
||||
|
||||
SELECT_PERIOD = "Выберите период:"
|
||||
SELECT_TRAFFIC = "Выберите пакет трафика:"
|
||||
SELECT_COUNTRIES = "Выберите страны:"
|
||||
SELECT_DEVICES = "Количество устройств:"
|
||||
|
||||
PERIOD_14_DAYS = f"📅 14 дней - {settings.format_price(settings.PRICE_14_DAYS)}"
|
||||
PERIOD_30_DAYS = f"📅 30 дней - {settings.format_price(settings.PRICE_30_DAYS)}"
|
||||
PERIOD_60_DAYS = f"📅 60 дней - {settings.format_price(settings.PRICE_60_DAYS)}"
|
||||
PERIOD_90_DAYS = f"📅 90 дней - {settings.format_price(settings.PRICE_90_DAYS)}"
|
||||
PERIOD_180_DAYS = f"📅 180 дней - {settings.format_price(settings.PRICE_180_DAYS)}"
|
||||
PERIOD_360_DAYS = f"📅 360 дней - {settings.format_price(settings.PRICE_360_DAYS)}"
|
||||
|
||||
TRAFFIC_5GB = f"📊 5 ГБ - {settings.format_price(settings.PRICE_TRAFFIC_5GB)}"
|
||||
TRAFFIC_10GB = f"📊 10 ГБ - {settings.format_price(settings.PRICE_TRAFFIC_10GB)}"
|
||||
TRAFFIC_25GB = f"📊 25 ГБ - {settings.format_price(settings.PRICE_TRAFFIC_25GB)}"
|
||||
TRAFFIC_50GB = f"📊 50 ГБ - {settings.format_price(settings.PRICE_TRAFFIC_50GB)}"
|
||||
TRAFFIC_100GB = f"📊 100 ГБ - {settings.format_price(settings.PRICE_TRAFFIC_100GB)}"
|
||||
TRAFFIC_250GB = f"📊 250 ГБ - {settings.format_price(settings.PRICE_TRAFFIC_250GB)}"
|
||||
TRAFFIC_UNLIMITED = f"📊 Безлимит - {settings.format_price(settings.PRICE_TRAFFIC_UNLIMITED)}"
|
||||
|
||||
SUBSCRIPTION_SUMMARY = """
|
||||
📋 <b>Итоговая конфигурация</b>
|
||||
|
||||
📅 <b>Период:</b> {period} дней
|
||||
📈 <b>Трафик:</b> {traffic}
|
||||
🌍 <b>Страны:</b> {countries}
|
||||
📱 <b>Устройства:</b> {devices}
|
||||
|
||||
💰 <b>Итого к оплате:</b> {total_price}
|
||||
|
||||
Подтвердить покупку?
|
||||
"""
|
||||
|
||||
INSUFFICIENT_BALANCE = "❌ Недостаточно средств на балансе. Пополните баланс и попробуйте снова."
|
||||
SUBSCRIPTION_PURCHASED = "🎉 Подписка успешно приобретена!"
|
||||
|
||||
BALANCE_INFO = """
|
||||
💰 <b>Баланс: {balance}</b>
|
||||
|
||||
Выберите действие:
|
||||
"""
|
||||
|
||||
BALANCE_HISTORY = "📊 История операций"
|
||||
BALANCE_TOP_UP = "💳 Пополнить"
|
||||
BALANCE_SUPPORT_REQUEST = "🛠️ Запрос через поддержку"
|
||||
|
||||
TOP_UP_AMOUNT = "💳 Введите сумму для пополнения (в рублях):"
|
||||
TOP_UP_METHODS = """
|
||||
💳 <b>Выберите способ оплаты</b>
|
||||
|
||||
Сумма: {amount}
|
||||
"""
|
||||
|
||||
TOP_UP_STARS = "⭐ Telegram Stars"
|
||||
TOP_UP_TRIBUTE = "💎 Банковская карта"
|
||||
|
||||
PROMOCODE_ENTER = "🎫 Введите промокод:"
|
||||
PROMOCODE_SUCCESS = "🎉 Промокод активирован! {description}"
|
||||
PROMOCODE_INVALID = "❌ Неверный промокод"
|
||||
PROMOCODE_EXPIRED = "❌ Промокод истек"
|
||||
PROMOCODE_USED = "❌ Промокод уже использован"
|
||||
|
||||
REFERRAL_INFO = """
|
||||
🤝 <b>Реферальная программа</b>
|
||||
|
||||
👥 <b>Приглашено:</b> {referrals_count} друзей
|
||||
💰 <b>Заработано:</b> {earned_amount}
|
||||
|
||||
🔗 <b>Ваша реферальная ссылка:</b>
|
||||
<code>{referral_link}</code>
|
||||
|
||||
🎫 <b>Ваш промокод:</b>
|
||||
<code>{referral_code}</code>
|
||||
|
||||
💰 <b>Условия:</b>
|
||||
• За каждого друга: {registration_bonus}
|
||||
• Процент с пополнений: {commission_percent}%
|
||||
"""
|
||||
|
||||
REFERRAL_INVITE_MESSAGE = """
|
||||
🎯 <b>Приглашение в VPN сервис</b>
|
||||
|
||||
Привет! Приглашаю тебя в отличный VPN сервис!
|
||||
|
||||
🎁 По моей ссылке ты получишь бонус: {bonus}
|
||||
|
||||
🔗 Переходи: {link}
|
||||
🎫 Или используй промокод: {code}
|
||||
|
||||
💪 Быстро, надежно, недорого!
|
||||
"""
|
||||
|
||||
CREATE_INVITE = "📝 Создать приглашение"
|
||||
|
||||
TRIAL_ENDING_SOON = """
|
||||
🎁 <b>Тестовая подписка скоро закончится!</b>
|
||||
|
||||
Ваша тестовая подписка истекает через несколько часов.
|
||||
|
||||
💎 <b>Не хотите остаться без VPN?</b>
|
||||
Переходите на полную подписку!
|
||||
|
||||
🔥 <b>Специальное предложение:</b>
|
||||
• 30 дней всего за {price}
|
||||
• Безлимитный трафик
|
||||
• Все серверы доступны
|
||||
• Поддержка до 3 устройств
|
||||
|
||||
⚡️ Успейте оформить до окончания тестового периода!
|
||||
"""
|
||||
|
||||
MAINTENANCE_MODE_ACTIVE = """
|
||||
🔧 Технические работы!
|
||||
|
||||
Сервис временно недоступен. Ведутся технические работы по улучшению качества обслуживания.
|
||||
|
||||
⏰ Ориентировочное время завершения: неизвестно
|
||||
🔄 Попробуйте позже
|
||||
|
||||
Приносим извинения за временные неудобства.
|
||||
"""
|
||||
|
||||
MAINTENANCE_MODE_API_ERROR = """
|
||||
🔧 Технические работы!
|
||||
|
||||
Сервис временно недоступен из-за проблем с подключением к серверам.
|
||||
|
||||
⏰ Мы работаем над восстановлением. Попробуйте через несколько минут.
|
||||
|
||||
🔄 Последняя проверка: {last_check}
|
||||
"""
|
||||
|
||||
SUBSCRIPTION_EXPIRING_PAID = """
|
||||
⚠️ <b>Подписка истекает через {days_text}!</b>
|
||||
|
||||
Ваша платная подписка истекает {end_date}.
|
||||
|
||||
💳 <b>Автоплатеж:</b> {autopay_status}
|
||||
|
||||
{action_text}
|
||||
"""
|
||||
|
||||
AUTOPAY_ENABLED_TEXT = "Включен - подписка продлится автоматически"
|
||||
AUTOPAY_DISABLED_TEXT = "Отключен - не забудьте продлить вручную!"
|
||||
|
||||
SUBSCRIPTION_EXPIRED = """
|
||||
❌ <b>Подписка истекла</b>
|
||||
|
||||
Ваша подписка истекла. Для восстановления доступа продлите подписку.
|
||||
|
||||
🔧 Доступ к серверам заблокирован до продления.
|
||||
"""
|
||||
|
||||
AUTOPAY_SUCCESS = """
|
||||
✅ <b>Автоплатеж выполнен</b>
|
||||
|
||||
Ваша подписка автоматически продлена на {days} дней.
|
||||
Списано с баланса: {amount}
|
||||
|
||||
Новая дата окончания: {new_end_date}
|
||||
"""
|
||||
|
||||
AUTOPAY_FAILED = """
|
||||
❌ <b>Ошибка автоплатежа</b>
|
||||
|
||||
Не удалось списать средства для продления подписки.
|
||||
|
||||
💰 Ваш баланс: {balance}
|
||||
💳 Требуется: {required}
|
||||
|
||||
Пополните баланс и продлите подписку вручную.
|
||||
"""
|
||||
|
||||
SUPPORT_INFO = f"""
|
||||
🛠️ <b>Техническая поддержка</b>
|
||||
|
||||
По всем вопросам обращайтесь к нашей поддержке:
|
||||
|
||||
👤 {settings.SUPPORT_USERNAME}
|
||||
|
||||
Мы поможем с:
|
||||
• Настройкой подключения
|
||||
• Решением технических проблем
|
||||
• Вопросами по оплате
|
||||
• Другими вопросами
|
||||
|
||||
⏰ Время ответа: обычно в течение 1-2 часов
|
||||
"""
|
||||
|
||||
CONTACT_SUPPORT = "💬 Написать в поддержку"
|
||||
|
||||
ADMIN_PANEL = """
|
||||
⚙️ <b>Административная панель</b>
|
||||
|
||||
Выберите раздел для управления:
|
||||
"""
|
||||
|
||||
ADMIN_USERS = "👥 Пользователи"
|
||||
ADMIN_SUBSCRIPTIONS = "📱 Подписки"
|
||||
ADMIN_PROMOCODES = "🎫 Промокоды"
|
||||
ADMIN_MESSAGES = "📨 Рассылки"
|
||||
ADMIN_MONITORING = "🔍 Мониторинг"
|
||||
ADMIN_REFERRALS = "🤝 Рефералы"
|
||||
ADMIN_RULES = "📋 Правила"
|
||||
ADMIN_REMNAWAVE = "🖥️ Remnawave"
|
||||
ADMIN_STATISTICS = "📊 Статистика"
|
||||
|
||||
ACCESS_DENIED = "❌ Доступ запрещен"
|
||||
USER_NOT_FOUND = "❌ Пользователь не найден"
|
||||
SUBSCRIPTION_NOT_FOUND = "❌ Подписка не найдена"
|
||||
INVALID_AMOUNT = "❌ Неверная сумма"
|
||||
OPERATION_CANCELLED = "❌ Операция отменена"
|
||||
|
||||
SUBSCRIPTION_EXPIRING = """
|
||||
⚠️ <b>Подписка истекает!</b>
|
||||
|
||||
Ваша подписка истекает через {days} дней.
|
||||
|
||||
Не забудьте продлить подписку, чтобы не потерять доступ к серверам.
|
||||
"""
|
||||
|
||||
SUBSCRIPTION_EXPIRED = """
|
||||
❌ <b>Подписка истекла</b>
|
||||
|
||||
Ваша подписка истекла. Для восстановления доступа продлите подписку.
|
||||
"""
|
||||
|
||||
AUTOPAY_SUCCESS = """
|
||||
✅ <b>Автоплатеж выполнен</b>
|
||||
|
||||
Ваша подписка автоматически продлена на {days} дней.
|
||||
Списано с баланса: {amount}
|
||||
"""
|
||||
|
||||
AUTOPAY_FAILED = """
|
||||
❌ <b>Ошибка автоплатежа</b>
|
||||
|
||||
Не удалось списать средства для продления подписки.
|
||||
Недостаточно средств на балансе: {balance}
|
||||
Требуется: {required}
|
||||
|
||||
Пополните баланс и продлите подписку вручную.
|
||||
"""
|
||||
|
||||
|
||||
class EnglishTexts(Texts):
|
||||
|
||||
def __init__(self):
|
||||
super().__init__("en")
|
||||
|
||||
WELCOME = """
|
||||
🎉 <b>Welcome to VPN Service!</b>
|
||||
|
||||
Our service provides fast and secure internet access without restrictions.
|
||||
|
||||
🔐 <b>Advantages:</b>
|
||||
• High connection speed
|
||||
• Servers in different countries
|
||||
• Reliable data protection
|
||||
• 24/7 support
|
||||
|
||||
To get started, select interface language:
|
||||
"""
|
||||
|
||||
LANGUAGE_SELECTED = "🌐 Interface language set: <b>English</b>"
|
||||
|
||||
BACK = "⬅️ Back"
|
||||
CANCEL = "❌ Cancel"
|
||||
CONFIRM = "✅ Confirm"
|
||||
CONTINUE = "➡️ Continue"
|
||||
YES = "✅ Yes"
|
||||
NO = "❌ No"
|
||||
|
||||
MENU_BALANCE = "💰 Balance"
|
||||
MENU_SUBSCRIPTION = "📱 Subscription"
|
||||
MENU_TRIAL = "🎁 Trial subscription"
|
||||
|
||||
|
||||
LANGUAGES = {
|
||||
"ru": RussianTexts,
|
||||
"en": EnglishTexts
|
||||
}
|
||||
|
||||
|
||||
def get_texts(language: str = "ru") -> Texts:
|
||||
return LANGUAGES.get(language, RussianTexts)()
|
||||
|
||||
async def get_rules_from_db(language: str = "ru") -> str:
|
||||
try:
|
||||
from app.database.database import get_db
|
||||
from app.database.crud.rules import get_current_rules_content
|
||||
|
||||
async for db in get_db():
|
||||
rules = await get_current_rules_content(db, language)
|
||||
if rules:
|
||||
_cached_rules[language] = rules
|
||||
return rules
|
||||
break
|
||||
|
||||
except Exception as e:
|
||||
print(f"Ошибка получения правил из БД: {e}")
|
||||
|
||||
default_rules = _get_default_rules(language)
|
||||
_cached_rules[language] = default_rules
|
||||
return default_rules
|
||||
|
||||
def get_rules_sync(language: str = "ru") -> str:
|
||||
try:
|
||||
if language in _cached_rules:
|
||||
return _cached_rules[language]
|
||||
|
||||
loop = asyncio.new_event_loop()
|
||||
asyncio.set_event_loop(loop)
|
||||
rules = loop.run_until_complete(get_rules_from_db(language))
|
||||
loop.close()
|
||||
return rules
|
||||
|
||||
except Exception as e:
|
||||
print(f"Ошибка получения правил: {e}")
|
||||
return _get_default_rules(language)
|
||||
|
||||
async def refresh_rules_cache(language: str = "ru"):
|
||||
try:
|
||||
if language in _cached_rules:
|
||||
del _cached_rules[language]
|
||||
|
||||
await get_rules_from_db(language)
|
||||
print(f"✅ Кеш правил для языка {language} обновлен")
|
||||
|
||||
except Exception as e:
|
||||
print(f"Ошибка обновления кеша правил: {e}")
|
||||
|
||||
def clear_rules_cache():
|
||||
global _cached_rules
|
||||
_cached_rules.clear()
|
||||
print("✅ Кеш правил очищен")
|
||||
@@ -0,0 +1,169 @@
|
||||
import logging
|
||||
from datetime import datetime
|
||||
from typing import Callable, Dict, Any, Awaitable
|
||||
from aiogram import BaseMiddleware
|
||||
from aiogram.types import Message, CallbackQuery, TelegramObject, User as TgUser
|
||||
from aiogram.fsm.context import FSMContext
|
||||
|
||||
from app.config import settings
|
||||
from app.database.database import get_db
|
||||
from app.database.crud.user import get_user_by_telegram_id, create_user
|
||||
from app.states import RegistrationStates
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class AuthMiddleware(BaseMiddleware):
|
||||
|
||||
async def __call__(
|
||||
self,
|
||||
handler: Callable[[TelegramObject, Dict[str, Any]], Awaitable[Any]],
|
||||
event: TelegramObject,
|
||||
data: Dict[str, Any]
|
||||
) -> Any:
|
||||
|
||||
user: TgUser = None
|
||||
if isinstance(event, (Message, CallbackQuery)):
|
||||
user = event.from_user
|
||||
|
||||
if not user:
|
||||
return await handler(event, data)
|
||||
|
||||
if user.is_bot:
|
||||
return await handler(event, data)
|
||||
|
||||
async for db in get_db():
|
||||
try:
|
||||
db_user = await get_user_by_telegram_id(db, user.id)
|
||||
|
||||
if not db_user:
|
||||
state: FSMContext = data.get('state')
|
||||
current_state = None
|
||||
|
||||
if state:
|
||||
current_state = await state.get_state()
|
||||
|
||||
registration_states = [
|
||||
RegistrationStates.waiting_for_rules_accept,
|
||||
RegistrationStates.waiting_for_referral_code
|
||||
]
|
||||
|
||||
is_registration_process = (
|
||||
(isinstance(event, Message) and event.text and event.text.startswith('/start'))
|
||||
or (isinstance(event, CallbackQuery) and current_state and
|
||||
any(str(state) in str(current_state) for state in registration_states))
|
||||
or (isinstance(event, CallbackQuery) and event.data and
|
||||
(event.data in ['rules_accept', 'rules_decline', 'referral_skip']))
|
||||
)
|
||||
|
||||
if is_registration_process:
|
||||
logger.info(f"🔍 Пропускаем пользователя {user.id} в процессе регистрации")
|
||||
data['db'] = db
|
||||
data['db_user'] = None
|
||||
data['is_admin'] = False
|
||||
return await handler(event, data)
|
||||
else:
|
||||
if isinstance(event, Message):
|
||||
await event.answer(
|
||||
"▶️ Для начала работы необходимо выполнить команду /start"
|
||||
)
|
||||
elif isinstance(event, CallbackQuery):
|
||||
await event.answer(
|
||||
"▶️ Необходимо начать с команды /start",
|
||||
show_alert=True
|
||||
)
|
||||
logger.info(f"🚫 Заблокирован незарегистрированный пользователь {user.id}")
|
||||
return
|
||||
else:
|
||||
from app.database.models import UserStatus
|
||||
|
||||
if db_user.status == UserStatus.BLOCKED.value:
|
||||
if isinstance(event, Message):
|
||||
await event.answer("🚫 Ваш аккаунт заблокирован администратором.")
|
||||
elif isinstance(event, CallbackQuery):
|
||||
await event.answer("🚫 Ваш аккаунт заблокирован администратором.", show_alert=True)
|
||||
logger.info(f"🚫 Заблокированный пользователь {user.id} попытался использовать бота")
|
||||
return
|
||||
|
||||
if db_user.status == UserStatus.DELETED.value:
|
||||
state: FSMContext = data.get('state')
|
||||
current_state = None
|
||||
|
||||
if state:
|
||||
current_state = await state.get_state()
|
||||
|
||||
registration_states = [
|
||||
RegistrationStates.waiting_for_rules_accept,
|
||||
RegistrationStates.waiting_for_referral_code
|
||||
]
|
||||
|
||||
is_start_or_registration = (
|
||||
(isinstance(event, Message) and event.text and event.text.startswith('/start'))
|
||||
or (isinstance(event, CallbackQuery) and current_state and
|
||||
any(str(state) in str(current_state) for state in registration_states))
|
||||
or (isinstance(event, CallbackQuery) and event.data and
|
||||
(event.data in ['rules_accept', 'rules_decline', 'referral_skip']))
|
||||
)
|
||||
|
||||
if is_start_or_registration:
|
||||
logger.info(f"🔄 Удаленный пользователь {user.id} начинает повторную регистрацию")
|
||||
data['db'] = db
|
||||
data['db_user'] = None
|
||||
data['is_admin'] = False
|
||||
return await handler(event, data)
|
||||
else:
|
||||
if isinstance(event, Message):
|
||||
await event.answer(
|
||||
"❌ Ваш аккаунт был удален.\n"
|
||||
"🔄 Для повторной регистрации выполните команду /start"
|
||||
)
|
||||
elif isinstance(event, CallbackQuery):
|
||||
await event.answer(
|
||||
"❌ Ваш аккаунт был удален. Для повторной регистрации выполните /start",
|
||||
show_alert=True
|
||||
)
|
||||
logger.info(f"❌ Удаленный пользователь {user.id} попытался использовать бота без /start")
|
||||
return
|
||||
|
||||
|
||||
profile_updated = False
|
||||
|
||||
if db_user.username != user.username:
|
||||
old_username = db_user.username
|
||||
db_user.username = user.username
|
||||
logger.info(f"📝 [Middleware] Username обновлен для {user.id}: '{old_username}' → '{db_user.username}'")
|
||||
profile_updated = True
|
||||
|
||||
if db_user.first_name != user.first_name:
|
||||
old_first_name = db_user.first_name
|
||||
db_user.first_name = user.first_name
|
||||
logger.info(f"📝 [Middleware] Имя обновлено для {user.id}: '{old_first_name}' → '{db_user.first_name}'")
|
||||
profile_updated = True
|
||||
|
||||
if db_user.last_name != user.last_name:
|
||||
old_last_name = db_user.last_name
|
||||
db_user.last_name = user.last_name
|
||||
logger.info(f"📝 [Middleware] Фамилия обновлена для {user.id}: '{old_last_name}' → '{db_user.last_name}'")
|
||||
profile_updated = True
|
||||
|
||||
db_user.last_activity = datetime.utcnow()
|
||||
|
||||
if profile_updated:
|
||||
db_user.updated_at = datetime.utcnow()
|
||||
logger.info(f"💾 [Middleware] Профиль пользователя {user.id} обновлен в middleware")
|
||||
|
||||
await db.commit()
|
||||
|
||||
data['db'] = db
|
||||
data['db_user'] = db_user
|
||||
data['is_admin'] = settings.is_admin(user.id)
|
||||
|
||||
return await handler(event, data)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка в AuthMiddleware: {e}")
|
||||
logger.error(f"Event type: {type(event)}")
|
||||
if hasattr(event, 'data'):
|
||||
logger.error(f"Callback data: {event.data}")
|
||||
await db.rollback()
|
||||
raise
|
||||
@@ -0,0 +1,42 @@
|
||||
import logging
|
||||
import time
|
||||
from typing import Callable, Dict, Any, Awaitable
|
||||
from aiogram import BaseMiddleware
|
||||
from aiogram.types import Message, CallbackQuery, TelegramObject
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class LoggingMiddleware(BaseMiddleware):
|
||||
|
||||
async def __call__(
|
||||
self,
|
||||
handler: Callable[[TelegramObject, Dict[str, Any]], Awaitable[Any]],
|
||||
event: TelegramObject,
|
||||
data: Dict[str, Any]
|
||||
) -> Any:
|
||||
|
||||
start_time = time.time()
|
||||
|
||||
try:
|
||||
if isinstance(event, Message):
|
||||
user_info = f"@{event.from_user.username}" if event.from_user.username else f"ID:{event.from_user.id}"
|
||||
text = event.text or event.caption or "[медиа]"
|
||||
logger.info(f"📩 Сообщение от {user_info}: {text}")
|
||||
|
||||
elif isinstance(event, CallbackQuery):
|
||||
user_info = f"@{event.from_user.username}" if event.from_user.username else f"ID:{event.from_user.id}"
|
||||
logger.info(f"🔘 Callback от {user_info}: {event.data}")
|
||||
|
||||
result = await handler(event, data)
|
||||
|
||||
execution_time = time.time() - start_time
|
||||
if execution_time > 1.0:
|
||||
logger.warning(f"⏱️ Медленная операция: {execution_time:.2f}s")
|
||||
|
||||
return result
|
||||
|
||||
except Exception as e:
|
||||
execution_time = time.time() - start_time
|
||||
logger.error(f"❌ Ошибка при обработке события за {execution_time:.2f}s: {e}")
|
||||
raise
|
||||
@@ -0,0 +1,45 @@
|
||||
import logging
|
||||
from typing import Callable, Dict, Any, Awaitable
|
||||
from aiogram import BaseMiddleware
|
||||
from aiogram.types import Message, CallbackQuery, TelegramObject, User as TgUser
|
||||
|
||||
from app.config import settings
|
||||
from app.services.maintenance_service import maintenance_service
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class MaintenanceMiddleware(BaseMiddleware):
|
||||
|
||||
async def __call__(
|
||||
self,
|
||||
handler: Callable[[TelegramObject, Dict[str, Any]], Awaitable[Any]],
|
||||
event: TelegramObject,
|
||||
data: Dict[str, Any]
|
||||
) -> Any:
|
||||
|
||||
user: TgUser = None
|
||||
if isinstance(event, (Message, CallbackQuery)):
|
||||
user = event.from_user
|
||||
|
||||
if not user or user.is_bot:
|
||||
return await handler(event, data)
|
||||
|
||||
if not maintenance_service.is_maintenance_active():
|
||||
return await handler(event, data)
|
||||
|
||||
if settings.is_admin(user.id):
|
||||
return await handler(event, data)
|
||||
|
||||
maintenance_message = maintenance_service.get_maintenance_message()
|
||||
|
||||
try:
|
||||
if isinstance(event, Message):
|
||||
await event.answer(maintenance_message, parse_mode="HTML")
|
||||
elif isinstance(event, CallbackQuery):
|
||||
await event.answer(maintenance_message, show_alert=True)
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка отправки сообщения о техработах пользователю {user.id}: {e}")
|
||||
|
||||
logger.info(f"🔧 Пользователь {user.id} заблокирован во время техработ")
|
||||
return
|
||||
@@ -0,0 +1,53 @@
|
||||
import logging
|
||||
from typing import Callable, Dict, Any, Awaitable
|
||||
from datetime import datetime
|
||||
from aiogram import BaseMiddleware
|
||||
from aiogram.types import TelegramObject, Update, Message, CallbackQuery
|
||||
|
||||
from app.database.database import get_db
|
||||
from app.database.crud.user import get_user_by_telegram_id
|
||||
from app.database.models import SubscriptionStatus
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class SubscriptionStatusMiddleware(BaseMiddleware):
|
||||
|
||||
async def __call__(
|
||||
self,
|
||||
handler: Callable[[TelegramObject, Dict[str, Any]], Awaitable[Any]],
|
||||
event: TelegramObject,
|
||||
data: Dict[str, Any]
|
||||
) -> Any:
|
||||
|
||||
telegram_id = None
|
||||
if isinstance(event, (Message, CallbackQuery)):
|
||||
telegram_id = event.from_user.id
|
||||
elif isinstance(event, Update):
|
||||
if event.message:
|
||||
telegram_id = event.message.from_user.id
|
||||
elif event.callback_query:
|
||||
telegram_id = event.callback_query.from_user.id
|
||||
|
||||
if telegram_id:
|
||||
try:
|
||||
async for db in get_db():
|
||||
user = await get_user_by_telegram_id(db, telegram_id)
|
||||
if user and user.subscription:
|
||||
current_time = datetime.utcnow()
|
||||
subscription = user.subscription
|
||||
|
||||
if (subscription.status == SubscriptionStatus.ACTIVE.value and
|
||||
subscription.end_date <= current_time):
|
||||
|
||||
subscription.status = SubscriptionStatus.EXPIRED.value
|
||||
subscription.updated_at = current_time
|
||||
await db.commit()
|
||||
|
||||
logger.info(f"⏰ Middleware: Статус подписки пользователя {user.id} изменен на 'expired' (время истекло)")
|
||||
break
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка проверки статуса подписки для пользователя {telegram_id}: {e}")
|
||||
|
||||
return await handler(event, data)
|
||||
@@ -0,0 +1,53 @@
|
||||
import asyncio
|
||||
import logging
|
||||
import time
|
||||
from typing import Callable, Dict, Any, Awaitable
|
||||
from aiogram import BaseMiddleware
|
||||
from aiogram.types import Message, CallbackQuery, TelegramObject
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class ThrottlingMiddleware(BaseMiddleware):
|
||||
|
||||
def __init__(self, rate_limit: float = 0.5):
|
||||
self.rate_limit = rate_limit
|
||||
self.user_buckets: Dict[int, float] = {}
|
||||
|
||||
async def __call__(
|
||||
self,
|
||||
handler: Callable[[TelegramObject, Dict[str, Any]], Awaitable[Any]],
|
||||
event: TelegramObject,
|
||||
data: Dict[str, Any]
|
||||
) -> Any:
|
||||
|
||||
user_id = None
|
||||
if isinstance(event, (Message, CallbackQuery)):
|
||||
user_id = event.from_user.id
|
||||
|
||||
if not user_id:
|
||||
return await handler(event, data)
|
||||
|
||||
now = time.time()
|
||||
last_call = self.user_buckets.get(user_id, 0)
|
||||
|
||||
if now - last_call < self.rate_limit:
|
||||
logger.warning(f"🚫 Throttling для пользователя {user_id}")
|
||||
|
||||
if isinstance(event, Message):
|
||||
await event.answer("⏳ Пожалуйста, не отправляйте сообщения так часто!")
|
||||
elif isinstance(event, CallbackQuery):
|
||||
await event.answer("⏳ Слишком быстро! Подождите немного.", show_alert=True)
|
||||
|
||||
return
|
||||
|
||||
self.user_buckets[user_id] = now
|
||||
|
||||
cleanup_threshold = now - 60
|
||||
self.user_buckets = {
|
||||
uid: timestamp
|
||||
for uid, timestamp in self.user_buckets.items()
|
||||
if timestamp > cleanup_threshold
|
||||
}
|
||||
|
||||
return await handler(event, data)
|
||||
@@ -0,0 +1,3 @@
|
||||
"""
|
||||
Сервисы бизнес-логики
|
||||
"""
|
||||
@@ -0,0 +1,406 @@
|
||||
import asyncio
|
||||
import logging
|
||||
from datetime import datetime, timedelta
|
||||
from typing import Optional, Dict, Any
|
||||
from dataclasses import dataclass
|
||||
|
||||
from app.config import settings
|
||||
from app.external.remnawave_api import RemnaWaveAPI, test_api_connection
|
||||
from app.utils.cache import cache
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@dataclass
|
||||
class MaintenanceStatus:
|
||||
is_active: bool
|
||||
enabled_at: Optional[datetime] = None
|
||||
last_check: Optional[datetime] = None
|
||||
reason: Optional[str] = None
|
||||
auto_enabled: bool = False
|
||||
api_status: bool = True
|
||||
consecutive_failures: int = 0
|
||||
|
||||
|
||||
class MaintenanceService:
|
||||
|
||||
def __init__(self):
|
||||
self._status = MaintenanceStatus(is_active=False)
|
||||
self._check_task: Optional[asyncio.Task] = None
|
||||
self._is_checking = False
|
||||
self._max_consecutive_failures = 3
|
||||
self._bot = None
|
||||
self._last_notification_sent = None
|
||||
|
||||
def set_bot(self, bot):
|
||||
"""Устанавливает ссылку на бота для отправки уведомлений"""
|
||||
self._bot = bot
|
||||
logger.info("Бот установлен для maintenance_service")
|
||||
|
||||
@property
|
||||
def status(self) -> MaintenanceStatus:
|
||||
return self._status
|
||||
|
||||
def is_maintenance_active(self) -> bool:
|
||||
return self._status.is_active
|
||||
|
||||
def get_maintenance_message(self) -> str:
|
||||
if self._status.auto_enabled:
|
||||
return f"""
|
||||
🔧 Технические работы!
|
||||
|
||||
Сервис временно недоступен из-за проблем с подключением к серверам.
|
||||
|
||||
⏰ Мы работаем над восстановлением. Попробуйте через несколько минут.
|
||||
|
||||
🔄 Последняя проверка: {self._status.last_check.strftime('%H:%M:%S') if self._status.last_check else 'неизвестно'}
|
||||
"""
|
||||
else:
|
||||
return settings.get_maintenance_message()
|
||||
|
||||
async def _notify_admins(self, message: str, alert_type: str = "info"):
|
||||
"""Отправка уведомлений администраторам"""
|
||||
if not self._bot:
|
||||
logger.warning("Бот не установлен, уведомления не могут быть отправлены")
|
||||
return
|
||||
|
||||
cache_key = f"maintenance_notification_{alert_type}"
|
||||
if await cache.get(cache_key):
|
||||
return
|
||||
|
||||
admin_ids = settings.get_admin_ids()
|
||||
if not admin_ids:
|
||||
logger.warning("Список администраторов пуст")
|
||||
return
|
||||
|
||||
emoji_map = {
|
||||
"error": "🚨",
|
||||
"warning": "⚠️",
|
||||
"success": "✅",
|
||||
"info": "ℹ️"
|
||||
}
|
||||
emoji = emoji_map.get(alert_type, "ℹ️")
|
||||
|
||||
formatted_message = f"{emoji} <b>Maintenance Service</b>\n\n{message}"
|
||||
|
||||
success_count = 0
|
||||
for admin_id in admin_ids:
|
||||
try:
|
||||
await self._bot.send_message(
|
||||
chat_id=admin_id,
|
||||
text=formatted_message,
|
||||
parse_mode="HTML"
|
||||
)
|
||||
success_count += 1
|
||||
await asyncio.sleep(0.1)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка отправки уведомления админу {admin_id}: {e}")
|
||||
|
||||
if success_count > 0:
|
||||
logger.info(f"Уведомление отправлено {success_count} администраторам")
|
||||
await cache.set(cache_key, True, expire=300)
|
||||
else:
|
||||
logger.error("Не удалось отправить уведомления ни одному администратору")
|
||||
|
||||
async def enable_maintenance(self, reason: Optional[str] = None, auto: bool = False) -> bool:
|
||||
try:
|
||||
if self._status.is_active:
|
||||
logger.warning("Режим техработ уже включен")
|
||||
return True
|
||||
|
||||
self._status.is_active = True
|
||||
self._status.enabled_at = datetime.utcnow()
|
||||
self._status.reason = reason or ("Автоматическое включение" if auto else "Включено администратором")
|
||||
self._status.auto_enabled = auto
|
||||
|
||||
await self._save_status_to_cache()
|
||||
|
||||
notification_msg = f"""
|
||||
Режим технических работ ВКЛЮЧЕН
|
||||
|
||||
📋 <b>Причина:</b> {self._status.reason}
|
||||
🤖 <b>Автоматически:</b> {'Да' if auto else 'Нет'}
|
||||
🕐 <b>Время:</b> {self._status.enabled_at.strftime('%d.%m.%Y %H:%M:%S')}
|
||||
|
||||
Обычные пользователи временно не смогут использовать бота.
|
||||
"""
|
||||
|
||||
await self._notify_admins(notification_msg, "warning" if auto else "info")
|
||||
|
||||
logger.warning(f"🔧 Режим техработ ВКЛЮЧЕН. Причина: {self._status.reason}")
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка включения режима техработ: {e}")
|
||||
return False
|
||||
|
||||
async def disable_maintenance(self) -> bool:
|
||||
try:
|
||||
if not self._status.is_active:
|
||||
logger.info("Режим техработ уже выключен")
|
||||
return True
|
||||
|
||||
was_auto = self._status.auto_enabled
|
||||
duration = None
|
||||
if self._status.enabled_at:
|
||||
duration = datetime.utcnow() - self._status.enabled_at
|
||||
|
||||
self._status.is_active = False
|
||||
self._status.enabled_at = None
|
||||
self._status.reason = None
|
||||
self._status.auto_enabled = False
|
||||
self._status.consecutive_failures = 0
|
||||
|
||||
await self._save_status_to_cache()
|
||||
|
||||
duration_str = ""
|
||||
if duration:
|
||||
hours = int(duration.total_seconds() // 3600)
|
||||
minutes = int((duration.total_seconds() % 3600) // 60)
|
||||
if hours > 0:
|
||||
duration_str = f"\n⏱️ <b>Длительность:</b> {hours}ч {minutes}мин"
|
||||
else:
|
||||
duration_str = f"\n⏱️ <b>Длительность:</b> {minutes}мин"
|
||||
|
||||
notification_msg = f"""
|
||||
Режим технических работ ВЫКЛЮЧЕН
|
||||
|
||||
🤖 <b>Автоматически:</b> {'Да' if was_auto else 'Нет'}
|
||||
🕐 <b>Время:</b> {datetime.utcnow().strftime('%d.%m.%Y %H:%M:%S')}
|
||||
{duration_str}
|
||||
|
||||
Сервис снова доступен для пользователей.
|
||||
"""
|
||||
|
||||
await self._notify_admins(notification_msg, "success")
|
||||
|
||||
logger.info("✅ Режим техработ ВЫКЛЮЧЕН")
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка выключения режима техработ: {e}")
|
||||
return False
|
||||
|
||||
async def start_monitoring(self) -> bool:
|
||||
try:
|
||||
if self._check_task and not self._check_task.done():
|
||||
logger.warning("Мониторинг уже запущен")
|
||||
return True
|
||||
|
||||
await self._load_status_from_cache()
|
||||
|
||||
self._check_task = asyncio.create_task(self._monitoring_loop())
|
||||
logger.info(f"🔄 Запущен мониторинг API RemnaWave (интервал: {settings.get_maintenance_check_interval()}с)")
|
||||
|
||||
await self._notify_admins(f"""
|
||||
Мониторинг технических работ запущен
|
||||
|
||||
🔄 <b>Интервал проверки:</b> {settings.get_maintenance_check_interval()} секунд
|
||||
🤖 <b>Автовключение:</b> {'Включено' if settings.is_maintenance_auto_enable() else 'Отключено'}
|
||||
🎯 <b>Порог ошибок:</b> {self._max_consecutive_failures}
|
||||
|
||||
Система будет следить за доступностью API.
|
||||
""", "info")
|
||||
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка запуска мониторинга: {e}")
|
||||
return False
|
||||
|
||||
async def stop_monitoring(self) -> bool:
|
||||
try:
|
||||
if self._check_task and not self._check_task.done():
|
||||
self._check_task.cancel()
|
||||
try:
|
||||
await self._check_task
|
||||
except asyncio.CancelledError:
|
||||
pass
|
||||
|
||||
await self._notify_admins("Мониторинг технических работ остановлен", "info")
|
||||
logger.info("ℹ️ Мониторинг API остановлен")
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка остановки мониторинга: {e}")
|
||||
return False
|
||||
|
||||
async def check_api_status(self) -> bool:
|
||||
try:
|
||||
if self._is_checking:
|
||||
return self._status.api_status
|
||||
|
||||
self._is_checking = True
|
||||
self._status.last_check = datetime.utcnow()
|
||||
|
||||
api = RemnaWaveAPI(settings.REMNAWAVE_API_URL, settings.REMNAWAVE_API_KEY)
|
||||
|
||||
async with api:
|
||||
is_connected = await test_api_connection(api)
|
||||
|
||||
if is_connected:
|
||||
# API восстановилось
|
||||
if not self._status.api_status:
|
||||
await self._notify_admins(f"""
|
||||
API RemnaWave восстановлено!
|
||||
|
||||
✅ <b>Статус:</b> Доступно
|
||||
🕐 <b>Время восстановления:</b> {self._status.last_check.strftime('%H:%M:%S')}
|
||||
🔄 <b>Неудачных попыток было:</b> {self._status.consecutive_failures}
|
||||
|
||||
API снова отвечает на запросы.
|
||||
""", "success")
|
||||
|
||||
self._status.api_status = True
|
||||
self._status.consecutive_failures = 0
|
||||
|
||||
if self._status.is_active and self._status.auto_enabled:
|
||||
await self.disable_maintenance()
|
||||
logger.info("✅ API восстановился, режим техработ автоматически отключен")
|
||||
|
||||
return True
|
||||
else:
|
||||
was_available = self._status.api_status
|
||||
self._status.api_status = False
|
||||
self._status.consecutive_failures += 1
|
||||
|
||||
if was_available:
|
||||
await self._notify_admins(f"""
|
||||
API RemnaWave недоступно!
|
||||
|
||||
❌ <b>Статус:</b> Недоступно
|
||||
🕐 <b>Время обнаружения:</b> {self._status.last_check.strftime('%H:%M:%S')}
|
||||
🔄 <b>Попытка:</b> {self._status.consecutive_failures}
|
||||
|
||||
Началась серия неудачных проверок API.
|
||||
""", "error")
|
||||
|
||||
if (self._status.consecutive_failures >= self._max_consecutive_failures and
|
||||
not self._status.is_active and
|
||||
settings.is_maintenance_auto_enable()):
|
||||
|
||||
await self.enable_maintenance(
|
||||
reason=f"Автоматическое включение после {self._status.consecutive_failures} неудачных проверок API",
|
||||
auto=True
|
||||
)
|
||||
|
||||
return False
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка проверки API: {e}")
|
||||
|
||||
if self._status.api_status:
|
||||
await self._notify_admins(f"""
|
||||
Ошибка при проверке API RemnaWave
|
||||
|
||||
❌ <b>Ошибка:</b> {str(e)}
|
||||
🕐 <b>Время:</b> {datetime.utcnow().strftime('%H:%M:%S')}
|
||||
|
||||
Не удалось выполнить проверку доступности API.
|
||||
""", "error")
|
||||
|
||||
self._status.api_status = False
|
||||
self._status.consecutive_failures += 1
|
||||
return False
|
||||
finally:
|
||||
self._is_checking = False
|
||||
await self._save_status_to_cache()
|
||||
|
||||
async def _monitoring_loop(self):
|
||||
while True:
|
||||
try:
|
||||
await self.check_api_status()
|
||||
await asyncio.sleep(settings.get_maintenance_check_interval())
|
||||
|
||||
except asyncio.CancelledError:
|
||||
logger.info("Мониторинг отменен")
|
||||
break
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка в цикле мониторинга: {e}")
|
||||
await asyncio.sleep(30)
|
||||
|
||||
async def _save_status_to_cache(self):
|
||||
try:
|
||||
status_data = {
|
||||
"is_active": self._status.is_active,
|
||||
"enabled_at": self._status.enabled_at.isoformat() if self._status.enabled_at else None,
|
||||
"reason": self._status.reason,
|
||||
"auto_enabled": self._status.auto_enabled,
|
||||
"consecutive_failures": self._status.consecutive_failures,
|
||||
"last_check": self._status.last_check.isoformat() if self._status.last_check else None
|
||||
}
|
||||
|
||||
await cache.set("maintenance_status", status_data, expire=3600)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка сохранения состояния в кеш: {e}")
|
||||
|
||||
async def _load_status_from_cache(self):
|
||||
try:
|
||||
status_data = await cache.get("maintenance_status")
|
||||
if not status_data:
|
||||
return
|
||||
|
||||
self._status.is_active = status_data.get("is_active", False)
|
||||
self._status.reason = status_data.get("reason")
|
||||
self._status.auto_enabled = status_data.get("auto_enabled", False)
|
||||
self._status.consecutive_failures = status_data.get("consecutive_failures", 0)
|
||||
|
||||
if status_data.get("enabled_at"):
|
||||
self._status.enabled_at = datetime.fromisoformat(status_data["enabled_at"])
|
||||
|
||||
if status_data.get("last_check"):
|
||||
self._status.last_check = datetime.fromisoformat(status_data["last_check"])
|
||||
|
||||
logger.info(f"🔥 Состояние техработ загружено из кеша: активен={self._status.is_active}")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка загрузки состояния из кеша: {e}")
|
||||
|
||||
def get_status_info(self) -> Dict[str, Any]:
|
||||
return {
|
||||
"is_active": self._status.is_active,
|
||||
"enabled_at": self._status.enabled_at,
|
||||
"last_check": self._status.last_check,
|
||||
"reason": self._status.reason,
|
||||
"auto_enabled": self._status.auto_enabled,
|
||||
"api_status": self._status.api_status,
|
||||
"consecutive_failures": self._status.consecutive_failures,
|
||||
"monitoring_active": self._check_task is not None and not self._check_task.done(),
|
||||
"auto_enable_configured": settings.is_maintenance_auto_enable(),
|
||||
"check_interval": settings.get_maintenance_check_interval(),
|
||||
"bot_connected": self._bot is not None
|
||||
}
|
||||
|
||||
async def force_api_check(self) -> Dict[str, Any]:
|
||||
start_time = datetime.utcnow()
|
||||
|
||||
try:
|
||||
api_status = await self.check_api_status()
|
||||
end_time = datetime.utcnow()
|
||||
response_time = (end_time - start_time).total_seconds()
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"api_available": api_status,
|
||||
"response_time": round(response_time, 2),
|
||||
"checked_at": end_time,
|
||||
"consecutive_failures": self._status.consecutive_failures
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
end_time = datetime.utcnow()
|
||||
response_time = (end_time - start_time).total_seconds()
|
||||
|
||||
return {
|
||||
"success": False,
|
||||
"api_available": False,
|
||||
"error": str(e),
|
||||
"response_time": round(response_time, 2),
|
||||
"checked_at": end_time,
|
||||
"consecutive_failures": self._status.consecutive_failures
|
||||
}
|
||||
|
||||
|
||||
maintenance_service = MaintenanceService()
|
||||
@@ -0,0 +1,746 @@
|
||||
import asyncio
|
||||
import logging
|
||||
from datetime import datetime, timedelta
|
||||
from typing import Dict, List, Any, Optional, Set
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy import select, and_
|
||||
from sqlalchemy.orm import selectinload
|
||||
|
||||
from app.config import settings
|
||||
from app.database.database import get_db
|
||||
from app.database.crud.subscription import (
|
||||
get_expired_subscriptions, get_expiring_subscriptions,
|
||||
get_subscriptions_for_autopay, deactivate_subscription,
|
||||
extend_subscription
|
||||
)
|
||||
from app.database.crud.user import (
|
||||
get_user_by_id, get_inactive_users, delete_user,
|
||||
subtract_user_balance
|
||||
)
|
||||
from app.database.models import MonitoringLog, SubscriptionStatus, Subscription, User
|
||||
from app.services.subscription_service import SubscriptionService
|
||||
from app.services.payment_service import PaymentService
|
||||
from app.localization.texts import get_texts
|
||||
|
||||
from app.external.remnawave_api import (
|
||||
RemnaWaveUser, UserStatus, TrafficLimitStrategy, RemnaWaveAPIError
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class MonitoringService:
|
||||
|
||||
def __init__(self, bot=None):
|
||||
self.is_running = False
|
||||
self.subscription_service = SubscriptionService()
|
||||
self.payment_service = PaymentService()
|
||||
self.bot = bot
|
||||
self._notified_users: Set[str] = set()
|
||||
self._last_cleanup = datetime.utcnow()
|
||||
|
||||
async def start_monitoring(self):
|
||||
if self.is_running:
|
||||
logger.warning("Мониторинг уже запущен")
|
||||
return
|
||||
|
||||
self.is_running = True
|
||||
logger.info("🔄 Запуск службы мониторинга")
|
||||
|
||||
while self.is_running:
|
||||
try:
|
||||
await self._monitoring_cycle()
|
||||
await asyncio.sleep(settings.MONITORING_INTERVAL * 60)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка в цикле мониторинга: {e}")
|
||||
await asyncio.sleep(60)
|
||||
|
||||
def stop_monitoring(self):
|
||||
self.is_running = False
|
||||
logger.info("ℹ️ Мониторинг остановлен")
|
||||
|
||||
async def _monitoring_cycle(self):
|
||||
async for db in get_db():
|
||||
try:
|
||||
await self._cleanup_notification_cache()
|
||||
|
||||
await self._check_expired_subscriptions(db)
|
||||
await self._check_expiring_subscriptions(db)
|
||||
await self._check_trial_expiring_soon(db)
|
||||
await self._process_autopayments(db)
|
||||
await self._cleanup_inactive_users(db)
|
||||
await self._sync_with_remnawave(db)
|
||||
|
||||
await self._log_monitoring_event(
|
||||
db, "monitoring_cycle_completed",
|
||||
"Цикл мониторинга успешно завершен",
|
||||
{"timestamp": datetime.utcnow().isoformat()}
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка в цикле мониторинга: {e}")
|
||||
await self._log_monitoring_event(
|
||||
db, "monitoring_cycle_error",
|
||||
f"Ошибка в цикле мониторинга: {str(e)}",
|
||||
{"error": str(e)},
|
||||
is_success=False
|
||||
)
|
||||
finally:
|
||||
break
|
||||
|
||||
async def _cleanup_notification_cache(self):
|
||||
current_time = datetime.utcnow()
|
||||
|
||||
if (current_time - self._last_cleanup).total_seconds() >= 3600:
|
||||
old_count = len(self._notified_users)
|
||||
self._notified_users.clear()
|
||||
self._last_cleanup = current_time
|
||||
logger.info(f"🧹 Очищен кеш уведомлений ({old_count} записей)")
|
||||
|
||||
async def _check_expired_subscriptions(self, db: AsyncSession):
|
||||
try:
|
||||
expired_subscriptions = await get_expired_subscriptions(db)
|
||||
|
||||
for subscription in expired_subscriptions:
|
||||
from app.database.crud.subscription import expire_subscription
|
||||
await expire_subscription(db, subscription)
|
||||
|
||||
user = await get_user_by_id(db, subscription.user_id)
|
||||
if user and user.remnawave_uuid:
|
||||
await self.subscription_service.disable_remnawave_user(user.remnawave_uuid)
|
||||
|
||||
if user and self.bot:
|
||||
await self._send_subscription_expired_notification(user)
|
||||
|
||||
logger.info(f"🔴 Подписка пользователя {subscription.user_id} истекла и статус изменен на 'expired'")
|
||||
|
||||
if expired_subscriptions:
|
||||
await self._log_monitoring_event(
|
||||
db, "expired_subscriptions_processed",
|
||||
f"Обработано {len(expired_subscriptions)} истёкших подписок",
|
||||
{"count": len(expired_subscriptions)}
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка проверки истёкших подписок: {e}")
|
||||
|
||||
async def update_remnawave_user(
|
||||
self,
|
||||
db: AsyncSession,
|
||||
subscription: Subscription
|
||||
) -> Optional[RemnaWaveUser]:
|
||||
|
||||
try:
|
||||
user = await get_user_by_id(db, subscription.user_id)
|
||||
if not user or not user.remnawave_uuid:
|
||||
logger.error(f"RemnaWave UUID не найден для пользователя {subscription.user_id}")
|
||||
return None
|
||||
|
||||
current_time = datetime.utcnow()
|
||||
is_active = (subscription.status == SubscriptionStatus.ACTIVE.value and
|
||||
subscription.end_date > current_time)
|
||||
|
||||
if (subscription.status == SubscriptionStatus.ACTIVE.value and
|
||||
subscription.end_date <= current_time):
|
||||
subscription.status = SubscriptionStatus.EXPIRED.value
|
||||
await db.commit()
|
||||
is_active = False
|
||||
logger.info(f"📝 Статус подписки {subscription.id} обновлен на 'expired'")
|
||||
|
||||
async with self.api as api:
|
||||
updated_user = await api.update_user(
|
||||
uuid=user.remnawave_uuid,
|
||||
status=UserStatus.ACTIVE if is_active else UserStatus.EXPIRED,
|
||||
expire_at=subscription.end_date,
|
||||
traffic_limit_bytes=self._gb_to_bytes(subscription.traffic_limit_gb),
|
||||
traffic_limit_strategy=TrafficLimitStrategy.MONTH,
|
||||
hwid_device_limit=subscription.device_limit,
|
||||
active_internal_squads=subscription.connected_squads
|
||||
)
|
||||
|
||||
subscription.subscription_url = updated_user.subscription_url
|
||||
await db.commit()
|
||||
|
||||
status_text = "активным" if is_active else "истёкшим"
|
||||
logger.info(f"✅ Обновлен RemnaWave пользователь {user.remnawave_uuid} со статусом {status_text}")
|
||||
return updated_user
|
||||
|
||||
except RemnaWaveAPIError as e:
|
||||
logger.error(f"Ошибка обновления RemnaWave пользователя: {e}")
|
||||
return None
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка обновления RemnaWave пользователя: {e}")
|
||||
return None
|
||||
|
||||
async def _check_expiring_subscriptions(self, db: AsyncSession):
|
||||
try:
|
||||
warning_days = settings.get_autopay_warning_days()
|
||||
all_processed_users = set()
|
||||
|
||||
for days in warning_days:
|
||||
expiring_subscriptions = await self._get_expiring_paid_subscriptions(db, days)
|
||||
sent_count = 0
|
||||
|
||||
for subscription in expiring_subscriptions:
|
||||
user = await get_user_by_id(db, subscription.user_id)
|
||||
if not user:
|
||||
continue
|
||||
|
||||
notification_key = f"expiring_{user.telegram_id}_{days}d_{subscription.id}"
|
||||
user_key = f"user_{user.telegram_id}_today"
|
||||
|
||||
if (notification_key in self._notified_users or
|
||||
user_key in all_processed_users):
|
||||
logger.debug(f"🔄 Пропускаем дублирование для пользователя {user.telegram_id} на {days} дней")
|
||||
continue
|
||||
|
||||
should_send = True
|
||||
for other_days in warning_days:
|
||||
if other_days < days:
|
||||
other_subs = await self._get_expiring_paid_subscriptions(db, other_days)
|
||||
if any(s.user_id == user.id for s in other_subs):
|
||||
should_send = False
|
||||
logger.debug(f"🎯 Пропускаем уведомление на {days} дней для пользователя {user.telegram_id}, есть более срочное на {other_days} дней")
|
||||
break
|
||||
|
||||
if not should_send:
|
||||
continue
|
||||
|
||||
if self.bot:
|
||||
success = await self._send_subscription_expiring_notification(user, subscription, days)
|
||||
if success:
|
||||
self._notified_users.add(notification_key)
|
||||
all_processed_users.add(user_key)
|
||||
sent_count += 1
|
||||
logger.info(f"✅ Пользователю {user.telegram_id} отправлено уведомление об истечении подписки через {days} дней")
|
||||
else:
|
||||
logger.warning(f"❌ Не удалось отправить уведомление пользователю {user.telegram_id}")
|
||||
|
||||
if sent_count > 0:
|
||||
await self._log_monitoring_event(
|
||||
db, "expiring_notifications_sent",
|
||||
f"Отправлено {sent_count} уведомлений об истечении через {days} дней",
|
||||
{"days": days, "count": sent_count}
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка проверки истекающих подписок: {e}")
|
||||
|
||||
async def _check_trial_expiring_soon(self, db: AsyncSession):
|
||||
try:
|
||||
threshold_time = datetime.utcnow() + timedelta(hours=2)
|
||||
|
||||
result = await db.execute(
|
||||
select(Subscription)
|
||||
.options(selectinload(Subscription.user))
|
||||
.where(
|
||||
and_(
|
||||
Subscription.status == SubscriptionStatus.ACTIVE.value,
|
||||
Subscription.is_trial == True,
|
||||
Subscription.end_date <= threshold_time,
|
||||
Subscription.end_date > datetime.utcnow()
|
||||
)
|
||||
)
|
||||
)
|
||||
trial_expiring = result.scalars().all()
|
||||
|
||||
for subscription in trial_expiring:
|
||||
user = subscription.user
|
||||
if not user:
|
||||
continue
|
||||
|
||||
notification_key = f"trial_2h_{user.telegram_id}_{subscription.id}"
|
||||
if notification_key in self._notified_users:
|
||||
continue
|
||||
|
||||
if self.bot:
|
||||
success = await self._send_trial_ending_notification(user, subscription)
|
||||
if success:
|
||||
self._notified_users.add(notification_key)
|
||||
logger.info(f"🎁 Пользователю {user.telegram_id} отправлено уведомление об окончании тестовой подписки через 2 часа")
|
||||
|
||||
if trial_expiring:
|
||||
await self._log_monitoring_event(
|
||||
db, "trial_expiring_notifications_sent",
|
||||
f"Отправлено {len(trial_expiring)} уведомлений об окончании тестовых подписок",
|
||||
{"count": len(trial_expiring)}
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка проверки истекающих тестовых подписок: {e}")
|
||||
|
||||
async def _get_expiring_paid_subscriptions(self, db: AsyncSession, days_before: int) -> List[Subscription]:
|
||||
current_time = datetime.utcnow()
|
||||
threshold_date = current_time + timedelta(days=days_before)
|
||||
|
||||
result = await db.execute(
|
||||
select(Subscription)
|
||||
.options(selectinload(Subscription.user))
|
||||
.where(
|
||||
and_(
|
||||
Subscription.status == SubscriptionStatus.ACTIVE.value,
|
||||
Subscription.is_trial == False,
|
||||
Subscription.end_date > current_time,
|
||||
Subscription.end_date <= threshold_date
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
logger.debug(f"🔍 Поиск платных подписок, истекающих в ближайшие {days_before} дней")
|
||||
logger.debug(f"📅 Текущее время: {current_time}")
|
||||
logger.debug(f"📅 Пороговая дата: {threshold_date}")
|
||||
|
||||
subscriptions = result.scalars().all()
|
||||
logger.info(f"📊 Найдено {len(subscriptions)} платных подписок для уведомлений")
|
||||
|
||||
return subscriptions
|
||||
|
||||
async def _process_autopayments(self, db: AsyncSession):
|
||||
try:
|
||||
current_time = datetime.utcnow()
|
||||
|
||||
result = await db.execute(
|
||||
select(Subscription)
|
||||
.options(selectinload(Subscription.user))
|
||||
.where(
|
||||
and_(
|
||||
Subscription.status == SubscriptionStatus.ACTIVE.value,
|
||||
Subscription.autopay_enabled == True,
|
||||
Subscription.is_trial == False
|
||||
)
|
||||
)
|
||||
)
|
||||
all_autopay_subscriptions = result.scalars().all()
|
||||
|
||||
autopay_subscriptions = []
|
||||
for sub in all_autopay_subscriptions:
|
||||
days_before_expiry = (sub.end_date - current_time).days
|
||||
if days_before_expiry <= sub.autopay_days_before:
|
||||
autopay_subscriptions.append(sub)
|
||||
|
||||
processed_count = 0
|
||||
failed_count = 0
|
||||
|
||||
for subscription in autopay_subscriptions:
|
||||
user = subscription.user
|
||||
if not user:
|
||||
continue
|
||||
|
||||
renewal_cost = settings.PRICE_30_DAYS
|
||||
|
||||
autopay_key = f"autopay_{user.telegram_id}_{subscription.id}"
|
||||
if autopay_key in self._notified_users:
|
||||
continue
|
||||
|
||||
if user.balance_kopeks >= renewal_cost:
|
||||
success = await subtract_user_balance(
|
||||
db, user, renewal_cost,
|
||||
"Автопродление подписки"
|
||||
)
|
||||
|
||||
if success:
|
||||
await extend_subscription(db, subscription, 30)
|
||||
await self.subscription_service.update_remnawave_user(db, subscription)
|
||||
|
||||
if self.bot:
|
||||
await self._send_autopay_success_notification(user, renewal_cost, 30)
|
||||
|
||||
processed_count += 1
|
||||
self._notified_users.add(autopay_key)
|
||||
logger.info(f"💳 Автопродление подписки пользователя {user.telegram_id} успешно")
|
||||
else:
|
||||
failed_count += 1
|
||||
if self.bot:
|
||||
await self._send_autopay_failed_notification(user, user.balance_kopeks, renewal_cost)
|
||||
logger.warning(f"💳 Ошибка списания средств для автопродления пользователя {user.telegram_id}")
|
||||
else:
|
||||
failed_count += 1
|
||||
if self.bot:
|
||||
await self._send_autopay_failed_notification(user, user.balance_kopeks, renewal_cost)
|
||||
logger.warning(f"💳 Недостаточно средств для автопродления у пользователя {user.telegram_id}")
|
||||
|
||||
if processed_count > 0 or failed_count > 0:
|
||||
await self._log_monitoring_event(
|
||||
db, "autopayments_processed",
|
||||
f"Автоплатежи: успешно {processed_count}, неудачно {failed_count}",
|
||||
{"processed": processed_count, "failed": failed_count}
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка обработки автоплатежей: {e}")
|
||||
|
||||
async def _send_subscription_expired_notification(self, user: User) -> bool:
|
||||
try:
|
||||
message = """
|
||||
⛔ <b>Подписка истекла</b>
|
||||
|
||||
Ваша подписка истекла. Для восстановления доступа продлите подписку.
|
||||
|
||||
🔧 Доступ к серверам заблокирован до продления.
|
||||
"""
|
||||
|
||||
from aiogram.types import InlineKeyboardMarkup, InlineKeyboardButton
|
||||
|
||||
keyboard = InlineKeyboardMarkup(inline_keyboard=[
|
||||
[InlineKeyboardButton(text="💎 Купить подписку", callback_data="menu_buy")],
|
||||
[InlineKeyboardButton(text="💳 Пополнить баланс", callback_data="balance_topup")]
|
||||
])
|
||||
|
||||
await self.bot.send_message(
|
||||
user.telegram_id,
|
||||
message,
|
||||
parse_mode="HTML",
|
||||
reply_markup=keyboard
|
||||
)
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка отправки уведомления об истечении подписки пользователю {user.telegram_id}: {e}")
|
||||
return False
|
||||
|
||||
async def _send_subscription_expiring_notification(self, user: User, subscription: Subscription, days: int) -> bool:
|
||||
try:
|
||||
from app.utils.formatters import format_days_declension
|
||||
|
||||
texts = get_texts(user.language)
|
||||
days_text = format_days_declension(days, user.language)
|
||||
|
||||
if subscription.autopay_enabled:
|
||||
autopay_status = "✅ Включен - подписка продлится автоматически"
|
||||
action_text = f"💰 Убедитесь, что на балансе достаточно средств: {texts.format_price(user.balance_kopeks)}"
|
||||
else:
|
||||
autopay_status = "❌ Отключен - не забудьте продлить вручную!"
|
||||
action_text = "💡 Включите автоплатеж или продлите подписку вручную"
|
||||
|
||||
message = f"""
|
||||
⚠️ <b>Подписка истекает через {days_text}!</b>
|
||||
|
||||
Ваша платная подписка истекает {subscription.end_date.strftime("%d.%m.%Y %H:%M")}.
|
||||
|
||||
💳 <b>Автоплатеж:</b> {autopay_status}
|
||||
|
||||
{action_text}
|
||||
"""
|
||||
|
||||
from aiogram.types import InlineKeyboardMarkup, InlineKeyboardButton
|
||||
|
||||
keyboard = InlineKeyboardMarkup(inline_keyboard=[
|
||||
[InlineKeyboardButton(text="⏰ Продлить подписку", callback_data="subscription_extend")],
|
||||
[InlineKeyboardButton(text="💳 Пополнить баланс", callback_data="balance_topup")],
|
||||
[InlineKeyboardButton(text="📱 Моя подписка", callback_data="menu_subscription")]
|
||||
])
|
||||
|
||||
await self.bot.send_message(
|
||||
user.telegram_id,
|
||||
message,
|
||||
parse_mode="HTML",
|
||||
reply_markup=keyboard
|
||||
)
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка отправки уведомления об истечении подписки пользователю {user.telegram_id}: {e}")
|
||||
return False
|
||||
|
||||
async def _send_trial_ending_notification(self, user: User, subscription: Subscription) -> bool:
|
||||
try:
|
||||
texts = get_texts(user.language)
|
||||
|
||||
message = f"""
|
||||
🎁 <b>Тестовая подписка скоро закончится!</b>
|
||||
|
||||
Ваша тестовая подписка истекает через 2 часа.
|
||||
|
||||
💎 <b>Не хотите остаться без VPN?</b>
|
||||
Переходите на полную подписку со скидкой!
|
||||
|
||||
🔥 <b>Специальное предложение:</b>
|
||||
• 30 дней всего за {settings.format_price(settings.PRICE_30_DAYS)}
|
||||
• Безлимитный трафик
|
||||
• Все серверы доступны
|
||||
• Поддержка до 3 устройств
|
||||
|
||||
⚡️ Успейте оформить до окончания тестового периода!
|
||||
"""
|
||||
|
||||
from aiogram.types import InlineKeyboardMarkup, InlineKeyboardButton
|
||||
|
||||
keyboard = InlineKeyboardMarkup(inline_keyboard=[
|
||||
[InlineKeyboardButton(text="💎 Купить подписку", callback_data="menu_buy")],
|
||||
[InlineKeyboardButton(text="💰 Пополнить баланс", callback_data="balance_topup")]
|
||||
])
|
||||
|
||||
await self.bot.send_message(
|
||||
user.telegram_id,
|
||||
message,
|
||||
parse_mode="HTML",
|
||||
reply_markup=keyboard
|
||||
)
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка отправки уведомления об окончании тестовой подписки пользователю {user.telegram_id}: {e}")
|
||||
return False
|
||||
|
||||
async def _send_autopay_success_notification(self, user: User, amount: int, days: int):
|
||||
try:
|
||||
texts = get_texts(user.language)
|
||||
message = texts.AUTOPAY_SUCCESS.format(
|
||||
days=days,
|
||||
amount=settings.format_price(amount)
|
||||
)
|
||||
await self.bot.send_message(user.telegram_id, message, parse_mode="HTML")
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка отправки уведомления об автоплатеже пользователю {user.telegram_id}: {e}")
|
||||
|
||||
async def _send_autopay_failed_notification(self, user: User, balance: int, required: int):
|
||||
try:
|
||||
texts = get_texts(user.language)
|
||||
message = texts.AUTOPAY_FAILED.format(
|
||||
balance=settings.format_price(balance),
|
||||
required=settings.format_price(required)
|
||||
)
|
||||
|
||||
from aiogram.types import InlineKeyboardMarkup, InlineKeyboardButton
|
||||
|
||||
keyboard = InlineKeyboardMarkup(inline_keyboard=[
|
||||
[InlineKeyboardButton(text="💳 Пополнить баланс", callback_data="balance_topup")],
|
||||
[InlineKeyboardButton(text="📱 Моя подписка", callback_data="menu_subscription")]
|
||||
])
|
||||
|
||||
await self.bot.send_message(
|
||||
user.telegram_id,
|
||||
message,
|
||||
parse_mode="HTML",
|
||||
reply_markup=keyboard
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка отправки уведомления о неудачном автоплатеже пользователю {user.telegram_id}: {e}")
|
||||
|
||||
async def _cleanup_inactive_users(self, db: AsyncSession):
|
||||
try:
|
||||
now = datetime.utcnow()
|
||||
if now.hour != 3:
|
||||
return
|
||||
|
||||
inactive_users = await get_inactive_users(db, settings.INACTIVE_USER_DELETE_MONTHS)
|
||||
deleted_count = 0
|
||||
|
||||
for user in inactive_users:
|
||||
if not user.subscription or not user.subscription.is_active:
|
||||
success = await delete_user(db, user)
|
||||
if success:
|
||||
deleted_count += 1
|
||||
|
||||
if deleted_count > 0:
|
||||
await self._log_monitoring_event(
|
||||
db, "inactive_users_cleanup",
|
||||
f"Удалено {deleted_count} неактивных пользователей",
|
||||
{"deleted_count": deleted_count}
|
||||
)
|
||||
logger.info(f"🗑️ Удалено {deleted_count} неактивных пользователей")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка очистки неактивных пользователей: {e}")
|
||||
|
||||
async def _sync_with_remnawave(self, db: AsyncSession):
|
||||
try:
|
||||
now = datetime.utcnow()
|
||||
if now.minute != 0:
|
||||
return
|
||||
|
||||
async with self.subscription_service.api as api:
|
||||
system_stats = await api.get_system_stats()
|
||||
|
||||
await self._log_monitoring_event(
|
||||
db, "remnawave_sync",
|
||||
"Синхронизация с RemnaWave завершена",
|
||||
{"stats": system_stats}
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка синхронизации с RemnaWave: {e}")
|
||||
await self._log_monitoring_event(
|
||||
db, "remnawave_sync_error",
|
||||
f"Ошибка синхронизации с RemnaWave: {str(e)}",
|
||||
{"error": str(e)},
|
||||
is_success=False
|
||||
)
|
||||
|
||||
async def _log_monitoring_event(
|
||||
self,
|
||||
db: AsyncSession,
|
||||
event_type: str,
|
||||
message: str,
|
||||
data: Dict[str, Any] = None,
|
||||
is_success: bool = True
|
||||
):
|
||||
try:
|
||||
log_entry = MonitoringLog(
|
||||
event_type=event_type,
|
||||
message=message,
|
||||
data=data or {},
|
||||
is_success=is_success
|
||||
)
|
||||
|
||||
db.add(log_entry)
|
||||
await db.commit()
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка логирования события мониторинга: {e}")
|
||||
|
||||
async def get_monitoring_status(self, db: AsyncSession) -> Dict[str, Any]:
|
||||
try:
|
||||
from sqlalchemy import select, desc
|
||||
|
||||
recent_events_result = await db.execute(
|
||||
select(MonitoringLog)
|
||||
.order_by(desc(MonitoringLog.created_at))
|
||||
.limit(10)
|
||||
)
|
||||
recent_events = recent_events_result.scalars().all()
|
||||
|
||||
yesterday = datetime.utcnow() - timedelta(days=1)
|
||||
|
||||
events_24h_result = await db.execute(
|
||||
select(MonitoringLog)
|
||||
.where(MonitoringLog.created_at >= yesterday)
|
||||
)
|
||||
events_24h = events_24h_result.scalars().all()
|
||||
|
||||
successful_events = sum(1 for event in events_24h if event.is_success)
|
||||
failed_events = sum(1 for event in events_24h if not event.is_success)
|
||||
|
||||
return {
|
||||
"is_running": self.is_running,
|
||||
"last_update": datetime.utcnow(),
|
||||
"recent_events": [
|
||||
{
|
||||
"type": event.event_type,
|
||||
"message": event.message,
|
||||
"success": event.is_success,
|
||||
"created_at": event.created_at
|
||||
}
|
||||
for event in recent_events
|
||||
],
|
||||
"stats_24h": {
|
||||
"total_events": len(events_24h),
|
||||
"successful": successful_events,
|
||||
"failed": failed_events,
|
||||
"success_rate": round(successful_events / len(events_24h) * 100, 1) if events_24h else 0
|
||||
}
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка получения статуса мониторинга: {e}")
|
||||
return {
|
||||
"is_running": self.is_running,
|
||||
"last_update": datetime.utcnow(),
|
||||
"recent_events": [],
|
||||
"stats_24h": {
|
||||
"total_events": 0,
|
||||
"successful": 0,
|
||||
"failed": 0,
|
||||
"success_rate": 0
|
||||
}
|
||||
}
|
||||
|
||||
async def force_check_subscriptions(self, db: AsyncSession) -> Dict[str, int]:
|
||||
try:
|
||||
expired_subscriptions = await get_expired_subscriptions(db)
|
||||
expired_count = 0
|
||||
|
||||
for subscription in expired_subscriptions:
|
||||
await deactivate_subscription(db, subscription)
|
||||
expired_count += 1
|
||||
|
||||
expiring_subscriptions = await get_expiring_subscriptions(db, 1)
|
||||
expiring_count = len(expiring_subscriptions)
|
||||
|
||||
autopay_subscriptions = await get_subscriptions_for_autopay(db)
|
||||
autopay_processed = 0
|
||||
|
||||
for subscription in autopay_subscriptions:
|
||||
user = await get_user_by_id(db, subscription.user_id)
|
||||
if user and user.balance_kopeks >= settings.PRICE_30_DAYS:
|
||||
autopay_processed += 1
|
||||
|
||||
await self._log_monitoring_event(
|
||||
db, "manual_check_subscriptions",
|
||||
f"Принудительная проверка: истекло {expired_count}, истекает {expiring_count}, автоплатежей {autopay_processed}",
|
||||
{
|
||||
"expired": expired_count,
|
||||
"expiring": expiring_count,
|
||||
"autopay_ready": autopay_processed
|
||||
}
|
||||
)
|
||||
|
||||
return {
|
||||
"expired": expired_count,
|
||||
"expiring": expiring_count,
|
||||
"autopay_ready": autopay_processed
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка принудительной проверки подписок: {e}")
|
||||
return {"expired": 0, "expiring": 0, "autopay_ready": 0}
|
||||
|
||||
async def get_monitoring_logs(
|
||||
self,
|
||||
db: AsyncSession,
|
||||
limit: int = 50,
|
||||
event_type: Optional[str] = None
|
||||
) -> List[Dict[str, Any]]:
|
||||
try:
|
||||
from sqlalchemy import select, desc
|
||||
|
||||
query = select(MonitoringLog).order_by(desc(MonitoringLog.created_at))
|
||||
|
||||
if event_type:
|
||||
query = query.where(MonitoringLog.event_type == event_type)
|
||||
|
||||
query = query.limit(limit)
|
||||
|
||||
result = await db.execute(query)
|
||||
logs = result.scalars().all()
|
||||
|
||||
return [
|
||||
{
|
||||
"id": log.id,
|
||||
"event_type": log.event_type,
|
||||
"message": log.message,
|
||||
"data": log.data,
|
||||
"is_success": log.is_success,
|
||||
"created_at": log.created_at
|
||||
}
|
||||
for log in logs
|
||||
]
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка получения логов мониторинга: {e}")
|
||||
return []
|
||||
|
||||
async def cleanup_old_logs(self, db: AsyncSession, days: int = 30) -> int:
|
||||
try:
|
||||
from sqlalchemy import delete
|
||||
|
||||
cutoff_date = datetime.utcnow() - timedelta(days=days)
|
||||
|
||||
result = await db.execute(
|
||||
delete(MonitoringLog).where(MonitoringLog.created_at < cutoff_date)
|
||||
)
|
||||
|
||||
deleted_count = result.rowcount
|
||||
await db.commit()
|
||||
|
||||
logger.info(f"Удалено {deleted_count} старых записей логов")
|
||||
return deleted_count
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка очистки логов: {e}")
|
||||
return 0
|
||||
|
||||
|
||||
monitoring_service = MonitoringService()
|
||||
@@ -0,0 +1,441 @@
|
||||
import logging
|
||||
import hashlib
|
||||
import hmac
|
||||
from typing import Optional, Dict, Any
|
||||
from datetime import datetime
|
||||
from aiogram import Bot
|
||||
from aiogram.types import LabeledPrice
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.config import settings
|
||||
from app.services.yookassa_service import YooKassaService
|
||||
from app.external.telegram_stars import TelegramStarsService
|
||||
from app.database.crud.yookassa import create_yookassa_payment, link_yookassa_payment_to_transaction
|
||||
from app.database.crud.transaction import create_transaction
|
||||
from app.database.crud.user import add_user_balance, get_user_by_id
|
||||
from app.database.models import TransactionType, PaymentMethod
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class PaymentService:
|
||||
|
||||
def __init__(self, bot: Optional[Bot] = None):
|
||||
self.bot = bot
|
||||
self.yookassa_service = YooKassaService() if settings.is_yookassa_enabled() else None
|
||||
self.stars_service = TelegramStarsService(bot) if bot else None
|
||||
|
||||
async def create_stars_invoice(
|
||||
self,
|
||||
amount_kopeks: int,
|
||||
description: str,
|
||||
payload: Optional[str] = None
|
||||
) -> str:
|
||||
|
||||
if not self.bot or not self.stars_service:
|
||||
raise ValueError("Bot instance required for Stars payments")
|
||||
|
||||
try:
|
||||
amount_rubles = amount_kopeks / 100
|
||||
stars_amount = TelegramStarsService.calculate_stars_from_rubles(amount_rubles)
|
||||
|
||||
invoice_link = await self.bot.create_invoice_link(
|
||||
title="Пополнение баланса VPN",
|
||||
description=f"{description} (≈{stars_amount} ⭐)",
|
||||
payload=payload or f"balance_topup_{amount_kopeks}",
|
||||
provider_token="",
|
||||
currency="XTR",
|
||||
prices=[LabeledPrice(label="Пополнение", amount=stars_amount)]
|
||||
)
|
||||
|
||||
logger.info(f"Создан Stars invoice на {stars_amount} звезд (~{amount_rubles:.2f}₽)")
|
||||
return invoice_link
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка создания Stars invoice: {e}")
|
||||
raise
|
||||
|
||||
async def process_stars_payment(
|
||||
self,
|
||||
db: AsyncSession,
|
||||
user_id: int,
|
||||
stars_amount: int,
|
||||
payload: str,
|
||||
telegram_payment_charge_id: str
|
||||
) -> bool:
|
||||
try:
|
||||
rubles_amount = TelegramStarsService.calculate_rubles_from_stars(stars_amount)
|
||||
amount_kopeks = int(rubles_amount * 100)
|
||||
|
||||
transaction = await create_transaction(
|
||||
db=db,
|
||||
user_id=user_id,
|
||||
type=TransactionType.DEPOSIT,
|
||||
amount_kopeks=amount_kopeks,
|
||||
description=f"Пополнение через Telegram Stars ({stars_amount} ⭐)",
|
||||
payment_method=PaymentMethod.TELEGRAM_STARS,
|
||||
external_id=telegram_payment_charge_id,
|
||||
is_completed=True
|
||||
)
|
||||
|
||||
user = await get_user_by_id(db, user_id)
|
||||
if user:
|
||||
old_balance = user.balance_kopeks
|
||||
user.balance_kopeks += amount_kopeks
|
||||
user.updated_at = datetime.utcnow()
|
||||
|
||||
await db.commit()
|
||||
await db.refresh(user)
|
||||
|
||||
logger.info(f"💰 Баланс пользователя {user.telegram_id} изменен: {old_balance} → {user.balance_kopeks} (изменение: +{amount_kopeks})")
|
||||
|
||||
description_for_referral = f"Пополнение Stars: {rubles_amount:.2f}₽ ({stars_amount} ⭐)"
|
||||
logger.info(f"🔍 Проверка реферальной логики для описания: '{description_for_referral}'")
|
||||
|
||||
if any(word in description_for_referral.lower() for word in ["пополнение", "stars", "yookassa", "topup"]) and not any(word in description_for_referral.lower() for word in ["комиссия", "бонус"]):
|
||||
logger.info(f"📞 Вызов process_referral_topup для пользователя {user_id}")
|
||||
try:
|
||||
from app.services.referral_service import process_referral_topup
|
||||
await process_referral_topup(db, user_id, amount_kopeks, self.bot)
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка обработки реферального пополнения: {e}")
|
||||
else:
|
||||
logger.info(f"❌ Описание '{description_for_referral}' не подходит для реферальной логики")
|
||||
|
||||
if self.bot:
|
||||
try:
|
||||
await self.bot.send_message(
|
||||
user.telegram_id,
|
||||
f"✅ <b>Пополнение успешно!</b>\n\n"
|
||||
f"⭐ Звезд: {stars_amount}\n"
|
||||
f"💰 Сумма: {settings.format_price(amount_kopeks)}\n"
|
||||
f"🏦 Способ: Telegram Stars\n"
|
||||
f"🆔 Транзакция: {telegram_payment_charge_id[:8]}...\n\n"
|
||||
f"Баланс пополнен автоматически!",
|
||||
parse_mode="HTML"
|
||||
)
|
||||
logger.info(f"✅ Отправлено уведомление пользователю {user.telegram_id} о пополнении на {rubles_amount:.2f}₽")
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка отправки уведомления о пополнении Stars: {e}")
|
||||
|
||||
logger.info(
|
||||
f"✅ Обработан Stars платеж: пользователь {user_id}, "
|
||||
f"{stars_amount} звезд → {rubles_amount:.2f}₽"
|
||||
)
|
||||
return True
|
||||
else:
|
||||
logger.error(f"Пользователь с ID {user_id} не найден при обработке Stars платежа")
|
||||
return False
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка обработки Stars платежа: {e}", exc_info=True)
|
||||
return False
|
||||
|
||||
async def create_yookassa_payment(
|
||||
self,
|
||||
db: AsyncSession,
|
||||
user_id: int,
|
||||
amount_kopeks: int,
|
||||
description: str,
|
||||
receipt_email: Optional[str] = None,
|
||||
receipt_phone: Optional[str] = None,
|
||||
metadata: Optional[Dict[str, Any]] = None
|
||||
) -> Optional[Dict[str, Any]]:
|
||||
|
||||
if not self.yookassa_service:
|
||||
logger.error("YooKassa сервис не инициализирован")
|
||||
return None
|
||||
|
||||
try:
|
||||
amount_rubles = amount_kopeks / 100
|
||||
|
||||
payment_metadata = metadata or {}
|
||||
payment_metadata.update({
|
||||
"user_id": str(user_id),
|
||||
"amount_kopeks": str(amount_kopeks),
|
||||
"type": "balance_topup"
|
||||
})
|
||||
|
||||
yookassa_response = await self.yookassa_service.create_payment(
|
||||
amount=amount_rubles,
|
||||
currency="RUB",
|
||||
description=description,
|
||||
metadata=payment_metadata,
|
||||
receipt_email=receipt_email,
|
||||
receipt_phone=receipt_phone
|
||||
)
|
||||
|
||||
if not yookassa_response or yookassa_response.get("error"):
|
||||
logger.error(f"Ошибка создания платежа YooKassa: {yookassa_response}")
|
||||
return None
|
||||
|
||||
yookassa_created_at = None
|
||||
if yookassa_response.get("created_at"):
|
||||
try:
|
||||
dt_with_tz = datetime.fromisoformat(
|
||||
yookassa_response["created_at"].replace('Z', '+00:00')
|
||||
)
|
||||
yookassa_created_at = dt_with_tz.replace(tzinfo=None)
|
||||
except Exception as e:
|
||||
logger.warning(f"Не удалось парсить created_at: {e}")
|
||||
yookassa_created_at = None
|
||||
|
||||
local_payment = await create_yookassa_payment(
|
||||
db=db,
|
||||
user_id=user_id,
|
||||
yookassa_payment_id=yookassa_response["id"],
|
||||
amount_kopeks=amount_kopeks,
|
||||
currency="RUB",
|
||||
description=description,
|
||||
status=yookassa_response["status"],
|
||||
confirmation_url=yookassa_response.get("confirmation_url"),
|
||||
metadata_json=payment_metadata,
|
||||
payment_method_type=None,
|
||||
yookassa_created_at=yookassa_created_at,
|
||||
test_mode=yookassa_response.get("test_mode", False)
|
||||
)
|
||||
|
||||
logger.info(f"Создан платеж YooKassa {yookassa_response['id']} на {amount_rubles}₽ для пользователя {user_id}")
|
||||
|
||||
return {
|
||||
"local_payment_id": local_payment.id,
|
||||
"yookassa_payment_id": yookassa_response["id"],
|
||||
"confirmation_url": yookassa_response.get("confirmation_url"),
|
||||
"amount_kopeks": amount_kopeks,
|
||||
"amount_rubles": amount_rubles,
|
||||
"status": yookassa_response["status"],
|
||||
"created_at": local_payment.created_at
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка создания платежа YooKassa: {e}")
|
||||
return None
|
||||
|
||||
async def process_yookassa_webhook(self, db: AsyncSession, webhook_data: dict) -> bool:
|
||||
try:
|
||||
from app.database.crud.yookassa import (
|
||||
get_yookassa_payment_by_id,
|
||||
update_yookassa_payment_status,
|
||||
link_yookassa_payment_to_transaction
|
||||
)
|
||||
from app.database.crud.transaction import create_transaction
|
||||
from app.database.models import TransactionType, PaymentMethod
|
||||
|
||||
payment_object = webhook_data.get("object", {})
|
||||
yookassa_payment_id = payment_object.get("id")
|
||||
status = payment_object.get("status")
|
||||
paid = payment_object.get("paid", False)
|
||||
|
||||
if not yookassa_payment_id:
|
||||
logger.error("Webhook без ID платежа")
|
||||
return False
|
||||
|
||||
payment = await get_yookassa_payment_by_id(db, yookassa_payment_id)
|
||||
if not payment:
|
||||
logger.error(f"Платеж не найден в БД: {yookassa_payment_id}")
|
||||
return False
|
||||
|
||||
captured_at = None
|
||||
if status == "succeeded":
|
||||
captured_at = datetime.utcnow()
|
||||
|
||||
updated_payment = await update_yookassa_payment_status(
|
||||
db,
|
||||
yookassa_payment_id,
|
||||
status,
|
||||
is_paid=paid,
|
||||
is_captured=(status == "succeeded"),
|
||||
captured_at=captured_at,
|
||||
payment_method_type=payment_object.get("payment_method", {}).get("type")
|
||||
)
|
||||
|
||||
if status == "succeeded" and paid and not updated_payment.transaction_id:
|
||||
transaction = await create_transaction(
|
||||
db,
|
||||
user_id=updated_payment.user_id,
|
||||
type=TransactionType.DEPOSIT,
|
||||
amount_kopeks=updated_payment.amount_kopeks,
|
||||
description=f"Пополнение через YooKassa ({yookassa_payment_id[:8]}...)",
|
||||
payment_method=PaymentMethod.YOOKASSA,
|
||||
external_id=yookassa_payment_id,
|
||||
is_completed=True
|
||||
)
|
||||
|
||||
await link_yookassa_payment_to_transaction(
|
||||
db, yookassa_payment_id, transaction.id
|
||||
)
|
||||
|
||||
user = await get_user_by_id(db, updated_payment.user_id)
|
||||
if user:
|
||||
old_balance = user.balance_kopeks
|
||||
user.balance_kopeks += updated_payment.amount_kopeks
|
||||
user.updated_at = datetime.utcnow()
|
||||
|
||||
await db.commit()
|
||||
await db.refresh(user)
|
||||
|
||||
try:
|
||||
from app.services.referral_service import process_referral_topup
|
||||
await process_referral_topup(db, user.id, updated_payment.amount_kopeks, self.bot)
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка обработки реферального пополнения YooKassa: {e}")
|
||||
|
||||
if self.bot:
|
||||
try:
|
||||
await self.bot.send_message(
|
||||
user.telegram_id,
|
||||
f"✅ <b>Пополнение успешно!</b>\n\n"
|
||||
f"💰 Сумма: {settings.format_price(updated_payment.amount_kopeks)}\n"
|
||||
f"🏦 Способ: Банковская карта\n"
|
||||
f"🆔 Транзакция: {yookassa_payment_id[:8]}...\n\n"
|
||||
f"Баланс пополнен автоматически!",
|
||||
parse_mode="HTML"
|
||||
)
|
||||
logger.info(f"✅ Отправлено уведомление пользователю {user.telegram_id} о пополнении на {updated_payment.amount_kopeks/100:.2f}₽")
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка отправки уведомления о пополнении: {e}")
|
||||
else:
|
||||
logger.error(f"Пользователь с ID {updated_payment.user_id} не найден при пополнении баланса")
|
||||
return False
|
||||
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка обработки YooKassa webhook: {e}", exc_info=True)
|
||||
return False
|
||||
|
||||
async def _process_successful_yookassa_payment(
|
||||
self,
|
||||
db: AsyncSession,
|
||||
payment: "YooKassaPayment"
|
||||
) -> bool:
|
||||
|
||||
try:
|
||||
transaction = await create_transaction(
|
||||
db=db,
|
||||
user_id=payment.user_id,
|
||||
transaction_type=TransactionType.DEPOSIT,
|
||||
amount_kopeks=payment.amount_kopeks,
|
||||
description=f"Пополнение через YooKassa: {payment.description}",
|
||||
payment_method=PaymentMethod.YOOKASSA,
|
||||
external_id=payment.yookassa_payment_id,
|
||||
is_completed=True
|
||||
)
|
||||
|
||||
await link_yookassa_payment_to_transaction(
|
||||
db=db,
|
||||
yookassa_payment_id=payment.yookassa_payment_id,
|
||||
transaction_id=transaction.id
|
||||
)
|
||||
|
||||
user = await get_user_by_id(db, payment.user_id)
|
||||
if user:
|
||||
await add_user_balance(db, user, payment.amount_kopeks, f"Пополнение YooKassa: {payment.amount_kopeks/100:.2f}₽")
|
||||
|
||||
logger.info(f"Успешно обработан платеж YooKassa {payment.yookassa_payment_id}: "
|
||||
f"пользователь {payment.user_id} получил {payment.amount_kopeks/100}₽")
|
||||
|
||||
if self.bot and user:
|
||||
try:
|
||||
await self._send_payment_success_notification(
|
||||
user.telegram_id,
|
||||
payment.amount_kopeks
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка отправки уведомления о платеже: {e}")
|
||||
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка обработки успешного платежа YooKassa {payment.yookassa_payment_id}: {e}")
|
||||
return False
|
||||
|
||||
async def _send_payment_success_notification(
|
||||
self,
|
||||
telegram_id: int,
|
||||
amount_kopeks: int
|
||||
) -> None:
|
||||
|
||||
if not self.bot:
|
||||
return
|
||||
|
||||
try:
|
||||
message = (f"✅ <b>Платеж успешно завершен!</b>\n\n"
|
||||
f"💰 Сумма: {settings.format_price(amount_kopeks)}\n"
|
||||
f"💳 Способ: Банковская карта (YooKassa)\n\n"
|
||||
f"Средства зачислены на ваш баланс!")
|
||||
|
||||
await self.bot.send_message(
|
||||
chat_id=telegram_id,
|
||||
text=message,
|
||||
parse_mode="HTML"
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка отправки уведомления пользователю {telegram_id}: {e}")
|
||||
|
||||
async def create_tribute_payment(
|
||||
self,
|
||||
amount_kopeks: int,
|
||||
user_id: int,
|
||||
description: str
|
||||
) -> str:
|
||||
|
||||
if not settings.TRIBUTE_ENABLED:
|
||||
raise ValueError("Tribute payments are disabled")
|
||||
|
||||
try:
|
||||
payment_data = {
|
||||
"amount": amount_kopeks,
|
||||
"currency": "RUB",
|
||||
"description": description,
|
||||
"user_id": user_id,
|
||||
"callback_url": f"{settings.WEBHOOK_URL}/tribute/callback"
|
||||
}
|
||||
|
||||
payment_url = f"https://tribute.ru/pay?amount={amount_kopeks}&user={user_id}"
|
||||
|
||||
logger.info(f"Создан Tribute платеж на {amount_kopeks/100}₽ для пользователя {user_id}")
|
||||
return payment_url
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка создания Tribute платежа: {e}")
|
||||
raise
|
||||
|
||||
def verify_tribute_webhook(
|
||||
self,
|
||||
data: dict,
|
||||
signature: str
|
||||
) -> bool:
|
||||
|
||||
if not settings.TRIBUTE_WEBHOOK_SECRET:
|
||||
return False
|
||||
|
||||
try:
|
||||
message = str(data).encode()
|
||||
expected_signature = hmac.new(
|
||||
settings.TRIBUTE_WEBHOOK_SECRET.encode(),
|
||||
message,
|
||||
hashlib.sha256
|
||||
).hexdigest()
|
||||
|
||||
return hmac.compare_digest(signature, expected_signature)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка проверки Tribute webhook: {e}")
|
||||
return False
|
||||
|
||||
async def process_successful_payment(
|
||||
self,
|
||||
payment_id: str,
|
||||
amount_kopeks: int,
|
||||
user_id: int,
|
||||
payment_method: str
|
||||
) -> bool:
|
||||
|
||||
try:
|
||||
logger.info(f"Обработан успешный платеж: {payment_id}, {amount_kopeks/100}₽, {user_id}")
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка обработки платежа: {e}")
|
||||
return False
|
||||
@@ -0,0 +1,144 @@
|
||||
import logging
|
||||
from datetime import datetime
|
||||
from typing import Dict, Any
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.database.crud.promocode import (
|
||||
get_promocode_by_code, use_promocode, check_user_promocode_usage,
|
||||
create_promocode_use, get_promocode_use_by_user_and_code
|
||||
)
|
||||
from app.database.crud.user import add_user_balance, get_user_by_id
|
||||
from app.database.crud.subscription import extend_subscription, get_subscription_by_user_id
|
||||
from app.database.models import PromoCodeType, SubscriptionStatus, User, PromoCode
|
||||
from app.services.remnawave_service import RemnaWaveService
|
||||
from app.services.subscription_service import SubscriptionService
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class PromoCodeService:
|
||||
|
||||
def __init__(self):
|
||||
self.remnawave_service = RemnaWaveService()
|
||||
self.subscription_service = SubscriptionService()
|
||||
|
||||
async def activate_promocode(
|
||||
self,
|
||||
db: AsyncSession,
|
||||
user_id: int,
|
||||
code: str
|
||||
) -> Dict[str, Any]:
|
||||
|
||||
try:
|
||||
user = await get_user_by_id(db, user_id)
|
||||
if not user:
|
||||
return {"success": False, "error": "user_not_found"}
|
||||
|
||||
promocode = await get_promocode_by_code(db, code)
|
||||
if not promocode:
|
||||
return {"success": False, "error": "not_found"}
|
||||
|
||||
if not promocode.is_valid:
|
||||
if promocode.current_uses >= promocode.max_uses:
|
||||
return {"success": False, "error": "used"}
|
||||
else:
|
||||
return {"success": False, "error": "expired"}
|
||||
|
||||
existing_use = await check_user_promocode_usage(db, user_id, promocode.id)
|
||||
if existing_use:
|
||||
return {"success": False, "error": "already_used_by_user"}
|
||||
|
||||
result_description = await self._apply_promocode_effects(db, user, promocode)
|
||||
|
||||
if promocode.type == PromoCodeType.SUBSCRIPTION_DAYS.value and promocode.subscription_days > 0:
|
||||
from app.utils.user_utils import mark_user_as_had_paid_subscription
|
||||
await mark_user_as_had_paid_subscription(db, user)
|
||||
|
||||
logger.info(f"🎯 Пользователь {user.telegram_id} получил платную подписку через промокод {code}")
|
||||
|
||||
await create_promocode_use(db, promocode.id, user_id)
|
||||
|
||||
promocode.current_uses += 1
|
||||
await db.commit()
|
||||
|
||||
logger.info(f"✅ Пользователь {user.telegram_id} активировал промокод {code}")
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"description": result_description
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка активации промокода {code} для пользователя {user_id}: {e}")
|
||||
await db.rollback()
|
||||
return {"success": False, "error": "server_error"}
|
||||
|
||||
async def _apply_promocode_effects(self, db: AsyncSession, user: User, promocode: PromoCode) -> str:
|
||||
effects = []
|
||||
|
||||
if promocode.balance_bonus_kopeks > 0:
|
||||
await add_user_balance(
|
||||
db, user, promocode.balance_bonus_kopeks,
|
||||
f"Бонус по промокоду {promocode.code}"
|
||||
)
|
||||
|
||||
balance_bonus_rubles = promocode.balance_bonus_kopeks / 100
|
||||
effects.append(f"💰 Баланс пополнен на {balance_bonus_rubles}₽")
|
||||
|
||||
if promocode.subscription_days > 0:
|
||||
from app.config import settings
|
||||
|
||||
subscription = await get_subscription_by_user_id(db, user.id)
|
||||
|
||||
if subscription:
|
||||
await extend_subscription(db, subscription, promocode.subscription_days)
|
||||
|
||||
await self.subscription_service.update_remnawave_user(db, subscription)
|
||||
|
||||
effects.append(f"⏰ Подписка продлена на {promocode.subscription_days} дней")
|
||||
logger.info(f"✅ Подписка пользователя {user.telegram_id} продлена на {promocode.subscription_days} дней в RemnaWave с текущими сквадами")
|
||||
|
||||
else:
|
||||
from app.database.crud.subscription import create_paid_subscription
|
||||
|
||||
trial_squads = []
|
||||
if hasattr(settings, 'TRIAL_SQUAD_UUID') and settings.TRIAL_SQUAD_UUID:
|
||||
trial_squads = [settings.TRIAL_SQUAD_UUID]
|
||||
|
||||
new_subscription = await create_paid_subscription(
|
||||
db=db,
|
||||
user_id=user.id,
|
||||
duration_days=promocode.subscription_days,
|
||||
traffic_limit_gb=0,
|
||||
device_limit=1,
|
||||
connected_squads=trial_squads
|
||||
)
|
||||
|
||||
await self.subscription_service.create_remnawave_user(db, new_subscription)
|
||||
|
||||
effects.append(f"🎉 Получена подписка на {promocode.subscription_days} дней")
|
||||
logger.info(f"✅ Создана новая подписка для пользователя {user.telegram_id} на {promocode.subscription_days} дней с триал сквадом {trial_squads}")
|
||||
|
||||
if promocode.type == PromoCodeType.TRIAL_SUBSCRIPTION.value:
|
||||
from app.database.crud.subscription import create_trial_subscription
|
||||
from app.config import settings
|
||||
|
||||
subscription = await get_subscription_by_user_id(db, user.id)
|
||||
|
||||
if not subscription:
|
||||
trial_days = promocode.subscription_days if promocode.subscription_days > 0 else settings.TRIAL_DURATION_DAYS
|
||||
|
||||
trial_subscription = await create_trial_subscription(
|
||||
db,
|
||||
user.id,
|
||||
duration_days=trial_days
|
||||
)
|
||||
|
||||
await self.subscription_service.create_remnawave_user(db, trial_subscription)
|
||||
|
||||
effects.append(f"🎁 Активирована тестовая подписка на {trial_days} дней")
|
||||
logger.info(f"✅ Создана триал подписка для пользователя {user.telegram_id} на {trial_days} дней")
|
||||
else:
|
||||
effects.append("ℹ️ У вас уже есть активная подписка")
|
||||
|
||||
return "\n".join(effects) if effects else "✅ Промокод активирован"
|
||||
@@ -0,0 +1,263 @@
|
||||
import logging
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy import select, delete
|
||||
from aiogram import Bot
|
||||
|
||||
from app.config import settings
|
||||
from app.database.crud.user import add_user_balance, get_user_by_id
|
||||
from app.database.crud.referral import create_referral_earning
|
||||
from app.database.models import TransactionType, ReferralEarning
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
async def send_referral_notification(
|
||||
bot: Bot,
|
||||
user_id: int,
|
||||
message: str
|
||||
):
|
||||
try:
|
||||
await bot.send_message(user_id, message, parse_mode="HTML")
|
||||
logger.info(f"✅ Уведомление отправлено пользователю {user_id}")
|
||||
except Exception as e:
|
||||
logger.error(f"❌ Ошибка отправки уведомления пользователю {user_id}: {e}")
|
||||
|
||||
|
||||
async def process_referral_registration(
|
||||
db: AsyncSession,
|
||||
new_user_id: int,
|
||||
referrer_id: int,
|
||||
bot: Bot = None
|
||||
):
|
||||
try:
|
||||
new_user = await get_user_by_id(db, new_user_id)
|
||||
referrer = await get_user_by_id(db, referrer_id)
|
||||
|
||||
if not new_user or not referrer:
|
||||
logger.error(f"Пользователи не найдены: new_user_id={new_user_id}, referrer_id={referrer_id}")
|
||||
return False
|
||||
|
||||
if new_user.referred_by_id != referrer_id:
|
||||
logger.error(f"Пользователь {new_user_id} не привязан к рефереру {referrer_id}")
|
||||
return False
|
||||
|
||||
await create_referral_earning(
|
||||
db=db,
|
||||
user_id=referrer_id,
|
||||
referral_id=new_user_id,
|
||||
amount_kopeks=0,
|
||||
reason="referral_registration_pending"
|
||||
)
|
||||
|
||||
if bot:
|
||||
referral_notification = (
|
||||
f"🎉 <b>Добро пожаловать!</b>\n\n"
|
||||
f"Вы перешли по реферальной ссылке пользователя <b>{referrer.full_name}</b>!\n\n"
|
||||
f"💰 При первом пополнении от {settings.format_price(settings.REFERRAL_MINIMUM_TOPUP_KOPEKS)} "
|
||||
f"вы получите бонус {settings.format_price(settings.REFERRAL_FIRST_TOPUP_BONUS_KOPEKS)}!\n\n"
|
||||
f"🎁 Ваш реферер также получит награду за ваше первое пополнение."
|
||||
)
|
||||
await send_referral_notification(bot, new_user.telegram_id, referral_notification)
|
||||
|
||||
inviter_notification = (
|
||||
f"👥 <b>Новый реферал!</b>\n\n"
|
||||
f"По вашей ссылке зарегистрировался пользователь <b>{new_user.full_name}</b>!\n\n"
|
||||
f"💰 Когда он пополнит баланс от {settings.format_price(settings.REFERRAL_MINIMUM_TOPUP_KOPEKS)}, "
|
||||
f"вы получите {settings.format_price(settings.REFERRAL_INVITER_BONUS_KOPEKS)}\n\n"
|
||||
f"📈 С каждого последующего пополнения вы будете получать {settings.REFERRAL_COMMISSION_PERCENT}% комиссии."
|
||||
)
|
||||
await send_referral_notification(bot, referrer.telegram_id, inviter_notification)
|
||||
|
||||
logger.info(f"✅ Зарегистрирован реферал {new_user_id} для {referrer_id}. Бонусы будут выданы после пополнения.")
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка обработки реферальной регистрации: {e}")
|
||||
return False
|
||||
|
||||
|
||||
async def process_referral_topup(
|
||||
db: AsyncSession,
|
||||
user_id: int,
|
||||
topup_amount_kopeks: int,
|
||||
bot: Bot = None
|
||||
):
|
||||
try:
|
||||
user = await get_user_by_id(db, user_id)
|
||||
if not user or not user.referred_by_id:
|
||||
logger.info(f"Пользователь {user_id} не является рефералом")
|
||||
return True
|
||||
|
||||
if topup_amount_kopeks < settings.REFERRAL_MINIMUM_TOPUP_KOPEKS:
|
||||
logger.info(f"Пополнение {user_id} на {topup_amount_kopeks/100}₽ меньше минимума")
|
||||
return True
|
||||
|
||||
referrer = await get_user_by_id(db, user.referred_by_id)
|
||||
if not referrer:
|
||||
logger.error(f"Реферер {user.referred_by_id} не найден")
|
||||
return False
|
||||
|
||||
if not user.has_made_first_topup:
|
||||
user.has_made_first_topup = True
|
||||
await db.commit()
|
||||
|
||||
try:
|
||||
await db.execute(
|
||||
delete(ReferralEarning).where(
|
||||
ReferralEarning.user_id == referrer.id,
|
||||
ReferralEarning.referral_id == user_id,
|
||||
ReferralEarning.reason == "referral_registration_pending"
|
||||
)
|
||||
)
|
||||
await db.commit()
|
||||
logger.info(f"🗑️ Удалена запись 'ожидание пополнения' для реферала {user_id}")
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка удаления записи ожидания: {e}")
|
||||
|
||||
if settings.REFERRAL_FIRST_TOPUP_BONUS_KOPEKS > 0:
|
||||
await add_user_balance(
|
||||
db, user, settings.REFERRAL_FIRST_TOPUP_BONUS_KOPEKS,
|
||||
f"Бонус за первое пополнение по реферальной программе",
|
||||
bot=bot
|
||||
)
|
||||
logger.info(f"💰 Реферал {user_id} получил бонус {settings.REFERRAL_FIRST_TOPUP_BONUS_KOPEKS/100}₽")
|
||||
|
||||
if bot:
|
||||
bonus_notification = (
|
||||
f"🎉 <b>Бонус получен!</b>\n\n"
|
||||
f"За первое пополнение вы получили бонус "
|
||||
f"{settings.format_price(settings.REFERRAL_FIRST_TOPUP_BONUS_KOPEKS)}!\n\n"
|
||||
f"💎 Средства зачислены на ваш баланс."
|
||||
)
|
||||
await send_referral_notification(bot, user.telegram_id, bonus_notification)
|
||||
|
||||
if settings.REFERRAL_INVITER_BONUS_KOPEKS > 0:
|
||||
await add_user_balance(
|
||||
db, referrer, settings.REFERRAL_INVITER_BONUS_KOPEKS,
|
||||
f"Бонус за первое пополнение реферала {user.full_name}",
|
||||
bot=bot
|
||||
)
|
||||
|
||||
await create_referral_earning(
|
||||
db=db,
|
||||
user_id=referrer.id,
|
||||
referral_id=user_id,
|
||||
amount_kopeks=settings.REFERRAL_INVITER_BONUS_KOPEKS,
|
||||
reason="referral_first_topup"
|
||||
)
|
||||
logger.info(f"💰 Реферер {referrer.telegram_id} получил бонус {settings.REFERRAL_INVITER_BONUS_KOPEKS/100}₽")
|
||||
|
||||
if bot:
|
||||
inviter_bonus_notification = (
|
||||
f"💰 <b>Реферальная награда!</b>\n\n"
|
||||
f"Ваш реферал <b>{user.full_name}</b> сделал первое пополнение!\n\n"
|
||||
f"🎁 Вы получили награду: {settings.format_price(settings.REFERRAL_INVITER_BONUS_KOPEKS)}\n\n"
|
||||
f"📈 Теперь с каждого его пополнения вы будете получать {settings.REFERRAL_COMMISSION_PERCENT}% комиссии."
|
||||
)
|
||||
await send_referral_notification(bot, referrer.telegram_id, inviter_bonus_notification)
|
||||
|
||||
else:
|
||||
if settings.REFERRAL_COMMISSION_PERCENT > 0:
|
||||
commission_amount = int(topup_amount_kopeks * settings.REFERRAL_COMMISSION_PERCENT / 100)
|
||||
|
||||
if commission_amount > 0:
|
||||
await add_user_balance(
|
||||
db, referrer, commission_amount,
|
||||
f"Комиссия {settings.REFERRAL_COMMISSION_PERCENT}% с пополнения {user.full_name}",
|
||||
bot=bot
|
||||
)
|
||||
|
||||
await create_referral_earning(
|
||||
db=db,
|
||||
user_id=referrer.id,
|
||||
referral_id=user_id,
|
||||
amount_kopeks=commission_amount,
|
||||
reason="referral_commission_topup"
|
||||
)
|
||||
|
||||
logger.info(f"💰 Комиссия с пополнения: {referrer.telegram_id} получил {commission_amount/100}₽")
|
||||
|
||||
if bot:
|
||||
commission_notification = (
|
||||
f"💰 <b>Реферальная комиссия!</b>\n\n"
|
||||
f"Ваш реферал <b>{user.full_name}</b> пополнил баланс на "
|
||||
f"{settings.format_price(topup_amount_kopeks)}\n\n"
|
||||
f"🎁 Ваша комиссия ({settings.REFERRAL_COMMISSION_PERCENT}%): "
|
||||
f"{settings.format_price(commission_amount)}\n\n"
|
||||
f"💎 Средства зачислены на ваш баланс."
|
||||
)
|
||||
await send_referral_notification(bot, referrer.telegram_id, commission_notification)
|
||||
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка обработки пополнения реферала: {e}")
|
||||
return False
|
||||
|
||||
|
||||
async def process_referral_purchase(
|
||||
db: AsyncSession,
|
||||
user_id: int,
|
||||
purchase_amount_kopeks: int,
|
||||
transaction_id: int = None,
|
||||
bot: Bot = None
|
||||
):
|
||||
try:
|
||||
user = await get_user_by_id(db, user_id)
|
||||
if not user or not user.referred_by_id:
|
||||
return True
|
||||
|
||||
referrer = await get_user_by_id(db, user.referred_by_id)
|
||||
if not referrer:
|
||||
logger.error(f"Реферер {user.referred_by_id} не найден")
|
||||
return False
|
||||
|
||||
if not (0 <= settings.REFERRAL_COMMISSION_PERCENT <= 100):
|
||||
logger.error(f"❌ КРИТИЧЕСКАЯ ОШИБКА: REFERRAL_COMMISSION_PERCENT = {settings.REFERRAL_COMMISSION_PERCENT} некорректный!")
|
||||
commission_percent = 10
|
||||
else:
|
||||
commission_percent = settings.REFERRAL_COMMISSION_PERCENT
|
||||
|
||||
commission_amount = int(purchase_amount_kopeks * commission_percent / 100)
|
||||
|
||||
if commission_amount > 0:
|
||||
await add_user_balance(
|
||||
db, referrer, commission_amount,
|
||||
f"Комиссия {commission_percent}% с покупки {user.full_name}",
|
||||
bot=bot
|
||||
)
|
||||
|
||||
await create_referral_earning(
|
||||
db=db,
|
||||
user_id=referrer.id,
|
||||
referral_id=user_id,
|
||||
amount_kopeks=commission_amount,
|
||||
reason="referral_commission",
|
||||
referral_transaction_id=transaction_id
|
||||
)
|
||||
|
||||
logger.info(f"💰 Комиссия с покупки: {referrer.telegram_id} получил {commission_amount/100}₽")
|
||||
|
||||
if bot:
|
||||
purchase_commission_notification = (
|
||||
f"💰 <b>Комиссия с покупки!</b>\n\n"
|
||||
f"Ваш реферал <b>{user.full_name}</b> совершил покупку на "
|
||||
f"{settings.format_price(purchase_amount_kopeks)}\n\n"
|
||||
f"🎁 Ваша комиссия ({commission_percent}%): "
|
||||
f"{settings.format_price(commission_amount)}\n\n"
|
||||
f"💎 Средства зачислены на ваш баланс."
|
||||
)
|
||||
await send_referral_notification(bot, referrer.telegram_id, purchase_commission_notification)
|
||||
|
||||
if not user.has_had_paid_subscription:
|
||||
user.has_had_paid_subscription = True
|
||||
await db.commit()
|
||||
logger.info(f"✅ Пользователь {user_id} отмечен как имевший платную подписку")
|
||||
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка обработки покупки реферала: {e}")
|
||||
import traceback
|
||||
logger.error(f"Полный traceback: {traceback.format_exc()}")
|
||||
return False
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,539 @@
|
||||
import logging
|
||||
from datetime import datetime, timedelta
|
||||
from typing import Optional, List, Tuple
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.config import settings
|
||||
from app.database.models import Subscription, User, SubscriptionStatus
|
||||
from app.external.remnawave_api import (
|
||||
RemnaWaveAPI, RemnaWaveUser, UserStatus,
|
||||
TrafficLimitStrategy, RemnaWaveAPIError
|
||||
)
|
||||
from app.database.crud.user import get_user_by_id
|
||||
from app.utils.pricing_utils import (
|
||||
calculate_months_from_days,
|
||||
get_remaining_months,
|
||||
calculate_prorated_price,
|
||||
validate_pricing_calculation
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class SubscriptionService:
|
||||
|
||||
def __init__(self):
|
||||
self.api = RemnaWaveAPI(
|
||||
base_url=settings.REMNAWAVE_API_URL,
|
||||
api_key=settings.REMNAWAVE_API_KEY
|
||||
)
|
||||
|
||||
async def create_remnawave_user(
|
||||
self,
|
||||
db: AsyncSession,
|
||||
subscription: Subscription
|
||||
) -> Optional[RemnaWaveUser]:
|
||||
|
||||
try:
|
||||
user = await get_user_by_id(db, subscription.user_id)
|
||||
if not user:
|
||||
logger.error(f"Пользователь {subscription.user_id} не найден")
|
||||
return None
|
||||
|
||||
validation_success = await self.validate_and_clean_subscription(db, subscription, user)
|
||||
if not validation_success:
|
||||
logger.error(f"Ошибка валидации подписки для пользователя {user.telegram_id}")
|
||||
return None
|
||||
|
||||
async with self.api as api:
|
||||
existing_users = await api.get_user_by_telegram_id(user.telegram_id)
|
||||
if existing_users:
|
||||
logger.info(f"🔄 Найден существующий пользователь в панели для {user.telegram_id}")
|
||||
remnawave_user = existing_users[0]
|
||||
|
||||
try:
|
||||
await api.reset_user_devices(remnawave_user.uuid)
|
||||
logger.info(f"🔧 Сброшены HWID устройства для пользователя {user.telegram_id}")
|
||||
except Exception as hwid_error:
|
||||
logger.warning(f"⚠️ Не удалось сбросить HWID: {hwid_error}")
|
||||
|
||||
updated_user = await api.update_user(
|
||||
uuid=remnawave_user.uuid,
|
||||
status=UserStatus.ACTIVE,
|
||||
expire_at=subscription.end_date,
|
||||
traffic_limit_bytes=self._gb_to_bytes(subscription.traffic_limit_gb),
|
||||
traffic_limit_strategy=TrafficLimitStrategy.MONTH,
|
||||
hwid_device_limit=subscription.device_limit,
|
||||
active_internal_squads=subscription.connected_squads
|
||||
)
|
||||
|
||||
else:
|
||||
logger.info(f"🆕 Создаем нового пользователя в панели для {user.telegram_id}")
|
||||
username = f"user_{user.telegram_id}"
|
||||
updated_user = await api.create_user(
|
||||
username=username,
|
||||
expire_at=subscription.end_date,
|
||||
status=UserStatus.ACTIVE,
|
||||
traffic_limit_bytes=self._gb_to_bytes(subscription.traffic_limit_gb),
|
||||
traffic_limit_strategy=TrafficLimitStrategy.MONTH,
|
||||
telegram_id=user.telegram_id,
|
||||
hwid_device_limit=subscription.device_limit,
|
||||
description=f"Bot user: {user.full_name}",
|
||||
active_internal_squads=subscription.connected_squads
|
||||
)
|
||||
|
||||
subscription.remnawave_short_uuid = updated_user.short_uuid
|
||||
subscription.subscription_url = updated_user.subscription_url
|
||||
user.remnawave_uuid = updated_user.uuid
|
||||
|
||||
await db.commit()
|
||||
|
||||
logger.info(f"✅ Создан/обновлен RemnaWave пользователь для подписки {subscription.id}")
|
||||
logger.info(f"🔗 Ссылка на подписку: {updated_user.subscription_url}")
|
||||
logger.info(f"📊 Стратегия сброса трафика: MONTH")
|
||||
return updated_user
|
||||
|
||||
except RemnaWaveAPIError as e:
|
||||
logger.error(f"Ошибка RemnaWave API: {e}")
|
||||
return None
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка создания RemnaWave пользователя: {e}")
|
||||
return None
|
||||
|
||||
async def update_remnawave_user(
|
||||
self,
|
||||
db: AsyncSession,
|
||||
subscription: Subscription
|
||||
) -> Optional[RemnaWaveUser]:
|
||||
|
||||
try:
|
||||
user = await get_user_by_id(db, subscription.user_id)
|
||||
if not user or not user.remnawave_uuid:
|
||||
logger.error(f"RemnaWave UUID не найден для пользователя {subscription.user_id}")
|
||||
return None
|
||||
|
||||
current_time = datetime.utcnow()
|
||||
is_actually_active = (subscription.status == SubscriptionStatus.ACTIVE.value and
|
||||
subscription.end_date > current_time)
|
||||
|
||||
if (subscription.status == SubscriptionStatus.ACTIVE.value and
|
||||
subscription.end_date <= current_time):
|
||||
|
||||
subscription.status = SubscriptionStatus.EXPIRED.value
|
||||
subscription.updated_at = current_time
|
||||
await db.commit()
|
||||
is_actually_active = False
|
||||
logger.info(f"🔔 Статус подписки {subscription.id} автоматически изменен на 'expired'")
|
||||
|
||||
async with self.api as api:
|
||||
updated_user = await api.update_user(
|
||||
uuid=user.remnawave_uuid,
|
||||
status=UserStatus.ACTIVE if is_actually_active else UserStatus.EXPIRED,
|
||||
expire_at=subscription.end_date,
|
||||
traffic_limit_bytes=self._gb_to_bytes(subscription.traffic_limit_gb),
|
||||
traffic_limit_strategy=TrafficLimitStrategy.MONTH,
|
||||
hwid_device_limit=subscription.device_limit,
|
||||
active_internal_squads=subscription.connected_squads
|
||||
)
|
||||
|
||||
subscription.subscription_url = updated_user.subscription_url
|
||||
await db.commit()
|
||||
|
||||
status_text = "активным" if is_actually_active else "истёкшим"
|
||||
logger.info(f"✅ Обновлен RemnaWave пользователь {user.remnawave_uuid} со статусом {status_text}")
|
||||
logger.info(f"📊 Стратегия сброса трафика: MONTH")
|
||||
return updated_user
|
||||
|
||||
except RemnaWaveAPIError as e:
|
||||
logger.error(f"Ошибка RemnaWave API: {e}")
|
||||
return None
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка обновления RemnaWave пользователя: {e}")
|
||||
return None
|
||||
|
||||
async def disable_remnawave_user(self, user_uuid: str) -> bool:
|
||||
|
||||
try:
|
||||
async with self.api as api:
|
||||
await api.disable_user(user_uuid)
|
||||
logger.info(f"✅ Отключен RemnaWave пользователь {user_uuid}")
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка отключения RemnaWave пользователя: {e}")
|
||||
return False
|
||||
|
||||
async def revoke_subscription(
|
||||
self,
|
||||
db: AsyncSession,
|
||||
subscription: Subscription
|
||||
) -> Optional[str]:
|
||||
|
||||
try:
|
||||
user = await get_user_by_id(db, subscription.user_id)
|
||||
if not user or not user.remnawave_uuid:
|
||||
return None
|
||||
|
||||
async with self.api as api:
|
||||
updated_user = await api.revoke_user_subscription(user.remnawave_uuid)
|
||||
|
||||
subscription.remnawave_short_uuid = updated_user.short_uuid
|
||||
subscription.subscription_url = updated_user.subscription_url
|
||||
await db.commit()
|
||||
|
||||
logger.info(f"✅ Обновлена ссылка подписки для пользователя {user.telegram_id}")
|
||||
return updated_user.subscription_url
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка обновления ссылки подписки: {e}")
|
||||
return None
|
||||
|
||||
async def get_subscription_info(self, short_uuid: str) -> Optional[dict]:
|
||||
|
||||
try:
|
||||
async with self.api as api:
|
||||
info = await api.get_subscription_info(short_uuid)
|
||||
return info
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка получения информации о подписке: {e}")
|
||||
return None
|
||||
|
||||
async def sync_subscription_usage(
|
||||
self,
|
||||
db: AsyncSession,
|
||||
subscription: Subscription
|
||||
) -> bool:
|
||||
|
||||
try:
|
||||
user = await get_user_by_id(db, subscription.user_id)
|
||||
if not user or not user.remnawave_uuid:
|
||||
return False
|
||||
|
||||
async with self.api as api:
|
||||
remnawave_user = await api.get_user_by_uuid(user.remnawave_uuid)
|
||||
if not remnawave_user:
|
||||
return False
|
||||
|
||||
used_gb = self._bytes_to_gb(remnawave_user.used_traffic_bytes)
|
||||
subscription.traffic_used_gb = used_gb
|
||||
|
||||
await db.commit()
|
||||
|
||||
logger.debug(f"Синхронизирован трафик для подписки {subscription.id}: {used_gb} ГБ")
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка синхронизации трафика: {e}")
|
||||
return False
|
||||
|
||||
async def calculate_subscription_price(
|
||||
self,
|
||||
period_days: int,
|
||||
traffic_gb: int,
|
||||
server_squad_ids: List[int],
|
||||
devices: int,
|
||||
db: AsyncSession
|
||||
) -> Tuple[int, List[int]]:
|
||||
|
||||
from app.config import PERIOD_PRICES
|
||||
from app.database.crud.server_squad import get_server_squad_by_id
|
||||
|
||||
if settings.MAX_DEVICES_LIMIT > 0 and devices > settings.MAX_DEVICES_LIMIT:
|
||||
raise ValueError(f"Превышен максимальный лимит устройств: {settings.MAX_DEVICES_LIMIT}")
|
||||
|
||||
base_price = PERIOD_PRICES.get(period_days, 0)
|
||||
|
||||
traffic_price = settings.get_traffic_price(traffic_gb)
|
||||
|
||||
server_prices = []
|
||||
total_servers_price = 0
|
||||
|
||||
for server_id in server_squad_ids:
|
||||
server = await get_server_squad_by_id(db, server_id)
|
||||
if server and server.is_available and not server.is_full:
|
||||
server_prices.append(server.price_kopeks)
|
||||
total_servers_price += server.price_kopeks
|
||||
logger.debug(f"Сервер {server.display_name}: {server.price_kopeks/100}₽")
|
||||
else:
|
||||
server_prices.append(0)
|
||||
logger.warning(f"Сервер ID {server_id} недоступен")
|
||||
|
||||
devices_price = max(0, devices - settings.DEFAULT_DEVICE_LIMIT) * settings.PRICE_PER_DEVICE
|
||||
|
||||
total_price = base_price + traffic_price + total_servers_price + devices_price
|
||||
|
||||
logger.info(f"Расчет стоимости новой подписки:")
|
||||
logger.info(f" Период {period_days} дней: {base_price/100}₽")
|
||||
logger.info(f" Трафик {traffic_gb} ГБ: {traffic_price/100}₽")
|
||||
logger.info(f" Серверы ({len(server_squad_ids)}): {total_servers_price/100}₽")
|
||||
logger.info(f" Устройства ({devices}): {devices_price/100}₽")
|
||||
logger.info(f" ИТОГО: {total_price/100}₽")
|
||||
|
||||
return total_price, server_prices
|
||||
|
||||
async def calculate_renewal_price(
|
||||
self,
|
||||
subscription: Subscription,
|
||||
period_days: int,
|
||||
db: AsyncSession
|
||||
) -> int:
|
||||
try:
|
||||
from app.config import PERIOD_PRICES
|
||||
|
||||
base_price = PERIOD_PRICES.get(period_days, 0)
|
||||
|
||||
servers_price, _ = await self.get_countries_price_by_uuids(
|
||||
subscription.connected_squads, db
|
||||
)
|
||||
|
||||
devices_price = max(0, subscription.device_limit - settings.DEFAULT_DEVICE_LIMIT) * settings.PRICE_PER_DEVICE
|
||||
|
||||
traffic_price = settings.get_traffic_price(subscription.traffic_limit_gb)
|
||||
|
||||
total_price = base_price + servers_price + devices_price + traffic_price
|
||||
|
||||
logger.info(f"💰 Расчет стоимости продления для подписки {subscription.id} (по текущим ценам):")
|
||||
logger.info(f" 📅 Период {period_days} дней: {base_price/100}₽")
|
||||
logger.info(f" 🌍 Серверы ({len(subscription.connected_squads)}) по текущим ценам: {servers_price/100}₽")
|
||||
logger.info(f" 📱 Устройства ({subscription.device_limit}): {devices_price/100}₽")
|
||||
logger.info(f" 📊 Трафик ({subscription.traffic_limit_gb} ГБ): {traffic_price/100}₽")
|
||||
logger.info(f" 💎 ИТОГО: {total_price/100}₽")
|
||||
|
||||
return total_price
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка расчета стоимости продления: {e}")
|
||||
from app.config import PERIOD_PRICES
|
||||
return PERIOD_PRICES.get(period_days, 0)
|
||||
|
||||
async def validate_and_clean_subscription(
|
||||
self,
|
||||
db: AsyncSession,
|
||||
subscription: Subscription,
|
||||
user: User
|
||||
) -> bool:
|
||||
try:
|
||||
needs_cleanup = False
|
||||
|
||||
if user.remnawave_uuid:
|
||||
try:
|
||||
async with self.api as api:
|
||||
remnawave_user = await api.get_user_by_uuid(user.remnawave_uuid)
|
||||
|
||||
if not remnawave_user:
|
||||
logger.warning(f"⚠️ Пользователь {user.telegram_id} имеет UUID {user.remnawave_uuid}, но не найден в панели")
|
||||
needs_cleanup = True
|
||||
else:
|
||||
if remnawave_user.telegram_id != user.telegram_id:
|
||||
logger.warning(f"⚠️ Несоответствие telegram_id для пользователя {user.telegram_id}")
|
||||
needs_cleanup = True
|
||||
except Exception as api_error:
|
||||
logger.error(f"❌ Ошибка проверки пользователя в панели: {api_error}")
|
||||
needs_cleanup = True
|
||||
|
||||
if subscription.remnawave_short_uuid and not user.remnawave_uuid:
|
||||
logger.warning(f"⚠️ У подписки есть short_uuid, но у пользователя нет remnawave_uuid")
|
||||
needs_cleanup = True
|
||||
|
||||
if needs_cleanup:
|
||||
logger.info(f"🧹 Очищаем мусорные данные подписки для пользователя {user.telegram_id}")
|
||||
|
||||
subscription.remnawave_short_uuid = None
|
||||
subscription.subscription_url = ""
|
||||
subscription.connected_squads = []
|
||||
|
||||
user.remnawave_uuid = None
|
||||
|
||||
await db.commit()
|
||||
logger.info(f"✅ Мусорные данные очищены для пользователя {user.telegram_id}")
|
||||
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"❌ Ошибка валидации подписки для пользователя {user.telegram_id}: {e}")
|
||||
await db.rollback()
|
||||
return False
|
||||
|
||||
async def get_countries_price_by_uuids(
|
||||
self,
|
||||
country_uuids: List[str],
|
||||
db: AsyncSession
|
||||
) -> Tuple[int, List[int]]:
|
||||
try:
|
||||
from app.database.crud.server_squad import get_server_squad_by_uuid
|
||||
|
||||
total_price = 0
|
||||
prices_list = []
|
||||
|
||||
for country_uuid in country_uuids:
|
||||
server = await get_server_squad_by_uuid(db, country_uuid)
|
||||
if server and server.is_available and not server.is_full:
|
||||
price = server.price_kopeks
|
||||
total_price += price
|
||||
prices_list.append(price)
|
||||
logger.debug(f"🏷️ Страна {server.display_name}: {price/100}₽")
|
||||
else:
|
||||
default_price = 0
|
||||
total_price += default_price
|
||||
prices_list.append(default_price)
|
||||
logger.warning(f"⚠️ Сервер {country_uuid} недоступен, используем базовую цену: {default_price/100}₽")
|
||||
|
||||
logger.info(f"💰 Общая стоимость стран: {total_price/100}₽")
|
||||
return total_price, prices_list
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка получения цен стран: {e}")
|
||||
default_prices = [0] * len(country_uuids)
|
||||
return sum(default_prices), default_prices
|
||||
|
||||
async def _get_countries_price(self, country_uuids: List[str], db: AsyncSession) -> int:
|
||||
try:
|
||||
total_price, _ = await self.get_countries_price_by_uuids(country_uuids, db)
|
||||
return total_price
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка получения цен стран: {e}")
|
||||
return len(country_uuids) * 1000
|
||||
|
||||
async def calculate_subscription_price_with_months(
|
||||
self,
|
||||
period_days: int,
|
||||
traffic_gb: int,
|
||||
server_squad_ids: List[int],
|
||||
devices: int,
|
||||
db: AsyncSession
|
||||
) -> Tuple[int, List[int]]:
|
||||
|
||||
from app.config import PERIOD_PRICES
|
||||
from app.database.crud.server_squad import get_server_squad_by_id
|
||||
|
||||
if settings.MAX_DEVICES_LIMIT > 0 and devices > settings.MAX_DEVICES_LIMIT:
|
||||
raise ValueError(f"Превышен максимальный лимит устройств: {settings.MAX_DEVICES_LIMIT}")
|
||||
|
||||
months_in_period = calculate_months_from_days(period_days)
|
||||
|
||||
base_price = PERIOD_PRICES.get(period_days, 0)
|
||||
|
||||
traffic_price_per_month = settings.get_traffic_price(traffic_gb)
|
||||
total_traffic_price = traffic_price_per_month * months_in_period
|
||||
|
||||
server_prices = []
|
||||
total_servers_price = 0
|
||||
|
||||
for server_id in server_squad_ids:
|
||||
server = await get_server_squad_by_id(db, server_id)
|
||||
if server and server.is_available and not server.is_full:
|
||||
server_price_per_month = server.price_kopeks
|
||||
server_price_total = server_price_per_month * months_in_period
|
||||
server_prices.append(server_price_total)
|
||||
total_servers_price += server_price_total
|
||||
logger.debug(f"Сервер {server.display_name}: {server_price_per_month/100}₽/мес x {months_in_period} мес = {server_price_total/100}₽")
|
||||
else:
|
||||
server_prices.append(0)
|
||||
logger.warning(f"Сервер ID {server_id} недоступен")
|
||||
|
||||
additional_devices = max(0, devices - settings.DEFAULT_DEVICE_LIMIT)
|
||||
devices_price_per_month = additional_devices * settings.PRICE_PER_DEVICE
|
||||
total_devices_price = devices_price_per_month * months_in_period
|
||||
|
||||
total_price = base_price + total_traffic_price + total_servers_price + total_devices_price
|
||||
|
||||
logger.info(f"Расчет стоимости новой подписки на {period_days} дней ({months_in_period} мес):")
|
||||
logger.info(f" Период {period_days} дней: {base_price/100}₽")
|
||||
logger.info(f" Трафик {traffic_gb} ГБ: {traffic_price_per_month/100}₽/мес x {months_in_period} = {total_traffic_price/100}₽")
|
||||
logger.info(f" Серверы ({len(server_squad_ids)}): {total_servers_price/100}₽")
|
||||
logger.info(f" Устройства ({additional_devices}): {devices_price_per_month/100}₽/мес x {months_in_period} = {total_devices_price/100}₽")
|
||||
logger.info(f" ИТОГО: {total_price/100}₽")
|
||||
|
||||
return total_price, server_prices
|
||||
|
||||
async def calculate_renewal_price_with_months(
|
||||
self,
|
||||
subscription: Subscription,
|
||||
period_days: int,
|
||||
db: AsyncSession
|
||||
) -> int:
|
||||
try:
|
||||
from app.config import PERIOD_PRICES
|
||||
|
||||
months_in_period = calculate_months_from_days(period_days)
|
||||
|
||||
base_price = PERIOD_PRICES.get(period_days, 0)
|
||||
|
||||
servers_price_per_month, _ = await self.get_countries_price_by_uuids(
|
||||
subscription.connected_squads, db
|
||||
)
|
||||
total_servers_price = servers_price_per_month * months_in_period
|
||||
|
||||
additional_devices = max(0, subscription.device_limit - settings.DEFAULT_DEVICE_LIMIT)
|
||||
devices_price_per_month = additional_devices * settings.PRICE_PER_DEVICE
|
||||
total_devices_price = devices_price_per_month * months_in_period
|
||||
|
||||
traffic_price_per_month = settings.get_traffic_price(subscription.traffic_limit_gb)
|
||||
total_traffic_price = traffic_price_per_month * months_in_period
|
||||
|
||||
total_price = base_price + total_servers_price + total_devices_price + total_traffic_price
|
||||
|
||||
logger.info(f"💰 Расчет стоимости продления подписки {subscription.id} на {period_days} дней ({months_in_period} мес):")
|
||||
logger.info(f" 📅 Период {period_days} дней: {base_price/100}₽")
|
||||
logger.info(f" 🌍 Серверы: {servers_price_per_month/100}₽/мес x {months_in_period} = {total_servers_price/100}₽")
|
||||
logger.info(f" 📱 Устройства: {devices_price_per_month/100}₽/мес x {months_in_period} = {total_devices_price/100}₽")
|
||||
logger.info(f" 📊 Трафик: {traffic_price_per_month/100}₽/мес x {months_in_period} = {total_traffic_price/100}₽")
|
||||
logger.info(f" 💎 ИТОГО: {total_price/100}₽")
|
||||
|
||||
return total_price
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка расчета стоимости продления: {e}")
|
||||
from app.config import PERIOD_PRICES
|
||||
return PERIOD_PRICES.get(period_days, 0)
|
||||
|
||||
async def calculate_addon_price_with_remaining_period(
|
||||
self,
|
||||
subscription: Subscription,
|
||||
additional_traffic_gb: int = 0,
|
||||
additional_devices: int = 0,
|
||||
additional_server_ids: List[int] = None,
|
||||
db: AsyncSession = None
|
||||
) -> int:
|
||||
|
||||
if additional_server_ids is None:
|
||||
additional_server_ids = []
|
||||
|
||||
current_time = datetime.utcnow()
|
||||
months_to_pay = get_remaining_months(subscription.end_date)
|
||||
|
||||
total_price = 0
|
||||
|
||||
if additional_traffic_gb > 0:
|
||||
traffic_price_per_month = settings.get_traffic_price(additional_traffic_gb)
|
||||
total_price += traffic_price_per_month * months_to_pay
|
||||
logger.info(f"Трафик +{additional_traffic_gb}ГБ: {traffic_price_per_month/100}₽/мес x {months_to_pay} = {traffic_price_per_month * months_to_pay/100}₽")
|
||||
|
||||
if additional_devices > 0:
|
||||
devices_price_per_month = additional_devices * settings.PRICE_PER_DEVICE
|
||||
total_price += devices_price_per_month * months_to_pay
|
||||
logger.info(f"Устройства +{additional_devices}: {devices_price_per_month/100}₽/мес x {months_to_pay} = {devices_price_per_month * months_to_pay/100}₽")
|
||||
|
||||
if additional_server_ids and db:
|
||||
for server_id in additional_server_ids:
|
||||
from app.database.crud.server_squad import get_server_squad_by_id
|
||||
server = await get_server_squad_by_id(db, server_id)
|
||||
if server and server.is_available:
|
||||
server_price_per_month = server.price_kopeks
|
||||
server_total_price = server_price_per_month * months_to_pay
|
||||
total_price += server_total_price
|
||||
logger.info(f"Сервер {server.display_name}: {server_price_per_month/100}₽/мес x {months_to_pay} = {server_total_price/100}₽")
|
||||
|
||||
logger.info(f"Итого доплата за {months_to_pay} мес: {total_price/100}₽")
|
||||
return total_price
|
||||
|
||||
def _gb_to_bytes(self, gb: int) -> int:
|
||||
if gb == 0:
|
||||
return 0
|
||||
return gb * 1024 * 1024 * 1024
|
||||
|
||||
def _bytes_to_gb(self, bytes_value: int) -> float:
|
||||
if bytes_value == 0:
|
||||
return 0.0
|
||||
return bytes_value / (1024 * 1024 * 1024)
|
||||
@@ -0,0 +1,349 @@
|
||||
import logging
|
||||
import hashlib
|
||||
import hmac
|
||||
import json
|
||||
from typing import Optional, Dict, Any
|
||||
from datetime import datetime
|
||||
|
||||
from aiogram import Bot
|
||||
from aiogram.types import InlineKeyboardMarkup, InlineKeyboardButton
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.config import settings
|
||||
from app.database.database import get_db
|
||||
from app.database.models import Transaction, TransactionType, PaymentMethod
|
||||
from app.database.crud.transaction import (
|
||||
create_transaction, get_transaction_by_external_id, complete_transaction
|
||||
)
|
||||
from app.database.crud.user import get_user_by_telegram_id, add_user_balance
|
||||
from app.external.tribute import TributeService as TributeAPI
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class TributeService:
|
||||
|
||||
def __init__(self, bot: Bot):
|
||||
self.bot = bot
|
||||
self.tribute_api = TributeAPI()
|
||||
|
||||
async def create_payment_link(
|
||||
self,
|
||||
user_id: int,
|
||||
amount_kopeks: int,
|
||||
description: str = "Пополнение баланса"
|
||||
) -> Optional[str]:
|
||||
|
||||
if not settings.TRIBUTE_ENABLED:
|
||||
logger.warning("Tribute платежи отключены")
|
||||
return None
|
||||
|
||||
try:
|
||||
payment_url = await self.tribute_api.create_payment_link(
|
||||
user_id=user_id,
|
||||
amount_kopeks=amount_kopeks,
|
||||
description=description
|
||||
)
|
||||
|
||||
if not payment_url:
|
||||
return None
|
||||
|
||||
return payment_url
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка создания Tribute платежа: {e}")
|
||||
return None
|
||||
|
||||
async def process_webhook(
|
||||
self,
|
||||
payload: str,
|
||||
signature: Optional[str] = None
|
||||
) -> Dict[str, Any]:
|
||||
|
||||
if signature and settings.TRIBUTE_WEBHOOK_SECRET:
|
||||
if not self.tribute_api.verify_webhook_signature(payload, signature):
|
||||
logger.warning("Неверная подпись Tribute webhook")
|
||||
return {"status": "error", "reason": "invalid_signature"}
|
||||
|
||||
try:
|
||||
webhook_data = json.loads(payload)
|
||||
except json.JSONDecodeError:
|
||||
logger.error("Некорректный JSON в Tribute webhook")
|
||||
return {"status": "error", "reason": "invalid_json"}
|
||||
|
||||
logger.info(f"Получен Tribute webhook: {json.dumps(webhook_data, ensure_ascii=False)}")
|
||||
|
||||
processed_data = await self.tribute_api.process_webhook(webhook_data)
|
||||
if not processed_data:
|
||||
return {"status": "ignored", "reason": "invalid_data"}
|
||||
|
||||
event_type = processed_data.get("event_type", "payment")
|
||||
status = processed_data.get("status")
|
||||
|
||||
if event_type == "payment" and status == "paid":
|
||||
await self._handle_successful_payment(processed_data)
|
||||
elif event_type == "payment" and status == "failed":
|
||||
await self._handle_failed_payment(processed_data)
|
||||
elif event_type == "refund":
|
||||
await self._handle_refund(processed_data)
|
||||
|
||||
return {"status": "ok", "event": event_type}
|
||||
|
||||
async def _handle_successful_payment(self, payment_data: Dict[str, Any]):
|
||||
"""Обработка успешного платежа - ПОЛНОСТЬЮ ПЕРЕРАБОТАННАЯ ВЕРСИЯ"""
|
||||
|
||||
try:
|
||||
user_id = payment_data["user_id"]
|
||||
amount_kopeks = payment_data["amount_kopeks"]
|
||||
payment_id = payment_data["payment_id"]
|
||||
|
||||
logger.info(f"Обрабатываем успешный Tribute платеж: user_id={user_id}, amount={amount_kopeks}, payment_id={payment_id}")
|
||||
|
||||
async for session in get_db():
|
||||
from app.database.crud.transaction import check_tribute_payment_duplicate, create_unique_tribute_transaction
|
||||
|
||||
duplicate_transaction = await check_tribute_payment_duplicate(
|
||||
session, payment_id, amount_kopeks, user_id
|
||||
)
|
||||
|
||||
if duplicate_transaction:
|
||||
logger.warning(f"Найден дубликат платежа:")
|
||||
logger.warning(f" Transaction ID: {duplicate_transaction.id}")
|
||||
logger.warning(f" Amount: {duplicate_transaction.amount_kopeks} коп")
|
||||
logger.warning(f" Created: {duplicate_transaction.created_at}")
|
||||
logger.warning(f"Платеж игнорирован")
|
||||
return
|
||||
|
||||
user = await get_user_by_telegram_id(session, user_id)
|
||||
if not user:
|
||||
logger.error(f"Пользователь {user_id} не найден")
|
||||
return
|
||||
|
||||
logger.info(f"Найден пользователь {user.telegram_id}, текущий баланс: {user.balance_kopeks} коп")
|
||||
|
||||
transaction = await create_unique_tribute_transaction(
|
||||
db=session,
|
||||
user_id=user.id,
|
||||
payment_id=payment_id,
|
||||
amount_kopeks=amount_kopeks,
|
||||
description=f"Пополнение через Tribute: {amount_kopeks/100}₽ (ID: {payment_id})"
|
||||
)
|
||||
|
||||
old_balance = user.balance_kopeks
|
||||
user.balance_kopeks += amount_kopeks
|
||||
user.updated_at = datetime.utcnow()
|
||||
|
||||
await session.commit()
|
||||
|
||||
logger.info(f"Баланс пользователя {user_id} обновлен: {old_balance} -> {user.balance_kopeks} коп (+{amount_kopeks})")
|
||||
|
||||
await self._send_success_notification(user_id, amount_kopeks)
|
||||
|
||||
logger.info(f"Успешно обработан Tribute платеж: {amount_kopeks/100}₽ для пользователя {user_id}")
|
||||
break
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка обработки успешного Tribute платежа: {e}", exc_info=True)
|
||||
|
||||
async def _handle_failed_payment(self, payment_data: Dict[str, Any]):
|
||||
"""Обработка неудачного платежа"""
|
||||
|
||||
try:
|
||||
user_id = payment_data["user_id"]
|
||||
payment_id = payment_data["payment_id"]
|
||||
|
||||
async for session in get_db():
|
||||
transaction = await get_transaction_by_external_id(
|
||||
session, f"donation_{payment_id}", PaymentMethod.TRIBUTE
|
||||
)
|
||||
|
||||
if transaction:
|
||||
transaction.description = f"{transaction.description} (платеж отклонен)"
|
||||
await session.commit()
|
||||
|
||||
await self._send_failure_notification(user_id)
|
||||
|
||||
logger.info(f"Обработан неудачный Tribute платеж для пользователя {user_id}")
|
||||
break
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка обработки неудачного Tribute платежа: {e}")
|
||||
|
||||
async def _handle_refund(self, refund_data: Dict[str, Any]):
|
||||
"""Обработка возврата"""
|
||||
|
||||
try:
|
||||
user_id = refund_data["user_id"]
|
||||
amount_kopeks = refund_data["amount_kopeks"]
|
||||
payment_id = refund_data["payment_id"]
|
||||
|
||||
async for session in get_db():
|
||||
await create_transaction(
|
||||
db=session,
|
||||
user_id=user_id,
|
||||
type=TransactionType.REFUND,
|
||||
amount_kopeks=-amount_kopeks,
|
||||
description=f"Возврат Tribute платежа {payment_id}",
|
||||
payment_method=PaymentMethod.TRIBUTE,
|
||||
external_id=f"refund_{payment_id}",
|
||||
is_completed=True
|
||||
)
|
||||
|
||||
user = await get_user_by_telegram_id(session, user_id)
|
||||
if user and user.balance_kopeks >= amount_kopeks:
|
||||
user.balance_kopeks -= amount_kopeks
|
||||
await session.commit()
|
||||
|
||||
await self._send_refund_notification(user_id, amount_kopeks)
|
||||
|
||||
logger.info(f"Обработан возврат Tribute: {amount_kopeks/100}₽ для пользователя {user_id}")
|
||||
break
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка обработки возврата Tribute: {e}")
|
||||
|
||||
async def _send_success_notification(self, user_id: int, amount_kopeks: int):
|
||||
"""Отправка уведомления об успешном платеже"""
|
||||
|
||||
try:
|
||||
amount_rubles = amount_kopeks / 100
|
||||
|
||||
text = (
|
||||
f"✅ **Платеж успешно получен!**\n\n"
|
||||
f"💰 Сумма: {amount_rubles:.2f} ₽\n"
|
||||
f"💳 Способ оплаты: Tribute\n"
|
||||
f"🎉 Средства зачислены на баланс!\n\n"
|
||||
f"Спасибо за оплату! 🙏"
|
||||
)
|
||||
|
||||
keyboard = InlineKeyboardMarkup(inline_keyboard=[
|
||||
[InlineKeyboardButton(text="💰 Мой баланс", callback_data="menu_balance")],
|
||||
[InlineKeyboardButton(text="🏠 Главное меню", callback_data="back_to_menu")]
|
||||
])
|
||||
|
||||
await self.bot.send_message(
|
||||
user_id,
|
||||
text,
|
||||
reply_markup=keyboard,
|
||||
parse_mode="Markdown"
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка отправки уведомления об успешном платеже: {e}")
|
||||
|
||||
async def _send_failure_notification(self, user_id: int):
|
||||
"""Отправка уведомления о неудачном платеже"""
|
||||
|
||||
try:
|
||||
text = (
|
||||
"❌ **Платеж не прошел**\n\n"
|
||||
"К сожалению, ваш платеж через Tribute был отклонен.\n\n"
|
||||
"Возможные причины:\n"
|
||||
"• Недостаточно средств на карте\n"
|
||||
"• Технические проблемы банка\n"
|
||||
"• Превышен лимит операций\n\n"
|
||||
"Попробуйте еще раз или обратитесь в поддержку."
|
||||
)
|
||||
|
||||
keyboard = InlineKeyboardMarkup(inline_keyboard=[
|
||||
[InlineKeyboardButton(text="🔄 Попробовать снова", callback_data="menu_balance")],
|
||||
[InlineKeyboardButton(text="💬 Поддержка", callback_data="menu_support")]
|
||||
])
|
||||
|
||||
await self.bot.send_message(
|
||||
user_id,
|
||||
text,
|
||||
reply_markup=keyboard,
|
||||
parse_mode="Markdown"
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка отправки уведомления о неудачном платеже: {e}")
|
||||
|
||||
async def _send_refund_notification(self, user_id: int, amount_kopeks: int):
|
||||
"""Отправка уведомления о возврате"""
|
||||
|
||||
try:
|
||||
amount_rubles = amount_kopeks / 100
|
||||
|
||||
text = (
|
||||
f"🔄 **Возврат средств**\n\n"
|
||||
f"💰 Сумма возврата: {amount_rubles:.2f} ₽\n"
|
||||
f"💳 Способ: Tribute\n\n"
|
||||
f"Средства будут возвращены на вашу карту в течение 3-5 рабочих дней.\n\n"
|
||||
f"Если у вас есть вопросы, обратитесь в поддержку."
|
||||
)
|
||||
|
||||
keyboard = InlineKeyboardMarkup(inline_keyboard=[
|
||||
[InlineKeyboardButton(text="💬 Поддержка", callback_data="menu_support")],
|
||||
[InlineKeyboardButton(text="🏠 Главное меню", callback_data="back_to_menu")]
|
||||
])
|
||||
|
||||
await self.bot.send_message(
|
||||
user_id,
|
||||
text,
|
||||
reply_markup=keyboard,
|
||||
parse_mode="Markdown"
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка отправки уведомления о возврате: {e}")
|
||||
|
||||
async def force_process_payment(
|
||||
self,
|
||||
payment_id: str,
|
||||
user_id: int,
|
||||
amount_kopeks: int,
|
||||
description: str = "Принудительная обработка Tribute платежа"
|
||||
) -> bool:
|
||||
"""Принудительная обработка платежа (для отладки)"""
|
||||
|
||||
try:
|
||||
logger.info(f"🔧 ПРИНУДИТЕЛЬНАЯ ОБРАБОТКА: payment_id={payment_id}, user_id={user_id}, amount={amount_kopeks}")
|
||||
|
||||
async for session in get_db():
|
||||
user = await get_user_by_telegram_id(session, user_id)
|
||||
if not user:
|
||||
logger.error(f"❌ Пользователь {user_id} не найден")
|
||||
return False
|
||||
|
||||
external_id = f"force_donation_{payment_id}_{int(datetime.utcnow().timestamp())}"
|
||||
|
||||
transaction = await create_transaction(
|
||||
db=session,
|
||||
user_id=user.id,
|
||||
type=TransactionType.DEPOSIT,
|
||||
amount_kopeks=amount_kopeks,
|
||||
description=description,
|
||||
payment_method=PaymentMethod.TRIBUTE,
|
||||
external_id=external_id,
|
||||
is_completed=True
|
||||
)
|
||||
|
||||
old_balance = user.balance_kopeks
|
||||
user.balance_kopeks += amount_kopeks
|
||||
user.updated_at = datetime.utcnow()
|
||||
|
||||
await session.commit()
|
||||
|
||||
logger.info(f"💰 ПРИНУДИТЕЛЬНО обновлен баланс: {old_balance} -> {user.balance_kopeks} коп")
|
||||
|
||||
await self._send_success_notification(user_id, amount_kopeks)
|
||||
|
||||
logger.info(f"✅ Принудительно обработан платеж {payment_id}")
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"❌ Ошибка принудительной обработки: {e}", exc_info=True)
|
||||
return False
|
||||
|
||||
async def get_payment_status(self, payment_id: str) -> Optional[Dict[str, Any]]:
|
||||
return await self.tribute_api.get_payment_status(payment_id)
|
||||
|
||||
async def create_refund(
|
||||
self,
|
||||
payment_id: str,
|
||||
amount_kopeks: Optional[int] = None,
|
||||
reason: str = "Возврат по запросу"
|
||||
) -> Optional[Dict[str, Any]]:
|
||||
return await self.tribute_api.refund_payment(payment_id, amount_kopeks, reason)
|
||||
@@ -0,0 +1,498 @@
|
||||
import logging
|
||||
from datetime import datetime, timedelta
|
||||
from typing import Optional, List, Dict, Any
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy import delete, select, update
|
||||
|
||||
from app.database.crud.user import (
|
||||
get_user_by_id, get_user_by_telegram_id, get_users_list,
|
||||
get_users_count, get_users_statistics, get_inactive_users,
|
||||
add_user_balance, subtract_user_balance, update_user, delete_user
|
||||
)
|
||||
from app.database.crud.transaction import get_user_transactions_count
|
||||
from app.database.crud.subscription import get_subscription_by_user_id
|
||||
from app.database.models import (
|
||||
User, UserStatus, Subscription, Transaction, PromoCodeUse,
|
||||
ReferralEarning, SubscriptionServer, YooKassaPayment, BroadcastHistory
|
||||
)
|
||||
from app.config import settings
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class UserService:
|
||||
|
||||
async def get_user_profile(
|
||||
self,
|
||||
db: AsyncSession,
|
||||
user_id: int
|
||||
) -> Optional[Dict[str, Any]]:
|
||||
try:
|
||||
user = await get_user_by_id(db, user_id)
|
||||
if not user:
|
||||
return None
|
||||
|
||||
subscription = await get_subscription_by_user_id(db, user_id)
|
||||
transactions_count = await get_user_transactions_count(db, user_id)
|
||||
|
||||
return {
|
||||
"user": user,
|
||||
"subscription": subscription,
|
||||
"transactions_count": transactions_count,
|
||||
"is_admin": settings.is_admin(user.telegram_id),
|
||||
"registration_days": (datetime.utcnow() - user.created_at).days
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка получения профиля пользователя {user_id}: {e}")
|
||||
return None
|
||||
|
||||
async def search_users(
|
||||
self,
|
||||
db: AsyncSession,
|
||||
query: str,
|
||||
page: int = 1,
|
||||
limit: int = 20
|
||||
) -> Dict[str, Any]:
|
||||
try:
|
||||
offset = (page - 1) * limit
|
||||
|
||||
users = await get_users_list(
|
||||
db, offset=offset, limit=limit, search=query
|
||||
)
|
||||
total_count = await get_users_count(db, search=query)
|
||||
|
||||
total_pages = (total_count + limit - 1) // limit
|
||||
|
||||
return {
|
||||
"users": users,
|
||||
"current_page": page,
|
||||
"total_pages": total_pages,
|
||||
"total_count": total_count,
|
||||
"has_next": page < total_pages,
|
||||
"has_prev": page > 1
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка поиска пользователей: {e}")
|
||||
return {
|
||||
"users": [],
|
||||
"current_page": 1,
|
||||
"total_pages": 1,
|
||||
"total_count": 0,
|
||||
"has_next": False,
|
||||
"has_prev": False
|
||||
}
|
||||
|
||||
async def get_users_page(
|
||||
self,
|
||||
db: AsyncSession,
|
||||
page: int = 1,
|
||||
limit: int = 20,
|
||||
status: Optional[UserStatus] = None
|
||||
) -> Dict[str, Any]:
|
||||
try:
|
||||
offset = (page - 1) * limit
|
||||
|
||||
users = await get_users_list(
|
||||
db, offset=offset, limit=limit, status=status
|
||||
)
|
||||
total_count = await get_users_count(db, status=status)
|
||||
|
||||
total_pages = (total_count + limit - 1) // limit
|
||||
|
||||
return {
|
||||
"users": users,
|
||||
"current_page": page,
|
||||
"total_pages": total_pages,
|
||||
"total_count": total_count,
|
||||
"has_next": page < total_pages,
|
||||
"has_prev": page > 1
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка получения страницы пользователей: {e}")
|
||||
return {
|
||||
"users": [],
|
||||
"current_page": 1,
|
||||
"total_pages": 1,
|
||||
"total_count": 0,
|
||||
"has_next": False,
|
||||
"has_prev": False
|
||||
}
|
||||
|
||||
async def update_user_balance(
|
||||
self,
|
||||
db: AsyncSession,
|
||||
user_id: int,
|
||||
amount_kopeks: int,
|
||||
description: str,
|
||||
admin_id: int
|
||||
) -> bool:
|
||||
try:
|
||||
user = await get_user_by_id(db, user_id)
|
||||
if not user:
|
||||
return False
|
||||
|
||||
if amount_kopeks > 0:
|
||||
await add_user_balance(db, user, amount_kopeks, description=description)
|
||||
logger.info(f"Админ {admin_id} пополнил баланс пользователя {user_id} на {amount_kopeks/100}₽")
|
||||
return True
|
||||
else:
|
||||
success = await subtract_user_balance(db, user, abs(amount_kopeks), description)
|
||||
if success:
|
||||
logger.info(f"Админ {admin_id} списал с баланса пользователя {user_id} {abs(amount_kopeks)/100}₽")
|
||||
return success
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка изменения баланса пользователя: {e}")
|
||||
return False
|
||||
|
||||
async def block_user(
|
||||
self,
|
||||
db: AsyncSession,
|
||||
user_id: int,
|
||||
admin_id: int,
|
||||
reason: str = "Заблокирован администратором"
|
||||
) -> bool:
|
||||
try:
|
||||
user = await get_user_by_id(db, user_id)
|
||||
if not user:
|
||||
return False
|
||||
|
||||
if user.remnawave_uuid:
|
||||
try:
|
||||
from app.services.subscription_service import SubscriptionService
|
||||
subscription_service = SubscriptionService()
|
||||
await subscription_service.disable_remnawave_user(user.remnawave_uuid)
|
||||
logger.info(f"✅ RemnaWave пользователь {user.remnawave_uuid} деактивирован при блокировке")
|
||||
except Exception as e:
|
||||
logger.error(f"❌ Ошибка деактивации RemnaWave пользователя при блокировке: {e}")
|
||||
|
||||
if user.subscription:
|
||||
from app.database.crud.subscription import deactivate_subscription
|
||||
await deactivate_subscription(db, user.subscription)
|
||||
|
||||
await update_user(db, user, status=UserStatus.BLOCKED.value)
|
||||
|
||||
logger.info(f"Админ {admin_id} заблокировал пользователя {user_id}: {reason}")
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка блокировки пользователя: {e}")
|
||||
return False
|
||||
|
||||
async def unblock_user(
|
||||
self,
|
||||
db: AsyncSession,
|
||||
user_id: int,
|
||||
admin_id: int
|
||||
) -> bool:
|
||||
try:
|
||||
user = await get_user_by_id(db, user_id)
|
||||
if not user:
|
||||
return False
|
||||
|
||||
await update_user(db, user, status=UserStatus.ACTIVE.value)
|
||||
|
||||
if user.subscription:
|
||||
from datetime import datetime
|
||||
from app.database.models import SubscriptionStatus
|
||||
|
||||
if user.subscription.end_date > datetime.utcnow():
|
||||
user.subscription.status = SubscriptionStatus.ACTIVE.value
|
||||
await db.commit()
|
||||
await db.refresh(user.subscription)
|
||||
logger.info(f"🔄 Подписка пользователя {user_id} восстановлена")
|
||||
|
||||
if user.remnawave_uuid:
|
||||
try:
|
||||
from app.services.subscription_service import SubscriptionService
|
||||
subscription_service = SubscriptionService()
|
||||
await subscription_service.update_remnawave_user(db, user.subscription)
|
||||
logger.info(f"✅ RemnaWave пользователь {user.remnawave_uuid} восстановлен при разблокировке")
|
||||
except Exception as e:
|
||||
logger.error(f"❌ Ошибка восстановления RemnaWave пользователя при разблокировке: {e}")
|
||||
else:
|
||||
logger.info(f"⏰ Подписка пользователя {user_id} истекла, восстановление невозможно")
|
||||
|
||||
logger.info(f"Админ {admin_id} разблокировал пользователя {user_id}")
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка разблокировки пользователя: {e}")
|
||||
return False
|
||||
|
||||
async def delete_user_account(
|
||||
self,
|
||||
db: AsyncSession,
|
||||
user_id: int,
|
||||
admin_id: int
|
||||
) -> bool:
|
||||
try:
|
||||
user = await get_user_by_id(db, user_id)
|
||||
if not user:
|
||||
logger.warning(f"Пользователь {user_id} не найден для удаления")
|
||||
return False
|
||||
|
||||
logger.info(f"🗑️ Начинаем полное удаление пользователя {user_id} (Telegram ID: {user.telegram_id})")
|
||||
|
||||
if user.remnawave_uuid:
|
||||
try:
|
||||
from app.services.subscription_service import SubscriptionService
|
||||
subscription_service = SubscriptionService()
|
||||
await subscription_service.disable_remnawave_user(user.remnawave_uuid)
|
||||
logger.info(f" ✅ RemnaWave пользователь {user.remnawave_uuid} деактивирован")
|
||||
except Exception as e:
|
||||
logger.warning(f" ⚠️ Ошибка деактивации RemnaWave: {e}")
|
||||
|
||||
try:
|
||||
from app.database.models import YooKassaPayment
|
||||
from sqlalchemy import select
|
||||
|
||||
yookassa_result = await db.execute(
|
||||
select(YooKassaPayment).where(YooKassaPayment.user_id == user_id)
|
||||
)
|
||||
yookassa_payments = yookassa_result.scalars().all()
|
||||
|
||||
if yookassa_payments:
|
||||
logger.info(f" 🔄 Удаляем {len(yookassa_payments)} YooKassa платежей")
|
||||
await db.execute(
|
||||
delete(YooKassaPayment).where(YooKassaPayment.user_id == user_id)
|
||||
)
|
||||
await db.flush()
|
||||
logger.info(f" ✅ YooKassa платежи удалены")
|
||||
except Exception as e:
|
||||
logger.error(f" ❌ Ошибка удаления YooKassa платежей: {e}")
|
||||
|
||||
try:
|
||||
transactions_result = await db.execute(
|
||||
select(Transaction).where(Transaction.user_id == user_id)
|
||||
)
|
||||
transactions = transactions_result.scalars().all()
|
||||
|
||||
if transactions:
|
||||
logger.info(f" 🔄 Удаляем {len(transactions)} транзакций")
|
||||
await db.execute(
|
||||
delete(Transaction).where(Transaction.user_id == user_id)
|
||||
)
|
||||
await db.flush()
|
||||
logger.info(f" ✅ Транзакции удалены")
|
||||
except Exception as e:
|
||||
logger.error(f" ❌ Ошибка удаления транзакций: {e}")
|
||||
|
||||
try:
|
||||
await db.execute(
|
||||
delete(PromoCodeUse).where(PromoCodeUse.user_id == user_id)
|
||||
)
|
||||
await db.flush()
|
||||
logger.info(f" 🗑️ Удалены использования промокодов пользователя {user_id}")
|
||||
except Exception as e:
|
||||
logger.error(f" ❌ Ошибка удаления использований промокодов: {e}")
|
||||
|
||||
try:
|
||||
await db.execute(
|
||||
delete(ReferralEarning).where(ReferralEarning.user_id == user_id)
|
||||
)
|
||||
await db.flush()
|
||||
logger.info(f" 🗑️ Удалены реферальные доходы пользователя {user_id}")
|
||||
except Exception as e:
|
||||
logger.error(f" ❌ Ошибка удаления реферальных доходов: {e}")
|
||||
|
||||
try:
|
||||
await db.execute(
|
||||
delete(ReferralEarning).where(ReferralEarning.referral_id == user_id)
|
||||
)
|
||||
await db.flush()
|
||||
logger.info(f" 🗑️ Удалены реферальные записи о пользователе {user_id}")
|
||||
except Exception as e:
|
||||
logger.error(f" ❌ Ошибка удаления реферальных записей: {e}")
|
||||
|
||||
try:
|
||||
from app.database.models import BroadcastHistory
|
||||
await db.execute(
|
||||
delete(BroadcastHistory).where(BroadcastHistory.admin_id == user_id)
|
||||
)
|
||||
await db.flush()
|
||||
logger.info(f" 🗑️ Удалена история рассылок админа {user_id}")
|
||||
except Exception as e:
|
||||
logger.error(f" ❌ Ошибка удаления истории рассылок: {e}")
|
||||
|
||||
if user.subscription:
|
||||
try:
|
||||
await db.execute(
|
||||
delete(SubscriptionServer).where(
|
||||
SubscriptionServer.subscription_id == user.subscription.id
|
||||
)
|
||||
)
|
||||
await db.flush()
|
||||
logger.info(f" 🗑️ Удалены записи SubscriptionServer для подписки {user.subscription.id}")
|
||||
except Exception as e:
|
||||
logger.error(f" ❌ Ошибка удаления SubscriptionServer: {e}")
|
||||
|
||||
if user.subscription:
|
||||
try:
|
||||
from app.database.models import Subscription
|
||||
await db.execute(
|
||||
delete(Subscription).where(Subscription.user_id == user_id)
|
||||
)
|
||||
await db.flush()
|
||||
logger.info(f" 🗑️ Удалена подписка пользователя {user_id}")
|
||||
except Exception as e:
|
||||
logger.error(f" ❌ Ошибка удаления подписки: {e}")
|
||||
|
||||
try:
|
||||
from sqlalchemy import update
|
||||
referrals_result = await db.execute(
|
||||
update(User)
|
||||
.where(User.referred_by_id == user_id)
|
||||
.values(referred_by_id=None)
|
||||
)
|
||||
if referrals_result.rowcount > 0:
|
||||
logger.info(f" 🔗 Очищены реферальные ссылки у {referrals_result.rowcount} рефералов")
|
||||
await db.flush()
|
||||
except Exception as e:
|
||||
logger.error(f" ❌ Ошибка очистки реферальных ссылок: {e}")
|
||||
|
||||
try:
|
||||
await db.execute(
|
||||
delete(User).where(User.id == user_id)
|
||||
)
|
||||
await db.commit()
|
||||
logger.info(f" ✅ Пользователь {user_id} окончательно удален из базы")
|
||||
except Exception as e:
|
||||
logger.error(f" ❌ Ошибка финального удаления пользователя: {e}")
|
||||
await db.rollback()
|
||||
return False
|
||||
|
||||
logger.info(f"✅ Пользователь {user.telegram_id} (ID: {user_id}) полностью удален администратором {admin_id}")
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"❌ Критическая ошибка удаления пользователя {user_id}: {e}")
|
||||
await db.rollback()
|
||||
return False
|
||||
|
||||
async def get_user_statistics(self, db: AsyncSession) -> Dict[str, Any]:
|
||||
try:
|
||||
stats = await get_users_statistics(db)
|
||||
return stats
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка получения статистики пользователей: {e}")
|
||||
return {
|
||||
"total_users": 0,
|
||||
"active_users": 0,
|
||||
"blocked_users": 0,
|
||||
"new_today": 0,
|
||||
"new_week": 0,
|
||||
"new_month": 0
|
||||
}
|
||||
|
||||
async def cleanup_inactive_users(
|
||||
self,
|
||||
db: AsyncSession,
|
||||
months: int = None
|
||||
) -> int:
|
||||
try:
|
||||
if months is None:
|
||||
months = settings.INACTIVE_USER_DELETE_MONTHS
|
||||
|
||||
inactive_users = await get_inactive_users(db, months)
|
||||
deleted_count = 0
|
||||
|
||||
for user in inactive_users:
|
||||
success = await self.delete_user_account(db, user.id, 0)
|
||||
if success:
|
||||
deleted_count += 1
|
||||
|
||||
logger.info(f"Удалено {deleted_count} неактивных пользователей")
|
||||
return deleted_count
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка очистки неактивных пользователей: {e}")
|
||||
return 0
|
||||
|
||||
async def get_user_activity_summary(
|
||||
self,
|
||||
db: AsyncSession,
|
||||
user_id: int
|
||||
) -> Dict[str, Any]:
|
||||
try:
|
||||
user = await get_user_by_id(db, user_id)
|
||||
if not user:
|
||||
return {}
|
||||
|
||||
subscription = await get_subscription_by_user_id(db, user_id)
|
||||
transactions_count = await get_user_transactions_count(db, user_id)
|
||||
|
||||
days_since_registration = (datetime.utcnow() - user.created_at).days
|
||||
|
||||
days_since_activity = (datetime.utcnow() - user.last_activity).days if user.last_activity else None
|
||||
|
||||
return {
|
||||
"user_id": user.id,
|
||||
"telegram_id": user.telegram_id,
|
||||
"username": user.username,
|
||||
"full_name": user.full_name,
|
||||
"status": user.status,
|
||||
"language": user.language,
|
||||
"balance_kopeks": user.balance_kopeks,
|
||||
"registration_date": user.created_at,
|
||||
"last_activity": user.last_activity,
|
||||
"days_since_registration": days_since_registration,
|
||||
"days_since_activity": days_since_activity,
|
||||
"has_subscription": subscription is not None,
|
||||
"subscription_active": subscription.is_active if subscription else False,
|
||||
"subscription_trial": subscription.is_trial if subscription else False,
|
||||
"transactions_count": transactions_count,
|
||||
"referrer_id": user.referred_by_id,
|
||||
"referral_code": user.referral_code
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка получения сводки активности пользователя {user_id}: {e}")
|
||||
return {}
|
||||
|
||||
async def get_users_by_criteria(
|
||||
self,
|
||||
db: AsyncSession,
|
||||
criteria: Dict[str, Any]
|
||||
) -> List[User]:
|
||||
try:
|
||||
status = criteria.get('status')
|
||||
has_subscription = criteria.get('has_subscription')
|
||||
is_trial = criteria.get('is_trial')
|
||||
min_balance = criteria.get('min_balance', 0)
|
||||
max_balance = criteria.get('max_balance')
|
||||
days_inactive = criteria.get('days_inactive')
|
||||
|
||||
registered_after = criteria.get('registered_after')
|
||||
registered_before = criteria.get('registered_before')
|
||||
|
||||
users = await get_users_list(db, offset=0, limit=10000, status=status)
|
||||
|
||||
filtered_users = []
|
||||
for user in users:
|
||||
if user.balance_kopeks < min_balance:
|
||||
continue
|
||||
if max_balance and user.balance_kopeks > max_balance:
|
||||
continue
|
||||
|
||||
if registered_after and user.created_at < registered_after:
|
||||
continue
|
||||
if registered_before and user.created_at > registered_before:
|
||||
continue
|
||||
|
||||
if days_inactive and user.last_activity:
|
||||
inactive_threshold = datetime.utcnow() - timedelta(days=days_inactive)
|
||||
if user.last_activity > inactive_threshold:
|
||||
continue
|
||||
|
||||
filtered_users.append(user)
|
||||
|
||||
return filtered_users
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка получения пользователей по критериям: {e}")
|
||||
return []
|
||||
@@ -0,0 +1,194 @@
|
||||
import uuid
|
||||
import logging
|
||||
import asyncio
|
||||
from typing import Optional, Dict, Any, List
|
||||
|
||||
from yookassa import Configuration, Payment as YooKassaPayment
|
||||
from yookassa.domain.request.payment_request_builder import PaymentRequestBuilder
|
||||
from yookassa.domain.common.confirmation_type import ConfirmationType
|
||||
|
||||
from app.config import settings
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class YooKassaService:
|
||||
|
||||
def __init__(self,
|
||||
shop_id: Optional[str] = None,
|
||||
secret_key: Optional[str] = None,
|
||||
configured_return_url: Optional[str] = None,
|
||||
bot_username_for_default_return: Optional[str] = None):
|
||||
|
||||
shop_id = shop_id or getattr(settings, 'YOOKASSA_SHOP_ID', None)
|
||||
secret_key = secret_key or getattr(settings, 'YOOKASSA_SECRET_KEY', None)
|
||||
configured_return_url = configured_return_url or getattr(settings, 'YOOKASSA_RETURN_URL', None)
|
||||
|
||||
if not shop_id or not secret_key:
|
||||
logger.warning(
|
||||
"YooKassa SHOP_ID или SECRET_KEY не настроены в settings. "
|
||||
"Функционал платежей будет ОТКЛЮЧЕН.")
|
||||
self.configured = False
|
||||
else:
|
||||
try:
|
||||
Configuration.configure(shop_id, secret_key)
|
||||
self.configured = True
|
||||
logger.info(
|
||||
f"YooKassa SDK сконфигурирован для shop_id: {shop_id[:5]}...")
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка конфигурации YooKassa SDK: {e}",
|
||||
exc_info=True)
|
||||
self.configured = False
|
||||
|
||||
if configured_return_url:
|
||||
self.return_url = configured_return_url
|
||||
elif bot_username_for_default_return:
|
||||
self.return_url = f"https://t.me/{bot_username_for_default_return}"
|
||||
logger.info(
|
||||
f"YOOKASSA_RETURN_URL не установлен, используем бота: {self.return_url}")
|
||||
else:
|
||||
self.return_url = "https://t.me/"
|
||||
logger.warning(
|
||||
f"КРИТИЧНО: YOOKASSA_RETURN_URL не установлен И username бота не предоставлен. "
|
||||
f"Используем заглушку: {self.return_url}. Платежи могут работать некорректно.")
|
||||
|
||||
logger.info(f"YooKassa Service return_url: {self.return_url}")
|
||||
|
||||
async def create_payment(
|
||||
self,
|
||||
amount: float,
|
||||
currency: str,
|
||||
description: str,
|
||||
metadata: Dict[str, Any],
|
||||
receipt_email: Optional[str] = None,
|
||||
receipt_phone: Optional[str] = None) -> Optional[Dict[str, Any]]:
|
||||
"""Создает платеж в YooKassa"""
|
||||
|
||||
if not self.configured:
|
||||
logger.error("YooKassa не сконфигурирован. Невозможно создать платеж.")
|
||||
return None
|
||||
|
||||
customer_contact_for_receipt = {}
|
||||
if receipt_email:
|
||||
customer_contact_for_receipt["email"] = receipt_email
|
||||
elif receipt_phone:
|
||||
customer_contact_for_receipt["phone"] = receipt_phone
|
||||
elif hasattr(settings, 'YOOKASSA_DEFAULT_RECEIPT_EMAIL') and settings.YOOKASSA_DEFAULT_RECEIPT_EMAIL:
|
||||
customer_contact_for_receipt["email"] = settings.YOOKASSA_DEFAULT_RECEIPT_EMAIL
|
||||
else:
|
||||
logger.error(
|
||||
"КРИТИЧНО: Не предоставлен email/телефон для чека YooKassa и YOOKASSA_DEFAULT_RECEIPT_EMAIL не установлен.")
|
||||
return {
|
||||
"error": True,
|
||||
"internal_message": "Отсутствуют контактные данные для чека YooKassa и не настроен email по умолчанию."
|
||||
}
|
||||
|
||||
try:
|
||||
builder = PaymentRequestBuilder()
|
||||
builder.set_amount({
|
||||
"value": str(round(amount, 2)),
|
||||
"currency": currency.upper()
|
||||
})
|
||||
builder.set_capture(True)
|
||||
builder.set_confirmation({
|
||||
"type": ConfirmationType.REDIRECT,
|
||||
"return_url": self.return_url
|
||||
})
|
||||
builder.set_description(description)
|
||||
builder.set_metadata(metadata)
|
||||
|
||||
receipt_items_list: List[Dict[str, Any]] = [{
|
||||
"description": description[:128],
|
||||
"quantity": "1.00",
|
||||
"amount": {
|
||||
"value": str(round(amount, 2)),
|
||||
"currency": currency.upper()
|
||||
},
|
||||
"vat_code": str(getattr(settings, 'YOOKASSA_VAT_CODE', 1)),
|
||||
"payment_mode": getattr(settings, 'YOOKASSA_PAYMENT_MODE', 'full_payment'),
|
||||
"payment_subject": getattr(settings, 'YOOKASSA_PAYMENT_SUBJECT', 'service')
|
||||
}]
|
||||
|
||||
receipt_data_dict: Dict[str, Any] = {
|
||||
"customer": customer_contact_for_receipt,
|
||||
"items": receipt_items_list
|
||||
}
|
||||
|
||||
builder.set_receipt(receipt_data_dict)
|
||||
|
||||
idempotence_key = str(uuid.uuid4())
|
||||
payment_request = builder.build()
|
||||
|
||||
logger.info(
|
||||
f"Создание платежа YooKassa (Idempotence-Key: {idempotence_key}). "
|
||||
f"Сумма: {amount} {currency}. Метаданные: {metadata}. Чек: {receipt_data_dict}")
|
||||
|
||||
loop = asyncio.get_running_loop()
|
||||
response = await loop.run_in_executor(
|
||||
None, lambda: YooKassaPayment.create(payment_request, idempotence_key))
|
||||
|
||||
logger.info(
|
||||
f"Ответ YooKassa Payment.create: ID={response.id}, Status={response.status}, Paid={response.paid}")
|
||||
|
||||
return {
|
||||
"id": response.id,
|
||||
"confirmation_url": response.confirmation.confirmation_url if response.confirmation else None,
|
||||
"status": response.status,
|
||||
"metadata": response.metadata,
|
||||
"amount_value": float(response.amount.value),
|
||||
"amount_currency": response.amount.currency,
|
||||
"idempotence_key_used": idempotence_key,
|
||||
"paid": response.paid,
|
||||
"refundable": response.refundable,
|
||||
"created_at": response.created_at.isoformat() if hasattr(
|
||||
response.created_at, 'isoformat') else str(response.created_at),
|
||||
"description_from_yk": response.description,
|
||||
"test_mode": response.test if hasattr(response, 'test') else None
|
||||
}
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка создания платежа YooKassa: {e}", exc_info=True)
|
||||
return None
|
||||
|
||||
async def get_payment_info(
|
||||
self, payment_id_in_yookassa: str) -> Optional[Dict[str, Any]]:
|
||||
|
||||
if not self.configured:
|
||||
logger.error("YooKassa не сконфигурирован. Невозможно получить информацию о платеже.")
|
||||
return None
|
||||
|
||||
try:
|
||||
logger.info(f"Получение информации о платеже YooKassa ID: {payment_id_in_yookassa}")
|
||||
|
||||
loop = asyncio.get_running_loop()
|
||||
payment_info_yk = await loop.run_in_executor(
|
||||
None, lambda: YooKassaPayment.find_one(payment_id_in_yookassa))
|
||||
|
||||
if payment_info_yk:
|
||||
logger.info(
|
||||
f"Информация о платеже YooKassa {payment_id_in_yookassa}: "
|
||||
f"Status={payment_info_yk.status}, Paid={payment_info_yk.paid}")
|
||||
return {
|
||||
"id": payment_info_yk.id,
|
||||
"status": payment_info_yk.status,
|
||||
"paid": payment_info_yk.paid,
|
||||
"amount_value": float(payment_info_yk.amount.value),
|
||||
"amount_currency": payment_info_yk.amount.currency,
|
||||
"metadata": payment_info_yk.metadata,
|
||||
"description": payment_info_yk.description,
|
||||
"refundable": payment_info_yk.refundable,
|
||||
"created_at": payment_info_yk.created_at.isoformat() if hasattr(
|
||||
payment_info_yk.created_at, 'isoformat') else str(payment_info_yk.created_at),
|
||||
"captured_at": payment_info_yk.captured_at.isoformat()
|
||||
if payment_info_yk.captured_at and hasattr(
|
||||
payment_info_yk.captured_at, 'isoformat') else None,
|
||||
"payment_method_type": payment_info_yk.payment_method.type
|
||||
if payment_info_yk.payment_method else None,
|
||||
"test_mode": payment_info_yk.test if hasattr(payment_info_yk, 'test') else None
|
||||
}
|
||||
else:
|
||||
logger.warning(f"Платеж не найден в YooKassa ID: {payment_id_in_yookassa}")
|
||||
return None
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка получения информации о платеже YooKassa {payment_id_in_yookassa}: {e}",
|
||||
exc_info=True)
|
||||
return None
|
||||
@@ -0,0 +1,87 @@
|
||||
from aiogram.fsm.state import State, StatesGroup
|
||||
|
||||
|
||||
class RegistrationStates(StatesGroup):
|
||||
waiting_for_rules_accept = State()
|
||||
waiting_for_referral_code = State()
|
||||
|
||||
|
||||
class SubscriptionStates(StatesGroup):
|
||||
selecting_period = State()
|
||||
selecting_traffic = State()
|
||||
selecting_countries = State()
|
||||
selecting_devices = State()
|
||||
confirming_purchase = State()
|
||||
|
||||
adding_countries = State()
|
||||
adding_traffic = State()
|
||||
adding_devices = State()
|
||||
extending_subscription = State()
|
||||
confirming_traffic_reset = State()
|
||||
|
||||
|
||||
class BalanceStates(StatesGroup):
|
||||
waiting_for_amount = State()
|
||||
waiting_for_stars_payment = State()
|
||||
waiting_for_support_request = State()
|
||||
|
||||
|
||||
class PromoCodeStates(StatesGroup):
|
||||
waiting_for_code = State()
|
||||
waiting_for_referral_code = State()
|
||||
|
||||
|
||||
class AdminStates(StatesGroup):
|
||||
|
||||
waiting_for_user_search = State()
|
||||
editing_user_balance = State()
|
||||
extending_subscription = State()
|
||||
adding_traffic = State()
|
||||
granting_subscription = State()
|
||||
editing_user_subscription = State()
|
||||
|
||||
creating_promocode = State()
|
||||
setting_promocode_type = State()
|
||||
setting_promocode_value = State()
|
||||
setting_promocode_uses = State()
|
||||
setting_promocode_expiry = State()
|
||||
|
||||
waiting_for_broadcast_message = State()
|
||||
confirming_broadcast = State()
|
||||
|
||||
editing_squad_price = State()
|
||||
editing_traffic_price = State()
|
||||
editing_device_price = State()
|
||||
editing_user_devices = State()
|
||||
editing_user_traffic = State()
|
||||
|
||||
editing_rules_page = State()
|
||||
|
||||
confirming_sync = State()
|
||||
|
||||
editing_server_name = State()
|
||||
editing_server_price = State()
|
||||
editing_server_country = State()
|
||||
editing_server_limit = State()
|
||||
editing_server_description = State()
|
||||
|
||||
creating_server_uuid = State()
|
||||
creating_server_name = State()
|
||||
creating_server_price = State()
|
||||
creating_server_country = State()
|
||||
|
||||
|
||||
class SupportStates(StatesGroup):
|
||||
waiting_for_message = State()
|
||||
|
||||
|
||||
class AutoPayStates(StatesGroup):
|
||||
setting_autopay_days = State()
|
||||
confirming_autopay_toggle = State()
|
||||
|
||||
class SquadCreateStates(StatesGroup):
|
||||
waiting_for_name = State()
|
||||
selecting_inbounds = State()
|
||||
|
||||
class SquadRenameStates(StatesGroup):
|
||||
waiting_for_new_name = State()
|
||||
@@ -0,0 +1,13 @@
|
||||
from .pricing_utils import (
|
||||
calculate_months_from_days,
|
||||
get_remaining_months,
|
||||
calculate_prorated_price,
|
||||
format_period_description
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
'calculate_months_from_days',
|
||||
'get_remaining_months',
|
||||
'calculate_prorated_price',
|
||||
'format_period_description'
|
||||
]
|
||||
@@ -0,0 +1,264 @@
|
||||
import json
|
||||
import logging
|
||||
from typing import Any, Optional, Union
|
||||
from datetime import datetime, timedelta
|
||||
import redis.asyncio as redis
|
||||
|
||||
from app.config import settings
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class CacheService:
|
||||
|
||||
def __init__(self):
|
||||
self.redis_client: Optional[redis.Redis] = None
|
||||
self._connected = False
|
||||
|
||||
async def connect(self):
|
||||
try:
|
||||
self.redis_client = redis.from_url(settings.REDIS_URL)
|
||||
await self.redis_client.ping()
|
||||
self._connected = True
|
||||
logger.info("✅ Подключение к Redis кешу установлено")
|
||||
except Exception as e:
|
||||
logger.warning(f"⚠️ Не удалось подключиться к Redis: {e}")
|
||||
self._connected = False
|
||||
|
||||
async def disconnect(self):
|
||||
if self.redis_client:
|
||||
await self.redis_client.close()
|
||||
self._connected = False
|
||||
|
||||
async def get(self, key: str) -> Optional[Any]:
|
||||
if not self._connected:
|
||||
return None
|
||||
|
||||
try:
|
||||
value = await self.redis_client.get(key)
|
||||
if value:
|
||||
return json.loads(value)
|
||||
return None
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка получения из кеша {key}: {e}")
|
||||
return None
|
||||
|
||||
async def set(
|
||||
self,
|
||||
key: str,
|
||||
value: Any,
|
||||
expire: Union[int, timedelta] = None
|
||||
) -> bool:
|
||||
if not self._connected:
|
||||
return False
|
||||
|
||||
try:
|
||||
serialized_value = json.dumps(value, default=str)
|
||||
|
||||
if isinstance(expire, timedelta):
|
||||
expire = int(expire.total_seconds())
|
||||
|
||||
await self.redis_client.set(key, serialized_value, ex=expire)
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка записи в кеш {key}: {e}")
|
||||
return False
|
||||
|
||||
async def delete(self, key: str) -> bool:
|
||||
if not self._connected:
|
||||
return False
|
||||
|
||||
try:
|
||||
deleted = await self.redis_client.delete(key)
|
||||
return deleted > 0
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка удаления из кеша {key}: {e}")
|
||||
return False
|
||||
|
||||
async def exists(self, key: str) -> bool:
|
||||
if not self._connected:
|
||||
return False
|
||||
|
||||
try:
|
||||
return await self.redis_client.exists(key)
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка проверки существования в кеше {key}: {e}")
|
||||
return False
|
||||
|
||||
async def expire(self, key: str, seconds: int) -> bool:
|
||||
if not self._connected:
|
||||
return False
|
||||
|
||||
try:
|
||||
return await self.redis_client.expire(key, seconds)
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка установки TTL для {key}: {e}")
|
||||
return False
|
||||
|
||||
async def get_keys(self, pattern: str = "*") -> list:
|
||||
if not self._connected:
|
||||
return []
|
||||
|
||||
try:
|
||||
keys = await self.redis_client.keys(pattern)
|
||||
return [key.decode() if isinstance(key, bytes) else key for key in keys]
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка получения ключей по паттерну {pattern}: {e}")
|
||||
return []
|
||||
|
||||
async def flush_all(self) -> bool:
|
||||
if not self._connected:
|
||||
return False
|
||||
|
||||
try:
|
||||
await self.redis_client.flushall()
|
||||
logger.info("🗑️ Кеш полностью очищен")
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка очистки кеша: {e}")
|
||||
return False
|
||||
|
||||
async def increment(self, key: str, amount: int = 1) -> Optional[int]:
|
||||
if not self._connected:
|
||||
return None
|
||||
|
||||
try:
|
||||
return await self.redis_client.incrby(key, amount)
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка инкремента {key}: {e}")
|
||||
return None
|
||||
|
||||
async def set_hash(self, name: str, mapping: dict, expire: int = None) -> bool:
|
||||
if not self._connected:
|
||||
return False
|
||||
|
||||
try:
|
||||
await self.redis_client.hset(name, mapping=mapping)
|
||||
if expire:
|
||||
await self.redis_client.expire(name, expire)
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка записи хеша {name}: {e}")
|
||||
return False
|
||||
|
||||
async def get_hash(self, name: str, key: str = None) -> Optional[Union[dict, str]]:
|
||||
if not self._connected:
|
||||
return None
|
||||
|
||||
try:
|
||||
if key:
|
||||
value = await self.redis_client.hget(name, key)
|
||||
return value.decode() if value else None
|
||||
else:
|
||||
hash_data = await self.redis_client.hgetall(name)
|
||||
return {k.decode(): v.decode() for k, v in hash_data.items()}
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка получения хеша {name}: {e}")
|
||||
return None
|
||||
|
||||
|
||||
cache = CacheService()
|
||||
|
||||
|
||||
def cache_key(*parts) -> str:
|
||||
return ":".join(str(part) for part in parts)
|
||||
|
||||
|
||||
async def cached_function(key: str, expire: int = 300):
|
||||
def decorator(func):
|
||||
async def wrapper(*args, **kwargs):
|
||||
cache_result = await cache.get(key)
|
||||
if cache_result is not None:
|
||||
return cache_result
|
||||
|
||||
result = await func(*args, **kwargs)
|
||||
await cache.set(key, result, expire)
|
||||
return result
|
||||
|
||||
return wrapper
|
||||
return decorator
|
||||
|
||||
|
||||
class UserCache:
|
||||
|
||||
@staticmethod
|
||||
async def get_user_data(user_id: int) -> Optional[dict]:
|
||||
key = cache_key("user", user_id)
|
||||
return await cache.get(key)
|
||||
|
||||
@staticmethod
|
||||
async def set_user_data(user_id: int, data: dict, expire: int = 3600) -> bool:
|
||||
key = cache_key("user", user_id)
|
||||
return await cache.set(key, data, expire)
|
||||
|
||||
@staticmethod
|
||||
async def delete_user_data(user_id: int) -> bool:
|
||||
key = cache_key("user", user_id)
|
||||
return await cache.delete(key)
|
||||
|
||||
@staticmethod
|
||||
async def get_user_session(user_id: int, session_key: str) -> Optional[Any]:
|
||||
key = cache_key("session", user_id, session_key)
|
||||
return await cache.get(key)
|
||||
|
||||
@staticmethod
|
||||
async def set_user_session(
|
||||
user_id: int,
|
||||
session_key: str,
|
||||
data: Any,
|
||||
expire: int = 1800
|
||||
) -> bool:
|
||||
key = cache_key("session", user_id, session_key)
|
||||
return await cache.set(key, data, expire)
|
||||
|
||||
|
||||
class SystemCache:
|
||||
|
||||
@staticmethod
|
||||
async def get_system_stats() -> Optional[dict]:
|
||||
return await cache.get("system:stats")
|
||||
|
||||
@staticmethod
|
||||
async def set_system_stats(stats: dict, expire: int = 300) -> bool:
|
||||
return await cache.set("system:stats", stats, expire)
|
||||
|
||||
@staticmethod
|
||||
async def get_nodes_status() -> Optional[list]:
|
||||
return await cache.get("remnawave:nodes")
|
||||
|
||||
@staticmethod
|
||||
async def set_nodes_status(nodes: list, expire: int = 60) -> bool:
|
||||
return await cache.set("remnawave:nodes", nodes, expire)
|
||||
|
||||
@staticmethod
|
||||
async def get_daily_stats(date: str) -> Optional[dict]:
|
||||
key = cache_key("stats", "daily", date)
|
||||
return await cache.get(key)
|
||||
|
||||
@staticmethod
|
||||
async def set_daily_stats(date: str, stats: dict) -> bool:
|
||||
key = cache_key("stats", "daily", date)
|
||||
return await cache.set(key, stats, 86400) # 24 часа
|
||||
|
||||
|
||||
class RateLimitCache:
|
||||
|
||||
@staticmethod
|
||||
async def is_rate_limited(user_id: int, action: str, limit: int, window: int) -> bool:
|
||||
key = cache_key("rate_limit", user_id, action)
|
||||
current = await cache.get(key)
|
||||
|
||||
if current is None:
|
||||
await cache.set(key, 1, window)
|
||||
return False
|
||||
|
||||
if current >= limit:
|
||||
return True
|
||||
|
||||
await cache.increment(key)
|
||||
return False
|
||||
|
||||
@staticmethod
|
||||
async def reset_rate_limit(user_id: int, action: str) -> bool:
|
||||
key = cache_key("rate_limit", user_id, action)
|
||||
return await cache.delete(key)
|
||||
@@ -0,0 +1,117 @@
|
||||
import logging
|
||||
import functools
|
||||
from typing import Callable, Any
|
||||
from aiogram import types
|
||||
from aiogram.fsm.context import FSMContext
|
||||
|
||||
from app.config import settings
|
||||
from app.localization.texts import get_texts
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def admin_required(func: Callable) -> Callable:
|
||||
|
||||
@functools.wraps(func)
|
||||
async def wrapper(
|
||||
event: types.Update,
|
||||
*args,
|
||||
**kwargs
|
||||
) -> Any:
|
||||
user = None
|
||||
if isinstance(event, (types.Message, types.CallbackQuery)):
|
||||
user = event.from_user
|
||||
|
||||
if not user or not settings.is_admin(user.id):
|
||||
texts = get_texts()
|
||||
|
||||
if isinstance(event, types.Message):
|
||||
await event.answer(texts.ACCESS_DENIED)
|
||||
elif isinstance(event, types.CallbackQuery):
|
||||
await event.answer(texts.ACCESS_DENIED, show_alert=True)
|
||||
|
||||
logger.warning(f"Попытка доступа к админской функции от {user.id if user else 'Unknown'}")
|
||||
return
|
||||
|
||||
return await func(event, *args, **kwargs)
|
||||
|
||||
return wrapper
|
||||
|
||||
|
||||
def error_handler(func: Callable) -> Callable:
|
||||
|
||||
@functools.wraps(func)
|
||||
async def wrapper(*args, **kwargs) -> Any:
|
||||
try:
|
||||
return await func(*args, **kwargs)
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка в {func.__name__}: {e}", exc_info=True)
|
||||
|
||||
event = None
|
||||
db_user = kwargs.get('db_user')
|
||||
|
||||
for arg in args:
|
||||
if isinstance(arg, (types.Message, types.CallbackQuery)):
|
||||
event = arg
|
||||
break
|
||||
|
||||
if event:
|
||||
texts = get_texts(db_user.language if db_user else 'ru')
|
||||
|
||||
if isinstance(event, types.Message):
|
||||
await event.answer(texts.ERROR)
|
||||
elif isinstance(event, types.CallbackQuery):
|
||||
await event.answer(texts.ERROR, show_alert=True)
|
||||
|
||||
return wrapper
|
||||
|
||||
|
||||
def state_cleanup(func: Callable) -> Callable:
|
||||
|
||||
@functools.wraps(func)
|
||||
async def wrapper(*args, **kwargs) -> Any:
|
||||
state = kwargs.get('state')
|
||||
|
||||
try:
|
||||
return await func(*args, **kwargs)
|
||||
except Exception as e:
|
||||
if state and isinstance(state, FSMContext):
|
||||
await state.clear()
|
||||
raise e
|
||||
|
||||
return wrapper
|
||||
|
||||
|
||||
def typing_action(func: Callable) -> Callable:
|
||||
|
||||
@functools.wraps(func)
|
||||
async def wrapper(
|
||||
event: types.Update,
|
||||
*args,
|
||||
**kwargs
|
||||
) -> Any:
|
||||
if isinstance(event, types.Message):
|
||||
await event.bot.send_chat_action(
|
||||
chat_id=event.chat.id,
|
||||
action="typing"
|
||||
)
|
||||
|
||||
return await func(event, *args, **kwargs)
|
||||
|
||||
return wrapper
|
||||
|
||||
|
||||
def rate_limit(rate: float = 1.0, key: str = None):
|
||||
def decorator(func: Callable) -> Callable:
|
||||
|
||||
@functools.wraps(func)
|
||||
async def wrapper(
|
||||
event: types.Update,
|
||||
*args,
|
||||
**kwargs
|
||||
) -> Any:
|
||||
return await func(event, *args, **kwargs)
|
||||
|
||||
return wrapper
|
||||
|
||||
return decorator
|
||||
@@ -0,0 +1,218 @@
|
||||
from datetime import datetime, timedelta
|
||||
from typing import Union, Optional
|
||||
|
||||
|
||||
def format_datetime(dt: Union[datetime, str], format_str: str = "%d.%m.%Y %H:%M") -> str:
|
||||
if isinstance(dt, str):
|
||||
if dt == "now" or dt == "":
|
||||
dt = datetime.now()
|
||||
else:
|
||||
try:
|
||||
dt = datetime.fromisoformat(dt.replace('Z', '+00:00'))
|
||||
except (ValueError, AttributeError):
|
||||
dt = datetime.now()
|
||||
|
||||
return dt.strftime(format_str)
|
||||
|
||||
|
||||
def format_date(dt: Union[datetime, str], format_str: str = "%d.%m.%Y") -> str:
|
||||
if isinstance(dt, str):
|
||||
if dt == "now" or dt == "":
|
||||
dt = datetime.now()
|
||||
else:
|
||||
try:
|
||||
dt = datetime.fromisoformat(dt.replace('Z', '+00:00'))
|
||||
except (ValueError, AttributeError):
|
||||
dt = datetime.now()
|
||||
|
||||
return dt.strftime(format_str)
|
||||
|
||||
|
||||
def format_time_ago(dt: Union[datetime, str]) -> str:
|
||||
if isinstance(dt, str):
|
||||
if dt == "now" or dt == "":
|
||||
dt = datetime.now()
|
||||
else:
|
||||
try:
|
||||
dt = datetime.fromisoformat(dt.replace('Z', '+00:00'))
|
||||
except (ValueError, AttributeError):
|
||||
dt = datetime.now()
|
||||
|
||||
now = datetime.utcnow()
|
||||
diff = now - dt
|
||||
|
||||
if diff.days > 0:
|
||||
if diff.days == 1:
|
||||
return "вчера"
|
||||
elif diff.days < 7:
|
||||
return f"{diff.days} дн. назад"
|
||||
elif diff.days < 30:
|
||||
weeks = diff.days // 7
|
||||
return f"{weeks} нед. назад"
|
||||
elif diff.days < 365:
|
||||
months = diff.days // 30
|
||||
return f"{months} мес. назад"
|
||||
else:
|
||||
years = diff.days // 365
|
||||
return f"{years} г. назад"
|
||||
|
||||
elif diff.seconds > 3600:
|
||||
hours = diff.seconds // 3600
|
||||
return f"{hours} ч. назад"
|
||||
|
||||
elif diff.seconds > 60:
|
||||
minutes = diff.seconds // 60
|
||||
return f"{minutes} мин. назад"
|
||||
|
||||
else:
|
||||
return "только что"
|
||||
|
||||
def format_days_declension(days: int, language: str = "ru") -> str:
|
||||
if language != "ru":
|
||||
return f"{days} day{'s' if days != 1 else ''}"
|
||||
|
||||
if days % 10 == 1 and days % 100 != 11:
|
||||
return f"{days} день"
|
||||
elif days % 10 in [2, 3, 4] and days % 100 not in [12, 13, 14]:
|
||||
return f"{days} дня"
|
||||
else:
|
||||
return f"{days} дней"
|
||||
|
||||
|
||||
def format_duration(seconds: int) -> str:
|
||||
if seconds < 60:
|
||||
return f"{seconds} сек."
|
||||
|
||||
minutes = seconds // 60
|
||||
if minutes < 60:
|
||||
return f"{minutes} мин."
|
||||
|
||||
hours = minutes // 60
|
||||
if hours < 24:
|
||||
return f"{hours} ч."
|
||||
|
||||
days = hours // 24
|
||||
return f"{days} дн."
|
||||
|
||||
|
||||
def format_bytes(bytes_value: int) -> str:
|
||||
if bytes_value == 0:
|
||||
return "0 B"
|
||||
|
||||
units = ["B", "KB", "MB", "GB", "TB"]
|
||||
size = float(bytes_value)
|
||||
unit_index = 0
|
||||
|
||||
while size >= 1024 and unit_index < len(units) - 1:
|
||||
size /= 1024
|
||||
unit_index += 1
|
||||
|
||||
if size == int(size):
|
||||
return f"{int(size)} {units[unit_index]}"
|
||||
else:
|
||||
return f"{size:.1f} {units[unit_index]}"
|
||||
|
||||
|
||||
def format_percentage(value: float, decimals: int = 1) -> str:
|
||||
return f"{value:.{decimals}f}%"
|
||||
|
||||
|
||||
def format_number(number: Union[int, float], separator: str = " ") -> str:
|
||||
if isinstance(number, float):
|
||||
integer_part = int(number)
|
||||
decimal_part = number - integer_part
|
||||
|
||||
formatted_integer = f"{integer_part:,}".replace(",", separator)
|
||||
|
||||
if decimal_part > 0:
|
||||
return f"{formatted_integer}.{decimal_part:.2f}".split('.')[0] + f".{str(decimal_part).split('.')[1][:2]}"
|
||||
else:
|
||||
return formatted_integer
|
||||
else:
|
||||
return f"{number:,}".replace(",", separator)
|
||||
|
||||
|
||||
def format_price_range(min_price: int, max_price: int) -> str:
|
||||
from app.config import settings
|
||||
|
||||
min_formatted = settings.format_price(min_price)
|
||||
max_formatted = settings.format_price(max_price)
|
||||
|
||||
if min_price == max_price:
|
||||
return min_formatted
|
||||
else:
|
||||
return f"{min_formatted} - {max_formatted}"
|
||||
|
||||
|
||||
def truncate_text(text: str, max_length: int = 100, suffix: str = "...") -> str:
|
||||
if len(text) <= max_length:
|
||||
return text
|
||||
|
||||
return text[:max_length - len(suffix)] + suffix
|
||||
|
||||
|
||||
def format_username(username: Optional[str], user_id: int, full_name: Optional[str] = None) -> str:
|
||||
if full_name:
|
||||
return full_name
|
||||
elif username:
|
||||
return f"@{username}"
|
||||
else:
|
||||
return f"ID{user_id}"
|
||||
|
||||
|
||||
def format_subscription_status(
|
||||
is_active: bool,
|
||||
is_trial: bool,
|
||||
end_date: Union[datetime, str],
|
||||
language: str = "ru"
|
||||
) -> str:
|
||||
|
||||
if isinstance(end_date, str):
|
||||
try:
|
||||
end_date = datetime.fromisoformat(end_date.replace('Z', '+00:00'))
|
||||
except (ValueError, AttributeError):
|
||||
end_date = datetime.now()
|
||||
|
||||
if not is_active:
|
||||
return "❌ Неактивна" if language == "ru" else "❌ Inactive"
|
||||
|
||||
if is_trial:
|
||||
status = "🎁 Тестовая" if language == "ru" else "🎁 Trial"
|
||||
else:
|
||||
status = "✅ Активна" if language == "ru" else "✅ Active"
|
||||
|
||||
now = datetime.utcnow()
|
||||
if end_date > now:
|
||||
days_left = (end_date - now).days
|
||||
if days_left > 0:
|
||||
status += f" ({days_left} дн.)" if language == "ru" else f" ({days_left} days)"
|
||||
else:
|
||||
hours_left = (end_date - now).seconds // 3600
|
||||
status += f" ({hours_left} ч.)" if language == "ru" else f" ({hours_left} hrs)"
|
||||
else:
|
||||
status = "⏰ Истекла" if language == "ru" else "⏰ Expired"
|
||||
|
||||
return status
|
||||
|
||||
|
||||
def format_traffic_usage(used_gb: float, limit_gb: int, language: str = "ru") -> str:
|
||||
|
||||
if limit_gb == 0:
|
||||
if language == "ru":
|
||||
return f"{used_gb:.1f} ГБ / ∞"
|
||||
else:
|
||||
return f"{used_gb:.1f} GB / ∞"
|
||||
|
||||
percentage = (used_gb / limit_gb) * 100 if limit_gb > 0 else 0
|
||||
|
||||
if language == "ru":
|
||||
return f"{used_gb:.1f} ГБ / {limit_gb} ГБ ({percentage:.1f}%)"
|
||||
else:
|
||||
return f"{used_gb:.1f} GB / {limit_gb} GB ({percentage:.1f}%)"
|
||||
|
||||
|
||||
def format_boolean(value: bool, language: str = "ru") -> str:
|
||||
if language == "ru":
|
||||
return "✅ Да" if value else "❌ Нет"
|
||||
else:
|
||||
return "✅ Yes" if value else "❌ No"
|
||||
@@ -0,0 +1,82 @@
|
||||
from typing import List, TypeVar, Generic, Dict, Any
|
||||
from math import ceil
|
||||
|
||||
T = TypeVar('T')
|
||||
|
||||
|
||||
class PaginationResult(Generic[T]):
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
items: List[T],
|
||||
total_count: int,
|
||||
page: int,
|
||||
per_page: int
|
||||
):
|
||||
self.items = items
|
||||
self.total_count = total_count
|
||||
self.page = page
|
||||
self.per_page = per_page
|
||||
self.total_pages = ceil(total_count / per_page) if per_page > 0 else 1
|
||||
self.has_prev = page > 1
|
||||
self.has_next = page < self.total_pages
|
||||
self.prev_page = page - 1 if self.has_prev else None
|
||||
self.next_page = page + 1 if self.has_next else None
|
||||
|
||||
|
||||
def paginate_list(
|
||||
items: List[T],
|
||||
page: int = 1,
|
||||
per_page: int = 10
|
||||
) -> PaginationResult[T]:
|
||||
total_count = len(items)
|
||||
|
||||
start_index = (page - 1) * per_page
|
||||
end_index = start_index + per_page
|
||||
|
||||
page_items = items[start_index:end_index]
|
||||
|
||||
return PaginationResult(
|
||||
items=page_items,
|
||||
total_count=total_count,
|
||||
page=page,
|
||||
per_page=per_page
|
||||
)
|
||||
|
||||
|
||||
def get_pagination_info(
|
||||
total_count: int,
|
||||
page: int = 1,
|
||||
per_page: int = 10
|
||||
) -> Dict[str, Any]:
|
||||
total_pages = ceil(total_count / per_page) if per_page > 0 else 1
|
||||
|
||||
return {
|
||||
"total_count": total_count,
|
||||
"page": page,
|
||||
"per_page": per_page,
|
||||
"total_pages": total_pages,
|
||||
"has_prev": page > 1,
|
||||
"has_next": page < total_pages,
|
||||
"prev_page": page - 1 if page > 1 else None,
|
||||
"next_page": page + 1 if page < total_pages else None,
|
||||
"offset": (page - 1) * per_page
|
||||
}
|
||||
|
||||
|
||||
def get_page_numbers(
|
||||
current_page: int,
|
||||
total_pages: int,
|
||||
max_visible: int = 5
|
||||
) -> List[int]:
|
||||
if total_pages <= max_visible:
|
||||
return list(range(1, total_pages + 1))
|
||||
|
||||
half_visible = max_visible // 2
|
||||
start_page = max(1, current_page - half_visible)
|
||||
end_page = min(total_pages, start_page + max_visible - 1)
|
||||
|
||||
if end_page - start_page + 1 < max_visible:
|
||||
start_page = max(1, end_page - max_visible + 1)
|
||||
|
||||
return list(range(start_page, end_page + 1))
|
||||
@@ -0,0 +1,94 @@
|
||||
from datetime import datetime, timedelta
|
||||
from typing import Tuple
|
||||
import logging
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def calculate_months_from_days(days: int) -> int:
|
||||
return max(1, round(days / 30))
|
||||
|
||||
|
||||
def get_remaining_months(end_date: datetime) -> int:
|
||||
current_time = datetime.utcnow()
|
||||
if end_date <= current_time:
|
||||
return 1
|
||||
|
||||
remaining_days = (end_date - current_time).days
|
||||
return max(1, round(remaining_days / 30))
|
||||
|
||||
|
||||
def calculate_period_multiplier(period_days: int) -> Tuple[int, float]:
|
||||
exact_months = period_days / 30
|
||||
months_count = max(1, round(exact_months))
|
||||
|
||||
logger.debug(f"Период {period_days} дней = {exact_months:.2f} точных месяцев ≈ {months_count} месяцев для расчета")
|
||||
|
||||
return months_count, exact_months
|
||||
|
||||
|
||||
def calculate_prorated_price(
|
||||
monthly_price: int,
|
||||
end_date: datetime,
|
||||
min_charge_months: int = 1
|
||||
) -> Tuple[int, int]:
|
||||
months_remaining = get_remaining_months(end_date)
|
||||
months_to_charge = max(min_charge_months, months_remaining)
|
||||
|
||||
total_price = monthly_price * months_to_charge
|
||||
|
||||
logger.debug(f"Расчет пропорциональной цены: {monthly_price/100}₽/мес × {months_to_charge} мес = {total_price/100}₽")
|
||||
|
||||
return total_price, months_to_charge
|
||||
|
||||
|
||||
def format_period_description(days: int, language: str = "ru") -> str:
|
||||
months = calculate_months_from_days(days)
|
||||
|
||||
if language == "ru":
|
||||
if days == 30:
|
||||
return "1 месяц"
|
||||
elif days == 60:
|
||||
return "2 месяца"
|
||||
elif days == 90:
|
||||
return "3 месяца"
|
||||
elif days == 180:
|
||||
return "6 месяцев"
|
||||
elif days == 360:
|
||||
return "12 месяцев"
|
||||
else:
|
||||
month_word = "месяц" if months == 1 else ("месяца" if 2 <= months <= 4 else "месяцев")
|
||||
return f"{days} дней ({months} {month_word})"
|
||||
else:
|
||||
month_word = "month" if months == 1 else "months"
|
||||
return f"{days} days ({months} {month_word})"
|
||||
|
||||
|
||||
def validate_pricing_calculation(
|
||||
base_price: int,
|
||||
monthly_additions: int,
|
||||
months: int,
|
||||
total_calculated: int
|
||||
) -> bool:
|
||||
expected_total = base_price + (monthly_additions * months)
|
||||
is_valid = expected_total == total_calculated
|
||||
|
||||
if not is_valid:
|
||||
logger.warning(f"Несоответствие в расчете цены: ожидалось {expected_total/100}₽, получено {total_calculated/100}₽")
|
||||
logger.warning(f"Детали: базовая цена {base_price/100}₽ + месячные дополнения {monthly_additions/100}₽ × {months} мес")
|
||||
|
||||
return is_valid
|
||||
|
||||
|
||||
STANDARD_PERIODS = {
|
||||
14: {"months": 0.5, "display_ru": "2 недели", "display_en": "2 weeks"},
|
||||
30: {"months": 1, "display_ru": "1 месяц", "display_en": "1 month"},
|
||||
60: {"months": 2, "display_ru": "2 месяца", "display_en": "2 months"},
|
||||
90: {"months": 3, "display_ru": "3 месяца", "display_en": "3 months"},
|
||||
180: {"months": 6, "display_ru": "6 месяцев", "display_en": "6 months"},
|
||||
360: {"months": 12, "display_ru": "1 год", "display_en": "1 year"},
|
||||
}
|
||||
|
||||
|
||||
def get_period_info(days: int) -> dict:
|
||||
return STANDARD_PERIODS.get(days)
|
||||
@@ -0,0 +1,97 @@
|
||||
import logging
|
||||
from datetime import datetime
|
||||
from typing import Optional
|
||||
from sqlalchemy import select, delete
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from app.database.models import Subscription, User
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
async def ensure_single_subscription(db: AsyncSession, user_id: int) -> Optional[Subscription]:
|
||||
result = await db.execute(
|
||||
select(Subscription)
|
||||
.where(Subscription.user_id == user_id)
|
||||
.order_by(Subscription.created_at.desc())
|
||||
)
|
||||
subscriptions = result.scalars().all()
|
||||
|
||||
if len(subscriptions) <= 1:
|
||||
return subscriptions[0] if subscriptions else None
|
||||
|
||||
latest_subscription = subscriptions[0]
|
||||
old_subscriptions = subscriptions[1:]
|
||||
|
||||
logger.warning(f"🚨 Обнаружено {len(subscriptions)} подписок у пользователя {user_id}. Удаляем {len(old_subscriptions)} старых.")
|
||||
|
||||
for old_sub in old_subscriptions:
|
||||
await db.delete(old_sub)
|
||||
logger.info(f"🗑️ Удалена подписка ID {old_sub.id} от {old_sub.created_at}")
|
||||
|
||||
await db.commit()
|
||||
await db.refresh(latest_subscription)
|
||||
|
||||
logger.info(f"✅ Оставлена подписка ID {latest_subscription.id} от {latest_subscription.created_at}")
|
||||
return latest_subscription
|
||||
|
||||
|
||||
async def update_or_create_subscription(
|
||||
db: AsyncSession,
|
||||
user_id: int,
|
||||
**subscription_data
|
||||
) -> Subscription:
|
||||
existing_subscription = await ensure_single_subscription(db, user_id)
|
||||
|
||||
if existing_subscription:
|
||||
for key, value in subscription_data.items():
|
||||
if hasattr(existing_subscription, key):
|
||||
setattr(existing_subscription, key, value)
|
||||
|
||||
existing_subscription.updated_at = datetime.utcnow()
|
||||
await db.commit()
|
||||
await db.refresh(existing_subscription)
|
||||
|
||||
logger.info(f"🔄 Обновлена существующая подписка ID {existing_subscription.id}")
|
||||
return existing_subscription
|
||||
|
||||
else:
|
||||
new_subscription = Subscription(
|
||||
user_id=user_id,
|
||||
**subscription_data
|
||||
)
|
||||
|
||||
db.add(new_subscription)
|
||||
await db.commit()
|
||||
await db.refresh(new_subscription)
|
||||
|
||||
logger.info(f"🆕 Создана новая подписка ID {new_subscription.id}")
|
||||
return new_subscription
|
||||
|
||||
|
||||
async def cleanup_duplicate_subscriptions(db: AsyncSession) -> int:
|
||||
result = await db.execute(
|
||||
select(Subscription.user_id)
|
||||
.group_by(Subscription.user_id)
|
||||
.having(func.count(Subscription.id) > 1)
|
||||
)
|
||||
users_with_duplicates = result.scalars().all()
|
||||
|
||||
total_deleted = 0
|
||||
|
||||
for user_id in users_with_duplicates:
|
||||
subscriptions_result = await db.execute(
|
||||
select(Subscription)
|
||||
.where(Subscription.user_id == user_id)
|
||||
.order_by(Subscription.created_at.desc())
|
||||
)
|
||||
subscriptions = subscriptions_result.scalars().all()
|
||||
|
||||
for old_subscription in subscriptions[1:]:
|
||||
await db.delete(old_subscription)
|
||||
total_deleted += 1
|
||||
logger.info(f"🗑️ Удалена дублирующаяся подписка ID {old_subscription.id} пользователя {user_id}")
|
||||
|
||||
await db.commit()
|
||||
logger.info(f"🧹 Очищено {total_deleted} дублирующихся подписок")
|
||||
|
||||
return total_deleted
|
||||
@@ -0,0 +1,298 @@
|
||||
import logging
|
||||
import secrets
|
||||
import string
|
||||
from datetime import datetime, timedelta
|
||||
from typing import Optional, Dict, List
|
||||
from sqlalchemy import select, func, and_
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy.orm import selectinload
|
||||
|
||||
from app.database.models import User, ReferralEarning, Transaction, TransactionType
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
async def generate_unique_referral_code(db: AsyncSession, telegram_id: int) -> str:
|
||||
max_attempts = 10
|
||||
|
||||
for _ in range(max_attempts):
|
||||
code = f"ref{''.join(secrets.choice(string.ascii_letters + string.digits) for _ in range(8))}"
|
||||
|
||||
result = await db.execute(
|
||||
select(User).where(User.referral_code == code)
|
||||
)
|
||||
if not result.scalar_one_or_none():
|
||||
return code
|
||||
|
||||
timestamp = str(int(datetime.utcnow().timestamp()))[-6:]
|
||||
return f"ref{timestamp}"
|
||||
|
||||
|
||||
async def mark_user_as_had_paid_subscription(db: AsyncSession, user_id: int) -> bool:
|
||||
try:
|
||||
from app.database.crud.user import get_user_by_id
|
||||
|
||||
user = await get_user_by_id(db, user_id)
|
||||
if not user:
|
||||
logger.error(f"Пользователь {user_id} не найден")
|
||||
return False
|
||||
|
||||
if not user.has_had_paid_subscription:
|
||||
user.has_had_paid_subscription = True
|
||||
user.updated_at = datetime.utcnow()
|
||||
await db.commit()
|
||||
logger.info(f"✅ Пользователь {user_id} отмечен как имевший платную подписку")
|
||||
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка отметки пользователя {user_id} как имевшего платную подписку: {e}")
|
||||
return False
|
||||
|
||||
|
||||
async def get_user_referral_summary(db: AsyncSession, user_id: int) -> Dict:
|
||||
try:
|
||||
invited_count_result = await db.execute(
|
||||
select(func.count(User.id)).where(User.referred_by_id == user_id)
|
||||
)
|
||||
invited_count = invited_count_result.scalar() or 0
|
||||
|
||||
referrals_result = await db.execute(
|
||||
select(User).where(User.referred_by_id == user_id)
|
||||
)
|
||||
referrals = referrals_result.scalars().all()
|
||||
|
||||
paid_referrals_count = sum(1 for ref in referrals if ref.has_made_first_topup)
|
||||
|
||||
total_earnings_result = await db.execute(
|
||||
select(func.coalesce(func.sum(ReferralEarning.amount_kopeks), 0))
|
||||
.where(ReferralEarning.user_id == user_id)
|
||||
)
|
||||
total_earned_kopeks = total_earnings_result.scalar() or 0
|
||||
|
||||
month_ago = datetime.utcnow() - timedelta(days=30)
|
||||
month_earnings_result = await db.execute(
|
||||
select(func.coalesce(func.sum(ReferralEarning.amount_kopeks), 0))
|
||||
.where(
|
||||
and_(
|
||||
ReferralEarning.user_id == user_id,
|
||||
ReferralEarning.created_at >= month_ago
|
||||
)
|
||||
)
|
||||
)
|
||||
month_earned_kopeks = month_earnings_result.scalar() or 0
|
||||
|
||||
recent_earnings_result = await db.execute(
|
||||
select(ReferralEarning)
|
||||
.options(selectinload(ReferralEarning.referral))
|
||||
.where(ReferralEarning.user_id == user_id)
|
||||
.order_by(ReferralEarning.created_at.desc())
|
||||
.limit(5)
|
||||
)
|
||||
recent_earnings_raw = recent_earnings_result.scalars().all()
|
||||
|
||||
recent_earnings = []
|
||||
for earning in recent_earnings_raw:
|
||||
if earning.referral:
|
||||
recent_earnings.append({
|
||||
'amount_kopeks': earning.amount_kopeks,
|
||||
'reason': earning.reason,
|
||||
'referral_name': earning.referral.full_name,
|
||||
'created_at': earning.created_at
|
||||
})
|
||||
|
||||
earnings_by_type = {}
|
||||
earnings_by_type_result = await db.execute(
|
||||
select(
|
||||
ReferralEarning.reason,
|
||||
func.count(ReferralEarning.id).label('count'),
|
||||
func.coalesce(func.sum(ReferralEarning.amount_kopeks), 0).label('total_amount')
|
||||
)
|
||||
.where(ReferralEarning.user_id == user_id)
|
||||
.group_by(ReferralEarning.reason)
|
||||
)
|
||||
|
||||
for row in earnings_by_type_result:
|
||||
earnings_by_type[row.reason] = {
|
||||
'count': row.count,
|
||||
'total_amount_kopeks': row.total_amount
|
||||
}
|
||||
|
||||
active_referrals_count = 0
|
||||
for referral in referrals:
|
||||
if referral.last_activity and referral.last_activity >= month_ago:
|
||||
active_referrals_count += 1
|
||||
|
||||
return {
|
||||
'invited_count': invited_count,
|
||||
'paid_referrals_count': paid_referrals_count,
|
||||
'active_referrals_count': active_referrals_count,
|
||||
'total_earned_kopeks': total_earned_kopeks,
|
||||
'month_earned_kopeks': month_earned_kopeks,
|
||||
'recent_earnings': recent_earnings,
|
||||
'earnings_by_type': earnings_by_type,
|
||||
'conversion_rate': round((paid_referrals_count / invited_count * 100) if invited_count > 0 else 0, 1)
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка получения статистики рефералов для пользователя {user_id}: {e}")
|
||||
return {
|
||||
'invited_count': 0,
|
||||
'paid_referrals_count': 0,
|
||||
'active_referrals_count': 0,
|
||||
'total_earned_kopeks': 0,
|
||||
'month_earned_kopeks': 0,
|
||||
'recent_earnings': [],
|
||||
'earnings_by_type': {},
|
||||
'conversion_rate': 0.0
|
||||
}
|
||||
|
||||
|
||||
async def get_detailed_referral_list(db: AsyncSession, user_id: int, limit: int = 20, offset: int = 0) -> Dict:
|
||||
try:
|
||||
referrals_result = await db.execute(
|
||||
select(User)
|
||||
.where(User.referred_by_id == user_id)
|
||||
.order_by(User.created_at.desc())
|
||||
.offset(offset)
|
||||
.limit(limit)
|
||||
)
|
||||
referrals = referrals_result.scalars().all()
|
||||
|
||||
total_count_result = await db.execute(
|
||||
select(func.count(User.id)).where(User.referred_by_id == user_id)
|
||||
)
|
||||
total_count = total_count_result.scalar() or 0
|
||||
|
||||
detailed_referrals = []
|
||||
for referral in referrals:
|
||||
earnings_result = await db.execute(
|
||||
select(func.coalesce(func.sum(ReferralEarning.amount_kopeks), 0))
|
||||
.where(
|
||||
and_(
|
||||
ReferralEarning.user_id == user_id,
|
||||
ReferralEarning.referral_id == referral.id
|
||||
)
|
||||
)
|
||||
)
|
||||
total_earned_from_referral = earnings_result.scalar() or 0
|
||||
|
||||
topups_result = await db.execute(
|
||||
select(func.count(Transaction.id))
|
||||
.where(
|
||||
and_(
|
||||
Transaction.user_id == referral.id,
|
||||
Transaction.type == TransactionType.DEPOSIT.value,
|
||||
Transaction.is_completed == True
|
||||
)
|
||||
)
|
||||
)
|
||||
topups_count = topups_result.scalar() or 0
|
||||
|
||||
days_since_registration = (datetime.utcnow() - referral.created_at).days
|
||||
|
||||
days_since_activity = None
|
||||
if referral.last_activity:
|
||||
days_since_activity = (datetime.utcnow() - referral.last_activity).days
|
||||
|
||||
detailed_referrals.append({
|
||||
'id': referral.id,
|
||||
'telegram_id': referral.telegram_id,
|
||||
'full_name': referral.full_name,
|
||||
'username': referral.username,
|
||||
'created_at': referral.created_at,
|
||||
'last_activity': referral.last_activity,
|
||||
'has_made_first_topup': referral.has_made_first_topup,
|
||||
'balance_kopeks': referral.balance_kopeks,
|
||||
'total_earned_kopeks': total_earned_from_referral,
|
||||
'topups_count': topups_count,
|
||||
'days_since_registration': days_since_registration,
|
||||
'days_since_activity': days_since_activity,
|
||||
'status': 'active' if days_since_activity is not None and days_since_activity <= 30 else 'inactive'
|
||||
})
|
||||
|
||||
return {
|
||||
'referrals': detailed_referrals,
|
||||
'total_count': total_count,
|
||||
'has_next': offset + limit < total_count,
|
||||
'has_prev': offset > 0,
|
||||
'current_page': (offset // limit) + 1,
|
||||
'total_pages': (total_count + limit - 1) // limit
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка получения списка рефералов для пользователя {user_id}: {e}")
|
||||
return {
|
||||
'referrals': [],
|
||||
'total_count': 0,
|
||||
'has_next': False,
|
||||
'has_prev': False,
|
||||
'current_page': 1,
|
||||
'total_pages': 1
|
||||
}
|
||||
|
||||
|
||||
async def get_referral_analytics(db: AsyncSession, user_id: int) -> Dict:
|
||||
try:
|
||||
now = datetime.utcnow()
|
||||
periods = {
|
||||
'today': now.replace(hour=0, minute=0, second=0, microsecond=0),
|
||||
'week': now - timedelta(days=7),
|
||||
'month': now - timedelta(days=30),
|
||||
'quarter': now - timedelta(days=90)
|
||||
}
|
||||
|
||||
earnings_by_period = {}
|
||||
for period_name, start_date in periods.items():
|
||||
result = await db.execute(
|
||||
select(func.coalesce(func.sum(ReferralEarning.amount_kopeks), 0))
|
||||
.where(
|
||||
and_(
|
||||
ReferralEarning.user_id == user_id,
|
||||
ReferralEarning.created_at >= start_date
|
||||
)
|
||||
)
|
||||
)
|
||||
earnings_by_period[period_name] = result.scalar() or 0
|
||||
|
||||
top_referrals_result = await db.execute(
|
||||
select(
|
||||
ReferralEarning.referral_id,
|
||||
func.coalesce(func.sum(ReferralEarning.amount_kopeks), 0).label('total_earned'),
|
||||
func.count(ReferralEarning.id).label('earnings_count')
|
||||
)
|
||||
.where(ReferralEarning.user_id == user_id)
|
||||
.group_by(ReferralEarning.referral_id)
|
||||
.order_by(func.sum(ReferralEarning.amount_kopeks).desc())
|
||||
.limit(5)
|
||||
)
|
||||
|
||||
top_referrals = []
|
||||
for row in top_referrals_result:
|
||||
referral_result = await db.execute(
|
||||
select(User).where(User.id == row.referral_id)
|
||||
)
|
||||
referral = referral_result.scalar_one_or_none()
|
||||
if referral:
|
||||
top_referrals.append({
|
||||
'referral_name': referral.full_name,
|
||||
'total_earned_kopeks': row.total_earned,
|
||||
'earnings_count': row.earnings_count
|
||||
})
|
||||
|
||||
return {
|
||||
'earnings_by_period': earnings_by_period,
|
||||
'top_referrals': top_referrals
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка получения аналитики рефералов для пользователя {user_id}: {e}")
|
||||
return {
|
||||
'earnings_by_period': {
|
||||
'today': 0,
|
||||
'week': 0,
|
||||
'month': 0,
|
||||
'quarter': 0
|
||||
},
|
||||
'top_referrals': []
|
||||
}
|
||||
@@ -0,0 +1,137 @@
|
||||
import re
|
||||
from typing import Optional, Union
|
||||
from datetime import datetime
|
||||
|
||||
|
||||
def validate_email(email: str) -> bool:
|
||||
pattern = r'^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$'
|
||||
return re.match(pattern, email) is not None
|
||||
|
||||
|
||||
def validate_phone(phone: str) -> bool:
|
||||
pattern = r'^\+?[1-9]\d{1,14}$'
|
||||
cleaned_phone = re.sub(r'[\s\-\(\)]', '', phone)
|
||||
return re.match(pattern, cleaned_phone) is not None
|
||||
|
||||
|
||||
def validate_telegram_username(username: str) -> bool:
|
||||
if not username:
|
||||
return False
|
||||
username = username.lstrip('@')
|
||||
pattern = r'^[a-zA-Z0-9_]{5,32}$'
|
||||
return re.match(pattern, username) is not None
|
||||
|
||||
|
||||
def validate_promocode(code: str) -> bool:
|
||||
if not code or len(code) < 3 or len(code) > 20:
|
||||
return False
|
||||
return code.replace('_', '').replace('-', '').isalnum()
|
||||
|
||||
|
||||
def validate_amount(amount_str: str, min_amount: float = 0, max_amount: float = float('inf')) -> Optional[float]:
|
||||
try:
|
||||
amount = float(amount_str.replace(',', '.'))
|
||||
if min_amount <= amount <= max_amount:
|
||||
return amount
|
||||
return None
|
||||
except (ValueError, TypeError):
|
||||
return None
|
||||
|
||||
|
||||
def validate_positive_integer(value: Union[str, int], max_value: int = None) -> Optional[int]:
|
||||
try:
|
||||
num = int(value)
|
||||
if num > 0 and (max_value is None or num <= max_value):
|
||||
return num
|
||||
return None
|
||||
except (ValueError, TypeError):
|
||||
return None
|
||||
|
||||
|
||||
def validate_date_string(date_str: str, date_format: str = "%Y-%m-%d") -> Optional[datetime]:
|
||||
try:
|
||||
return datetime.strptime(date_str, date_format)
|
||||
except ValueError:
|
||||
return None
|
||||
|
||||
|
||||
def validate_url(url: str) -> bool:
|
||||
pattern = r'^https?:\/\/(www\.)?[-a-zA-Z0-9@:%._\+~#=]{1,256}\.[a-zA-Z0-9()]{1,6}\b([-a-zA-Z0-9()@:%_\+.~#?&//=]*)$'
|
||||
return re.match(pattern, url) is not None
|
||||
|
||||
|
||||
def validate_uuid(uuid_str: str) -> bool:
|
||||
pattern = r'^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$'
|
||||
return re.match(pattern, uuid_str.lower()) is not None
|
||||
|
||||
|
||||
def validate_traffic_amount(traffic_str: str) -> Optional[int]:
|
||||
traffic_str = traffic_str.upper().strip()
|
||||
|
||||
if traffic_str in ['UNLIMITED', 'БЕЗЛИМИТ', '∞']:
|
||||
return 0
|
||||
|
||||
units = {
|
||||
'MB': 1,
|
||||
'GB': 1024,
|
||||
'TB': 1024 * 1024,
|
||||
'МБ': 1,
|
||||
'ГБ': 1024,
|
||||
'ТБ': 1024 * 1024
|
||||
}
|
||||
|
||||
for unit, multiplier in units.items():
|
||||
if traffic_str.endswith(unit):
|
||||
try:
|
||||
value = float(traffic_str[:-len(unit)].strip())
|
||||
return int(value * multiplier)
|
||||
except ValueError:
|
||||
break
|
||||
|
||||
try:
|
||||
return int(float(traffic_str))
|
||||
except ValueError:
|
||||
return None
|
||||
|
||||
|
||||
def validate_subscription_period(days: Union[str, int]) -> Optional[int]:
|
||||
try:
|
||||
days_int = int(days)
|
||||
if 1 <= days_int <= 3650:
|
||||
return days_int
|
||||
return None
|
||||
except (ValueError, TypeError):
|
||||
return None
|
||||
|
||||
|
||||
def sanitize_html(text: str) -> str:
|
||||
allowed_tags = ['b', 'strong', 'i', 'em', 'u', 'ins', 's', 'strike', 'del', 'code', 'pre']
|
||||
|
||||
for tag in allowed_tags:
|
||||
text = re.sub(f'<{tag}>', f'<{tag}>', text, flags=re.IGNORECASE)
|
||||
text = re.sub(f'</{tag}>', f'</{tag}>', text, flags=re.IGNORECASE)
|
||||
|
||||
text = re.sub(r'<(?!/?(?:' + '|'.join(allowed_tags) + r')\b)[^>]*>', '', text)
|
||||
|
||||
return text
|
||||
|
||||
|
||||
def validate_device_count(count: Union[str, int]) -> Optional[int]:
|
||||
try:
|
||||
count_int = int(count)
|
||||
if 1 <= count_int <= 10:
|
||||
return count_int
|
||||
return None
|
||||
except (ValueError, TypeError):
|
||||
return None
|
||||
|
||||
|
||||
def validate_referral_code(code: str) -> bool:
|
||||
if not code:
|
||||
return False
|
||||
|
||||
if code.startswith('ref') and len(code) > 3:
|
||||
user_id_part = code[3:]
|
||||
return user_id_part.isdigit()
|
||||
|
||||
return validate_promocode(code)
|
||||
@@ -0,0 +1,295 @@
|
||||
<svg viewBox="0 0 400 400" xmlns="http://www.w3.org/2000/svg">
|
||||
<defs>
|
||||
<!-- Градиенты -->
|
||||
<linearGradient id="mainGradient" x1="0%" y1="0%" x2="100%" y2="100%">
|
||||
<stop offset="0%" style="stop-color:#667eea;stop-opacity:1" />
|
||||
<stop offset="50%" style="stop-color:#764ba2;stop-opacity:1" />
|
||||
<stop offset="100%" style="stop-color:#f093fb;stop-opacity:1" />
|
||||
</linearGradient>
|
||||
|
||||
<linearGradient id="skinGradient" x1="0%" y1="0%" x2="100%" y2="100%">
|
||||
<stop offset="0%" style="stop-color:#d4a574;stop-opacity:1" />
|
||||
<stop offset="100%" style="stop-color:#b8935f;stop-opacity:1" />
|
||||
</linearGradient>
|
||||
|
||||
<linearGradient id="redSkinGradient" x1="0%" y1="0%" x2="100%" y2="100%">
|
||||
<stop offset="0%" style="stop-color:#e8b598;stop-opacity:1" />
|
||||
<stop offset="100%" style="stop-color:#d4a574;stop-opacity:1" />
|
||||
</linearGradient>
|
||||
|
||||
<linearGradient id="clothesGradient" x1="0%" y1="0%" x2="100%" y2="100%">
|
||||
<stop offset="0%" style="stop-color:#3a3a3a;stop-opacity:1" />
|
||||
<stop offset="100%" style="stop-color:#595959;stop-opacity:1" />
|
||||
</linearGradient>
|
||||
|
||||
<linearGradient id="bottleGradient" x1="0%" y1="0%" x2="100%" y2="100%">
|
||||
<stop offset="0%" style="stop-color:#2d5016;stop-opacity:0.8" />
|
||||
<stop offset="100%" style="stop-color:#1a2f0d;stop-opacity:0.9" />
|
||||
</linearGradient>
|
||||
|
||||
<!-- Фильтры -->
|
||||
<filter id="glow" x="-50%" y="-50%" width="200%" height="200%">
|
||||
<feGaussianBlur stdDeviation="3" result="coloredBlur"/>
|
||||
<feMerge>
|
||||
<feMergeNode in="coloredBlur"/>
|
||||
<feMergeNode in="SourceGraphic"/>
|
||||
</feMerge>
|
||||
</filter>
|
||||
|
||||
<filter id="dropshadow" x="-50%" y="-50%" width="200%" height="200%">
|
||||
<feGaussianBlur in="SourceAlpha" stdDeviation="4"/>
|
||||
<feOffset dx="2" dy="4" result="offset" />
|
||||
<feComponentTransfer>
|
||||
<feFuncA type="linear" slope="0.3"/>
|
||||
</feComponentTransfer>
|
||||
<feMerge>
|
||||
<feMergeNode/>
|
||||
<feMergeNode in="SourceGraphic"/>
|
||||
</feMerge>
|
||||
</filter>
|
||||
</defs>
|
||||
|
||||
<!-- Фон -->
|
||||
<rect width="400" height="400" fill="#1a1a2e" rx="50"/>
|
||||
|
||||
<!-- Декоративные элементы фона -->
|
||||
<circle cx="50" cy="80" r="30" fill="url(#mainGradient)" opacity="0.1"/>
|
||||
<circle cx="350" cy="120" r="40" fill="#8B4513" opacity="0.1"/>
|
||||
|
||||
<!-- Волны RemnaWave в фоне -->
|
||||
<g transform="translate(200,200)" opacity="0.2">
|
||||
<path d="M-120,-100 Q-60,-130 0,-100 Q60,-70 120,-100"
|
||||
stroke="url(#mainGradient)"
|
||||
stroke-width="4"
|
||||
fill="none">
|
||||
<animate attributeName="d"
|
||||
values="M-120,-100 Q-60,-130 0,-100 Q60,-70 120,-100;M-120,-100 Q-60,-70 0,-100 Q60,-130 120,-100;M-120,-100 Q-60,-130 0,-100 Q60,-70 120,-100"
|
||||
dur="4s"
|
||||
repeatCount="indefinite"/>
|
||||
</path>
|
||||
</g>
|
||||
|
||||
<!-- Главный персонаж - Бомж-алкаш -->
|
||||
<g transform="translate(200,180)" filter="url(#dropshadow)">
|
||||
|
||||
<!-- Тело в рваной куртке -->
|
||||
<ellipse cx="0" cy="30" rx="45" ry="60" fill="url(#clothesGradient)"/>
|
||||
|
||||
<!-- Рваные края одежды -->
|
||||
<path d="M-45,70 L-40,75 L-35,70 L-30,75 L-25,70 L-20,75 L-15,70 L-10,75 L-5,70 L0,75 L5,70 L10,75 L15,70 L20,75 L25,70 L30,75 L35,70 L40,75 L45,70"
|
||||
stroke="none" fill="#2a2a2a"/>
|
||||
|
||||
<!-- Заплатки -->
|
||||
<rect x="-20" y="10" width="12" height="10" fill="#5a4a3a" opacity="0.7" rx="2"/>
|
||||
<rect x="15" y="35" width="10" height="8" fill="#4a4a4a" opacity="0.8" rx="1"/>
|
||||
<rect x="-10" y="45" width="8" height="12" fill="#6a5a4a" opacity="0.6" rx="1"/>
|
||||
|
||||
<!-- Шея -->
|
||||
<rect x="-12" y="-25" width="24" height="20" fill="url(#redSkinGradient)" rx="8"/>
|
||||
|
||||
<!-- Голова (более человечная форма) -->
|
||||
<ellipse cx="0" cy="-45" rx="28" ry="32" fill="url(#redSkinGradient)"/>
|
||||
|
||||
<!-- Сальные волосы -->
|
||||
<path d="M-25,-70 Q-20,-75 -15,-72 Q-10,-78 -5,-73 Q0,-80 5,-73 Q10,-78 15,-72 Q20,-75 25,-70"
|
||||
stroke="#3a3a3a" stroke-width="4" fill="#3a3a3a" stroke-linecap="round"/>
|
||||
<path d="M-28,-65 Q-25,-70 -20,-67" stroke="#3a3a3a" stroke-width="3" fill="none" stroke-linecap="round"/>
|
||||
<path d="M28,-65 Q25,-70 20,-67" stroke="#3a3a3a" stroke-width="3" fill="none" stroke-linecap="round"/>
|
||||
|
||||
<!-- Левое ухо -->
|
||||
<ellipse cx="-28" cy="-45" rx="6" ry="10" fill="url(#skinGradient)"/>
|
||||
|
||||
<!-- Правое ухо -->
|
||||
<ellipse cx="28" cy="-45" rx="6" ry="10" fill="url(#skinGradient)"/>
|
||||
|
||||
<!-- Опухшие глаза -->
|
||||
<ellipse cx="-10" cy="-50" rx="8" ry="5" fill="white"/>
|
||||
<ellipse cx="10" cy="-50" rx="8" ry="5" fill="white"/>
|
||||
|
||||
<!-- Зрачки (косящие) -->
|
||||
<g id="eyes">
|
||||
<circle cx="-8" cy="-49" r="3" fill="#333">
|
||||
<animate attributeName="cx" values="-8;-10;-8" dur="3s" repeatCount="indefinite"/>
|
||||
</circle>
|
||||
<circle cx="12" cy="-49" r="3" fill="#333">
|
||||
<animate attributeName="cx" values="12;10;12" dur="3s" repeatCount="indefinite"/>
|
||||
</circle>
|
||||
</g>
|
||||
|
||||
<!-- Красные глаза -->
|
||||
<path d="M-18,-50 L-15,-51" stroke="red" stroke-width="1" opacity="0.7"/>
|
||||
<path d="M-17,-48 L-14,-49" stroke="red" stroke-width="0.5" opacity="0.7"/>
|
||||
<path d="M18,-50 L15,-51" stroke="red" stroke-width="1" opacity="0.7"/>
|
||||
<path d="M17,-48 L14,-49" stroke="red" stroke-width="0.5" opacity="0.7"/>
|
||||
|
||||
<!-- Мешки под глазами -->
|
||||
<ellipse cx="-10" cy="-44" rx="8" ry="3" fill="#8a6a4a" opacity="0.5"/>
|
||||
<ellipse cx="10" cy="-44" rx="8" ry="3" fill="#8a6a4a" opacity="0.5"/>
|
||||
|
||||
<!-- Красный нос алкаша -->
|
||||
<ellipse cx="0" cy="-38" rx="6" ry="5" fill="#d67070"/>
|
||||
<circle cx="-2" cy="-39" r="1" fill="#e08080" opacity="0.8"/>
|
||||
|
||||
<!-- Небритость -->
|
||||
<g opacity="0.6">
|
||||
<circle cx="-15" cy="-30" r="1" fill="#555"/>
|
||||
<circle cx="-12" cy="-28" r="0.8" fill="#555"/>
|
||||
<circle cx="-8" cy="-30" r="0.8" fill="#555"/>
|
||||
<circle cx="15" cy="-30" r="1" fill="#555"/>
|
||||
<circle cx="12" cy="-28" r="0.8" fill="#555"/>
|
||||
<circle cx="8" cy="-30" r="0.8" fill="#555"/>
|
||||
<circle cx="0" cy="-28" r="0.8" fill="#555"/>
|
||||
<circle cx="-5" cy="-26" r="0.8" fill="#555"/>
|
||||
<circle cx="5" cy="-26" r="0.8" fill="#555"/>
|
||||
</g>
|
||||
|
||||
<!-- Рот с сигаретой/бутылкой (анимированный) -->
|
||||
<g id="mouth-group">
|
||||
<!-- Основной рот -->
|
||||
<path d="M-12,-20 Q0,-18 12,-20" stroke="#444" stroke-width="2" fill="none" stroke-linecap="round"/>
|
||||
|
||||
<!-- Сигарета (появляется и исчезает) -->
|
||||
<g id="cigarette">
|
||||
<rect x="10" y="-22" width="25" height="3" fill="white" rx="1">
|
||||
<animate attributeName="opacity" values="0;0;0;1;1;1;0;0;0" dur="8s" repeatCount="indefinite"/>
|
||||
</rect>
|
||||
<rect x="30" y="-22" width="5" height="3" fill="#ff6b35">
|
||||
<animate attributeName="opacity" values="0;0;0;1;1;1;0;0;0" dur="8s" repeatCount="indefinite"/>
|
||||
</rect>
|
||||
<!-- Дым от сигареты -->
|
||||
<g opacity="0.4">
|
||||
<circle cx="35" cy="-22" r="2" fill="#999">
|
||||
<animate attributeName="opacity" values="0;0;0;0.6;0.3;0;0;0;0" dur="8s" repeatCount="indefinite"/>
|
||||
<animate attributeName="cy" values="-22;-22;-22;-28;-35;-40;-22;-22;-22" dur="8s" repeatCount="indefinite"/>
|
||||
<animate attributeName="r" values="2;2;2;4;6;8;2;2;2" dur="8s" repeatCount="indefinite"/>
|
||||
</circle>
|
||||
</g>
|
||||
</g>
|
||||
</g>
|
||||
|
||||
<!-- Левая рука с бутылкой -->
|
||||
<g id="left-arm">
|
||||
<!-- Рука -->
|
||||
<line x1="-45" y1="10" x2="-55" y2="-5" stroke="url(#skinGradient)" stroke-width="8" stroke-linecap="round">
|
||||
<animate attributeName="x2" values="-55;-50;-55" dur="8s" repeatCount="indefinite"/>
|
||||
<animate attributeName="y2" values="-5;-20;-5" dur="8s" repeatCount="indefinite"/>
|
||||
</line>
|
||||
|
||||
<!-- Кисть -->
|
||||
<circle cx="-55" cy="-5" r="6" fill="url(#skinGradient)">
|
||||
<animate attributeName="cx" values="-55;-50;-55" dur="8s" repeatCount="indefinite"/>
|
||||
<animate attributeName="cy" values="-5;-20;-5" dur="8s" repeatCount="indefinite"/>
|
||||
</circle>
|
||||
|
||||
<!-- Бутылка -->
|
||||
<g id="bottle">
|
||||
<rect x="-62" y="-25" width="14" height="30" fill="url(#bottleGradient)" rx="2">
|
||||
<animate attributeName="x" values="-62;-57;-62" dur="8s" repeatCount="indefinite"/>
|
||||
<animate attributeName="y" values="-25;-40;-25" dur="8s" repeatCount="indefinite"/>
|
||||
<animate attributeName="transform" values="rotate(0 -55 -10);rotate(-30 -50 -25);rotate(0 -55 -10)" dur="8s" repeatCount="indefinite"/>
|
||||
</rect>
|
||||
<rect x="-58" y="-30" width="6" height="8" fill="#2d5016">
|
||||
<animate attributeName="x" values="-58;-53;-58" dur="8s" repeatCount="indefinite"/>
|
||||
<animate attributeName="y" values="-30;-45;-30" dur="8s" repeatCount="indefinite"/>
|
||||
</rect>
|
||||
<!-- Этикетка -->
|
||||
<rect x="-60" y="-15" width="10" height="8" fill="white" opacity="0.3">
|
||||
<animate attributeName="x" values="-60;-55;-60" dur="8s" repeatCount="indefinite"/>
|
||||
<animate attributeName="y" values="-15;-30;-15" dur="8s" repeatCount="indefinite"/>
|
||||
</rect>
|
||||
</g>
|
||||
</g>
|
||||
|
||||
<!-- Правая рука (свободная, дрожащая) -->
|
||||
<g id="right-arm">
|
||||
<line x1="45" y1="10" x2="55" y2="20" stroke="url(#skinGradient)" stroke-width="8" stroke-linecap="round">
|
||||
<animate attributeName="x2" values="55;57;55;53;55" dur="2s" repeatCount="indefinite"/>
|
||||
</line>
|
||||
<circle cx="55" cy="20" r="6" fill="url(#skinGradient)">
|
||||
<animate attributeName="cx" values="55;57;55;53;55" dur="2s" repeatCount="indefinite"/>
|
||||
</circle>
|
||||
</g>
|
||||
|
||||
<!-- Ноги в рваных штанах -->
|
||||
<rect x="-15" y="85" width="12" height="35" fill="#4a4a5a" rx="2"/>
|
||||
<rect x="3" y="85" width="12" height="35" fill="#4a4a5a" rx="2"/>
|
||||
|
||||
<!-- Дырки в штанах -->
|
||||
<ellipse cx="-9" cy="95" rx="3" ry="4" fill="#1a1a2e"/>
|
||||
<ellipse cx="9" cy="105" rx="2" ry="3" fill="#1a1a2e"/>
|
||||
|
||||
<!-- Старые ботинки -->
|
||||
<ellipse cx="-9" cy="125" rx="10" ry="6" fill="#2a2a2a"/>
|
||||
<ellipse cx="9" cy="125" rx="10" ry="6" fill="#2a2a2a"/>
|
||||
<!-- Дырки в ботинках -->
|
||||
<circle cx="-6" cy="123" r="2" fill="#1a1a2e"/>
|
||||
<circle cx="12" cy="123" r="1.5" fill="#1a1a2e"/>
|
||||
|
||||
<!-- Значок "B" на груди -->
|
||||
<circle cx="0" cy="25" r="20" fill="url(#mainGradient)" opacity="0.8" stroke="white" stroke-width="1"/>
|
||||
<text x="0" y="35"
|
||||
font-family="Arial Black, sans-serif"
|
||||
font-size="28"
|
||||
font-weight="bold"
|
||||
text-anchor="middle"
|
||||
fill="white">
|
||||
B
|
||||
</text>
|
||||
</g>
|
||||
|
||||
<!-- Упавшие бутылки вокруг -->
|
||||
<g opacity="0.6">
|
||||
<rect x="80" y="340" width="8" height="20" fill="#2d5016" rx="1" transform="rotate(75 84 350)"/>
|
||||
<rect x="310" y="330" width="7" height="18" fill="#3a5a1a" rx="1" transform="rotate(-60 313 339)"/>
|
||||
<rect x="150" y="350" width="6" height="16" fill="#2d5016" rx="1" transform="rotate(45 153 358)"/>
|
||||
</g>
|
||||
|
||||
<!-- Окурки -->
|
||||
<g opacity="0.7">
|
||||
<rect x="120" y="360" width="12" height="2" fill="white" rx="1"/>
|
||||
<rect x="130" y="360" width="2" height="2" fill="#ff6b35"/>
|
||||
|
||||
<rect x="250" y="355" width="10" height="2" fill="white" rx="1" transform="rotate(30 255 356)"/>
|
||||
<rect x="258" y="355" width="2" height="2" fill="#ff6b35" transform="rotate(30 259 356)"/>
|
||||
</g>
|
||||
|
||||
<!-- Текст логотипа -->
|
||||
<text x="200" y="320"
|
||||
font-family="Arial Black, sans-serif"
|
||||
font-size="20"
|
||||
font-weight="bold"
|
||||
text-anchor="middle"
|
||||
fill="url(#mainGradient)"
|
||||
filter="url(#glow)">
|
||||
Remnawave
|
||||
</text>
|
||||
|
||||
<text x="200" y="345"
|
||||
font-family="Arial Black, sans-serif"
|
||||
font-size="18"
|
||||
font-weight="bold"
|
||||
text-anchor="middle"
|
||||
fill="#d67070">
|
||||
Bedolaga Bot
|
||||
</text>
|
||||
|
||||
<!-- Подпись -->
|
||||
<text x="200" y="365"
|
||||
font-family="Arial, sans-serif"
|
||||
font-size="11"
|
||||
text-anchor="middle"
|
||||
fill="#8892b0"
|
||||
opacity="0.8">
|
||||
Telegram VPN Manager
|
||||
</text>
|
||||
|
||||
<!-- Мухи летающие вокруг (признак антисанитарии) -->
|
||||
<g opacity="0.5">
|
||||
<circle cx="150" cy="150" r="1" fill="#333">
|
||||
<animateTransform attributeName="transform" type="rotate" values="0 200 180;360 200 180" dur="6s" repeatCount="indefinite"/>
|
||||
</circle>
|
||||
<circle cx="250" cy="150" r="1" fill="#333">
|
||||
<animateTransform attributeName="transform" type="rotate" values="360 200 180;0 200 180" dur="5s" repeatCount="indefinite"/>
|
||||
</circle>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 13 KiB |
@@ -1,73 +0,0 @@
|
||||
import os
|
||||
from dataclasses import dataclass
|
||||
from typing import List
|
||||
|
||||
@dataclass
|
||||
class Config:
|
||||
BOT_TOKEN: str
|
||||
REMNAWAVE_URL: str
|
||||
REMNAWAVE_TOKEN: str
|
||||
REMNAWAVE_MODE: str
|
||||
DATABASE_URL: str
|
||||
ADMIN_IDS: List[int]
|
||||
DEFAULT_LANGUAGE: str
|
||||
SUPPORT_USERNAME: str
|
||||
|
||||
# Subscription URL settings
|
||||
SUBSCRIPTION_BASE_URL: str
|
||||
|
||||
# Trial subscription settings
|
||||
TRIAL_ENABLED: bool
|
||||
TRIAL_DURATION_DAYS: int
|
||||
TRIAL_TRAFFIC_GB: int
|
||||
TRIAL_SQUAD_UUID: str
|
||||
TRIAL_PRICE: float
|
||||
|
||||
# Monitor service settings
|
||||
MONITOR_CHECK_INTERVAL: int
|
||||
MONITOR_DAILY_CHECK_HOUR: int
|
||||
MONITOR_WARNING_DAYS: int
|
||||
|
||||
def load_config() -> Config:
|
||||
"""Load configuration from environment variables"""
|
||||
# Parse admin IDs
|
||||
admin_ids_str = os.getenv('ADMIN_IDS', '')
|
||||
admin_ids = []
|
||||
if admin_ids_str:
|
||||
try:
|
||||
admin_ids = [int(x.strip()) for x in admin_ids_str.split(',') if x.strip()]
|
||||
except ValueError:
|
||||
admin_ids = []
|
||||
|
||||
# Get subscription base URL -
|
||||
subscription_base_url = os.getenv('SUBSCRIPTION_BASE_URL', '')
|
||||
|
||||
# Если SUBSCRIPTION_BASE_URL не установлен, используем значение по умолчанию
|
||||
if not subscription_base_url:
|
||||
subscription_base_url = 'https://sub.fring.tech'
|
||||
|
||||
return Config(
|
||||
BOT_TOKEN=os.getenv('BOT_TOKEN', ''),
|
||||
REMNAWAVE_URL=os.getenv('REMNAWAVE_URL', ''),
|
||||
REMNAWAVE_TOKEN=os.getenv('REMNAWAVE_TOKEN', ''),
|
||||
REMNAWAVE_MODE=os.getenv('REMNAWAVE_MODE', 'local'),
|
||||
DATABASE_URL=os.getenv('DATABASE_URL', 'sqlite+aiosqlite:///bot.db'),
|
||||
ADMIN_IDS=admin_ids,
|
||||
DEFAULT_LANGUAGE=os.getenv('DEFAULT_LANGUAGE', 'ru'),
|
||||
SUPPORT_USERNAME=os.getenv('SUPPORT_USERNAME', 'support'),
|
||||
|
||||
# Subscription URL
|
||||
SUBSCRIPTION_BASE_URL=subscription_base_url,
|
||||
|
||||
# Trial subscription settings
|
||||
TRIAL_ENABLED=os.getenv('TRIAL_ENABLED', 'true').lower() == 'true',
|
||||
TRIAL_DURATION_DAYS=int(os.getenv('TRIAL_DURATION_DAYS', '3')),
|
||||
TRIAL_TRAFFIC_GB=int(os.getenv('TRIAL_TRAFFIC_GB', '2')),
|
||||
TRIAL_SQUAD_UUID=os.getenv('TRIAL_SQUAD_UUID', '19bd5bde-5eea-4368-809c-6ba1ffb93897'),
|
||||
TRIAL_PRICE=float(os.getenv('TRIAL_PRICE', '0.0')),
|
||||
|
||||
# Monitor service settings
|
||||
MONITOR_CHECK_INTERVAL=int(os.getenv('MONITOR_CHECK_INTERVAL', '3600')),
|
||||
MONITOR_DAILY_CHECK_HOUR=int(os.getenv('MONITOR_DAILY_CHECK_HOUR', '10')),
|
||||
MONITOR_WARNING_DAYS=int(os.getenv('MONITOR_WARNING_DAYS', '2'))
|
||||
)
|
||||
-735
@@ -1,735 +0,0 @@
|
||||
from sqlalchemy.ext.asyncio import AsyncSession, create_async_engine, async_sessionmaker
|
||||
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column
|
||||
from sqlalchemy import BigInteger, String, Float, DateTime, Boolean, Text, Integer, text
|
||||
from datetime import datetime
|
||||
from typing import Optional, List
|
||||
import logging
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
class Base(DeclarativeBase):
|
||||
pass
|
||||
|
||||
class User(Base):
|
||||
__tablename__ = 'users'
|
||||
|
||||
id: Mapped[int] = mapped_column(primary_key=True)
|
||||
telegram_id: Mapped[int] = mapped_column(BigInteger, unique=True, index=True)
|
||||
username: Mapped[Optional[str]] = mapped_column(String(255))
|
||||
first_name: Mapped[Optional[str]] = mapped_column(String(255))
|
||||
last_name: Mapped[Optional[str]] = mapped_column(String(255))
|
||||
language: Mapped[str] = mapped_column(String(10), default='ru')
|
||||
balance: Mapped[float] = mapped_column(Float, default=0.0)
|
||||
is_admin: Mapped[bool] = mapped_column(Boolean, default=False)
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)
|
||||
remnawave_uuid: Mapped[Optional[str]] = mapped_column(String(255))
|
||||
is_trial_used: Mapped[bool] = mapped_column(Boolean, default=False)
|
||||
|
||||
class Subscription(Base):
|
||||
__tablename__ = 'subscriptions'
|
||||
|
||||
id: Mapped[int] = mapped_column(primary_key=True)
|
||||
name: Mapped[str] = mapped_column(String(255))
|
||||
description: Mapped[Optional[str]] = mapped_column(Text)
|
||||
price: Mapped[float] = mapped_column(Float)
|
||||
duration_days: Mapped[int] = mapped_column(Integer)
|
||||
traffic_limit_gb: Mapped[int] = mapped_column(Integer, default=0) # 0 = unlimited
|
||||
squad_uuid: Mapped[str] = mapped_column(String(255))
|
||||
is_active: Mapped[bool] = mapped_column(Boolean, default=True)
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)
|
||||
is_trial: Mapped[bool] = mapped_column(Boolean, default=False)
|
||||
is_imported: Mapped[bool] = mapped_column(Boolean, default=False)
|
||||
|
||||
class UserSubscription(Base):
|
||||
__tablename__ = 'user_subscriptions'
|
||||
|
||||
id: Mapped[int] = mapped_column(primary_key=True)
|
||||
user_id: Mapped[int] = mapped_column(BigInteger, index=True)
|
||||
subscription_id: Mapped[int] = mapped_column(Integer, index=True)
|
||||
short_uuid: Mapped[str] = mapped_column(String(255)) # УБРАНО unique=True
|
||||
expires_at: Mapped[datetime] = mapped_column(DateTime)
|
||||
is_active: Mapped[bool] = mapped_column(Boolean, default=True)
|
||||
traffic_limit_gb: Mapped[Optional[int]] = mapped_column(Integer) # Добавлено поле
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)
|
||||
updated_at: Mapped[Optional[datetime]] = mapped_column(DateTime, onupdate=datetime.utcnow) # Добавлено поле
|
||||
|
||||
class Payment(Base):
|
||||
__tablename__ = 'payments'
|
||||
|
||||
id: Mapped[int] = mapped_column(primary_key=True)
|
||||
user_id: Mapped[int] = mapped_column(BigInteger, index=True)
|
||||
amount: Mapped[float] = mapped_column(Float)
|
||||
payment_type: Mapped[str] = mapped_column(String(50)) # 'topup', 'subscription'
|
||||
description: Mapped[str] = mapped_column(Text)
|
||||
status: Mapped[str] = mapped_column(String(50), default='pending') # 'pending', 'completed', 'cancelled'
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)
|
||||
|
||||
class Promocode(Base):
|
||||
__tablename__ = 'promocodes'
|
||||
|
||||
id: Mapped[int] = mapped_column(primary_key=True)
|
||||
code: Mapped[str] = mapped_column(String(255), unique=True, index=True)
|
||||
discount_amount: Mapped[float] = mapped_column(Float)
|
||||
discount_percent: Mapped[Optional[int]] = mapped_column(Integer)
|
||||
usage_limit: Mapped[int] = mapped_column(Integer, default=1)
|
||||
used_count: Mapped[int] = mapped_column(Integer, default=0)
|
||||
expires_at: Mapped[Optional[datetime]] = mapped_column(DateTime)
|
||||
is_active: Mapped[bool] = mapped_column(Boolean, default=True)
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)
|
||||
|
||||
class PromocodeUsage(Base):
|
||||
__tablename__ = 'promocode_usage'
|
||||
|
||||
id: Mapped[int] = mapped_column(primary_key=True)
|
||||
user_id: Mapped[int] = mapped_column(BigInteger, index=True)
|
||||
promocode_id: Mapped[int] = mapped_column(Integer, index=True)
|
||||
used_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)
|
||||
|
||||
class Database:
|
||||
def __init__(self, database_url: str):
|
||||
self.engine = create_async_engine(
|
||||
database_url,
|
||||
echo=False,
|
||||
pool_pre_ping=True,
|
||||
pool_recycle=300
|
||||
)
|
||||
self.session_factory = async_sessionmaker(
|
||||
self.engine,
|
||||
class_=AsyncSession,
|
||||
expire_on_commit=False
|
||||
)
|
||||
|
||||
async def init_db(self):
|
||||
async with self.engine.begin() as conn:
|
||||
await conn.run_sync(Base.metadata.create_all)
|
||||
|
||||
# Выполняем миграции
|
||||
await self.migrate_user_subscriptions()
|
||||
await self.migrate_subscription_imported_field()
|
||||
|
||||
async def close(self):
|
||||
await self.engine.dispose()
|
||||
|
||||
# User methods
|
||||
async def get_user_by_telegram_id(self, telegram_id: int) -> Optional[User]:
|
||||
async with self.session_factory() as session:
|
||||
try:
|
||||
from sqlalchemy import select
|
||||
result = await session.execute(
|
||||
select(User).where(User.telegram_id == telegram_id)
|
||||
)
|
||||
return result.scalar_one_or_none()
|
||||
except Exception as e:
|
||||
logger.error(f"Error getting user by telegram_id {telegram_id}: {e}")
|
||||
return None
|
||||
|
||||
async def create_user(self, telegram_id: int, username: str = None,
|
||||
first_name: str = None, last_name: str = None,
|
||||
language: str = 'ru', is_admin: bool = False) -> User:
|
||||
async with self.session_factory() as session:
|
||||
try:
|
||||
user = User(
|
||||
telegram_id=telegram_id,
|
||||
username=username,
|
||||
first_name=first_name,
|
||||
last_name=last_name,
|
||||
language=language,
|
||||
is_admin=is_admin
|
||||
)
|
||||
session.add(user)
|
||||
await session.commit()
|
||||
await session.refresh(user)
|
||||
return user
|
||||
except Exception as e:
|
||||
logger.error(f"Error creating user {telegram_id}: {e}")
|
||||
await session.rollback()
|
||||
raise
|
||||
|
||||
async def update_user(self, user: User) -> User:
|
||||
async with self.session_factory() as session:
|
||||
try:
|
||||
await session.merge(user)
|
||||
await session.commit()
|
||||
return user
|
||||
except Exception as e:
|
||||
logger.error(f"Error updating user {user.telegram_id}: {e}")
|
||||
await session.rollback()
|
||||
raise
|
||||
|
||||
async def add_balance(self, user_id: int, amount: float) -> bool:
|
||||
async with self.session_factory() as session:
|
||||
try:
|
||||
from sqlalchemy import select, update
|
||||
result = await session.execute(
|
||||
update(User)
|
||||
.where(User.telegram_id == user_id)
|
||||
.values(balance=User.balance + amount)
|
||||
)
|
||||
await session.commit()
|
||||
return result.rowcount > 0
|
||||
except Exception as e:
|
||||
logger.error(f"Error adding balance to user {user_id}: {e}")
|
||||
await session.rollback()
|
||||
return False
|
||||
|
||||
# Subscription methods
|
||||
async def get_all_subscriptions(self, include_inactive: bool = False, exclude_trial: bool = True, exclude_imported: bool = True) -> List[Subscription]:
|
||||
async with self.session_factory() as session:
|
||||
try:
|
||||
from sqlalchemy import select
|
||||
query = select(Subscription)
|
||||
if not include_inactive:
|
||||
query = query.where(Subscription.is_active == True)
|
||||
if exclude_trial:
|
||||
query = query.where(Subscription.is_trial == False)
|
||||
if exclude_imported:
|
||||
query = query.where(Subscription.is_imported == False) # Исключаем импортированные
|
||||
result = await session.execute(query)
|
||||
return list(result.scalars().all())
|
||||
except Exception as e:
|
||||
logger.error(f"Error getting subscriptions: {e}")
|
||||
return []
|
||||
|
||||
async def get_all_subscriptions_admin(self) -> List[Subscription]:
|
||||
"""Get all subscriptions including imported ones (for admin purposes)"""
|
||||
async with self.session_factory() as session:
|
||||
try:
|
||||
from sqlalchemy import select
|
||||
result = await session.execute(select(Subscription))
|
||||
return list(result.scalars().all())
|
||||
except Exception as e:
|
||||
logger.error(f"Error getting admin subscriptions: {e}")
|
||||
return []
|
||||
|
||||
async def migrate_subscription_imported_field(self):
|
||||
"""Add is_imported field to subscriptions table"""
|
||||
try:
|
||||
async with self.engine.begin() as conn:
|
||||
try:
|
||||
await conn.execute(text("""
|
||||
ALTER TABLE subscriptions
|
||||
ADD COLUMN IF NOT EXISTS is_imported BOOLEAN DEFAULT FALSE
|
||||
"""))
|
||||
logger.info("Successfully added is_imported field to subscriptions table")
|
||||
except Exception as e:
|
||||
logger.info(f"Migration may have already been applied: {e}")
|
||||
except Exception as e:
|
||||
logger.error(f"Error during subscription migration: {e}")
|
||||
|
||||
async def get_subscription_by_id(self, subscription_id: int) -> Optional[Subscription]:
|
||||
async with self.session_factory() as session:
|
||||
try:
|
||||
from sqlalchemy import select
|
||||
result = await session.execute(
|
||||
select(Subscription).where(Subscription.id == subscription_id)
|
||||
)
|
||||
return result.scalar_one_or_none()
|
||||
except Exception as e:
|
||||
logger.error(f"Error getting subscription {subscription_id}: {e}")
|
||||
return None
|
||||
|
||||
async def create_subscription(self, name: str, description: str, price: float,
|
||||
duration_days: int, traffic_limit_gb: int,
|
||||
squad_uuid: str, is_imported: bool = False) -> Subscription:
|
||||
async with self.session_factory() as session:
|
||||
try:
|
||||
subscription = Subscription(
|
||||
name=name,
|
||||
description=description,
|
||||
price=price,
|
||||
duration_days=duration_days,
|
||||
traffic_limit_gb=traffic_limit_gb,
|
||||
squad_uuid=squad_uuid,
|
||||
is_imported=is_imported # Добавляем поддержку is_imported
|
||||
)
|
||||
session.add(subscription)
|
||||
await session.commit()
|
||||
await session.refresh(subscription)
|
||||
return subscription
|
||||
except Exception as e:
|
||||
logger.error(f"Error creating subscription: {e}")
|
||||
await session.rollback()
|
||||
raise
|
||||
|
||||
async def update_subscription(self, subscription: Subscription) -> Subscription:
|
||||
async with self.session_factory() as session:
|
||||
try:
|
||||
await session.merge(subscription)
|
||||
await session.commit()
|
||||
return subscription
|
||||
except Exception as e:
|
||||
logger.error(f"Error updating subscription {subscription.id}: {e}")
|
||||
await session.rollback()
|
||||
raise
|
||||
|
||||
async def delete_subscription(self, subscription_id: int) -> bool:
|
||||
async with self.session_factory() as session:
|
||||
try:
|
||||
from sqlalchemy import delete
|
||||
result = await session.execute(
|
||||
delete(Subscription).where(Subscription.id == subscription_id)
|
||||
)
|
||||
await session.commit()
|
||||
return result.rowcount > 0
|
||||
except Exception as e:
|
||||
logger.error(f"Error deleting subscription {subscription_id}: {e}")
|
||||
await session.rollback()
|
||||
return False
|
||||
|
||||
async def get_user_subscriptions(self, user_id: int) -> List[UserSubscription]:
|
||||
async with self.session_factory() as session:
|
||||
try:
|
||||
from sqlalchemy import select
|
||||
result = await session.execute(
|
||||
select(UserSubscription).where(UserSubscription.user_id == user_id)
|
||||
)
|
||||
return list(result.scalars().all())
|
||||
except Exception as e:
|
||||
logger.error(f"Error getting user subscriptions for {user_id}: {e}")
|
||||
return []
|
||||
|
||||
async def create_user_subscription(self, user_id: int, subscription_id: int,
|
||||
short_uuid: str, expires_at: datetime,
|
||||
is_active: bool = True, traffic_limit_gb: int = None) -> Optional[UserSubscription]:
|
||||
"""Create user subscription with proper error handling"""
|
||||
async with self.session_factory() as session:
|
||||
try:
|
||||
# Проверяем что подписка не существует
|
||||
from sqlalchemy import select
|
||||
existing = await session.execute(
|
||||
select(UserSubscription).where(
|
||||
UserSubscription.user_id == user_id,
|
||||
UserSubscription.short_uuid == short_uuid
|
||||
)
|
||||
)
|
||||
existing_sub = existing.scalar_one_or_none()
|
||||
|
||||
if existing_sub:
|
||||
logger.warning(f"Subscription with short_uuid {short_uuid} already exists for user {user_id}")
|
||||
return existing_sub
|
||||
|
||||
# Создаем новую подписку
|
||||
new_subscription = UserSubscription(
|
||||
user_id=user_id,
|
||||
subscription_id=subscription_id,
|
||||
short_uuid=short_uuid,
|
||||
expires_at=expires_at,
|
||||
is_active=is_active
|
||||
)
|
||||
|
||||
session.add(new_subscription)
|
||||
await session.commit()
|
||||
await session.refresh(new_subscription)
|
||||
|
||||
return new_subscription
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error creating user subscription: {e}")
|
||||
await session.rollback()
|
||||
return None
|
||||
|
||||
# Payment methods
|
||||
async def create_payment(self, user_id: int, amount: float, payment_type: str,
|
||||
description: str, status: str = 'pending') -> Payment:
|
||||
async with self.session_factory() as session:
|
||||
try:
|
||||
payment = Payment(
|
||||
user_id=user_id,
|
||||
amount=amount,
|
||||
payment_type=payment_type,
|
||||
description=description,
|
||||
status=status
|
||||
)
|
||||
session.add(payment)
|
||||
await session.commit()
|
||||
await session.refresh(payment)
|
||||
return payment
|
||||
except Exception as e:
|
||||
logger.error(f"Error creating payment: {e}")
|
||||
await session.rollback()
|
||||
raise
|
||||
|
||||
async def get_payment_by_id(self, payment_id: int) -> Optional[Payment]:
|
||||
async with self.session_factory() as session:
|
||||
try:
|
||||
from sqlalchemy import select
|
||||
result = await session.execute(
|
||||
select(Payment).where(Payment.id == payment_id)
|
||||
)
|
||||
return result.scalar_one_or_none()
|
||||
except Exception as e:
|
||||
logger.error(f"Error getting payment {payment_id}: {e}")
|
||||
return None
|
||||
|
||||
async def update_payment(self, payment: Payment) -> Payment:
|
||||
async with self.session_factory() as session:
|
||||
try:
|
||||
await session.merge(payment)
|
||||
await session.commit()
|
||||
return payment
|
||||
except Exception as e:
|
||||
logger.error(f"Error updating payment {payment.id}: {e}")
|
||||
await session.rollback()
|
||||
raise
|
||||
|
||||
async def get_user_payments(self, user_id: int) -> List[Payment]:
|
||||
async with self.session_factory() as session:
|
||||
try:
|
||||
from sqlalchemy import select, desc
|
||||
result = await session.execute(
|
||||
select(Payment)
|
||||
.where(Payment.user_id == user_id)
|
||||
.order_by(desc(Payment.created_at))
|
||||
)
|
||||
return list(result.scalars().all())
|
||||
except Exception as e:
|
||||
logger.error(f"Error getting user payments for {user_id}: {e}")
|
||||
return []
|
||||
|
||||
# Promocode methods
|
||||
async def get_promocode_by_code(self, code: str) -> Optional[Promocode]:
|
||||
async with self.session_factory() as session:
|
||||
try:
|
||||
from sqlalchemy import select
|
||||
result = await session.execute(
|
||||
select(Promocode).where(Promocode.code == code)
|
||||
)
|
||||
return result.scalar_one_or_none()
|
||||
except Exception as e:
|
||||
logger.error(f"Error getting promocode {code}: {e}")
|
||||
return None
|
||||
|
||||
async def create_promocode(self, code: str, discount_amount: float = 0,
|
||||
discount_percent: int = None, usage_limit: int = 1,
|
||||
expires_at: datetime = None) -> Promocode:
|
||||
async with self.session_factory() as session:
|
||||
try:
|
||||
promocode = Promocode(
|
||||
code=code,
|
||||
discount_amount=discount_amount,
|
||||
discount_percent=discount_percent,
|
||||
usage_limit=usage_limit,
|
||||
expires_at=expires_at
|
||||
)
|
||||
session.add(promocode)
|
||||
await session.commit()
|
||||
await session.refresh(promocode)
|
||||
return promocode
|
||||
except Exception as e:
|
||||
logger.error(f"Error creating promocode: {e}")
|
||||
await session.rollback()
|
||||
raise
|
||||
|
||||
async def use_promocode(self, user_id: int, promocode: Promocode) -> bool:
|
||||
async with self.session_factory() as session:
|
||||
try:
|
||||
# Check if already used
|
||||
from sqlalchemy import select
|
||||
existing = await session.execute(
|
||||
select(PromocodeUsage).where(
|
||||
PromocodeUsage.user_id == user_id,
|
||||
PromocodeUsage.promocode_id == promocode.id
|
||||
)
|
||||
)
|
||||
if existing.scalar_one_or_none():
|
||||
return False
|
||||
|
||||
# Create usage record
|
||||
usage = PromocodeUsage(user_id=user_id, promocode_id=promocode.id)
|
||||
session.add(usage)
|
||||
|
||||
# Update promocode used count
|
||||
promocode.used_count += 1
|
||||
await session.merge(promocode)
|
||||
|
||||
await session.commit()
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.error(f"Error using promocode: {e}")
|
||||
await session.rollback()
|
||||
return False
|
||||
|
||||
async def get_all_promocodes(self) -> List[Promocode]:
|
||||
async with self.session_factory() as session:
|
||||
try:
|
||||
from sqlalchemy import select
|
||||
result = await session.execute(select(Promocode))
|
||||
return list(result.scalars().all())
|
||||
except Exception as e:
|
||||
logger.error(f"Error getting promocodes: {e}")
|
||||
return []
|
||||
|
||||
# Admin methods
|
||||
async def get_all_users(self) -> List[User]:
|
||||
async with self.session_factory() as session:
|
||||
try:
|
||||
from sqlalchemy import select
|
||||
result = await session.execute(select(User))
|
||||
return list(result.scalars().all())
|
||||
except Exception as e:
|
||||
logger.error(f"Error getting all users: {e}")
|
||||
return []
|
||||
|
||||
async def get_stats(self) -> dict:
|
||||
async with self.session_factory() as session:
|
||||
try:
|
||||
from sqlalchemy import select, func
|
||||
|
||||
# Total users
|
||||
total_users = await session.execute(
|
||||
select(func.count(User.id))
|
||||
)
|
||||
total_users = total_users.scalar()
|
||||
|
||||
# Total subscriptions (excluding trial)
|
||||
total_subs_non_trial = await session.execute(
|
||||
select(func.count(UserSubscription.id))
|
||||
.join(Subscription, UserSubscription.subscription_id == Subscription.id)
|
||||
.where(Subscription.is_trial == False)
|
||||
)
|
||||
total_subs_non_trial = total_subs_non_trial.scalar()
|
||||
|
||||
# Total payments (excluding trial payments)
|
||||
total_payments = await session.execute(
|
||||
select(func.sum(Payment.amount)).where(
|
||||
Payment.status == 'completed',
|
||||
Payment.payment_type != 'trial' # Исключаем тестовые платежи
|
||||
)
|
||||
)
|
||||
total_payments = total_payments.scalar() or 0
|
||||
|
||||
return {
|
||||
'total_users': total_users,
|
||||
'total_subscriptions_non_trial': total_subs_non_trial,
|
||||
'total_revenue': total_payments
|
||||
}
|
||||
except Exception as e:
|
||||
logger.error(f"Error getting stats: {e}")
|
||||
return {
|
||||
'total_users': 0,
|
||||
'total_subscriptions_non_trial': 0,
|
||||
'total_revenue': 0
|
||||
}
|
||||
|
||||
async def get_trial_subscriptions(self) -> List[Subscription]:
|
||||
"""Get only trial subscriptions"""
|
||||
async with self.session_factory() as session:
|
||||
try:
|
||||
from sqlalchemy import select
|
||||
result = await session.execute(
|
||||
select(Subscription).where(Subscription.is_trial == True)
|
||||
)
|
||||
return list(result.scalars().all())
|
||||
except Exception as e:
|
||||
logger.error(f"Error getting trial subscriptions: {e}")
|
||||
return []
|
||||
|
||||
async def get_user_subscription_by_short_uuid(self, user_id: int, short_uuid: str) -> Optional[UserSubscription]:
|
||||
"""Get user subscription by short_uuid"""
|
||||
async with self.session_factory() as session:
|
||||
try:
|
||||
from sqlalchemy import select
|
||||
result = await session.execute(
|
||||
select(UserSubscription).where(
|
||||
UserSubscription.user_id == user_id,
|
||||
UserSubscription.short_uuid == short_uuid
|
||||
)
|
||||
)
|
||||
return result.scalar_one_or_none()
|
||||
except Exception as e:
|
||||
logger.error(f"Error getting user subscription by short_uuid: {e}")
|
||||
return None
|
||||
|
||||
async def update_user_subscription(self, user_subscription: UserSubscription) -> bool:
|
||||
"""Update user subscription"""
|
||||
async with self.session_factory() as session:
|
||||
try:
|
||||
# Устанавливаем время обновления
|
||||
user_subscription.updated_at = datetime.utcnow()
|
||||
|
||||
# Обновляем подписку
|
||||
await session.merge(user_subscription)
|
||||
await session.commit()
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.error(f"Error updating user subscription: {e}")
|
||||
await session.rollback()
|
||||
return False
|
||||
|
||||
async def migrate_user_subscriptions(self):
|
||||
"""Migrate user_subscriptions table to add missing columns"""
|
||||
try:
|
||||
async with self.engine.begin() as conn:
|
||||
# Проверяем существование столбцов и добавляем их если нет
|
||||
try:
|
||||
await conn.execute(text("""
|
||||
ALTER TABLE user_subscriptions
|
||||
ADD COLUMN IF NOT EXISTS traffic_limit_gb INTEGER,
|
||||
ADD COLUMN IF NOT EXISTS updated_at TIMESTAMP
|
||||
"""))
|
||||
logger.info("Successfully migrated user_subscriptions table")
|
||||
except Exception as e:
|
||||
logger.info(f"Migration may have already been applied or error occurred: {e}")
|
||||
except Exception as e:
|
||||
logger.error(f"Error during migration: {e}")
|
||||
|
||||
async def get_expiring_subscriptions(self, user_id: int, days_threshold: int = 3) -> List[UserSubscription]:
|
||||
async with self.session_factory() as session:
|
||||
try:
|
||||
from sqlalchemy import select
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
threshold_date = datetime.utcnow() + timedelta(days=days_threshold)
|
||||
|
||||
result = await session.execute(
|
||||
select(UserSubscription).where(
|
||||
UserSubscription.user_id == user_id,
|
||||
UserSubscription.is_active == True,
|
||||
UserSubscription.expires_at <= threshold_date,
|
||||
UserSubscription.expires_at > datetime.utcnow()
|
||||
)
|
||||
)
|
||||
return list(result.scalars().all())
|
||||
except Exception as e:
|
||||
logger.error(f"Error getting expiring subscriptions for {user_id}: {e}")
|
||||
return []
|
||||
|
||||
async def has_used_trial(self, user_id: int) -> bool:
|
||||
async with self.session_factory() as session:
|
||||
try:
|
||||
from sqlalchemy import select
|
||||
result = await session.execute(
|
||||
select(User.is_trial_used).where(User.telegram_id == user_id)
|
||||
)
|
||||
is_trial_used = result.scalar_one_or_none()
|
||||
return is_trial_used or False
|
||||
except Exception as e:
|
||||
logger.error(f"Error checking trial usage for user {user_id}: {e}")
|
||||
return False
|
||||
|
||||
async def mark_trial_used(self, user_id: int) -> bool:
|
||||
async with self.session_factory() as session:
|
||||
try:
|
||||
from sqlalchemy import update
|
||||
result = await session.execute(
|
||||
update(User)
|
||||
.where(User.telegram_id == user_id)
|
||||
.values(is_trial_used=True)
|
||||
)
|
||||
await session.commit()
|
||||
return result.rowcount > 0
|
||||
except Exception as e:
|
||||
logger.error(f"Error marking trial used for user {user_id}: {e}")
|
||||
await session.rollback()
|
||||
return False
|
||||
|
||||
async def get_all_payments_paginated(self, offset: int = 0, limit: int = 10) -> tuple[List[Payment], int]:
|
||||
"""Get all payments with pagination"""
|
||||
async with self.session_factory() as session:
|
||||
try:
|
||||
from sqlalchemy import select, desc, func
|
||||
|
||||
# Получаем общее количество записей
|
||||
count_result = await session.execute(
|
||||
select(func.count(Payment.id))
|
||||
)
|
||||
total_count = count_result.scalar()
|
||||
|
||||
# Получаем платежи с пагинацией
|
||||
result = await session.execute(
|
||||
select(Payment)
|
||||
.order_by(desc(Payment.created_at))
|
||||
.offset(offset)
|
||||
.limit(limit)
|
||||
)
|
||||
payments = list(result.scalars().all())
|
||||
|
||||
return payments, total_count
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error getting paginated payments: {e}")
|
||||
return [], 0
|
||||
|
||||
async def get_payments_by_type_paginated(self, payment_type: str, offset: int = 0, limit: int = 10) -> tuple[List[Payment], int]:
|
||||
"""Get payments by type with pagination"""
|
||||
async with self.session_factory() as session:
|
||||
try:
|
||||
from sqlalchemy import select, desc, func
|
||||
|
||||
# Получаем общее количество записей
|
||||
count_result = await session.execute(
|
||||
select(func.count(Payment.id)).where(Payment.payment_type == payment_type)
|
||||
)
|
||||
total_count = count_result.scalar()
|
||||
|
||||
# Получаем платежи с пагинацией
|
||||
result = await session.execute(
|
||||
select(Payment)
|
||||
.where(Payment.payment_type == payment_type)
|
||||
.order_by(desc(Payment.created_at))
|
||||
.offset(offset)
|
||||
.limit(limit)
|
||||
)
|
||||
payments = list(result.scalars().all())
|
||||
|
||||
return payments, total_count
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error getting paginated payments by type: {e}")
|
||||
return [], 0
|
||||
|
||||
async def get_payments_by_status_paginated(self, status: str, offset: int = 0, limit: int = 10) -> tuple[List[Payment], int]:
|
||||
"""Get payments by status with pagination"""
|
||||
async with self.session_factory() as session:
|
||||
try:
|
||||
from sqlalchemy import select, desc, func
|
||||
|
||||
# Получаем общее количество записей
|
||||
count_result = await session.execute(
|
||||
select(func.count(Payment.id)).where(Payment.status == status)
|
||||
)
|
||||
total_count = count_result.scalar()
|
||||
|
||||
# Получаем платежи с пагинацией
|
||||
result = await session.execute(
|
||||
select(Payment)
|
||||
.where(Payment.status == status)
|
||||
.order_by(desc(Payment.created_at))
|
||||
.offset(offset)
|
||||
.limit(limit)
|
||||
)
|
||||
payments = list(result.scalars().all())
|
||||
|
||||
return payments, total_count
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error getting paginated payments by status: {e}")
|
||||
return [], 0
|
||||
|
||||
async def get_user_subscriptions_by_plan_id(self, plan_id: int) -> List[UserSubscription]:
|
||||
"""Get all user subscriptions for a specific plan"""
|
||||
async with self.session_factory() as session:
|
||||
try:
|
||||
from sqlalchemy import select
|
||||
result = await session.execute(
|
||||
select(UserSubscription).where(UserSubscription.subscription_id == plan_id)
|
||||
)
|
||||
return list(result.scalars().all())
|
||||
except Exception as e:
|
||||
logger.error(f"Error getting user subscriptions for plan {plan_id}: {e}")
|
||||
return []
|
||||
|
||||
async def delete_user_subscription(self, user_subscription_id: int) -> bool:
|
||||
"""Delete user subscription by ID"""
|
||||
async with self.session_factory() as session:
|
||||
try:
|
||||
from sqlalchemy import delete
|
||||
result = await session.execute(
|
||||
delete(UserSubscription).where(UserSubscription.id == user_subscription_id)
|
||||
)
|
||||
await session.commit()
|
||||
return result.rowcount > 0
|
||||
except Exception as e:
|
||||
logger.error(f"Error deleting user subscription {user_subscription_id}: {e}")
|
||||
await session.rollback()
|
||||
return False
|
||||
+39
-64
@@ -1,98 +1,73 @@
|
||||
|
||||
services:
|
||||
# PostgreSQL Database
|
||||
postgres:
|
||||
image: postgres:15-alpine
|
||||
container_name: remnawave_bot_db
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
POSTGRES_DB: remnawave_bot
|
||||
POSTGRES_USER: remnawave_user
|
||||
POSTGRES_PASSWORD: secure_password_123
|
||||
POSTGRES_INITDB_ARGS: "--encoding=UTF-8 --lc-collate=C --lc-ctype=C"
|
||||
POSTGRES_DB: ${POSTGRES_DB:-remnawave_bot}
|
||||
POSTGRES_USER: ${POSTGRES_USER:-remnawave_user}
|
||||
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-secure_password_123}
|
||||
POSTGRES_INITDB_ARGS: "--encoding=UTF8"
|
||||
volumes:
|
||||
- postgres_data:/var/lib/postgresql/data
|
||||
- ./init-db.sql:/docker-entrypoint-initdb.d/init-db.sql:ro
|
||||
ports:
|
||||
- "5432:5432"
|
||||
networks:
|
||||
- bot_network
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "pg_isready -U remnawave_user -d remnawave_bot"]
|
||||
interval: 15s
|
||||
timeout: 10s
|
||||
test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-remnawave_user} -d ${POSTGRES_DB:-remnawave_bot}"]
|
||||
interval: 30s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
start_period: 30s
|
||||
|
||||
# RemnaWave Bot
|
||||
redis:
|
||||
image: redis:7-alpine
|
||||
container_name: remnawave_bot_redis
|
||||
restart: unless-stopped
|
||||
command: redis-server --appendonly yes
|
||||
volumes:
|
||||
- redis_data:/data
|
||||
networks:
|
||||
- bot_network
|
||||
healthcheck:
|
||||
test: ["CMD", "redis-cli", "ping"]
|
||||
interval: 30s
|
||||
timeout: 10s
|
||||
retries: 3
|
||||
|
||||
bot:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: Dockerfile
|
||||
image: fr1ngg/remnawave-bedolaga-telegram-bot:latest
|
||||
container_name: remnawave_bot
|
||||
restart: unless-stopped
|
||||
depends_on:
|
||||
postgres:
|
||||
condition: service_healthy
|
||||
redis:
|
||||
condition: service_healthy
|
||||
env_file:
|
||||
- .env
|
||||
environment:
|
||||
# Override database URL to use PostgreSQL container
|
||||
DATABASE_URL: postgresql+asyncpg://remnawave_user:secure_password_123@postgres:5432/remnawave_bot
|
||||
DATABASE_URL: postgresql+asyncpg://${POSTGRES_USER:-remnawave_user}:${POSTGRES_PASSWORD:-secure_password_123}@postgres:5432/${POSTGRES_DB:-remnawave_bot}
|
||||
REDIS_URL: redis://redis:6379/0
|
||||
volumes:
|
||||
- ./logs:/app/logs
|
||||
- ./data:/app/data
|
||||
networks:
|
||||
- bot_network
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "python -c 'print(\"Bot is running\")'"]
|
||||
interval: 30s
|
||||
timeout: 10s
|
||||
retries: 3
|
||||
start_period: 30s
|
||||
|
||||
# Redis (optional, for caching and session storage)
|
||||
redis:
|
||||
image: redis:7-alpine
|
||||
container_name: remnawave_bot_redis
|
||||
restart: unless-stopped
|
||||
command: redis-server --appendonly yes --requirepass redis_password_123
|
||||
volumes:
|
||||
- redis_data:/data
|
||||
- ./logs:/app/logs:rw
|
||||
- ./data:/app/data:rw
|
||||
- /etc/timezone:/etc/timezone:ro
|
||||
- /etc/localtime:/etc/localtime:ro
|
||||
ports:
|
||||
- "6379:6379"
|
||||
- "${TRIBUTE_WEBHOOK_PORT:-8081}:8081"
|
||||
- "${YOOKASSA_WEBHOOK_PORT:-8082}:8082"
|
||||
networks:
|
||||
- bot_network
|
||||
healthcheck:
|
||||
test: ["CMD", "redis-cli", "--raw", "incr", "ping"]
|
||||
interval: 10s
|
||||
timeout: 3s
|
||||
retries: 5
|
||||
|
||||
# Nginx (optional, for serving static files or reverse proxy)
|
||||
nginx:
|
||||
image: nginx:alpine
|
||||
container_name: remnawave_bot_nginx
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "80:80"
|
||||
- "443:443"
|
||||
volumes:
|
||||
- ./nginx.conf:/etc/nginx/nginx.conf:ro
|
||||
- ./ssl:/etc/nginx/ssl:ro
|
||||
- ./static:/usr/share/nginx/html:ro
|
||||
networks:
|
||||
- bot_network
|
||||
depends_on:
|
||||
- bot
|
||||
profiles:
|
||||
- with-nginx
|
||||
user: "1000:1000"
|
||||
command: >
|
||||
bash -c "
|
||||
mkdir -p /app/logs /app/data &&
|
||||
python main.py
|
||||
"
|
||||
|
||||
volumes:
|
||||
postgres_data:
|
||||
driver: local
|
||||
redis_data:
|
||||
driver: local
|
||||
|
||||
networks:
|
||||
bot_network:
|
||||
|
||||
-1240
File diff suppressed because it is too large
Load Diff
-507
@@ -1,507 +0,0 @@
|
||||
from database import Subscription
|
||||
from aiogram.types import InlineKeyboardMarkup, InlineKeyboardButton
|
||||
from typing import List, Optional, Dict
|
||||
from translations import t
|
||||
|
||||
def language_keyboard() -> InlineKeyboardMarkup:
|
||||
"""Language selection keyboard"""
|
||||
keyboard = InlineKeyboardMarkup(inline_keyboard=[
|
||||
[
|
||||
InlineKeyboardButton(text="🇷🇺 Русский", callback_data="lang_ru"),
|
||||
InlineKeyboardButton(text="🇺🇸 English", callback_data="lang_en")
|
||||
]
|
||||
])
|
||||
return keyboard
|
||||
|
||||
def main_menu_keyboard(lang: str = 'ru', is_admin: bool = False, show_trial: bool = False) -> InlineKeyboardMarkup:
|
||||
"""Beautiful main menu keyboard with emojis and better layout"""
|
||||
buttons = [
|
||||
# Первый ряд - основные функции
|
||||
[
|
||||
InlineKeyboardButton(text="💰 " + t('balance', lang), callback_data="balance"),
|
||||
InlineKeyboardButton(text="📋 " + t('my_subscriptions', lang), callback_data="my_subscriptions")
|
||||
],
|
||||
# Второй ряд - покупка подписки (выделена отдельно как главная функция)
|
||||
[InlineKeyboardButton(text="🛒 " + t('buy_subscription', lang), callback_data="buy_subscription")],
|
||||
]
|
||||
|
||||
# Добавляем тестовую подписку если доступна
|
||||
if show_trial:
|
||||
buttons.insert(1, [InlineKeyboardButton(text="🆓 Тестовая подписка", callback_data="trial_subscription")])
|
||||
|
||||
# Добавляем остальные кнопки
|
||||
buttons.extend([
|
||||
# Дополнительные функции
|
||||
[
|
||||
InlineKeyboardButton(text="🎁 " + t('promocode', lang), callback_data="promocode"),
|
||||
InlineKeyboardButton(text="💬 " + t('support', lang), callback_data="support")
|
||||
],
|
||||
# Последний ряд - настройки
|
||||
[InlineKeyboardButton(text="🌐 " + t('change_language', lang), callback_data="change_language")]
|
||||
])
|
||||
|
||||
# Добавляем админ панель если пользователь админ
|
||||
if is_admin:
|
||||
buttons.append([InlineKeyboardButton(text="⚙️ " + t('admin_panel', lang), callback_data="admin_panel")])
|
||||
|
||||
return InlineKeyboardMarkup(inline_keyboard=buttons)
|
||||
|
||||
# Остальные функции остаются прежними...
|
||||
def balance_keyboard(lang: str = 'ru') -> InlineKeyboardMarkup:
|
||||
"""Beautiful balance menu keyboard"""
|
||||
keyboard = InlineKeyboardMarkup(inline_keyboard=[
|
||||
[InlineKeyboardButton(text="💳 " + t('topup_balance', lang), callback_data="topup_balance")],
|
||||
[InlineKeyboardButton(text="📊 " + t('payment_history', lang), callback_data="payment_history")],
|
||||
[InlineKeyboardButton(text="🔙 " + t('back', lang), callback_data="main_menu")]
|
||||
])
|
||||
return keyboard
|
||||
|
||||
def topup_keyboard(lang: str = 'ru') -> InlineKeyboardMarkup:
|
||||
"""Beautiful top up balance keyboard"""
|
||||
keyboard = InlineKeyboardMarkup(inline_keyboard=[
|
||||
[InlineKeyboardButton(text="💳 " + t('topup_card', lang), callback_data="topup_card")],
|
||||
[InlineKeyboardButton(text="👨💼 " + t('topup_support', lang), callback_data="topup_support")],
|
||||
[InlineKeyboardButton(text="🔙 " + t('back', lang), callback_data="balance")]
|
||||
])
|
||||
return keyboard
|
||||
|
||||
def subscriptions_keyboard(subscriptions: List[dict], lang: str = 'ru') -> InlineKeyboardMarkup:
|
||||
"""Beautiful available subscriptions keyboard"""
|
||||
buttons = []
|
||||
|
||||
# Группируем подписки по две в ряд для компактности
|
||||
for i in range(0, len(subscriptions), 2):
|
||||
row = []
|
||||
for j in range(2):
|
||||
if i + j < len(subscriptions):
|
||||
sub = subscriptions[i + j]
|
||||
price_text = f"{sub['price']:.0f}₽"
|
||||
# Используем разные эмодзи для разных ценовых категорий
|
||||
if sub['price'] <= 100:
|
||||
emoji = "🥉"
|
||||
elif sub['price'] <= 300:
|
||||
emoji = "🥈"
|
||||
else:
|
||||
emoji = "🥇"
|
||||
|
||||
row.append(InlineKeyboardButton(
|
||||
text=f"{emoji} {sub['name']} - {price_text}",
|
||||
callback_data=f"buy_sub_{sub['id']}"
|
||||
))
|
||||
buttons.append(row)
|
||||
|
||||
buttons.append([InlineKeyboardButton(text="🔙 " + t('back', lang), callback_data="main_menu")])
|
||||
return InlineKeyboardMarkup(inline_keyboard=buttons)
|
||||
|
||||
def subscription_detail_keyboard(subscription_id: int, lang: str = 'ru') -> InlineKeyboardMarkup:
|
||||
"""Beautiful subscription detail keyboard"""
|
||||
keyboard = InlineKeyboardMarkup(inline_keyboard=[
|
||||
[InlineKeyboardButton(
|
||||
text="💎 " + t('buy_subscription_btn', lang, price=0),
|
||||
callback_data=f"confirm_buy_{subscription_id}"
|
||||
)],
|
||||
[InlineKeyboardButton(text="🔙 " + t('back', lang), callback_data="buy_subscription")]
|
||||
])
|
||||
return keyboard
|
||||
|
||||
def user_subscriptions_keyboard(user_subscriptions: List[dict], lang: str = 'ru') -> InlineKeyboardMarkup:
|
||||
"""Beautiful user's subscriptions keyboard"""
|
||||
buttons = []
|
||||
|
||||
for sub in user_subscriptions:
|
||||
# Добавляем эмодзи статуса для каждой подписки
|
||||
buttons.append([InlineKeyboardButton(
|
||||
text=f"📱 {sub['name']}",
|
||||
callback_data=f"view_sub_{sub['id']}"
|
||||
)])
|
||||
|
||||
if not user_subscriptions:
|
||||
# Если нет подписок, показываем кнопку покупки
|
||||
buttons.append([InlineKeyboardButton(text="🛒 " + t('buy_subscription', lang), callback_data="buy_subscription")])
|
||||
|
||||
buttons.append([InlineKeyboardButton(text="🔙 " + t('back', lang), callback_data="main_menu")])
|
||||
return InlineKeyboardMarkup(inline_keyboard=buttons)
|
||||
|
||||
def user_subscription_detail_keyboard(subscription_id: int, lang: str = 'ru', show_extend: bool = False) -> InlineKeyboardMarkup:
|
||||
"""Beautiful user's subscription detail keyboard with connection and optional extend button"""
|
||||
buttons = []
|
||||
|
||||
# Add extend button if subscription is expiring soon
|
||||
if show_extend:
|
||||
buttons.append([InlineKeyboardButton(text="⏰ " + t('extend_subscription', lang), callback_data=f"extend_sub_{subscription_id}")])
|
||||
|
||||
# Connection button (главная кнопка)
|
||||
buttons.append([InlineKeyboardButton(text="🔗 Получить ссылку подключения", callback_data=f"get_connection_{subscription_id}")])
|
||||
|
||||
# Back button
|
||||
buttons.append([InlineKeyboardButton(text="🔙 " + t('back', lang), callback_data="my_subscriptions")])
|
||||
|
||||
return InlineKeyboardMarkup(inline_keyboard=buttons)
|
||||
|
||||
def extend_subscription_keyboard(subscription_id: int, lang: str = 'ru') -> InlineKeyboardMarkup:
|
||||
"""Beautiful extend subscription confirmation keyboard"""
|
||||
keyboard = InlineKeyboardMarkup(inline_keyboard=[
|
||||
[
|
||||
InlineKeyboardButton(text="✅ Да, продлить", callback_data=f"confirm_extend_{subscription_id}"),
|
||||
InlineKeyboardButton(text="❌ Отмена", callback_data=f"view_sub_{subscription_id}")
|
||||
]
|
||||
])
|
||||
return keyboard
|
||||
|
||||
def back_keyboard(callback_data: str, lang: str = 'ru') -> InlineKeyboardMarkup:
|
||||
"""Beautiful back button keyboard"""
|
||||
keyboard = InlineKeyboardMarkup(inline_keyboard=[
|
||||
[InlineKeyboardButton(text="🔙 " + t('back', lang), callback_data=callback_data)]
|
||||
])
|
||||
return keyboard
|
||||
|
||||
def cancel_keyboard(lang: str = 'ru') -> InlineKeyboardMarkup:
|
||||
"""Beautiful cancel button keyboard"""
|
||||
keyboard = InlineKeyboardMarkup(inline_keyboard=[
|
||||
[InlineKeyboardButton(text="❌ " + t('cancel', lang), callback_data="main_menu")]
|
||||
])
|
||||
return keyboard
|
||||
|
||||
# Admin keyboards
|
||||
|
||||
def admin_menu_keyboard(lang: str = 'ru') -> InlineKeyboardMarkup:
|
||||
"""Beautiful admin menu keyboard"""
|
||||
keyboard = InlineKeyboardMarkup(inline_keyboard=[
|
||||
# Первый ряд - управление контентом
|
||||
[
|
||||
InlineKeyboardButton(text="📦 " + t('manage_subscriptions', lang), callback_data="admin_subscriptions"),
|
||||
InlineKeyboardButton(text="👥 " + t('manage_users', lang), callback_data="admin_users")
|
||||
],
|
||||
# Второй ряд - финансы
|
||||
[
|
||||
InlineKeyboardButton(text="💰 " + t('manage_balance', lang), callback_data="admin_balance"),
|
||||
InlineKeyboardButton(text="🎁 " + t('manage_promocodes', lang), callback_data="admin_promocodes")
|
||||
],
|
||||
# Третий ряд - коммуникации и система
|
||||
[
|
||||
InlineKeyboardButton(text="📨 " + t('send_message', lang), callback_data="admin_messages"),
|
||||
InlineKeyboardButton(text="🖥 Система RemnaWave", callback_data="admin_system") # НОВОЕ!
|
||||
],
|
||||
# Четвертый ряд - мониторинг и статистика
|
||||
[
|
||||
InlineKeyboardButton(text="🔍 Мониторинг подписок", callback_data="admin_monitor"),
|
||||
InlineKeyboardButton(text="📊 " + t('statistics', lang), callback_data="admin_stats")
|
||||
],
|
||||
# Назад
|
||||
[InlineKeyboardButton(text="🔙 " + t('back', lang), callback_data="main_menu")]
|
||||
])
|
||||
return keyboard
|
||||
|
||||
def admin_subscriptions_keyboard(lang: str = 'ru') -> InlineKeyboardMarkup:
|
||||
"""Beautiful admin subscriptions management keyboard"""
|
||||
keyboard = InlineKeyboardMarkup(inline_keyboard=[
|
||||
[InlineKeyboardButton(text="➕ " + t('create_subscription', lang), callback_data="create_subscription")],
|
||||
[InlineKeyboardButton(text="📋 Список подписок", callback_data="list_admin_subscriptions")],
|
||||
[InlineKeyboardButton(text="🔙 " + t('back', lang), callback_data="admin_panel")]
|
||||
])
|
||||
return keyboard
|
||||
|
||||
def admin_users_keyboard(lang: str = 'ru') -> InlineKeyboardMarkup:
|
||||
"""Beautiful admin users management keyboard"""
|
||||
keyboard = InlineKeyboardMarkup(inline_keyboard=[
|
||||
[InlineKeyboardButton(text="👥 Список пользователей", callback_data="list_users")],
|
||||
[InlineKeyboardButton(text="🔍 Поиск пользователя", callback_data="search_user")],
|
||||
[InlineKeyboardButton(text="🔙 " + t('back', lang), callback_data="admin_panel")]
|
||||
])
|
||||
return keyboard
|
||||
|
||||
def admin_balance_keyboard(lang: str = 'ru') -> InlineKeyboardMarkup:
|
||||
"""Beautiful admin balance management keyboard"""
|
||||
keyboard = InlineKeyboardMarkup(inline_keyboard=[
|
||||
[InlineKeyboardButton(text="💸 Пополнить баланс пользователю", callback_data="admin_add_balance")],
|
||||
[InlineKeyboardButton(text="📊 История платежей", callback_data="admin_payment_history")],
|
||||
[InlineKeyboardButton(text="🔙 " + t('back', lang), callback_data="admin_panel")]
|
||||
])
|
||||
return keyboard
|
||||
|
||||
def admin_promocodes_keyboard(lang: str = 'ru') -> InlineKeyboardMarkup:
|
||||
"""Beautiful admin promocodes management keyboard"""
|
||||
keyboard = InlineKeyboardMarkup(inline_keyboard=[
|
||||
[InlineKeyboardButton(text="➕ " + t('create_promocode', lang), callback_data="create_promocode")],
|
||||
[InlineKeyboardButton(text="📋 Список промокодов", callback_data="list_promocodes")],
|
||||
[InlineKeyboardButton(text="🔙 " + t('back', lang), callback_data="admin_panel")]
|
||||
])
|
||||
return keyboard
|
||||
|
||||
def confirmation_keyboard(confirm_callback: str, cancel_callback: str, lang: str = 'ru') -> InlineKeyboardMarkup:
|
||||
"""Beautiful confirmation keyboard"""
|
||||
keyboard = InlineKeyboardMarkup(inline_keyboard=[
|
||||
[
|
||||
InlineKeyboardButton(text="✅ Да", callback_data=confirm_callback),
|
||||
InlineKeyboardButton(text="❌ Нет", callback_data=cancel_callback)
|
||||
]
|
||||
])
|
||||
return keyboard
|
||||
|
||||
def pagination_keyboard(page: int, total_pages: int, prefix: str, lang: str = 'ru') -> InlineKeyboardMarkup:
|
||||
"""Beautiful pagination keyboard"""
|
||||
buttons = []
|
||||
|
||||
if total_pages > 1:
|
||||
nav_buttons = []
|
||||
if page > 1:
|
||||
nav_buttons.append(InlineKeyboardButton(text="⬅️ Назад", callback_data=f"{prefix}_page_{page-1}"))
|
||||
|
||||
nav_buttons.append(InlineKeyboardButton(text=f"📄 {page}/{total_pages}", callback_data="noop"))
|
||||
|
||||
if page < total_pages:
|
||||
nav_buttons.append(InlineKeyboardButton(text="Вперед ➡️", callback_data=f"{prefix}_page_{page+1}"))
|
||||
|
||||
buttons.append(nav_buttons)
|
||||
|
||||
buttons.append([InlineKeyboardButton(text="🔙 " + t('back', lang), callback_data="admin_panel")])
|
||||
return InlineKeyboardMarkup(inline_keyboard=buttons)
|
||||
|
||||
def admin_subscriptions_list_keyboard(subs: List[Subscription], lang: str = 'ru') -> InlineKeyboardMarkup:
|
||||
"""Beautiful keyboard for admin subscriptions list with enhanced controls"""
|
||||
buttons = []
|
||||
for sub in subs:
|
||||
status_emoji = "🟢" if sub.is_active else "🔴"
|
||||
price = f"{sub.price:.0f}₽"
|
||||
|
||||
# Основная кнопка с информацией о подписке
|
||||
buttons.append([
|
||||
InlineKeyboardButton(
|
||||
text=f"{status_emoji} {sub.name} — {price}",
|
||||
callback_data=f"list_sub_{sub.id}"
|
||||
)
|
||||
])
|
||||
|
||||
# Кнопки управления для каждой подписки в одну строку
|
||||
control_buttons = [
|
||||
InlineKeyboardButton(text="✏️", callback_data=f"edit_sub_{sub.id}"),
|
||||
InlineKeyboardButton(
|
||||
text="🟢" if not sub.is_active else "🔴",
|
||||
callback_data=f"toggle_sub_{sub.id}"
|
||||
),
|
||||
InlineKeyboardButton(text="🗑", callback_data=f"delete_sub_{sub.id}")
|
||||
]
|
||||
|
||||
buttons.append(control_buttons)
|
||||
|
||||
# Кнопка создания новой подписки
|
||||
buttons.append([InlineKeyboardButton(text="➕ Создать подписку", callback_data="create_subscription")])
|
||||
buttons.append([InlineKeyboardButton(text="🔙 " + t('back', lang), callback_data="admin_subscriptions")])
|
||||
return InlineKeyboardMarkup(inline_keyboard=buttons)
|
||||
|
||||
def admin_payment_keyboard(payment_id: int, lang: str = 'ru') -> InlineKeyboardMarkup:
|
||||
"""Beautiful keyboard for admin payment approval"""
|
||||
keyboard = InlineKeyboardMarkup(inline_keyboard=[
|
||||
[
|
||||
InlineKeyboardButton(text="✅ Одобрить платеж", callback_data=f"approve_payment_{payment_id}"),
|
||||
InlineKeyboardButton(text="❌ Отклонить", callback_data=f"reject_payment_{payment_id}")
|
||||
]
|
||||
])
|
||||
return keyboard
|
||||
|
||||
def admin_messages_keyboard(lang: str = 'ru') -> InlineKeyboardMarkup:
|
||||
"""Beautiful admin messages management keyboard"""
|
||||
keyboard = InlineKeyboardMarkup(inline_keyboard=[
|
||||
[InlineKeyboardButton(text="👤 " + t('send_to_user', lang), callback_data="admin_send_to_user")],
|
||||
[InlineKeyboardButton(text="📢 " + t('send_to_all', lang), callback_data="admin_send_to_all")],
|
||||
[InlineKeyboardButton(text="🔙 " + t('back', lang), callback_data="admin_panel")]
|
||||
])
|
||||
return keyboard
|
||||
|
||||
def quick_topup_keyboard(lang: str = 'ru') -> InlineKeyboardMarkup:
|
||||
"""Quick topup amounts keyboard"""
|
||||
keyboard = InlineKeyboardMarkup(inline_keyboard=[
|
||||
[
|
||||
InlineKeyboardButton(text="💳 100₽", callback_data="quick_topup_100"),
|
||||
InlineKeyboardButton(text="💳 300₽", callback_data="quick_topup_300")
|
||||
],
|
||||
[
|
||||
InlineKeyboardButton(text="💳 500₽", callback_data="quick_topup_500"),
|
||||
InlineKeyboardButton(text="💳 1000₽", callback_data="quick_topup_1000")
|
||||
],
|
||||
[InlineKeyboardButton(text="💰 Другая сумма", callback_data="topup_support")],
|
||||
[InlineKeyboardButton(text="🔙 " + t('back', lang), callback_data="balance")]
|
||||
])
|
||||
return keyboard
|
||||
|
||||
def connection_keyboard(subscription_url: str, lang: str = 'ru') -> InlineKeyboardMarkup:
|
||||
"""Beautiful connection keyboard with web app"""
|
||||
keyboard = InlineKeyboardMarkup(inline_keyboard=[
|
||||
[InlineKeyboardButton(text="🔗 Подключиться", url=subscription_url)],
|
||||
[InlineKeyboardButton(text="📱 Инструкция", callback_data="connection_guide")],
|
||||
[InlineKeyboardButton(text="🔙 " + t('back', lang), callback_data="my_subscriptions")]
|
||||
])
|
||||
return keyboard
|
||||
|
||||
def trial_subscription_keyboard(lang: str = 'ru') -> InlineKeyboardMarkup:
|
||||
"""Trial subscription confirmation keyboard"""
|
||||
buttons = [
|
||||
[InlineKeyboardButton(text="✅ Получить тестовую подписку", callback_data="confirm_trial")],
|
||||
[InlineKeyboardButton(text=t('back', lang), callback_data="main_menu")]
|
||||
]
|
||||
return InlineKeyboardMarkup(inline_keyboard=buttons)
|
||||
|
||||
def admin_monitor_keyboard(lang: str = 'ru') -> InlineKeyboardMarkup:
|
||||
"""Beautiful admin monitor management keyboard"""
|
||||
keyboard = InlineKeyboardMarkup(inline_keyboard=[
|
||||
[InlineKeyboardButton(text="📊 Статус сервиса", callback_data="monitor_status")],
|
||||
[InlineKeyboardButton(text="🔄 Принудительная проверка", callback_data="monitor_force_check")],
|
||||
[InlineKeyboardButton(text="⚰️ Деактивировать истекшие", callback_data="monitor_deactivate_expired")],
|
||||
[InlineKeyboardButton(text="👤 Тест для пользователя", callback_data="monitor_test_user")],
|
||||
[InlineKeyboardButton(text="🔙 " + t('back', lang), callback_data="admin_panel")]
|
||||
])
|
||||
return keyboard
|
||||
|
||||
def admin_system_keyboard(lang: str = 'ru') -> InlineKeyboardMarkup:
|
||||
"""Beautiful admin system management keyboard"""
|
||||
keyboard = InlineKeyboardMarkup(inline_keyboard=[
|
||||
[InlineKeyboardButton(text="📊 Системная статистика", callback_data="system_stats")],
|
||||
[InlineKeyboardButton(text="🖥 Управление нодами", callback_data="nodes_management")],
|
||||
[InlineKeyboardButton(text="👥 Пользователи системы", callback_data="system_users")],
|
||||
[InlineKeyboardButton(text="🔄 Синхронизация с RemnaWave", callback_data="sync_remnawave")],
|
||||
[InlineKeyboardButton(text="🔍 Отладка API", callback_data="debug_api_comprehensive")],
|
||||
[InlineKeyboardButton(text="🔙 " + t('back', lang), callback_data="admin_panel")]
|
||||
])
|
||||
return keyboard
|
||||
|
||||
def system_stats_keyboard(lang: str = 'ru') -> InlineKeyboardMarkup:
|
||||
"""System statistics keyboard with refresh"""
|
||||
keyboard = InlineKeyboardMarkup(inline_keyboard=[
|
||||
[InlineKeyboardButton(text="🔄 Обновить статистику", callback_data="refresh_system_stats")],
|
||||
[InlineKeyboardButton(text="🖥 Ноды", callback_data="nodes_management")],
|
||||
[InlineKeyboardButton(text="👥 Системные пользователи", callback_data="system_users")],
|
||||
[InlineKeyboardButton(text="🔙 Назад", callback_data="admin_system")]
|
||||
])
|
||||
return keyboard
|
||||
|
||||
def nodes_management_keyboard(nodes: List[Dict], lang: str = 'ru', timestamp: int = None) -> InlineKeyboardMarkup:
|
||||
"""Improved nodes management keyboard"""
|
||||
buttons = []
|
||||
|
||||
if nodes:
|
||||
# Statistics row
|
||||
online_count = len([n for n in nodes if n.get('status') == 'online'])
|
||||
total_count = len(nodes)
|
||||
|
||||
buttons.append([
|
||||
InlineKeyboardButton(
|
||||
text=f"📊 Ноды: {online_count}/{total_count} онлайн",
|
||||
callback_data="noop"
|
||||
)
|
||||
])
|
||||
|
||||
# Show first 5 nodes with improved display
|
||||
for i, node in enumerate(nodes[:5]):
|
||||
status = node.get('status', 'unknown')
|
||||
|
||||
# Status emoji based on actual status
|
||||
if status == 'online':
|
||||
status_emoji = "🟢"
|
||||
elif status == 'disabled':
|
||||
status_emoji = "⚫"
|
||||
elif status == 'disconnected':
|
||||
status_emoji = "🔴"
|
||||
elif status == 'xray_stopped':
|
||||
status_emoji = "🟡"
|
||||
else:
|
||||
status_emoji = "⚪"
|
||||
|
||||
node_name = node.get('name', f'Node-{i+1}')
|
||||
node_id = node.get('id', node.get('uuid'))
|
||||
|
||||
# Truncate long names
|
||||
if len(node_name) > 20:
|
||||
display_name = node_name[:17] + "..."
|
||||
else:
|
||||
display_name = node_name
|
||||
|
||||
# CPU/Memory usage if available
|
||||
usage_info = ""
|
||||
if node.get('cpuUsage'):
|
||||
usage_info += f" CPU:{node['cpuUsage']:.0f}%"
|
||||
if node.get('memUsage'):
|
||||
usage_info += f" MEM:{node['memUsage']:.0f}%"
|
||||
|
||||
buttons.append([
|
||||
InlineKeyboardButton(
|
||||
text=f"{status_emoji} {display_name}{usage_info}",
|
||||
callback_data=f"node_details_{node_id}"
|
||||
),
|
||||
InlineKeyboardButton(
|
||||
text="🔄",
|
||||
callback_data=f"restart_node_{node_id}"
|
||||
),
|
||||
InlineKeyboardButton(
|
||||
text="⚙️",
|
||||
callback_data=f"node_settings_{node_id}"
|
||||
)
|
||||
])
|
||||
|
||||
if len(nodes) > 5:
|
||||
buttons.append([
|
||||
InlineKeyboardButton(
|
||||
text=f"... и еще {len(nodes) - 5} нод",
|
||||
callback_data="show_all_nodes"
|
||||
)
|
||||
])
|
||||
else:
|
||||
buttons.append([
|
||||
InlineKeyboardButton(
|
||||
text="❌ Ноды не найдены",
|
||||
callback_data="noop"
|
||||
)
|
||||
])
|
||||
|
||||
# Action buttons
|
||||
buttons.append([
|
||||
InlineKeyboardButton(text="🔄 Перезагрузить все", callback_data="restart_all_nodes"),
|
||||
InlineKeyboardButton(text="📊 Статистика", callback_data="nodes_statistics")
|
||||
])
|
||||
|
||||
# Refresh button
|
||||
refresh_callback = f"refresh_nodes_stats_{timestamp}" if timestamp else "refresh_nodes_stats"
|
||||
buttons.append([
|
||||
InlineKeyboardButton(text="🔄 Обновить", callback_data=refresh_callback)
|
||||
])
|
||||
|
||||
# Back button
|
||||
buttons.append([
|
||||
InlineKeyboardButton(text="🔙 Назад", callback_data="admin_system")
|
||||
])
|
||||
|
||||
return InlineKeyboardMarkup(inline_keyboard=buttons)
|
||||
|
||||
def system_users_keyboard(lang: str = 'ru') -> InlineKeyboardMarkup:
|
||||
"""System users management keyboard - ИСПРАВЛЕНО"""
|
||||
keyboard = InlineKeyboardMarkup(inline_keyboard=[
|
||||
[InlineKeyboardButton(text="📊 Статистика пользователей", callback_data="users_statistics")],
|
||||
[InlineKeyboardButton(text="👥 Список всех пользователей", callback_data="list_all_system_users")],
|
||||
[InlineKeyboardButton(text="🔍 Поиск пользователя", callback_data="search_user_uuid")],
|
||||
[InlineKeyboardButton(text="🔍 Отладка API пользователей", callback_data="debug_users_api")],
|
||||
[InlineKeyboardButton(text="🔙 " + t('back', lang), callback_data="admin_system")]
|
||||
])
|
||||
return keyboard
|
||||
|
||||
def bulk_operations_keyboard(lang: str = 'ru') -> InlineKeyboardMarkup:
|
||||
"""Bulk operations keyboard"""
|
||||
keyboard = InlineKeyboardMarkup(inline_keyboard=[
|
||||
[InlineKeyboardButton(text="🔄 Сбросить трафик", callback_data="bulk_reset_traffic")],
|
||||
[InlineKeyboardButton(text="❌ Отключить пользователей", callback_data="bulk_disable_users")],
|
||||
[InlineKeyboardButton(text="✅ Включить пользователей", callback_data="bulk_enable_users")],
|
||||
[InlineKeyboardButton(text="🗑 Удалить пользователей", callback_data="bulk_delete_users")],
|
||||
[InlineKeyboardButton(text="🔙 " + t('back', lang), callback_data="system_users")]
|
||||
])
|
||||
return keyboard
|
||||
|
||||
def confirm_restart_keyboard(node_id: str = None, lang: str = 'ru') -> InlineKeyboardMarkup:
|
||||
"""Confirmation keyboard for node restart"""
|
||||
action = f"confirm_restart_node_{node_id}" if node_id else "confirm_restart_all_nodes"
|
||||
back_action = f"node_details_{node_id}" if node_id else "nodes_management"
|
||||
|
||||
keyboard = InlineKeyboardMarkup(inline_keyboard=[
|
||||
[
|
||||
InlineKeyboardButton(text="✅ Да, перезагрузить", callback_data=action),
|
||||
InlineKeyboardButton(text="❌ Отмена", callback_data=back_action)
|
||||
]
|
||||
])
|
||||
return keyboard
|
||||
@@ -2,254 +2,217 @@ import asyncio
|
||||
import logging
|
||||
import sys
|
||||
import os
|
||||
from aiogram import Bot, Dispatcher
|
||||
from aiogram.fsm.storage.memory import MemoryStorage
|
||||
from aiogram.client.default import DefaultBotProperties
|
||||
from aiogram.enums import ParseMode
|
||||
import signal
|
||||
from pathlib import Path
|
||||
|
||||
# Import our modules
|
||||
from config import load_config
|
||||
from database import Database
|
||||
from remnawave_api import RemnaWaveAPI
|
||||
from subscription_monitor import create_subscription_monitor
|
||||
from middlewares import DatabaseMiddleware, UserMiddleware, LoggingMiddleware, ThrottlingMiddleware, WorkflowDataMiddleware, BotMiddleware
|
||||
from handlers import router
|
||||
from admin_handlers import admin_router
|
||||
sys.path.append(str(Path(__file__).parent))
|
||||
|
||||
# Configure logging
|
||||
logging.basicConfig(
|
||||
level=logging.INFO,
|
||||
format='%(asctime)s - %(name)s - %(levelname)s - %(message)s',
|
||||
handlers=[
|
||||
logging.FileHandler('bot.log'),
|
||||
logging.StreamHandler(sys.stdout)
|
||||
]
|
||||
)
|
||||
logger = logging.getLogger(__name__)
|
||||
from app.bot import setup_bot
|
||||
from app.config import settings
|
||||
from app.database.database import init_db
|
||||
from app.services.monitoring_service import monitoring_service
|
||||
from app.services.maintenance_service import maintenance_service
|
||||
from app.services.payment_service import PaymentService
|
||||
from app.external.webhook_server import WebhookServer
|
||||
from app.external.yookassa_webhook import start_yookassa_webhook_server
|
||||
from app.database.universal_migration import run_universal_migration
|
||||
|
||||
class BotApplication:
|
||||
"""Main bot application class"""
|
||||
|
||||
class GracefulExit:
|
||||
|
||||
def __init__(self):
|
||||
self.config = None
|
||||
self.db = None
|
||||
self.api = None
|
||||
self.bot = None
|
||||
self.dp = None
|
||||
self.monitor_service = None
|
||||
self.exit = False
|
||||
|
||||
async def initialize(self):
|
||||
"""Initialize all components"""
|
||||
# Load configuration
|
||||
self.config = load_config()
|
||||
|
||||
# Validate required environment variables
|
||||
if not self.config.BOT_TOKEN:
|
||||
logger.error("BOT_TOKEN is required")
|
||||
raise ValueError("BOT_TOKEN is required")
|
||||
|
||||
if not self.config.REMNAWAVE_URL or not self.config.REMNAWAVE_TOKEN:
|
||||
logger.error("REMNAWAVE_URL and REMNAWAVE_TOKEN are required")
|
||||
raise ValueError("REMNAWAVE_URL and REMNAWAVE_TOKEN are required")
|
||||
|
||||
logger.info("Starting RemnaWave Bot...")
|
||||
logger.info(f"RemnaWave URL: {self.config.REMNAWAVE_URL}")
|
||||
logger.info(f"Admin IDs: {self.config.ADMIN_IDS}")
|
||||
|
||||
# Initialize database
|
||||
self.db = Database(self.config.DATABASE_URL)
|
||||
await self._init_database()
|
||||
|
||||
# Initialize RemnaWave API
|
||||
self.api = RemnaWaveAPI(
|
||||
self.config.REMNAWAVE_URL,
|
||||
self.config.REMNAWAVE_TOKEN,
|
||||
self.config.SUBSCRIPTION_BASE_URL
|
||||
)
|
||||
logger.info("RemnaWave API initialized")
|
||||
|
||||
# Test API connection (optional - don't fail if it doesn't work)
|
||||
await self._test_api_connection()
|
||||
|
||||
# Initialize bot and dispatcher
|
||||
self.bot = Bot(
|
||||
token=self.config.BOT_TOKEN,
|
||||
default=DefaultBotProperties(parse_mode=ParseMode.HTML)
|
||||
)
|
||||
|
||||
# Test bot token
|
||||
await self._test_bot_token()
|
||||
|
||||
# Initialize dispatcher
|
||||
self._setup_dispatcher()
|
||||
|
||||
# Initialize subscription monitor service
|
||||
await self._init_monitor_service()
|
||||
|
||||
async def _init_database(self):
|
||||
"""Initialize database with retry logic"""
|
||||
max_retries = 3
|
||||
for attempt in range(max_retries):
|
||||
try:
|
||||
await self.db.init_db()
|
||||
logger.info("Database initialized successfully")
|
||||
break
|
||||
except Exception as e:
|
||||
logger.error(f"Database initialization attempt {attempt + 1} failed: {e}")
|
||||
if attempt == max_retries - 1:
|
||||
logger.error("Failed to initialize database after all retries")
|
||||
raise
|
||||
await asyncio.sleep(2) # Wait before retry
|
||||
|
||||
async def _test_api_connection(self):
|
||||
"""Test API connection"""
|
||||
try:
|
||||
system_stats = await self.api.get_system_stats()
|
||||
if system_stats:
|
||||
logger.info("RemnaWave API connection successful")
|
||||
else:
|
||||
logger.warning("RemnaWave API connection test failed - continuing anyway")
|
||||
except Exception as e:
|
||||
logger.warning(f"RemnaWave API connection error: {e} - continuing anyway")
|
||||
|
||||
async def _test_bot_token(self):
|
||||
"""Test bot token before starting"""
|
||||
try:
|
||||
bot_info = await self.bot.get_me()
|
||||
logger.info(f"Bot started: @{bot_info.username} ({bot_info.first_name})")
|
||||
except Exception as e:
|
||||
logger.error(f"Invalid bot token or network error: {e}")
|
||||
raise
|
||||
|
||||
def _setup_dispatcher(self):
|
||||
"""Setup dispatcher with middlewares and routers"""
|
||||
storage = MemoryStorage()
|
||||
self.dp = Dispatcher(storage=storage)
|
||||
|
||||
# Store config, api, db, and monitor_service in dispatcher workflow_data for access in handlers
|
||||
self.dp.workflow_data.update({
|
||||
"config": self.config,
|
||||
"api": self.api,
|
||||
"db": self.db,
|
||||
"monitor_service": None # Will be updated after monitor service is created
|
||||
})
|
||||
|
||||
# Setup middlewares in correct order
|
||||
self.dp.message.middleware(LoggingMiddleware())
|
||||
self.dp.callback_query.middleware(LoggingMiddleware())
|
||||
|
||||
self.dp.message.middleware(ThrottlingMiddleware(rate_limit=0.5))
|
||||
self.dp.callback_query.middleware(ThrottlingMiddleware(rate_limit=0.3))
|
||||
|
||||
self.dp.message.middleware(WorkflowDataMiddleware())
|
||||
self.dp.callback_query.middleware(WorkflowDataMiddleware())
|
||||
|
||||
self.dp.message.middleware(BotMiddleware(self.bot))
|
||||
self.dp.callback_query.middleware(BotMiddleware(self.bot))
|
||||
|
||||
self.dp.message.middleware(DatabaseMiddleware(self.db))
|
||||
self.dp.callback_query.middleware(DatabaseMiddleware(self.db))
|
||||
|
||||
self.dp.message.middleware(UserMiddleware(self.db, self.config))
|
||||
self.dp.callback_query.middleware(UserMiddleware(self.db, self.config))
|
||||
|
||||
# Register routers
|
||||
self.dp.include_router(router)
|
||||
self.dp.include_router(admin_router)
|
||||
|
||||
async def _init_monitor_service(self):
|
||||
"""Initialize subscription monitor service"""
|
||||
try:
|
||||
self.monitor_service = await create_subscription_monitor(
|
||||
self.bot, self.db, self.config, self.api
|
||||
)
|
||||
|
||||
# Update workflow_data with monitor service
|
||||
self.dp.workflow_data["monitor_service"] = self.monitor_service
|
||||
|
||||
# Start the monitor service
|
||||
await self.monitor_service.start()
|
||||
logger.info("Subscription monitor service started successfully")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to initialize monitor service: {e}")
|
||||
# Don't fail the entire application if monitor service fails
|
||||
logger.warning("Continuing without monitor service")
|
||||
self.monitor_service = None
|
||||
|
||||
async def start(self):
|
||||
"""Start bot polling"""
|
||||
logger.info("Bot polling started successfully")
|
||||
try:
|
||||
await self.dp.start_polling(self.bot)
|
||||
except Exception as e:
|
||||
logger.error(f"Error during polling: {e}")
|
||||
raise
|
||||
finally:
|
||||
await self.shutdown()
|
||||
|
||||
async def shutdown(self):
|
||||
"""Shutdown all services"""
|
||||
logger.info("Shutting down bot...")
|
||||
|
||||
# Stop monitor service first
|
||||
if self.monitor_service:
|
||||
try:
|
||||
await self.monitor_service.stop()
|
||||
logger.info("Monitor service stopped")
|
||||
except Exception as e:
|
||||
logger.error(f"Error stopping monitor service: {e}")
|
||||
|
||||
# Close API connection
|
||||
if self.api:
|
||||
try:
|
||||
await self.api.close()
|
||||
logger.info("API connection closed")
|
||||
except Exception as e:
|
||||
logger.error(f"Error closing API: {e}")
|
||||
|
||||
# Close database connection
|
||||
if self.db:
|
||||
try:
|
||||
await self.db.close()
|
||||
logger.info("Database connection closed")
|
||||
except Exception as e:
|
||||
logger.error(f"Error closing database: {e}")
|
||||
|
||||
# Close bot session
|
||||
if self.bot:
|
||||
try:
|
||||
await self.bot.session.close()
|
||||
logger.info("Bot session closed")
|
||||
except Exception as e:
|
||||
logger.error(f"Error closing bot session: {e}")
|
||||
|
||||
logger.info("Bot shutdown complete")
|
||||
def exit_gracefully(self, signum, frame):
|
||||
logging.getLogger(__name__).info(f"Получен сигнал {signum}. Корректное завершение работы...")
|
||||
self.exit = True
|
||||
|
||||
|
||||
async def main():
|
||||
"""Main function"""
|
||||
app = None
|
||||
logging.basicConfig(
|
||||
level=getattr(logging, settings.LOG_LEVEL),
|
||||
format='%(asctime)s - %(name)s - %(levelname)s - %(message)s',
|
||||
handlers=[
|
||||
logging.FileHandler(settings.LOG_FILE, encoding='utf-8'),
|
||||
logging.StreamHandler(sys.stdout)
|
||||
]
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
logger.info("🚀 Запуск Bedolaga Remnawave Bot...")
|
||||
|
||||
killer = GracefulExit()
|
||||
signal.signal(signal.SIGINT, killer.exit_gracefully)
|
||||
signal.signal(signal.SIGTERM, killer.exit_gracefully)
|
||||
|
||||
webhook_server = None
|
||||
yookassa_server_task = None
|
||||
monitoring_task = None
|
||||
maintenance_task = None
|
||||
polling_task = None
|
||||
|
||||
try:
|
||||
app = BotApplication()
|
||||
await app.initialize()
|
||||
await app.start()
|
||||
logger.info("📊 Инициализация базы данных...")
|
||||
await init_db()
|
||||
|
||||
except KeyboardInterrupt:
|
||||
logger.info("Bot stopped by user (Ctrl+C)")
|
||||
skip_migration = os.getenv('SKIP_MIGRATION', 'false').lower() == 'true'
|
||||
|
||||
if not skip_migration:
|
||||
logger.info("🔧 Выполняем проверку и миграцию базы данных...")
|
||||
try:
|
||||
migration_success = await run_universal_migration()
|
||||
|
||||
if migration_success:
|
||||
logger.info("✅ Миграция базы данных завершена успешно")
|
||||
else:
|
||||
logger.warning("⚠️ Миграция завершилась с предупреждениями, но продолжаем запуск")
|
||||
|
||||
except Exception as migration_error:
|
||||
logger.error(f"❌ Ошибка выполнения миграции: {migration_error}")
|
||||
logger.warning("⚠️ Продолжаем запуск без миграции")
|
||||
else:
|
||||
logger.info("ℹ️ Миграция пропущена (SKIP_MIGRATION=true)")
|
||||
|
||||
logger.info("🤖 Настройка бота...")
|
||||
bot, dp = await setup_bot()
|
||||
|
||||
monitoring_service.bot = bot
|
||||
maintenance_service.set_bot(bot)
|
||||
logger.info("🔗 Бот подключен к сервисам мониторинга и техработ")
|
||||
|
||||
payment_service = PaymentService(bot)
|
||||
|
||||
if settings.TRIBUTE_ENABLED:
|
||||
logger.info("🌐 Запуск Tribute webhook сервера...")
|
||||
webhook_server = WebhookServer(bot)
|
||||
await webhook_server.start()
|
||||
else:
|
||||
logger.info("ℹ️ Tribute отключен, webhook сервер не запускается")
|
||||
|
||||
if settings.is_yookassa_enabled():
|
||||
logger.info("💳 Запуск YooKassa webhook сервера...")
|
||||
yookassa_server_task = asyncio.create_task(
|
||||
start_yookassa_webhook_server(payment_service)
|
||||
)
|
||||
else:
|
||||
logger.info("ℹ️ YooKassa отключена, webhook сервер не запускается")
|
||||
|
||||
logger.info("📊 Запуск службы мониторинга...")
|
||||
monitoring_task = asyncio.create_task(monitoring_service.start_monitoring())
|
||||
|
||||
logger.info("🔧 Проверка службы техработ...")
|
||||
if not maintenance_service._check_task or maintenance_service._check_task.done():
|
||||
logger.info("🔧 Запуск службы техработ...")
|
||||
maintenance_task = asyncio.create_task(maintenance_service.start_monitoring())
|
||||
else:
|
||||
logger.info("🔧 Служба техработ уже запущена")
|
||||
maintenance_task = None
|
||||
|
||||
logger.info("🔄 Запуск polling...")
|
||||
polling_task = asyncio.create_task(dp.start_polling(bot, skip_updates=True))
|
||||
|
||||
logger.info("=" * 50)
|
||||
logger.info("🎯 Активные webhook endpoints:")
|
||||
if settings.TRIBUTE_ENABLED:
|
||||
logger.info(f" Tribute: {settings.WEBHOOK_URL}:{settings.TRIBUTE_WEBHOOK_PORT}{settings.TRIBUTE_WEBHOOK_PATH}")
|
||||
if settings.is_yookassa_enabled():
|
||||
logger.info(f" YooKassa: {settings.WEBHOOK_URL}:{settings.YOOKASSA_WEBHOOK_PORT}{settings.YOOKASSA_WEBHOOK_PATH}")
|
||||
logger.info("=" * 50)
|
||||
|
||||
try:
|
||||
while not killer.exit:
|
||||
await asyncio.sleep(1)
|
||||
|
||||
if yookassa_server_task and yookassa_server_task.done():
|
||||
exception = yookassa_server_task.exception()
|
||||
if exception:
|
||||
logger.error(f"YooKassa webhook сервер завершился с ошибкой: {exception}")
|
||||
logger.info("🔄 Перезапуск YooKassa webhook сервера...")
|
||||
yookassa_server_task = asyncio.create_task(
|
||||
start_yookassa_webhook_server(payment_service)
|
||||
)
|
||||
|
||||
if monitoring_task.done():
|
||||
exception = monitoring_task.exception()
|
||||
if exception:
|
||||
logger.error(f"Служба мониторинга завершилась с ошибкой: {exception}")
|
||||
monitoring_task = asyncio.create_task(monitoring_service.start_monitoring())
|
||||
|
||||
if maintenance_task and maintenance_task.done():
|
||||
exception = maintenance_task.exception()
|
||||
if exception:
|
||||
logger.error(f"Служба техработ завершилась с ошибкой: {exception}")
|
||||
maintenance_task = asyncio.create_task(maintenance_service.start_monitoring())
|
||||
|
||||
if polling_task.done():
|
||||
exception = polling_task.exception()
|
||||
if exception:
|
||||
logger.error(f"Polling завершился с ошибкой: {exception}")
|
||||
break
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка в основном цикле: {e}")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Critical error in main: {e}")
|
||||
import traceback
|
||||
traceback.print_exc()
|
||||
logger.error(f"❌ Критическая ошибка при запуске: {e}")
|
||||
raise
|
||||
|
||||
finally:
|
||||
if app:
|
||||
await app.shutdown()
|
||||
logger.info("🛑 Начинается корректное завершение работы...")
|
||||
|
||||
if yookassa_server_task and not yookassa_server_task.done():
|
||||
logger.info("ℹ️ Остановка YooKassa webhook сервера...")
|
||||
yookassa_server_task.cancel()
|
||||
try:
|
||||
await yookassa_server_task
|
||||
except asyncio.CancelledError:
|
||||
pass
|
||||
|
||||
if monitoring_task and not monitoring_task.done():
|
||||
logger.info("ℹ️ Остановка службы мониторинга...")
|
||||
monitoring_service.stop_monitoring()
|
||||
monitoring_task.cancel()
|
||||
try:
|
||||
await monitoring_task
|
||||
except asyncio.CancelledError:
|
||||
pass
|
||||
|
||||
if maintenance_task and not maintenance_task.done():
|
||||
logger.info("ℹ️ Остановка службы техработ...")
|
||||
await maintenance_service.stop_monitoring()
|
||||
maintenance_task.cancel()
|
||||
try:
|
||||
await maintenance_task
|
||||
except asyncio.CancelledError:
|
||||
pass
|
||||
|
||||
if polling_task and not polling_task.done():
|
||||
logger.info("ℹ️ Остановка polling...")
|
||||
polling_task.cancel()
|
||||
try:
|
||||
await polling_task
|
||||
except asyncio.CancelledError:
|
||||
pass
|
||||
|
||||
if webhook_server:
|
||||
logger.info("ℹ️ Остановка Tribute webhook сервера...")
|
||||
await webhook_server.stop()
|
||||
|
||||
if 'bot' in locals():
|
||||
try:
|
||||
await bot.session.close()
|
||||
logger.info("✅ Сессия бота закрыта")
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка закрытия сессии бота: {e}")
|
||||
|
||||
logger.info("✅ Завершение работы бота завершено")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
try:
|
||||
asyncio.run(main())
|
||||
except KeyboardInterrupt:
|
||||
logger.info("Bot stopped by user")
|
||||
print("\n🛑 Бот остановлен пользователем")
|
||||
except Exception as e:
|
||||
logger.error(f"Fatal error: {e}")
|
||||
print(f"❌ Критическая ошибка: {e}")
|
||||
sys.exit(1)
|
||||
|
||||
-199
@@ -1,199 +0,0 @@
|
||||
from aiogram import BaseMiddleware
|
||||
from aiogram.types import TelegramObject, User as TgUser, Message, CallbackQuery
|
||||
from typing import Callable, Dict, Any, Awaitable
|
||||
import logging
|
||||
from database import Database, User
|
||||
from config import Config
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
class DatabaseMiddleware(BaseMiddleware):
|
||||
"""Middleware for database operations"""
|
||||
|
||||
def __init__(self, db: Database):
|
||||
self.db = db
|
||||
|
||||
async def __call__(
|
||||
self,
|
||||
handler: Callable[[TelegramObject, Dict[str, Any]], Awaitable[Any]],
|
||||
event: TelegramObject,
|
||||
data: Dict[str, Any]
|
||||
) -> Any:
|
||||
data['db'] = self.db
|
||||
return await handler(event, data)
|
||||
|
||||
class UserMiddleware(BaseMiddleware):
|
||||
"""Middleware for user management"""
|
||||
|
||||
def __init__(self, db: Database, config: Config):
|
||||
self.db = db
|
||||
self.config = config
|
||||
|
||||
async def __call__(
|
||||
self,
|
||||
handler: Callable[[TelegramObject, Dict[str, Any]], Awaitable[Any]],
|
||||
event: TelegramObject,
|
||||
data: Dict[str, Any]
|
||||
) -> Any:
|
||||
telegram_user: TgUser = data.get('event_from_user')
|
||||
|
||||
if telegram_user and not telegram_user.is_bot:
|
||||
try:
|
||||
# Get or create user
|
||||
user = await self.db.get_user_by_telegram_id(telegram_user.id)
|
||||
|
||||
if not user:
|
||||
# Check if user is admin
|
||||
is_admin = telegram_user.id in self.config.ADMIN_IDS
|
||||
|
||||
# Create new user
|
||||
user = await self.db.create_user(
|
||||
telegram_id=telegram_user.id,
|
||||
username=telegram_user.username,
|
||||
first_name=telegram_user.first_name,
|
||||
last_name=telegram_user.last_name,
|
||||
language=self.config.DEFAULT_LANGUAGE,
|
||||
is_admin=is_admin
|
||||
)
|
||||
logger.info(f"Created new user: {telegram_user.id}")
|
||||
else:
|
||||
# Update user info if changed
|
||||
updated = False
|
||||
if user.username != telegram_user.username:
|
||||
user.username = telegram_user.username
|
||||
updated = True
|
||||
if user.first_name != telegram_user.first_name:
|
||||
user.first_name = telegram_user.first_name
|
||||
updated = True
|
||||
if user.last_name != telegram_user.last_name:
|
||||
user.last_name = telegram_user.last_name
|
||||
updated = True
|
||||
|
||||
# Check admin status
|
||||
should_be_admin = telegram_user.id in self.config.ADMIN_IDS
|
||||
if user.is_admin != should_be_admin:
|
||||
user.is_admin = should_be_admin
|
||||
updated = True
|
||||
|
||||
if updated:
|
||||
await self.db.update_user(user)
|
||||
|
||||
data['user'] = user
|
||||
data['lang'] = user.language if user else self.config.DEFAULT_LANGUAGE
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error in UserMiddleware: {e}")
|
||||
# Create a minimal user object with defaults if database fails
|
||||
class FallbackUser:
|
||||
def __init__(self, telegram_id: int, username: str = None, config: Config = None):
|
||||
self.telegram_id = telegram_id
|
||||
self.username = username
|
||||
self.first_name = None
|
||||
self.last_name = None
|
||||
self.language = config.DEFAULT_LANGUAGE if config else 'ru'
|
||||
self.balance = 0.0
|
||||
self.is_admin = telegram_id in (config.ADMIN_IDS if config else [])
|
||||
self.remnawave_uuid = None
|
||||
|
||||
# Try to use fallback, but if database is completely broken, set None
|
||||
try:
|
||||
fallback_user = FallbackUser(telegram_user.id, telegram_user.username, self.config)
|
||||
data['user'] = fallback_user
|
||||
data['lang'] = self.config.DEFAULT_LANGUAGE
|
||||
except:
|
||||
data['user'] = None
|
||||
data['lang'] = self.config.DEFAULT_LANGUAGE
|
||||
else:
|
||||
data['user'] = None
|
||||
data['lang'] = self.config.DEFAULT_LANGUAGE
|
||||
|
||||
return await handler(event, data)
|
||||
|
||||
class LoggingMiddleware(BaseMiddleware):
|
||||
"""Middleware for logging"""
|
||||
|
||||
async def __call__(
|
||||
self,
|
||||
handler: Callable[[TelegramObject, Dict[str, Any]], Awaitable[Any]],
|
||||
event: TelegramObject,
|
||||
data: Dict[str, Any]
|
||||
) -> Any:
|
||||
telegram_user: TgUser = data.get('event_from_user')
|
||||
|
||||
if isinstance(event, Message):
|
||||
username = f"@{telegram_user.username}" if telegram_user and telegram_user.username else "no_username"
|
||||
user_id = telegram_user.id if telegram_user else "unknown"
|
||||
text = event.text or "no_text"
|
||||
logger.info(f"Message from {user_id} ({username}): {text}")
|
||||
elif isinstance(event, CallbackQuery):
|
||||
username = f"@{telegram_user.username}" if telegram_user and telegram_user.username else "no_username"
|
||||
user_id = telegram_user.id if telegram_user else "unknown"
|
||||
callback_data = event.data or "no_data"
|
||||
logger.info(f"Callback from {user_id} ({username}): {callback_data}")
|
||||
|
||||
return await handler(event, data)
|
||||
|
||||
class ThrottlingMiddleware(BaseMiddleware):
|
||||
"""Simple throttling middleware"""
|
||||
|
||||
def __init__(self, rate_limit: float = 1.0):
|
||||
self.rate_limit = rate_limit
|
||||
self.user_last_action = {}
|
||||
|
||||
async def __call__(
|
||||
self,
|
||||
handler: Callable[[TelegramObject, Dict[str, Any]], Awaitable[Any]],
|
||||
event: TelegramObject,
|
||||
data: Dict[str, Any]
|
||||
) -> Any:
|
||||
telegram_user: TgUser = data.get('event_from_user')
|
||||
|
||||
if telegram_user:
|
||||
import time
|
||||
current_time = time.time()
|
||||
user_id = telegram_user.id
|
||||
|
||||
if user_id in self.user_last_action:
|
||||
if current_time - self.user_last_action[user_id] < self.rate_limit:
|
||||
logger.warning(f"Throttling user {user_id}")
|
||||
return
|
||||
|
||||
self.user_last_action[user_id] = current_time
|
||||
|
||||
return await handler(event, data)
|
||||
|
||||
class WorkflowDataMiddleware(BaseMiddleware):
|
||||
"""Middleware to pass workflow data to handlers"""
|
||||
|
||||
async def __call__(
|
||||
self,
|
||||
handler: Callable[[TelegramObject, Dict[str, Any]], Awaitable[Any]],
|
||||
event: TelegramObject,
|
||||
data: Dict[str, Any]
|
||||
) -> Any:
|
||||
# Get dispatcher from event
|
||||
if hasattr(event, 'bot'):
|
||||
dp = getattr(event.bot, '_dispatcher', None)
|
||||
if dp and hasattr(dp, 'workflow_data'):
|
||||
# Add workflow data to handler data
|
||||
workflow_data = dp.workflow_data
|
||||
for key, value in workflow_data.items():
|
||||
if key not in data: # Don't override existing data
|
||||
data[key] = value
|
||||
|
||||
return await handler(event, data)
|
||||
|
||||
class BotMiddleware(BaseMiddleware):
|
||||
"""Middleware to add bot instance to handler data"""
|
||||
|
||||
def __init__(self, bot):
|
||||
self.bot = bot
|
||||
|
||||
async def __call__(
|
||||
self,
|
||||
handler: Callable[[TelegramObject, Dict[str, Any]], Awaitable[Any]],
|
||||
event: TelegramObject,
|
||||
data: Dict[str, Any]
|
||||
) -> Any:
|
||||
data['bot'] = self.bot
|
||||
return await handler(event, data)
|
||||
@@ -0,0 +1,41 @@
|
||||
[alembic]
|
||||
script_location = migrations/alembic
|
||||
prepend_sys_path = .
|
||||
version_path_separator = os
|
||||
sqlalchemy.url = postgresql+asyncpg://vpn_user:your_password@localhost:5432/vpn_bot
|
||||
|
||||
[post_write_hooks]
|
||||
|
||||
[loggers]
|
||||
keys = root,sqlalchemy,alembic
|
||||
|
||||
[handlers]
|
||||
keys = console
|
||||
|
||||
[formatters]
|
||||
keys = generic
|
||||
|
||||
[logger_root]
|
||||
level = WARN
|
||||
handlers = console
|
||||
qualname =
|
||||
|
||||
[logger_sqlalchemy]
|
||||
level = WARN
|
||||
handlers =
|
||||
qualname = sqlalchemy.engine
|
||||
|
||||
[logger_alembic]
|
||||
level = INFO
|
||||
handlers =
|
||||
qualname = alembic
|
||||
|
||||
[handler_console]
|
||||
class = StreamHandler
|
||||
args = (sys.stderr,)
|
||||
level = NOTSET
|
||||
formatter = generic
|
||||
|
||||
[formatter_generic]
|
||||
format = %(levelname)-5.5s [%(name)s] %(message)s
|
||||
datefmt = %H:%M:%S
|
||||
@@ -0,0 +1,67 @@
|
||||
import asyncio
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from logging.config import fileConfig
|
||||
|
||||
from sqlalchemy import pool
|
||||
from sqlalchemy.engine import Connection
|
||||
from sqlalchemy.ext.asyncio import async_engine_from_config
|
||||
|
||||
from alembic import context
|
||||
|
||||
sys.path.append(str(Path(__file__).parent.parent.parent))
|
||||
|
||||
from app.database.models import Base
|
||||
from app.config import settings
|
||||
|
||||
config = context.config
|
||||
|
||||
if config.config_file_name is not None:
|
||||
fileConfig(config.config_file_name)
|
||||
|
||||
target_metadata = Base.metadata
|
||||
|
||||
config.set_main_option("sqlalchemy.url", settings.DATABASE_URL)
|
||||
|
||||
|
||||
def run_migrations_offline() -> None:
|
||||
url = config.get_main_option("sqlalchemy.url")
|
||||
context.configure(
|
||||
url=url,
|
||||
target_metadata=target_metadata,
|
||||
literal_binds=True,
|
||||
dialect_opts={"paramstyle": "named"},
|
||||
)
|
||||
|
||||
with context.begin_transaction():
|
||||
context.run_migrations()
|
||||
|
||||
|
||||
def do_run_migrations(connection: Connection) -> None:
|
||||
context.configure(connection=connection, target_metadata=target_metadata)
|
||||
|
||||
with context.begin_transaction():
|
||||
context.run_migrations()
|
||||
|
||||
|
||||
async def run_async_migrations() -> None:
|
||||
connectable = async_engine_from_config(
|
||||
config.get_section(config.config_ini_section, {}),
|
||||
prefix="sqlalchemy.",
|
||||
poolclass=pool.NullPool,
|
||||
)
|
||||
|
||||
async with connectable.connect() as connection:
|
||||
await connection.run_sync(do_run_migrations)
|
||||
|
||||
await connectable.dispose()
|
||||
|
||||
|
||||
def run_migrations_online() -> None:
|
||||
asyncio.run(run_async_migrations())
|
||||
|
||||
|
||||
if context.is_offline_mode():
|
||||
run_migrations_offline()
|
||||
else:
|
||||
run_migrations_online()
|
||||
@@ -0,0 +1,29 @@
|
||||
"""add_paid_price_to_subscription
|
||||
|
||||
Revision ID: 3d9b35c6bd8f
|
||||
Revises:
|
||||
Create Date: 2025-08-23 08:17:00.563340
|
||||
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = '3d9b35c6bd8f'
|
||||
down_revision: Union[str, None] = None
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade():
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
op.add_column('subscriptions', sa.Column('paid_price_kopeks', sa.Integer(), nullable=False, server_default='0'))
|
||||
# ### end Alembic commands ###
|
||||
|
||||
def downgrade():
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
op.drop_column('subscriptions', 'paid_price_kopeks')
|
||||
# ### end Alembic commands ###
|
||||
-1003
File diff suppressed because it is too large
Load Diff
+22
-33
@@ -1,38 +1,27 @@
|
||||
# Telegram Bot Framework
|
||||
aiogram>=3.4.0
|
||||
# Основные зависимости
|
||||
aiogram==3.7.0
|
||||
aiohttp==3.9.1
|
||||
asyncpg==0.29.0
|
||||
SQLAlchemy==2.0.25
|
||||
alembic==1.13.1
|
||||
aiosqlite==0.19.0
|
||||
|
||||
# Database
|
||||
SQLAlchemy>=2.0.0
|
||||
alembic>=1.12.0
|
||||
# Дополнительные зависимости
|
||||
pydantic==2.5.3
|
||||
pydantic-settings==2.1.0
|
||||
python-dotenv==1.0.0
|
||||
redis==5.0.1
|
||||
|
||||
# PostgreSQL driver
|
||||
asyncpg>=0.28.0
|
||||
psycopg2-binary>=2.9.0
|
||||
# YooKassa SDK
|
||||
yookassa==3.0.0
|
||||
|
||||
# SQLite driver (fallback)
|
||||
aiosqlite>=0.19.0
|
||||
# Логирование и мониторинг
|
||||
structlog==23.2.0
|
||||
|
||||
# HTTP Client
|
||||
aiohttp>=3.8.0
|
||||
aiofiles>=23.0.0
|
||||
# Планировщик задач для техработ
|
||||
APScheduler==3.10.4
|
||||
|
||||
# Redis (optional)
|
||||
redis>=4.5.0
|
||||
aioredis>=2.0.0
|
||||
|
||||
# Utilities
|
||||
python-dotenv>=1.0.0
|
||||
pydantic>=2.0.0
|
||||
pydantic-settings>=2.0.0
|
||||
|
||||
# Logging and monitoring
|
||||
structlog>=23.0.0
|
||||
|
||||
# Date and time
|
||||
python-dateutil>=2.8.0
|
||||
|
||||
# Cryptography
|
||||
cryptography>=42.0.0
|
||||
|
||||
# JSON handling
|
||||
orjson>=3.9.0
|
||||
# Утилиты
|
||||
python-dateutil==2.8.2
|
||||
pytz==2023.4
|
||||
cryptography>=41.0.0
|
||||
|
||||
@@ -1,218 +0,0 @@
|
||||
#!/bin/bash
|
||||
|
||||
# === RemnaWave Bot Setup & Management Script ===
|
||||
|
||||
SERVICE_NAME="remnawave-bot"
|
||||
BOT_FILE="main.py"
|
||||
ENV_FILE=".env"
|
||||
VENV_DIR="venv"
|
||||
|
||||
# Select language
|
||||
echo "🌐 Select language / Выберите язык:"
|
||||
echo "1) English"
|
||||
echo "2) Русский"
|
||||
read -p ">>> " LANG_CHOICE
|
||||
|
||||
case $LANG_CHOICE in
|
||||
2) LANG_CODE="ru" ;;
|
||||
*) LANG_CODE="en" ;;
|
||||
esac
|
||||
|
||||
# === Internationalized messages ===
|
||||
msg() {
|
||||
case $1 in
|
||||
check_python) [[ $LANG_CODE == "ru" ]] && echo "🔍 Проверка Python..." || echo "🔍 Checking Python..." ;;
|
||||
python_missing) [[ $LANG_CODE == "ru" ]] && echo "❌ Python 3 не установлен. Установите Python 3.8+." || echo "❌ Python 3 is not installed. Please install Python 3.8+." ;;
|
||||
version) echo "📋 Python version: $PYTHON_VERSION" ;;
|
||||
creating_venv) [[ $LANG_CODE == "ru" ]] && echo "🔧 Создание виртуального окружения..." || echo "🔧 Creating virtual environment..." ;;
|
||||
activating_venv) [[ $LANG_CODE == "ru" ]] && echo "🔧 Активация виртуального окружения..." || echo "🔧 Activating virtual environment..." ;;
|
||||
upgrading_pip) [[ $LANG_CODE == "ru" ]] && echo "📦 Обновление pip..." || echo "📦 Upgrading pip..." ;;
|
||||
installing_requirements) [[ $LANG_CODE == "ru" ]] && echo "📦 Установка зависимостей..." || echo "📦 Installing requirements..." ;;
|
||||
env_missing) [[ $LANG_CODE == "ru" ]] && echo "⚠️ Файл .env не найден!" || echo "⚠️ .env file not found!" ;;
|
||||
env_created) [[ $LANG_CODE == "ru" ]] && echo "📋 Создан .env файл. Пожалуйста, отредактируйте его." || echo "📋 .env created. Please edit it." ;;
|
||||
validating) [[ $LANG_CODE == "ru" ]] && echo "🔍 Проверка конфигурации..." || echo "🔍 Validating configuration..." ;;
|
||||
config_ok) [[ $LANG_CODE == "ru" ]] && echo "✅ Конфигурация прошла проверку." || echo "✅ Configuration validated successfully." ;;
|
||||
creating_service) [[ $LANG_CODE == "ru" ]] && echo "🔧 Создание systemd-сервиса..." || echo "🔧 Creating systemd service..." ;;
|
||||
service_created) [[ $LANG_CODE == "ru" ]] && echo "✅ Сервис создан и включён." || echo "✅ Service created and enabled." ;;
|
||||
start_prompt) [[ $LANG_CODE == "ru" ]] && echo "🚀 Запуск бота..." || echo "🚀 Starting the bot..." ;;
|
||||
already_running) [[ $LANG_CODE == "ru" ]] && echo "🟢 Бот уже запущен!" || echo "🟢 Bot is already running!" ;;
|
||||
last_logs) [[ $LANG_CODE == "ru" ]] && echo "📄 Последние 20 строк лога:" || echo "📄 Last 20 log lines:" ;;
|
||||
action_menu)
|
||||
[[ $LANG_CODE == "ru" ]] && {
|
||||
echo -e "\nЧто вы хотите сделать?"
|
||||
echo "1) Остановить бота"
|
||||
echo "2) Перезапустить бота"
|
||||
echo "3) Выйти"
|
||||
} || {
|
||||
echo -e "\nWhat would you like to do?"
|
||||
echo "1) Stop the bot"
|
||||
echo "2) Restart the bot"
|
||||
echo "3) Exit"
|
||||
}
|
||||
;;
|
||||
restarting) [[ $LANG_CODE == "ru" ]] && echo "🔄 Перезапуск бота..." || echo "🔄 Restarting bot..." ;;
|
||||
completed)
|
||||
echo ""
|
||||
[[ $LANG_CODE == "ru" ]] && {
|
||||
echo "🎉 Установка завершена!"
|
||||
echo "📋 Возможности бота:"
|
||||
echo " ✅ Мультиязычный интерфейс"
|
||||
echo " ✅ Управление балансом и подписками"
|
||||
echo " ✅ Промокоды и поддержка"
|
||||
echo ""
|
||||
} || {
|
||||
echo "🎉 Installation complete!"
|
||||
echo "📋 Bot Features:"
|
||||
echo " ✅ Multi-language support"
|
||||
echo " ✅ Subscription and balance management"
|
||||
echo " ✅ Promocode system and support"
|
||||
echo ""
|
||||
}
|
||||
;;
|
||||
esac
|
||||
}
|
||||
|
||||
# === Check if bot is running ===
|
||||
if systemctl is-active --quiet "$SERVICE_NAME"; then
|
||||
msg already_running
|
||||
msg last_logs
|
||||
sudo journalctl -u "$SERVICE_NAME" -n 20 --no-pager
|
||||
msg action_menu
|
||||
read -p ">>> " choice
|
||||
case $choice in
|
||||
1)
|
||||
[[ $LANG_CODE == "ru" ]] && echo "🛑 Остановка бота..." || echo "🛑 Stopping the bot..."
|
||||
sudo systemctl stop "$SERVICE_NAME"
|
||||
[[ $LANG_CODE == "ru" ]] && echo "✅ Бот остановлен." || echo "✅ Bot stopped."
|
||||
exit 0
|
||||
;;
|
||||
2)
|
||||
msg restarting
|
||||
sudo systemctl restart "$SERVICE_NAME"
|
||||
[[ $LANG_CODE == "ru" ]] && echo "✅ Бот перезапущен." || echo "✅ Bot restarted."
|
||||
exit 0
|
||||
;;
|
||||
3)
|
||||
[[ $LANG_CODE == "ru" ]] && echo "👋 Выход." || echo "👋 Exiting."
|
||||
exit 0
|
||||
;;
|
||||
*)
|
||||
[[ $LANG_CODE == "ru" ]] && echo "⚠️ Неверный выбор." || echo "⚠️ Invalid choice."
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
fi
|
||||
|
||||
# === Install/Setup ===
|
||||
msg check_python
|
||||
if ! command -v python3 &> /dev/null; then
|
||||
msg python_missing
|
||||
exit 1
|
||||
fi
|
||||
|
||||
PYTHON_VERSION=$(python3 -c 'import sys; print(".".join(map(str, sys.version_info[:2])))')
|
||||
msg version
|
||||
|
||||
if [ ! -d "$VENV_DIR" ]; then
|
||||
msg creating_venv
|
||||
python3 -m venv "$VENV_DIR"
|
||||
fi
|
||||
|
||||
msg activating_venv
|
||||
source "$VENV_DIR/bin/activate"
|
||||
|
||||
msg upgrading_pip
|
||||
pip install --upgrade pip
|
||||
|
||||
msg installing_requirements
|
||||
pip install -r requirements.txt
|
||||
|
||||
# Create .env if needed
|
||||
if [ ! -f "$ENV_FILE" ]; then
|
||||
msg env_missing
|
||||
cat > "$ENV_FILE" <<EOF
|
||||
# Bot Configuration
|
||||
BOT_TOKEN=
|
||||
|
||||
# RemnaWave API Configuration
|
||||
REMNAWAVE_URL=
|
||||
REMNAWAVE_MODE=
|
||||
REMNAWAVE_TOKEN=
|
||||
|
||||
# Admin Configuration (comma-separated list of Telegram user IDs)
|
||||
ADMIN_IDS=
|
||||
|
||||
# Support Configuration
|
||||
SUPPORT_USERNAME=support
|
||||
|
||||
# Database Configuration (optional, defaults to SQLite)
|
||||
# DATABASE_URL=sqlite+aiosqlite:///bot.db
|
||||
|
||||
# Trial Configuration
|
||||
TRIAL_ENABLED=true
|
||||
TRIAL_DURATION_DAYS=3
|
||||
TRIAL_TRAFFIC_GB=2
|
||||
TRIAL_SQUAD_UUID=
|
||||
TRIAL_PRICE=0.0
|
||||
EOF
|
||||
msg env_created
|
||||
echo ""
|
||||
read -p "🔧 Press Enter after configuring .env / Нажмите Enter после настройки .env..."
|
||||
fi
|
||||
|
||||
# Load env
|
||||
export $(grep -v '^#' "$ENV_FILE" | xargs)
|
||||
|
||||
msg validating
|
||||
|
||||
if [[ -z "$BOT_TOKEN" || "$BOT_TOKEN" == "your_telegram_bot_token_here" ]]; then
|
||||
echo "❌ BOT_TOKEN not set properly."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [[ -z "$REMNAWAVE_URL" || "$REMNAWAVE_URL" == "https://your-panel.com" ]]; then
|
||||
echo "❌ REMNAWAVE_URL not set properly."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [[ -z "$REMNAWAVE_TOKEN" || "$REMNAWAVE_TOKEN" == "your_jwt_token_here" ]]; then
|
||||
echo "❌ REMNAWAVE_TOKEN not set properly."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
msg config_ok
|
||||
|
||||
# Create systemd service
|
||||
read -p "🔧 Create systemd service? (y/n): " CREATE_SERVICE
|
||||
if [[ "$CREATE_SERVICE" == "y" || "$CREATE_SERVICE" == "Y" ]]; then
|
||||
msg creating_service
|
||||
SERVICE_FILE="/etc/systemd/system/$SERVICE_NAME.service"
|
||||
CURRENT_DIR=$(pwd)
|
||||
CURRENT_USER=$(whoami)
|
||||
sudo tee "$SERVICE_FILE" > /dev/null <<EOF
|
||||
[Unit]
|
||||
Description=RemnaWave Bot
|
||||
After=network.target
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
User=$CURRENT_USER
|
||||
WorkingDirectory=$CURRENT_DIR
|
||||
Environment=PATH=$CURRENT_DIR/$VENV_DIR/bin
|
||||
EnvironmentFile=$CURRENT_DIR/.env
|
||||
ExecStart=$CURRENT_DIR/$VENV_DIR/bin/python3 $BOT_FILE
|
||||
Restart=always
|
||||
RestartSec=10
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
EOF
|
||||
|
||||
sudo systemctl daemon-reload
|
||||
sudo systemctl enable "$SERVICE_NAME"
|
||||
msg service_created
|
||||
fi
|
||||
|
||||
msg completed
|
||||
msg start_prompt
|
||||
python3 "$BOT_FILE"
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 212 KiB |
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user