Compare commits
1557 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| f3fc136d1e | |||
| eea908da36 | |||
| 04ff996d14 | |||
| c3dd90a9a9 | |||
| 3c8b1fe640 | |||
| 0f449680ee | |||
| 4fa5c882ba | |||
| 8f031d3d1f | |||
| 534313ce12 | |||
| 586ff2e895 | |||
| 3f62af27b7 | |||
| bf01c6bcfc | |||
| ec8adb0907 | |||
| eafe0c56af | |||
| b09a71aeaf | |||
| dd6ce9ff1d | |||
| 90291798eb | |||
| 1d42ab1f04 | |||
| 74b991252a | |||
| f6b3c31d5a | |||
| 6324426b27 | |||
| b58dc6ab82 | |||
| fc130e86f9 | |||
| bb63732e91 | |||
| a96717bf2a | |||
| a198d1bce0 | |||
| 2041e66956 | |||
| 2f1d8ff9e5 | |||
| 2a68f8e36c | |||
| 472a099a8f | |||
| 3ca8c0f960 | |||
| 13fd27aa96 | |||
| cc57c8610b | |||
| 3f8db29405 | |||
| bb8b07c318 | |||
| 0aa4cf0996 | |||
| 355f210b8c | |||
| b290fc6382 | |||
| 0cc39b4d7f | |||
| 3c00f0cc10 | |||
| b9c0701fe6 | |||
| 0fd8948870 | |||
| 833721bd60 | |||
| 8693115571 | |||
| 7f6f00286c | |||
| 5556bfc6c7 | |||
| 14b97a9c3d | |||
| 4372e651b9 | |||
| 1f47f723d0 | |||
| e4b79d4410 | |||
| 0873cdb16d | |||
| 0265e9cb50 | |||
| 3e1f78f94c | |||
| 81d44d9f7c | |||
| 62cde5d6d5 | |||
| 22d82c9199 | |||
| 84e9637453 | |||
| db16c0d49c | |||
| a967352e9a | |||
| 93acfa3af9 | |||
| 8dca92b5d9 | |||
| 08f0ab2846 | |||
| 6477afd00f | |||
| 6478495e6e | |||
| a60405e4f4 | |||
| f2a9f81ddf | |||
| 80bf976fe5 | |||
| 1b2de84f2d | |||
| 858968665b | |||
| af2d6507da | |||
| 786e2d2288 | |||
| 12c1d5badb | |||
| 2eeccf99ee | |||
| 3eb3fd229e | |||
| d88d1d9482 | |||
| abd0315244 | |||
| 80dc53f272 | |||
| 218a5b544d | |||
| 2e25957855 | |||
| f60a5b7506 | |||
| 316b6359fc | |||
| a6ed138c09 | |||
| 9c8b2c8c50 | |||
| 457048579a | |||
| 78afc78ab7 | |||
| e187d1167b | |||
| 35e9a6d104 | |||
| f32ebc95cd | |||
| 79ddde90ca | |||
| adfadcf127 | |||
| 8a0b9b7c98 | |||
| 9bec6fe341 | |||
| ab39653bab | |||
| a7e3f302d6 | |||
| 38a8fe251a | |||
| 03763a79f6 | |||
| bdbd262fce | |||
| ea87cc0393 | |||
| 5f40605e9f | |||
| 86836da169 | |||
| 92e2e8cb3f | |||
| f6ca1bda22 | |||
| 2025329e5f | |||
| e71a20cd3e | |||
| 20c9b36c26 | |||
| 5c2bde726c | |||
| 7937bceb9c | |||
| 5aa762aaf1 | |||
| 3d2269a368 | |||
| 4f5878bc4e | |||
| 851e452292 | |||
| 985ae18bfb | |||
| 9c999e7220 | |||
| 877c005a6f | |||
| 1b1780df55 | |||
| 9bacc0c8e3 | |||
| 01dea3cccf | |||
| 748adc6697 | |||
| 919335d858 | |||
| ab4418b503 | |||
| 654b20c18f | |||
| 8183c2b678 | |||
| ccacc98b0e | |||
| 0d11a9b645 | |||
| 6a29a7acd4 | |||
| ea91d1f7b0 | |||
| ef59eeac4c | |||
| 26da97187c | |||
| 48ebdeca0f | |||
| 2b1018d7d3 | |||
| 317d905a2a | |||
| 610ccdd1f9 | |||
| d2288c62a3 | |||
| 9f10fed3e7 | |||
| 1140a6c050 | |||
| f2d14d69b0 | |||
| 28cfe91175 | |||
| 7a985eb9c6 | |||
| afd99691f6 | |||
| 153be452b0 | |||
| 679e65ec44 | |||
| f44bd9e4b6 | |||
| 0385e15815 | |||
| b109834543 | |||
| 9f353e8b5c | |||
| 8b77ccda9e | |||
| 1d5c80cd35 | |||
| 041e1c898d | |||
| 76390df4bc | |||
| c3941397cf | |||
| 05e8bfece4 | |||
| f6f9fdce18 | |||
| 4525769370 | |||
| 60447b41f6 | |||
| 045fbddecb | |||
| 84d8024d08 | |||
| 617cdbad10 | |||
| 532f066ffb | |||
| e29e903efa | |||
| 0c7da4b9fd | |||
| ea6538a487 | |||
| e1602da083 | |||
| 96b977adbd | |||
| f7d792963c | |||
| f6eeeb8ea0 | |||
| 738a72b628 | |||
| 784e38c345 | |||
| da44b4e041 | |||
| 12dd8f75fb | |||
| 057a71cfce | |||
| 3c25fb3015 | |||
| a573fdc4d4 | |||
| a6c34beff1 | |||
| a256a2f338 | |||
| e6313bf5a3 | |||
| 8fdd2dd3c4 | |||
| 7090daefc5 | |||
| d1e739c029 | |||
| 92c2a1f2c7 | |||
| 0ca56d0967 | |||
| 99bd6ce42f | |||
| d012d7a9c4 | |||
| 82fc4a9b33 | |||
| 5a769f8972 | |||
| a1e7db2ac5 | |||
| a1347dff04 | |||
| 5a4df05b7b | |||
| eca7009a55 | |||
| bdab2360c3 | |||
| 8d80360615 | |||
| 50c73821a6 | |||
| f1a5465e36 | |||
| 7938a0dc4f | |||
| 24c7adc73c | |||
| 42f1e81436 | |||
| 66900c284c | |||
| 4aa764a126 | |||
| 2e866103df | |||
| 3d7c76b113 | |||
| 15e9982530 | |||
| 199ea9f153 | |||
| 32a2ea8d13 | |||
| 5d73fd4960 | |||
| 8f3882819a | |||
| ebf1b22094 | |||
| b09bde32e4 | |||
| 23e01da0fd | |||
| d127624121 | |||
| d073e1a3bd | |||
| 706d81f4bc | |||
| f43954cd77 | |||
| dbb2395639 | |||
| e16f5a50ee | |||
| e4641a0e86 | |||
| 478a6ecd8b | |||
| 12a615b5a8 | |||
| 6a3def212c | |||
| b829d32236 | |||
| 52714536e3 | |||
| ebdd7e124c | |||
| be74a7cadc | |||
| 7a533dd0da | |||
| b5d13beb96 | |||
| e01e7b4d1e | |||
| d1837bd414 | |||
| 04365dd335 | |||
| 1b6b2b2956 | |||
| 353545ce8d | |||
| 20f83d1a82 | |||
| c424c41048 | |||
| 81751093d9 | |||
| b68b539c68 | |||
| 17b9e92809 | |||
| 4b8bea65be | |||
| c1e26ca1ce | |||
| 425ba29e12 | |||
| 7cc6ddb4ef | |||
| ea7310bcb8 | |||
| 284d79dd67 | |||
| 412a31b938 | |||
| 1a6450284d | |||
| 5e6333d08e | |||
| 3a23883e46 | |||
| b444367887 | |||
| 752292cb4a | |||
| b0482cca1a | |||
| 4e1d7e51a9 | |||
| e19e00aa04 | |||
| 45041982d5 | |||
| c4bafc6c1d | |||
| 5baf44640d | |||
| d6b920ff4d | |||
| 6d9e1aba4a | |||
| 840e5e4c53 | |||
| e16580857f | |||
| fc3871fd8e | |||
| b1f0462aab | |||
| 417089cef4 | |||
| c08072a87b | |||
| a10316d215 | |||
| 4d3d23ac58 | |||
| 9074876e3d | |||
| 1487fb2858 | |||
| 16d031d90e | |||
| 1afa57bff4 | |||
| 05c591336b | |||
| cd9b92d036 | |||
| 344a24a669 | |||
| a5883e9057 | |||
| e292accccb | |||
| ed5426dd71 | |||
| 9b5ab83b1f | |||
| 4795107178 | |||
| 379944200b | |||
| cb8453f415 | |||
| 69f86fe821 | |||
| 099be68d29 | |||
| 7ab58d4461 | |||
| 9bb95e8189 | |||
| a257e61349 | |||
| 44d6fe87dd | |||
| b01eabdcda | |||
| 2a3f346434 | |||
| 8597eed42d | |||
| 070773d28d | |||
| e2a6712382 | |||
| 4ab4eedd04 | |||
| e2e73e130e | |||
| fa1317a5fb | |||
| cb0a155597 | |||
| 00f24acc4e | |||
| f5b4ab65ea | |||
| b61c3daca8 | |||
| 83d93a833d | |||
| b999e4aced | |||
| 214c9f0c65 | |||
| 31545a4225 | |||
| 1441a16474 | |||
| cec91b711f | |||
| f87da6db7a | |||
| f02a6358e9 | |||
| 20c1bd6a75 | |||
| 254620774e | |||
| 3078089044 | |||
| 9c67c401b9 | |||
| af79f511e5 | |||
| b20ec194c5 | |||
| adfb556259 | |||
| e9ff8d851e | |||
| ac258cda37 | |||
| 5fc4ce8930 | |||
| 3aa7b1edc5 | |||
| 10eca6c6ac | |||
| b59b3f0e53 | |||
| 290ef28a7f | |||
| 66bb7c8d33 | |||
| b947a48ca2 | |||
| 8dde5acbbc | |||
| 760aab75cd | |||
| a6a77238f7 | |||
| f75b5e8312 | |||
| 39701b1f55 | |||
| 5f69a36077 | |||
| 2556d3842b | |||
| ffec36fa54 | |||
| da6815c5f1 | |||
| 76c0c468f5 | |||
| 357c8a01e7 | |||
| 595c312e2b | |||
| 28435d2943 | |||
| 171634522e | |||
| 424cb50942 | |||
| 15ce453523 | |||
| cfe8b2edcd | |||
| 3b64b28b86 | |||
| 918872e4b4 | |||
| f21b49d5fd | |||
| 2bf2d98c88 | |||
| 63cc967dfc | |||
| 9bea1d6a6c | |||
| 97234d8835 | |||
| c5e8917043 | |||
| b39ae1a1ee | |||
| d5190ea07e | |||
| 7f35ec0745 | |||
| 16b22101af | |||
| ccdb490b84 | |||
| c12e61fb41 | |||
| c877aef711 | |||
| a174b7bd81 | |||
| 43f4657089 | |||
| 50d2d5f716 | |||
| 34618a3de1 | |||
| 1b930460ca | |||
| fe439cd31d | |||
| 81733b041a | |||
| 7ac599aa18 | |||
| 279409600b | |||
| d701ce31c8 | |||
| 315459ed2e | |||
| 0dd3641151 | |||
| c610fb118e | |||
| 8f471a1e43 | |||
| c7b2f15131 | |||
| 12079cac34 | |||
| 96b283c6cb | |||
| aeee2ec352 | |||
| 19c0db3690 | |||
| ede74ef7af | |||
| 4607c81e7f | |||
| cc70ac8f14 | |||
| f7eb475292 | |||
| 38da3cca91 | |||
| bf1e454f0c | |||
| 36ea55a10d | |||
| 9ee2f0addf | |||
| 52aa6f81e2 | |||
| b1c53d5fad | |||
| 52d5f784a2 | |||
| cafe576478 | |||
| 9427cb20d9 | |||
| 68b9092749 | |||
| e3887fa66a | |||
| ffd08e726d | |||
| 03444b65ba | |||
| b817b5f087 | |||
| f93dc40f5f | |||
| 71c8014be1 | |||
| 33e44abef0 | |||
| e7f91a971b | |||
| 8ee133edc6 | |||
| 8954d3a756 | |||
| 0640c4f1fd | |||
| 2c9680cc8d | |||
| 9d3c0cb4b1 | |||
| 6bfa94d9bb | |||
| 58cda2e5ff | |||
| 2d4cdb0e35 | |||
| 1331632bdc | |||
| eadcacdedc | |||
| 855398d186 | |||
| 8bd09018ec | |||
| 36b074b209 | |||
| 318598026c | |||
| 755c85db2d | |||
| 9074eb277d | |||
| 55cbe579f7 | |||
| d9292840c5 | |||
| f817110476 | |||
| 4699c6a394 | |||
| 0abb83aae1 | |||
| d532f1211c | |||
| e4538ec0f2 | |||
| 157f7454bd | |||
| d9f6013aa1 | |||
| a3d8a6233a | |||
| 5ecb5b66f0 | |||
| c9797f9b26 | |||
| 19063b3592 | |||
| f0c1f62d62 | |||
| e7410ae776 | |||
| 606af6f52a | |||
| 3f22e20c24 | |||
| c2110e7304 | |||
| c07e55a86e | |||
| 48e15fbaad | |||
| e280a4dc12 | |||
| 493f01d6aa | |||
| a93855e1a6 | |||
| a9821aced7 | |||
| 98055bc1ab | |||
| 801dac4884 | |||
| a315222df7 | |||
| 34feb87b0a | |||
| ab04498488 | |||
| 6f8bc8ad24 | |||
| 1e6c901e43 | |||
| 2aade7acf9 | |||
| 9ade45347c | |||
| 1fb7f8d412 | |||
| aabfaf10ee | |||
| bbc907ef93 | |||
| e7e7de8115 | |||
| a65c38394f | |||
| 82ad2ae8e9 | |||
| 3e251a46ea | |||
| c76c8d6544 | |||
| d61c0dcb30 | |||
| e2d31525ea | |||
| 4dda834805 | |||
| 6a46f7e7ff | |||
| 05ee8ff1f4 | |||
| 9f696fa9cd | |||
| 9666947125 | |||
| 0f6d09c06c | |||
| f1e3cb0c05 | |||
| 20a4c0ab38 | |||
| eef645fbcf | |||
| e00588d468 | |||
| 5ab21c9e8d | |||
| cb45a4d760 | |||
| 7d8d874390 | |||
| 36107a32c7 | |||
| c0dc3dcc18 | |||
| df43ea2716 | |||
| 43af9df078 | |||
| 31b9d0abdf | |||
| 572aa88ee1 | |||
| 3dbec2196a | |||
| 9a5cc083ca | |||
| 2172aa02a8 | |||
| 4316bf2209 | |||
| 331d140b42 | |||
| e67dce82d6 | |||
| b442e70657 | |||
| 4fb02063fd | |||
| 96fb83f7f4 | |||
| cc3b8a0571 | |||
| 32fda4e8b5 | |||
| cd903944fa | |||
| 3ab1007178 | |||
| 0e820f62c9 | |||
| 3e33513fe9 | |||
| daa9710579 | |||
| 6b2f4f6ac5 | |||
| 8e44c9755b | |||
| d55fbf0fed | |||
| e05f8f8183 | |||
| 1ae19cd351 | |||
| d79deb307e | |||
| 8f494c0b98 | |||
| 71803174b0 | |||
| 1c660320da | |||
| 014dbd7d73 | |||
| 22e0d610a7 | |||
| d4f77df197 | |||
| 0f7a9e58d8 | |||
| 9ae2de2eb5 | |||
| 1309c3ef43 | |||
| 0edadd9592 | |||
| cc403fac67 | |||
| faecb7fb13 | |||
| 32cc047943 | |||
| d4d2ff1867 | |||
| d2d6ba1be9 | |||
| 96905c0f51 | |||
| fe2c70ab75 | |||
| 1174a3ff34 | |||
| e66a465eee | |||
| d6ec510055 | |||
| 29bd962d49 | |||
| 8fb8e203ae | |||
| 78cfd35ad8 | |||
| e6429fda9d | |||
| d491178410 | |||
| b3b21d1f5f | |||
| e8030c1db9 | |||
| a1f0449e5c | |||
| fd3e26dd47 | |||
| ae39b6df9a | |||
| cbd2f58499 | |||
| 8ea78943fe | |||
| 8bd904af6c | |||
| b3369d8708 | |||
| 4af368549e | |||
| 07bbd9b177 | |||
| 11898a5d13 | |||
| e662e0d06e | |||
| dc2e1158fa | |||
| 109df2354c | |||
| 4d337e3df5 | |||
| 9a042c638a | |||
| d520f02c03 | |||
| 0581327358 | |||
| 0883cf2bf6 | |||
| 6067eca053 | |||
| 48d7e197a1 | |||
| 961514d712 | |||
| 97bfc1bc43 | |||
| b3b15db8c0 | |||
| ce275babc3 | |||
| 3aac6b9872 | |||
| 8813664f3f | |||
| ebd50f8b3d | |||
| 62fccf2b10 | |||
| 943027f17f | |||
| ea0a3dc962 | |||
| 2d2a11e5b6 | |||
| 55fe1342cf | |||
| 105f798c26 | |||
| 944295165d | |||
| 18be0049cc | |||
| 26a32b6f07 | |||
| f477094614 | |||
| 367331d113 | |||
| 51d40323e5 | |||
| 5ddf7f8242 | |||
| e4a73f9aaa | |||
| b99c090a84 | |||
| 15ad1a0f79 | |||
| 344e4757ef | |||
| 55461aa50b | |||
| 24928fe561 | |||
| f16084b941 | |||
| caeb531445 | |||
| fefbf4da39 | |||
| cd3c19b79d | |||
| 6cd1dd8bc2 | |||
| 3837f47bb6 | |||
| bff7350224 | |||
| cd4c526da0 | |||
| 38e931446a | |||
| 55af3bde3d | |||
| 6bca31f92c | |||
| f8858e8561 | |||
| d3c729cdc2 | |||
| 4c954409cd | |||
| b2dbdb60b6 | |||
| a26d334653 | |||
| ef1cf1fe26 | |||
| 1356170fb0 | |||
| 998cf9a6e1 | |||
| 85a6be831e | |||
| 9df5d8469d | |||
| 3133cc7799 | |||
| 7b894300b7 | |||
| 2621d29ef0 | |||
| 76ad4e5821 | |||
| 0056454375 | |||
| 3c15589b03 | |||
| 4849c899c2 | |||
| 44b9986a45 | |||
| a9359e9dc0 | |||
| 76e730475d | |||
| 18d3b4246a | |||
| 22121b9171 | |||
| ff498feb7e | |||
| 15fcc0ac70 | |||
| b1c73dc1a6 | |||
| 888812babd | |||
| 023cd1728d | |||
| abbd3474b4 | |||
| fa17faa424 | |||
| 799c657605 | |||
| fa875fb3bc | |||
| cc628a08fa | |||
| fa85fb70f1 | |||
| 1d5980d725 | |||
| 9474ccfb06 | |||
| c6e91c0543 | |||
| 593206f6a0 | |||
| ea6f5297c1 | |||
| 471714b80d | |||
| a92a19b84d | |||
| 289e0ae5b5 | |||
| d08b410888 | |||
| 455455d696 | |||
| 57ad26711d | |||
| 24428a030e | |||
| c42fee4298 | |||
| aba9620c0f | |||
| dd8c87f887 | |||
| eebc2ee1d5 | |||
| 7bd5ecc55f | |||
| 5d25e211df | |||
| 0887e28b2b | |||
| e04be209cb | |||
| d19eee8897 | |||
| 3efdeed00c | |||
| 9e89a2db10 | |||
| 59a23ea9bd | |||
| 216c8f8d81 | |||
| d0f6511c5c | |||
| e1ecb6a1dc | |||
| 99c02874ec | |||
| 0cd896d35e | |||
| ab35ce9178 | |||
| f71e6eb226 | |||
| cd3b55271a | |||
| e71de5f3cf | |||
| 0fc2c535de | |||
| aef1eb2861 | |||
| b57d79d65c | |||
| d9533e673e | |||
| 3e452c4c1b | |||
| 31582080c6 | |||
| d85dad2a9e | |||
| 7f15e208e1 | |||
| ae291e2ae7 | |||
| 7fd15da4a6 | |||
| 4fdbaca7e8 | |||
| 54180c6b28 | |||
| b7db9be638 | |||
| 76d6476ea7 | |||
| 70867dccec | |||
| dd7144cd5b | |||
| 29f54a408d | |||
| bff7d3ede0 | |||
| 2a5a254cae | |||
| f7db7f445b | |||
| ec9016f958 | |||
| d73a765866 | |||
| eb70e38a02 | |||
| b587cf527e | |||
| a8ce3f65ee | |||
| 5200e25e88 | |||
| e676213102 | |||
| 1f56820ce5 | |||
| 58937a405d | |||
| b7d13576b8 | |||
| 0828bca2ed | |||
| c1dc10e5ed | |||
| 50eba2d093 | |||
| ea6d9d300f | |||
| a06ee6f395 | |||
| 86e678de08 | |||
| 772a677424 | |||
| cccb963da2 | |||
| 2b3ce6ce55 | |||
| 1c119b1d7a | |||
| 4043380411 | |||
| c3d71bdc28 | |||
| dc6f73c539 | |||
| 476a8cb842 | |||
| bc81c64cbe | |||
| 812264722a | |||
| 0d06f61d40 | |||
| 2634facd51 | |||
| a92b6d1e86 | |||
| 4cfbcd8590 | |||
| 73ba081779 | |||
| 3aa6c26940 | |||
| dc8ff4a007 | |||
| 6390a18327 | |||
| a5d9dad826 | |||
| 06e6b30d31 | |||
| f9e404a7c2 | |||
| 3920992cee | |||
| fcb6dd9773 | |||
| 6d42647bf6 | |||
| 6e3b33e335 | |||
| 063ade1a2a | |||
| 56ffbd654e | |||
| 47777c7ee8 | |||
| 27d021171e | |||
| d4b00fb494 | |||
| 7816fa535c | |||
| 0208c48212 | |||
| 4295e4b5ec | |||
| 4fb13a089a | |||
| f1841caea7 | |||
| 4a67f9233d | |||
| e55a89af8e | |||
| 1f172f1a21 | |||
| 3f7bcb090a | |||
| a3b049dc1f | |||
| bf2d225c55 | |||
| 33b15b8486 | |||
| 19a273baf4 | |||
| 1c205f4a24 | |||
| f2a47de5da | |||
| 372933e230 | |||
| 11236ac64e | |||
| 46f71a7a6e | |||
| 70293fe387 | |||
| c7915a45b8 | |||
| 8b016c7010 | |||
| 17032811a4 | |||
| ff591fc387 | |||
| ece35bb90a | |||
| dfec3c5748 | |||
| 29ecb61ad2 | |||
| 79fd3cde5d | |||
| 5f4a23e808 | |||
| 3d10e2c1fe | |||
| 6b8507253e | |||
| 90d9c0bc54 | |||
| 71301f90ae | |||
| 472a3ad69d | |||
| 5cd63bc89d | |||
| 77bd87a2b5 | |||
| 53483ce6aa | |||
| a9e546091a | |||
| 924b5e15c5 | |||
| ae9b5b1bf7 | |||
| adb669fa9e | |||
| 2daf2fadc8 | |||
| c9b0cd2db1 | |||
| 098459f3ce | |||
| 64d25967c9 | |||
| 5804a8e87a | |||
| 388fbb5afc | |||
| d3e502ce43 | |||
| af497f644a | |||
| e57680f3a7 | |||
| c7eff5cc3e | |||
| 3ccbf00259 | |||
| 6066775cf4 | |||
| 92ec071e6b | |||
| 8e070349ba | |||
| 80360691f2 | |||
| 2f0c22abe8 | |||
| 9c7cbe403b | |||
| fcdfd3a484 | |||
| 8f1ceb4e66 | |||
| 2db29fba9d | |||
| d0125c1f33 | |||
| 0dba6a6e42 | |||
| 02fbf89d09 | |||
| 305fbde885 | |||
| 3d94037d4e | |||
| 4a5aae06c3 | |||
| 19e476aa3f | |||
| 5c62ed8d92 | |||
| f138572b38 | |||
| f907906d04 | |||
| 097ce51b2c | |||
| 1fa553f246 | |||
| 32c65b3194 | |||
| a4d11f67fa | |||
| 1775d90bab | |||
| 1d6ae5fa07 | |||
| fa2d3eaa0a | |||
| 148e99ea6f | |||
| 64f99ac660 | |||
| bded99263a | |||
| aded5606a8 | |||
| 79982f5aa6 | |||
| dc27a6e69b | |||
| 8af59ffbfe | |||
| 013b26ecc8 | |||
| 7df831c084 | |||
| 507e0b5e1d | |||
| f2a12b8d98 | |||
| 1d089cce7d | |||
| ba89c054fd | |||
| 33801f58c4 | |||
| a963f4ad12 | |||
| b8472ee428 | |||
| 769f5eaf9a | |||
| c9e791fa76 | |||
| a32d9d422f | |||
| 39baa1640c | |||
| 37ebf4cdee | |||
| bb0085a438 | |||
| b05e57fb16 | |||
| 4c977d07ce | |||
| bd39825cf9 | |||
| e01a7efe4a | |||
| 8b159ab75d | |||
| 316bd191a2 | |||
| 29f6fe40e0 | |||
| 40f1f1b14e | |||
| 31822ee634 | |||
| 773db02746 | |||
| 2ba9615151 | |||
| 19c4c0496a | |||
| 903cde9a78 | |||
| 3592eccea9 | |||
| 4afd1668c9 | |||
| f799dd8766 | |||
| 254c489010 | |||
| 53bc6f6167 | |||
| b48cb94b12 | |||
| 0813ea2897 | |||
| 770f40f4ff | |||
| dba8752436 | |||
| 4b503ce31a | |||
| 8b9c00f53e | |||
| 55811aff56 | |||
| 310aa9bce6 | |||
| 2dcd1e65db | |||
| 4f840c171a | |||
| bfefdef304 | |||
| 3de049e436 | |||
| 74bb6b3f6d | |||
| 56e6dc3ae6 | |||
| c34626aec0 | |||
| d4187149b8 | |||
| e982a5b718 | |||
| a9a17bd693 | |||
| aef1973720 | |||
| 5a77f29bd5 | |||
| 0789b3fe9d | |||
| c82f64980b | |||
| 7623f339bc | |||
| 3de1635efa | |||
| eb824162d2 | |||
| 54d5df72af | |||
| 74c991cf85 | |||
| 46e6102412 | |||
| 698b8a9484 | |||
| 94f3c00628 | |||
| 630cf61f84 | |||
| d3b384aa61 | |||
| d209cf11c7 | |||
| 49eb0e68a1 | |||
| 3e7bebf189 | |||
| 73fbc22324 | |||
| 9774db74e5 | |||
| 82d8b50956 | |||
| 587bf76c61 | |||
| c2e4719f18 | |||
| 7b4bddfa28 | |||
| 8be57829fb | |||
| ceb3e160da | |||
| 007f3a7bcf | |||
| cc42900142 | |||
| 1009bb4001 | |||
| e4727dd0a4 | |||
| 3d6acf51f6 | |||
| 1634588be9 | |||
| c379011fd2 | |||
| 8483ef1df0 | |||
| 9be291c1e8 | |||
| 6871f2d15e | |||
| 60c9a4f1e6 | |||
| 5a894e0e10 | |||
| 45b9c56282 | |||
| 88c4d940e8 | |||
| 3741e4f3bf | |||
| 28e7c78601 | |||
| 37fbd35e8f | |||
| c41b77d365 | |||
| 1d19d8b744 | |||
| b04bc4dca5 | |||
| 4dd4d829d7 | |||
| bf35156455 | |||
| e6a1b55045 | |||
| 455ab76c8a | |||
| 720c145aa3 | |||
| 580ce90442 | |||
| 239426f6d5 | |||
| 3c4ae34997 | |||
| 899da825cb | |||
| 59e0d965da | |||
| b70e27932d | |||
| 69dcef073e | |||
| 89394ef630 | |||
| 79a9a95ffc | |||
| ee9e65d692 | |||
| 9bc135a51a | |||
| fabdfc6ab6 | |||
| dff378ad81 | |||
| b1739cf4ea | |||
| d47952fe3e | |||
| 74d58ce310 | |||
| 5f43bb6e55 | |||
| f7c906f608 | |||
| e97e3060f7 | |||
| b6ebfd5c1e | |||
| 3d67311e61 | |||
| 7bfbf89054 | |||
| 43959a996e | |||
| 591370ce60 | |||
| bb803ab145 | |||
| 07d14a301b | |||
| 56af0a7885 | |||
| c2ba4e9ab6 | |||
| 7b372e095e | |||
| 33f98276ce | |||
| 8818d91232 | |||
| 0aab2096a5 | |||
| 5a3f54c3a7 | |||
| 79ebe6b6bc | |||
| 6c323dc7df | |||
| 94980d4524 | |||
| 78c00b5454 | |||
| 9437ee9c90 | |||
| 4addb772a0 | |||
| a1d051a3f1 | |||
| c4d3c921bd | |||
| 1596c85bee | |||
| 3d4a72d3fd | |||
| ad9c49f841 | |||
| cc2b9b52c5 | |||
| 5a1d4c1b24 | |||
| 99cc4b26bc | |||
| 970511ca1d | |||
| b81a0f289f | |||
| 13259b435b | |||
| 44cadc29ba | |||
| a2c71a49f2 | |||
| e7d54ae317 | |||
| 1459bdacfa | |||
| a9e111bd4f | |||
| b63c3fb115 | |||
| a0a6686058 | |||
| 4c4a1a297c | |||
| fc4bb88db8 | |||
| e17cde7c72 | |||
| 3d7684b72b | |||
| 51558aeeda | |||
| d83da5daaa | |||
| 67b2da3447 | |||
| eeb330b21f | |||
| efff92b2b3 | |||
| 4d5cc954b5 | |||
| 1e204a3e29 | |||
| 98d0c03aee | |||
| 1a29bb6dd5 | |||
| 0959742f5b | |||
| 1c505b74d5 | |||
| 96a6612124 | |||
| 78181e7230 | |||
| 66bba43ec4 | |||
| 4140fbae78 | |||
| e4a4d68758 | |||
| 478d2ee0e6 | |||
| 588c32453d | |||
| ce6186fa17 | |||
| a617b940c1 | |||
| c370d32154 | |||
| 89f329458c | |||
| 4df9140cf8 | |||
| 8e74c12bca | |||
| 4e280d1b3e | |||
| c158c1339f | |||
| 9db8fdf79b | |||
| b6184d4c90 | |||
| 981dc0db01 | |||
| 0403ad5688 | |||
| f05b7416ef | |||
| 1d433d2cfd | |||
| 17a0fa0b21 | |||
| 7154374fdc | |||
| 34b1927c22 | |||
| 1e9367ab93 | |||
| a7d0cca66c | |||
| e3bb7f6663 | |||
| 450b8e17f0 | |||
| f176a11ff9 | |||
| aadd2edf45 | |||
| a02062a4b4 | |||
| 0092764f8f | |||
| 55b065f784 | |||
| eafe0a78d2 | |||
| 9da808a024 | |||
| 3d295743e2 | |||
| 6f8bc2b08d | |||
| 32d0ac81bd | |||
| 35f214a45f | |||
| 8fdc78c2e8 | |||
| 6943794e50 | |||
| 086e3fe5b0 | |||
| d247b764f7 | |||
| 283016f043 | |||
| 7c19492c05 | |||
| 054d710d6c | |||
| f11b014acf | |||
| ab27328cb7 | |||
| 9b98faedfd | |||
| e957fbdfa1 | |||
| 94273b5ee2 | |||
| 87d92b7dae | |||
| 9a823eddf3 | |||
| 52d124c879 | |||
| 287d0a635b | |||
| 1057fd6ef6 | |||
| d935a9cd49 | |||
| 97d598a293 | |||
| c9664c136b | |||
| 0b0fca8648 | |||
| 9577c8272f | |||
| 0d82294bdb | |||
| d40c845d62 | |||
| 70e3f5c714 | |||
| 8a15d0320c | |||
| 73bf682f97 | |||
| ba53e9e199 | |||
| e13937b965 | |||
| 7758415ddf | |||
| a19c325e15 | |||
| 10df771a15 | |||
| 6cd7e8acee | |||
| d8944a9c52 | |||
| 930ce0fcdc | |||
| fceff14d0a | |||
| 16e31d4abb | |||
| 5289548fd9 | |||
| 681fd7d92c | |||
| 932ef51de4 | |||
| 080a99cd55 | |||
| 93ac01592f | |||
| 1742519659 | |||
| 954c6b6ae7 | |||
| 50811afa30 | |||
| afc913a2ff | |||
| bab6a807f3 | |||
| 6329dfd3ef | |||
| 6484bf2c6e | |||
| 1e64817e3f | |||
| 15fef698d9 | |||
| c108ec4b2d | |||
| 5d70576582 | |||
| 177cbb8881 | |||
| 7d8050ce1c | |||
| fadafecb43 | |||
| ab6af88114 | |||
| 3b7bb5436b | |||
| bfe8ed8b3c | |||
| 95b338a1c2 | |||
| 072a90a028 | |||
| 933c8100e4 | |||
| 4951a5cf78 | |||
| 5e904f3045 | |||
| 0840a07ed9 | |||
| 2becee7f02 | |||
| ae22950ce3 | |||
| 169b276ef1 | |||
| 5966555ae9 | |||
| de27fa24d4 | |||
| 0b0b002c82 | |||
| 0e4f59a242 | |||
| 051714e613 | |||
| c780b1ffd7 | |||
| 88caa0bab3 | |||
| e6b071f64d | |||
| 9a809aa646 | |||
| 52bdf10e0e | |||
| 1642bde410 | |||
| 2fdb29d517 | |||
| ad8d28f7f0 | |||
| a4776d7682 | |||
| 3c7451e8fc | |||
| f65ed3eada | |||
| b4c47faac9 | |||
| 217bb457a1 | |||
| ce79caa58d | |||
| 76d72ba203 | |||
| 035ef47619 | |||
| 4792abc6d5 | |||
| 42ac740589 | |||
| d5371e4c04 | |||
| 7c883b07f9 | |||
| fa681fac92 | |||
| d1a9691c87 | |||
| b31c9d2e10 | |||
| 11176a16bc | |||
| 39aaa88532 | |||
| cf6fcdbb76 | |||
| 4e09e04dd9 | |||
| e0639c7116 | |||
| 1d915f849c | |||
| 404d502d2f | |||
| bcfb4c7942 | |||
| bd5cc56441 | |||
| bc42b0e52b | |||
| 71e549866b | |||
| ca9dd17450 | |||
| 91f12fe627 | |||
| 1406e87ab3 | |||
| 9398199f25 | |||
| 6ed83097de | |||
| 4d38ab0d3a | |||
| d955a6ccfc | |||
| 82da0cbf53 | |||
| 360d73dd26 | |||
| 14400018c9 | |||
| f845baa3d0 | |||
| 54e6c2aaaa | |||
| 240ad42beb | |||
| a8d8972855 | |||
| b70b432d05 | |||
| 77d6b8c67c | |||
| 9251087d2c | |||
| 5f2f3ad683 | |||
| 0f507aa101 | |||
| 99574ce751 | |||
| 441e7ef696 | |||
| 8395c29ae2 | |||
| ff37c48579 | |||
| 8e778eb7af | |||
| 2f6b85d4a6 | |||
| bb034ba6c6 | |||
| d86acbfb9d | |||
| baf0e2d695 | |||
| b58079d86f | |||
| ee2ae3f14d | |||
| 5420c38592 | |||
| 2a3d2098c9 | |||
| 36a44f3669 | |||
| ccbe82a140 | |||
| 8b6afcf571 | |||
| 74353143cf | |||
| 7f48f5b7b5 | |||
| 5bb83fe904 | |||
| bfdf5f9f41 | |||
| d7d0e3da42 | |||
| 11c84eae82 | |||
| cd7db1330e | |||
| 24c1f8b9cb | |||
| 8c62573664 | |||
| fc1dda5597 | |||
| 9e899f4ec6 | |||
| bb2fea1396 | |||
| 9a05ff82e4 | |||
| 21e82eed79 | |||
| ca1a279144 | |||
| 4e6eb4ea7f | |||
| e3787a041c | |||
| cd411c6b49 | |||
| 9acc9d8304 | |||
| cb19c3829f | |||
| b42cc6b484 | |||
| 4cf1f5dad8 | |||
| d863eb1ed9 | |||
| cbd0902e42 | |||
| fa2b45104c | |||
| bac2dbaf85 | |||
| f9e29f4fa9 | |||
| 82985aa96e | |||
| d34f49c9b2 | |||
| bbe355008a | |||
| ae115ba13b | |||
| dc7da4441a | |||
| d91465c435 | |||
| 9cb9b9441a | |||
| ac3a4a397f | |||
| 995964f70a | |||
| 708b0be31f | |||
| 052124d483 | |||
| b40bf3f833 | |||
| 11d7005954 | |||
| 7893be6ef8 | |||
| 18ca2b275c | |||
| bc333b12c2 | |||
| c3e052f73c | |||
| 29f606736b | |||
| ce17181be5 | |||
| 041c8e1511 | |||
| 270f01511d | |||
| a352901d85 | |||
| 8e5ce5768a | |||
| 97c03d0396 | |||
| 1b9b3e6729 | |||
| 3f336919ad | |||
| 8f8e89667d | |||
| 88b05547de | |||
| c35d067306 | |||
| 06489967f8 | |||
| 47e45cf596 | |||
| bc77c6a5ff | |||
| 31eb551b6d | |||
| 122b82253e | |||
| 5c79302cd6 | |||
| 34a99ceb98 | |||
| 576a315baa | |||
| 73965b64dd | |||
| 9673f10af7 | |||
| 8a9825f629 | |||
| fc830a427c | |||
| 26e8e91cb7 | |||
| 4cae37ef33 | |||
| a04a6bd5d2 | |||
| ac632a5e96 | |||
| 6701222697 | |||
| f42fe4236e | |||
| 3041e0a208 | |||
| 61bf3cd129 | |||
| fb34af114f | |||
| 56678abbb4 | |||
| 347805a8ae | |||
| 2438ac58e1 | |||
| 23b60389c0 | |||
| a58e45133a | |||
| 6fc26754bf | |||
| 8bfdc6e319 | |||
| 8d4e32c86c | |||
| f16226e48e | |||
| 05bef3d389 | |||
| d811ba5623 | |||
| abd6c03ba0 | |||
| 84a7c1c930 | |||
| 96b8ad8555 | |||
| e4f9301591 | |||
| 1eb60dc0c2 | |||
| cbcae665e4 | |||
| 4da82282de | |||
| 1548907ead | |||
| 854c6fa536 | |||
| 09ca65de0c | |||
| 52613d9feb | |||
| 4878cc2eab | |||
| 7e2f56f20b | |||
| 6b2914bff4 | |||
| 10b45ec226 | |||
| 23ec7e3ccb | |||
| 3aaa8d1ea3 | |||
| a46e8d3a5a | |||
| 6f9ceb1a89 | |||
| d5dbb39444 | |||
| 0f0af0c194 | |||
| 545345a020 | |||
| 0766cda29e | |||
| 6563b7dc67 | |||
| 5b8b950aaf | |||
| 8020fd9a7d | |||
| 8836dc577e | |||
| a2be86b134 | |||
| e9bc47680a | |||
| da62ab5a90 | |||
| dd84c8dd3a | |||
| 7e40a1f96c | |||
| 69f880af2b | |||
| 7c93c8cc31 | |||
| c2d06e10b8 | |||
| 9c8fafabb1 | |||
| c63708cb83 | |||
| 8f9b84277b | |||
| 8363d319c2 | |||
| a353d41738 | |||
| 0cfd656d86 | |||
| 5001fc94fe | |||
| a014596d51 | |||
| 64b4cfd1b2 | |||
| dcc7d5e218 | |||
| ca8c5691d3 | |||
| b257d8c66d | |||
| 601598dfd2 | |||
| aef9c344ab | |||
| f6f1227dd8 | |||
| 6d750c91f8 | |||
| 533d4fa64c | |||
| 0be6ed0631 | |||
| 346dfaee2e | |||
| e2796d22e2 | |||
| b96c42c153 | |||
| 83b5a8608a | |||
| 1abe1c7166 | |||
| c35bd82ebe | |||
| b6ed49ad82 | |||
| e70a2d74ba | |||
| d3f00acd5c | |||
| 00b0823eb3 | |||
| ab488ed0a3 | |||
| 12046b9c5c | |||
| a457598109 | |||
| 1f2f6ee82a | |||
| 8d41b19767 | |||
| cb59a1d176 | |||
| 2961477ecf | |||
| 5db30c05f7 | |||
| 90aa095621 | |||
| c7d5ff94c0 | |||
| 136832b22a | |||
| 79c723ec06 | |||
| 43883a3c06 | |||
| 0eba072826 | |||
| 0604675be6 | |||
| 75147eb5a1 | |||
| 5dd1fab7e9 | |||
| 31fa90aef1 | |||
| 4f0df51abd | |||
| 584fec2dc8 | |||
| 5744d03ad9 | |||
| 4aa521c6b9 | |||
| 545ceac0b8 | |||
| bd699b5700 | |||
| cb7684b015 | |||
| 567c04d74e | |||
| ac3451a209 | |||
| 8f4403accd | |||
| 8b19cf0956 | |||
| 5db5c8377b | |||
| 59ca46908e | |||
| efd56e513d | |||
| 6857e2b9c8 | |||
| 72438fea7e | |||
| 7a4aa5099d | |||
| 13efb0cc45 | |||
| 0c65d378cd | |||
| e73b8e6538 | |||
| 5bbc248a4b | |||
| b37c724aa0 | |||
| b89b0fe1ce | |||
| 577592479b | |||
| 20031eb843 | |||
| 35939d89b8 | |||
| 2ae03cb6b0 | |||
| 47be269ddf | |||
| ee30f5ab95 | |||
| e473988750 | |||
| 2a768568a9 | |||
| 1b45580390 | |||
| 37f8cf9881 | |||
| 6c84c873cf | |||
| a2a3d85cf5 | |||
| 59db7344a0 | |||
| 8f2d027a83 | |||
| 0a37596471 | |||
| 2f0208a905 | |||
| af8ecffb6d | |||
| 08d38e651b | |||
| 0db832473a | |||
| e7f682f02d | |||
| e002035518 | |||
| c16f2e8c72 | |||
| d588726838 | |||
| b5c8f4fa28 | |||
| dab1a8d754 | |||
| 27f5b9d43a | |||
| 4bf5b3a87d | |||
| adaae41826 | |||
| aaa754f66f | |||
| a294f123d2 | |||
| 617e599e74 | |||
| 426e2a881a | |||
| 96c9792dce | |||
| 0864a8a1ce | |||
| 5963cdc854 | |||
| 04c7616661 | |||
| ca68eb58d3 | |||
| b8b6f29c48 | |||
| 1d9aa3b432 | |||
| b12bb33242 | |||
| 1f7d0a263f | |||
| 34e213cc78 | |||
| 6651034413 | |||
| 800a86c500 | |||
| 1806a3db4b | |||
| 6a1f3f810e | |||
| cf923e1e9a | |||
| d122b59c6d | |||
| 2bd4d802a3 | |||
| 012ed787e8 | |||
| 6ada73b6cb | |||
| 719865fe83 | |||
| c34c5ef7af | |||
| 9c85ae31e5 | |||
| 987602551e | |||
| 9dcbc7d736 | |||
| 92fbe6ced5 | |||
| d79d7004ec | |||
| 4d1a75939e | |||
| 1c9af07a7d | |||
| 8f16fa4c1a | |||
| fe2ae18f20 | |||
| aa73bdd066 | |||
| a48bc4a0d8 | |||
| 3461f47cfd | |||
| 38d99b2a47 | |||
| 25a51549ac | |||
| bca136a55e | |||
| 1953e7c0cb | |||
| 6754a7da93 | |||
| 8fb3b1691f | |||
| 91bc384508 | |||
| 666c3b34af | |||
| 38817c82f7 | |||
| 10a6179b7c | |||
| 411647585d | |||
| f5c15546e2 | |||
| 109a968941 | |||
| b32cf0a9a0 | |||
| bbd68becfc | |||
| c4400466dd | |||
| da4ed7dd22 | |||
| c0fc972ce5 | |||
| cdcfcb7987 | |||
| 48528e611f | |||
| 0e982e8361 | |||
| cc9715ca7b | |||
| 6cb6aa4c67 | |||
| 040e32a50a | |||
| 40092c7085 | |||
| 1bd138e78b | |||
| fae10faf7e | |||
| 51d0b71e1d | |||
| 9cf29cc59c | |||
| 3e680fd31d | |||
| 2e5d581fb5 | |||
| 8a235e129d | |||
| d899ddf663 | |||
| cd6d05320e | |||
| 4ef0ce9d19 | |||
| c78fee4a6b | |||
| abc46bf172 | |||
| 3680d0606a | |||
| 45e4b2f696 | |||
| f11e3f255e | |||
| 3915f37e55 | |||
| 0a4e1e8c50 | |||
| 0d390bb9cc | |||
| 15f3e89e61 | |||
| 869a0e8472 | |||
| 4f0c64b786 | |||
| 10e10b7157 | |||
| b192b43bd3 | |||
| e129a06d9b | |||
| c678a9bfce | |||
| c8cdfc980b | |||
| 146512b2ab | |||
| 30c03677fe | |||
| 2baf63dccd | |||
| 363bf80bf4 | |||
| 50146be4a6 | |||
| 09673a145c | |||
| 16561610b6 | |||
| a71afc554e | |||
| 7512868219 | |||
| 9008ba3b99 | |||
| 83d589fff5 | |||
| 5cb07aa68a | |||
| dd5463881d | |||
| 77084b1ff2 | |||
| e965652e1f | |||
| 1c3d24527f | |||
| 9bfb39f1ae | |||
| 0c46a70f53 | |||
| dedeac394b | |||
| 57e813e3d4 | |||
| 02847dbb93 | |||
| 470a05018e | |||
| 649d8060df | |||
| 5a04b360db | |||
| 76d2f28128 | |||
| 37561c086f | |||
| 262b7c9489 | |||
| 73cfe74c1c | |||
| 68f6994b94 | |||
| 57ecdbbda5 | |||
| 3ff07e4c76 | |||
| 8d4bec79b3 | |||
| 546f4c52cb | |||
| b223b4463a | |||
| 258f8fe2f7 | |||
| c697498cb4 | |||
| 4b1e8b555a | |||
| ec4217fc4f | |||
| 49fa3693ad | |||
| 845a7a287b | |||
| b5928722eb | |||
| 9f0d0acb53 | |||
| b4af296384 | |||
| 3466157550 | |||
| 754a33cbc7 | |||
| 8538050fb5 | |||
| 165e2955e0 | |||
| bcce80a52d | |||
| 811ca4ff30 | |||
| 80249609af | |||
| 63039e3537 | |||
| def9d5a016 | |||
| 41dd64f7c8 | |||
| a0bada3e76 | |||
| a405f0a5ad | |||
| 7bd484528a | |||
| 2c6b2b9784 | |||
| 41ca9b704f | |||
| 06a1360714 | |||
| 6583f2e6cc | |||
| eae768615c | |||
| 10b4562982 | |||
| ed5a129b6a | |||
| dc661dabcc | |||
| c5e9cb9b18 | |||
| 576a389028 | |||
| 709c1064a5 | |||
| 8588b89421 | |||
| f7886dcfea | |||
| 60fa6cb4f1 | |||
| 98832d7ade | |||
| dbfed0dc2a | |||
| a674c321de | |||
| d91e9180ea | |||
| fbe7e9f820 | |||
| c7fb1045c2 | |||
| 4ba3e14961 | |||
| 7746835832 | |||
| 264d6186fa | |||
| 3f5c449817 | |||
| d03907b43c | |||
| 56bd72c3bc | |||
| f42ca88e01 | |||
| 87095772fe | |||
| bfca706b31 | |||
| bba0e3ebdb | |||
| 4958061a19 | |||
| 8aec92fe43 | |||
| f84293f270 | |||
| aa830c9a37 | |||
| 58357f2f8b | |||
| 19f40c8fad | |||
| e1aadf9781 | |||
| 3198cd13f4 | |||
| 67b85478aa | |||
| 08cbeceea4 | |||
| 60d7e89452 | |||
| 3937cb105c | |||
| 16c776bf82 | |||
| 467557fb59 | |||
| 140020838b | |||
| 18f329b529 | |||
| 271ea09380 | |||
| d6dc9ab365 | |||
| 376cd07b5c |
@@ -1,17 +1,23 @@
|
||||
#!/bin/sh
|
||||
|
||||
# Сохраняем текущие изменения
|
||||
echo "Сохранение текущих изменений..."
|
||||
git stash -q --keep-index
|
||||
|
||||
# Запускаем форматирование кода
|
||||
echo "Запуск Ruff format..."
|
||||
ruff format . --config pyproject.toml --exclude main.py,handlers/payments
|
||||
|
||||
# Запускаем проверку и исправление кода
|
||||
echo "Запуск Ruff check с автоисправлением..."
|
||||
ruff check . --config pyproject.toml --exclude main.py,handlers/payments --fix
|
||||
|
||||
# Добавляем изменения, внесенные форматированием
|
||||
git add -u
|
||||
|
||||
# Восстанавливаем сохраненные изменения
|
||||
echo "Восстановление сохраненных изменений..."
|
||||
git stash pop -q
|
||||
|
||||
# Выход с кодом 0 (успешно)
|
||||
exit 0
|
||||
+8
-28
@@ -27,19 +27,15 @@ env/
|
||||
*.db
|
||||
|
||||
# Sensitive configuration files
|
||||
/config.py
|
||||
/config.ini
|
||||
/alembic.ini
|
||||
/.env
|
||||
config.py
|
||||
config.ini
|
||||
alembic.ini
|
||||
.env
|
||||
|
||||
# Backup files
|
||||
*.bak
|
||||
*.swp
|
||||
*~
|
||||
backups/
|
||||
backup_bot/
|
||||
Solo_backup/
|
||||
.cursor/
|
||||
|
||||
# Specific project files
|
||||
vpn_users.db
|
||||
@@ -55,29 +51,13 @@ handlers/texts.py
|
||||
Thumbs.db
|
||||
|
||||
nginx.conf
|
||||
/scripts/load_balancer.py
|
||||
/scripts/__pycache__
|
||||
scripts
|
||||
.csv
|
||||
/logs
|
||||
setup.py
|
||||
.ruff_cache
|
||||
.github/workflows/
|
||||
modules/
|
||||
storage/
|
||||
static/web_uploads/
|
||||
|
||||
.license_state
|
||||
Solo_backup/
|
||||
|
||||
# Node / Next.js artifacts (root — web-app has its own)
|
||||
node_modules/
|
||||
.next/
|
||||
.vite/
|
||||
.claude/
|
||||
|
||||
.cursor/hooks.json
|
||||
.cursor/hooks/after-agent-response.cjs
|
||||
.cursor/hooks/before-submit-prompt.cjs
|
||||
.cursor/hooks/after-agent-response.js
|
||||
.cursor/hooks/before-submit-prompt.js
|
||||
.cursor/cursor-notifier.json
|
||||
.cursor/cursor-notifier-start.json
|
||||
nuitka-crash-report*
|
||||
.license_state
|
||||
+1
-3
@@ -20,9 +20,7 @@ RUN rm -rf /app/venv \
|
||||
&& /app/venv/bin/pip install --upgrade pip \
|
||||
&& /app/venv/bin/pip install -r requirements.txt
|
||||
|
||||
RUN adduser --disabled-password --gecos "" appuser \
|
||||
&& mkdir -p /app/backups \
|
||||
&& chown -R appuser:appuser /app
|
||||
RUN adduser --disabled-password --gecos "" appuser && chown -R appuser:appuser /app
|
||||
USER appuser
|
||||
|
||||
CMD ["/app/venv/bin/python", "main.py"]
|
||||
|
||||
@@ -8,12 +8,3 @@ lint:
|
||||
format-payments:
|
||||
@echo "Running Ruff format ONLY on handlers/payments..." && ruff format handlers/payments --config pyproject.toml
|
||||
@echo "Running Ruff check ONLY on handlers/payments..." && ruff check handlers/payments --config pyproject.toml --fix
|
||||
|
||||
test:
|
||||
@echo "Running unit tests..." && cd /tmp && PYTHONPATH="$(CURDIR)" "$(CURDIR)/venv/bin/python" -m unittest discover -s "$(CURDIR)/tests" -q
|
||||
|
||||
test-sudo:
|
||||
@echo "Running unit tests with sudo..." && cd /tmp && sudo env PYTHONPATH="$(CURDIR)" "$(CURDIR)/venv/bin/python" -m unittest discover -s "$(CURDIR)/tests" -q
|
||||
|
||||
smoke:
|
||||
@echo "Running smoke checks..." && bash "$(CURDIR)/tests/smoke_runner.sh"
|
||||
|
||||
+4
-231
@@ -1,60 +1,27 @@
|
||||
import hashlib
|
||||
|
||||
from collections.abc import AsyncGenerator
|
||||
from urllib.parse import urlparse
|
||||
|
||||
from fastapi import Depends, HTTPException, Header, Query, Request, Response
|
||||
from fastapi import Depends, HTTPException, Header, Query
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from datetime import datetime
|
||||
|
||||
from audit import set_api_actor
|
||||
from database import (
|
||||
async_session_maker,
|
||||
identities as idb,
|
||||
identity_sessions as idsess,
|
||||
)
|
||||
from database.access.resolution import ResolvedActor, resolve_actor_from_identity
|
||||
from database.models import Admin, Identity
|
||||
from database import async_session_maker
|
||||
from database.models import Admin
|
||||
|
||||
|
||||
async def get_session() -> AsyncGenerator[AsyncSession, None]:
|
||||
async with async_session_maker() as session:
|
||||
try:
|
||||
yield session
|
||||
await session.commit()
|
||||
except Exception:
|
||||
await session.rollback()
|
||||
raise
|
||||
yield session
|
||||
|
||||
|
||||
def hash_token(token: str) -> str:
|
||||
return hashlib.sha256(token.encode()).hexdigest()
|
||||
|
||||
|
||||
async def bind_identity_actor(
|
||||
request: Request | None,
|
||||
session: AsyncSession,
|
||||
identity: Identity,
|
||||
) -> ResolvedActor:
|
||||
actor = await resolve_actor_from_identity(session, identity)
|
||||
set_api_actor(request, identity_id=actor.identity_id, tg_id=actor.telegram_chat_id)
|
||||
if request is not None:
|
||||
request.state.actor = actor
|
||||
return actor
|
||||
|
||||
|
||||
def get_request_actor(request: Request | None) -> ResolvedActor | None:
|
||||
if request is None:
|
||||
return None
|
||||
return getattr(request.state, "actor", None)
|
||||
|
||||
|
||||
async def verify_admin_token(
|
||||
admin_id: int = Query(..., alias="tg_id"),
|
||||
token: str = Header(..., alias="X-Token"),
|
||||
request: Request = None,
|
||||
session: AsyncSession = Depends(get_session),
|
||||
) -> Admin:
|
||||
hashed = hash_token(token)
|
||||
@@ -62,198 +29,4 @@ async def verify_admin_token(
|
||||
admin = result.scalar_one_or_none()
|
||||
if not admin:
|
||||
raise HTTPException(status_code=401, detail="Unauthorized")
|
||||
set_api_actor(request, tg_id=admin.tg_id)
|
||||
return admin
|
||||
|
||||
|
||||
AUTH_COOKIE_NAME = "auth_token"
|
||||
|
||||
|
||||
IS_ADMIN_COOKIE_NAME = "is_admin"
|
||||
|
||||
|
||||
AUTH_COOKIE_MAX_AGE_SECONDS = 30 * 24 * 60 * 60
|
||||
|
||||
|
||||
def _is_secure_request(request: Request | None) -> bool:
|
||||
if request is None:
|
||||
return False
|
||||
if request.url.scheme == "https":
|
||||
return True
|
||||
forwarded_proto = request.headers.get("x-forwarded-proto", "").lower()
|
||||
return forwarded_proto == "https"
|
||||
|
||||
|
||||
def set_auth_cookie(response: Response, token: str, request: Request | None = None) -> None:
|
||||
"""Устанавливает HttpOnly cookie с auth-токеном на ответ. Используется во всех login-ручках."""
|
||||
response.set_cookie(
|
||||
key=AUTH_COOKIE_NAME,
|
||||
value=token,
|
||||
max_age=AUTH_COOKIE_MAX_AGE_SECONDS,
|
||||
path="/",
|
||||
httponly=True,
|
||||
secure=_is_secure_request(request),
|
||||
samesite="lax",
|
||||
)
|
||||
|
||||
|
||||
def clear_auth_cookie(response: Response, request: Request | None = None) -> None:
|
||||
"""Удаляет auth cookie на стороне браузера."""
|
||||
response.delete_cookie(
|
||||
key=AUTH_COOKIE_NAME,
|
||||
path="/",
|
||||
httponly=True,
|
||||
secure=_is_secure_request(request),
|
||||
samesite="lax",
|
||||
)
|
||||
|
||||
clear_is_admin_cookie(response, request)
|
||||
|
||||
|
||||
def set_is_admin_cookie(response: Response, identity: Identity, request: Request | None = None) -> None:
|
||||
"""Ставит/гасит `is_admin` cookie в зависимости от текущей identity."""
|
||||
if getattr(identity, "is_admin", False):
|
||||
response.set_cookie(
|
||||
key=IS_ADMIN_COOKIE_NAME,
|
||||
value="1",
|
||||
max_age=AUTH_COOKIE_MAX_AGE_SECONDS,
|
||||
path="/",
|
||||
httponly=True,
|
||||
secure=_is_secure_request(request),
|
||||
samesite="lax",
|
||||
)
|
||||
else:
|
||||
clear_is_admin_cookie(response, request)
|
||||
|
||||
|
||||
def clear_is_admin_cookie(response: Response, request: Request | None = None) -> None:
|
||||
response.delete_cookie(
|
||||
key=IS_ADMIN_COOKIE_NAME,
|
||||
path="/",
|
||||
httponly=True,
|
||||
secure=_is_secure_request(request),
|
||||
samesite="lax",
|
||||
)
|
||||
|
||||
|
||||
def _read_auth_cookie(request: Request | None) -> str | None:
|
||||
if request is None:
|
||||
return None
|
||||
raw = request.cookies.get(AUTH_COOKIE_NAME)
|
||||
if not raw:
|
||||
return None
|
||||
raw = raw.strip()
|
||||
return raw or None
|
||||
|
||||
|
||||
async def _identity_from_cookie(session: AsyncSession, request: Request | None) -> Identity | None:
|
||||
token = _read_auth_cookie(request)
|
||||
if not token:
|
||||
return None
|
||||
token_hash = hash_token(token)
|
||||
sess = await idsess.get_session_by_token_hash(session, token_hash)
|
||||
if sess is None:
|
||||
return None
|
||||
if sess.expires_at is not None and sess.expires_at <= datetime.utcnow():
|
||||
return None
|
||||
identity = await idb.get_identity_by_id(session, sess.identity_id)
|
||||
if identity is None:
|
||||
return None
|
||||
await idsess.touch_session_last_seen(session, sess)
|
||||
if request is not None:
|
||||
try:
|
||||
request.state.auth_session = sess
|
||||
except Exception:
|
||||
pass
|
||||
return identity
|
||||
|
||||
|
||||
async def verify_identity_token(
|
||||
request: Request,
|
||||
session: AsyncSession = Depends(get_session),
|
||||
):
|
||||
"""Проверяет токен из HttpOnly cookie `auth_token`; возвращает Identity."""
|
||||
from database.site_state import mark_site_initialized
|
||||
|
||||
identity = await _identity_from_cookie(session, request)
|
||||
if identity is None:
|
||||
raise HTTPException(status_code=401, detail="Unauthorized")
|
||||
await bind_identity_actor(request, session, identity)
|
||||
if getattr(identity, "is_admin", False):
|
||||
await mark_site_initialized(session)
|
||||
return identity
|
||||
|
||||
|
||||
async def verify_identity_admin(
|
||||
request: Request,
|
||||
session: AsyncSession = Depends(get_session),
|
||||
):
|
||||
"""Проверяет токен из cookie и что identity.is_admin; для админских ручек v2."""
|
||||
from database.site_state import mark_site_initialized
|
||||
|
||||
identity = await _identity_from_cookie(session, request)
|
||||
if identity is None:
|
||||
raise HTTPException(status_code=401, detail="Unauthorized")
|
||||
if not identity.is_admin:
|
||||
raise HTTPException(status_code=403, detail="Forbidden")
|
||||
await bind_identity_actor(request, session, identity)
|
||||
await mark_site_initialized(session)
|
||||
return identity
|
||||
|
||||
|
||||
async def verify_identity_admin_short(
|
||||
request: Request,
|
||||
):
|
||||
"""Проверка админа с короткой сессией (для broadcast и др.), чтобы не держать соединение с БД."""
|
||||
identity = None
|
||||
actor = None
|
||||
async with async_session_maker() as session:
|
||||
identity = await _identity_from_cookie(session, request)
|
||||
if identity:
|
||||
actor = await resolve_actor_from_identity(session, identity)
|
||||
await session.commit()
|
||||
if not identity:
|
||||
raise HTTPException(status_code=401, detail="Unauthorized")
|
||||
if not identity.is_admin:
|
||||
raise HTTPException(status_code=403, detail="Forbidden")
|
||||
if actor is not None:
|
||||
set_api_actor(request, identity_id=identity.id, tg_id=actor.telegram_chat_id)
|
||||
if request is not None:
|
||||
request.state.actor = actor
|
||||
else:
|
||||
set_api_actor(request, identity_id=identity.id, tg_id=identity.tg_id)
|
||||
return identity
|
||||
|
||||
|
||||
async def verify_admin_token_short(
|
||||
admin_id: int = Query(..., alias="tg_id"),
|
||||
token: str = Header(..., alias="X-Token"),
|
||||
request: Request = None,
|
||||
) -> Admin:
|
||||
"""Проверка админа с короткой сессией (для broadcast и др.), чтобы не держать соединение с БД."""
|
||||
hashed = hash_token(token)
|
||||
async with async_session_maker() as session:
|
||||
result = await session.execute(select(Admin).where(Admin.tg_id == admin_id, Admin.token == hashed))
|
||||
admin = result.scalar_one_or_none()
|
||||
await session.commit()
|
||||
if not admin:
|
||||
raise HTTPException(status_code=401, detail="Unauthorized")
|
||||
set_api_actor(request, tg_id=admin.tg_id)
|
||||
return admin
|
||||
|
||||
|
||||
def validate_redirect_url(url: str, base_url: str) -> str:
|
||||
"""Validate redirect URL is same-origin or relative. Returns safe URL or base_url fallback."""
|
||||
url = url.strip()
|
||||
if not url:
|
||||
return base_url
|
||||
if url.startswith("/"):
|
||||
return url
|
||||
try:
|
||||
parsed = urlparse(url)
|
||||
base_parsed = urlparse(base_url)
|
||||
if parsed.scheme in ("http", "https") and parsed.netloc == base_parsed.netloc:
|
||||
return url
|
||||
except Exception:
|
||||
pass
|
||||
return base_url
|
||||
|
||||
+43
-198
@@ -1,219 +1,64 @@
|
||||
import asyncio
|
||||
import hashlib
|
||||
import os
|
||||
from time import perf_counter
|
||||
|
||||
from fastapi import Depends, FastAPI, Request
|
||||
from fastapi.responses import ORJSONResponse
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from starlette.middleware.cors import CORSMiddleware
|
||||
from starlette.middleware.gzip import GZipMiddleware
|
||||
from starlette.responses import Response as StarletteResponse
|
||||
from starlette.staticfiles import StaticFiles
|
||||
|
||||
from audit import ensure_api_context, log_api_access, record_api_access_event_background
|
||||
from config import API_LOGGING, API_VERSION, API_CORS_ORIGINS
|
||||
from database import async_session_maker
|
||||
from fastapi import FastAPI, Request
|
||||
from api.routes import (
|
||||
users,
|
||||
keys,
|
||||
coupons,
|
||||
servers,
|
||||
tariffs,
|
||||
gifts,
|
||||
referrals,
|
||||
misc,
|
||||
partners,
|
||||
modules,
|
||||
management,
|
||||
settings,
|
||||
)
|
||||
from config import API_LOGGING
|
||||
from logger import logger
|
||||
|
||||
if API_VERSION == 1:
|
||||
from api.v1 import router as api_router, VERSION as API_DOC_VERSION
|
||||
else:
|
||||
from api.v2 import VERSION as API_DOC_VERSION
|
||||
from api.v2.router import router as api_router
|
||||
|
||||
app = FastAPI(
|
||||
title=f"SoloBot API (Alpha) — API v{API_DOC_VERSION}",
|
||||
version=API_DOC_VERSION,
|
||||
description=f"Версия API: **v{API_DOC_VERSION}**.",
|
||||
title="SoloBot API (Alpha)",
|
||||
version="0.5.2",
|
||||
docs_url="/api/docs",
|
||||
redoc_url="/api/redoc",
|
||||
openapi_url="/api/openapi.json",
|
||||
default_response_class=ORJSONResponse,
|
||||
)
|
||||
|
||||
_cors_origins = API_CORS_ORIGINS if API_CORS_ORIGINS != ["*"] else API_CORS_ORIGINS
|
||||
_cors_credentials = API_CORS_ORIGINS != ["*"]
|
||||
|
||||
app.add_middleware(
|
||||
CORSMiddleware,
|
||||
allow_origins=_cors_origins,
|
||||
allow_credentials=_cors_credentials,
|
||||
allow_methods=["GET", "POST", "PUT", "DELETE", "PATCH", "OPTIONS"],
|
||||
allow_headers=["X-Identity-Id", "X-Token", "Content-Type", "Authorization"],
|
||||
)
|
||||
|
||||
app.add_middleware(GZipMiddleware, minimum_size=1024, compresslevel=6)
|
||||
|
||||
|
||||
@app.exception_handler(Exception)
|
||||
async def _generic_exception_handler(request: Request, exc: Exception):
|
||||
from audit import ensure_api_context
|
||||
|
||||
context = ensure_api_context(request)
|
||||
logger.exception("[API] Unhandled exception at {} {}: {}", request.method, request.url.path, exc)
|
||||
return ORJSONResponse(
|
||||
status_code=500,
|
||||
content={"detail": "Внутренняя ошибка сервера", "request_id": context.request_id},
|
||||
)
|
||||
|
||||
|
||||
_ETAG_MAX_BODY_BYTES = 256 * 1024
|
||||
|
||||
|
||||
@app.middleware("http")
|
||||
async def security_and_cache_middleware(request: Request, call_next):
|
||||
response = await call_next(request)
|
||||
response.headers.setdefault("X-Content-Type-Options", "nosniff")
|
||||
response.headers.setdefault("X-Frame-Options", "DENY")
|
||||
response.headers.setdefault("Referrer-Policy", "strict-origin-when-cross-origin")
|
||||
response.headers.setdefault("X-XSS-Protection", "1; mode=block")
|
||||
|
||||
content_type = response.headers.get("content-type", "")
|
||||
path = request.url.path
|
||||
|
||||
if path.startswith("/api/web/uploads/") and request.method == "GET" and response.status_code == 200:
|
||||
response.headers.setdefault("Cache-Control", "public, max-age=31536000, immutable")
|
||||
return response
|
||||
|
||||
if request.method == "GET" and response.status_code == 200 and "application/json" in content_type:
|
||||
content_length_header = response.headers.get("content-length")
|
||||
try:
|
||||
cl = int(content_length_header) if content_length_header is not None else None
|
||||
except (TypeError, ValueError):
|
||||
cl = None
|
||||
if cl is not None and cl > _ETAG_MAX_BODY_BYTES:
|
||||
response.headers.setdefault("Cache-Control", "no-cache")
|
||||
return response
|
||||
chunks: list[bytes] = []
|
||||
total = 0
|
||||
too_big = False
|
||||
async for chunk in response.body_iterator:
|
||||
total += len(chunk)
|
||||
if total > _ETAG_MAX_BODY_BYTES:
|
||||
chunks.append(chunk)
|
||||
too_big = True
|
||||
async for remaining in response.body_iterator:
|
||||
chunks.append(remaining)
|
||||
break
|
||||
chunks.append(chunk)
|
||||
body = b"".join(chunks)
|
||||
if too_big:
|
||||
headers = dict(response.headers)
|
||||
headers["Cache-Control"] = "no-cache"
|
||||
headers.pop("content-length", None)
|
||||
return StarletteResponse(content=body, status_code=200, headers=headers, media_type=response.media_type)
|
||||
etag = '"' + hashlib.md5(body).hexdigest() + '"'
|
||||
if_none_match = request.headers.get("if-none-match", "")
|
||||
client_etags = [t.strip() for t in if_none_match.split(",") if t.strip()]
|
||||
if etag in client_etags or if_none_match.strip() == "*":
|
||||
return StarletteResponse(status_code=304, headers={"ETag": etag, "Cache-Control": "no-cache"})
|
||||
headers = dict(response.headers)
|
||||
headers["ETag"] = etag
|
||||
headers["Cache-Control"] = "no-cache"
|
||||
return StarletteResponse(content=body, status_code=200, headers=headers, media_type=response.media_type)
|
||||
|
||||
response.headers.setdefault("Cache-Control", "no-store")
|
||||
return response
|
||||
|
||||
|
||||
@app.middleware("http")
|
||||
async def api_access_log_middleware(request: Request, call_next):
|
||||
context = ensure_api_context(request)
|
||||
started = perf_counter()
|
||||
if not API_LOGGING:
|
||||
response = await call_next(request)
|
||||
response.headers["X-Request-Id"] = context.request_id
|
||||
response.headers["X-Response-Time"] = f"{int((perf_counter() - started) * 1000)}ms"
|
||||
return response
|
||||
|
||||
try:
|
||||
response = await call_next(request)
|
||||
except Exception as exc:
|
||||
duration_ms = int((perf_counter() - started) * 1000)
|
||||
log_api_access(
|
||||
request,
|
||||
status_code=500,
|
||||
duration_ms=duration_ms,
|
||||
result="fail",
|
||||
reason=type(exc).__name__,
|
||||
)
|
||||
asyncio.create_task(
|
||||
record_api_access_event_background(
|
||||
async_session_maker,
|
||||
request,
|
||||
result="fail",
|
||||
reason=type(exc).__name__,
|
||||
status_code=500,
|
||||
)
|
||||
)
|
||||
raise
|
||||
return await call_next(request)
|
||||
|
||||
started = perf_counter()
|
||||
response = await call_next(request)
|
||||
duration_ms = int((perf_counter() - started) * 1000)
|
||||
response.headers["X-Request-Id"] = context.request_id
|
||||
response.headers["X-Response-Time"] = f"{duration_ms}ms"
|
||||
result = "success" if response.status_code < 400 else "fail"
|
||||
log_api_access(
|
||||
request,
|
||||
status_code=response.status_code,
|
||||
duration_ms=duration_ms,
|
||||
result=result,
|
||||
)
|
||||
asyncio.create_task(
|
||||
record_api_access_event_background(
|
||||
async_session_maker,
|
||||
request,
|
||||
result=result,
|
||||
reason=None if response.status_code < 400 else str(response.status_code),
|
||||
status_code=response.status_code,
|
||||
)
|
||||
client_ip = request.client.host if request.client else "-"
|
||||
path_qs = request.url.path
|
||||
if request.url.query:
|
||||
path_qs = f"{path_qs}?{request.url.query}"
|
||||
|
||||
logger.info(
|
||||
f'[API] {client_ip} "{request.method} {path_qs}" {response.status_code} {duration_ms}ms'
|
||||
)
|
||||
return response
|
||||
|
||||
|
||||
@app.get("/api/health", include_in_schema=False)
|
||||
async def health():
|
||||
return {"status": "ok"}
|
||||
app.include_router(users.router, prefix="/api/users", tags=["Users"])
|
||||
app.include_router(keys.router, prefix="/api/keys", tags=["Keys"])
|
||||
app.include_router(coupons.router, prefix="/api/coupons", tags=["Coupons"])
|
||||
app.include_router(servers.router, prefix="/api/servers", tags=["Servers"])
|
||||
app.include_router(tariffs.router, prefix="/api/tariffs", tags=["Tariffs"])
|
||||
app.include_router(gifts.router, prefix="/api/gifts", tags=["Gifts"])
|
||||
app.include_router(referrals.router, prefix="/api/referrals", tags=["Referrals"])
|
||||
app.include_router(partners.router, prefix="/api/partners", tags=["Partners"])
|
||||
app.include_router(misc.router, prefix="/api")
|
||||
app.include_router(modules.router, prefix="/api")
|
||||
app.include_router(management.router, prefix="/api/management", tags=["Management"])
|
||||
app.include_router(settings.router, prefix="/api/settings", tags=["Settings"])
|
||||
|
||||
|
||||
from api.depends import get_session as _get_session, verify_identity_admin as _verify_admin
|
||||
|
||||
|
||||
@app.get("/api/health/detailed", include_in_schema=False)
|
||||
async def health_detailed(
|
||||
session: AsyncSession = Depends(_get_session),
|
||||
_identity=Depends(_verify_admin),
|
||||
):
|
||||
import time
|
||||
from sqlalchemy import text
|
||||
from core.redis_cache import _get_redis
|
||||
|
||||
checks: dict[str, object] = {"status": "ok", "timestamp": int(time.time())}
|
||||
|
||||
try:
|
||||
await session.execute(text("SELECT 1"))
|
||||
checks["db"] = {"ok": True}
|
||||
except Exception as e:
|
||||
checks["db"] = {"ok": False, "error": str(e)[:200]}
|
||||
checks["status"] = "degraded"
|
||||
|
||||
try:
|
||||
client = await _get_redis()
|
||||
if client is not None:
|
||||
await client.ping()
|
||||
checks["redis"] = {"ok": True}
|
||||
else:
|
||||
checks["redis"] = {"ok": False, "error": "unavailable"}
|
||||
checks["status"] = "degraded"
|
||||
except Exception as e:
|
||||
checks["redis"] = {"ok": False, "error": str(e)[:200]}
|
||||
checks["status"] = "degraded"
|
||||
|
||||
return checks
|
||||
|
||||
|
||||
app.include_router(api_router)
|
||||
|
||||
_web_uploads_dir = "static/web_uploads"
|
||||
os.makedirs(_web_uploads_dir, exist_ok=True)
|
||||
app.mount("/api/web/uploads", StaticFiles(directory=_web_uploads_dir), name="web_uploads")
|
||||
@app.get("/api", include_in_schema=False)
|
||||
async def root():
|
||||
return {"message": "Welcome to SoloBot API. Docs: /api/docs"}
|
||||
|
||||
@@ -1,62 +0,0 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from fastapi import HTTPException, Request
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from api.depends import _identity_from_cookie
|
||||
|
||||
|
||||
async def enforce_rate_limit(
|
||||
request: Request,
|
||||
session: AsyncSession,
|
||||
*,
|
||||
bucket: str,
|
||||
max_per_window: int,
|
||||
window_sec: int,
|
||||
identity_aware: bool = True,
|
||||
) -> None:
|
||||
try:
|
||||
from api.v2.routes.auth._fallback_limiter import check_and_increment
|
||||
from core.redis_cache import cache_incr_checked
|
||||
except Exception:
|
||||
return
|
||||
|
||||
owner = "anon"
|
||||
if identity_aware:
|
||||
try:
|
||||
identity = await _identity_from_cookie(session, request)
|
||||
if identity is not None and getattr(identity, "id", None):
|
||||
owner = f"id:{identity.id}"
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
if owner == "anon":
|
||||
ip = (request.client.host if request.client else "") or "unknown"
|
||||
owner = f"ip:{ip}"
|
||||
|
||||
key = f"rl:{bucket}:{owner}"
|
||||
try:
|
||||
count, redis_ok = await cache_incr_checked(key, window_sec)
|
||||
if not redis_ok:
|
||||
count = check_and_increment(key, max_per_window, window_sec)
|
||||
except Exception:
|
||||
return
|
||||
|
||||
if count > max_per_window:
|
||||
raise HTTPException(status_code=429, detail="Слишком много запросов, подождите и попробуйте снова")
|
||||
|
||||
|
||||
def rate_limit_dependency(*, bucket: str, max_per_window: int, window_sec: int):
|
||||
from api.depends import get_session
|
||||
from fastapi import Depends
|
||||
|
||||
async def _dep(request: Request, session: AsyncSession = Depends(get_session)) -> None:
|
||||
await enforce_rate_limit(
|
||||
request,
|
||||
session,
|
||||
bucket=bucket,
|
||||
max_per_window=max_per_window,
|
||||
window_sec=window_sec,
|
||||
)
|
||||
|
||||
return _dep
|
||||
@@ -5,14 +5,25 @@ from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy.orm.attributes import InstrumentedAttribute
|
||||
|
||||
from api.depends import get_session, verify_identity_admin
|
||||
from api.v1.routes.base_crud import (
|
||||
_apply_user_relationship_loader,
|
||||
cast_identifier_type,
|
||||
normalize_outgoing_object,
|
||||
to_schema,
|
||||
)
|
||||
from database.access.resolution import resolve_user_optional
|
||||
from api.depends import get_session, verify_admin_token
|
||||
from database.models import Admin
|
||||
|
||||
|
||||
def cast_identifier_type(field: InstrumentedAttribute, value: int | str):
|
||||
column_type = type(field.property.columns[0].type).__name__
|
||||
if column_type in ("Integer", "BigInteger"):
|
||||
return int(value)
|
||||
return value
|
||||
|
||||
|
||||
def normalize_outgoing_object(obj: object) -> None:
|
||||
if hasattr(obj, "vless") and getattr(obj, "vless") is None:
|
||||
setattr(obj, "vless", False)
|
||||
|
||||
|
||||
def to_schema(schema_response: type, obj: object):
|
||||
normalize_outgoing_object(obj)
|
||||
return schema_response.model_validate(obj, from_attributes=True)
|
||||
|
||||
|
||||
def generate_crud_router(
|
||||
@@ -24,28 +35,18 @@ def generate_crud_router(
|
||||
identifier_field: str = "tg_id",
|
||||
parameter_name: str = "tg_id",
|
||||
extra_get_by_email: bool = False,
|
||||
telegram_path_to_user_id: bool = False,
|
||||
enabled_methods: list[str] = ("get_all", "get_one", "get_by_email", "create", "update", "delete"),
|
||||
) -> APIRouter:
|
||||
router = APIRouter()
|
||||
|
||||
async def _path_filter(session: AsyncSession, value: int | str):
|
||||
if telegram_path_to_user_id:
|
||||
u = await resolve_user_optional(session, int(value))
|
||||
if u is None:
|
||||
return None
|
||||
return model.user_id, u.id
|
||||
field = getattr(model, identifier_field)
|
||||
return field, cast_identifier_type(field, value)
|
||||
|
||||
if "get_all" in enabled_methods:
|
||||
|
||||
@router.get("/", response_model=list[schema_response])
|
||||
async def get_all(
|
||||
identity=Depends(verify_identity_admin),
|
||||
admin: Admin = Depends(verify_admin_token),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
):
|
||||
result = await session.execute(_apply_user_relationship_loader(model, select(model)))
|
||||
result = await session.execute(select(model))
|
||||
items = result.scalars().all()
|
||||
for item in items:
|
||||
normalize_outgoing_object(item)
|
||||
@@ -56,7 +57,7 @@ def generate_crud_router(
|
||||
@router.get("/by_email", response_model=schema_response)
|
||||
async def get_by_email(
|
||||
email: str = Query(...),
|
||||
identity=Depends(verify_identity_admin),
|
||||
admin: Admin = Depends(verify_admin_token),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
):
|
||||
result = await session.execute(select(model).where(model.email == email))
|
||||
@@ -70,14 +71,12 @@ def generate_crud_router(
|
||||
@router.get(f"/{{{parameter_name}}}", response_model=schema_response)
|
||||
async def get_one(
|
||||
value: int | str = Path(..., alias=parameter_name),
|
||||
identity=Depends(verify_identity_admin),
|
||||
admin: Admin = Depends(verify_admin_token),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
):
|
||||
resolved = await _path_filter(session, value)
|
||||
if resolved is None:
|
||||
raise HTTPException(status_code=404, detail=f"{model.__name__} not found")
|
||||
field, casted = resolved
|
||||
result = await session.execute(_apply_user_relationship_loader(model, select(model).where(field == casted)))
|
||||
field = getattr(model, identifier_field)
|
||||
casted = cast_identifier_type(field, value)
|
||||
result = await session.execute(select(model).where(field == casted))
|
||||
obj = result.scalar_one_or_none()
|
||||
if not obj:
|
||||
raise HTTPException(status_code=404, detail=f"{model.__name__} not found")
|
||||
@@ -88,14 +87,12 @@ def generate_crud_router(
|
||||
@router.get(f"/all/{{{parameter_name}}}", response_model=list[schema_response])
|
||||
async def get_all_by_field(
|
||||
value: int | str = Path(..., alias=parameter_name),
|
||||
identity=Depends(verify_identity_admin),
|
||||
admin: Admin = Depends(verify_admin_token),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
):
|
||||
resolved = await _path_filter(session, value)
|
||||
if resolved is None:
|
||||
raise HTTPException(status_code=404, detail=f"{model.__name__} not found")
|
||||
field, casted = resolved
|
||||
result = await session.execute(_apply_user_relationship_loader(model, select(model).where(field == casted)))
|
||||
field = getattr(model, identifier_field)
|
||||
casted = cast_identifier_type(field, value)
|
||||
result = await session.execute(select(model).where(field == casted))
|
||||
objs = result.scalars().all()
|
||||
if not objs:
|
||||
raise HTTPException(status_code=404, detail=f"{model.__name__} not found")
|
||||
@@ -108,7 +105,7 @@ def generate_crud_router(
|
||||
@router.post("/", response_model=schema_response)
|
||||
async def create(
|
||||
payload: Any = Body(...),
|
||||
identity=Depends(verify_identity_admin),
|
||||
admin: Admin = Depends(verify_admin_token),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
):
|
||||
validated = schema_create.model_validate(payload)
|
||||
@@ -127,20 +124,20 @@ def generate_crud_router(
|
||||
async def update(
|
||||
payload: Any = Body(...),
|
||||
value: int | str = Path(..., alias=parameter_name),
|
||||
identity=Depends(verify_identity_admin),
|
||||
admin: Admin = Depends(verify_admin_token),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
):
|
||||
resolved = await _path_filter(session, value)
|
||||
if resolved is None:
|
||||
raise HTTPException(status_code=404, detail=f"{model.__name__} not found")
|
||||
field, casted = resolved
|
||||
field = getattr(model, identifier_field)
|
||||
casted = cast_identifier_type(field, value)
|
||||
result = await session.execute(select(model).where(field == casted))
|
||||
obj = result.scalar_one_or_none()
|
||||
if not obj:
|
||||
raise HTTPException(status_code=404, detail=f"{model.__name__} not found")
|
||||
|
||||
validated = schema_update.model_validate(payload)
|
||||
for k, v in validated.model_dump(exclude_unset=True).items():
|
||||
setattr(obj, k, v)
|
||||
|
||||
await session.commit()
|
||||
await session.refresh(obj)
|
||||
return to_schema(schema_response, obj)
|
||||
@@ -150,22 +147,18 @@ def generate_crud_router(
|
||||
@router.delete(f"/{{{parameter_name}}}", response_model=dict)
|
||||
async def delete(
|
||||
value: int | str = Path(..., alias=parameter_name),
|
||||
identity=Depends(verify_identity_admin),
|
||||
admin: Admin = Depends(verify_admin_token),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
):
|
||||
resolved = await _path_filter(session, value)
|
||||
if resolved is None:
|
||||
raise HTTPException(status_code=404, detail=f"{model.__name__} not found")
|
||||
field, casted = resolved
|
||||
field = getattr(model, identifier_field)
|
||||
casted = cast_identifier_type(field, value)
|
||||
result = await session.execute(select(model).where(field == casted))
|
||||
obj = result.scalar_one_or_none()
|
||||
if not obj:
|
||||
raise HTTPException(status_code=404, detail=f"{model.__name__} not found")
|
||||
|
||||
await session.delete(obj)
|
||||
await session.commit()
|
||||
return {"detail": f"{model.__name__} deleted"}
|
||||
|
||||
return router
|
||||
|
||||
|
||||
__all__ = ("generate_crud_router", "to_schema", "normalize_outgoing_object", "cast_identifier_type")
|
||||
@@ -1,7 +1,7 @@
|
||||
from fastapi import APIRouter
|
||||
|
||||
from api.v1.routes.base_crud import generate_crud_router
|
||||
from api.v1.schemas import CouponBase, CouponResponse, CouponUpdate
|
||||
from api.routes.base_crud import generate_crud_router
|
||||
from api.schemas import CouponBase, CouponResponse, CouponUpdate
|
||||
from database.models import Coupon
|
||||
|
||||
|
||||
@@ -3,9 +3,8 @@ from sqlalchemy import delete, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from api.depends import get_session, verify_admin_token
|
||||
from api.v1.routes.base_crud import generate_crud_router
|
||||
from api.v1.schemas import GiftBase, GiftResponse, GiftUpdate, GiftUsageResponse
|
||||
from database.access.resolution import resolve_user_optional
|
||||
from api.routes.base_crud import generate_crud_router
|
||||
from api.schemas import GiftBase, GiftResponse, GiftUpdate, GiftUsageResponse
|
||||
from database.models import Admin, Gift, GiftUsage
|
||||
|
||||
|
||||
@@ -30,10 +29,7 @@ async def get_gifts_by_tg_id(
|
||||
admin: Admin = Depends(verify_admin_token),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
):
|
||||
u = await resolve_user_optional(session, tg_id)
|
||||
if u is None:
|
||||
raise HTTPException(status_code=404, detail="Gifts not found")
|
||||
result = await session.execute(select(Gift).where(Gift.sender_user_id == u.id))
|
||||
result = await session.execute(select(Gift).where(Gift.sender_tg_id == tg_id))
|
||||
gifts = result.scalars().all()
|
||||
if not gifts:
|
||||
raise HTTPException(status_code=404, detail="Gifts not found")
|
||||
@@ -65,4 +61,5 @@ async def delete_gift_with_usages(
|
||||
|
||||
await session.execute(delete(GiftUsage).where(GiftUsage.gift_id == gift_id))
|
||||
await session.delete(gift)
|
||||
await session.commit()
|
||||
return {"message": "Подарок и связанные использования удалены"}
|
||||
@@ -5,12 +5,11 @@ from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from api.depends import get_session, verify_admin_token
|
||||
from api.v1.routes.base_crud import generate_crud_router
|
||||
from api.v1.schemas.keys import KeyBase, KeyCreateRequest, KeyResponse, KeyUpdate
|
||||
from database.access.resolution import resolve_user_optional
|
||||
from api.routes.base_crud import generate_crud_router
|
||||
from api.schemas.keys import KeyBase, KeyCreateRequest, KeyResponse, KeyUpdate
|
||||
from database.models import Admin, Key, Tariff
|
||||
from handlers.keys.operations import create_key_on_cluster, delete_key_from_cluster, renew_key_in_cluster
|
||||
from logger import logger
|
||||
from services.operations import create_key_on_cluster, delete_key_from_cluster, renew_key_in_cluster
|
||||
|
||||
|
||||
router = generate_crud_router(
|
||||
@@ -44,6 +43,7 @@ async def delete_key_by_email(
|
||||
cluster_id=db_key.server_id,
|
||||
)
|
||||
await session.delete(db_key)
|
||||
await session.commit()
|
||||
logger.info(f"[API] Ключ удалён: {db_key.client_id}")
|
||||
return {"message": "Ключ успешно удалён"}
|
||||
|
||||
@@ -63,10 +63,7 @@ async def get_router_keys_by_tg_id(
|
||||
if not tariff_ids:
|
||||
return []
|
||||
|
||||
u = await resolve_user_optional(session, tg_id)
|
||||
if u is None:
|
||||
return []
|
||||
keys_result = await session.execute(select(Key).where(Key.user_id == u.id, Key.tariff_id.in_(tariff_ids)))
|
||||
keys_result = await session.execute(select(Key).where(Key.tg_id == tg_id, Key.tariff_id.in_(tariff_ids)))
|
||||
keys = keys_result.scalars().all()
|
||||
return keys
|
||||
|
||||
@@ -83,7 +80,7 @@ async def edit_key_by_email(
|
||||
if not db_key:
|
||||
raise HTTPException(status_code=404, detail="Ключ не найден")
|
||||
|
||||
for field, value in key_update.model_dump(exclude_unset=True).items():
|
||||
for field, value in key_update.dict(exclude_unset=True).items():
|
||||
if field == "expiry_time" and value is not None:
|
||||
if isinstance(value, int):
|
||||
ms = value
|
||||
@@ -107,6 +104,7 @@ async def edit_key_by_email(
|
||||
hwid_device_limit=getattr(db_key, "device_limit", None),
|
||||
reset_traffic=True,
|
||||
)
|
||||
await session.commit()
|
||||
|
||||
logger.info(f"[API] Ключ обновлён: {db_key.client_id}")
|
||||
return db_key
|
||||
@@ -0,0 +1,219 @@
|
||||
import os
|
||||
import re
|
||||
import subprocess
|
||||
import sys
|
||||
import asyncio
|
||||
from typing import Literal
|
||||
|
||||
import psutil
|
||||
from aiogram import Bot
|
||||
from aiogram.client.default import DefaultBotProperties
|
||||
from aiogram.enums import ParseMode
|
||||
from fastapi import APIRouter, BackgroundTasks, Depends, HTTPException
|
||||
from pydantic import BaseModel
|
||||
from sqlalchemy import distinct, exists, func, select, update
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from api.depends import get_session, verify_admin_token
|
||||
from config import API_TOKEN
|
||||
from core.bootstrap import MANAGEMENT_CONFIG
|
||||
from core.settings.management_config import update_management_config
|
||||
from database.models import Key, User
|
||||
from database.models import Server
|
||||
from handlers.admin.sender.sender_service import BroadcastService
|
||||
from handlers.admin.sender.sender_utils import get_recipients, parse_message_buttons
|
||||
from logger import logger
|
||||
from utils.backup import backup_database
|
||||
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
class MaintenanceUpdate(BaseModel):
|
||||
enabled: bool
|
||||
|
||||
|
||||
class DomainChange(BaseModel):
|
||||
domain: str
|
||||
|
||||
|
||||
class BroadcastLaunchPayload(BaseModel):
|
||||
send_to: Literal["all", "subscribed", "unsubscribed", "untrial", "trial", "hotleads", "cluster"] = "all"
|
||||
text: str
|
||||
photo: str | None = None
|
||||
cluster_name: str | None = None
|
||||
workers: int = 5
|
||||
messages_per_second: int = 35
|
||||
|
||||
|
||||
_broadcast_bot: Bot | None = None
|
||||
|
||||
|
||||
def _get_broadcast_bot() -> Bot:
|
||||
global _broadcast_bot
|
||||
if _broadcast_bot is None:
|
||||
_broadcast_bot = Bot(token=API_TOKEN, default=DefaultBotProperties(parse_mode=ParseMode.HTML))
|
||||
return _broadcast_bot
|
||||
|
||||
|
||||
async def _restart_bot() -> None:
|
||||
await asyncio.sleep(1)
|
||||
try:
|
||||
parent = psutil.Process(os.getpid()).parent()
|
||||
is_systemd = parent and "systemd" in parent.name().lower()
|
||||
|
||||
if is_systemd:
|
||||
subprocess.run(
|
||||
["sudo", "systemctl", "restart", "bot.service"],
|
||||
check=True,
|
||||
)
|
||||
else:
|
||||
python_exe = sys.executable
|
||||
script_path = os.path.abspath(sys.argv[0])
|
||||
os.execv(python_exe, [python_exe, script_path] + sys.argv[1:])
|
||||
except Exception:
|
||||
os._exit(1)
|
||||
|
||||
|
||||
@router.get("/status")
|
||||
async def get_status(admin=Depends(verify_admin_token)):
|
||||
return {
|
||||
"maintenance_enabled": bool(MANAGEMENT_CONFIG.get("MAINTENANCE_ENABLED", False)),
|
||||
"management": dict(MANAGEMENT_CONFIG or {}),
|
||||
}
|
||||
|
||||
|
||||
@router.post("/maintenance")
|
||||
async def set_maintenance(
|
||||
payload: MaintenanceUpdate,
|
||||
admin=Depends(verify_admin_token),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
):
|
||||
current_config = dict(MANAGEMENT_CONFIG or {})
|
||||
current_config["MAINTENANCE_ENABLED"] = bool(payload.enabled)
|
||||
await update_management_config(session, current_config)
|
||||
return {"maintenance_enabled": bool(MANAGEMENT_CONFIG.get("MAINTENANCE_ENABLED", False))}
|
||||
|
||||
|
||||
@router.post("/restart")
|
||||
async def restart_bot(
|
||||
background: BackgroundTasks,
|
||||
admin=Depends(verify_admin_token),
|
||||
):
|
||||
background.add_task(_restart_bot)
|
||||
return {"status": "restarting"}
|
||||
|
||||
|
||||
@router.post("/change-domain")
|
||||
async def change_domain(
|
||||
payload: DomainChange,
|
||||
admin=Depends(verify_admin_token),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
):
|
||||
domain = payload.domain.strip()
|
||||
if not domain or " " in domain or not re.fullmatch(r"[a-zA-Z0-9.-]+", domain):
|
||||
raise HTTPException(status_code=400, detail="Invalid domain")
|
||||
|
||||
new_domain_url = f"https://{domain}"
|
||||
|
||||
stmt = (
|
||||
update(Key)
|
||||
.values(
|
||||
key=func.regexp_replace(Key.key, r"^https://[^/]+", new_domain_url),
|
||||
remnawave_link=func.regexp_replace(Key.remnawave_link, r"^https://[^/]+", new_domain_url),
|
||||
)
|
||||
.where(
|
||||
(Key.key.startswith("https://") & ~Key.key.startswith(new_domain_url))
|
||||
| (Key.remnawave_link.startswith("https://") & ~Key.remnawave_link.startswith(new_domain_url))
|
||||
)
|
||||
)
|
||||
result = await session.execute(stmt)
|
||||
await session.commit()
|
||||
|
||||
return {"updated": result.rowcount or 0}
|
||||
|
||||
|
||||
@router.post("/restore-trials")
|
||||
async def restore_trials(
|
||||
admin=Depends(verify_admin_token),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
):
|
||||
stmt = (
|
||||
update(User)
|
||||
.where(
|
||||
User.trial == 1,
|
||||
~exists(select(Key.tg_id).where(Key.tg_id == User.tg_id)),
|
||||
)
|
||||
.values(trial=0)
|
||||
)
|
||||
result = await session.execute(stmt)
|
||||
await session.commit()
|
||||
|
||||
return {"restored": result.rowcount or 0}
|
||||
|
||||
|
||||
@router.post("/backup")
|
||||
async def trigger_backup(admin=Depends(verify_admin_token)):
|
||||
async def _run_backup() -> None:
|
||||
exception = await backup_database()
|
||||
if exception:
|
||||
logger.error(f"[Management] Backup finished with error: {exception}")
|
||||
|
||||
asyncio.create_task(_run_backup())
|
||||
return {"status": "backup_started"}
|
||||
|
||||
|
||||
@router.get("/broadcast/clusters")
|
||||
async def get_broadcast_clusters(
|
||||
admin=Depends(verify_admin_token),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
):
|
||||
result = await session.execute(select(distinct(Server.cluster_name)).where(Server.cluster_name.is_not(None)))
|
||||
clusters = sorted([row[0] for row in result.all() if row and row[0]])
|
||||
return {"clusters": clusters}
|
||||
|
||||
|
||||
@router.post("/broadcast")
|
||||
async def launch_broadcast(
|
||||
payload: BroadcastLaunchPayload,
|
||||
admin=Depends(verify_admin_token),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
):
|
||||
text_raw = (payload.text or "").strip()
|
||||
if not text_raw:
|
||||
raise HTTPException(status_code=400, detail="Broadcast text is required")
|
||||
|
||||
if payload.send_to == "cluster" and not (payload.cluster_name or "").strip():
|
||||
raise HTTPException(status_code=400, detail="Cluster name is required for cluster broadcast")
|
||||
|
||||
clean_text, keyboard = parse_message_buttons(text_raw)
|
||||
|
||||
max_len = 1024 if payload.photo else 4096
|
||||
if len(clean_text) > max_len:
|
||||
raise HTTPException(status_code=400, detail=f"Message too long. Max {max_len} symbols")
|
||||
|
||||
tg_ids, total_users = await get_recipients(session, payload.send_to, (payload.cluster_name or None))
|
||||
if not tg_ids:
|
||||
return {"success": False, "message": "No recipients found", "stats": {"total_messages": 0}}
|
||||
|
||||
bot = _get_broadcast_bot()
|
||||
messages = [
|
||||
{
|
||||
"tg_id": tg_id,
|
||||
"text": clean_text,
|
||||
"photo": payload.photo,
|
||||
"keyboard": keyboard,
|
||||
}
|
||||
for tg_id in tg_ids
|
||||
]
|
||||
|
||||
workers = max(1, min(int(payload.workers or 5), 30))
|
||||
rate = max(1, min(int(payload.messages_per_second or 35), 60))
|
||||
broadcast_service = BroadcastService(bot=bot, session=session, messages_per_second=rate)
|
||||
stats = await broadcast_service.broadcast(messages, workers=workers)
|
||||
return {
|
||||
"success": True,
|
||||
"message": "Broadcast completed",
|
||||
"recipients": total_users,
|
||||
"stats": stats,
|
||||
}
|
||||
@@ -3,8 +3,8 @@ from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from api.depends import get_session, verify_admin_token
|
||||
from api.v1.routes.base_crud import generate_crud_router
|
||||
from api.v1.schemas import (
|
||||
from api.routes.base_crud import generate_crud_router
|
||||
from api.schemas import (
|
||||
BlockedUserResponse,
|
||||
ManualBanResponse,
|
||||
NotificationResponse,
|
||||
@@ -13,7 +13,6 @@ from api.v1.schemas import (
|
||||
TrackingSourceResponse,
|
||||
)
|
||||
from database import get_tracking_source_stats
|
||||
from database.access.resolution import resolve_user_optional
|
||||
from database.models import (
|
||||
Admin,
|
||||
BlockedUser,
|
||||
@@ -48,10 +47,7 @@ async def get_payments_by_tg_id(
|
||||
admin: Admin = Depends(verify_admin_token),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
):
|
||||
u = await resolve_user_optional(session, tg_id)
|
||||
if u is None:
|
||||
raise HTTPException(status_code=404, detail="Payments not found")
|
||||
result = await session.execute(select(Payment).where(Payment.user_id == u.id))
|
||||
result = await session.execute(select(Payment).where(Payment.tg_id == tg_id))
|
||||
payments = result.scalars().all()
|
||||
if not payments:
|
||||
raise HTTPException(status_code=404, detail="Payments not found")
|
||||
@@ -64,8 +60,7 @@ router.include_router(
|
||||
schema_response=NotificationResponse,
|
||||
schema_create=None,
|
||||
schema_update=None,
|
||||
identifier_field="user_id",
|
||||
telegram_path_to_user_id=True,
|
||||
identifier_field="tg_id",
|
||||
enabled_methods=["get_all", "get_one", "delete"],
|
||||
),
|
||||
prefix="/notifications",
|
||||
@@ -80,9 +75,7 @@ router.include_router(
|
||||
schema_response=ManualBanResponse,
|
||||
schema_create=None,
|
||||
schema_update=None,
|
||||
identifier_field="user_id",
|
||||
parameter_name="tg_id",
|
||||
telegram_path_to_user_id=True,
|
||||
identifier_field="tg_id",
|
||||
enabled_methods=["get_all", "get_one", "delete"],
|
||||
),
|
||||
prefix="/manual-bans",
|
||||
@@ -96,9 +89,7 @@ router.include_router(
|
||||
schema_response=BlockedUserResponse,
|
||||
schema_create=None,
|
||||
schema_update=None,
|
||||
identifier_field="user_id",
|
||||
parameter_name="tg_id",
|
||||
telegram_path_to_user_id=True,
|
||||
identifier_field="tg_id",
|
||||
enabled_methods=["get_all", "get_one", "delete"],
|
||||
),
|
||||
prefix="/blocked-users",
|
||||
@@ -112,9 +103,7 @@ router.include_router(
|
||||
schema_response=TemporaryDataResponse,
|
||||
schema_create=None,
|
||||
schema_update=None,
|
||||
identifier_field="user_id",
|
||||
parameter_name="tg_id",
|
||||
telegram_path_to_user_id=True,
|
||||
identifier_field="tg_id",
|
||||
enabled_methods=["get_all", "get_one", "delete"],
|
||||
),
|
||||
prefix="/temporary-data",
|
||||
@@ -1,125 +1,124 @@
|
||||
import pkgutil
|
||||
|
||||
from pathlib import Path
|
||||
from typing import Literal
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from pydantic import BaseModel
|
||||
|
||||
from api.depends import verify_admin_token
|
||||
from core.executor import run_io
|
||||
from utils.modules_loader import _is_safe_module_name
|
||||
from utils.modules_manager import manager
|
||||
|
||||
|
||||
router = APIRouter(prefix="/modules", tags=["Modules"])
|
||||
|
||||
|
||||
MODULES_DIR = Path(__file__).resolve().parents[3] / "modules"
|
||||
|
||||
|
||||
class ModuleAction(BaseModel):
|
||||
action: Literal["start", "stop", "restart"]
|
||||
|
||||
|
||||
def _available_module_names() -> list[str]:
|
||||
candidates: set[str] = set()
|
||||
if MODULES_DIR.is_dir():
|
||||
for _finder, name, _ispkg in pkgutil.iter_modules([str(MODULES_DIR)]):
|
||||
name = (name or "").strip()
|
||||
if name and _is_safe_module_name(name):
|
||||
candidates.add(name)
|
||||
|
||||
return sorted(n for n in candidates if _is_safe_module_name(n))
|
||||
|
||||
|
||||
def _prune_missing_state(installed: set[str]) -> None:
|
||||
changed = False
|
||||
|
||||
stale_disabled = {name for name in manager.disabled if name not in installed}
|
||||
if stale_disabled:
|
||||
for name in stale_disabled:
|
||||
manager.disabled.discard(name)
|
||||
changed = True
|
||||
|
||||
stale_registry = [name for name in list(manager.registry.keys()) if name not in installed]
|
||||
if stale_registry:
|
||||
for name in stale_registry:
|
||||
manager.registry.pop(name, None)
|
||||
changed = True
|
||||
|
||||
if changed:
|
||||
save_state = getattr(manager, "_save_state", None)
|
||||
if callable(save_state):
|
||||
save_state()
|
||||
|
||||
|
||||
def _module_state(name: str) -> dict:
|
||||
normalized = name.strip()
|
||||
record = manager.registry.get(normalized)
|
||||
is_enabled = manager.is_enabled(normalized)
|
||||
return {
|
||||
"name": normalized,
|
||||
"enabled": is_enabled,
|
||||
"loaded": bool(record and record.enabled),
|
||||
"autostart": manager.should_autostart(normalized),
|
||||
}
|
||||
|
||||
|
||||
def _read_local_module_version(name: str) -> str | None:
|
||||
version_file = MODULES_DIR / name / "VERSION"
|
||||
if not version_file.exists() or not version_file.is_file():
|
||||
return None
|
||||
|
||||
try:
|
||||
with version_file.open("r", encoding="utf-8") as handle:
|
||||
for line in handle:
|
||||
value = line.strip()
|
||||
if value:
|
||||
return value
|
||||
except Exception:
|
||||
return None
|
||||
return None
|
||||
|
||||
|
||||
def sync_list_modules() -> list:
|
||||
"""Вся синхронная работа со списком модулей (файлы, состояние). Вызывать через run_io()."""
|
||||
refresh = getattr(manager, "refresh_state", None) or getattr(manager, "_load_state", None)
|
||||
if callable(refresh):
|
||||
refresh()
|
||||
module_names = _available_module_names()
|
||||
_prune_missing_state(set(module_names))
|
||||
modules = [_module_state(name) for name in module_names]
|
||||
for item in modules:
|
||||
name = str(item.get("name") or "").strip()
|
||||
item["local_version"] = _read_local_module_version(name)
|
||||
return modules
|
||||
|
||||
|
||||
@router.get("/")
|
||||
async def list_modules(admin=Depends(verify_admin_token)):
|
||||
modules = await run_io(sync_list_modules)
|
||||
return {"items": modules}
|
||||
|
||||
|
||||
@router.post("/{module_name}/actions")
|
||||
async def control_module(module_name: str, payload: ModuleAction, admin=Depends(verify_admin_token)):
|
||||
name = (module_name or "").strip()
|
||||
if not _is_safe_module_name(name):
|
||||
raise HTTPException(status_code=404, detail="Module not found")
|
||||
|
||||
try:
|
||||
if payload.action == "start":
|
||||
await manager.start(name)
|
||||
elif payload.action == "stop":
|
||||
await manager.stop(name)
|
||||
elif payload.action == "restart":
|
||||
await manager.restart(name)
|
||||
else:
|
||||
raise HTTPException(status_code=400, detail="Unsupported action")
|
||||
except ValueError as exc:
|
||||
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
||||
except RuntimeError as exc:
|
||||
raise HTTPException(status_code=500, detail=str(exc)) from exc
|
||||
|
||||
return {"item": _module_state(name)}
|
||||
import pkgutil
|
||||
from pathlib import Path
|
||||
from typing import Literal
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from pydantic import BaseModel
|
||||
|
||||
from api.depends import verify_admin_token
|
||||
from utils.modules_loader import _is_safe_module_name
|
||||
from utils.modules_manager import manager
|
||||
|
||||
|
||||
router = APIRouter(prefix="/modules", tags=["Modules"])
|
||||
|
||||
|
||||
MODULES_DIR = Path(__file__).resolve().parents[2] / "modules"
|
||||
|
||||
|
||||
class ModuleAction(BaseModel):
|
||||
action: Literal["start", "stop", "restart"]
|
||||
|
||||
|
||||
def _available_module_names() -> list[str]:
|
||||
candidates: set[str] = set()
|
||||
if MODULES_DIR.is_dir():
|
||||
for _finder, name, _ispkg in pkgutil.iter_modules([str(MODULES_DIR)]):
|
||||
name = (name or "").strip()
|
||||
if name and _is_safe_module_name(name):
|
||||
candidates.add(name)
|
||||
|
||||
return sorted(n for n in candidates if _is_safe_module_name(n))
|
||||
|
||||
|
||||
def _prune_missing_state(installed: set[str]) -> None:
|
||||
changed = False
|
||||
|
||||
stale_disabled = {name for name in manager.disabled if name not in installed}
|
||||
if stale_disabled:
|
||||
for name in stale_disabled:
|
||||
manager.disabled.discard(name)
|
||||
changed = True
|
||||
|
||||
stale_registry = [name for name in list(manager.registry.keys()) if name not in installed]
|
||||
if stale_registry:
|
||||
for name in stale_registry:
|
||||
manager.registry.pop(name, None)
|
||||
changed = True
|
||||
|
||||
if changed:
|
||||
save_state = getattr(manager, "_save_state", None)
|
||||
if callable(save_state):
|
||||
save_state()
|
||||
|
||||
|
||||
def _module_state(name: str) -> dict:
|
||||
normalized = name.strip()
|
||||
record = manager.registry.get(normalized)
|
||||
is_enabled = manager.is_enabled(normalized)
|
||||
return {
|
||||
"name": normalized,
|
||||
"enabled": is_enabled,
|
||||
"loaded": bool(record and record.enabled),
|
||||
"autostart": manager.should_autostart(normalized),
|
||||
}
|
||||
|
||||
|
||||
def _read_local_module_version(name: str) -> str | None:
|
||||
version_file = MODULES_DIR / name / "VERSION"
|
||||
if not version_file.exists() or not version_file.is_file():
|
||||
return None
|
||||
|
||||
try:
|
||||
with version_file.open("r", encoding="utf-8") as handle:
|
||||
for line in handle:
|
||||
value = line.strip()
|
||||
if value:
|
||||
return value
|
||||
except Exception:
|
||||
return None
|
||||
return None
|
||||
|
||||
|
||||
@router.get("/")
|
||||
async def list_modules(admin=Depends(verify_admin_token)):
|
||||
refresh = getattr(manager, "refresh_state", None)
|
||||
if callable(refresh):
|
||||
refresh()
|
||||
else:
|
||||
legacy_refresh = getattr(manager, "_load_state", None)
|
||||
if callable(legacy_refresh):
|
||||
legacy_refresh()
|
||||
module_names = _available_module_names()
|
||||
_prune_missing_state(set(module_names))
|
||||
modules = [_module_state(name) for name in module_names]
|
||||
|
||||
for item in modules:
|
||||
name = str(item.get("name") or "").strip()
|
||||
local_version = _read_local_module_version(name)
|
||||
item["local_version"] = local_version
|
||||
|
||||
return {"items": modules}
|
||||
|
||||
|
||||
@router.post("/{module_name}/actions")
|
||||
async def control_module(module_name: str, payload: ModuleAction, admin=Depends(verify_admin_token)):
|
||||
name = (module_name or "").strip()
|
||||
if not _is_safe_module_name(name):
|
||||
raise HTTPException(status_code=404, detail="Module not found")
|
||||
|
||||
try:
|
||||
if payload.action == "start":
|
||||
await manager.start(name)
|
||||
elif payload.action == "stop":
|
||||
await manager.stop(name)
|
||||
elif payload.action == "restart":
|
||||
await manager.restart(name)
|
||||
else:
|
||||
raise HTTPException(status_code=400, detail="Unsupported action")
|
||||
except ValueError as exc:
|
||||
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
||||
except RuntimeError as exc:
|
||||
raise HTTPException(status_code=500, detail=str(exc)) from exc
|
||||
|
||||
return {"item": _module_state(name)}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -3,9 +3,8 @@ from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from api.depends import get_session, verify_admin_token
|
||||
from api.v1.routes.base_crud import generate_crud_router
|
||||
from api.v1.schemas import ReferralResponse
|
||||
from database.access.resolution import resolve_user_optional
|
||||
from api.routes.base_crud import generate_crud_router
|
||||
from api.schemas import ReferralResponse
|
||||
from database.models import Admin, Referral
|
||||
|
||||
|
||||
@@ -14,9 +13,7 @@ router = generate_crud_router(
|
||||
schema_response=ReferralResponse,
|
||||
schema_create=None,
|
||||
schema_update=None,
|
||||
identifier_field="referrer_user_id",
|
||||
parameter_name="referrer_tg_id",
|
||||
telegram_path_to_user_id=True,
|
||||
identifier_field="referrer_tg_id",
|
||||
enabled_methods=["get_all", "get_one", "get_all_by_field"],
|
||||
)
|
||||
|
||||
@@ -28,18 +25,12 @@ async def delete_one_referral(
|
||||
admin: Admin = Depends(verify_admin_token),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
):
|
||||
ru_ref = await resolve_user_optional(session, referrer_tg_id)
|
||||
rd_ref = await resolve_user_optional(session, referred_tg_id)
|
||||
if ru_ref is None or rd_ref is None:
|
||||
raise HTTPException(status_code=404, detail="Referral not found")
|
||||
result = await session.execute(
|
||||
select(Referral).where(
|
||||
Referral.referrer_user_id == ru_ref.id,
|
||||
Referral.referred_user_id == rd_ref.id,
|
||||
)
|
||||
select(Referral).where(Referral.referrer_tg_id == referrer_tg_id, Referral.referred_tg_id == referred_tg_id)
|
||||
)
|
||||
obj = result.scalar_one_or_none()
|
||||
if not obj:
|
||||
raise HTTPException(status_code=404, detail="Referral not found")
|
||||
await session.delete(obj)
|
||||
await session.commit()
|
||||
return {"status": "deleted_one"}
|
||||
@@ -1,7 +1,7 @@
|
||||
from fastapi import APIRouter
|
||||
|
||||
from api.v1.routes.base_crud import generate_crud_router
|
||||
from api.v1.schemas import ServerBase, ServerResponse, ServerUpdate
|
||||
from api.routes.base_crud import generate_crud_router
|
||||
from api.schemas import ServerBase, ServerResponse, ServerUpdate
|
||||
from database.models import Server
|
||||
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
from typing import Any
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from pydantic import BaseModel
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from api.depends import get_session, verify_admin_token
|
||||
from api.v1.schemas.settings import SettingResponse, SettingUpsert
|
||||
from api.schemas.settings import SettingResponse, SettingUpsert
|
||||
from database.models import Setting
|
||||
from database.settings import set_setting
|
||||
from core.settings.buttons_config import BUTTONS_CONFIG, update_buttons_config
|
||||
from core.settings.modes_config import MODES_CONFIG, update_modes_config
|
||||
from core.settings.money_config import MONEY_CONFIG, update_money_config
|
||||
@@ -14,9 +15,7 @@ from core.settings.notifications_config import NOTIFICATIONS_CONFIG, update_noti
|
||||
from core.settings.payments_config import PAYMENTS_CONFIG, update_payments_config
|
||||
from core.settings.providers_order_config import PROVIDERS_ORDER, update_providers_order
|
||||
from core.settings.tariffs_config import TARIFFS_CONFIG, update_tariffs_config
|
||||
from database.models import Setting
|
||||
from database.settings import set_setting
|
||||
from database.settings_cache import settings_cache
|
||||
from pydantic import BaseModel
|
||||
|
||||
|
||||
router = APIRouter()
|
||||
@@ -27,9 +26,12 @@ class ConfigUpdatePayload(BaseModel):
|
||||
|
||||
|
||||
@router.get("/", response_model=list[SettingResponse])
|
||||
async def get_all_settings(admin=Depends(verify_admin_token)):
|
||||
"""Список всех настроек (из кэша, без запроса к БД)."""
|
||||
return settings_cache.get_all()
|
||||
async def get_all_settings(
|
||||
admin=Depends(verify_admin_token),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
):
|
||||
result = await session.execute(select(Setting))
|
||||
return result.scalars().all()
|
||||
|
||||
|
||||
@router.get("/configs")
|
||||
@@ -104,9 +106,13 @@ async def update_config_scope(
|
||||
|
||||
|
||||
@router.get("/{key}", response_model=SettingResponse)
|
||||
async def get_setting_by_key(key: str, admin=Depends(verify_admin_token)):
|
||||
"""Настройка по ключу (из кэша, без запроса к БД)."""
|
||||
obj = settings_cache.get(key)
|
||||
async def get_setting_by_key(
|
||||
key: str,
|
||||
admin=Depends(verify_admin_token),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
):
|
||||
result = await session.execute(select(Setting).where(Setting.key == key))
|
||||
obj = result.scalar_one_or_none()
|
||||
if not obj:
|
||||
raise HTTPException(status_code=404, detail="Setting not found")
|
||||
return obj
|
||||
@@ -125,14 +131,8 @@ async def upsert_setting(
|
||||
value=payload.value,
|
||||
description=payload.description,
|
||||
)
|
||||
await session.commit()
|
||||
await session.refresh(obj)
|
||||
settings_cache.update(
|
||||
key,
|
||||
obj.value,
|
||||
obj.description,
|
||||
created_at=getattr(obj, "created_at", None),
|
||||
updated_at=getattr(obj, "updated_at", None),
|
||||
)
|
||||
return obj
|
||||
|
||||
|
||||
@@ -147,5 +147,7 @@ async def delete_setting(
|
||||
if not obj:
|
||||
raise HTTPException(status_code=404, detail="Setting not found")
|
||||
await session.delete(obj)
|
||||
settings_cache.delete(key)
|
||||
await session.commit()
|
||||
return {"detail": "Setting deleted"}
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
from fastapi import APIRouter
|
||||
|
||||
from api.v1.routes.base_crud import generate_crud_router
|
||||
from api.v1.schemas import TariffBase, TariffResponse, TariffUpdate
|
||||
from api.routes.base_crud import generate_crud_router
|
||||
from api.schemas import TariffBase, TariffResponse, TariffUpdate
|
||||
from database.models import Tariff
|
||||
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
import asyncio
|
||||
|
||||
from fastapi import Depends, HTTPException, Path
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from api.depends import get_session, verify_admin_token
|
||||
from api.routes.base_crud import generate_crud_router
|
||||
from api.schemas.users import UserBase, UserResponse, UserUpdate
|
||||
from database import delete_user_data, get_servers
|
||||
from database.models import Key, User
|
||||
from handlers.keys.operations import delete_key_from_cluster
|
||||
from logger import logger
|
||||
|
||||
|
||||
router = generate_crud_router(
|
||||
model=User,
|
||||
schema_response=UserResponse,
|
||||
schema_create=UserBase,
|
||||
schema_update=UserUpdate,
|
||||
identifier_field="tg_id",
|
||||
enabled_methods=["get_all", "get_one", "get_by_email", "create", "update"],
|
||||
)
|
||||
|
||||
|
||||
@router.delete("/{tg_id}", response_model=dict)
|
||||
async def delete_user(
|
||||
tg_id: int = Path(..., description="Telegram ID пользователя"),
|
||||
admin=Depends(verify_admin_token),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
):
|
||||
try:
|
||||
result = await session.execute(select(Key.email, Key.client_id).where(Key.tg_id == tg_id))
|
||||
key_records = result.all()
|
||||
|
||||
async def delete_keys_from_servers():
|
||||
try:
|
||||
servers = await get_servers(session=session)
|
||||
tasks = []
|
||||
for email, client_id in key_records:
|
||||
for cluster_id in servers:
|
||||
tasks.append(delete_key_from_cluster(cluster_id, email, client_id, session))
|
||||
await asyncio.gather(*tasks, return_exceptions=True)
|
||||
except Exception as e:
|
||||
logger.error(f"[DELETE] Ошибка при удалении ключей с серверов для пользователя {tg_id}: {e}")
|
||||
|
||||
await delete_keys_from_servers()
|
||||
await delete_user_data(session, tg_id)
|
||||
|
||||
return {"detail": f"Пользователь {tg_id} и его ключи успешно удалены."}
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"[DELETE] Ошибка при удалении пользователя {tg_id}: {e}")
|
||||
raise HTTPException(status_code=500, detail="Ошибка при удалении пользователя")
|
||||
@@ -4,13 +4,11 @@ from pydantic import BaseModel
|
||||
|
||||
|
||||
class GiftBase(BaseModel):
|
||||
sender_user_id: int
|
||||
recipient_user_id: int | None = None
|
||||
sender_tg_id: int
|
||||
recipient_tg_id: int | None = None
|
||||
selected_months: int | None = None
|
||||
expiry_time: datetime
|
||||
gift_link: str
|
||||
telegram_gift_link: str | None = None
|
||||
site_gift_link: str | None = None
|
||||
is_used: bool = False
|
||||
is_unlimited: bool | None = False
|
||||
max_usages: int | None = None
|
||||
@@ -35,12 +33,10 @@ class GiftUsageResponse(BaseModel):
|
||||
|
||||
|
||||
class GiftUpdate(BaseModel):
|
||||
recipient_user_id: int | None = None
|
||||
recipient_tg_id: int | None = None
|
||||
selected_months: int | None = None
|
||||
expiry_time: datetime | None = None
|
||||
gift_link: str | None = None
|
||||
telegram_gift_link: str | None = None
|
||||
site_gift_link: str | None = None
|
||||
is_used: bool | None = None
|
||||
is_unlimited: bool | None = None
|
||||
max_usages: int | None = None
|
||||
@@ -2,9 +2,8 @@ from pydantic import BaseModel, Field
|
||||
|
||||
|
||||
class KeyBase(BaseModel):
|
||||
user_id: int
|
||||
tg_id: int
|
||||
client_id: str
|
||||
tg_id: int | None = None
|
||||
email: str | None = None
|
||||
created_at: int | None = None
|
||||
expiry_time: int
|
||||
@@ -0,0 +1,9 @@
|
||||
from pydantic import BaseModel
|
||||
|
||||
|
||||
class MaintenanceUpdate(BaseModel):
|
||||
enabled: bool
|
||||
|
||||
|
||||
class DomainChange(BaseModel):
|
||||
domain: str
|
||||
@@ -4,8 +4,7 @@ from pydantic import BaseModel
|
||||
|
||||
|
||||
class PaymentBase(BaseModel):
|
||||
user_id: int
|
||||
tg_id: int | None = None
|
||||
tg_id: int
|
||||
amount: float
|
||||
payment_system: str
|
||||
status: str
|
||||
@@ -20,8 +19,8 @@ class PaymentResponse(PaymentBase):
|
||||
|
||||
|
||||
class ReferralResponse(BaseModel):
|
||||
referred_user_id: int
|
||||
referrer_user_id: int
|
||||
referred_tg_id: int
|
||||
referrer_tg_id: int
|
||||
reward_issued: bool = False
|
||||
|
||||
class Config:
|
||||
@@ -38,13 +37,11 @@ class NotificationResponse(BaseModel):
|
||||
|
||||
|
||||
class GiftBase(BaseModel):
|
||||
sender_user_id: int
|
||||
recipient_user_id: int | None = None
|
||||
sender_tg_id: int
|
||||
recipient_tg_id: int | None = None
|
||||
selected_months: int
|
||||
expiry_time: datetime
|
||||
gift_link: str
|
||||
telegram_gift_link: str | None = None
|
||||
site_gift_link: str | None = None
|
||||
is_used: bool = False
|
||||
is_unlimited: bool = False
|
||||
max_usages: int | None = None
|
||||
@@ -69,8 +66,7 @@ class GiftUsageResponse(BaseModel):
|
||||
|
||||
|
||||
class ManualBanResponse(BaseModel):
|
||||
user_id: int
|
||||
tg_id: int | None = None
|
||||
tg_id: int
|
||||
banned_at: datetime
|
||||
reason: str
|
||||
banned_by: int
|
||||
@@ -81,8 +77,7 @@ class ManualBanResponse(BaseModel):
|
||||
|
||||
|
||||
class TemporaryDataResponse(BaseModel):
|
||||
user_id: int
|
||||
tg_id: int | None = None
|
||||
tg_id: int
|
||||
state: str
|
||||
data: dict
|
||||
updated_at: datetime
|
||||
@@ -92,8 +87,7 @@ class TemporaryDataResponse(BaseModel):
|
||||
|
||||
|
||||
class BlockedUserResponse(BaseModel):
|
||||
user_id: int
|
||||
tg_id: int | None = None
|
||||
tg_id: int
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
@@ -2,8 +2,8 @@ from pydantic import BaseModel
|
||||
|
||||
|
||||
class ReferralResponse(BaseModel):
|
||||
referred_user_id: int
|
||||
referrer_user_id: int
|
||||
referred_tg_id: int
|
||||
referrer_tg_id: int
|
||||
reward_issued: bool = False
|
||||
|
||||
class Config:
|
||||
@@ -1,6 +0,0 @@
|
||||
from api.v1.router import router
|
||||
|
||||
|
||||
VERSION = "1.0.0"
|
||||
|
||||
__all__ = ("router", "VERSION")
|
||||
@@ -1,38 +0,0 @@
|
||||
from fastapi import APIRouter
|
||||
|
||||
from api.v1.routes import (
|
||||
coupons,
|
||||
gifts,
|
||||
keys,
|
||||
management,
|
||||
misc,
|
||||
modules,
|
||||
partners,
|
||||
referrals,
|
||||
servers,
|
||||
settings,
|
||||
tariffs,
|
||||
users,
|
||||
)
|
||||
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.get("/api", include_in_schema=False)
|
||||
async def root():
|
||||
return {"message": "Welcome to SoloBot API", "docs": "/api/docs"}
|
||||
|
||||
|
||||
router.include_router(users.router, prefix="/api/users", tags=["Users"])
|
||||
router.include_router(keys.router, prefix="/api/keys", tags=["Keys"])
|
||||
router.include_router(coupons.router, prefix="/api/coupons", tags=["Coupons"])
|
||||
router.include_router(servers.router, prefix="/api/servers", tags=["Servers"])
|
||||
router.include_router(tariffs.router, prefix="/api/tariffs", tags=["Tariffs"])
|
||||
router.include_router(gifts.router, prefix="/api/gifts", tags=["Gifts"])
|
||||
router.include_router(referrals.router, prefix="/api/referrals", tags=["Referrals"])
|
||||
router.include_router(partners.router, prefix="/api/partners", tags=["Partners"])
|
||||
router.include_router(misc.router, prefix="/api")
|
||||
router.include_router(modules.router, prefix="/api")
|
||||
router.include_router(management.router, prefix="/api/management", tags=["Management"])
|
||||
router.include_router(settings.router, prefix="/api/settings", tags=["Settings"])
|
||||
@@ -1,206 +0,0 @@
|
||||
from typing import Any
|
||||
|
||||
from fastapi import APIRouter, Body, Depends, HTTPException, Path, Query
|
||||
from sqlalchemy import (
|
||||
inspect as sa_inspect,
|
||||
select,
|
||||
)
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy.orm import selectinload
|
||||
from sqlalchemy.orm.attributes import InstrumentedAttribute
|
||||
|
||||
from api.depends import get_session, verify_admin_token
|
||||
from database.access.resolution import resolve_user_optional
|
||||
from database.models import Admin
|
||||
from handlers.texts import get_site_gift_link, get_telegram_gift_link
|
||||
|
||||
|
||||
def _apply_user_relationship_loader(model: type, stmt):
|
||||
if model.__name__ in ("ManualBan", "BlockedUser", "TemporaryData"):
|
||||
return stmt.options(selectinload(model.user))
|
||||
return stmt
|
||||
|
||||
|
||||
def cast_identifier_type(field: InstrumentedAttribute, value: int | str):
|
||||
column_type = type(field.property.columns[0].type).__name__
|
||||
if column_type in ("Integer", "BigInteger"):
|
||||
return int(value)
|
||||
return value
|
||||
|
||||
|
||||
def normalize_outgoing_object(obj: object) -> None:
|
||||
if hasattr(obj, "vless") and obj.vless is None:
|
||||
obj.vless = False
|
||||
cls_name = type(obj).__name__
|
||||
if cls_name == "Gift":
|
||||
gift_id = getattr(obj, "gift_id", None)
|
||||
if gift_id:
|
||||
obj.telegram_gift_link = get_telegram_gift_link(gift_id)
|
||||
obj.site_gift_link = get_site_gift_link(gift_id)
|
||||
if cls_name in ("ManualBan", "BlockedUser", "TemporaryData"):
|
||||
insp = sa_inspect(obj)
|
||||
stored = getattr(obj, "tg_id", None)
|
||||
if "user" in insp.unloaded:
|
||||
obj.tg_id = stored
|
||||
return
|
||||
rel = getattr(obj, "user", None)
|
||||
rel_tg = getattr(rel, "tg_id", None) if rel is not None else None
|
||||
obj.tg_id = stored if stored is not None else rel_tg
|
||||
|
||||
|
||||
def to_schema(schema_response: type, obj: object):
|
||||
normalize_outgoing_object(obj)
|
||||
return schema_response.model_validate(obj, from_attributes=True)
|
||||
|
||||
|
||||
def generate_crud_router(
|
||||
*,
|
||||
model: type,
|
||||
schema_response: type,
|
||||
schema_create: type,
|
||||
schema_update: type,
|
||||
identifier_field: str = "tg_id",
|
||||
parameter_name: str = "tg_id",
|
||||
extra_get_by_email: bool = False,
|
||||
telegram_path_to_user_id: bool = False,
|
||||
enabled_methods: list[str] = ("get_all", "get_one", "get_by_email", "create", "update", "delete"),
|
||||
) -> APIRouter:
|
||||
router = APIRouter()
|
||||
|
||||
async def _path_filter(session: AsyncSession, value: int | str):
|
||||
if telegram_path_to_user_id:
|
||||
u = await resolve_user_optional(session, int(value))
|
||||
if u is None:
|
||||
return None
|
||||
return model.user_id, u.id
|
||||
field = getattr(model, identifier_field)
|
||||
return field, cast_identifier_type(field, value)
|
||||
|
||||
if "get_all" in enabled_methods:
|
||||
|
||||
@router.get("/", response_model=list[schema_response])
|
||||
async def get_all(
|
||||
admin: Admin = Depends(verify_admin_token),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
):
|
||||
result = await session.execute(_apply_user_relationship_loader(model, select(model)))
|
||||
items = result.scalars().all()
|
||||
for item in items:
|
||||
normalize_outgoing_object(item)
|
||||
return [schema_response.model_validate(item, from_attributes=True) for item in items]
|
||||
|
||||
if "get_by_email" in enabled_methods and extra_get_by_email:
|
||||
|
||||
@router.get("/by_email", response_model=schema_response)
|
||||
async def get_by_email(
|
||||
email: str = Query(...),
|
||||
admin: Admin = Depends(verify_admin_token),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
):
|
||||
result = await session.execute(select(model).where(model.email == email))
|
||||
obj = result.scalar_one_or_none()
|
||||
if not obj:
|
||||
raise HTTPException(status_code=404, detail="Not found by email")
|
||||
return to_schema(schema_response, obj)
|
||||
|
||||
if "get_one" in enabled_methods:
|
||||
|
||||
@router.get(f"/{{{parameter_name}}}", response_model=schema_response)
|
||||
async def get_one(
|
||||
value: int | str = Path(..., alias=parameter_name),
|
||||
admin: Admin = Depends(verify_admin_token),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
):
|
||||
resolved = await _path_filter(session, value)
|
||||
if resolved is None:
|
||||
raise HTTPException(status_code=404, detail=f"{model.__name__} not found")
|
||||
field, casted = resolved
|
||||
result = await session.execute(_apply_user_relationship_loader(model, select(model).where(field == casted)))
|
||||
obj = result.scalar_one_or_none()
|
||||
if not obj:
|
||||
raise HTTPException(status_code=404, detail=f"{model.__name__} not found")
|
||||
return to_schema(schema_response, obj)
|
||||
|
||||
if "get_all_by_field" in enabled_methods:
|
||||
|
||||
@router.get(f"/all/{{{parameter_name}}}", response_model=list[schema_response])
|
||||
async def get_all_by_field(
|
||||
value: int | str = Path(..., alias=parameter_name),
|
||||
admin: Admin = Depends(verify_admin_token),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
):
|
||||
resolved = await _path_filter(session, value)
|
||||
if resolved is None:
|
||||
raise HTTPException(status_code=404, detail=f"{model.__name__} not found")
|
||||
field, casted = resolved
|
||||
result = await session.execute(_apply_user_relationship_loader(model, select(model).where(field == casted)))
|
||||
objs = result.scalars().all()
|
||||
if not objs:
|
||||
raise HTTPException(status_code=404, detail=f"{model.__name__} not found")
|
||||
for obj in objs:
|
||||
normalize_outgoing_object(obj)
|
||||
return [schema_response.model_validate(obj, from_attributes=True) for obj in objs]
|
||||
|
||||
if "create" in enabled_methods:
|
||||
|
||||
@router.post("/", response_model=schema_response)
|
||||
async def create(
|
||||
payload: Any = Body(...),
|
||||
admin: Admin = Depends(verify_admin_token),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
):
|
||||
validated = schema_create.model_validate(payload)
|
||||
data = validated.model_dump(exclude_unset=True)
|
||||
if "days" in data and data["days"] == 0:
|
||||
data["days"] = None
|
||||
obj = model(**data)
|
||||
session.add(obj)
|
||||
await session.refresh(obj)
|
||||
return to_schema(schema_response, obj)
|
||||
|
||||
if "update" in enabled_methods:
|
||||
|
||||
@router.patch(f"/{{{parameter_name}}}", response_model=schema_response)
|
||||
async def update(
|
||||
payload: Any = Body(...),
|
||||
value: int | str = Path(..., alias=parameter_name),
|
||||
admin: Admin = Depends(verify_admin_token),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
):
|
||||
resolved = await _path_filter(session, value)
|
||||
if resolved is None:
|
||||
raise HTTPException(status_code=404, detail=f"{model.__name__} not found")
|
||||
field, casted = resolved
|
||||
result = await session.execute(select(model).where(field == casted))
|
||||
obj = result.scalar_one_or_none()
|
||||
if not obj:
|
||||
raise HTTPException(status_code=404, detail=f"{model.__name__} not found")
|
||||
|
||||
validated = schema_update.model_validate(payload)
|
||||
for k, v in validated.model_dump(exclude_unset=True).items():
|
||||
setattr(obj, k, v)
|
||||
|
||||
await session.refresh(obj)
|
||||
return to_schema(schema_response, obj)
|
||||
|
||||
if "delete" in enabled_methods:
|
||||
|
||||
@router.delete(f"/{{{parameter_name}}}", response_model=dict)
|
||||
async def delete(
|
||||
value: int | str = Path(..., alias=parameter_name),
|
||||
admin: Admin = Depends(verify_admin_token),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
):
|
||||
resolved = await _path_filter(session, value)
|
||||
if resolved is None:
|
||||
raise HTTPException(status_code=404, detail=f"{model.__name__} not found")
|
||||
field, casted = resolved
|
||||
result = await session.execute(select(model).where(field == casted))
|
||||
obj = result.scalar_one_or_none()
|
||||
if not obj:
|
||||
raise HTTPException(status_code=404, detail=f"{model.__name__} not found")
|
||||
|
||||
await session.delete(obj)
|
||||
return {"detail": f"{model.__name__} deleted"}
|
||||
|
||||
return router
|
||||
@@ -1,375 +0,0 @@
|
||||
import asyncio
|
||||
import os
|
||||
import re
|
||||
import subprocess
|
||||
import sys
|
||||
|
||||
from datetime import datetime, timezone
|
||||
from typing import Literal
|
||||
|
||||
import psutil
|
||||
|
||||
from aiogram import Bot
|
||||
from aiogram.client.default import DefaultBotProperties
|
||||
from aiogram.enums import ParseMode
|
||||
from fastapi import APIRouter, BackgroundTasks, Depends, HTTPException, Query
|
||||
from pydantic import BaseModel
|
||||
from sqlalchemy import distinct, exists, func, select, update
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from api.depends import get_session, verify_admin_token, verify_admin_token_short
|
||||
from config import API_TOKEN, BOT_SERVICE
|
||||
from core.bootstrap import MANAGEMENT_CONFIG
|
||||
from core.executor import run_io
|
||||
from core.settings.management_config import update_management_config
|
||||
from database import async_session_maker
|
||||
from database.models import Key, ScheduledBroadcast, Server, User
|
||||
from database.scheduled_broadcasts import (
|
||||
cancel_scheduled_broadcast,
|
||||
create_scheduled_broadcast,
|
||||
get_scheduled_broadcast,
|
||||
list_scheduled_broadcasts,
|
||||
mark_scheduled_broadcast_failed,
|
||||
mark_scheduled_broadcast_sent,
|
||||
start_scheduled_broadcast,
|
||||
update_scheduled_broadcast,
|
||||
)
|
||||
from handlers.admin.sender.scheduled_service import (
|
||||
ensure_utc_datetime,
|
||||
execute_broadcast_payload,
|
||||
execute_scheduled_broadcast,
|
||||
prepare_broadcast_payload,
|
||||
scheduled_broadcast_to_dict,
|
||||
)
|
||||
from logger import logger
|
||||
from utils.backup import backup_database
|
||||
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
class MaintenanceUpdate(BaseModel):
|
||||
enabled: bool
|
||||
|
||||
|
||||
class DomainChange(BaseModel):
|
||||
domain: str
|
||||
|
||||
|
||||
class BroadcastLaunchPayload(BaseModel):
|
||||
send_to: Literal["all", "subscribed", "unsubscribed", "untrial", "trial", "hotleads", "cluster"] = "all"
|
||||
channel: Literal["bot", "site", "both"] = "both"
|
||||
text: str
|
||||
photo: str | None = None
|
||||
cluster_name: str | None = None
|
||||
workers: int = 5
|
||||
messages_per_second: int = 35
|
||||
|
||||
|
||||
class ScheduledBroadcastCreatePayload(BroadcastLaunchPayload):
|
||||
scheduled_for: datetime
|
||||
|
||||
|
||||
class ScheduledBroadcastUpdatePayload(BaseModel):
|
||||
send_to: Literal["all", "subscribed", "unsubscribed", "untrial", "trial", "hotleads", "cluster"] | None = None
|
||||
channel: Literal["bot", "site", "both"] | None = None
|
||||
text: str | None = None
|
||||
photo: str | None = None
|
||||
cluster_name: str | None = None
|
||||
workers: int | None = None
|
||||
messages_per_second: int | None = None
|
||||
scheduled_for: datetime | None = None
|
||||
|
||||
|
||||
_broadcast_bot: Bot | None = None
|
||||
|
||||
|
||||
def _get_broadcast_bot() -> Bot:
|
||||
global _broadcast_bot
|
||||
if _broadcast_bot is None:
|
||||
_broadcast_bot = Bot(token=API_TOKEN, default=DefaultBotProperties(parse_mode=ParseMode.HTML))
|
||||
return _broadcast_bot
|
||||
|
||||
|
||||
def _require_future_schedule(value: datetime) -> datetime:
|
||||
scheduled_for = ensure_utc_datetime(value)
|
||||
if scheduled_for <= datetime.now(timezone.utc):
|
||||
raise HTTPException(status_code=400, detail="scheduled_for must be in the future")
|
||||
return scheduled_for
|
||||
|
||||
|
||||
def _resolve_update_payload(
|
||||
payload: ScheduledBroadcastUpdatePayload,
|
||||
current: ScheduledBroadcast,
|
||||
) -> dict:
|
||||
fields = payload.model_fields_set
|
||||
text_changed = "text" in fields
|
||||
send_to = payload.send_to if "send_to" in fields else current.send_to
|
||||
channel = payload.channel if "channel" in fields else current.channel
|
||||
text = payload.text if "text" in fields else current.text
|
||||
photo = payload.photo if "photo" in fields else current.photo
|
||||
cluster_name = payload.cluster_name if "cluster_name" in fields else current.cluster_name
|
||||
workers = payload.workers if "workers" in fields else current.workers
|
||||
messages_per_second = (
|
||||
payload.messages_per_second if "messages_per_second" in fields else current.messages_per_second
|
||||
)
|
||||
prepared = prepare_broadcast_payload(
|
||||
send_to=send_to,
|
||||
text=text,
|
||||
photo=photo,
|
||||
cluster_name=cluster_name,
|
||||
workers=workers,
|
||||
messages_per_second=messages_per_second,
|
||||
channel=channel,
|
||||
)
|
||||
if not text_changed:
|
||||
prepared["text"] = current.text
|
||||
prepared["keyboard_json"] = current.keyboard_json
|
||||
if "scheduled_for" in fields:
|
||||
prepared["scheduled_for"] = _require_future_schedule(payload.scheduled_for)
|
||||
return prepared
|
||||
|
||||
|
||||
async def _restart_bot() -> None:
|
||||
await asyncio.sleep(1)
|
||||
try:
|
||||
parent = psutil.Process(os.getpid()).parent()
|
||||
is_systemd = parent and "systemd" in parent.name().lower()
|
||||
|
||||
if is_systemd:
|
||||
await run_io(lambda: subprocess.run(["sudo", "systemctl", "restart", BOT_SERVICE], check=True))
|
||||
else:
|
||||
python_exe = sys.executable
|
||||
script_path = os.path.abspath(sys.argv[0])
|
||||
os.execv(python_exe, [python_exe, script_path] + sys.argv[1:])
|
||||
except Exception:
|
||||
os._exit(1)
|
||||
|
||||
|
||||
@router.get("/status")
|
||||
async def get_status(admin=Depends(verify_admin_token)):
|
||||
return {
|
||||
"maintenance_enabled": bool(MANAGEMENT_CONFIG.get("MAINTENANCE_ENABLED", False)),
|
||||
"management": dict(MANAGEMENT_CONFIG or {}),
|
||||
}
|
||||
|
||||
|
||||
@router.post("/maintenance")
|
||||
async def set_maintenance(
|
||||
payload: MaintenanceUpdate,
|
||||
admin=Depends(verify_admin_token),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
):
|
||||
current_config = dict(MANAGEMENT_CONFIG or {})
|
||||
current_config["MAINTENANCE_ENABLED"] = bool(payload.enabled)
|
||||
await update_management_config(session, current_config)
|
||||
return {"maintenance_enabled": bool(MANAGEMENT_CONFIG.get("MAINTENANCE_ENABLED", False))}
|
||||
|
||||
|
||||
@router.post("/restart")
|
||||
async def restart_bot(
|
||||
background: BackgroundTasks,
|
||||
admin=Depends(verify_admin_token),
|
||||
):
|
||||
background.add_task(_restart_bot)
|
||||
return {"status": "restarting"}
|
||||
|
||||
|
||||
@router.post("/change-domain")
|
||||
async def change_domain(
|
||||
payload: DomainChange,
|
||||
admin=Depends(verify_admin_token),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
):
|
||||
domain = payload.domain.strip()
|
||||
if not domain or " " in domain or not re.fullmatch(r"[a-zA-Z0-9.-]+", domain):
|
||||
raise HTTPException(status_code=400, detail="Invalid domain")
|
||||
|
||||
new_domain_url = f"https://{domain}"
|
||||
|
||||
stmt = (
|
||||
update(Key)
|
||||
.values(
|
||||
key=func.regexp_replace(Key.key, r"^https://[^/]+", new_domain_url),
|
||||
remnawave_link=func.regexp_replace(Key.remnawave_link, r"^https://[^/]+", new_domain_url),
|
||||
)
|
||||
.where(
|
||||
(Key.key.startswith("https://") & ~Key.key.startswith(new_domain_url))
|
||||
| (Key.remnawave_link.startswith("https://") & ~Key.remnawave_link.startswith(new_domain_url))
|
||||
)
|
||||
)
|
||||
result = await session.execute(stmt)
|
||||
|
||||
return {"updated": result.rowcount or 0}
|
||||
|
||||
|
||||
@router.post("/restore-trials")
|
||||
async def restore_trials(
|
||||
admin=Depends(verify_admin_token),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
):
|
||||
stmt = (
|
||||
update(User)
|
||||
.where(
|
||||
User.trial == 1,
|
||||
~exists(select(Key.user_id).where(Key.user_id == User.id)),
|
||||
)
|
||||
.values(trial=0)
|
||||
)
|
||||
result = await session.execute(stmt)
|
||||
|
||||
return {"restored": result.rowcount or 0}
|
||||
|
||||
|
||||
@router.post("/backup")
|
||||
async def trigger_backup(admin=Depends(verify_admin_token)):
|
||||
async def _run_backup() -> None:
|
||||
exception = await backup_database()
|
||||
if exception:
|
||||
logger.error(f"[Management] Backup finished with error: {exception}")
|
||||
|
||||
asyncio.create_task(_run_backup())
|
||||
return {"status": "backup_started"}
|
||||
|
||||
|
||||
@router.get("/broadcast/clusters")
|
||||
async def get_broadcast_clusters(
|
||||
admin=Depends(verify_admin_token),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
):
|
||||
result = await session.execute(select(distinct(Server.cluster_name)).where(Server.cluster_name.is_not(None)))
|
||||
clusters = sorted([row[0] for row in result.all() if row and row[0]])
|
||||
return {"clusters": clusters}
|
||||
|
||||
|
||||
@router.post("/broadcast")
|
||||
async def launch_broadcast(
|
||||
payload: BroadcastLaunchPayload,
|
||||
admin=Depends(verify_admin_token_short),
|
||||
):
|
||||
"""Запуск рассылки. Сессия БД не держится на время рассылки."""
|
||||
try:
|
||||
prepared = prepare_broadcast_payload(
|
||||
send_to=payload.send_to,
|
||||
text=payload.text,
|
||||
photo=payload.photo,
|
||||
cluster_name=payload.cluster_name,
|
||||
workers=payload.workers,
|
||||
messages_per_second=payload.messages_per_second,
|
||||
channel=payload.channel,
|
||||
)
|
||||
except ValueError as exc:
|
||||
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
||||
return await execute_broadcast_payload(prepared, bot=_get_broadcast_bot())
|
||||
|
||||
|
||||
@router.post("/broadcast/scheduled")
|
||||
async def create_broadcast_schedule(
|
||||
payload: ScheduledBroadcastCreatePayload,
|
||||
admin=Depends(verify_admin_token_short),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
):
|
||||
try:
|
||||
prepared = prepare_broadcast_payload(
|
||||
send_to=payload.send_to,
|
||||
text=payload.text,
|
||||
photo=payload.photo,
|
||||
cluster_name=payload.cluster_name,
|
||||
workers=payload.workers,
|
||||
messages_per_second=payload.messages_per_second,
|
||||
channel=payload.channel,
|
||||
)
|
||||
except ValueError as exc:
|
||||
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
||||
broadcast = await create_scheduled_broadcast(
|
||||
session,
|
||||
created_by_tg_id=getattr(admin, "tg_id", None),
|
||||
send_to=prepared["send_to"],
|
||||
channel=prepared["channel"],
|
||||
cluster_name=prepared["cluster_name"],
|
||||
text=prepared["text"],
|
||||
photo=prepared["photo"],
|
||||
keyboard_json=prepared["keyboard_json"],
|
||||
scheduled_for=_require_future_schedule(payload.scheduled_for),
|
||||
workers=prepared["workers"],
|
||||
messages_per_second=prepared["messages_per_second"],
|
||||
)
|
||||
return {"success": True, "item": scheduled_broadcast_to_dict(broadcast)}
|
||||
|
||||
|
||||
@router.get("/broadcast/scheduled")
|
||||
async def list_broadcast_schedules(
|
||||
admin=Depends(verify_admin_token),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
status: str | None = Query(None),
|
||||
limit: int = Query(20, ge=1, le=100),
|
||||
offset: int = Query(0, ge=0),
|
||||
):
|
||||
statuses = [item.strip() for item in (status or "").split(",") if item.strip()] or None
|
||||
items = await list_scheduled_broadcasts(session, statuses=statuses, limit=limit, offset=offset)
|
||||
return {"items": [scheduled_broadcast_to_dict(item) for item in items], "limit": limit, "offset": offset}
|
||||
|
||||
|
||||
@router.get("/broadcast/scheduled/{broadcast_id}")
|
||||
async def get_broadcast_schedule(
|
||||
broadcast_id: str,
|
||||
admin=Depends(verify_admin_token),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
):
|
||||
item = await get_scheduled_broadcast(session, broadcast_id)
|
||||
if item is None:
|
||||
raise HTTPException(status_code=404, detail="Scheduled broadcast not found")
|
||||
return {"item": scheduled_broadcast_to_dict(item)}
|
||||
|
||||
|
||||
@router.patch("/broadcast/scheduled/{broadcast_id}")
|
||||
async def update_broadcast_schedule(
|
||||
broadcast_id: str,
|
||||
payload: ScheduledBroadcastUpdatePayload,
|
||||
admin=Depends(verify_admin_token_short),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
):
|
||||
current = await get_scheduled_broadcast(session, broadcast_id)
|
||||
if current is None:
|
||||
raise HTTPException(status_code=404, detail="Scheduled broadcast not found")
|
||||
try:
|
||||
values = _resolve_update_payload(payload, current)
|
||||
except ValueError as exc:
|
||||
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
||||
updated = await update_scheduled_broadcast(session, broadcast_id, **values)
|
||||
if updated is None:
|
||||
raise HTTPException(status_code=409, detail="Scheduled broadcast can no longer be edited")
|
||||
return {"success": True, "item": scheduled_broadcast_to_dict(updated)}
|
||||
|
||||
|
||||
@router.post("/broadcast/scheduled/{broadcast_id}/cancel")
|
||||
async def cancel_broadcast_schedule(
|
||||
broadcast_id: str,
|
||||
admin=Depends(verify_admin_token_short),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
):
|
||||
item = await cancel_scheduled_broadcast(session, broadcast_id)
|
||||
if item is None:
|
||||
raise HTTPException(status_code=409, detail="Scheduled broadcast can no longer be cancelled")
|
||||
return {"success": True, "item": scheduled_broadcast_to_dict(item)}
|
||||
|
||||
|
||||
@router.post("/broadcast/scheduled/{broadcast_id}/send-now")
|
||||
async def send_broadcast_schedule_now(
|
||||
broadcast_id: str,
|
||||
admin=Depends(verify_admin_token_short),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
):
|
||||
item = await start_scheduled_broadcast(session, broadcast_id)
|
||||
if item is None:
|
||||
raise HTTPException(status_code=409, detail="Scheduled broadcast can no longer be sent now")
|
||||
try:
|
||||
result = await execute_scheduled_broadcast(item, bot=_get_broadcast_bot())
|
||||
except Exception as exc:
|
||||
await mark_scheduled_broadcast_failed(session, broadcast_id, str(exc))
|
||||
raise HTTPException(status_code=500, detail=str(exc)) from exc
|
||||
if result.get("success"):
|
||||
item = await mark_scheduled_broadcast_sent(session, broadcast_id, result)
|
||||
else:
|
||||
item = await mark_scheduled_broadcast_failed(session, broadcast_id, result.get("message", "Broadcast failed"))
|
||||
return {"success": bool(result.get("success")), "item": scheduled_broadcast_to_dict(item), "result": result}
|
||||
@@ -1,64 +0,0 @@
|
||||
import asyncio
|
||||
|
||||
from fastapi import Depends, HTTPException, Path
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from api.depends import get_session, verify_admin_token
|
||||
from api.v1.routes.base_crud import generate_crud_router
|
||||
from api.v1.schemas.users import UserBase, UserResponse, UserUpdate
|
||||
from database import async_session_maker, delete_user_data, get_servers
|
||||
from database.access.resolution import resolve_user_optional
|
||||
from database.models import Key, User
|
||||
from logger import logger
|
||||
from services.operations import delete_key_from_cluster
|
||||
|
||||
|
||||
router = generate_crud_router(
|
||||
model=User,
|
||||
schema_response=UserResponse,
|
||||
schema_create=UserBase,
|
||||
schema_update=UserUpdate,
|
||||
identifier_field="tg_id",
|
||||
enabled_methods=["get_all", "get_one", "get_by_email", "create", "update"],
|
||||
)
|
||||
|
||||
|
||||
@router.delete("/{tg_id}", response_model=dict)
|
||||
async def delete_user(
|
||||
tg_id: int = Path(..., description="Telegram ID пользователя"),
|
||||
admin=Depends(verify_admin_token),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
):
|
||||
try:
|
||||
u = await resolve_user_optional(session, tg_id)
|
||||
if u is None:
|
||||
raise HTTPException(status_code=404, detail="Пользователь не найден")
|
||||
result = await session.execute(select(Key.email, Key.client_id).where(Key.user_id == u.id))
|
||||
key_records = result.all()
|
||||
|
||||
async with async_session_maker() as s:
|
||||
servers = await get_servers(session=s)
|
||||
cluster_ids = list(servers.keys())
|
||||
|
||||
async def _delete_one(cluster_id: str, email: str, client_id: str):
|
||||
async with async_session_maker() as s:
|
||||
await delete_key_from_cluster(cluster_id, email, client_id, s)
|
||||
|
||||
try:
|
||||
tasks = [
|
||||
_delete_one(cluster_id, email, client_id)
|
||||
for email, client_id in key_records
|
||||
for cluster_id in cluster_ids
|
||||
]
|
||||
await asyncio.gather(*tasks, return_exceptions=True)
|
||||
except Exception as e:
|
||||
logger.error(f"[DELETE] Ошибка при удалении ключей с серверов для пользователя {tg_id}: {e}")
|
||||
|
||||
await delete_user_data(session, tg_id)
|
||||
|
||||
return {"detail": f"Пользователь {tg_id} и его ключи успешно удалены."}
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"[DELETE] Ошибка при удалении пользователя {tg_id}: {e}")
|
||||
raise HTTPException(status_code=500, detail="Ошибка при удалении пользователя")
|
||||
@@ -1,10 +0,0 @@
|
||||
VERSION = "2.0.0"
|
||||
__all__ = ("router", "VERSION")
|
||||
|
||||
|
||||
def __getattr__(name: str):
|
||||
if name == "router":
|
||||
from api.v2.router import router
|
||||
|
||||
return router
|
||||
raise AttributeError(name)
|
||||
@@ -1,49 +0,0 @@
|
||||
from fastapi import APIRouter
|
||||
|
||||
from api.v2.routes import (
|
||||
auth,
|
||||
coupons,
|
||||
flows,
|
||||
gifts,
|
||||
identities,
|
||||
keys,
|
||||
management,
|
||||
misc,
|
||||
modules,
|
||||
notifications,
|
||||
partners,
|
||||
payment_links,
|
||||
referrals,
|
||||
root_router,
|
||||
servers,
|
||||
settings,
|
||||
tariffs,
|
||||
users,
|
||||
web,
|
||||
)
|
||||
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
router.include_router(root_router)
|
||||
router.include_router(auth.router, prefix="/api")
|
||||
router.include_router(users.router, prefix="/api/users", tags=["Users"])
|
||||
router.include_router(keys.user_router, prefix="/api/keys", tags=["Keys"])
|
||||
router.include_router(keys.router, prefix="/api/admin/keys", tags=["AdminKeys"])
|
||||
router.include_router(coupons.router, prefix="/api/coupons", tags=["Coupons"])
|
||||
router.include_router(servers.router, prefix="/api/servers", tags=["Servers"])
|
||||
router.include_router(tariffs.public_router, prefix="/api/tariffs", tags=["Tariffs"])
|
||||
router.include_router(tariffs.user_tariff_router, prefix="/api/tariffs", tags=["Tariffs"])
|
||||
router.include_router(tariffs.router, prefix="/api/tariffs", tags=["Tariffs"])
|
||||
router.include_router(gifts.router, prefix="/api/gifts", tags=["Gifts"])
|
||||
router.include_router(referrals.router, prefix="/api/referrals", tags=["Referrals"])
|
||||
router.include_router(partners.router, prefix="/api/partners", tags=["Partners"])
|
||||
router.include_router(payment_links.router, prefix="/api/payment-links", tags=["PaymentLinks"])
|
||||
router.include_router(identities.router, prefix="/api/identities", tags=["Identities"])
|
||||
router.include_router(misc.router, prefix="/api")
|
||||
router.include_router(modules.router, prefix="/api")
|
||||
router.include_router(management.router, prefix="/api/management", tags=["Management"])
|
||||
router.include_router(settings.router, prefix="/api/settings", tags=["Settings"])
|
||||
router.include_router(web.router, prefix="", tags=["Web"])
|
||||
router.include_router(flows.router, prefix="/api", tags=["Flows"])
|
||||
router.include_router(notifications.router, prefix="/api", tags=["Notifications"])
|
||||
@@ -1,9 +0,0 @@
|
||||
__all__ = ("root_router",)
|
||||
|
||||
|
||||
def __getattr__(name: str):
|
||||
if name == "root_router":
|
||||
from api.v2.routes.root import router
|
||||
|
||||
return router
|
||||
raise AttributeError(name)
|
||||
@@ -1,115 +0,0 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import re
|
||||
import uuid
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
|
||||
UPLOAD_DIR = Path("static/web_uploads")
|
||||
DATA_URI_THRESHOLD_BYTES = 2048
|
||||
|
||||
_DATA_URI_RE = re.compile(r"^data:([\w./+-]+);base64,(.+)$", re.DOTALL)
|
||||
|
||||
_MIME_TO_EXT = {
|
||||
"image/png": ".png",
|
||||
"image/jpeg": ".jpg",
|
||||
"image/jpg": ".jpg",
|
||||
"image/gif": ".gif",
|
||||
"image/webp": ".webp",
|
||||
"image/svg+xml": ".svg",
|
||||
"video/mp4": ".mp4",
|
||||
"video/webm": ".webm",
|
||||
}
|
||||
|
||||
|
||||
def _save_data_uri_to_file(data_uri: str) -> str | None:
|
||||
match = _DATA_URI_RE.match(data_uri)
|
||||
if not match:
|
||||
return None
|
||||
mime = match.group(1).strip().lower()
|
||||
payload = match.group(2)
|
||||
ext = _MIME_TO_EXT.get(mime)
|
||||
if not ext:
|
||||
return None
|
||||
try:
|
||||
cleaned = "".join(payload.split())
|
||||
decoded = base64.b64decode(cleaned, validate=False)
|
||||
except Exception:
|
||||
return None
|
||||
if not decoded:
|
||||
return None
|
||||
UPLOAD_DIR.mkdir(parents=True, exist_ok=True)
|
||||
name = f"{uuid.uuid4().hex}{ext}"
|
||||
(UPLOAD_DIR / name).write_bytes(decoded)
|
||||
return f"/api/web/uploads/{name}"
|
||||
|
||||
|
||||
def migrate_json_data_uris(value: Any) -> tuple[Any, int]:
|
||||
replaced = 0
|
||||
|
||||
def walk(node: Any) -> Any:
|
||||
nonlocal replaced
|
||||
if isinstance(node, str):
|
||||
if not node.startswith("data:"):
|
||||
return node
|
||||
if len(node) < DATA_URI_THRESHOLD_BYTES:
|
||||
return node
|
||||
url = _save_data_uri_to_file(node)
|
||||
if url is None:
|
||||
return node
|
||||
replaced += 1
|
||||
return url
|
||||
if isinstance(node, list):
|
||||
return [walk(item) for item in node]
|
||||
if isinstance(node, dict):
|
||||
return {key: walk(item) for key, item in node.items()}
|
||||
return node
|
||||
|
||||
return walk(value), replaced
|
||||
|
||||
|
||||
async def run_startup_data_uri_migration(session: AsyncSession) -> tuple[int, int]:
|
||||
from database.models import (
|
||||
WebBlock,
|
||||
WebPageVariant,
|
||||
WebPageVariantBlock,
|
||||
WebTheme,
|
||||
)
|
||||
|
||||
rows_updated = 0
|
||||
uris_replaced = 0
|
||||
|
||||
for theme in (await session.execute(select(WebTheme))).scalars().all():
|
||||
cleaned, replaced = migrate_json_data_uris(theme.tokens or {})
|
||||
if replaced:
|
||||
theme.tokens = cleaned
|
||||
rows_updated += 1
|
||||
uris_replaced += replaced
|
||||
|
||||
for variant in (await session.execute(select(WebPageVariant))).scalars().all():
|
||||
cleaned, replaced = migrate_json_data_uris(variant.theme_tokens or {})
|
||||
if replaced:
|
||||
variant.theme_tokens = cleaned
|
||||
rows_updated += 1
|
||||
uris_replaced += replaced
|
||||
|
||||
for block in (await session.execute(select(WebBlock))).scalars().all():
|
||||
cleaned, replaced = migrate_json_data_uris(block.data or {})
|
||||
if replaced:
|
||||
block.data = cleaned
|
||||
rows_updated += 1
|
||||
uris_replaced += replaced
|
||||
|
||||
for block in (await session.execute(select(WebPageVariantBlock))).scalars().all():
|
||||
cleaned, replaced = migrate_json_data_uris(block.data or {})
|
||||
if replaced:
|
||||
block.data = cleaned
|
||||
rows_updated += 1
|
||||
uris_replaced += replaced
|
||||
|
||||
return rows_updated, uris_replaced
|
||||
@@ -1,15 +0,0 @@
|
||||
from fastapi import APIRouter
|
||||
|
||||
from api.v2.routes.auth import email_verify, google, link, password, session, telegram, yandex
|
||||
|
||||
|
||||
router = APIRouter(prefix="/auth", tags=["Auth"])
|
||||
router.include_router(password.router)
|
||||
router.include_router(telegram.router)
|
||||
router.include_router(google.router)
|
||||
router.include_router(yandex.router)
|
||||
router.include_router(link.router)
|
||||
router.include_router(email_verify.router)
|
||||
router.include_router(session.router)
|
||||
|
||||
__all__ = ["router"]
|
||||
@@ -1,144 +0,0 @@
|
||||
from fastapi import Request
|
||||
from sqlalchemy import text
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from api.v2.schemas.identities import IdentityResponse, LoginResponse
|
||||
from config import API_TOKEN_TTL_DAYS
|
||||
from logger import logger
|
||||
from utils.referral_codes import encode_partner_code
|
||||
|
||||
|
||||
TOKEN_TTL_HINT = "бессрочно" if API_TOKEN_TTL_DAYS is None else f"{API_TOKEN_TTL_DAYS} дн."
|
||||
TELEGRAM_LOGIN_MAX_AGE = 86400
|
||||
|
||||
|
||||
def build_login_response(identity) -> LoginResponse:
|
||||
return LoginResponse(
|
||||
identity_id=identity.id,
|
||||
identity=IdentityResponse.model_validate(identity),
|
||||
)
|
||||
|
||||
_TRUSTED_PROXY_CIDRS: list[str] = []
|
||||
|
||||
|
||||
def _client_ip(request: Request) -> str:
|
||||
client_host = (request.client.host if request.client else "") or ""
|
||||
forwarded = request.headers.get("x-forwarded-for") or request.headers.get("X-Forwarded-For")
|
||||
if not forwarded:
|
||||
return client_host
|
||||
if not _TRUSTED_PROXY_CIDRS and client_host not in ("127.0.0.1", "::1"):
|
||||
return client_host
|
||||
return forwarded.split(",")[0].strip() or client_host
|
||||
|
||||
|
||||
async def _resolve_partner_snapshot(session: AsyncSession, billing_user_id: int) -> dict[str, object]:
|
||||
partner_feature_enabled = False
|
||||
default_percent = 0.0
|
||||
try:
|
||||
from modules.partner_program import settings as partner_settings
|
||||
|
||||
partner_feature_enabled = True
|
||||
raw_percent = getattr(partner_settings, "PARTNER_BONUS_PERCENTAGES", {}).get(1, 0.0)
|
||||
default_percent = float(raw_percent) * 100.0
|
||||
except Exception:
|
||||
partner_feature_enabled = False
|
||||
default_percent = 0.0
|
||||
payload: dict[str, object] = {
|
||||
"partner_enabled": partner_feature_enabled,
|
||||
"partner_code": "",
|
||||
"partner_balance": 0.0,
|
||||
"partner_percent": default_percent,
|
||||
"partner_percent_custom": False,
|
||||
"partner_referred_total": 0,
|
||||
"partner_referred_paid": 0,
|
||||
"partner_payout_method": None,
|
||||
}
|
||||
try:
|
||||
partner_row = (
|
||||
await session.execute(
|
||||
text(
|
||||
"""
|
||||
SELECT
|
||||
tg_id,
|
||||
COALESCE(partner_balance, 0),
|
||||
partner_percent,
|
||||
COALESCE(partner_percent_custom, false),
|
||||
partner_code,
|
||||
payout_method
|
||||
FROM users
|
||||
WHERE id = :user_id
|
||||
LIMIT 1
|
||||
"""
|
||||
),
|
||||
{"user_id": int(billing_user_id)},
|
||||
)
|
||||
).first()
|
||||
except Exception:
|
||||
partner_row = None
|
||||
if partner_row is None:
|
||||
return payload
|
||||
tg_id = int(partner_row[0]) if partner_row[0] is not None else None
|
||||
balance = float(partner_row[1] or 0.0)
|
||||
percent_raw = partner_row[2]
|
||||
percent_custom = bool(partner_row[3])
|
||||
percent_value = float(percent_raw) if (percent_custom and percent_raw is not None) else float(default_percent)
|
||||
code = str(partner_row[4] or "").strip()
|
||||
if (not code or code.isdigit() or code.startswith("r1_")) and int(billing_user_id) > 0:
|
||||
generated_code = encode_partner_code(int(billing_user_id))
|
||||
code = generated_code
|
||||
try:
|
||||
await session.execute(
|
||||
text("UPDATE users SET partner_code = :code WHERE id = :id"),
|
||||
{"code": generated_code, "id": int(billing_user_id)},
|
||||
)
|
||||
await session.flush()
|
||||
except Exception as e:
|
||||
logger.warning("[Auth] Ошибка сохранения partner_code для billing_user_id={}: {}", billing_user_id, e)
|
||||
payout_method = str(partner_row[5] or "").strip() or None
|
||||
referred_total = 0
|
||||
referred_paid = 0
|
||||
if tg_id is not None:
|
||||
try:
|
||||
referred_total = int(
|
||||
(
|
||||
await session.execute(
|
||||
text("SELECT COUNT(*) FROM partners WHERE partner_tg_id = :tg_id"),
|
||||
{"tg_id": int(tg_id)},
|
||||
)
|
||||
).scalar()
|
||||
or 0
|
||||
)
|
||||
except Exception:
|
||||
referred_total = 0
|
||||
try:
|
||||
referred_paid = int(
|
||||
(
|
||||
await session.execute(
|
||||
text(
|
||||
"SELECT COUNT(DISTINCT pr.joined_tg_id) "
|
||||
"FROM partners pr "
|
||||
"WHERE pr.partner_tg_id = :tg_id "
|
||||
"AND EXISTS ("
|
||||
" SELECT 1 FROM payments pay "
|
||||
" WHERE pay.tg_id = pr.joined_tg_id "
|
||||
" AND lower(pay.status) = 'success'"
|
||||
")"
|
||||
),
|
||||
{"tg_id": int(tg_id)},
|
||||
)
|
||||
).scalar()
|
||||
or 0
|
||||
)
|
||||
except Exception:
|
||||
referred_paid = 0
|
||||
payload.update({
|
||||
"partner_enabled": bool(partner_feature_enabled or code or referred_total > 0 or balance > 0),
|
||||
"partner_code": code,
|
||||
"partner_balance": balance,
|
||||
"partner_percent": percent_value,
|
||||
"partner_percent_custom": percent_custom,
|
||||
"partner_referred_total": referred_total,
|
||||
"partner_referred_paid": referred_paid,
|
||||
"partner_payout_method": payout_method,
|
||||
})
|
||||
return payload
|
||||
@@ -1,44 +0,0 @@
|
||||
"""In-memory rate limit fallback для случаев когда Redis недоступен.
|
||||
|
||||
Используется только когда Redis не ответил — чтобы критичные auth-эндпоинты
|
||||
не теряли защиту при кратковременных Redis-сбоях. Per-process, не шарится
|
||||
между репликами — поэтому на нескольких инстансах лимит будет N*limit.
|
||||
"""
|
||||
|
||||
import time
|
||||
|
||||
from collections import deque
|
||||
from threading import Lock
|
||||
|
||||
|
||||
_BUCKETS: dict[str, deque[float]] = {}
|
||||
_LOCK = Lock()
|
||||
_MAX_KEYS = 10000
|
||||
|
||||
|
||||
def _prune(bucket: deque[float], window_sec: int) -> None:
|
||||
threshold = time.monotonic() - window_sec
|
||||
while bucket and bucket[0] < threshold:
|
||||
bucket.popleft()
|
||||
|
||||
|
||||
def _evict_if_full() -> None:
|
||||
if len(_BUCKETS) < _MAX_KEYS:
|
||||
return
|
||||
now = time.monotonic()
|
||||
dead = [k for k, b in _BUCKETS.items() if not b or b[-1] < now - 3600]
|
||||
for k in dead:
|
||||
del _BUCKETS[k]
|
||||
if len(_BUCKETS) >= _MAX_KEYS:
|
||||
oldest = min(_BUCKETS.keys(), key=lambda k: _BUCKETS[k][0] if _BUCKETS[k] else 0)
|
||||
del _BUCKETS[oldest]
|
||||
|
||||
|
||||
def check_and_increment(key: str, limit: int, window_sec: int) -> int:
|
||||
"""Возвращает текущее значение счётчика после инкремента. Если >= limit — превышение."""
|
||||
with _LOCK:
|
||||
_evict_if_full()
|
||||
bucket = _BUCKETS.setdefault(key, deque())
|
||||
_prune(bucket, window_sec)
|
||||
bucket.append(time.monotonic())
|
||||
return len(bucket)
|
||||
@@ -1,78 +0,0 @@
|
||||
import secrets
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Request
|
||||
from pydantic import (
|
||||
BaseModel,
|
||||
Field as PydanticField,
|
||||
)
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from api.depends import get_session, verify_identity_token
|
||||
from api.v2.routes.auth._common import _client_ip
|
||||
from mail import send_email_verify_code_email, smtp_configured
|
||||
from utils import web_email_verify_code as verify_util
|
||||
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
class VerifyEmailRequest(BaseModel):
|
||||
code: str = PydanticField(..., min_length=1, max_length=10)
|
||||
|
||||
|
||||
@router.post("/send-verify-code")
|
||||
async def send_email_verify_code(
|
||||
request: Request,
|
||||
session: AsyncSession = Depends(get_session),
|
||||
identity=Depends(verify_identity_token),
|
||||
):
|
||||
"""Отправить код подтверждения email. Требует авторизации."""
|
||||
if not smtp_configured():
|
||||
raise HTTPException(status_code=503, detail="Почтовый сервер не настроен")
|
||||
email = (identity.email or "").strip().lower()
|
||||
if not email:
|
||||
raise HTTPException(status_code=400, detail="Email не привязан к аккаунту")
|
||||
if getattr(identity, "email_verified", False):
|
||||
return {"ok": True, "detail": "Email уже подтверждён"}
|
||||
if not await verify_util.redis_ready():
|
||||
raise HTTPException(status_code=503, detail="Сервис временно недоступен")
|
||||
ip = _client_ip(request)
|
||||
if not await verify_util.try_consume_ip_send_budget(ip):
|
||||
raise HTTPException(status_code=429, detail="Слишком много запросов, попробуйте позже")
|
||||
if not await verify_util.try_consume_email_send_budget(email):
|
||||
raise HTTPException(status_code=429, detail="Слишком много запросов на этот email")
|
||||
if not await verify_util.try_acquire_resend_cooldown(email):
|
||||
raise HTTPException(status_code=429, detail="Подождите минуту перед повторной отправкой")
|
||||
code = f"{secrets.randbelow(900000) + 100000}"
|
||||
await verify_util.store_code(email, code)
|
||||
try:
|
||||
await send_email_verify_code_email(email, code)
|
||||
except Exception:
|
||||
await verify_util.delete_code(email)
|
||||
raise HTTPException(status_code=503, detail="Не удалось отправить письмо")
|
||||
return {"ok": True}
|
||||
|
||||
|
||||
@router.post("/verify-email")
|
||||
async def verify_email(
|
||||
body: VerifyEmailRequest,
|
||||
request: Request,
|
||||
session: AsyncSession = Depends(get_session),
|
||||
identity=Depends(verify_identity_token),
|
||||
):
|
||||
"""Подтвердить email по коду."""
|
||||
email = (identity.email or "").strip().lower()
|
||||
if not email:
|
||||
raise HTTPException(status_code=400, detail="Email не привязан к аккаунту")
|
||||
if getattr(identity, "email_verified", False):
|
||||
return {"ok": True, "detail": "Email уже подтверждён"}
|
||||
if not await verify_util.try_consume_verify_budget(email):
|
||||
raise HTTPException(status_code=429, detail="Слишком много попыток, попробуйте позже")
|
||||
if not await verify_util.verify_and_consume_code(email, body.code.strip()):
|
||||
raise HTTPException(status_code=400, detail="Неверный или просроченный код")
|
||||
from sqlalchemy import update
|
||||
|
||||
from database.models import Identity as IdentityModel
|
||||
|
||||
await session.execute(update(IdentityModel).where(IdentityModel.id == identity.id).values(email_verified=True))
|
||||
return {"ok": True}
|
||||
@@ -1,210 +0,0 @@
|
||||
import base64
|
||||
import hashlib
|
||||
import hmac
|
||||
import secrets
|
||||
import time
|
||||
|
||||
from urllib.parse import urlencode
|
||||
|
||||
import httpx
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, Request, Response
|
||||
from fastapi.responses import RedirectResponse
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from api.depends import (
|
||||
bind_identity_actor,
|
||||
get_session,
|
||||
set_auth_cookie,
|
||||
set_is_admin_cookie,
|
||||
)
|
||||
from api.v2.routes.auth._common import _client_ip
|
||||
from database import identities as idb
|
||||
from logger import logger
|
||||
|
||||
|
||||
try:
|
||||
from config import GOOGLE_CLIENT_ID as _GOOGLE_CLIENT_ID
|
||||
except ImportError:
|
||||
_GOOGLE_CLIENT_ID = ""
|
||||
try:
|
||||
from config import GOOGLE_CLIENT_SECRET as _GOOGLE_CLIENT_SECRET
|
||||
except ImportError:
|
||||
_GOOGLE_CLIENT_SECRET = ""
|
||||
try:
|
||||
from config import GOOGLE_REDIRECT_URI as _GOOGLE_REDIRECT_URI
|
||||
except ImportError:
|
||||
_GOOGLE_REDIRECT_URI = ""
|
||||
try:
|
||||
from config import OAUTH_SUCCESS_URI as _OAUTH_SUCCESS_URI
|
||||
except ImportError:
|
||||
_OAUTH_SUCCESS_URI = "/dashboard"
|
||||
try:
|
||||
from config import API_TOKEN as _GOOGLE_STATE_SECRET
|
||||
except ImportError:
|
||||
_GOOGLE_STATE_SECRET = "solo-google-state-fallback"
|
||||
|
||||
|
||||
GOOGLE_AUTH_ENDPOINT = "https://accounts.google.com/o/oauth2/v2/auth"
|
||||
GOOGLE_TOKEN_ENDPOINT = "https://oauth2.googleapis.com/token"
|
||||
GOOGLE_USERINFO_ENDPOINT = "https://openidconnect.googleapis.com/v1/userinfo"
|
||||
STATE_TTL_SECONDS = 600
|
||||
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
def google_configured() -> bool:
|
||||
return bool(_GOOGLE_CLIENT_ID and _GOOGLE_CLIENT_SECRET and _GOOGLE_REDIRECT_URI)
|
||||
|
||||
|
||||
def _sign_state(payload: str) -> str:
|
||||
mac = hmac.new(str(_GOOGLE_STATE_SECRET).encode(), payload.encode(), hashlib.sha256).digest()
|
||||
return base64.urlsafe_b64encode(mac).decode().rstrip("=")
|
||||
|
||||
|
||||
def _make_state(return_to: str) -> str:
|
||||
nonce = secrets.token_urlsafe(16)
|
||||
ts = str(int(time.time()))
|
||||
payload = f"{nonce}.{ts}.{return_to}"
|
||||
sig = _sign_state(payload)
|
||||
raw = f"{payload}.{sig}".encode()
|
||||
return base64.urlsafe_b64encode(raw).decode().rstrip("=")
|
||||
|
||||
|
||||
def _verify_state(state: str) -> str | None:
|
||||
try:
|
||||
padded = state + "=" * (-len(state) % 4)
|
||||
raw = base64.urlsafe_b64decode(padded).decode()
|
||||
except Exception:
|
||||
return None
|
||||
parts = raw.rsplit(".", 1)
|
||||
if len(parts) != 2:
|
||||
return None
|
||||
payload, sig = parts
|
||||
expected = _sign_state(payload)
|
||||
if not hmac.compare_digest(sig, expected):
|
||||
return None
|
||||
chunks = payload.split(".", 2)
|
||||
if len(chunks) != 3:
|
||||
return None
|
||||
_nonce, ts, return_to = chunks
|
||||
try:
|
||||
if int(time.time()) - int(ts) > STATE_TTL_SECONDS:
|
||||
return None
|
||||
except Exception:
|
||||
return None
|
||||
return return_to or _OAUTH_SUCCESS_URI
|
||||
|
||||
|
||||
@router.get("/google/authorize")
|
||||
async def google_authorize(
|
||||
request: Request,
|
||||
return_to: str = Query(default=""),
|
||||
):
|
||||
"""Начинает OAuth-флоу Google: редиректит юзера на Google consent screen."""
|
||||
if not google_configured():
|
||||
raise HTTPException(status_code=503, detail="Google Sign-In не настроен на этом сервере")
|
||||
safe_return = return_to if return_to.startswith("/") else _OAUTH_SUCCESS_URI
|
||||
state = _make_state(safe_return)
|
||||
params = {
|
||||
"client_id": _GOOGLE_CLIENT_ID,
|
||||
"redirect_uri": _GOOGLE_REDIRECT_URI,
|
||||
"response_type": "code",
|
||||
"scope": "openid email profile",
|
||||
"state": state,
|
||||
"access_type": "online",
|
||||
"prompt": "select_account",
|
||||
}
|
||||
url = f"{GOOGLE_AUTH_ENDPOINT}?{urlencode(params)}"
|
||||
logger.info("[Auth] Google authorize: ip={}", _client_ip(request))
|
||||
return RedirectResponse(url, status_code=302)
|
||||
|
||||
|
||||
@router.get("/google/callback")
|
||||
async def google_callback(
|
||||
request: Request,
|
||||
response: Response,
|
||||
code: str = Query(default=""),
|
||||
state: str = Query(default=""),
|
||||
error: str = Query(default=""),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
):
|
||||
"""Коллбек Google: обмен code → token → userinfo → identity."""
|
||||
if not google_configured():
|
||||
raise HTTPException(status_code=503, detail="Google Sign-In не настроен на этом сервере")
|
||||
if error:
|
||||
logger.warning("[Auth] Google callback error: {} ip={}", error, _client_ip(request))
|
||||
return RedirectResponse(f"/login?error=google_{error}", status_code=302)
|
||||
if not code or not state:
|
||||
raise HTTPException(status_code=400, detail="Отсутствует code или state")
|
||||
return_to = _verify_state(state)
|
||||
if return_to is None:
|
||||
logger.warning("[Auth] Google callback: invalid/expired state ip={}", _client_ip(request))
|
||||
raise HTTPException(status_code=400, detail="Неверный или просроченный state")
|
||||
|
||||
async with httpx.AsyncClient(timeout=10.0) as client:
|
||||
try:
|
||||
token_res = await client.post(
|
||||
GOOGLE_TOKEN_ENDPOINT,
|
||||
data={
|
||||
"code": code,
|
||||
"client_id": _GOOGLE_CLIENT_ID,
|
||||
"client_secret": _GOOGLE_CLIENT_SECRET,
|
||||
"redirect_uri": _GOOGLE_REDIRECT_URI,
|
||||
"grant_type": "authorization_code",
|
||||
},
|
||||
headers={"Accept": "application/json"},
|
||||
)
|
||||
except httpx.HTTPError as e:
|
||||
logger.warning("[Auth] Google token exchange network error: {}", e)
|
||||
raise HTTPException(status_code=502, detail="Не удалось связаться с Google") from e
|
||||
if token_res.status_code != 200:
|
||||
logger.warning("[Auth] Google token exchange failed: {} {}", token_res.status_code, token_res.text[:200])
|
||||
raise HTTPException(status_code=401, detail="Google отклонил токен")
|
||||
token_payload = token_res.json()
|
||||
access_token = token_payload.get("access_token")
|
||||
if not access_token:
|
||||
raise HTTPException(status_code=401, detail="Google не вернул access_token")
|
||||
|
||||
try:
|
||||
info_res = await client.get(
|
||||
GOOGLE_USERINFO_ENDPOINT,
|
||||
headers={"Authorization": f"Bearer {access_token}"},
|
||||
)
|
||||
except httpx.HTTPError as e:
|
||||
logger.warning("[Auth] Google userinfo network error: {}", e)
|
||||
raise HTTPException(status_code=502, detail="Не удалось получить профиль Google") from e
|
||||
|
||||
if info_res.status_code != 200:
|
||||
raise HTTPException(status_code=401, detail="Google не вернул профиль")
|
||||
info = info_res.json()
|
||||
google_sub = str(info.get("sub") or "").strip()
|
||||
email = (info.get("email") or "").strip().lower() or None
|
||||
email_verified = bool(info.get("email_verified"))
|
||||
if not google_sub:
|
||||
raise HTTPException(status_code=401, detail="Google не вернул идентификатор пользователя")
|
||||
|
||||
identity = await idb.get_or_create_identity_for_google(
|
||||
session,
|
||||
google_sub=google_sub,
|
||||
email=email if (email and email_verified) else None,
|
||||
)
|
||||
await bind_identity_actor(request, session, identity)
|
||||
token = await idb.issue_token_for_identity(session, identity, request=request)
|
||||
logger.info(
|
||||
"[Auth] Login success: identity={}, google_sub={}, ip={}, method=google",
|
||||
identity.id,
|
||||
google_sub,
|
||||
_client_ip(request),
|
||||
)
|
||||
redirect = RedirectResponse(return_to, status_code=302)
|
||||
set_auth_cookie(redirect, token, request)
|
||||
set_is_admin_cookie(redirect, identity, request)
|
||||
return redirect
|
||||
|
||||
|
||||
@router.get("/google/status")
|
||||
async def google_status():
|
||||
"""Позволяет фронтенду узнать, настроен ли Google-вход на этом сервере."""
|
||||
return {"enabled": google_configured()}
|
||||
@@ -1,124 +0,0 @@
|
||||
import secrets
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Request, Response
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from api.depends import (
|
||||
bind_identity_actor,
|
||||
get_session,
|
||||
set_is_admin_cookie,
|
||||
verify_identity_token,
|
||||
)
|
||||
from api.v2.routes.auth._common import _client_ip
|
||||
from api.v2.schemas.identities import (
|
||||
IdentityResponse,
|
||||
LinkEmailConfirmRequest,
|
||||
LinkEmailSendCodeRequest,
|
||||
)
|
||||
from database import identities as idb
|
||||
from mail import send_email_link_code_email, smtp_configured
|
||||
from utils import web_email_link_code as email_link_code
|
||||
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.post("/link-email/send-code")
|
||||
async def link_email_send_code(
|
||||
body: LinkEmailSendCodeRequest,
|
||||
request: Request,
|
||||
session: AsyncSession = Depends(get_session),
|
||||
identity=Depends(verify_identity_token),
|
||||
):
|
||||
email_norm = email_link_code.normalize_email(body.email)
|
||||
if not email_norm:
|
||||
raise HTTPException(status_code=400, detail="Укажите корректный email")
|
||||
if identity.email and str(identity.email).strip().lower() == email_norm:
|
||||
raise HTTPException(status_code=409, detail="Этот email уже привязан к аккаунту")
|
||||
if not smtp_configured():
|
||||
raise HTTPException(
|
||||
status_code=503,
|
||||
detail="Отправка кода недоступна: почта не настроена на сервере",
|
||||
)
|
||||
if not await email_link_code.redis_ready():
|
||||
raise HTTPException(
|
||||
status_code=503,
|
||||
detail="Сервис временно недоступен. Попробуйте позже.",
|
||||
)
|
||||
existing = await idb.get_identity_by_email(session, email_norm)
|
||||
if existing and existing.id != identity.id:
|
||||
our_tg = identity.tg_id
|
||||
their_tg = existing.tg_id
|
||||
can_merge = their_tg is None or (our_tg is not None and int(their_tg) == int(our_tg))
|
||||
if not can_merge:
|
||||
raise HTTPException(
|
||||
status_code=409,
|
||||
detail="Этот email уже привязан к другому аккаунту",
|
||||
)
|
||||
ip = _client_ip(request)
|
||||
if not await email_link_code.try_consume_ip_budget(ip):
|
||||
raise HTTPException(
|
||||
status_code=429,
|
||||
detail="Слишком много запросов с вашего адреса. Попробуйте позже.",
|
||||
)
|
||||
if not await email_link_code.try_consume_email_send_budget(email_norm):
|
||||
raise HTTPException(
|
||||
status_code=429,
|
||||
detail="Слишком много запросов для этого адреса. Попробуйте позже.",
|
||||
)
|
||||
if not await email_link_code.try_acquire_cooldown(email_norm):
|
||||
raise HTTPException(
|
||||
status_code=429,
|
||||
detail="Код уже отправлен. Подождите перед повторной отправкой.",
|
||||
)
|
||||
code = "".join(secrets.choice("0123456789") for _ in range(6))
|
||||
if not await email_link_code.store_code(email_norm, code):
|
||||
await email_link_code.release_cooldown(email_norm)
|
||||
raise HTTPException(
|
||||
status_code=503,
|
||||
detail="Не удалось сохранить код. Попробуйте позже.",
|
||||
)
|
||||
try:
|
||||
await send_email_link_code_email(email_norm, code)
|
||||
except Exception:
|
||||
await email_link_code.release_cooldown(email_norm)
|
||||
await email_link_code.delete_code(email_norm)
|
||||
raise HTTPException(
|
||||
status_code=503,
|
||||
detail="Не удалось отправить письмо. Попробуйте позже.",
|
||||
) from None
|
||||
return {"ok": True, "message": "Код подтверждения отправлен на почту"}
|
||||
|
||||
|
||||
@router.post("/link-email/confirm", response_model=IdentityResponse)
|
||||
async def link_email_confirm(
|
||||
body: LinkEmailConfirmRequest,
|
||||
request: Request,
|
||||
response: Response,
|
||||
session: AsyncSession = Depends(get_session),
|
||||
identity=Depends(verify_identity_token),
|
||||
):
|
||||
email_norm = email_link_code.normalize_email(body.email)
|
||||
if not email_norm or not body.code or not str(body.code).strip():
|
||||
raise HTTPException(status_code=400, detail="Email и код обязательны")
|
||||
if not await email_link_code.redis_ready():
|
||||
raise HTTPException(
|
||||
status_code=503,
|
||||
detail="Сервис временно недоступен. Попробуйте позже.",
|
||||
)
|
||||
if not await email_link_code.try_consume_email_verify_budget(email_norm):
|
||||
raise HTTPException(
|
||||
status_code=429,
|
||||
detail="Слишком много попыток. Запросите новый код.",
|
||||
)
|
||||
if not await email_link_code.verify_and_consume_code(email_norm, str(body.code).strip()):
|
||||
raise HTTPException(status_code=401, detail="Неверный код или срок действия истёк")
|
||||
result = await idb.attach_email(session, identity.id, email_norm)
|
||||
if not result:
|
||||
raise HTTPException(
|
||||
status_code=409,
|
||||
detail="Этот email уже привязан к другой идентичности",
|
||||
)
|
||||
await bind_identity_actor(request, session, result)
|
||||
set_is_admin_cookie(response, result, request)
|
||||
return IdentityResponse.model_validate(result)
|
||||
@@ -1,430 +0,0 @@
|
||||
import secrets
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Request, Response
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from api.depends import (
|
||||
bind_identity_actor,
|
||||
get_session,
|
||||
set_auth_cookie,
|
||||
set_is_admin_cookie,
|
||||
)
|
||||
from api.v2.routes.auth._common import TOKEN_TTL_HINT, _client_ip, build_login_response
|
||||
from api.v2.schemas.identities import (
|
||||
ConfirmPasswordResetRequest,
|
||||
LoginByCodeRequest,
|
||||
LoginRequest,
|
||||
LoginResponse,
|
||||
RegisterByEmailRequest,
|
||||
RegisterResponse,
|
||||
SendLoginCodeRequest,
|
||||
)
|
||||
from database import (
|
||||
add_referral,
|
||||
get_referral_by_referred_id,
|
||||
identities as idb,
|
||||
)
|
||||
from database.access.resolution import resolve_user_optional
|
||||
from logger import logger
|
||||
from mail import (
|
||||
send_email_verify_code_email,
|
||||
send_login_code_email,
|
||||
send_password_reset_code_email,
|
||||
smtp_configured,
|
||||
)
|
||||
from utils import (
|
||||
web_email_verify_code as email_verify,
|
||||
web_password_reset_code as pwd_reset,
|
||||
)
|
||||
from utils.disposable_emails import is_disposable_email
|
||||
from utils.referral_codes import decode_referral_code
|
||||
from utils.turnstile import turnstile_enabled, verify_turnstile_token
|
||||
from utils.web_login_code import (
|
||||
delete_code,
|
||||
normalize_login_email,
|
||||
redis_ready_for_login_codes,
|
||||
release_resend_cooldown,
|
||||
store_code,
|
||||
try_acquire_resend_cooldown,
|
||||
try_consume_email_send_budget,
|
||||
try_consume_email_verify_budget,
|
||||
try_consume_ip_send_budget,
|
||||
verify_and_consume_code,
|
||||
)
|
||||
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
_RESET_OK_MESSAGE = {
|
||||
"ok": True,
|
||||
"message": "Если для этого адреса есть аккаунт с паролем, мы отправили код. Проверьте почту.",
|
||||
}
|
||||
|
||||
|
||||
@router.post("/register", response_model=RegisterResponse)
|
||||
async def register_by_email(
|
||||
body: RegisterByEmailRequest,
|
||||
request: Request,
|
||||
response: Response,
|
||||
session: AsyncSession = Depends(get_session),
|
||||
):
|
||||
(
|
||||
"""Регистрация по почте и паролю: создаётся идентичность, выдаётся токен. Срок действия токена: """
|
||||
+ TOKEN_TTL_HINT
|
||||
+ "."
|
||||
)
|
||||
ip = _client_ip(request)
|
||||
try:
|
||||
from api.v2.routes.auth._fallback_limiter import check_and_increment
|
||||
from core.redis_cache import cache_incr_checked
|
||||
|
||||
count, redis_ok = await cache_incr_checked(f"register_rate:{ip}", 3600)
|
||||
if not redis_ok:
|
||||
count = check_and_increment(f"register_rate:{ip}", 5, 3600)
|
||||
if count > 5:
|
||||
raise HTTPException(status_code=429, detail="Слишком много регистраций с этого IP. Попробуйте позже.")
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception:
|
||||
pass
|
||||
if turnstile_enabled():
|
||||
if not await verify_turnstile_token(body.turnstile_token, ip):
|
||||
raise HTTPException(status_code=400, detail="Проверка CAPTCHA не пройдена")
|
||||
email = body.email.strip().lower()
|
||||
if not email:
|
||||
raise HTTPException(status_code=400, detail="Email обязателен")
|
||||
if is_disposable_email(email):
|
||||
raise HTTPException(status_code=400, detail="Одноразовые email-адреса не поддерживаются")
|
||||
if not body.password or len(body.password) < 8:
|
||||
raise HTTPException(status_code=400, detail="Пароль минимум 8 символов")
|
||||
existing = await idb.get_identity_by_email(session, email)
|
||||
if existing:
|
||||
raise HTTPException(status_code=409, detail="Идентичность с таким email уже существует")
|
||||
raw_referral = str(body.referral_code or "").strip()
|
||||
if "/referral/" in raw_referral:
|
||||
raw_referral = raw_referral.split("/referral/", 1)[-1]
|
||||
if "start=referral_" in raw_referral:
|
||||
raw_referral = raw_referral.split("start=referral_", 1)[-1]
|
||||
raw_referral = raw_referral.split("?", 1)[0].split("#", 1)[0].strip()
|
||||
referrer_legacy = decode_referral_code(raw_referral)
|
||||
referrer_user = None
|
||||
if body.referral_code and referrer_legacy is None:
|
||||
raise HTTPException(status_code=400, detail="Код приглашения недействителен")
|
||||
if referrer_legacy is not None:
|
||||
referrer_user = await resolve_user_optional(session, referrer_legacy)
|
||||
if referrer_user is None:
|
||||
raise HTTPException(status_code=400, detail="Код приглашения недействителен")
|
||||
identity, token = await idb.create_identity_with_token(
|
||||
session, email=email, password=body.password, request=request
|
||||
)
|
||||
await bind_identity_actor(request, session, identity)
|
||||
billing_user_id = await idb.ensure_billing_user_for_identity(session, identity)
|
||||
if referrer_user is not None and not await get_referral_by_referred_id(session, billing_user_id):
|
||||
await add_referral(session, billing_user_id, referrer_user.id)
|
||||
if referrer_user.tg_id is not None:
|
||||
try:
|
||||
from database.web_notifications import notify_web
|
||||
|
||||
await notify_web(
|
||||
session,
|
||||
tg_id=int(referrer_user.tg_id),
|
||||
type="referral_joined",
|
||||
title="Ваш реферал присоединился",
|
||||
message="Новый пользователь зарегистрировался по вашей реферальной ссылке.",
|
||||
data={"referred_user_id": int(billing_user_id)},
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
if smtp_configured():
|
||||
try:
|
||||
code = f"{secrets.randbelow(900000) + 100000}"
|
||||
await email_verify.store_code(email, code)
|
||||
await send_email_verify_code_email(email, code)
|
||||
except Exception as e:
|
||||
logger.warning("[Auth] Не удалось отправить код подтверждения email при регистрации: {}", e)
|
||||
logger.info("[Auth] Register success: identity={}, email={}, ip={}", identity.id, email, _client_ip(request))
|
||||
set_auth_cookie(response, token, request)
|
||||
set_is_admin_cookie(response, identity, request)
|
||||
return RegisterResponse(identity_id=identity.id)
|
||||
|
||||
|
||||
@router.post("/login", response_model=LoginResponse)
|
||||
async def login(
|
||||
body: LoginRequest,
|
||||
request: Request,
|
||||
response: Response,
|
||||
session: AsyncSession = Depends(get_session),
|
||||
):
|
||||
"""Вход по email и паролю. Возвращает identity_id и новый токен. Срок действия токена: """ + TOKEN_TTL_HINT + "."
|
||||
email = body.email.strip().lower()
|
||||
if not email:
|
||||
raise HTTPException(status_code=400, detail="Email обязателен")
|
||||
ip = _client_ip(request)
|
||||
try:
|
||||
from api.v2.routes.auth._fallback_limiter import check_and_increment
|
||||
from core.redis_cache import cache_get, cache_incr_checked
|
||||
|
||||
lockout_key = f"login_lockout:{email}"
|
||||
locked = await cache_get(lockout_key)
|
||||
if locked:
|
||||
raise HTTPException(status_code=429, detail="Аккаунт временно заблокирован. Попробуйте через 15 минут.")
|
||||
rkey = f"login_pwd_rate:{ip}"
|
||||
count, redis_ok = await cache_incr_checked(rkey, 900)
|
||||
if not redis_ok:
|
||||
count = check_and_increment(rkey, 10, 900)
|
||||
if count > 10:
|
||||
raise HTTPException(status_code=429, detail="Слишком много попыток. Попробуйте позже.")
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.warning("[Auth] Ошибка rate-limit проверки для email-логина: {}", e)
|
||||
result = await idb.login_by_email(session, email, body.password, request=request)
|
||||
if not result:
|
||||
from database.setup.web_admin_bootstrap import ensure_web_admin
|
||||
|
||||
try:
|
||||
await ensure_web_admin(session)
|
||||
await session.flush()
|
||||
result = await idb.login_by_email(session, email, body.password, request=request)
|
||||
except Exception as exc:
|
||||
logger.warning("[Auth] lazy web-admin bootstrap failed: {}", exc)
|
||||
if not result:
|
||||
try:
|
||||
from core.redis_cache import cache_incr, cache_set
|
||||
|
||||
fail_key = f"login_fail:{email}"
|
||||
fails = await cache_incr(fail_key, 900)
|
||||
if fails >= 10:
|
||||
await cache_set(f"login_lockout:{email}", "1", 900)
|
||||
except Exception:
|
||||
pass
|
||||
raise HTTPException(status_code=401, detail="Неверный email или пароль")
|
||||
try:
|
||||
from core.redis_cache import cache_delete
|
||||
|
||||
await cache_delete(f"login_fail:{email}")
|
||||
except Exception:
|
||||
pass
|
||||
identity, token = result
|
||||
await bind_identity_actor(request, session, identity)
|
||||
if getattr(identity, "is_admin", False):
|
||||
from database.site_state import mark_site_initialized
|
||||
|
||||
await mark_site_initialized(session)
|
||||
logger.info("[Auth] Login success: identity={}, email={}, ip={}, method=password", identity.id, email, ip)
|
||||
set_auth_cookie(response, token, request)
|
||||
set_is_admin_cookie(response, identity, request)
|
||||
return build_login_response(identity)
|
||||
|
||||
|
||||
@router.post("/send-login-code")
|
||||
async def send_login_code(
|
||||
body: SendLoginCodeRequest,
|
||||
request: Request,
|
||||
session: AsyncSession = Depends(get_session),
|
||||
):
|
||||
"""Отправить код входа на email (SMTP + Redis)."""
|
||||
ip = _client_ip(request)
|
||||
try:
|
||||
from api.v2.routes.auth._fallback_limiter import check_and_increment
|
||||
from core.redis_cache import cache_incr_checked
|
||||
|
||||
count, redis_ok = await cache_incr_checked(f"send_code_rate:{ip}", 3600)
|
||||
if not redis_ok:
|
||||
count = check_and_increment(f"send_code_rate:{ip}", 10, 3600)
|
||||
if count > 10:
|
||||
raise HTTPException(status_code=429, detail="Слишком много запросов кодов. Попробуйте позже.")
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception:
|
||||
pass
|
||||
if turnstile_enabled():
|
||||
if not await verify_turnstile_token(body.turnstile_token, ip):
|
||||
raise HTTPException(status_code=400, detail="Проверка CAPTCHA не пройдена")
|
||||
email_norm = normalize_login_email(body.email)
|
||||
if not email_norm:
|
||||
raise HTTPException(status_code=400, detail="Email обязателен")
|
||||
if is_disposable_email(email_norm):
|
||||
raise HTTPException(status_code=400, detail="Одноразовые email-адреса не поддерживаются")
|
||||
if not smtp_configured():
|
||||
raise HTTPException(
|
||||
status_code=503,
|
||||
detail="Отправка кода недоступна: почта не настроена на сервере",
|
||||
)
|
||||
if not await redis_ready_for_login_codes():
|
||||
raise HTTPException(
|
||||
status_code=503,
|
||||
detail="Сервис временно недоступен. Попробуйте позже.",
|
||||
)
|
||||
identity = await idb.get_identity_by_email(session, email_norm)
|
||||
if not identity:
|
||||
if not body.allow_register:
|
||||
return {"ok": True, "message": "Код отправлен на почту"}
|
||||
identity = await idb.create_identity(session, email=email_norm)
|
||||
ip = _client_ip(request)
|
||||
if not await try_consume_ip_send_budget(ip):
|
||||
raise HTTPException(
|
||||
status_code=429,
|
||||
detail="Слишком много запросов с вашего адреса. Попробуйте позже.",
|
||||
)
|
||||
if not await try_consume_email_send_budget(email_norm):
|
||||
raise HTTPException(
|
||||
status_code=429,
|
||||
detail="Слишком много запросов для этого адреса. Попробуйте позже.",
|
||||
)
|
||||
if not await try_acquire_resend_cooldown(email_norm):
|
||||
raise HTTPException(
|
||||
status_code=429,
|
||||
detail="Код уже отправлен. Подождите перед повторной отправкой.",
|
||||
)
|
||||
code = "".join(secrets.choice("0123456789") for _ in range(6))
|
||||
if not await store_code(email_norm, code):
|
||||
await release_resend_cooldown(email_norm)
|
||||
raise HTTPException(
|
||||
status_code=503,
|
||||
detail="Не удалось сохранить код. Попробуйте позже.",
|
||||
)
|
||||
try:
|
||||
await send_login_code_email(email_norm, code)
|
||||
except Exception:
|
||||
await release_resend_cooldown(email_norm)
|
||||
await delete_code(email_norm)
|
||||
raise HTTPException(
|
||||
status_code=503,
|
||||
detail="Не удалось отправить письмо. Попробуйте позже.",
|
||||
) from None
|
||||
return {"ok": True, "message": "Код отправлен на почту"}
|
||||
|
||||
|
||||
@router.post("/login-by-code", response_model=LoginResponse)
|
||||
async def login_by_code(
|
||||
body: LoginByCodeRequest,
|
||||
request: Request,
|
||||
response: Response,
|
||||
session: AsyncSession = Depends(get_session),
|
||||
):
|
||||
"""Вход по email и коду из письма."""
|
||||
email_norm = normalize_login_email(body.email)
|
||||
if not email_norm or not body.code or not body.code.strip():
|
||||
raise HTTPException(status_code=400, detail="Email и код обязательны")
|
||||
if not await redis_ready_for_login_codes():
|
||||
raise HTTPException(
|
||||
status_code=503,
|
||||
detail="Сервис временно недоступен. Попробуйте позже.",
|
||||
)
|
||||
if not await try_consume_email_verify_budget(email_norm):
|
||||
raise HTTPException(
|
||||
status_code=429,
|
||||
detail="Слишком много попыток. Запросите новый код.",
|
||||
)
|
||||
if not await verify_and_consume_code(email_norm, body.code.strip()):
|
||||
raise HTTPException(status_code=401, detail="Неверный код или срок действия истёк")
|
||||
identity = await idb.get_identity_by_email(session, email_norm)
|
||||
if not identity:
|
||||
raise HTTPException(status_code=401, detail="Аккаунт не найден")
|
||||
if not getattr(identity, "email_verified", False):
|
||||
from sqlalchemy import update as sa_update
|
||||
|
||||
from database.models import Identity as IdentityModel
|
||||
|
||||
await session.execute(
|
||||
sa_update(IdentityModel).where(IdentityModel.id == identity.id).values(email_verified=True)
|
||||
)
|
||||
await bind_identity_actor(request, session, identity)
|
||||
token = await idb.issue_token_for_identity(session, identity, request=request)
|
||||
if getattr(identity, "is_admin", False):
|
||||
from database.site_state import mark_site_initialized
|
||||
|
||||
await mark_site_initialized(session)
|
||||
logger.info("[Auth] Login success: identity={}, email={}, method=code", identity.id, email_norm)
|
||||
set_auth_cookie(response, token, request)
|
||||
set_is_admin_cookie(response, identity, request)
|
||||
return build_login_response(identity)
|
||||
|
||||
|
||||
@router.post("/request-password-reset")
|
||||
async def request_password_reset(
|
||||
body: SendLoginCodeRequest,
|
||||
request: Request,
|
||||
session: AsyncSession = Depends(get_session),
|
||||
):
|
||||
email_norm = normalize_login_email(body.email)
|
||||
if not email_norm:
|
||||
raise HTTPException(status_code=400, detail="Email обязателен")
|
||||
if not smtp_configured() or not await pwd_reset.redis_ready():
|
||||
return _RESET_OK_MESSAGE
|
||||
identity = await idb.get_identity_by_email(session, email_norm)
|
||||
if not identity or not identity.password_hash:
|
||||
return _RESET_OK_MESSAGE
|
||||
ip = _client_ip(request)
|
||||
if not await pwd_reset.try_consume_ip_budget(ip):
|
||||
raise HTTPException(
|
||||
status_code=429,
|
||||
detail="Слишком много запросов с вашего адреса. Попробуйте позже.",
|
||||
)
|
||||
if not await pwd_reset.try_consume_email_send_budget(email_norm):
|
||||
raise HTTPException(
|
||||
status_code=429,
|
||||
detail="Слишком много запросов для этого адреса. Попробуйте позже.",
|
||||
)
|
||||
if not await pwd_reset.try_acquire_cooldown(email_norm):
|
||||
raise HTTPException(
|
||||
status_code=429,
|
||||
detail="Код уже отправлен. Подождите перед повторной отправкой.",
|
||||
)
|
||||
code = "".join(secrets.choice("0123456789") for _ in range(6))
|
||||
if not await pwd_reset.store_code(email_norm, code):
|
||||
await pwd_reset.release_cooldown(email_norm)
|
||||
raise HTTPException(
|
||||
status_code=503,
|
||||
detail="Не удалось сохранить код. Попробуйте позже.",
|
||||
)
|
||||
try:
|
||||
await send_password_reset_code_email(email_norm, code)
|
||||
except Exception:
|
||||
await pwd_reset.release_cooldown(email_norm)
|
||||
await pwd_reset.delete_code(email_norm)
|
||||
raise HTTPException(
|
||||
status_code=503,
|
||||
detail="Не удалось отправить письмо. Попробуйте позже.",
|
||||
) from None
|
||||
return _RESET_OK_MESSAGE
|
||||
|
||||
|
||||
@router.post("/confirm-password-reset", response_model=LoginResponse)
|
||||
async def confirm_password_reset(
|
||||
body: ConfirmPasswordResetRequest,
|
||||
request: Request,
|
||||
response: Response,
|
||||
session: AsyncSession = Depends(get_session),
|
||||
):
|
||||
email_norm = normalize_login_email(body.email)
|
||||
if not email_norm or not body.code or not body.code.strip():
|
||||
raise HTTPException(status_code=400, detail="Email и код обязательны")
|
||||
if body.password != body.password_confirm:
|
||||
raise HTTPException(status_code=400, detail="Пароли не совпадают")
|
||||
if len(body.password) < 8:
|
||||
raise HTTPException(status_code=400, detail="Пароль минимум 8 символов")
|
||||
if not await pwd_reset.redis_ready():
|
||||
raise HTTPException(
|
||||
status_code=503,
|
||||
detail="Сервис временно недоступен. Попробуйте позже.",
|
||||
)
|
||||
if not await pwd_reset.try_consume_email_verify_budget(email_norm):
|
||||
raise HTTPException(
|
||||
status_code=429,
|
||||
detail="Слишком много попыток. Запросите новый код.",
|
||||
)
|
||||
if not await pwd_reset.verify_and_consume_code(email_norm, body.code.strip()):
|
||||
raise HTTPException(status_code=401, detail="Неверный код или срок действия истёк")
|
||||
identity = await idb.get_identity_by_email(session, email_norm)
|
||||
if not identity:
|
||||
raise HTTPException(status_code=400, detail="Аккаунт не найден")
|
||||
updated = await idb.set_password_for_identity(session, identity.id, body.password)
|
||||
if not updated:
|
||||
raise HTTPException(status_code=400, detail="Не удалось обновить пароль")
|
||||
await bind_identity_actor(request, session, updated)
|
||||
token = await idb.issue_token_for_identity(session, updated, request=request)
|
||||
set_auth_cookie(response, token, request)
|
||||
set_is_admin_cookie(response, updated, request)
|
||||
return build_login_response(updated)
|
||||
@@ -1,601 +0,0 @@
|
||||
import re
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Path, Request, Response
|
||||
from fastapi.responses import HTMLResponse
|
||||
from pydantic import BaseModel
|
||||
from sqlalchemy import String, func, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from api.depends import (
|
||||
bind_identity_actor,
|
||||
clear_auth_cookie,
|
||||
get_request_actor,
|
||||
get_session,
|
||||
hash_token,
|
||||
verify_identity_token,
|
||||
)
|
||||
from api.depends import AUTH_COOKIE_NAME
|
||||
from api.v2.routes.auth._common import _resolve_partner_snapshot
|
||||
from api.v2.schemas.identities import (
|
||||
ChangePasswordRequest,
|
||||
IdentityResponse,
|
||||
IdentitySessionItem,
|
||||
IdentitySessionsResponse,
|
||||
SetPasswordRequest,
|
||||
)
|
||||
from api.v2.schemas.web_public import (
|
||||
AccountSearchHit,
|
||||
AccountSearchResponse,
|
||||
AccountSummaryResponse,
|
||||
)
|
||||
from database import (
|
||||
get_balance,
|
||||
get_keys,
|
||||
get_trial,
|
||||
identities as idb,
|
||||
identity_sessions as idsess,
|
||||
)
|
||||
from database.models import CouponUsage, Gift, GiftUsage, IdentityNotifPref, Key, Payment, WebNotification
|
||||
from database.referrals import get_referral_stats
|
||||
from database.web_notifications import count_unread_for_identity
|
||||
from utils.referral_codes import encode_referral_code
|
||||
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.get("/me", response_model=IdentityResponse)
|
||||
async def me(
|
||||
identity=Depends(verify_identity_token),
|
||||
):
|
||||
"""Текущая идентичность по HttpOnly cookie `auth_token`."""
|
||||
return IdentityResponse.model_validate(identity)
|
||||
|
||||
|
||||
def _current_token_hash(request: Request) -> str | None:
|
||||
raw = request.cookies.get(AUTH_COOKIE_NAME)
|
||||
if not raw or not raw.strip():
|
||||
return None
|
||||
return hash_token(raw.strip())
|
||||
|
||||
|
||||
@router.get("/sessions", response_model=IdentitySessionsResponse)
|
||||
async def list_my_sessions(
|
||||
request: Request,
|
||||
session: AsyncSession = Depends(get_session),
|
||||
identity=Depends(verify_identity_token),
|
||||
):
|
||||
"""Возвращает активные сессии текущей identity (все устройства)."""
|
||||
current_hash = _current_token_hash(request)
|
||||
rows = await idsess.list_sessions_for_identity(session, identity.id)
|
||||
items = [
|
||||
IdentitySessionItem(
|
||||
id=row.id,
|
||||
device_label=row.device_label,
|
||||
ip=row.ip,
|
||||
created_at=row.created_at,
|
||||
last_seen_at=row.last_seen_at,
|
||||
expires_at=row.expires_at,
|
||||
is_current=bool(current_hash and row.token_hash == current_hash),
|
||||
)
|
||||
for row in rows
|
||||
]
|
||||
return IdentitySessionsResponse(sessions=items)
|
||||
|
||||
|
||||
@router.delete("/sessions/{session_id}")
|
||||
async def revoke_my_session(
|
||||
session_id: str,
|
||||
request: Request,
|
||||
response: Response,
|
||||
session: AsyncSession = Depends(get_session),
|
||||
identity=Depends(verify_identity_token),
|
||||
):
|
||||
"""Удаляет одну сессию текущей identity. Если удалена текущая — очищаем cookie."""
|
||||
ok = await idsess.delete_session_by_id(
|
||||
session, session_id=session_id, identity_id=identity.id
|
||||
)
|
||||
if not ok:
|
||||
raise HTTPException(status_code=404, detail="Сессия не найдена")
|
||||
current_hash = _current_token_hash(request)
|
||||
rows = await idsess.list_sessions_for_identity(session, identity.id)
|
||||
if current_hash and not any(r.token_hash == current_hash for r in rows):
|
||||
clear_auth_cookie(response, request)
|
||||
return {"ok": True}
|
||||
|
||||
|
||||
@router.post("/sessions/revoke-others")
|
||||
async def revoke_other_sessions(
|
||||
request: Request,
|
||||
session: AsyncSession = Depends(get_session),
|
||||
identity=Depends(verify_identity_token),
|
||||
):
|
||||
"""Удаляет все сессии текущей identity кроме текущей."""
|
||||
current_hash = _current_token_hash(request)
|
||||
if not current_hash:
|
||||
raise HTTPException(status_code=400, detail="Текущая сессия не определена")
|
||||
removed = await idsess.delete_other_sessions(
|
||||
session, identity_id=identity.id, keep_token_hash=current_hash
|
||||
)
|
||||
return {"ok": True, "removed": removed}
|
||||
|
||||
|
||||
@router.post("/logout")
|
||||
async def logout(
|
||||
request: Request,
|
||||
response: Response,
|
||||
session: AsyncSession = Depends(get_session),
|
||||
):
|
||||
"""Удаляет текущую сессию из БД и очищает auth cookie. Не требует валидной сессии."""
|
||||
raw = request.cookies.get(AUTH_COOKIE_NAME)
|
||||
if raw and raw.strip():
|
||||
try:
|
||||
await idsess.delete_session_by_token_hash(session, hash_token(raw.strip()))
|
||||
except Exception:
|
||||
pass
|
||||
clear_auth_cookie(response, request)
|
||||
return {"ok": True}
|
||||
|
||||
|
||||
@router.post("/me/onboarding/complete", response_model=IdentityResponse)
|
||||
async def onboarding_complete(
|
||||
session: AsyncSession = Depends(get_session),
|
||||
identity=Depends(verify_identity_token),
|
||||
):
|
||||
"""Отмечает, что админ прошёл/скипнул онбординг-тур."""
|
||||
from datetime import datetime as _dt
|
||||
|
||||
if identity.onboarding_completed_at is None:
|
||||
identity.onboarding_completed_at = _dt.utcnow()
|
||||
return IdentityResponse.model_validate(identity)
|
||||
|
||||
|
||||
@router.post("/me/onboarding/reset", response_model=IdentityResponse)
|
||||
async def onboarding_reset(
|
||||
session: AsyncSession = Depends(get_session),
|
||||
identity=Depends(verify_identity_token),
|
||||
):
|
||||
"""Сбрасывает флаг онбординга — туториал запустится снова."""
|
||||
identity.onboarding_completed_at = None
|
||||
identity.onboarding_stage = "landing"
|
||||
return IdentityResponse.model_validate(identity)
|
||||
|
||||
|
||||
_ONBOARDING_STAGES = {"landing", "header", "cabinet", "flow", "elements", "done"}
|
||||
|
||||
|
||||
@router.post("/me/onboarding/stage", response_model=IdentityResponse)
|
||||
async def onboarding_set_stage(
|
||||
body: dict,
|
||||
session: AsyncSession = Depends(get_session),
|
||||
identity=Depends(verify_identity_token),
|
||||
):
|
||||
"""Переводит админа на указанный этап онбординга."""
|
||||
from datetime import datetime as _dt
|
||||
|
||||
stage = str(body.get("stage") or "").strip()
|
||||
if stage not in _ONBOARDING_STAGES:
|
||||
raise HTTPException(status_code=400, detail="Неизвестный этап онбординга")
|
||||
identity.onboarding_stage = stage
|
||||
if stage == "done" and identity.onboarding_completed_at is None:
|
||||
identity.onboarding_completed_at = _dt.utcnow()
|
||||
return IdentityResponse.model_validate(identity)
|
||||
|
||||
|
||||
@router.get("/summary", response_model=AccountSummaryResponse)
|
||||
async def auth_summary(
|
||||
request: Request,
|
||||
session: AsyncSession = Depends(get_session),
|
||||
identity=Depends(verify_identity_token),
|
||||
):
|
||||
actor = get_request_actor(request)
|
||||
billing_user_id = actor.billing_user_id if actor and actor.billing_user_id is not None else None
|
||||
if billing_user_id is None:
|
||||
billing_user_id = await idb.ensure_billing_user_for_identity(session, identity)
|
||||
balance = float(await get_balance(session, billing_user_id))
|
||||
trial_status = await get_trial(session, billing_user_id)
|
||||
keys = await get_keys(session, billing_user_id)
|
||||
keys_total = len(keys) if keys else 0
|
||||
gifts_sent_r = await session.execute(
|
||||
select(func.count()).select_from(Gift).where(Gift.sender_user_id == billing_user_id)
|
||||
)
|
||||
gifts_sent = gifts_sent_r.scalar_one() or 0
|
||||
gifts_claimed_r = await session.execute(
|
||||
select(func.count()).select_from(GiftUsage).where(GiftUsage.user_id == billing_user_id)
|
||||
)
|
||||
gifts_claimed = gifts_claimed_r.scalar_one() or 0
|
||||
coupons_r = await session.execute(
|
||||
select(func.count()).select_from(CouponUsage).where(CouponUsage.user_id == billing_user_id)
|
||||
)
|
||||
coupons_used = coupons_r.scalar_one() or 0
|
||||
ref = await get_referral_stats(session, billing_user_id)
|
||||
partner = await _resolve_partner_snapshot(session, int(billing_user_id))
|
||||
unread_notifications = await count_unread_for_identity(session, identity.id)
|
||||
return AccountSummaryResponse(
|
||||
identity_id=identity.id,
|
||||
email=identity.email,
|
||||
tg_id=identity.tg_id,
|
||||
linked_telegram=identity.tg_id is not None,
|
||||
created_at=identity.created_at.isoformat() if identity.created_at else None,
|
||||
password_set=bool(identity.password_set),
|
||||
referral_code=encode_referral_code(int(billing_user_id)),
|
||||
balance=balance,
|
||||
trial_status=int(trial_status),
|
||||
keys_total=keys_total,
|
||||
referrals_total=int(ref.get("total_referrals") or 0),
|
||||
referrals_active=int(ref.get("active_referrals") or 0),
|
||||
referral_bonus_total=float(ref.get("total_referral_bonus") or 0),
|
||||
gifts_sent=int(gifts_sent),
|
||||
gifts_claimed=int(gifts_claimed),
|
||||
coupons_used=int(coupons_used),
|
||||
partner_enabled=bool(partner.get("partner_enabled", False)),
|
||||
partner_code=str(partner.get("partner_code") or ""),
|
||||
partner_balance=float(partner.get("partner_balance") or 0.0),
|
||||
partner_percent=float(partner.get("partner_percent") or 0.0),
|
||||
partner_percent_custom=bool(partner.get("partner_percent_custom", False)),
|
||||
partner_referred_total=int(partner.get("partner_referred_total") or 0),
|
||||
partner_referred_paid=int(partner.get("partner_referred_paid") or 0),
|
||||
partner_payout_method=partner.get("partner_payout_method"),
|
||||
unread_notifications=int(unread_notifications),
|
||||
)
|
||||
|
||||
|
||||
class MyPaymentItem(BaseModel):
|
||||
id: int
|
||||
payment_id: str | None
|
||||
amount: float
|
||||
currency: str
|
||||
status: str
|
||||
provider: str
|
||||
created_at: str | None
|
||||
purpose: str | None
|
||||
|
||||
|
||||
class MyPaymentsResponse(BaseModel):
|
||||
ok: bool = True
|
||||
payments: list[MyPaymentItem]
|
||||
|
||||
|
||||
@router.get("/me/payments", response_model=MyPaymentsResponse)
|
||||
async def my_payments(
|
||||
request: Request,
|
||||
session: AsyncSession = Depends(get_session),
|
||||
identity=Depends(verify_identity_token),
|
||||
limit: int = 50,
|
||||
):
|
||||
"""История платежей текущего юзера. Привязка через Identity → User → Payment."""
|
||||
actor = get_request_actor(request)
|
||||
billing_user_id = actor.billing_user_id if actor and actor.billing_user_id is not None else None
|
||||
if billing_user_id is None:
|
||||
billing_user_id = await idb.ensure_billing_user_for_identity(session, identity)
|
||||
if billing_user_id is None:
|
||||
return MyPaymentsResponse(ok=True, payments=[])
|
||||
safe_limit = max(1, min(200, int(limit) if limit else 50))
|
||||
rows = await session.execute(
|
||||
select(Payment)
|
||||
.where(Payment.user_id == billing_user_id)
|
||||
.order_by(Payment.created_at.desc())
|
||||
.limit(safe_limit)
|
||||
)
|
||||
payments = rows.scalars().all()
|
||||
items: list[MyPaymentItem] = []
|
||||
for p in payments:
|
||||
meta = p.metadata_ if isinstance(p.metadata_, dict) else None
|
||||
purpose = None
|
||||
if meta:
|
||||
purpose = meta.get("purpose") or meta.get("description") or meta.get("tariff_name")
|
||||
if purpose is not None:
|
||||
purpose = str(purpose)
|
||||
items.append(
|
||||
MyPaymentItem(
|
||||
id=int(p.id),
|
||||
payment_id=str(p.payment_id) if p.payment_id else None,
|
||||
amount=float(p.amount or 0),
|
||||
currency=str(p.currency or "RUB"),
|
||||
status=str(p.status or ""),
|
||||
provider=str(p.payment_system or ""),
|
||||
created_at=p.created_at.isoformat() if p.created_at else None,
|
||||
purpose=purpose,
|
||||
)
|
||||
)
|
||||
return MyPaymentsResponse(ok=True, payments=items)
|
||||
|
||||
|
||||
def _esc(value: object) -> str:
|
||||
s = "" if value is None else str(value)
|
||||
return (
|
||||
s.replace("&", "&")
|
||||
.replace("<", "<")
|
||||
.replace(">", ">")
|
||||
.replace('"', """)
|
||||
.replace("'", "'")
|
||||
)
|
||||
|
||||
|
||||
@router.get("/me/payments/{payment_id}/invoice", response_class=HTMLResponse)
|
||||
async def get_my_payment_invoice(
|
||||
payment_id: int = Path(..., ge=1),
|
||||
request: Request = None, # type: ignore[assignment]
|
||||
session: AsyncSession = Depends(get_session),
|
||||
identity=Depends(verify_identity_token),
|
||||
):
|
||||
"""HTML-инвойс по конкретному платежу. Браузер может сохранить как PDF (Cmd+P → Save as PDF)."""
|
||||
actor = get_request_actor(request) if request is not None else None
|
||||
billing_user_id = actor.billing_user_id if actor and actor.billing_user_id is not None else None
|
||||
if billing_user_id is None:
|
||||
billing_user_id = await idb.ensure_billing_user_for_identity(session, identity)
|
||||
if billing_user_id is None:
|
||||
raise HTTPException(status_code=404, detail="Платёж не найден")
|
||||
payment = (
|
||||
await session.execute(
|
||||
select(Payment).where(Payment.id == payment_id, Payment.user_id == billing_user_id).limit(1)
|
||||
)
|
||||
).scalar_one_or_none()
|
||||
if payment is None:
|
||||
raise HTTPException(status_code=404, detail="Платёж не найден")
|
||||
meta = payment.metadata_ if isinstance(payment.metadata_, dict) else {}
|
||||
purpose = ""
|
||||
if meta:
|
||||
v = meta.get("purpose") or meta.get("description") or meta.get("tariff_name")
|
||||
if v is not None:
|
||||
purpose = str(v)
|
||||
created = payment.created_at.strftime("%d.%m.%Y %H:%M") if payment.created_at else "—"
|
||||
amount_value = float(payment.amount or 0)
|
||||
currency = str(payment.currency or "RUB").upper()
|
||||
status_raw = str(payment.status or "")
|
||||
status_norm = status_raw.lower()
|
||||
status_label = "ОПЛАЧЕН" if status_norm in {"completed", "success", "paid"} else "ОЖИДАЕТ" if status_norm in {"pending", "processing"} else "ОТКЛОНЁН"
|
||||
provider = str(payment.payment_system or "").upper() or "—"
|
||||
user_label = identity.email or (f"tg · {identity.tg_id}" if identity.tg_id else identity.id)
|
||||
payment_identifier = str(payment.payment_id).strip() if payment.payment_id else ""
|
||||
title_suffix = payment_identifier if payment_identifier else f"#{payment.id}"
|
||||
html = f"""<!DOCTYPE html>
|
||||
<html lang=\"ru\">
|
||||
<head>
|
||||
<meta charset=\"utf-8\" />
|
||||
<title>Квитанция {_esc(title_suffix)}</title>
|
||||
<style>
|
||||
@page {{ size: A4; margin: 18mm; }}
|
||||
body {{ font-family: 'JetBrains Mono', ui-monospace, monospace; color: #111; background: #fff; max-width: 720px; margin: 0 auto; padding: 24px; }}
|
||||
h1 {{ font-size: 24px; letter-spacing: -0.02em; margin: 0 0 4px; text-transform: uppercase; }}
|
||||
.sub {{ color: #888; font-size: 11px; letter-spacing: 0.18em; text-transform: uppercase; margin-bottom: 32px; }}
|
||||
table {{ width: 100%; border-collapse: collapse; font-size: 13px; }}
|
||||
td {{ padding: 11px 0; border-bottom: 1px dashed #ddd; vertical-align: top; }}
|
||||
td.k {{ color: #888; width: 35%; letter-spacing: 0.08em; text-transform: uppercase; font-size: 11px; }}
|
||||
td.v {{ font-weight: 600; }}
|
||||
.amount {{ font-size: 32px; font-weight: 800; letter-spacing: -0.02em; margin: 24px 0 8px; }}
|
||||
.badge {{ display: inline-block; padding: 4px 10px; border: 1px solid #111; font-size: 11px; letter-spacing: 0.14em; text-transform: uppercase; }}
|
||||
.footer {{ margin-top: 48px; font-size: 10px; color: #aaa; letter-spacing: 0.12em; text-transform: uppercase; text-align: center; }}
|
||||
@media print {{ .no-print {{ display: none; }} }}
|
||||
.print-btn {{ position: fixed; top: 16px; right: 16px; padding: 10px 16px; background: #111; color: #fff; border: 0; cursor: pointer; font-family: inherit; font-size: 12px; letter-spacing: 0.1em; text-transform: uppercase; }}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<button class=\"print-btn no-print\" onclick=\"window.print()\">Сохранить PDF</button>
|
||||
<h1>Квитанция {_esc(title_suffix)}</h1>
|
||||
<div class=\"sub\">// {_esc(created)}</div>
|
||||
<div class=\"amount\">{amount_value:,.2f} {_esc(currency)}</div>
|
||||
<span class=\"badge\">{_esc(status_label)}</span>
|
||||
<table>
|
||||
<tr><td class=\"k\">Назначение</td><td class=\"v\">{_esc(purpose) or "—"}</td></tr>
|
||||
<tr><td class=\"k\">Провайдер</td><td class=\"v\">{_esc(provider)}</td></tr>
|
||||
<tr><td class=\"k\">Дата</td><td class=\"v\">{_esc(created)}</td></tr>
|
||||
<tr><td class=\"k\">Получатель</td><td class=\"v\">{_esc(user_label)}</td></tr>
|
||||
<tr><td class=\"k\">Идентификатор платежа</td><td class=\"v\" style=\"font-size:11px;color:#666\">{_esc(payment_identifier) if payment_identifier else "—"}</td></tr>
|
||||
</table>
|
||||
<div class=\"footer\">Документ сгенерирован автоматически. Не требует подписи и печати.</div>
|
||||
</body>
|
||||
</html>"""
|
||||
return HTMLResponse(content=html, status_code=200)
|
||||
|
||||
|
||||
class NotifChannelPref(BaseModel):
|
||||
channel: str
|
||||
enabled: bool
|
||||
|
||||
|
||||
class NotifChannelPrefsResponse(BaseModel):
|
||||
ok: bool = True
|
||||
channels: list[NotifChannelPref]
|
||||
|
||||
|
||||
class NotifChannelPrefsUpdateRequest(BaseModel):
|
||||
channels: list[NotifChannelPref]
|
||||
|
||||
|
||||
_NOTIF_CHANNEL_RE = re.compile(r"^[a-zA-Z0-9_-]{1,32}$")
|
||||
|
||||
|
||||
@router.get("/me/notification-prefs", response_model=NotifChannelPrefsResponse)
|
||||
async def get_my_notification_prefs(
|
||||
session: AsyncSession = Depends(get_session),
|
||||
identity=Depends(verify_identity_token),
|
||||
):
|
||||
rows = (
|
||||
await session.execute(
|
||||
select(IdentityNotifPref).where(IdentityNotifPref.identity_id == identity.id)
|
||||
)
|
||||
).scalars().all()
|
||||
return NotifChannelPrefsResponse(
|
||||
ok=True,
|
||||
channels=[NotifChannelPref(channel=str(r.channel), enabled=bool(r.enabled)) for r in rows],
|
||||
)
|
||||
|
||||
|
||||
@router.put("/me/notification-prefs", response_model=NotifChannelPrefsResponse)
|
||||
async def set_my_notification_prefs(
|
||||
body: NotifChannelPrefsUpdateRequest,
|
||||
session: AsyncSession = Depends(get_session),
|
||||
identity=Depends(verify_identity_token),
|
||||
):
|
||||
for entry in body.channels:
|
||||
channel = str(entry.channel or "").strip()
|
||||
if not channel or not _NOTIF_CHANNEL_RE.match(channel):
|
||||
raise HTTPException(status_code=422, detail=f"Некорректный канал: {channel!r}")
|
||||
existing = (
|
||||
await session.execute(
|
||||
select(IdentityNotifPref).where(
|
||||
IdentityNotifPref.identity_id == identity.id,
|
||||
IdentityNotifPref.channel == channel,
|
||||
)
|
||||
)
|
||||
).scalar_one_or_none()
|
||||
if existing is None:
|
||||
session.add(
|
||||
IdentityNotifPref(identity_id=identity.id, channel=channel, enabled=bool(entry.enabled))
|
||||
)
|
||||
else:
|
||||
existing.enabled = bool(entry.enabled)
|
||||
await session.flush()
|
||||
rows = (
|
||||
await session.execute(
|
||||
select(IdentityNotifPref).where(IdentityNotifPref.identity_id == identity.id)
|
||||
)
|
||||
).scalars().all()
|
||||
return NotifChannelPrefsResponse(
|
||||
ok=True,
|
||||
channels=[NotifChannelPref(channel=str(r.channel), enabled=bool(r.enabled)) for r in rows],
|
||||
)
|
||||
|
||||
|
||||
@router.get("/me/search", response_model=AccountSearchResponse)
|
||||
async def my_search(
|
||||
q: str,
|
||||
request: Request,
|
||||
session: AsyncSession = Depends(get_session),
|
||||
identity=Depends(verify_identity_token),
|
||||
limit: int = 8,
|
||||
):
|
||||
"""Поиск по подпискам, платежам, уведомлениям текущего user'а. Простое ILIKE."""
|
||||
query_raw = (q or "").strip()
|
||||
if len(query_raw) < 2:
|
||||
return AccountSearchResponse(query=query_raw, hits=[], total=0)
|
||||
actor = get_request_actor(request)
|
||||
billing_user_id = actor.billing_user_id if actor and actor.billing_user_id is not None else None
|
||||
if billing_user_id is None:
|
||||
billing_user_id = await idb.ensure_billing_user_for_identity(session, identity)
|
||||
if billing_user_id is None:
|
||||
return AccountSearchResponse(query=query_raw, hits=[], total=0)
|
||||
safe_limit = max(1, min(20, int(limit) if limit else 8))
|
||||
pattern = f"%{query_raw.lower()}%"
|
||||
hits: list[AccountSearchHit] = []
|
||||
|
||||
# Keys: alias / email / server_id
|
||||
keys_rows = (
|
||||
await session.execute(
|
||||
select(Key)
|
||||
.where(Key.user_id == billing_user_id)
|
||||
.where(
|
||||
func.lower(func.coalesce(Key.alias, ""))
|
||||
.like(pattern)
|
||||
| func.lower(func.coalesce(Key.email, "")).like(pattern)
|
||||
| func.lower(func.coalesce(Key.server_id, "")).like(pattern)
|
||||
| func.lower(func.coalesce(Key.client_id, "")).like(pattern)
|
||||
)
|
||||
.limit(safe_limit)
|
||||
)
|
||||
).scalars().all()
|
||||
for k in keys_rows:
|
||||
label = (k.alias or k.email or k.client_id or "").strip() or "—"
|
||||
sublabel = (k.server_id or "").strip() or "—"
|
||||
hits.append(AccountSearchHit(kind="subscription", label=label, sublabel=sublabel, href="/dashboard/keys", meta=str(k.client_id)))
|
||||
|
||||
# Payments: provider / metadata.purpose
|
||||
payments_rows = (
|
||||
await session.execute(
|
||||
select(Payment)
|
||||
.where(Payment.user_id == billing_user_id)
|
||||
.where(
|
||||
func.lower(func.coalesce(Payment.payment_system, "")).like(pattern)
|
||||
| func.cast(Payment.metadata_, String).ilike(pattern)
|
||||
)
|
||||
.order_by(Payment.created_at.desc())
|
||||
.limit(safe_limit)
|
||||
)
|
||||
).scalars().all()
|
||||
for p in payments_rows:
|
||||
meta = p.metadata_ if isinstance(p.metadata_, dict) else None
|
||||
purpose = ""
|
||||
if meta:
|
||||
v = meta.get("purpose") or meta.get("description") or meta.get("tariff_name")
|
||||
if v is not None:
|
||||
purpose = str(v)
|
||||
amount_label = f"{float(p.amount or 0):,.0f} {(p.currency or 'RUB').upper()}"
|
||||
hits.append(AccountSearchHit(
|
||||
kind="payment",
|
||||
label=purpose or amount_label,
|
||||
sublabel=f"{(p.payment_system or '').upper()} · {amount_label}",
|
||||
href="/dashboard",
|
||||
meta=str(p.id),
|
||||
))
|
||||
|
||||
# Notifications: title / message
|
||||
notif_rows = (
|
||||
await session.execute(
|
||||
select(WebNotification)
|
||||
.where(WebNotification.identity_id == identity.id)
|
||||
.where(
|
||||
func.lower(WebNotification.title).like(pattern)
|
||||
| func.lower(WebNotification.message).like(pattern)
|
||||
)
|
||||
.order_by(WebNotification.created_at.desc())
|
||||
.limit(safe_limit)
|
||||
)
|
||||
).scalars().all()
|
||||
for n in notif_rows:
|
||||
hits.append(AccountSearchHit(
|
||||
kind="notification",
|
||||
label=str(n.title or "—"),
|
||||
sublabel=(str(n.message or "")[:80]),
|
||||
href="/dashboard/notifications",
|
||||
meta=str(n.id),
|
||||
))
|
||||
|
||||
return AccountSearchResponse(query=query_raw, hits=hits, total=len(hits))
|
||||
|
||||
|
||||
@router.post("/set-password")
|
||||
async def set_password(
|
||||
body: SetPasswordRequest,
|
||||
request: Request,
|
||||
session: AsyncSession = Depends(get_session),
|
||||
identity=Depends(verify_identity_token),
|
||||
):
|
||||
if body.password != body.password_confirm:
|
||||
raise HTTPException(status_code=400, detail="Пароли не совпадают")
|
||||
updated = await idb.set_initial_password(session, identity.id, body.password)
|
||||
if not updated:
|
||||
raise HTTPException(
|
||||
status_code=409,
|
||||
detail="Пароль уже установлен или аккаунт недоступен",
|
||||
)
|
||||
await bind_identity_actor(request, session, updated)
|
||||
return {"ok": True}
|
||||
|
||||
|
||||
@router.post("/change-password")
|
||||
async def change_password(
|
||||
body: ChangePasswordRequest,
|
||||
request: Request,
|
||||
session: AsyncSession = Depends(get_session),
|
||||
identity=Depends(verify_identity_token),
|
||||
):
|
||||
if body.password != body.password_confirm:
|
||||
raise HTTPException(status_code=400, detail="Новые пароли не совпадают")
|
||||
err = await idb.change_identity_password(
|
||||
session,
|
||||
identity.id,
|
||||
body.current_password,
|
||||
body.password,
|
||||
)
|
||||
if err == "no_password":
|
||||
raise HTTPException(
|
||||
status_code=409,
|
||||
detail="Пароль ещё не установлен. Сначала задайте пароль в кабинете.",
|
||||
)
|
||||
if err == "wrong_password":
|
||||
raise HTTPException(status_code=401, detail="Неверный текущий пароль")
|
||||
refreshed = await idb.get_identity_by_id(session, identity.id)
|
||||
if refreshed:
|
||||
await bind_identity_actor(request, session, refreshed)
|
||||
return {"ok": True}
|
||||
@@ -1,236 +0,0 @@
|
||||
from fastapi import APIRouter, Depends, HTTPException, Request, Response
|
||||
from pydantic import (
|
||||
BaseModel,
|
||||
Field as PydanticField,
|
||||
)
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from api.depends import (
|
||||
bind_identity_actor,
|
||||
get_session,
|
||||
set_auth_cookie,
|
||||
set_is_admin_cookie,
|
||||
verify_identity_token,
|
||||
)
|
||||
from api.v2.routes.auth._common import TELEGRAM_LOGIN_MAX_AGE, TOKEN_TTL_HINT, _client_ip, build_login_response
|
||||
from api.v2.schemas.identities import (
|
||||
IdentityResponse,
|
||||
LinkTelegramRequest,
|
||||
LoginResponse,
|
||||
LoginTelegramRequest,
|
||||
)
|
||||
from config import API_TOKEN
|
||||
from database import identities as idb
|
||||
from logger import logger
|
||||
from utils.telegram_login import verify_telegram_login
|
||||
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
def _get_oidc_credentials() -> tuple[str, str]:
|
||||
try:
|
||||
from config import TELEGRAM_CLIENT_ID, TELEGRAM_CLIENT_SECRET
|
||||
cid = str(TELEGRAM_CLIENT_ID).strip()
|
||||
secret = str(TELEGRAM_CLIENT_SECRET).strip()
|
||||
if cid and secret:
|
||||
return cid, secret
|
||||
except ImportError:
|
||||
pass
|
||||
return "", ""
|
||||
|
||||
|
||||
class LoginTelegramWebAppRequest(BaseModel):
|
||||
init_data: str = PydanticField(..., min_length=1)
|
||||
|
||||
|
||||
@router.post("/login-telegram", response_model=LoginResponse)
|
||||
async def login_telegram(
|
||||
body: LoginTelegramRequest,
|
||||
request: Request,
|
||||
response: Response,
|
||||
session: AsyncSession = Depends(get_session),
|
||||
):
|
||||
(
|
||||
"""Вход через Telegram Login Widget (кнопка на сайте). По tg_id находим или создаём Identity, выдаём токен. Срок действия токена: """
|
||||
+ TOKEN_TTL_HINT
|
||||
+ "."
|
||||
)
|
||||
payload = body.model_dump(mode="json")
|
||||
if not verify_telegram_login(payload, API_TOKEN, max_age_seconds=TELEGRAM_LOGIN_MAX_AGE):
|
||||
raise HTTPException(status_code=401, detail="Неверная подпись или устаревшие данные от Telegram")
|
||||
identity = await idb.get_or_create_identity_for_tg(session, body.id)
|
||||
await bind_identity_actor(request, session, identity)
|
||||
token = await idb.issue_token_for_identity(session, identity, request=request)
|
||||
logger.info(
|
||||
"[Auth] Login success: identity={}, tg_id={}, ip={}, method=telegram_widget",
|
||||
identity.id,
|
||||
body.id,
|
||||
_client_ip(request),
|
||||
)
|
||||
set_auth_cookie(response, token, request)
|
||||
set_is_admin_cookie(response, identity, request)
|
||||
return build_login_response(identity)
|
||||
|
||||
|
||||
@router.post("/login-telegram-webapp", response_model=LoginResponse)
|
||||
async def login_telegram_webapp(
|
||||
body: LoginTelegramWebAppRequest,
|
||||
request: Request,
|
||||
response: Response,
|
||||
session: AsyncSession = Depends(get_session),
|
||||
):
|
||||
"""Вход через Telegram WebApp initData. Валидирует HMAC, находит/создаёт Identity по tg_id."""
|
||||
from utils.telegram_login import verify_webapp_init_data
|
||||
|
||||
result = verify_webapp_init_data(body.init_data, API_TOKEN)
|
||||
if not result:
|
||||
raise HTTPException(status_code=401, detail="Неверная подпись initData")
|
||||
tg_id = result.get("user_id")
|
||||
if not tg_id:
|
||||
raise HTTPException(status_code=401, detail="Не удалось определить пользователя из initData")
|
||||
identity = await idb.get_or_create_identity_for_tg(session, int(tg_id))
|
||||
await bind_identity_actor(request, session, identity)
|
||||
token = await idb.issue_token_for_identity(session, identity, request=request)
|
||||
logger.info(
|
||||
"[Auth] Login success: identity={}, tg_id={}, ip={}, method=telegram_webapp",
|
||||
identity.id,
|
||||
tg_id,
|
||||
_client_ip(request),
|
||||
)
|
||||
set_auth_cookie(response, token, request)
|
||||
set_is_admin_cookie(response, identity, request)
|
||||
return build_login_response(identity)
|
||||
|
||||
|
||||
class LoginTelegramOIDCRequest(BaseModel):
|
||||
code: str = PydanticField(..., min_length=1)
|
||||
redirect_uri: str = PydanticField(..., min_length=1)
|
||||
code_verifier: str = PydanticField(default="", description="PKCE code_verifier")
|
||||
|
||||
|
||||
@router.post("/login-telegram-oidc", response_model=LoginResponse)
|
||||
async def login_telegram_oidc(
|
||||
body: LoginTelegramOIDCRequest,
|
||||
request: Request,
|
||||
response: Response,
|
||||
session: AsyncSession = Depends(get_session),
|
||||
):
|
||||
"""Вход через Telegram OIDC (authorization code flow). Обменивает code на id_token, верифицирует JWT."""
|
||||
import base64
|
||||
|
||||
import aiohttp
|
||||
import jwt as pyjwt
|
||||
|
||||
client_id, client_secret = _get_oidc_credentials()
|
||||
if not client_id or not client_secret:
|
||||
logger.warning("[Auth] Telegram OIDC credentials missing in config")
|
||||
raise HTTPException(status_code=503, detail="Вход через Telegram временно недоступен")
|
||||
|
||||
token_data = {
|
||||
"grant_type": "authorization_code",
|
||||
"code": body.code,
|
||||
"redirect_uri": body.redirect_uri,
|
||||
}
|
||||
if body.code_verifier:
|
||||
token_data["code_verifier"] = body.code_verifier
|
||||
|
||||
basic = base64.b64encode(f"{client_id}:{client_secret}".encode()).decode()
|
||||
headers = {"Authorization": f"Basic {basic}", "Content-Type": "application/x-www-form-urlencoded"}
|
||||
|
||||
async with aiohttp.ClientSession() as http:
|
||||
async with http.post("https://oauth.telegram.org/token", data=token_data, headers=headers) as resp:
|
||||
if resp.status != 200:
|
||||
err_text = await resp.text()
|
||||
logger.warning("[Auth] Telegram OIDC token exchange failed: {} {}", resp.status, err_text[:200])
|
||||
raise HTTPException(status_code=401, detail="Не удалось обменять код авторизации")
|
||||
token_response = await resp.json()
|
||||
|
||||
id_token = token_response.get("id_token")
|
||||
if not id_token:
|
||||
raise HTTPException(status_code=401, detail="Telegram не вернул id_token")
|
||||
|
||||
async with aiohttp.ClientSession() as http:
|
||||
async with http.get("https://oauth.telegram.org/.well-known/jwks.json") as resp:
|
||||
jwks_data = await resp.json()
|
||||
|
||||
try:
|
||||
signing_key = pyjwt.PyJWKClient.__new__(pyjwt.PyJWKClient)
|
||||
from jwt.api_jwk import PyJWKSet
|
||||
jwk_set = PyJWKSet.from_dict(jwks_data)
|
||||
unverified_header = pyjwt.get_unverified_header(id_token)
|
||||
kid = unverified_header.get("kid")
|
||||
key = None
|
||||
for k in jwk_set.keys:
|
||||
if k.key_id == kid:
|
||||
key = k
|
||||
break
|
||||
if not key:
|
||||
raise HTTPException(status_code=401, detail="Ключ подписи не найден в JWKS")
|
||||
|
||||
claims = pyjwt.decode(
|
||||
id_token,
|
||||
key.key,
|
||||
algorithms=["RS256"],
|
||||
audience=client_id,
|
||||
issuer="https://oauth.telegram.org",
|
||||
)
|
||||
except pyjwt.ExpiredSignatureError:
|
||||
raise HTTPException(status_code=401, detail="ID токен истёк")
|
||||
except pyjwt.InvalidTokenError as exc:
|
||||
logger.warning("[Auth] Telegram OIDC JWT invalid: {}", exc)
|
||||
raise HTTPException(status_code=401, detail="Невалидный ID токен")
|
||||
|
||||
logger.info("[Auth] Telegram OIDC claims: {}", {k: v for k, v in claims.items() if k not in ("iat", "exp", "iss", "aud")})
|
||||
|
||||
tg_id = claims.get("id") or claims.get("telegram_id")
|
||||
if not tg_id:
|
||||
raise HTTPException(status_code=401, detail="Не удалось определить пользователя из id_token")
|
||||
|
||||
try:
|
||||
tg_id_int = int(tg_id)
|
||||
except (TypeError, ValueError):
|
||||
raise HTTPException(status_code=401, detail="Не удалось определить пользователя") from None
|
||||
if tg_id_int <= 0 or tg_id_int > 2**53:
|
||||
raise HTTPException(status_code=401, detail="Не удалось определить пользователя")
|
||||
|
||||
identity = await idb.get_or_create_identity_for_tg(session, tg_id_int)
|
||||
await bind_identity_actor(request, session, identity)
|
||||
token = await idb.issue_token_for_identity(session, identity, request=request)
|
||||
|
||||
if getattr(identity, "is_admin", False):
|
||||
from database.site_state import mark_site_initialized
|
||||
await mark_site_initialized(session)
|
||||
|
||||
logger.info(
|
||||
"[Auth] Login success: identity={}, tg_id={}, ip={}, method=telegram_oidc",
|
||||
identity.id,
|
||||
tg_id,
|
||||
_client_ip(request),
|
||||
)
|
||||
set_auth_cookie(response, token, request)
|
||||
set_is_admin_cookie(response, identity, request)
|
||||
return build_login_response(identity)
|
||||
|
||||
|
||||
@router.post("/link-telegram", response_model=IdentityResponse)
|
||||
async def link_telegram(
|
||||
body: LinkTelegramRequest,
|
||||
request: Request,
|
||||
response: Response,
|
||||
session: AsyncSession = Depends(get_session),
|
||||
identity=Depends(verify_identity_token),
|
||||
):
|
||||
"""Привязывает Telegram к текущей идентичности. Требуется подпись от Telegram Login Widget (доказательство владения аккаунтом)."""
|
||||
payload = body.model_dump(mode="json")
|
||||
if not verify_telegram_login(payload, API_TOKEN, max_age_seconds=TELEGRAM_LOGIN_MAX_AGE):
|
||||
raise HTTPException(status_code=401, detail="Неверная подпись или устаревшие данные от Telegram")
|
||||
result = await idb.attach_telegram(session, identity.id, body.id)
|
||||
if not result:
|
||||
raise HTTPException(
|
||||
status_code=409,
|
||||
detail="Этот Telegram уже привязан к другой идентичности",
|
||||
)
|
||||
await bind_identity_actor(request, session, result)
|
||||
set_is_admin_cookie(response, result, request)
|
||||
return IdentityResponse.model_validate(result)
|
||||
@@ -1,207 +0,0 @@
|
||||
import base64
|
||||
import hashlib
|
||||
import hmac
|
||||
import secrets
|
||||
import time
|
||||
|
||||
from urllib.parse import urlencode
|
||||
|
||||
import httpx
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, Request, Response
|
||||
from fastapi.responses import RedirectResponse
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from api.depends import (
|
||||
bind_identity_actor,
|
||||
get_session,
|
||||
set_auth_cookie,
|
||||
set_is_admin_cookie,
|
||||
)
|
||||
from api.v2.routes.auth._common import _client_ip
|
||||
from database import identities as idb
|
||||
from logger import logger
|
||||
|
||||
|
||||
try:
|
||||
from config import YANDEX_CLIENT_ID as _YANDEX_CLIENT_ID
|
||||
except ImportError:
|
||||
_YANDEX_CLIENT_ID = ""
|
||||
try:
|
||||
from config import YANDEX_CLIENT_SECRET as _YANDEX_CLIENT_SECRET
|
||||
except ImportError:
|
||||
_YANDEX_CLIENT_SECRET = ""
|
||||
try:
|
||||
from config import YANDEX_REDIRECT_URI as _YANDEX_REDIRECT_URI
|
||||
except ImportError:
|
||||
_YANDEX_REDIRECT_URI = ""
|
||||
try:
|
||||
from config import OAUTH_SUCCESS_URI as _OAUTH_SUCCESS_URI
|
||||
except ImportError:
|
||||
_OAUTH_SUCCESS_URI = "/dashboard"
|
||||
try:
|
||||
from config import API_TOKEN as _YANDEX_STATE_SECRET
|
||||
except ImportError:
|
||||
_YANDEX_STATE_SECRET = "solo-yandex-state-fallback"
|
||||
|
||||
|
||||
YANDEX_AUTH_ENDPOINT = "https://oauth.yandex.ru/authorize"
|
||||
YANDEX_TOKEN_ENDPOINT = "https://oauth.yandex.ru/token"
|
||||
YANDEX_USERINFO_ENDPOINT = "https://login.yandex.ru/info"
|
||||
STATE_TTL_SECONDS = 600
|
||||
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
def yandex_configured() -> bool:
|
||||
return bool(_YANDEX_CLIENT_ID and _YANDEX_CLIENT_SECRET and _YANDEX_REDIRECT_URI)
|
||||
|
||||
|
||||
def _sign_state(payload: str) -> str:
|
||||
mac = hmac.new(str(_YANDEX_STATE_SECRET).encode(), payload.encode(), hashlib.sha256).digest()
|
||||
return base64.urlsafe_b64encode(mac).decode().rstrip("=")
|
||||
|
||||
|
||||
def _make_state(return_to: str) -> str:
|
||||
nonce = secrets.token_urlsafe(16)
|
||||
ts = str(int(time.time()))
|
||||
payload = f"{nonce}.{ts}.{return_to}"
|
||||
sig = _sign_state(payload)
|
||||
raw = f"{payload}.{sig}".encode()
|
||||
return base64.urlsafe_b64encode(raw).decode().rstrip("=")
|
||||
|
||||
|
||||
def _verify_state(state: str) -> str | None:
|
||||
try:
|
||||
padded = state + "=" * (-len(state) % 4)
|
||||
raw = base64.urlsafe_b64decode(padded).decode()
|
||||
except Exception:
|
||||
return None
|
||||
parts = raw.rsplit(".", 1)
|
||||
if len(parts) != 2:
|
||||
return None
|
||||
payload, sig = parts
|
||||
expected = _sign_state(payload)
|
||||
if not hmac.compare_digest(sig, expected):
|
||||
return None
|
||||
chunks = payload.split(".", 2)
|
||||
if len(chunks) != 3:
|
||||
return None
|
||||
_nonce, ts, return_to = chunks
|
||||
try:
|
||||
if int(time.time()) - int(ts) > STATE_TTL_SECONDS:
|
||||
return None
|
||||
except Exception:
|
||||
return None
|
||||
return return_to or _OAUTH_SUCCESS_URI
|
||||
|
||||
|
||||
@router.get("/yandex/authorize")
|
||||
async def yandex_authorize(
|
||||
request: Request,
|
||||
return_to: str = Query(default=""),
|
||||
):
|
||||
"""Начинает OAuth-флоу Яндекс ID: редиректит юзера на consent screen."""
|
||||
if not yandex_configured():
|
||||
raise HTTPException(status_code=503, detail="Вход через Яндекс не настроен на этом сервере")
|
||||
safe_return = return_to if return_to.startswith("/") else _OAUTH_SUCCESS_URI
|
||||
state = _make_state(safe_return)
|
||||
params = {
|
||||
"client_id": _YANDEX_CLIENT_ID,
|
||||
"redirect_uri": _YANDEX_REDIRECT_URI,
|
||||
"response_type": "code",
|
||||
"state": state,
|
||||
"force_confirm": "yes",
|
||||
}
|
||||
url = f"{YANDEX_AUTH_ENDPOINT}?{urlencode(params)}"
|
||||
logger.info("[Auth] Yandex authorize: ip={}", _client_ip(request))
|
||||
return RedirectResponse(url, status_code=302)
|
||||
|
||||
|
||||
@router.get("/yandex/callback")
|
||||
async def yandex_callback(
|
||||
request: Request,
|
||||
response: Response,
|
||||
code: str = Query(default=""),
|
||||
state: str = Query(default=""),
|
||||
error: str = Query(default=""),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
):
|
||||
"""Коллбек Яндекс ID: обмен code → token → userinfo → identity."""
|
||||
if not yandex_configured():
|
||||
raise HTTPException(status_code=503, detail="Вход через Яндекс не настроен на этом сервере")
|
||||
if error:
|
||||
logger.warning("[Auth] Yandex callback error: {} ip={}", error, _client_ip(request))
|
||||
return RedirectResponse(f"/login?error=yandex_{error}", status_code=302)
|
||||
if not code or not state:
|
||||
raise HTTPException(status_code=400, detail="Отсутствует code или state")
|
||||
return_to = _verify_state(state)
|
||||
if return_to is None:
|
||||
logger.warning("[Auth] Yandex callback: invalid/expired state ip={}", _client_ip(request))
|
||||
raise HTTPException(status_code=400, detail="Неверный или просроченный state")
|
||||
|
||||
async with httpx.AsyncClient(timeout=10.0) as client:
|
||||
try:
|
||||
token_res = await client.post(
|
||||
YANDEX_TOKEN_ENDPOINT,
|
||||
data={
|
||||
"grant_type": "authorization_code",
|
||||
"code": code,
|
||||
"client_id": _YANDEX_CLIENT_ID,
|
||||
"client_secret": _YANDEX_CLIENT_SECRET,
|
||||
},
|
||||
headers={"Accept": "application/json"},
|
||||
)
|
||||
except httpx.HTTPError as e:
|
||||
logger.warning("[Auth] Yandex token exchange network error: {}", e)
|
||||
raise HTTPException(status_code=502, detail="Не удалось связаться с Яндекс") from e
|
||||
if token_res.status_code != 200:
|
||||
logger.warning("[Auth] Yandex token exchange failed: {} {}", token_res.status_code, token_res.text[:200])
|
||||
raise HTTPException(status_code=401, detail="Яндекс отклонил токен")
|
||||
token_payload = token_res.json()
|
||||
access_token = token_payload.get("access_token")
|
||||
if not access_token:
|
||||
raise HTTPException(status_code=401, detail="Яндекс не вернул access_token")
|
||||
|
||||
try:
|
||||
info_res = await client.get(
|
||||
YANDEX_USERINFO_ENDPOINT,
|
||||
headers={"Authorization": f"OAuth {access_token}"},
|
||||
params={"format": "json"},
|
||||
)
|
||||
except httpx.HTTPError as e:
|
||||
logger.warning("[Auth] Yandex userinfo network error: {}", e)
|
||||
raise HTTPException(status_code=502, detail="Не удалось получить профиль Яндекс") from e
|
||||
|
||||
if info_res.status_code != 200:
|
||||
raise HTTPException(status_code=401, detail="Яндекс не вернул профиль")
|
||||
info = info_res.json()
|
||||
yandex_sub = str(info.get("id") or "").strip()
|
||||
email = (info.get("default_email") or "").strip().lower() or None
|
||||
if not yandex_sub:
|
||||
raise HTTPException(status_code=401, detail="Яндекс не вернул идентификатор пользователя")
|
||||
|
||||
identity = await idb.get_or_create_identity_for_yandex(
|
||||
session,
|
||||
yandex_sub=yandex_sub,
|
||||
email=email,
|
||||
)
|
||||
await bind_identity_actor(request, session, identity)
|
||||
token = await idb.issue_token_for_identity(session, identity, request=request)
|
||||
logger.info(
|
||||
"[Auth] Login success: identity={}, yandex_sub={}, ip={}, method=yandex",
|
||||
identity.id,
|
||||
yandex_sub,
|
||||
_client_ip(request),
|
||||
)
|
||||
redirect = RedirectResponse(return_to, status_code=302)
|
||||
set_auth_cookie(redirect, token, request)
|
||||
set_is_admin_cookie(redirect, identity, request)
|
||||
return redirect
|
||||
|
||||
|
||||
@router.get("/yandex/status")
|
||||
async def yandex_status():
|
||||
"""Позволяет фронтенду узнать, настроен ли вход через Яндекс на этом сервере."""
|
||||
return {"enabled": yandex_configured()}
|
||||
@@ -1,31 +0,0 @@
|
||||
from fastapi import HTTPException
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from services.coupons import resolve_percent_coupon
|
||||
from services.errors import ServiceError
|
||||
|
||||
|
||||
async def resolve_percent_coupon_pricing(
|
||||
session: AsyncSession,
|
||||
billing_user_id: int,
|
||||
base_price_rub: int,
|
||||
coupon_code: str | None,
|
||||
) -> tuple[int, int, int | None, str | None]:
|
||||
"""Применяет процентный купон. Бросает HTTPException при ошибке."""
|
||||
try:
|
||||
return await resolve_percent_coupon(
|
||||
session=session,
|
||||
billing_user_id=billing_user_id,
|
||||
base_price_rub=base_price_rub,
|
||||
coupon_code=coupon_code,
|
||||
)
|
||||
except ServiceError as e:
|
||||
status_map = {
|
||||
"not_found": 404,
|
||||
"limit_exceeded": 409,
|
||||
"validation_error": 400,
|
||||
}
|
||||
raise HTTPException(
|
||||
status_code=status_map.get(e.code, 400),
|
||||
detail=e.message,
|
||||
)
|
||||
@@ -1,73 +0,0 @@
|
||||
from fastapi import Depends, HTTPException, Request
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from api.depends import get_request_actor, get_session, verify_identity_token
|
||||
from api.v2.base_crud import generate_crud_router
|
||||
from api.v2.schemas import CouponBase, CouponResponse, CouponUpdate
|
||||
from api.v2.schemas.web_public import CouponApplyRequest, CouponApplyResponse
|
||||
from database import identities as idb
|
||||
from database.models import Coupon
|
||||
from services.coupons import apply_fixed_coupon
|
||||
from services.errors import LimitExceededError, NotFoundError, ServiceError, ValidationError
|
||||
|
||||
|
||||
router = generate_crud_router(
|
||||
model=Coupon,
|
||||
schema_response=CouponResponse,
|
||||
schema_create=CouponBase,
|
||||
schema_update=CouponUpdate,
|
||||
identifier_field="code",
|
||||
parameter_name="code",
|
||||
enabled_methods=["get_all", "get_one", "create", "update", "delete"],
|
||||
)
|
||||
|
||||
|
||||
async def _resolve_coupon_user_id(session: AsyncSession, request: Request, identity) -> tuple[int, int | None]:
|
||||
actor = get_request_actor(request)
|
||||
billing_user_id = actor.billing_user_id if actor and actor.billing_user_id is not None else None
|
||||
if billing_user_id is None:
|
||||
billing_user_id = await idb.ensure_billing_user_for_identity(session, identity)
|
||||
tg_id = actor.telegram_chat_id if actor else None
|
||||
return int(billing_user_id), tg_id
|
||||
|
||||
|
||||
def _service_error_to_http(e: ServiceError) -> HTTPException:
|
||||
status_map = {
|
||||
"not_found": 404,
|
||||
"limit_exceeded": 409,
|
||||
"validation_error": 400,
|
||||
"forbidden": 403,
|
||||
}
|
||||
return HTTPException(status_code=status_map.get(e.code, 400), detail=e.message)
|
||||
|
||||
|
||||
@router.post("/apply", response_model=CouponApplyResponse)
|
||||
async def apply_coupon(
|
||||
body: CouponApplyRequest,
|
||||
request: Request,
|
||||
session: AsyncSession = Depends(get_session),
|
||||
identity=Depends(verify_identity_token),
|
||||
):
|
||||
from api.ratelimit import enforce_rate_limit
|
||||
await enforce_rate_limit(request, session, bucket="coupon_apply", max_per_window=10, window_sec=60)
|
||||
user_id, tg_id = await _resolve_coupon_user_id(session, request, identity)
|
||||
try:
|
||||
result = await apply_fixed_coupon(
|
||||
session=session,
|
||||
user_id=user_id,
|
||||
tg_id=tg_id,
|
||||
code=str(body.code or ""),
|
||||
)
|
||||
return CouponApplyResponse(
|
||||
ok=True,
|
||||
message="Купон успешно активирован",
|
||||
coupon_code=result.coupon_code,
|
||||
amount=result.amount,
|
||||
balance=result.balance,
|
||||
)
|
||||
except ServiceError as e:
|
||||
await session.rollback()
|
||||
raise _service_error_to_http(e)
|
||||
except Exception:
|
||||
await session.rollback()
|
||||
raise HTTPException(status_code=500, detail="Ошибка активации купона")
|
||||
@@ -1,117 +0,0 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import UTC, datetime
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from api.depends import get_session, verify_identity_admin
|
||||
from api.v2.schemas.flows import FlowCreate, FlowResponse, FlowUpdate
|
||||
from database.models import WebFlow
|
||||
from database.site_revision import bump_site_revision
|
||||
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
def _flow_to_response(flow: WebFlow) -> FlowResponse:
|
||||
return FlowResponse(
|
||||
id=flow.id,
|
||||
name=flow.name,
|
||||
nodes=flow.nodes or [],
|
||||
edges=flow.edges or [],
|
||||
entry_node_id=flow.entry_node_id,
|
||||
version=flow.version,
|
||||
)
|
||||
|
||||
|
||||
@router.get("/flows/{flow_id}", response_model=FlowResponse, response_model_by_alias=True)
|
||||
async def get_flow_public(flow_id: str, session: AsyncSession = Depends(get_session)):
|
||||
flow = await session.get(WebFlow, flow_id)
|
||||
if not flow:
|
||||
raise HTTPException(404, "Flow not found")
|
||||
return _flow_to_response(flow)
|
||||
|
||||
|
||||
@router.get("/admin/flows", response_model=list[FlowResponse], response_model_by_alias=True)
|
||||
async def list_flows(
|
||||
session: AsyncSession = Depends(get_session),
|
||||
_identity=Depends(verify_identity_admin),
|
||||
):
|
||||
result = await session.execute(select(WebFlow))
|
||||
return [_flow_to_response(f) for f in result.scalars().all()]
|
||||
|
||||
|
||||
@router.get("/admin/flows/{flow_id}", response_model=FlowResponse, response_model_by_alias=True)
|
||||
async def get_flow_admin(
|
||||
flow_id: str,
|
||||
session: AsyncSession = Depends(get_session),
|
||||
_identity=Depends(verify_identity_admin),
|
||||
):
|
||||
flow = await session.get(WebFlow, flow_id)
|
||||
if not flow:
|
||||
raise HTTPException(404, "Flow not found")
|
||||
return _flow_to_response(flow)
|
||||
|
||||
|
||||
@router.post("/admin/flows", response_model=FlowResponse, status_code=201, response_model_by_alias=True)
|
||||
async def create_flow(
|
||||
body: FlowCreate,
|
||||
session: AsyncSession = Depends(get_session),
|
||||
_identity=Depends(verify_identity_admin),
|
||||
):
|
||||
existing = await session.get(WebFlow, body.id)
|
||||
if existing:
|
||||
raise HTTPException(409, "Flow with this ID already exists")
|
||||
|
||||
flow = WebFlow(
|
||||
id=body.id,
|
||||
name=body.name,
|
||||
nodes=[n.model_dump() for n in body.nodes],
|
||||
edges=[e.model_dump() for e in body.edges],
|
||||
entry_node_id=body.entry_node_id,
|
||||
version=1,
|
||||
updated_at=datetime.now(UTC),
|
||||
)
|
||||
session.add(flow)
|
||||
await bump_site_revision(session)
|
||||
await session.refresh(flow)
|
||||
return _flow_to_response(flow)
|
||||
|
||||
|
||||
@router.put("/admin/flows/{flow_id}", response_model=FlowResponse, response_model_by_alias=True)
|
||||
async def update_flow(
|
||||
flow_id: str,
|
||||
body: FlowUpdate,
|
||||
session: AsyncSession = Depends(get_session),
|
||||
_identity=Depends(verify_identity_admin),
|
||||
):
|
||||
flow = await session.get(WebFlow, flow_id)
|
||||
if not flow:
|
||||
raise HTTPException(404, "Flow not found")
|
||||
|
||||
if body.name is not None:
|
||||
flow.name = body.name
|
||||
flow.nodes = [n.model_dump() for n in body.nodes]
|
||||
flow.edges = [e.model_dump() for e in body.edges]
|
||||
flow.entry_node_id = body.entry_node_id
|
||||
flow.version = flow.version + 1
|
||||
flow.updated_at = datetime.now(UTC)
|
||||
|
||||
await bump_site_revision(session)
|
||||
await session.refresh(flow)
|
||||
return _flow_to_response(flow)
|
||||
|
||||
|
||||
@router.delete("/admin/flows/{flow_id}", status_code=204)
|
||||
async def delete_flow(
|
||||
flow_id: str,
|
||||
session: AsyncSession = Depends(get_session),
|
||||
_identity=Depends(verify_identity_admin),
|
||||
):
|
||||
flow = await session.get(WebFlow, flow_id)
|
||||
if not flow:
|
||||
raise HTTPException(404, "Flow not found")
|
||||
await session.delete(flow)
|
||||
await bump_site_revision(session)
|
||||
@@ -1,337 +0,0 @@
|
||||
from math import ceil
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Path, Query, Request
|
||||
from sqlalchemy import delete, func, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from api.depends import (
|
||||
get_request_actor,
|
||||
get_session,
|
||||
validate_redirect_url,
|
||||
verify_identity_admin,
|
||||
verify_identity_token,
|
||||
)
|
||||
from api.v2.base_crud import generate_crud_router
|
||||
from api.v2.routes.tariffs import _resolve_default_web_payment_provider, _resolve_public_base_url
|
||||
from api.v2.schemas import GiftBase, GiftResponse, GiftUpdate, GiftUsageResponse
|
||||
from api.v2.schemas.web_public import (
|
||||
GiftCreatePreviewResponse,
|
||||
GiftCreateRequest,
|
||||
GiftCreateResponse,
|
||||
GiftRedeemRequest,
|
||||
GiftRedeemResponse,
|
||||
GiftUsageEntry,
|
||||
MyGiftItem,
|
||||
MyGiftsResponse,
|
||||
)
|
||||
from config import GIFT_BUTTON
|
||||
from core.bootstrap import BUTTONS_CONFIG
|
||||
from database import (
|
||||
get_balance,
|
||||
identities as idb,
|
||||
)
|
||||
from database.access.resolution import resolve_user_optional
|
||||
from database.models import Gift, GiftUsage, Tariff
|
||||
from database.tariffs import get_tariff_by_id
|
||||
from database.temporary_data import create_temporary_data
|
||||
from services.errors import NotFoundError, ValidationError
|
||||
from services.formatting import get_site_gift_link
|
||||
from services.gifts import (
|
||||
create_gift as service_create_gift,
|
||||
redeem_gift as service_redeem_gift,
|
||||
)
|
||||
from services.payments.payment_links import PaymentLinkRequest, create_payment_link
|
||||
from services.tariffs import calculate_config_price
|
||||
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
def _check_gifts_enabled():
|
||||
if not bool(BUTTONS_CONFIG.get("GIFT_BUTTON_ENABLE", GIFT_BUTTON)):
|
||||
raise HTTPException(status_code=403, detail="Подарки отключены")
|
||||
|
||||
|
||||
@router.post("/create", tags=["Gifts"])
|
||||
async def create_gift_for_user(
|
||||
body: GiftCreateRequest,
|
||||
request: Request,
|
||||
preview: bool = Query(False),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
identity=Depends(verify_identity_token),
|
||||
):
|
||||
from api.ratelimit import enforce_rate_limit
|
||||
if not preview:
|
||||
await enforce_rate_limit(request, session, bucket="gift_create", max_per_window=10, window_sec=60)
|
||||
_check_gifts_enabled()
|
||||
actor = get_request_actor(request)
|
||||
billing_user_id = actor.billing_user_id if actor and actor.billing_user_id is not None else None
|
||||
if billing_user_id is None:
|
||||
billing_user_id = await idb.ensure_billing_user_for_identity(session, identity)
|
||||
|
||||
tariff = await get_tariff_by_id(session, body.tariff_id)
|
||||
if not tariff or tariff.get("group_code") != "gifts" or not tariff.get("is_active", True):
|
||||
raise HTTPException(status_code=404, detail="Тариф не найден")
|
||||
|
||||
price = int(calculate_config_price(tariff, body.selected_device_limit, body.selected_traffic_gb))
|
||||
balance = float(await get_balance(session, billing_user_id))
|
||||
|
||||
required_amount = int(max(0, ceil(float(price) - balance)))
|
||||
|
||||
if preview:
|
||||
return GiftCreatePreviewResponse(
|
||||
ok=True,
|
||||
price_rub=price,
|
||||
balance_rub=balance,
|
||||
sufficient_funds=balance >= price,
|
||||
tariff_name=str(tariff.get("name", "")),
|
||||
duration_days=int(tariff.get("duration_days") or 0),
|
||||
)
|
||||
|
||||
if required_amount > 0:
|
||||
provider_id = str(body.provider_id or _resolve_default_web_payment_provider() or "").strip().upper()
|
||||
if not provider_id:
|
||||
raise HTTPException(status_code=503, detail="Нет доступных провайдеров оплаты")
|
||||
base_url = _resolve_public_base_url(request)
|
||||
success_url = validate_redirect_url(str(body.success_url or ""), f"{base_url}/payment-success")
|
||||
failure_url = validate_redirect_url(str(body.failure_url or ""), f"{base_url}/payment-failure")
|
||||
payment_request = PaymentLinkRequest(
|
||||
legacy_user_ref=int(billing_user_id),
|
||||
amount=required_amount,
|
||||
currency="RUB",
|
||||
provider_id=provider_id,
|
||||
success_url=success_url,
|
||||
failure_url=failure_url,
|
||||
metadata={
|
||||
"payment_flow": "gift_create",
|
||||
"tariff_id": int(body.tariff_id),
|
||||
"selected_device_limit": body.selected_device_limit,
|
||||
"selected_traffic_gb": body.selected_traffic_gb,
|
||||
"selected_price_rub": int(price),
|
||||
},
|
||||
)
|
||||
payment_result = await create_payment_link(session, payment_request)
|
||||
if not payment_result.success or not payment_result.payment_url or not payment_result.payment_id:
|
||||
raise HTTPException(status_code=400, detail=payment_result.error or "Не удалось создать ссылку оплаты")
|
||||
await create_temporary_data(
|
||||
session,
|
||||
int(billing_user_id),
|
||||
"waiting_for_payment",
|
||||
{
|
||||
"payment_flow": "gift_create",
|
||||
"tariff_id": int(body.tariff_id),
|
||||
"required_amount": int(required_amount),
|
||||
"selected_price_rub": int(price),
|
||||
"selected_device_limit": body.selected_device_limit,
|
||||
"selected_traffic_gb": body.selected_traffic_gb,
|
||||
},
|
||||
)
|
||||
return GiftCreateResponse(
|
||||
ok=True,
|
||||
message="Требуется оплата для создания подарка",
|
||||
payment_required=True,
|
||||
required_amount_rub=required_amount,
|
||||
payment_id=payment_result.payment_id,
|
||||
payment_url=payment_result.payment_url,
|
||||
)
|
||||
|
||||
from services.errors import InsufficientFundsError, NotFoundError
|
||||
|
||||
try:
|
||||
result = await service_create_gift(
|
||||
session=session,
|
||||
sender_user_ref=billing_user_id,
|
||||
tariff_id=body.tariff_id,
|
||||
selected_device_limit=body.selected_device_limit,
|
||||
selected_traffic_gb=body.selected_traffic_gb,
|
||||
selected_price_rub=price,
|
||||
)
|
||||
except InsufficientFundsError as e:
|
||||
raise HTTPException(status_code=400, detail=str(e)) from None
|
||||
except NotFoundError as e:
|
||||
raise HTTPException(status_code=404, detail=str(e)) from None
|
||||
|
||||
new_balance = float(await get_balance(session, billing_user_id))
|
||||
return GiftCreateResponse(
|
||||
ok=True,
|
||||
message=f"Подарок создан — {result.tariff_name} на {result.duration_text}",
|
||||
gift_id=result.gift_id,
|
||||
site_gift_link=result.site_gift_link,
|
||||
tariff_name=result.tariff_name,
|
||||
duration_days=result.duration_days,
|
||||
price_charged=result.price_charged,
|
||||
balance_rub=new_balance,
|
||||
)
|
||||
|
||||
|
||||
@router.get("/my", response_model=MyGiftsResponse, tags=["Gifts"])
|
||||
async def get_my_gifts(
|
||||
request: Request,
|
||||
limit: int = Query(20, ge=1, le=100),
|
||||
offset: int = Query(0, ge=0),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
identity=Depends(verify_identity_token),
|
||||
):
|
||||
_check_gifts_enabled()
|
||||
actor = get_request_actor(request)
|
||||
billing_user_id = actor.billing_user_id if actor and actor.billing_user_id is not None else None
|
||||
if billing_user_id is None:
|
||||
billing_user_id = await idb.ensure_billing_user_for_identity(session, identity)
|
||||
|
||||
base_filter = Gift.sender_user_id == billing_user_id
|
||||
total = (await session.execute(select(func.count()).select_from(Gift).where(base_filter))).scalar_one()
|
||||
|
||||
result = await session.execute(
|
||||
select(Gift).where(base_filter).order_by(Gift.created_at.desc()).limit(limit).offset(offset)
|
||||
)
|
||||
gifts = result.scalars().all()
|
||||
|
||||
tariff_ids = {g.tariff_id for g in gifts if g.tariff_id}
|
||||
tariff_map: dict[int, str] = {}
|
||||
duration_map: dict[int, int] = {}
|
||||
if tariff_ids:
|
||||
tariff_rows = await session.execute(select(Tariff).where(Tariff.id.in_(tariff_ids)))
|
||||
for t in tariff_rows.scalars().all():
|
||||
tariff_map[t.id] = t.name or ""
|
||||
duration_map[t.id] = int(t.duration_days or 0)
|
||||
|
||||
gift_ids = [g.gift_id for g in gifts]
|
||||
usages_map: dict[str, list[GiftUsageEntry]] = {gid: [] for gid in gift_ids}
|
||||
if gift_ids:
|
||||
usage_rows = await session.execute(select(GiftUsage).where(GiftUsage.gift_id.in_(gift_ids)))
|
||||
for u in usage_rows.scalars().all():
|
||||
usages_map.setdefault(u.gift_id, []).append(
|
||||
GiftUsageEntry(
|
||||
user_id=int(u.user_id),
|
||||
used_at=u.used_at.isoformat() if u.used_at else None,
|
||||
)
|
||||
)
|
||||
|
||||
items = []
|
||||
for g in gifts:
|
||||
items.append(
|
||||
MyGiftItem(
|
||||
gift_id=g.gift_id,
|
||||
tariff_name=tariff_map.get(g.tariff_id, ""),
|
||||
duration_days=duration_map.get(g.tariff_id, 0),
|
||||
price_rub=int(g.selected_price_rub or 0),
|
||||
created_at=g.created_at.isoformat() if g.created_at else None,
|
||||
expiry_time=g.expiry_time.isoformat() if g.expiry_time else None,
|
||||
is_used=bool(g.is_used),
|
||||
is_unlimited=bool(g.is_unlimited),
|
||||
max_usages=g.max_usages,
|
||||
site_gift_link=get_site_gift_link(g.gift_id),
|
||||
usages=usages_map.get(g.gift_id, []),
|
||||
)
|
||||
)
|
||||
|
||||
return MyGiftsResponse(ok=True, gifts=items, total=total, limit=limit, offset=offset)
|
||||
|
||||
|
||||
@router.delete("/my/{gift_id}", response_model=dict, tags=["Gifts"])
|
||||
async def delete_my_gift(
|
||||
request: Request,
|
||||
gift_id: str = Path(...),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
identity=Depends(verify_identity_token),
|
||||
):
|
||||
"""Удаляет свой подарок."""
|
||||
actor = get_request_actor(request)
|
||||
billing_user_id = actor.billing_user_id if actor and actor.billing_user_id is not None else None
|
||||
if billing_user_id is None:
|
||||
billing_user_id = await idb.ensure_billing_user_for_identity(session, identity)
|
||||
|
||||
result = await session.execute(select(Gift).where(Gift.gift_id == gift_id))
|
||||
gift = result.scalar_one_or_none()
|
||||
if not gift or gift.sender_user_id != billing_user_id:
|
||||
raise HTTPException(status_code=404, detail="Подарок не найден")
|
||||
await session.execute(delete(GiftUsage).where(GiftUsage.gift_id == gift_id))
|
||||
await session.delete(gift)
|
||||
return {"ok": True, "message": "Подарок удалён"}
|
||||
|
||||
|
||||
gift_router = generate_crud_router(
|
||||
model=Gift,
|
||||
schema_response=GiftResponse,
|
||||
schema_create=GiftBase,
|
||||
schema_update=GiftUpdate,
|
||||
identifier_field="gift_id",
|
||||
parameter_name="gift_id",
|
||||
enabled_methods=["get_all", "get_one", "create", "update"],
|
||||
)
|
||||
router.include_router(gift_router, prefix="", tags=["Gifts"])
|
||||
|
||||
|
||||
@router.post("/redeem", response_model=GiftRedeemResponse, tags=["Gifts"])
|
||||
async def redeem_gift(
|
||||
body: GiftRedeemRequest,
|
||||
request: Request,
|
||||
session: AsyncSession = Depends(get_session),
|
||||
identity=Depends(verify_identity_token),
|
||||
):
|
||||
from api.ratelimit import enforce_rate_limit
|
||||
await enforce_rate_limit(request, session, bucket="gift_redeem", max_per_window=10, window_sec=60)
|
||||
_check_gifts_enabled()
|
||||
actor = get_request_actor(request)
|
||||
billing_user_id = actor.billing_user_id if actor and actor.billing_user_id is not None else None
|
||||
if billing_user_id is None:
|
||||
billing_user_id = await idb.ensure_billing_user_for_identity(session, identity)
|
||||
try:
|
||||
result = await service_redeem_gift(session, body.gift_code, billing_user_id)
|
||||
except ValidationError as e:
|
||||
raise HTTPException(status_code=400, detail=str(e)) from None
|
||||
except NotFoundError as e:
|
||||
raise HTTPException(status_code=404, detail=str(e)) from None
|
||||
except Exception:
|
||||
raise HTTPException(status_code=500, detail="Не удалось активировать подарок") from None
|
||||
return GiftRedeemResponse(
|
||||
ok=True,
|
||||
message=result.message,
|
||||
gift_id=result.gift_id,
|
||||
tariff_id=result.tariff_id,
|
||||
duration_days=result.duration_days,
|
||||
)
|
||||
|
||||
|
||||
@router.get("/by_tg_id/{tg_id}", response_model=list[GiftResponse], tags=["Gifts"])
|
||||
async def get_gifts_by_tg_id(
|
||||
tg_id: int = Path(...),
|
||||
identity=Depends(verify_identity_admin),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
):
|
||||
"""Список подарков по tg_id отправителя."""
|
||||
u = await resolve_user_optional(session, tg_id)
|
||||
if u is None:
|
||||
raise HTTPException(status_code=404, detail="Gifts not found")
|
||||
result = await session.execute(select(Gift).where(Gift.sender_user_id == u.id))
|
||||
gifts = result.scalars().all()
|
||||
if not gifts:
|
||||
raise HTTPException(status_code=404, detail="Gifts not found")
|
||||
return gifts
|
||||
|
||||
|
||||
gift_usage_router = generate_crud_router(
|
||||
model=GiftUsage,
|
||||
schema_response=GiftUsageResponse,
|
||||
schema_create=None,
|
||||
schema_update=None,
|
||||
identifier_field="gift_id",
|
||||
enabled_methods=["get_all", "get_one", "delete"],
|
||||
)
|
||||
router.include_router(gift_usage_router, prefix="/usages", tags=["Gifts", "GiftUsages"])
|
||||
|
||||
|
||||
@router.delete("/{gift_id}", response_model=dict, tags=["Gifts"])
|
||||
async def delete_gift_with_usages(
|
||||
gift_id: str = Path(..., description="ID подарка"),
|
||||
identity=Depends(verify_identity_admin),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
):
|
||||
"""Удаляет подарок и все его использования."""
|
||||
result = await session.execute(select(Gift).where(Gift.gift_id == gift_id))
|
||||
gift = result.scalar_one_or_none()
|
||||
if not gift:
|
||||
raise HTTPException(status_code=404, detail="Gift not found")
|
||||
await session.execute(delete(GiftUsage).where(GiftUsage.gift_id == gift_id))
|
||||
await session.delete(gift)
|
||||
return {"message": "Подарок и связанные использования удалены"}
|
||||
@@ -1,101 +0,0 @@
|
||||
from fastapi import APIRouter, Depends, HTTPException, Path, Query
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from api.depends import get_session, verify_identity_admin
|
||||
from api.v2.schemas.identities import (
|
||||
IdentityAttachEmail,
|
||||
IdentityAttachTelegram,
|
||||
IdentityCreate,
|
||||
IdentityResponse,
|
||||
)
|
||||
from database import identities as idb
|
||||
|
||||
|
||||
router = APIRouter(tags=["Identities"])
|
||||
|
||||
|
||||
@router.post("/", response_model=IdentityResponse)
|
||||
async def create_identity(
|
||||
body: IdentityCreate,
|
||||
session: AsyncSession = Depends(get_session),
|
||||
identity=Depends(verify_identity_admin),
|
||||
):
|
||||
"""Создаёт идентичность; можно указать email и/или tg_id."""
|
||||
email = body.email.strip().lower() if body.email and body.email.strip() else None
|
||||
if not email and not body.tg_id:
|
||||
raise HTTPException(status_code=400, detail="Укажите email и/или tg_id")
|
||||
identity = await idb.create_identity(session, email=email, tg_id=body.tg_id)
|
||||
return IdentityResponse.model_validate(identity)
|
||||
|
||||
|
||||
@router.get("/{identity_id}", response_model=IdentityResponse)
|
||||
async def get_identity(
|
||||
identity_id: str = Path(...),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
identity=Depends(verify_identity_admin),
|
||||
):
|
||||
"""Возвращает идентичность по id."""
|
||||
identity = await idb.get_identity_by_id(session, identity_id)
|
||||
if not identity:
|
||||
raise HTTPException(status_code=404, detail="Identity not found")
|
||||
return IdentityResponse.model_validate(identity)
|
||||
|
||||
|
||||
@router.get("/by/email", response_model=IdentityResponse)
|
||||
async def get_identity_by_email(
|
||||
email: str = Query(..., min_length=1),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
identity=Depends(verify_identity_admin),
|
||||
):
|
||||
"""Возвращает идентичность по email."""
|
||||
identity = await idb.get_identity_by_email(session, email)
|
||||
if not identity:
|
||||
raise HTTPException(status_code=404, detail="Identity not found")
|
||||
return IdentityResponse.model_validate(identity)
|
||||
|
||||
|
||||
@router.get("/by/tg_id/{tg_id}", response_model=IdentityResponse)
|
||||
async def get_identity_by_tg_id(
|
||||
tg_id: int = Path(...),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
identity=Depends(verify_identity_admin),
|
||||
):
|
||||
"""Возвращает идентичность по Telegram ID."""
|
||||
identity = await idb.get_identity_by_tg_id(session, tg_id)
|
||||
if not identity:
|
||||
raise HTTPException(status_code=404, detail="Identity not found")
|
||||
return IdentityResponse.model_validate(identity)
|
||||
|
||||
|
||||
@router.patch("/{identity_id}/attach-email", response_model=IdentityResponse)
|
||||
async def attach_email(
|
||||
identity_id: str = Path(...),
|
||||
body: IdentityAttachEmail = ...,
|
||||
session: AsyncSession = Depends(get_session),
|
||||
identity=Depends(verify_identity_admin),
|
||||
):
|
||||
"""Привязывает email к идентичности."""
|
||||
identity = await idb.attach_email(session, identity_id, body.email)
|
||||
if not identity:
|
||||
raise HTTPException(
|
||||
status_code=404,
|
||||
detail="Identity not found или email уже привязан к другой идентичности",
|
||||
)
|
||||
return IdentityResponse.model_validate(identity)
|
||||
|
||||
|
||||
@router.patch("/{identity_id}/attach-telegram", response_model=IdentityResponse)
|
||||
async def attach_telegram(
|
||||
identity_id: str = Path(...),
|
||||
body: IdentityAttachTelegram = ...,
|
||||
session: AsyncSession = Depends(get_session),
|
||||
identity=Depends(verify_identity_admin),
|
||||
):
|
||||
"""Привязывает Telegram (tg_id) к идентичности."""
|
||||
identity = await idb.attach_telegram(session, identity_id, body.tg_id)
|
||||
if not identity:
|
||||
raise HTTPException(
|
||||
status_code=404,
|
||||
detail="Identity not found или tg_id уже привязан к другой идентичности",
|
||||
)
|
||||
return IdentityResponse.model_validate(identity)
|
||||
@@ -1,5 +0,0 @@
|
||||
from . import admin, user # noqa: F401 — import triggers endpoint registration
|
||||
from ._common import router, user_router
|
||||
|
||||
|
||||
__all__ = ["router", "user_router"]
|
||||
@@ -1,271 +0,0 @@
|
||||
import asyncio
|
||||
import re
|
||||
|
||||
from base64 import b64encode
|
||||
from datetime import datetime
|
||||
from io import BytesIO
|
||||
from math import ceil
|
||||
from typing import Any
|
||||
from urllib.parse import urlsplit
|
||||
|
||||
import qrcode
|
||||
|
||||
from fastapi import APIRouter, Body, Depends, HTTPException, Path, Query, Request, status
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from api.depends import (
|
||||
get_request_actor,
|
||||
get_session,
|
||||
validate_redirect_url,
|
||||
verify_identity_admin,
|
||||
verify_identity_token,
|
||||
)
|
||||
from api.v2.base_crud import generate_crud_router
|
||||
from api.v2.routes.coupon_pricing import resolve_percent_coupon_pricing
|
||||
from api.v2.schemas import KeyBase, KeyCreateRequest, KeyResponse, KeyUpdate
|
||||
from api.v2.schemas.web_public import (
|
||||
AccountKeyActionResponse,
|
||||
AccountKeyActionsAvailability,
|
||||
AccountKeyActionsConfigResponse,
|
||||
AccountKeyAddonOptionResponse,
|
||||
AccountKeyAddonsPreviewRequest,
|
||||
AccountKeyAddonsPreviewResponse,
|
||||
AccountKeyAliasUpdateRequest,
|
||||
AccountKeyApplyAddonsResponse,
|
||||
AccountKeyChangeLocationRequest,
|
||||
AccountKeyChangeLocationResponse,
|
||||
AccountKeyConnectionResponse,
|
||||
AccountKeyDetailsResponse,
|
||||
AccountKeyLocationOptionResponse,
|
||||
AccountKeyLocationsResponse,
|
||||
AccountKeyQrResponse,
|
||||
AccountKeyRenewRequest,
|
||||
AccountKeyRenewResponse,
|
||||
AccountKeyResetHwidResponse,
|
||||
AccountKeyResponse,
|
||||
)
|
||||
from config import (
|
||||
ENABLE_DELETE_KEY_BUTTON,
|
||||
HWID_RESET_BUTTON,
|
||||
INSTRUCTIONS_BUTTON,
|
||||
QRCODE,
|
||||
REMNAWAVE_LOGIN,
|
||||
REMNAWAVE_PASSWORD,
|
||||
USE_COUNTRY_SELECTION,
|
||||
)
|
||||
from core.bootstrap import BUTTONS_CONFIG, MODES_CONFIG, PAYMENTS_CONFIG, TARIFFS_CONFIG
|
||||
from core.settings.tariffs_config import normalize_tariff_config
|
||||
from database import (
|
||||
check_server_name_by_cluster,
|
||||
filter_cluster_by_subgroup,
|
||||
get_balance,
|
||||
get_key_details,
|
||||
get_keys,
|
||||
get_tariff_by_id,
|
||||
identities as idb,
|
||||
save_key_config_with_mode,
|
||||
update_balance,
|
||||
)
|
||||
from database.access.resolution import resolve_user_optional
|
||||
from database.coupons import mark_coupon_used
|
||||
from database.models import Key, Server, ServerSpecialgroup, Tariff
|
||||
from database.temporary_data import create_temporary_data
|
||||
from handlers.buttons import CONNECT_DEVICE, ROUTER_BUTTON, TV_BUTTON
|
||||
from handlers.keys.key_view import build_key_view_payload
|
||||
from handlers.tariffs.addons.key_addons_pack import calc_pack_full_price_rub, get_pack_flags
|
||||
from handlers.tariffs.addons.utils import calc_remaining_ratio_seconds, is_not_downgrade
|
||||
from handlers.utils import ALLOWED_GROUP_CODES, is_full_remnawave_cluster
|
||||
from logger import logger
|
||||
from panels._3xui import delete_client, get_xui_instance
|
||||
from panels.remnawave import RemnawaveAPI, get_vless_link_for_remnawave_by_username
|
||||
from panels.remnawave_runtime import get_remnawave_profile, invalidate_remnawave_profile, with_remnawave_api
|
||||
from services.operations import (
|
||||
create_client_on_server,
|
||||
create_key_on_cluster,
|
||||
delete_key_from_cluster,
|
||||
renew_key_in_cluster,
|
||||
)
|
||||
from services.operations.aggregated_links import make_aggregated_link
|
||||
from services.payments.payment_links import PaymentLinkRequest, create_payment_link
|
||||
from services.payments.providers import WEB_LINK_PROVIDER_IDS
|
||||
from services.tariffs import calculate_config_price
|
||||
from services.tariffs.tariff_display import GB, get_effective_limits_for_key, get_key_tariff_addons_state
|
||||
|
||||
|
||||
router = generate_crud_router(
|
||||
model=Key,
|
||||
schema_response=KeyResponse,
|
||||
schema_create=KeyBase,
|
||||
schema_update=KeyUpdate,
|
||||
identifier_field="tg_id",
|
||||
extra_get_by_email=True,
|
||||
enabled_methods=["get_all", "get_one", "get_by_email", "get_all_by_field"],
|
||||
)
|
||||
user_router = APIRouter()
|
||||
|
||||
|
||||
def _key_actions_config() -> AccountKeyActionsConfigResponse:
|
||||
addons_mode = str(TARIFFS_CONFIG.get("KEY_ADDONS_PACK_MODE", "") or "").strip().lower()
|
||||
if addons_mode not in {"", "traffic", "devices", "all"}:
|
||||
addons_mode = ""
|
||||
addons_enabled_default = addons_mode in {"", "traffic", "devices", "all"}
|
||||
return AccountKeyActionsConfigResponse(
|
||||
renew_enabled=True,
|
||||
delete_enabled=bool(BUTTONS_CONFIG.get("DELETE_KEY_BUTTON_ENABLE", ENABLE_DELETE_KEY_BUTTON)),
|
||||
qr_enabled=bool(BUTTONS_CONFIG.get("QRCODE_BUTTON_ENABLE", QRCODE)),
|
||||
hwid_reset_enabled=bool(BUTTONS_CONFIG.get("HWID_RESET_BUTTON_ENABLE", HWID_RESET_BUTTON)),
|
||||
country_change_enabled=bool(MODES_CONFIG.get("COUNTRY_SELECTION_ENABLED", USE_COUNTRY_SELECTION)),
|
||||
instructions_enabled=bool(BUTTONS_CONFIG.get("INSTRUCTIONS_BUTTON_ENABLE", INSTRUCTIONS_BUTTON)),
|
||||
addons_enabled=addons_enabled_default,
|
||||
addons_mode=addons_mode,
|
||||
tv_connect_enabled=bool(BUTTONS_CONFIG.get("ANDROID_TV_BUTTON_ENABLE")),
|
||||
)
|
||||
|
||||
|
||||
def _extract_key_actions_from_markup(markup) -> AccountKeyActionsAvailability:
|
||||
actions = AccountKeyActionsAvailability()
|
||||
rows = getattr(markup, "inline_keyboard", None) or []
|
||||
for row in rows:
|
||||
for button in row:
|
||||
callback_data = str(getattr(button, "callback_data", "") or "")
|
||||
text = str(getattr(button, "text", "") or "")
|
||||
has_url = bool(getattr(button, "url", None))
|
||||
has_web_app = bool(getattr(button, "web_app", None))
|
||||
if callback_data.startswith("connect_router|") or text == ROUTER_BUTTON:
|
||||
actions.can_connect_router = True
|
||||
if callback_data.startswith("connect_tv|") or text == TV_BUTTON:
|
||||
actions.can_connect_tv = True
|
||||
if callback_data.startswith("connect_device|") or (text == CONNECT_DEVICE and (has_url or has_web_app)):
|
||||
actions.can_connect_device = True
|
||||
if callback_data.startswith("renew_key|"):
|
||||
actions.can_renew = True
|
||||
if callback_data.startswith("key_addons|"):
|
||||
actions.can_addons = True
|
||||
if callback_data.startswith("reset_hwid|"):
|
||||
actions.can_reset_hwid = True
|
||||
if callback_data.startswith("show_qr|"):
|
||||
actions.can_qr = True
|
||||
if callback_data.startswith("delete_key|"):
|
||||
actions.can_delete = True
|
||||
if callback_data.startswith("change_location|"):
|
||||
actions.can_change_location = True
|
||||
return actions
|
||||
|
||||
|
||||
async def _resolve_available_location_servers(session: AsyncSession, db_key: Key) -> list[str]:
|
||||
current_server = str(getattr(db_key, "server_id", "") or "")
|
||||
if not current_server:
|
||||
return []
|
||||
cluster_info = await check_server_name_by_cluster(session, current_server)
|
||||
if not cluster_info:
|
||||
return []
|
||||
cluster_name = str(cluster_info.get("cluster_name") or "")
|
||||
if not cluster_name:
|
||||
return []
|
||||
q = (
|
||||
select(
|
||||
Server.id,
|
||||
Server.server_name,
|
||||
Server.api_url,
|
||||
Server.panel_type,
|
||||
Server.enabled,
|
||||
Server.max_keys,
|
||||
)
|
||||
.where(Server.cluster_name == cluster_name)
|
||||
.where(Server.server_name != current_server)
|
||||
)
|
||||
servers = [dict(m) for m in (await session.execute(q)).mappings().all()]
|
||||
if not servers:
|
||||
return []
|
||||
server_ids = [s["id"] for s in servers if s.get("id") is not None]
|
||||
groups_map: dict[int, list[str]] = {}
|
||||
if server_ids:
|
||||
r = await session.execute(
|
||||
select(ServerSpecialgroup.server_id, ServerSpecialgroup.group_code).where(
|
||||
ServerSpecialgroup.server_id.in_(server_ids)
|
||||
)
|
||||
)
|
||||
for sid, gc in r.all():
|
||||
groups_map.setdefault(int(sid), []).append(gc)
|
||||
for server in servers:
|
||||
sid_raw = server.get("id")
|
||||
sid = int(sid_raw) if sid_raw is not None else -1
|
||||
server["special_groups"] = [g for g in groups_map.get(sid, []) if g in ALLOWED_GROUP_CODES]
|
||||
key_tariff_id = getattr(db_key, "tariff_id", None)
|
||||
subgroup_title = None
|
||||
tariff_dict = None
|
||||
if key_tariff_id:
|
||||
tariff_dict = await get_tariff_by_id(session, int(key_tariff_id))
|
||||
if tariff_dict:
|
||||
subgroup_title = tariff_dict.get("subgroup_title")
|
||||
available_servers = [s for s in servers if bool(s.get("enabled", True))]
|
||||
if subgroup_title and available_servers:
|
||||
filtered_servers = await filter_cluster_by_subgroup(
|
||||
session=session,
|
||||
cluster=available_servers,
|
||||
target_subgroup=str(subgroup_title).strip(),
|
||||
cluster_id=cluster_name,
|
||||
tariff_id=int(key_tariff_id) if key_tariff_id else None,
|
||||
)
|
||||
if filtered_servers:
|
||||
available_servers = filtered_servers
|
||||
else:
|
||||
available_servers = []
|
||||
if available_servers and tariff_dict:
|
||||
special = None
|
||||
gc = str(tariff_dict.get("group_code") or "").lower()
|
||||
if gc and gc in ALLOWED_GROUP_CODES:
|
||||
special = gc
|
||||
if special:
|
||||
bound_servers = [s for s in available_servers if special in (s.get("special_groups") or [])]
|
||||
if bound_servers:
|
||||
available_servers = bound_servers
|
||||
names = sorted({
|
||||
str(s.get("server_name") or "").strip() for s in available_servers if str(s.get("server_name") or "").strip()
|
||||
})
|
||||
return names
|
||||
|
||||
|
||||
async def _resolve_billing_user_id(request: Request, identity, session: AsyncSession) -> int:
|
||||
actor = get_request_actor(request)
|
||||
billing_user_id = actor.billing_user_id if actor and actor.billing_user_id is not None else None
|
||||
if billing_user_id is None:
|
||||
billing_user_id = await idb.ensure_billing_user_for_identity(session, identity)
|
||||
return int(billing_user_id)
|
||||
|
||||
|
||||
def _resolve_public_base_url(request: Request) -> str:
|
||||
origin = str(request.headers.get("origin") or "").strip()
|
||||
if origin.startswith(("http://", "https://")):
|
||||
return origin.rstrip("/")
|
||||
referer = str(request.headers.get("referer") or request.headers.get("referrer") or "").strip()
|
||||
if referer.startswith(("http://", "https://")):
|
||||
parsed = urlsplit(referer)
|
||||
if parsed.scheme and parsed.netloc:
|
||||
return f"{parsed.scheme}://{parsed.netloc}".rstrip("/")
|
||||
forwarded_host = str(request.headers.get("x-forwarded-host") or "").strip()
|
||||
host = forwarded_host or str(request.headers.get("host") or "").strip()
|
||||
forwarded_proto = str(request.headers.get("x-forwarded-proto") or "").split(",", 1)[0].strip().lower()
|
||||
scheme = forwarded_proto if forwarded_proto in {"http", "https"} else request.url.scheme
|
||||
if host:
|
||||
return f"{scheme}://{host}".rstrip("/")
|
||||
return str(request.base_url).rstrip("/")
|
||||
|
||||
|
||||
def _resolve_default_web_payment_provider() -> str | None:
|
||||
for provider_id in WEB_LINK_PROVIDER_IDS:
|
||||
if bool(PAYMENTS_CONFIG.get(provider_id)):
|
||||
return provider_id
|
||||
return WEB_LINK_PROVIDER_IDS[0] if WEB_LINK_PROVIDER_IDS else None
|
||||
|
||||
|
||||
def _normalize_expiry_ms(raw_value: int | float | None) -> int:
|
||||
if not raw_value:
|
||||
return 0
|
||||
value = int(raw_value)
|
||||
if value > 10**13:
|
||||
value //= 1000
|
||||
elif value < 10**10:
|
||||
value *= 1000
|
||||
return value
|
||||
@@ -1,115 +0,0 @@
|
||||
from ._common import * # noqa: F401,F403
|
||||
from ._common import router, user_router # noqa: F401
|
||||
|
||||
|
||||
@router.delete("/by_email/{email}", response_model=dict)
|
||||
async def delete_key_by_email(
|
||||
email: str = Path(..., description="Email клиента"),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
identity=Depends(verify_identity_admin),
|
||||
):
|
||||
"""Удаляет ключ по email с кластера и из БД."""
|
||||
result = await session.execute(select(Key).where(Key.email == email))
|
||||
db_key = result.scalar_one_or_none()
|
||||
if not db_key:
|
||||
raise HTTPException(status_code=404, detail="Ключ не найден")
|
||||
try:
|
||||
await delete_key_from_cluster(
|
||||
session=session,
|
||||
email=db_key.email,
|
||||
client_id=db_key.client_id,
|
||||
cluster_id=db_key.server_id,
|
||||
)
|
||||
await session.delete(db_key)
|
||||
logger.info(f"[API] Ключ удалён: {db_key.client_id}")
|
||||
return {"message": "Ключ успешно удалён"}
|
||||
except Exception as e:
|
||||
logger.error(f"[API] Ошибка при удалении ключа: {e}")
|
||||
raise HTTPException(status_code=500, detail="Ошибка при удалении ключа")
|
||||
|
||||
|
||||
@router.get("/routers/{tg_id}", response_model=list[KeyResponse])
|
||||
async def get_router_keys_by_tg_id(
|
||||
tg_id: int = Path(..., description="Telegram ID пользователя"),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
identity=Depends(verify_identity_admin),
|
||||
):
|
||||
"""Список ключей пользователя с тарифами группы routers."""
|
||||
tariffs_result = await session.execute(select(Tariff.id).where(Tariff.group_code == "routers"))
|
||||
tariff_ids = [row[0] for row in tariffs_result.all()]
|
||||
if not tariff_ids:
|
||||
return []
|
||||
u = await resolve_user_optional(session, tg_id)
|
||||
if u is None:
|
||||
return []
|
||||
keys_result = await session.execute(select(Key).where(Key.user_id == u.id, Key.tariff_id.in_(tariff_ids)))
|
||||
return keys_result.scalars().all()
|
||||
|
||||
|
||||
@router.patch("/edit/by_email/{email}", response_model=KeyResponse)
|
||||
async def edit_key_by_email(
|
||||
email: str = Path(..., description="Email клиента"),
|
||||
key_update: KeyUpdate = Body(...),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
identity=Depends(verify_identity_admin),
|
||||
):
|
||||
"""Обновляет ключ по email и синхронизирует с кластером."""
|
||||
result = await session.execute(select(Key).where(Key.email == email))
|
||||
db_key = result.scalar_one_or_none()
|
||||
if not db_key:
|
||||
raise HTTPException(status_code=404, detail="Ключ не найден")
|
||||
for field, value in key_update.model_dump(exclude_unset=True).items():
|
||||
if field == "expiry_time" and value is not None:
|
||||
if isinstance(value, int):
|
||||
ms = value
|
||||
elif isinstance(value, datetime):
|
||||
ms = int(value.timestamp() * 1000)
|
||||
else:
|
||||
raise HTTPException(status_code=400, detail="Некорректный формат времени")
|
||||
setattr(db_key, field, ms)
|
||||
else:
|
||||
setattr(db_key, field, value)
|
||||
try:
|
||||
new_expiry_time = db_key.expiry_time
|
||||
await renew_key_in_cluster(
|
||||
cluster_id=db_key.server_id,
|
||||
email=db_key.email,
|
||||
client_id=db_key.client_id,
|
||||
new_expiry_time=new_expiry_time,
|
||||
total_gb=getattr(db_key, "traffic_limit", None),
|
||||
session=session,
|
||||
hwid_device_limit=getattr(db_key, "device_limit", None),
|
||||
reset_traffic=True,
|
||||
)
|
||||
logger.info(f"[API] Ключ обновлён: {db_key.client_id}")
|
||||
return db_key
|
||||
except Exception as e:
|
||||
logger.error(f"[API] Ошибка при обновлении ключа: {e}")
|
||||
raise HTTPException(status_code=500, detail="Ошибка при обновлении ключа")
|
||||
|
||||
|
||||
@router.post("/create", response_model=dict, status_code=status.HTTP_201_CREATED)
|
||||
async def create_key_api(
|
||||
payload: KeyCreateRequest = Body(...),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
identity=Depends(verify_identity_admin),
|
||||
):
|
||||
"""Создаёт ключ на кластере."""
|
||||
try:
|
||||
await create_key_on_cluster(
|
||||
cluster_id=payload.cluster_id,
|
||||
tg_id=payload.tg_id,
|
||||
client_id=payload.client_id,
|
||||
email=payload.email or f"{payload.tg_id}_key",
|
||||
expiry_timestamp=payload.expiry_timestamp,
|
||||
plan=payload.tariff_id,
|
||||
session=session,
|
||||
remnawave_link=payload.remnawave_link,
|
||||
hwid_limit=payload.hwid_limit,
|
||||
traffic_limit_bytes=payload.traffic_limit_bytes,
|
||||
is_trial=payload.is_trial or False,
|
||||
)
|
||||
return {"message": "Ключ успешно создан"}
|
||||
except Exception as e:
|
||||
logger.error(f"[API] Ошибка при создании ключа: {e}")
|
||||
raise HTTPException(status_code=500, detail="Ошибка при создании ключа")
|
||||
@@ -1 +0,0 @@
|
||||
from . import addons, core, hwid, location, renew # noqa: F401 — trigger registration
|
||||
@@ -1,662 +0,0 @@
|
||||
"""User-facing key endpoints (/api/keys/*).
|
||||
|
||||
Регистрирует эндпоинты на ``user_router`` из ``_common``. Импорт этого модуля
|
||||
из ``__init__.py`` запускает регистрацию декораторов.
|
||||
"""
|
||||
|
||||
from .._common import * # noqa: F401,F403 — подтягиваем все имена для endpoints
|
||||
from .._common import (
|
||||
_key_actions_config,
|
||||
_normalize_expiry_ms,
|
||||
_resolve_available_location_servers,
|
||||
_resolve_billing_user_id,
|
||||
_resolve_default_web_payment_provider,
|
||||
_resolve_public_base_url,
|
||||
router,
|
||||
user_router,
|
||||
)
|
||||
|
||||
|
||||
@user_router.get("/{client_id}/addons-preview", response_model=AccountKeyAddonsPreviewResponse)
|
||||
async def user_key_addons_preview(
|
||||
client_id: str,
|
||||
request: Request,
|
||||
selected_device_limit: int | None = Query(None),
|
||||
selected_traffic_gb: int | None = Query(None),
|
||||
include_device: bool | None = Query(None),
|
||||
include_traffic: bool | None = Query(None),
|
||||
coupon_code: str | None = Query(None),
|
||||
force_web: bool = Query(False),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
identity=Depends(verify_identity_token),
|
||||
):
|
||||
actions = _key_actions_config()
|
||||
if not force_web and not actions.addons_enabled:
|
||||
raise HTTPException(status_code=403, detail="Доп. опции отключены в настройках")
|
||||
billing_user_id = await _resolve_billing_user_id(request, identity, session)
|
||||
db_key = (
|
||||
await session.execute(select(Key).where(Key.user_id == billing_user_id, Key.client_id == client_id).limit(1))
|
||||
).scalar_one_or_none()
|
||||
if db_key is None:
|
||||
raise HTTPException(status_code=404, detail="Подписка не найдена")
|
||||
tariff_id = getattr(db_key, "tariff_id", None)
|
||||
if not tariff_id:
|
||||
raise HTTPException(status_code=400, detail="Для подписки не назначен тариф")
|
||||
tariff = await get_tariff_by_id(session, int(tariff_id))
|
||||
if not tariff:
|
||||
raise HTTPException(status_code=404, detail="Тариф не найден")
|
||||
key_details = await get_key_details(session, str(getattr(db_key, "email", "") or ""))
|
||||
if not key_details:
|
||||
raise HTTPException(status_code=404, detail="Подписка не найдена")
|
||||
(
|
||||
_tariff_name,
|
||||
_subgroup_title,
|
||||
_traffic_limit_gb,
|
||||
_device_limit,
|
||||
_panel,
|
||||
is_tariff_configurable,
|
||||
addons_devices_enabled,
|
||||
addons_traffic_enabled,
|
||||
) = await get_key_tariff_addons_state(session=session, key_record=key_details, db_key=db_key)
|
||||
if not is_tariff_configurable:
|
||||
raise HTTPException(status_code=400, detail="Тариф не поддерживает доп. опции")
|
||||
cfg = normalize_tariff_config(tariff)
|
||||
raw_device_options = cfg.get("device_options") or tariff.get("device_options") or []
|
||||
raw_traffic_options = cfg.get("traffic_options_gb") or tariff.get("traffic_options_gb") or []
|
||||
device_options: list[int] = []
|
||||
for value in raw_device_options:
|
||||
try:
|
||||
device_options.append(int(value))
|
||||
except (TypeError, ValueError):
|
||||
continue
|
||||
traffic_options: list[int] = []
|
||||
for value in raw_traffic_options:
|
||||
try:
|
||||
traffic_options.append(int(value))
|
||||
except (TypeError, ValueError):
|
||||
continue
|
||||
device_options = sorted(set(device_options), key=lambda val: (int(val == 0), val))
|
||||
traffic_options = sorted(set(traffic_options), key=lambda val: (int(val == 0), val))
|
||||
has_device_option = bool(device_options) and bool(addons_devices_enabled)
|
||||
has_traffic_option = bool(traffic_options) and bool(addons_traffic_enabled)
|
||||
pack_devices, pack_traffic, pack_mode = get_pack_flags()
|
||||
if pack_mode:
|
||||
has_device_option = has_device_option and bool(pack_devices)
|
||||
has_traffic_option = has_traffic_option and bool(pack_traffic)
|
||||
if not has_device_option:
|
||||
device_options = []
|
||||
if not has_traffic_option:
|
||||
traffic_options = []
|
||||
if not has_device_option and not has_traffic_option:
|
||||
raise HTTPException(status_code=400, detail="Доп. опции для этой подписки недоступны")
|
||||
selected_device_limit_db = key_details.get("selected_device_limit")
|
||||
selected_traffic_limit_db = key_details.get("selected_traffic_limit")
|
||||
current_device_limit_db = key_details.get("current_device_limit")
|
||||
current_traffic_limit_db = key_details.get("current_traffic_limit")
|
||||
base_devices = tariff.get("device_limit")
|
||||
base_devices = int(base_devices) if base_devices is not None else None
|
||||
base_traffic_bytes = tariff.get("traffic_limit")
|
||||
base_traffic_gb_from_tariff = int(base_traffic_bytes / GB) if base_traffic_bytes else None
|
||||
current_device_limit = (
|
||||
int(current_device_limit_db)
|
||||
if current_device_limit_db is not None
|
||||
else (int(selected_device_limit_db) if selected_device_limit_db is not None else base_devices)
|
||||
)
|
||||
current_traffic_gb = (
|
||||
int(current_traffic_limit_db)
|
||||
if current_traffic_limit_db is not None
|
||||
else (int(selected_traffic_limit_db) if selected_traffic_limit_db is not None else base_traffic_gb_from_tariff)
|
||||
)
|
||||
if pack_mode and current_device_limit is not None and int(current_device_limit) == 0:
|
||||
has_device_option = False
|
||||
device_options = []
|
||||
if pack_mode and current_traffic_gb is not None and int(current_traffic_gb) == 0:
|
||||
has_traffic_option = False
|
||||
traffic_options = []
|
||||
if not has_device_option and not has_traffic_option:
|
||||
raise HTTPException(status_code=400, detail="Доп. опции для этой подписки недоступны")
|
||||
if pack_mode:
|
||||
include_device_effective = (
|
||||
bool(include_device) if include_device is not None else selected_device_limit is not None
|
||||
)
|
||||
include_traffic_effective = (
|
||||
bool(include_traffic) if include_traffic is not None else selected_traffic_gb is not None
|
||||
)
|
||||
selected_device = selected_device_limit if selected_device_limit is not None else None
|
||||
selected_traffic = selected_traffic_gb if selected_traffic_gb is not None else None
|
||||
else:
|
||||
include_device_effective = has_device_option
|
||||
include_traffic_effective = has_traffic_option
|
||||
selected_device = selected_device_limit if selected_device_limit is not None else current_device_limit
|
||||
selected_traffic = selected_traffic_gb if selected_traffic_gb is not None else current_traffic_gb
|
||||
if (
|
||||
has_device_option
|
||||
and include_device_effective
|
||||
and selected_device is not None
|
||||
and int(selected_device) not in device_options
|
||||
):
|
||||
raise HTTPException(status_code=400, detail="Выбранный пакет устройств недоступен")
|
||||
if (
|
||||
has_traffic_option
|
||||
and include_traffic_effective
|
||||
and selected_traffic is not None
|
||||
and int(selected_traffic) not in traffic_options
|
||||
):
|
||||
raise HTTPException(status_code=400, detail="Выбранный пакет трафика недоступен")
|
||||
current_devices_for_price = int(current_device_limit) if current_device_limit is not None else None
|
||||
current_traffic_for_price = int(current_traffic_gb) if current_traffic_gb is not None else None
|
||||
base_price_for_current = int(
|
||||
calculate_config_price(
|
||||
tariff=tariff,
|
||||
selected_device_limit=current_devices_for_price,
|
||||
selected_traffic_gb=current_traffic_for_price,
|
||||
)
|
||||
)
|
||||
if pack_mode:
|
||||
diff_full = int(
|
||||
calc_pack_full_price_rub(
|
||||
tariff=tariff,
|
||||
has_device_option=bool(has_device_option and include_device_effective),
|
||||
has_traffic_option=bool(has_traffic_option and include_traffic_effective),
|
||||
selected_devices=int(selected_device)
|
||||
if has_device_option and include_device_effective and selected_device is not None
|
||||
else None,
|
||||
selected_traffic_gb=int(selected_traffic)
|
||||
if has_traffic_option and include_traffic_effective and selected_traffic is not None
|
||||
else None,
|
||||
)
|
||||
)
|
||||
recalc_enabled = bool(
|
||||
MODES_CONFIG.get(
|
||||
"KEY_ADDONS_RECALC_PRICE",
|
||||
TARIFFS_CONFIG.get("KEY_ADDONS_RECALC_PRICE", False),
|
||||
)
|
||||
)
|
||||
if recalc_enabled:
|
||||
remaining_seconds, total_seconds = calc_remaining_ratio_seconds(
|
||||
key_details.get("expiry_time"),
|
||||
tariff,
|
||||
)
|
||||
extra_price_rub = int((diff_full * remaining_seconds + total_seconds - 1) // total_seconds)
|
||||
else:
|
||||
extra_price_rub = diff_full
|
||||
total_price_rub = int(base_price_for_current + diff_full)
|
||||
else:
|
||||
total_price_rub = int(
|
||||
calculate_config_price(
|
||||
tariff=tariff,
|
||||
selected_device_limit=int(selected_device)
|
||||
if has_device_option and include_device_effective and selected_device is not None
|
||||
else None,
|
||||
selected_traffic_gb=int(selected_traffic)
|
||||
if has_traffic_option and include_traffic_effective and selected_traffic is not None
|
||||
else None,
|
||||
)
|
||||
)
|
||||
extra_price_rub = int(max(0, total_price_rub - base_price_for_current))
|
||||
final_extra_price_rub, discount_rub, _coupon_id, applied_coupon_code = await resolve_percent_coupon_pricing(
|
||||
session=session,
|
||||
billing_user_id=int(billing_user_id),
|
||||
base_price_rub=int(max(0, extra_price_rub)),
|
||||
coupon_code=coupon_code,
|
||||
)
|
||||
return AccountKeyAddonsPreviewResponse(
|
||||
client_id=str(getattr(db_key, "client_id", "") or ""),
|
||||
tariff_id=int(tariff_id),
|
||||
addons_mode=str(pack_mode or ""),
|
||||
has_device_option=bool(has_device_option),
|
||||
has_traffic_option=bool(has_traffic_option),
|
||||
current_device_limit=int(current_device_limit) if current_device_limit is not None else None,
|
||||
current_traffic_gb=int(current_traffic_gb) if current_traffic_gb is not None else None,
|
||||
selected_device_limit=int(selected_device)
|
||||
if has_device_option and include_device_effective and selected_device is not None
|
||||
else None,
|
||||
selected_traffic_gb=int(selected_traffic)
|
||||
if has_traffic_option and include_traffic_effective and selected_traffic is not None
|
||||
else None,
|
||||
device_options=[
|
||||
AccountKeyAddonOptionResponse(
|
||||
value=int(val),
|
||||
label=(
|
||||
"Безлимит устройств"
|
||||
if int(val) <= 0
|
||||
else (f"+{int(val)} устройств" if pack_mode else f"{int(val)} устройств")
|
||||
),
|
||||
)
|
||||
for val in device_options
|
||||
],
|
||||
traffic_options=[
|
||||
AccountKeyAddonOptionResponse(
|
||||
value=int(val),
|
||||
label=("Безлимит трафика" if int(val) <= 0 else (f"+{int(val)} ГБ" if pack_mode else f"{int(val)} ГБ")),
|
||||
)
|
||||
for val in traffic_options
|
||||
],
|
||||
total_price_rub=int(total_price_rub),
|
||||
extra_price_rub=int(max(0, extra_price_rub)),
|
||||
discount_rub=int(discount_rub),
|
||||
final_price_rub=int(max(0, final_extra_price_rub)),
|
||||
applied_coupon_code=applied_coupon_code,
|
||||
balance_rub=float(await get_balance(session, int(billing_user_id))),
|
||||
)
|
||||
|
||||
|
||||
@user_router.post("/{client_id}/apply-addons", response_model=AccountKeyApplyAddonsResponse)
|
||||
async def user_key_apply_addons(
|
||||
client_id: str,
|
||||
body: AccountKeyAddonsPreviewRequest,
|
||||
request: Request,
|
||||
force_web: bool = Query(False),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
identity=Depends(verify_identity_token),
|
||||
):
|
||||
actions = _key_actions_config()
|
||||
if not force_web and not actions.addons_enabled:
|
||||
raise HTTPException(status_code=403, detail="Доп. опции отключены в настройках")
|
||||
billing_user_id = await _resolve_billing_user_id(request, identity, session)
|
||||
db_key = (
|
||||
await session.execute(select(Key).where(Key.user_id == billing_user_id, Key.client_id == client_id).limit(1))
|
||||
).scalar_one_or_none()
|
||||
if db_key is None:
|
||||
raise HTTPException(status_code=404, detail="Подписка не найдена")
|
||||
tariff_id = getattr(db_key, "tariff_id", None)
|
||||
if not tariff_id:
|
||||
raise HTTPException(status_code=400, detail="Для подписки не назначен тариф")
|
||||
tariff = await get_tariff_by_id(session, int(tariff_id))
|
||||
if not tariff:
|
||||
raise HTTPException(status_code=404, detail="Тариф не найден")
|
||||
key_details = await get_key_details(session, str(getattr(db_key, "email", "") or ""))
|
||||
if not key_details:
|
||||
raise HTTPException(status_code=404, detail="Подписка не найдена")
|
||||
(
|
||||
_tariff_name,
|
||||
_subgroup_title,
|
||||
_traffic_limit_gb,
|
||||
_device_limit,
|
||||
_panel,
|
||||
is_tariff_configurable,
|
||||
addons_devices_enabled,
|
||||
addons_traffic_enabled,
|
||||
) = await get_key_tariff_addons_state(session=session, key_record=key_details, db_key=db_key)
|
||||
if not is_tariff_configurable:
|
||||
raise HTTPException(status_code=400, detail="Тариф не поддерживает доп. опции")
|
||||
cfg = normalize_tariff_config(tariff)
|
||||
raw_device_options = cfg.get("device_options") or tariff.get("device_options") or []
|
||||
raw_traffic_options = cfg.get("traffic_options_gb") or tariff.get("traffic_options_gb") or []
|
||||
device_options: list[int] = []
|
||||
for value in raw_device_options:
|
||||
try:
|
||||
device_options.append(int(value))
|
||||
except (TypeError, ValueError):
|
||||
continue
|
||||
traffic_options: list[int] = []
|
||||
for value in raw_traffic_options:
|
||||
try:
|
||||
traffic_options.append(int(value))
|
||||
except (TypeError, ValueError):
|
||||
continue
|
||||
device_options = sorted(set(device_options), key=lambda val: (int(val == 0), val))
|
||||
traffic_options = sorted(set(traffic_options), key=lambda val: (int(val == 0), val))
|
||||
has_device_option = bool(device_options) and bool(addons_devices_enabled)
|
||||
has_traffic_option = bool(traffic_options) and bool(addons_traffic_enabled)
|
||||
pack_devices, pack_traffic, pack_mode = get_pack_flags()
|
||||
if pack_mode:
|
||||
has_device_option = has_device_option and bool(pack_devices)
|
||||
has_traffic_option = has_traffic_option and bool(pack_traffic)
|
||||
if not has_device_option:
|
||||
device_options = []
|
||||
if not has_traffic_option:
|
||||
traffic_options = []
|
||||
if not has_device_option and not has_traffic_option:
|
||||
raise HTTPException(status_code=400, detail="Доп. опции для этой подписки недоступны")
|
||||
selected_device_limit_db = key_details.get("selected_device_limit")
|
||||
selected_traffic_limit_db = key_details.get("selected_traffic_limit")
|
||||
current_device_limit_db = key_details.get("current_device_limit")
|
||||
current_traffic_limit_db = key_details.get("current_traffic_limit")
|
||||
base_devices = tariff.get("device_limit")
|
||||
base_devices = int(base_devices) if base_devices is not None else None
|
||||
base_traffic_bytes = tariff.get("traffic_limit")
|
||||
base_traffic_gb_from_tariff = int(base_traffic_bytes / GB) if base_traffic_bytes else None
|
||||
current_device_limit = (
|
||||
int(current_device_limit_db)
|
||||
if current_device_limit_db is not None
|
||||
else (int(selected_device_limit_db) if selected_device_limit_db is not None else base_devices)
|
||||
)
|
||||
current_traffic_gb = (
|
||||
int(current_traffic_limit_db)
|
||||
if current_traffic_limit_db is not None
|
||||
else (int(selected_traffic_limit_db) if selected_traffic_limit_db is not None else base_traffic_gb_from_tariff)
|
||||
)
|
||||
if pack_mode and current_device_limit is not None and int(current_device_limit) == 0:
|
||||
has_device_option = False
|
||||
device_options = []
|
||||
if pack_mode and current_traffic_gb is not None and int(current_traffic_gb) == 0:
|
||||
has_traffic_option = False
|
||||
traffic_options = []
|
||||
if not has_device_option and not has_traffic_option:
|
||||
raise HTTPException(status_code=400, detail="Доп. опции для этой подписки недоступны")
|
||||
if pack_mode:
|
||||
include_device_effective = (
|
||||
bool(body.include_device) if body.include_device is not None else body.selected_device_limit is not None
|
||||
)
|
||||
include_traffic_effective = (
|
||||
bool(body.include_traffic) if body.include_traffic is not None else body.selected_traffic_gb is not None
|
||||
)
|
||||
selected_device = body.selected_device_limit if body.selected_device_limit is not None else None
|
||||
selected_traffic = body.selected_traffic_gb if body.selected_traffic_gb is not None else None
|
||||
else:
|
||||
include_device_effective = has_device_option
|
||||
include_traffic_effective = has_traffic_option
|
||||
selected_device = body.selected_device_limit if body.selected_device_limit is not None else current_device_limit
|
||||
selected_traffic = body.selected_traffic_gb if body.selected_traffic_gb is not None else current_traffic_gb
|
||||
if (
|
||||
has_device_option
|
||||
and include_device_effective
|
||||
and selected_device is not None
|
||||
and int(selected_device) not in device_options
|
||||
):
|
||||
raise HTTPException(status_code=400, detail="Выбранный пакет устройств недоступен")
|
||||
if (
|
||||
has_traffic_option
|
||||
and include_traffic_effective
|
||||
and selected_traffic is not None
|
||||
and int(selected_traffic) not in traffic_options
|
||||
):
|
||||
raise HTTPException(status_code=400, detail="Выбранный пакет трафика недоступен")
|
||||
current_devices_for_price = int(current_device_limit) if current_device_limit is not None else None
|
||||
current_traffic_for_price = int(current_traffic_gb) if current_traffic_gb is not None else None
|
||||
base_price_for_current = int(
|
||||
calculate_config_price(
|
||||
tariff=tariff,
|
||||
selected_device_limit=current_devices_for_price,
|
||||
selected_traffic_gb=current_traffic_for_price,
|
||||
)
|
||||
)
|
||||
total_price_after_purchase = base_price_for_current
|
||||
if pack_mode:
|
||||
diff_full = int(
|
||||
calc_pack_full_price_rub(
|
||||
tariff=tariff,
|
||||
has_device_option=bool(has_device_option and include_device_effective),
|
||||
has_traffic_option=bool(has_traffic_option and include_traffic_effective),
|
||||
selected_devices=int(selected_device)
|
||||
if has_device_option and include_device_effective and selected_device is not None
|
||||
else None,
|
||||
selected_traffic_gb=int(selected_traffic)
|
||||
if has_traffic_option and include_traffic_effective and selected_traffic is not None
|
||||
else None,
|
||||
)
|
||||
)
|
||||
recalc_enabled = bool(
|
||||
MODES_CONFIG.get(
|
||||
"KEY_ADDONS_RECALC_PRICE",
|
||||
TARIFFS_CONFIG.get("KEY_ADDONS_RECALC_PRICE", False),
|
||||
)
|
||||
)
|
||||
if recalc_enabled:
|
||||
remaining_seconds, total_seconds = calc_remaining_ratio_seconds(
|
||||
key_details.get("expiry_time"),
|
||||
tariff,
|
||||
)
|
||||
extra_price_rub = int((diff_full * remaining_seconds + total_seconds - 1) // total_seconds)
|
||||
else:
|
||||
extra_price_rub = diff_full
|
||||
total_price_after_purchase = int(base_price_for_current + diff_full)
|
||||
else:
|
||||
selected_total_price = int(
|
||||
calculate_config_price(
|
||||
tariff=tariff,
|
||||
selected_device_limit=int(selected_device)
|
||||
if has_device_option and selected_device is not None
|
||||
else None,
|
||||
selected_traffic_gb=int(selected_traffic)
|
||||
if has_traffic_option and selected_traffic is not None
|
||||
else None,
|
||||
)
|
||||
)
|
||||
extra_price_rub = int(max(0, selected_total_price - base_price_for_current))
|
||||
total_price_after_purchase = selected_total_price
|
||||
allow_downgrade = bool(TARIFFS_CONFIG.get("ALLOW_DOWNGRADE", True))
|
||||
device_downgrade = (
|
||||
allow_downgrade
|
||||
and has_device_option
|
||||
and current_device_limit is not None
|
||||
and selected_device is not None
|
||||
and not is_not_downgrade(current_device_limit, selected_device)
|
||||
)
|
||||
traffic_downgrade = (
|
||||
allow_downgrade
|
||||
and has_traffic_option
|
||||
and current_traffic_gb is not None
|
||||
and selected_traffic is not None
|
||||
and not is_not_downgrade(current_traffic_gb, selected_traffic)
|
||||
)
|
||||
if device_downgrade or traffic_downgrade:
|
||||
raise HTTPException(status_code=400, detail="Снижение параметров через сайт пока не поддерживается")
|
||||
final_extra_price_rub, discount_rub, coupon_id, applied_coupon_code = await resolve_percent_coupon_pricing(
|
||||
session=session,
|
||||
billing_user_id=int(billing_user_id),
|
||||
base_price_rub=int(max(0, extra_price_rub)),
|
||||
coupon_code=body.coupon_code,
|
||||
)
|
||||
balance = float(await get_balance(session, int(billing_user_id)))
|
||||
required_amount = int(max(0, ceil(float(final_extra_price_rub) - balance)))
|
||||
if extra_price_rub <= 0:
|
||||
return AccountKeyApplyAddonsResponse(
|
||||
ok=True,
|
||||
message="Доплата не требуется",
|
||||
client_id=str(getattr(db_key, "client_id", "") or ""),
|
||||
tariff_id=int(tariff_id),
|
||||
total_price_rub=int(total_price_after_purchase),
|
||||
extra_price_rub=0,
|
||||
discount_rub=0,
|
||||
final_price_rub=0,
|
||||
applied_coupon_code=None,
|
||||
charged_rub=0,
|
||||
balance_rub=balance,
|
||||
)
|
||||
expiry_time = int(getattr(db_key, "expiry_time", 0) or 0)
|
||||
email = str(getattr(db_key, "email", "") or "")
|
||||
server_id = str(getattr(db_key, "server_id", "") or "")
|
||||
if not email or not server_id:
|
||||
raise HTTPException(status_code=400, detail="Некорректные данные подписки")
|
||||
if required_amount > 0:
|
||||
provider_id = str(body.provider_id or _resolve_default_web_payment_provider() or "").strip().upper()
|
||||
if not provider_id:
|
||||
raise HTTPException(status_code=503, detail="Нет доступных провайдеров оплаты")
|
||||
base_url = _resolve_public_base_url(request)
|
||||
success_url = validate_redirect_url(str(body.success_url or ""), f"{base_url}/payment-success")
|
||||
failure_url = validate_redirect_url(str(body.failure_url or ""), f"{base_url}/payment-failure")
|
||||
payment_request = PaymentLinkRequest(
|
||||
legacy_user_ref=int(billing_user_id),
|
||||
amount=required_amount,
|
||||
currency="RUB",
|
||||
provider_id=provider_id,
|
||||
success_url=success_url,
|
||||
failure_url=failure_url,
|
||||
metadata={
|
||||
"payment_flow": "key_addons",
|
||||
"tariff_id": int(tariff_id),
|
||||
"email": email,
|
||||
"selected_device_limit": int(selected_device)
|
||||
if has_device_option and include_device_effective and selected_device is not None
|
||||
else None,
|
||||
"selected_traffic_gb": int(selected_traffic)
|
||||
if has_traffic_option and include_traffic_effective and selected_traffic is not None
|
||||
else None,
|
||||
"current_device_limit": int(current_device_limit) if current_device_limit is not None else None,
|
||||
"current_traffic_gb": int(current_traffic_gb) if current_traffic_gb is not None else None,
|
||||
"original_price": int(base_price_for_current),
|
||||
"base_price_rub": int(max(0, extra_price_rub)),
|
||||
"discount_rub": int(discount_rub),
|
||||
"applied_coupon_code": applied_coupon_code,
|
||||
"coupon_id": int(coupon_id) if coupon_id is not None else None,
|
||||
},
|
||||
)
|
||||
payment_result = await create_payment_link(session, payment_request)
|
||||
if not payment_result.success or not payment_result.payment_url or not payment_result.payment_id:
|
||||
raise HTTPException(status_code=400, detail=payment_result.error or "Не удалось создать ссылку оплаты")
|
||||
await create_temporary_data(
|
||||
session,
|
||||
int(billing_user_id),
|
||||
"waiting_for_addons_payment",
|
||||
{
|
||||
"tariff_id": int(tariff_id),
|
||||
"email": email,
|
||||
"required_amount": int(required_amount),
|
||||
"selected_device_limit": int(selected_device)
|
||||
if has_device_option and include_device_effective and selected_device is not None
|
||||
else None,
|
||||
"selected_traffic_gb": int(selected_traffic)
|
||||
if has_traffic_option and include_traffic_effective and selected_traffic is not None
|
||||
else None,
|
||||
"current_device_limit": int(current_device_limit) if current_device_limit is not None else None,
|
||||
"current_traffic_gb": int(current_traffic_gb) if current_traffic_gb is not None else None,
|
||||
"original_price": int(base_price_for_current),
|
||||
"base_price_rub": int(max(0, extra_price_rub)),
|
||||
"discount_rub": int(discount_rub),
|
||||
"applied_coupon_code": applied_coupon_code,
|
||||
"coupon_id": int(coupon_id) if coupon_id is not None else None,
|
||||
},
|
||||
)
|
||||
return AccountKeyApplyAddonsResponse(
|
||||
ok=True,
|
||||
message="Требуется оплата для применения доп. опций",
|
||||
client_id=str(getattr(db_key, "client_id", "") or ""),
|
||||
tariff_id=int(tariff_id),
|
||||
total_price_rub=int(total_price_after_purchase),
|
||||
extra_price_rub=int(extra_price_rub),
|
||||
discount_rub=int(discount_rub),
|
||||
final_price_rub=int(final_extra_price_rub),
|
||||
applied_coupon_code=applied_coupon_code,
|
||||
charged_rub=0,
|
||||
balance_rub=balance,
|
||||
payment_required=True,
|
||||
required_amount_rub=required_amount,
|
||||
payment_id=payment_result.payment_id,
|
||||
payment_url=payment_result.payment_url,
|
||||
)
|
||||
target_subgroup = tariff.get("subgroup_title")
|
||||
current_subgroup = None
|
||||
current_tariff_id = key_details.get("tariff_id")
|
||||
if current_tariff_id:
|
||||
current_tariff = await get_tariff_by_id(session, int(current_tariff_id))
|
||||
if current_tariff:
|
||||
current_subgroup = current_tariff.get("subgroup_title")
|
||||
if pack_mode:
|
||||
device_limit_effective_current, traffic_limit_bytes_effective_current = await get_effective_limits_for_key(
|
||||
session=session,
|
||||
tariff_id=int(tariff_id),
|
||||
selected_device_limit=int(current_device_limit) if current_device_limit is not None else None,
|
||||
selected_traffic_gb=int(current_traffic_gb) if current_traffic_gb is not None else None,
|
||||
)
|
||||
traffic_limit_gb_effective_current = (
|
||||
int(traffic_limit_bytes_effective_current / GB) if traffic_limit_bytes_effective_current else 0
|
||||
)
|
||||
new_device_limit_effective = device_limit_effective_current
|
||||
new_traffic_limit_gb_effective = traffic_limit_gb_effective_current
|
||||
if has_device_option and include_device_effective and selected_device is not None:
|
||||
pack_devices_val = int(selected_device)
|
||||
if pack_devices_val <= 0 or (
|
||||
new_device_limit_effective is not None and int(new_device_limit_effective) <= 0
|
||||
):
|
||||
new_device_limit_effective = 0
|
||||
else:
|
||||
if new_device_limit_effective is None:
|
||||
new_device_limit_effective = pack_devices_val
|
||||
else:
|
||||
new_device_limit_effective = int(new_device_limit_effective) + pack_devices_val
|
||||
if has_traffic_option and include_traffic_effective and selected_traffic is not None:
|
||||
pack_traffic_val = int(selected_traffic)
|
||||
if pack_traffic_val <= 0 or int(new_traffic_limit_gb_effective) <= 0:
|
||||
new_traffic_limit_gb_effective = 0
|
||||
else:
|
||||
new_traffic_limit_gb_effective = int(new_traffic_limit_gb_effective) + pack_traffic_val
|
||||
await renew_key_in_cluster(
|
||||
cluster_id=server_id,
|
||||
email=email,
|
||||
client_id=str(getattr(db_key, "client_id", "") or ""),
|
||||
new_expiry_time=expiry_time,
|
||||
total_gb=int(new_traffic_limit_gb_effective),
|
||||
session=session,
|
||||
hwid_device_limit=int(new_device_limit_effective) if new_device_limit_effective is not None else 0,
|
||||
reset_traffic=False,
|
||||
target_subgroup=target_subgroup,
|
||||
old_subgroup=current_subgroup,
|
||||
plan=int(tariff_id),
|
||||
)
|
||||
await save_key_config_with_mode(
|
||||
session=session,
|
||||
email=email,
|
||||
selected_devices=int(new_device_limit_effective) if new_device_limit_effective is not None else None,
|
||||
selected_traffic_gb=int(new_traffic_limit_gb_effective)
|
||||
if new_traffic_limit_gb_effective is not None
|
||||
else None,
|
||||
total_price=int(total_price_after_purchase),
|
||||
has_device_choice=bool(has_device_option and include_device_effective),
|
||||
has_traffic_choice=bool(has_traffic_option and include_traffic_effective),
|
||||
config_mode="pack",
|
||||
)
|
||||
else:
|
||||
selected_device_for_effective = (
|
||||
int(selected_device)
|
||||
if has_device_option and include_device_effective and selected_device is not None
|
||||
else None
|
||||
)
|
||||
selected_traffic_for_effective = (
|
||||
int(selected_traffic)
|
||||
if has_traffic_option and include_traffic_effective and selected_traffic is not None
|
||||
else 0
|
||||
)
|
||||
device_limit_effective_new, traffic_limit_bytes_effective_new = await get_effective_limits_for_key(
|
||||
session=session,
|
||||
tariff_id=int(tariff_id),
|
||||
selected_device_limit=selected_device_for_effective,
|
||||
selected_traffic_gb=selected_traffic_for_effective,
|
||||
)
|
||||
traffic_limit_gb_effective = (
|
||||
int(traffic_limit_bytes_effective_new / GB) if traffic_limit_bytes_effective_new else 0
|
||||
)
|
||||
await renew_key_in_cluster(
|
||||
cluster_id=server_id,
|
||||
email=email,
|
||||
client_id=str(getattr(db_key, "client_id", "") or ""),
|
||||
new_expiry_time=expiry_time,
|
||||
total_gb=int(traffic_limit_gb_effective),
|
||||
session=session,
|
||||
hwid_device_limit=int(device_limit_effective_new) if device_limit_effective_new is not None else 0,
|
||||
reset_traffic=False,
|
||||
target_subgroup=target_subgroup,
|
||||
old_subgroup=current_subgroup,
|
||||
plan=int(tariff_id),
|
||||
)
|
||||
await save_key_config_with_mode(
|
||||
session=session,
|
||||
email=email,
|
||||
selected_devices=int(selected_device)
|
||||
if has_device_option and include_device_effective and selected_device is not None
|
||||
else None,
|
||||
selected_traffic_gb=int(selected_traffic)
|
||||
if has_traffic_option and include_traffic_effective and selected_traffic is not None
|
||||
else None,
|
||||
total_price=int(total_price_after_purchase),
|
||||
has_device_choice=bool(has_device_option and include_device_effective),
|
||||
has_traffic_choice=bool(has_traffic_option and include_traffic_effective),
|
||||
config_mode="addon",
|
||||
)
|
||||
await update_balance(session, int(billing_user_id), -int(final_extra_price_rub))
|
||||
if coupon_id is not None:
|
||||
await mark_coupon_used(session, int(coupon_id), int(billing_user_id))
|
||||
return AccountKeyApplyAddonsResponse(
|
||||
ok=True,
|
||||
message="Доп. опции применены",
|
||||
client_id=str(getattr(db_key, "client_id", "") or ""),
|
||||
tariff_id=int(tariff_id),
|
||||
total_price_rub=int(total_price_after_purchase),
|
||||
extra_price_rub=int(extra_price_rub),
|
||||
discount_rub=int(discount_rub),
|
||||
final_price_rub=int(final_extra_price_rub),
|
||||
applied_coupon_code=applied_coupon_code,
|
||||
charged_rub=int(final_extra_price_rub),
|
||||
balance_rub=float(await get_balance(session, int(billing_user_id))),
|
||||
)
|
||||
@@ -1,299 +0,0 @@
|
||||
"""User-facing key endpoints (/api/keys/*).
|
||||
|
||||
Регистрирует эндпоинты на ``user_router`` из ``_common``. Импорт этого модуля
|
||||
из ``__init__.py`` запускает регистрацию декораторов.
|
||||
"""
|
||||
|
||||
import time
|
||||
|
||||
from .._common import * # noqa: F401,F403 — подтягиваем все имена для endpoints
|
||||
from .._common import (
|
||||
_key_actions_config,
|
||||
_normalize_expiry_ms,
|
||||
_resolve_available_location_servers,
|
||||
_resolve_billing_user_id,
|
||||
_resolve_default_web_payment_provider,
|
||||
_resolve_public_base_url,
|
||||
router,
|
||||
user_router,
|
||||
)
|
||||
|
||||
|
||||
@user_router.get("", response_model=list[AccountKeyResponse])
|
||||
async def user_keys(
|
||||
request: Request,
|
||||
session: AsyncSession = Depends(get_session),
|
||||
identity=Depends(verify_identity_token),
|
||||
):
|
||||
billing_user_id = await _resolve_billing_user_id(request, identity, session)
|
||||
keys = await get_keys(session, billing_user_id)
|
||||
result: list[AccountKeyResponse] = []
|
||||
for key in keys:
|
||||
key_actions = AccountKeyActionsAvailability()
|
||||
try:
|
||||
key_ref = str(getattr(key, "client_id", "") or getattr(key, "email", "") or "")
|
||||
_, markup, _ = await build_key_view_payload(session, int(billing_user_id), key_ref)
|
||||
key_actions = _extract_key_actions_from_markup(markup)
|
||||
except Exception:
|
||||
key_actions = AccountKeyActionsAvailability()
|
||||
result.append(
|
||||
AccountKeyResponse(
|
||||
email=str(getattr(key, "email", "") or ""),
|
||||
alias=getattr(key, "alias", None),
|
||||
client_id=str(getattr(key, "client_id", "") or ""),
|
||||
tariff_id=getattr(key, "tariff_id", None),
|
||||
server_id=str(getattr(key, "server_id", "") or ""),
|
||||
created_at=int(getattr(key, "created_at", 0) or 0),
|
||||
expiry_time=int(getattr(key, "expiry_time", 0) or 0),
|
||||
key=getattr(key, "key", None),
|
||||
remnawave_link=getattr(key, "remnawave_link", None),
|
||||
is_frozen=bool(getattr(key, "is_frozen", False)),
|
||||
actions=key_actions,
|
||||
)
|
||||
)
|
||||
return result
|
||||
|
||||
|
||||
@user_router.get("/actions-config", response_model=AccountKeyActionsConfigResponse)
|
||||
async def user_keys_actions_config(
|
||||
identity=Depends(verify_identity_token),
|
||||
):
|
||||
_ = identity
|
||||
return _key_actions_config()
|
||||
|
||||
|
||||
@user_router.get("/{client_id}/connection", response_model=AccountKeyConnectionResponse)
|
||||
async def user_key_connection(
|
||||
client_id: str,
|
||||
request: Request,
|
||||
session: AsyncSession = Depends(get_session),
|
||||
identity=Depends(verify_identity_token),
|
||||
):
|
||||
"""Лёгкая инфо о текущей подписке: онлайн/offline, сервер, протокол, дни до окончания."""
|
||||
billing_user_id = await _resolve_billing_user_id(request, identity, session)
|
||||
db_key = (
|
||||
await session.execute(select(Key).where(Key.user_id == billing_user_id, Key.client_id == client_id).limit(1))
|
||||
).scalar_one_or_none()
|
||||
if db_key is None:
|
||||
raise HTTPException(status_code=404, detail="Подписка не найдена")
|
||||
server_name = str(getattr(db_key, "server_id", "") or "")
|
||||
cluster_name = ""
|
||||
panel_type = ""
|
||||
if server_name:
|
||||
srv = (
|
||||
await session.execute(
|
||||
select(Server).where(Server.server_name == server_name).limit(1)
|
||||
)
|
||||
).scalar_one_or_none()
|
||||
if srv is not None:
|
||||
cluster_name = str(getattr(srv, "cluster_name", "") or "")
|
||||
panel_type = str(getattr(srv, "panel_type", "") or "").lower()
|
||||
expiry_ms = int(getattr(db_key, "expiry_time", 0) or 0)
|
||||
is_frozen = bool(getattr(db_key, "is_frozen", False))
|
||||
now_ms = int(time.time() * 1000)
|
||||
online = not is_frozen and expiry_ms > now_ms
|
||||
expires_in_days = max(0, int((expiry_ms - now_ms) / (1000 * 60 * 60 * 24))) if expiry_ms > 0 else 0
|
||||
if panel_type == "remnawave":
|
||||
protocol = "VLESS"
|
||||
elif panel_type == "marzban":
|
||||
protocol = "VLESS"
|
||||
elif panel_type == "3xui":
|
||||
protocol = "VLESS"
|
||||
else:
|
||||
protocol = panel_type.upper() or "VLESS"
|
||||
return AccountKeyConnectionResponse(
|
||||
client_id=str(getattr(db_key, "client_id", "") or ""),
|
||||
online=online,
|
||||
is_frozen=is_frozen,
|
||||
expiry_time=expiry_ms,
|
||||
expires_in_days=expires_in_days,
|
||||
server_name=server_name,
|
||||
cluster_name=cluster_name,
|
||||
panel_type=panel_type,
|
||||
protocol=protocol,
|
||||
)
|
||||
|
||||
|
||||
@user_router.get("/{client_id}/details", response_model=AccountKeyDetailsResponse)
|
||||
async def user_key_details(
|
||||
client_id: str,
|
||||
request: Request,
|
||||
session: AsyncSession = Depends(get_session),
|
||||
identity=Depends(verify_identity_token),
|
||||
):
|
||||
billing_user_id = await _resolve_billing_user_id(request, identity, session)
|
||||
db_key = (
|
||||
await session.execute(select(Key).where(Key.user_id == billing_user_id, Key.client_id == client_id).limit(1))
|
||||
).scalar_one_or_none()
|
||||
if db_key is None:
|
||||
raise HTTPException(status_code=404, detail="Подписка не найдена")
|
||||
key_details = await get_key_details(session, str(getattr(db_key, "email", "") or ""))
|
||||
if not key_details:
|
||||
raise HTTPException(status_code=404, detail="Подписка не найдена")
|
||||
tariff_name = ""
|
||||
subgroup_title = ""
|
||||
traffic_limit_gb = 0
|
||||
device_limit = 0
|
||||
is_tariff_configurable = False
|
||||
addons_devices_enabled = False
|
||||
addons_traffic_enabled = False
|
||||
(
|
||||
tariff_name,
|
||||
subgroup_title,
|
||||
traffic_limit_gb,
|
||||
device_limit,
|
||||
_,
|
||||
is_tariff_configurable,
|
||||
addons_devices_enabled,
|
||||
addons_traffic_enabled,
|
||||
) = await get_key_tariff_addons_state(
|
||||
session=session,
|
||||
key_record=key_details,
|
||||
db_key=db_key,
|
||||
)
|
||||
connected_devices = 0
|
||||
used_traffic_gb = None
|
||||
try:
|
||||
profile = await get_remnawave_profile(
|
||||
session,
|
||||
str(getattr(db_key, "server_id", "") or ""),
|
||||
client_id,
|
||||
fallback_any=True,
|
||||
)
|
||||
if profile:
|
||||
connected_devices = int(profile.get("hwid_count") or 0)
|
||||
used_raw = profile.get("used_gb")
|
||||
used_traffic_gb = float(used_raw) if used_raw is not None else None
|
||||
traffic_limit_bytes_actual = profile.get("traffic_limit_bytes")
|
||||
if traffic_limit_bytes_actual is not None:
|
||||
try:
|
||||
traffic_limit_bytes_actual = int(traffic_limit_bytes_actual)
|
||||
traffic_limit_gb = int(traffic_limit_bytes_actual / GB) if traffic_limit_bytes_actual > 0 else 0
|
||||
except (TypeError, ValueError):
|
||||
pass
|
||||
except Exception:
|
||||
connected_devices = 0
|
||||
used_traffic_gb = None
|
||||
return AccountKeyDetailsResponse(
|
||||
client_id=str(getattr(db_key, "client_id", "") or ""),
|
||||
email=str(getattr(db_key, "email", "") or ""),
|
||||
alias=getattr(db_key, "alias", None),
|
||||
expiry_time=int(getattr(db_key, "expiry_time", 0) or 0),
|
||||
is_frozen=bool(getattr(db_key, "is_frozen", False)),
|
||||
tariff_name=str(tariff_name or ""),
|
||||
subgroup_title=str(subgroup_title or ""),
|
||||
traffic_limit_gb=int(traffic_limit_gb or 0),
|
||||
used_traffic_gb=used_traffic_gb,
|
||||
device_limit=int(device_limit or 0),
|
||||
connected_devices=int(connected_devices or 0),
|
||||
is_tariff_configurable=bool(is_tariff_configurable),
|
||||
addons_devices_enabled=bool(addons_devices_enabled),
|
||||
addons_traffic_enabled=bool(addons_traffic_enabled),
|
||||
)
|
||||
|
||||
|
||||
@user_router.get("/{client_id}/qr", response_model=AccountKeyQrResponse)
|
||||
async def user_key_qr(
|
||||
client_id: str,
|
||||
request: Request,
|
||||
force_web: bool = Query(False),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
identity=Depends(verify_identity_token),
|
||||
):
|
||||
from api.ratelimit import enforce_rate_limit
|
||||
await enforce_rate_limit(request, session, bucket="key_qr", max_per_window=30, window_sec=60)
|
||||
actions = _key_actions_config()
|
||||
if not force_web and not actions.qr_enabled:
|
||||
raise HTTPException(status_code=403, detail="QR для подписок отключен в настройках")
|
||||
billing_user_id = await _resolve_billing_user_id(request, identity, session)
|
||||
db_key = (
|
||||
await session.execute(select(Key).where(Key.user_id == billing_user_id, Key.client_id == client_id).limit(1))
|
||||
).scalar_one_or_none()
|
||||
if db_key is None:
|
||||
raise HTTPException(status_code=404, detail="Подписка не найдена")
|
||||
qr_data = str(getattr(db_key, "key", "") or "").strip() or str(getattr(db_key, "remnawave_link", "") or "").strip()
|
||||
if not qr_data:
|
||||
raise HTTPException(status_code=400, detail="Ссылка для подключения отсутствует")
|
||||
qr = qrcode.QRCode(version=1, box_size=10, border=4)
|
||||
qr.add_data(qr_data)
|
||||
qr.make(fit=True)
|
||||
img = qr.make_image(fill_color="black", back_color="white")
|
||||
buffer = BytesIO()
|
||||
img.save(buffer, format="PNG")
|
||||
image_data = b64encode(buffer.getvalue()).decode("ascii")
|
||||
return AccountKeyQrResponse(
|
||||
ok=True,
|
||||
message="QR-код готов",
|
||||
link=qr_data,
|
||||
image_data_url=f"data:image/png;base64,{image_data}",
|
||||
)
|
||||
|
||||
|
||||
@user_router.patch("/{client_id}/alias", response_model=AccountKeyResponse)
|
||||
async def user_key_update_alias(
|
||||
client_id: str,
|
||||
body: AccountKeyAliasUpdateRequest,
|
||||
request: Request,
|
||||
session: AsyncSession = Depends(get_session),
|
||||
identity=Depends(verify_identity_token),
|
||||
):
|
||||
from api.ratelimit import enforce_rate_limit
|
||||
await enforce_rate_limit(request, session, bucket="key_alias", max_per_window=20, window_sec=60)
|
||||
alias = str(body.alias or "").strip()
|
||||
if not alias:
|
||||
raise HTTPException(status_code=400, detail="Укажите alias")
|
||||
if len(alias) > 10:
|
||||
raise HTTPException(status_code=400, detail="Alias должен быть не длиннее 10 символов")
|
||||
if not re.match(r"^[a-zA-Zа-яА-ЯёЁ0-9@._-]+$", alias):
|
||||
raise HTTPException(status_code=400, detail="Alias содержит недопустимые символы")
|
||||
billing_user_id = await _resolve_billing_user_id(request, identity, session)
|
||||
db_key = (
|
||||
await session.execute(select(Key).where(Key.user_id == billing_user_id, Key.client_id == client_id).limit(1))
|
||||
).scalar_one_or_none()
|
||||
if db_key is None:
|
||||
raise HTTPException(status_code=404, detail="Подписка не найдена")
|
||||
db_key.alias = alias
|
||||
return AccountKeyResponse(
|
||||
email=str(getattr(db_key, "email", "") or ""),
|
||||
alias=getattr(db_key, "alias", None),
|
||||
client_id=str(getattr(db_key, "client_id", "") or ""),
|
||||
tariff_id=getattr(db_key, "tariff_id", None),
|
||||
server_id=str(getattr(db_key, "server_id", "") or ""),
|
||||
created_at=int(getattr(db_key, "created_at", 0) or 0),
|
||||
expiry_time=int(getattr(db_key, "expiry_time", 0) or 0),
|
||||
key=getattr(db_key, "key", None),
|
||||
remnawave_link=getattr(db_key, "remnawave_link", None),
|
||||
is_frozen=bool(getattr(db_key, "is_frozen", False)),
|
||||
)
|
||||
|
||||
|
||||
@user_router.delete("/{client_id}", response_model=AccountKeyActionResponse)
|
||||
async def user_key_delete(
|
||||
client_id: str,
|
||||
request: Request,
|
||||
force_web: bool = Query(False),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
identity=Depends(verify_identity_token),
|
||||
):
|
||||
from api.ratelimit import enforce_rate_limit
|
||||
await enforce_rate_limit(request, session, bucket="key_delete", max_per_window=10, window_sec=60)
|
||||
actions = _key_actions_config()
|
||||
if not force_web and not actions.delete_enabled:
|
||||
raise HTTPException(status_code=403, detail="Удаление подписки отключено в настройках")
|
||||
billing_user_id = await _resolve_billing_user_id(request, identity, session)
|
||||
db_key = (
|
||||
await session.execute(select(Key).where(Key.user_id == billing_user_id, Key.client_id == client_id).limit(1))
|
||||
).scalar_one_or_none()
|
||||
if db_key is None:
|
||||
raise HTTPException(status_code=404, detail="Подписка не найдена")
|
||||
cluster_id = str(getattr(db_key, "server_id", "") or "")
|
||||
email = str(getattr(db_key, "email", "") or "")
|
||||
if cluster_id and email:
|
||||
await delete_key_from_cluster(
|
||||
cluster_id=cluster_id,
|
||||
email=email,
|
||||
client_id=client_id,
|
||||
session=session,
|
||||
)
|
||||
await session.delete(db_key)
|
||||
return AccountKeyActionResponse(ok=True, message="Подписка удалена")
|
||||
@@ -1,73 +0,0 @@
|
||||
"""User-facing key endpoints (/api/keys/*).
|
||||
|
||||
Регистрирует эндпоинты на ``user_router`` из ``_common``. Импорт этого модуля
|
||||
из ``__init__.py`` запускает регистрацию декораторов.
|
||||
"""
|
||||
|
||||
from .._common import * # noqa: F401,F403 — подтягиваем все имена для endpoints
|
||||
from .._common import (
|
||||
_key_actions_config,
|
||||
_normalize_expiry_ms,
|
||||
_resolve_available_location_servers,
|
||||
_resolve_billing_user_id,
|
||||
_resolve_default_web_payment_provider,
|
||||
_resolve_public_base_url,
|
||||
router,
|
||||
user_router,
|
||||
)
|
||||
|
||||
|
||||
@user_router.post("/{client_id}/reset-hwid", response_model=AccountKeyResetHwidResponse)
|
||||
async def user_key_reset_hwid(
|
||||
client_id: str,
|
||||
request: Request,
|
||||
force_web: bool = Query(False),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
identity=Depends(verify_identity_token),
|
||||
):
|
||||
actions = _key_actions_config()
|
||||
if not force_web and not actions.hwid_reset_enabled:
|
||||
raise HTTPException(status_code=403, detail="Сброс устройств отключен в настройках")
|
||||
billing_user_id = await _resolve_billing_user_id(request, identity, session)
|
||||
db_key = (
|
||||
await session.execute(select(Key).where(Key.user_id == billing_user_id, Key.client_id == client_id).limit(1))
|
||||
).scalar_one_or_none()
|
||||
if db_key is None:
|
||||
raise HTTPException(status_code=404, detail="Подписка не найдена")
|
||||
server_id = str(getattr(db_key, "server_id", "") or "")
|
||||
if not server_id:
|
||||
raise HTTPException(status_code=400, detail="У подписки не указан сервер")
|
||||
|
||||
async def _reset_devices(api):
|
||||
devices = await api.get_user_hwid_devices(client_id)
|
||||
if not devices:
|
||||
return 0, 0
|
||||
reset_local = 0
|
||||
for device in devices:
|
||||
hwid = device.get("hwid")
|
||||
if hwid and await api.delete_user_hwid_device(client_id, hwid):
|
||||
reset_local += 1
|
||||
return len(devices), reset_local
|
||||
|
||||
reset_result = await with_remnawave_api(
|
||||
session,
|
||||
server_id,
|
||||
_reset_devices,
|
||||
fallback_any=True,
|
||||
timeout_sec=12.0,
|
||||
)
|
||||
if reset_result is None:
|
||||
raise HTTPException(status_code=502, detail="Не удалось выполнить сброс устройств")
|
||||
total_devices, reset_devices = reset_result
|
||||
await invalidate_remnawave_profile(
|
||||
session,
|
||||
server_id,
|
||||
str(client_id),
|
||||
fallback_any=True,
|
||||
)
|
||||
return AccountKeyResetHwidResponse(
|
||||
ok=True,
|
||||
message="Устройства сброшены" if total_devices > 0 else "Устройства не были привязаны",
|
||||
total_devices=int(total_devices),
|
||||
reset_devices=int(reset_devices),
|
||||
)
|
||||
@@ -1,225 +0,0 @@
|
||||
"""User-facing key endpoints (/api/keys/*).
|
||||
|
||||
Регистрирует эндпоинты на ``user_router`` из ``_common``. Импорт этого модуля
|
||||
из ``__init__.py`` запускает регистрацию декораторов.
|
||||
"""
|
||||
|
||||
from .._common import * # noqa: F401,F403 — подтягиваем все имена для endpoints
|
||||
from .._common import (
|
||||
_key_actions_config,
|
||||
_normalize_expiry_ms,
|
||||
_resolve_available_location_servers,
|
||||
_resolve_billing_user_id,
|
||||
_resolve_default_web_payment_provider,
|
||||
_resolve_public_base_url,
|
||||
router,
|
||||
user_router,
|
||||
)
|
||||
|
||||
|
||||
@user_router.get("/{client_id}/locations", response_model=AccountKeyLocationsResponse)
|
||||
async def user_key_locations(
|
||||
client_id: str,
|
||||
request: Request,
|
||||
force_web: bool = Query(False),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
identity=Depends(verify_identity_token),
|
||||
):
|
||||
actions = _key_actions_config()
|
||||
if not force_web and not actions.country_change_enabled:
|
||||
raise HTTPException(status_code=403, detail="Смена локации отключена в настройках")
|
||||
billing_user_id = await _resolve_billing_user_id(request, identity, session)
|
||||
db_key = (
|
||||
await session.execute(select(Key).where(Key.user_id == billing_user_id, Key.client_id == client_id).limit(1))
|
||||
).scalar_one_or_none()
|
||||
if db_key is None:
|
||||
raise HTTPException(status_code=404, detail="Подписка не найдена")
|
||||
names = await _resolve_available_location_servers(session, db_key)
|
||||
return AccountKeyLocationsResponse(
|
||||
client_id=str(getattr(db_key, "client_id", "") or ""),
|
||||
current_server=str(getattr(db_key, "server_id", "") or ""),
|
||||
locations=[AccountKeyLocationOptionResponse(server_name=name) for name in names],
|
||||
)
|
||||
|
||||
|
||||
@user_router.post("/{client_id}/change-location", response_model=AccountKeyChangeLocationResponse)
|
||||
async def user_key_change_location(
|
||||
client_id: str,
|
||||
body: AccountKeyChangeLocationRequest,
|
||||
request: Request,
|
||||
force_web: bool = Query(False),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
identity=Depends(verify_identity_token),
|
||||
):
|
||||
actions = _key_actions_config()
|
||||
if not force_web and not actions.country_change_enabled:
|
||||
raise HTTPException(status_code=403, detail="Смена локации отключена в настройках")
|
||||
target_server = str(body.server_name or "").strip()
|
||||
if not target_server:
|
||||
raise HTTPException(status_code=400, detail="Укажите целевую локацию")
|
||||
billing_user_id = await _resolve_billing_user_id(request, identity, session)
|
||||
db_key = (
|
||||
await session.execute(select(Key).where(Key.user_id == billing_user_id, Key.client_id == client_id).limit(1))
|
||||
).scalar_one_or_none()
|
||||
if db_key is None:
|
||||
raise HTTPException(status_code=404, detail="Подписка не найдена")
|
||||
current_server = str(getattr(db_key, "server_id", "") or "")
|
||||
if not current_server:
|
||||
raise HTTPException(status_code=400, detail="У подписки не указан текущий сервер")
|
||||
if current_server == target_server:
|
||||
raise HTTPException(status_code=400, detail="Подписка уже в этой локации")
|
||||
available_names = await _resolve_available_location_servers(session, db_key)
|
||||
if target_server not in available_names:
|
||||
raise HTTPException(status_code=400, detail="Выбранная локация недоступна")
|
||||
email = str(getattr(db_key, "email", "") or "")
|
||||
if not email:
|
||||
raise HTTPException(status_code=400, detail="У подписки отсутствует email")
|
||||
key_details = await get_key_details(session, email)
|
||||
if not key_details:
|
||||
raise HTTPException(status_code=404, detail="Подписка не найдена")
|
||||
old_server_info = (
|
||||
await session.execute(select(Server).where(Server.server_name == current_server).limit(1))
|
||||
).scalar_one_or_none()
|
||||
if old_server_info:
|
||||
old_panel_type = str(getattr(old_server_info, "panel_type", "") or "").lower()
|
||||
try:
|
||||
if old_panel_type == "3x-ui":
|
||||
xui = await get_xui_instance(str(getattr(old_server_info, "api_url", "") or ""))
|
||||
await delete_client(
|
||||
xui,
|
||||
int(getattr(old_server_info, "inbound_id", 0) or 0),
|
||||
email,
|
||||
str(getattr(db_key, "client_id", "") or ""),
|
||||
)
|
||||
elif old_panel_type == "remnawave":
|
||||
remna_del = RemnawaveAPI(str(getattr(old_server_info, "api_url", "") or ""))
|
||||
if await remna_del.login(REMNAWAVE_LOGIN, REMNAWAVE_PASSWORD):
|
||||
await remna_del.delete_user(str(getattr(db_key, "client_id", "") or ""))
|
||||
except Exception:
|
||||
pass
|
||||
target_server_info = (
|
||||
await session.execute(select(Server).where(Server.server_name == target_server).limit(1))
|
||||
).scalar_one_or_none()
|
||||
if target_server_info is None:
|
||||
raise HTTPException(status_code=404, detail="Целевая локация не найдена")
|
||||
tariff_id = getattr(db_key, "tariff_id", None)
|
||||
tariff = await get_tariff_by_id(session, int(tariff_id)) if tariff_id else None
|
||||
need_vless_key = bool(tariff.get("vless")) if tariff else False
|
||||
external_squad_uuid = (tariff.get("external_squad") if tariff else None) or None
|
||||
selected_traffic_gb = getattr(db_key, "selected_traffic_limit", None)
|
||||
selected_device_limit = getattr(db_key, "selected_device_limit", None)
|
||||
if selected_traffic_gb is not None:
|
||||
traffic_limit_bytes = int(selected_traffic_gb) * GB
|
||||
else:
|
||||
raw_traffic_limit = int(tariff.get("traffic_limit") or 0) if tariff else 0
|
||||
traffic_limit_bytes = raw_traffic_limit * GB if raw_traffic_limit > 0 else 0
|
||||
if selected_device_limit is not None:
|
||||
device_limit = int(selected_device_limit)
|
||||
else:
|
||||
device_limit = int(tariff.get("device_limit") or 0) if tariff else 0
|
||||
key_client_id = str(getattr(db_key, "client_id", "") or "")
|
||||
expiry_timestamp = int(getattr(db_key, "expiry_time", 0) or 0)
|
||||
target_cluster_info = await check_server_name_by_cluster(session, target_server)
|
||||
target_cluster_name = str((target_cluster_info or {}).get("cluster_name") or "")
|
||||
full_remnawave_cluster = (
|
||||
await is_full_remnawave_cluster(target_cluster_name, session) if target_cluster_name else False
|
||||
)
|
||||
panel_type = str(getattr(target_server_info, "panel_type", "") or "").lower()
|
||||
remnawave_link = None
|
||||
if panel_type == "remnawave" or full_remnawave_cluster:
|
||||
remna = RemnawaveAPI(str(getattr(target_server_info, "api_url", "") or ""))
|
||||
if not await remna.login(REMNAWAVE_LOGIN, REMNAWAVE_PASSWORD):
|
||||
raise HTTPException(status_code=502, detail="Не удалось авторизоваться в Remnawave")
|
||||
expire_at = datetime.utcfromtimestamp(expiry_timestamp / 1000).isoformat() + "Z"
|
||||
user_data: dict[str, Any] = {
|
||||
"username": email,
|
||||
"trafficLimitStrategy": "NO_RESET",
|
||||
"expireAt": expire_at,
|
||||
"telegramId": int(key_details.get("tg_id") or 0),
|
||||
"activeInternalSquads": [getattr(target_server_info, "inbound_id", None)],
|
||||
"uuid": key_client_id,
|
||||
}
|
||||
if traffic_limit_bytes > 0:
|
||||
user_data["trafficLimitBytes"] = traffic_limit_bytes
|
||||
if device_limit > 0:
|
||||
user_data["hwidDeviceLimit"] = device_limit
|
||||
if external_squad_uuid:
|
||||
user_data["externalSquadUuid"] = external_squad_uuid
|
||||
result = await remna.create_user(user_data)
|
||||
if not result:
|
||||
raise HTTPException(status_code=502, detail="Не удалось создать подписку в новой локации")
|
||||
key_client_id = str(result.get("uuid") or result.get("id") or key_client_id)
|
||||
if need_vless_key:
|
||||
try:
|
||||
remnawave_link = await get_vless_link_for_remnawave_by_username(remna, email, email)
|
||||
except Exception:
|
||||
remnawave_link = None
|
||||
if not remnawave_link:
|
||||
try:
|
||||
sub = await remna.get_subscription_by_username(email)
|
||||
except Exception:
|
||||
sub = None
|
||||
if sub:
|
||||
links = sub.get("links") or []
|
||||
remnawave_link = (
|
||||
next(
|
||||
(link for link in links if isinstance(link, str) and link.lower().startswith("vless://")),
|
||||
None,
|
||||
)
|
||||
if need_vless_key
|
||||
else None
|
||||
)
|
||||
if not remnawave_link:
|
||||
remnawave_link = sub.get("subscriptionUrl")
|
||||
if panel_type == "3x-ui":
|
||||
await create_client_on_server(
|
||||
{
|
||||
"api_url": str(getattr(target_server_info, "api_url", "") or ""),
|
||||
"inbound_id": getattr(target_server_info, "inbound_id", None),
|
||||
"server_name": str(getattr(target_server_info, "server_name", "") or ""),
|
||||
"panel_type": str(getattr(target_server_info, "panel_type", "") or ""),
|
||||
},
|
||||
int(key_details.get("tg_id") or 0),
|
||||
key_client_id,
|
||||
email,
|
||||
expiry_timestamp,
|
||||
asyncio.Semaphore(1),
|
||||
plan=int(tariff_id) if tariff_id else None,
|
||||
session=session,
|
||||
is_trial=False,
|
||||
total_traffic_limit_bytes=traffic_limit_bytes,
|
||||
device_limit_value=device_limit,
|
||||
)
|
||||
subgroup_code = tariff.get("subgroup_title") if tariff and tariff.get("subgroup_title") else None
|
||||
public_link = await make_aggregated_link(
|
||||
session=session,
|
||||
cluster_all=[
|
||||
{
|
||||
"server_name": str(getattr(target_server_info, "server_name", "") or ""),
|
||||
"api_url": str(getattr(target_server_info, "api_url", "") or ""),
|
||||
"panel_type": str(getattr(target_server_info, "panel_type", "") or ""),
|
||||
"inbound_id": getattr(target_server_info, "inbound_id", None),
|
||||
"enabled": True,
|
||||
"max_keys": getattr(target_server_info, "max_keys", None),
|
||||
}
|
||||
],
|
||||
cluster_id=target_cluster_name or target_server,
|
||||
email=email,
|
||||
client_id=key_client_id,
|
||||
tg_id=int(key_details.get("tg_id") or 0),
|
||||
subgroup_code=subgroup_code,
|
||||
remna_link_override=remnawave_link,
|
||||
plan=int(tariff_id) if tariff_id else None,
|
||||
)
|
||||
db_key.server_id = target_server
|
||||
db_key.client_id = key_client_id
|
||||
db_key.key = public_link if isinstance(public_link, str) and public_link.strip() else None
|
||||
db_key.remnawave_link = remnawave_link
|
||||
return AccountKeyChangeLocationResponse(
|
||||
ok=True,
|
||||
message="Локация успешно изменена",
|
||||
client_id=str(getattr(db_key, "client_id", "") or ""),
|
||||
server_id=str(getattr(db_key, "server_id", "") or ""),
|
||||
link=str(getattr(db_key, "key", "") or ""),
|
||||
remnawave_link=getattr(db_key, "remnawave_link", None),
|
||||
)
|
||||
@@ -1,192 +0,0 @@
|
||||
"""User-facing key endpoints (/api/keys/*).
|
||||
|
||||
Регистрирует эндпоинты на ``user_router`` из ``_common``. Импорт этого модуля
|
||||
из ``__init__.py`` запускает регистрацию декораторов.
|
||||
"""
|
||||
|
||||
from .._common import * # noqa: F401,F403 — подтягиваем все имена для endpoints
|
||||
from .._common import (
|
||||
_key_actions_config,
|
||||
_normalize_expiry_ms,
|
||||
_resolve_available_location_servers,
|
||||
_resolve_billing_user_id,
|
||||
_resolve_default_web_payment_provider,
|
||||
_resolve_public_base_url,
|
||||
router,
|
||||
user_router,
|
||||
)
|
||||
|
||||
|
||||
@user_router.post("/{client_id}/renew", response_model=AccountKeyRenewResponse)
|
||||
async def user_key_renew(
|
||||
client_id: str,
|
||||
body: AccountKeyRenewRequest,
|
||||
request: Request,
|
||||
force_web: bool = Query(False),
|
||||
preview: bool = Query(False),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
identity=Depends(verify_identity_token),
|
||||
):
|
||||
from api.ratelimit import enforce_rate_limit
|
||||
from services.errors import ServiceError
|
||||
from services.keys import (
|
||||
calculate_renewal_pricing,
|
||||
execute_renewal,
|
||||
normalize_expiry_ms as _svc_normalize_expiry,
|
||||
)
|
||||
|
||||
if not preview:
|
||||
await enforce_rate_limit(request, session, bucket="key_renew", max_per_window=10, window_sec=60)
|
||||
|
||||
actions = _key_actions_config()
|
||||
if not force_web and not actions.renew_enabled:
|
||||
raise HTTPException(status_code=403, detail="Продление подписки отключено в настройках")
|
||||
billing_user_id = await _resolve_billing_user_id(request, identity, session)
|
||||
db_key = (
|
||||
await session.execute(select(Key).where(Key.user_id == billing_user_id, Key.client_id == client_id).limit(1))
|
||||
).scalar_one_or_none()
|
||||
if db_key is None:
|
||||
raise HTTPException(status_code=404, detail="Подписка не найдена")
|
||||
if bool(getattr(db_key, "is_frozen", False)):
|
||||
raise HTTPException(status_code=400, detail="Продление для замороженной подписки недоступно")
|
||||
tariff_id = getattr(db_key, "tariff_id", None)
|
||||
if not tariff_id:
|
||||
raise HTTPException(status_code=400, detail="Для подписки не назначен тариф")
|
||||
key_email = str(getattr(db_key, "email", "") or "")
|
||||
key_server_id = str(getattr(db_key, "server_id", "") or "")
|
||||
|
||||
try:
|
||||
pricing = await calculate_renewal_pricing(
|
||||
session=session,
|
||||
billing_user_id=int(billing_user_id),
|
||||
key_email=key_email,
|
||||
tariff_id=int(tariff_id),
|
||||
coupon_code=body.coupon_code,
|
||||
)
|
||||
except ServiceError as e:
|
||||
raise HTTPException(status_code=400, detail=e.message)
|
||||
|
||||
if preview:
|
||||
return AccountKeyRenewResponse(
|
||||
ok=True,
|
||||
message="Расчет обновлен",
|
||||
client_id=str(client_id),
|
||||
tariff_id=int(tariff_id),
|
||||
charged_rub=0,
|
||||
balance_rub=pricing.balance,
|
||||
base_price_rub=pricing.base_price_rub,
|
||||
discount_rub=pricing.discount_rub,
|
||||
final_price_rub=pricing.final_price_rub,
|
||||
applied_coupon_code=pricing.applied_coupon_code,
|
||||
payment_required=pricing.payment_required,
|
||||
required_amount_rub=pricing.required_amount,
|
||||
payment_id=None,
|
||||
payment_url=None,
|
||||
)
|
||||
if pricing.payment_required:
|
||||
provider_id = str(body.provider_id or _resolve_default_web_payment_provider() or "").strip().upper()
|
||||
if not provider_id:
|
||||
raise HTTPException(status_code=503, detail="Нет доступных провайдеров оплаты")
|
||||
base_url = _resolve_public_base_url(request)
|
||||
success_url = validate_redirect_url(str(body.success_url or ""), f"{base_url}/payment-success")
|
||||
failure_url = validate_redirect_url(str(body.failure_url or ""), f"{base_url}/payment-failure")
|
||||
payment_request = PaymentLinkRequest(
|
||||
legacy_user_ref=int(billing_user_id),
|
||||
amount=pricing.required_amount,
|
||||
currency="RUB",
|
||||
provider_id=provider_id,
|
||||
success_url=success_url,
|
||||
failure_url=failure_url,
|
||||
metadata={
|
||||
"payment_flow": "key_renewal",
|
||||
"tariff_id": int(tariff_id),
|
||||
"client_id": str(client_id),
|
||||
"email": key_email,
|
||||
"cost": pricing.final_price_rub,
|
||||
"selected_duration_days": pricing.duration_days,
|
||||
"selected_device_limit": pricing.selected_device_limit,
|
||||
"selected_traffic_limit": pricing.selected_traffic_limit,
|
||||
"selected_price_rub": pricing.final_price_rub,
|
||||
"total_gb": pricing.total_gb,
|
||||
"base_price_rub": pricing.base_price_rub,
|
||||
"discount_rub": pricing.discount_rub,
|
||||
"applied_coupon_code": pricing.applied_coupon_code,
|
||||
"coupon_id": pricing.coupon_id,
|
||||
},
|
||||
)
|
||||
payment_result = await create_payment_link(session, payment_request)
|
||||
if not payment_result.success or not payment_result.payment_url or not payment_result.payment_id:
|
||||
raise HTTPException(status_code=400, detail=payment_result.error or "Не удалось создать ссылку оплаты")
|
||||
await create_temporary_data(
|
||||
session,
|
||||
int(billing_user_id),
|
||||
"waiting_for_renewal_payment",
|
||||
{
|
||||
"tariff_id": int(tariff_id),
|
||||
"client_id": str(client_id),
|
||||
"email": key_email,
|
||||
"cost": pricing.final_price_rub,
|
||||
"required_amount": pricing.required_amount,
|
||||
"selected_duration_days": pricing.duration_days,
|
||||
"selected_device_limit": pricing.selected_device_limit,
|
||||
"selected_traffic_limit": pricing.selected_traffic_limit,
|
||||
"selected_price_rub": pricing.final_price_rub,
|
||||
"total_gb": pricing.total_gb,
|
||||
"base_price_rub": pricing.base_price_rub,
|
||||
"discount_rub": pricing.discount_rub,
|
||||
"applied_coupon_code": pricing.applied_coupon_code,
|
||||
"coupon_id": pricing.coupon_id,
|
||||
},
|
||||
)
|
||||
return AccountKeyRenewResponse(
|
||||
ok=True,
|
||||
message="Требуется оплата для продления подписки",
|
||||
client_id=str(client_id),
|
||||
tariff_id=int(tariff_id),
|
||||
charged_rub=0,
|
||||
balance_rub=pricing.balance,
|
||||
base_price_rub=pricing.base_price_rub,
|
||||
discount_rub=pricing.discount_rub,
|
||||
final_price_rub=pricing.final_price_rub,
|
||||
applied_coupon_code=pricing.applied_coupon_code,
|
||||
payment_required=True,
|
||||
required_amount_rub=pricing.required_amount,
|
||||
payment_id=payment_result.payment_id,
|
||||
payment_url=payment_result.payment_url,
|
||||
)
|
||||
expiry_raw = _normalize_expiry_ms(getattr(db_key, "expiry_time", None))
|
||||
now_ms = int(datetime.utcnow().timestamp() * 1000)
|
||||
base_expiry = now_ms if expiry_raw <= now_ms else expiry_raw
|
||||
new_expiry_time = int(base_expiry + pricing.duration_days * 24 * 60 * 60 * 1000)
|
||||
if not key_email or not key_server_id:
|
||||
raise HTTPException(status_code=400, detail="Некорректные данные подписки")
|
||||
try:
|
||||
result = await execute_renewal(
|
||||
session=session,
|
||||
billing_user_id=int(billing_user_id),
|
||||
client_id=str(client_id),
|
||||
key_email=key_email,
|
||||
key_server_id=key_server_id,
|
||||
tariff_id=int(tariff_id),
|
||||
new_expiry_time=new_expiry_time,
|
||||
total_gb=pricing.total_gb,
|
||||
cost=float(pricing.final_price_rub),
|
||||
selected_device_limit=pricing.selected_device_limit,
|
||||
selected_traffic_limit=pricing.selected_traffic_limit,
|
||||
selected_price_rub=pricing.final_price_rub,
|
||||
coupon_id=pricing.coupon_id,
|
||||
)
|
||||
except ServiceError as e:
|
||||
raise HTTPException(status_code=400, detail=e.message)
|
||||
return AccountKeyRenewResponse(
|
||||
ok=True,
|
||||
message="Подписка продлена",
|
||||
client_id=result.client_id,
|
||||
tariff_id=result.tariff_id,
|
||||
charged_rub=result.charged_rub,
|
||||
balance_rub=result.balance_rub,
|
||||
base_price_rub=pricing.base_price_rub,
|
||||
discount_rub=pricing.discount_rub,
|
||||
final_price_rub=pricing.final_price_rub,
|
||||
applied_coupon_code=pricing.applied_coupon_code,
|
||||
)
|
||||
@@ -1,514 +0,0 @@
|
||||
import asyncio
|
||||
import os
|
||||
import re
|
||||
import subprocess
|
||||
import sys
|
||||
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from typing import Literal
|
||||
|
||||
import psutil
|
||||
|
||||
from aiogram import Bot
|
||||
from aiogram.client.default import DefaultBotProperties
|
||||
from aiogram.enums import ParseMode
|
||||
from fastapi import APIRouter, BackgroundTasks, Depends, HTTPException, Query
|
||||
from pydantic import BaseModel
|
||||
from sqlalchemy import distinct, exists, func, select, update
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from api.depends import get_session, verify_identity_admin, verify_identity_admin_short
|
||||
from api.v2.schemas.audit import (
|
||||
AuditEventListResponse,
|
||||
AuditEventResponse,
|
||||
AuditStatsResponse,
|
||||
)
|
||||
from audit import drain_audit_redis_to_db, get_audit_funnel, get_audit_stats, list_audit_events
|
||||
from config import API_TOKEN, BOT_SERVICE
|
||||
from core.bootstrap import MANAGEMENT_CONFIG
|
||||
from core.executor import run_io
|
||||
from core.redis_cache import cache_incr
|
||||
from core.settings.management_config import update_management_config
|
||||
from database import async_session_maker
|
||||
from database.models import Key, ScheduledBroadcast, Server, User
|
||||
from database.scheduled_broadcasts import (
|
||||
cancel_scheduled_broadcast,
|
||||
create_scheduled_broadcast,
|
||||
get_scheduled_broadcast,
|
||||
list_scheduled_broadcasts,
|
||||
mark_scheduled_broadcast_failed,
|
||||
mark_scheduled_broadcast_sent,
|
||||
start_scheduled_broadcast,
|
||||
update_scheduled_broadcast,
|
||||
)
|
||||
from handlers.admin.sender.scheduled_service import (
|
||||
ensure_utc_datetime,
|
||||
execute_broadcast_payload,
|
||||
execute_scheduled_broadcast,
|
||||
prepare_broadcast_payload,
|
||||
scheduled_broadcast_to_dict,
|
||||
)
|
||||
from logger import logger
|
||||
from utils.backup import backup_database
|
||||
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
async def _admin_rate_limit(request_or_identity, action: str, max_calls: int, window_sec: int) -> None:
|
||||
identity_id = getattr(request_or_identity, "id", "unknown")
|
||||
key = f"admin_rl:{action}:{identity_id}"
|
||||
count = await cache_incr(key, window_sec)
|
||||
if count > max_calls:
|
||||
raise HTTPException(status_code=429, detail="Слишком много запросов. Попробуйте позже.")
|
||||
|
||||
|
||||
class MaintenanceUpdate(BaseModel):
|
||||
enabled: bool
|
||||
|
||||
|
||||
class DomainChange(BaseModel):
|
||||
domain: str
|
||||
|
||||
|
||||
class BroadcastLaunchPayload(BaseModel):
|
||||
send_to: Literal["all", "subscribed", "unsubscribed", "untrial", "trial", "hotleads", "cluster"] = "all"
|
||||
channel: Literal["bot", "site", "both"] = "both"
|
||||
text: str
|
||||
photo: str | None = None
|
||||
cluster_name: str | None = None
|
||||
workers: int = 5
|
||||
messages_per_second: int = 35
|
||||
|
||||
|
||||
class ScheduledBroadcastCreatePayload(BroadcastLaunchPayload):
|
||||
scheduled_for: datetime
|
||||
|
||||
|
||||
class ScheduledBroadcastUpdatePayload(BaseModel):
|
||||
send_to: Literal["all", "subscribed", "unsubscribed", "untrial", "trial", "hotleads", "cluster"] | None = None
|
||||
channel: Literal["bot", "site", "both"] | None = None
|
||||
text: str | None = None
|
||||
photo: str | None = None
|
||||
cluster_name: str | None = None
|
||||
workers: int | None = None
|
||||
messages_per_second: int | None = None
|
||||
scheduled_for: datetime | None = None
|
||||
|
||||
|
||||
_broadcast_bot: Bot | None = None
|
||||
|
||||
|
||||
def _get_broadcast_bot() -> Bot:
|
||||
"""Возвращает экземпляр бота для рассылки."""
|
||||
global _broadcast_bot
|
||||
if _broadcast_bot is None:
|
||||
_broadcast_bot = Bot(token=API_TOKEN, default=DefaultBotProperties(parse_mode=ParseMode.HTML))
|
||||
return _broadcast_bot
|
||||
|
||||
|
||||
def _require_future_schedule(value: datetime) -> datetime:
|
||||
scheduled_for = ensure_utc_datetime(value)
|
||||
if scheduled_for <= datetime.now(timezone.utc):
|
||||
raise HTTPException(status_code=400, detail="scheduled_for must be in the future")
|
||||
return scheduled_for
|
||||
|
||||
|
||||
def _resolve_update_payload(
|
||||
payload: ScheduledBroadcastUpdatePayload,
|
||||
current: ScheduledBroadcast,
|
||||
) -> dict:
|
||||
fields = payload.model_fields_set
|
||||
text_changed = "text" in fields
|
||||
send_to = payload.send_to if "send_to" in fields else current.send_to
|
||||
channel = payload.channel if "channel" in fields else current.channel
|
||||
text = payload.text if "text" in fields else current.text
|
||||
photo = payload.photo if "photo" in fields else current.photo
|
||||
cluster_name = payload.cluster_name if "cluster_name" in fields else current.cluster_name
|
||||
workers = payload.workers if "workers" in fields else current.workers
|
||||
messages_per_second = (
|
||||
payload.messages_per_second if "messages_per_second" in fields else current.messages_per_second
|
||||
)
|
||||
prepared = prepare_broadcast_payload(
|
||||
send_to=send_to,
|
||||
text=text,
|
||||
photo=photo,
|
||||
cluster_name=cluster_name,
|
||||
workers=workers,
|
||||
messages_per_second=messages_per_second,
|
||||
channel=channel,
|
||||
)
|
||||
if not text_changed:
|
||||
prepared["text"] = current.text
|
||||
prepared["keyboard_json"] = current.keyboard_json
|
||||
if "scheduled_for" in fields:
|
||||
prepared["scheduled_for"] = _require_future_schedule(payload.scheduled_for)
|
||||
return prepared
|
||||
|
||||
|
||||
async def _restart_bot() -> None:
|
||||
"""Перезапуск процесса бота (systemctl или execv)."""
|
||||
await asyncio.sleep(1)
|
||||
try:
|
||||
parent = psutil.Process(os.getpid()).parent()
|
||||
is_systemd = parent and "systemd" in parent.name().lower()
|
||||
if is_systemd:
|
||||
await run_io(lambda: subprocess.run(["sudo", "systemctl", "restart", BOT_SERVICE], check=True))
|
||||
else:
|
||||
python_exe = sys.executable
|
||||
script_path = os.path.abspath(sys.argv[0])
|
||||
os.execv(python_exe, [python_exe, script_path] + sys.argv[1:])
|
||||
except Exception:
|
||||
os._exit(1)
|
||||
|
||||
|
||||
@router.get("/status")
|
||||
async def get_status(identity=Depends(verify_identity_admin)):
|
||||
"""Текущий статус: maintenance и management config."""
|
||||
return {
|
||||
"maintenance_enabled": bool(MANAGEMENT_CONFIG.get("MAINTENANCE_ENABLED", False)),
|
||||
"management": dict(MANAGEMENT_CONFIG or {}),
|
||||
}
|
||||
|
||||
|
||||
@router.post("/maintenance")
|
||||
async def set_maintenance(
|
||||
payload: MaintenanceUpdate,
|
||||
identity=Depends(verify_identity_admin),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
):
|
||||
"""Включение/выключение режима обслуживания."""
|
||||
current_config = dict(MANAGEMENT_CONFIG or {})
|
||||
current_config["MAINTENANCE_ENABLED"] = bool(payload.enabled)
|
||||
await update_management_config(session, current_config)
|
||||
return {"maintenance_enabled": bool(MANAGEMENT_CONFIG.get("MAINTENANCE_ENABLED", False))}
|
||||
|
||||
|
||||
@router.post("/restart")
|
||||
async def restart_bot(
|
||||
background: BackgroundTasks,
|
||||
identity=Depends(verify_identity_admin),
|
||||
):
|
||||
"""Запуск перезапуска бота в фоне."""
|
||||
await _admin_rate_limit(identity, "restart", max_calls=3, window_sec=60)
|
||||
background.add_task(_restart_bot)
|
||||
return {"status": "restarting"}
|
||||
|
||||
|
||||
@router.post("/change-domain")
|
||||
async def change_domain(
|
||||
payload: DomainChange,
|
||||
identity=Depends(verify_identity_admin),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
):
|
||||
"""Массовая замена домена в ключах и remnawave_link."""
|
||||
await _admin_rate_limit(identity, "change_domain", max_calls=3, window_sec=300)
|
||||
domain = payload.domain.strip()
|
||||
if not domain or " " in domain or not re.fullmatch(r"[a-zA-Z0-9.-]+", domain):
|
||||
raise HTTPException(status_code=400, detail="Invalid domain")
|
||||
new_domain_url = f"https://{domain}"
|
||||
stmt = (
|
||||
update(Key)
|
||||
.values(
|
||||
key=func.regexp_replace(Key.key, r"^https://[^/]+", new_domain_url),
|
||||
remnawave_link=func.regexp_replace(Key.remnawave_link, r"^https://[^/]+", new_domain_url),
|
||||
)
|
||||
.where(
|
||||
(Key.key.startswith("https://") & ~Key.key.startswith(new_domain_url))
|
||||
| (Key.remnawave_link.startswith("https://") & ~Key.remnawave_link.startswith(new_domain_url))
|
||||
)
|
||||
)
|
||||
result = await session.execute(stmt)
|
||||
return {"updated": result.rowcount or 0}
|
||||
|
||||
|
||||
@router.post("/restore-trials")
|
||||
async def restore_trials(
|
||||
identity=Depends(verify_identity_admin),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
):
|
||||
"""Сбрасывает trial=0 у пользователей без ключей."""
|
||||
await _admin_rate_limit(identity, "restore_trials", max_calls=3, window_sec=300)
|
||||
stmt = (
|
||||
update(User)
|
||||
.where(
|
||||
User.trial == 1,
|
||||
~exists(select(Key.user_id).where(Key.user_id == User.id)),
|
||||
)
|
||||
.values(trial=0)
|
||||
)
|
||||
result = await session.execute(stmt)
|
||||
return {"restored": result.rowcount or 0}
|
||||
|
||||
|
||||
@router.post("/backup")
|
||||
async def trigger_backup(identity=Depends(verify_identity_admin)):
|
||||
"""Запуск бэкапа БД в фоне."""
|
||||
await _admin_rate_limit(identity, "backup", max_calls=2, window_sec=300)
|
||||
|
||||
async def _run_backup() -> None:
|
||||
exception = await backup_database()
|
||||
if exception:
|
||||
logger.error(f"[Management] Backup finished with error: {exception}")
|
||||
|
||||
asyncio.create_task(_run_backup())
|
||||
return {"status": "backup_started"}
|
||||
|
||||
|
||||
@router.get("/broadcast/clusters")
|
||||
async def get_broadcast_clusters(
|
||||
identity=Depends(verify_identity_admin),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
):
|
||||
"""Список кластеров для рассылки по кластеру."""
|
||||
result = await session.execute(select(distinct(Server.cluster_name)).where(Server.cluster_name.is_not(None)))
|
||||
clusters = sorted([row[0] for row in result.all() if row and row[0]])
|
||||
return {"clusters": clusters}
|
||||
|
||||
|
||||
def _parse_date_range(
|
||||
date: str | None = None,
|
||||
date_from: str | None = None,
|
||||
date_to: str | None = None,
|
||||
) -> tuple[datetime, datetime]:
|
||||
"""Возвращает (date_from, date_to) в UTC. Либо date=YYYY-MM-DD (один день), либо date_from + date_to."""
|
||||
tz = timezone.utc
|
||||
if date:
|
||||
try:
|
||||
d = datetime.strptime(date, "%Y-%m-%d").date()
|
||||
start = datetime(d.year, d.month, d.day, 0, 0, 0, tzinfo=tz)
|
||||
end = start + timedelta(days=1)
|
||||
return start, end
|
||||
except ValueError:
|
||||
raise HTTPException(status_code=400, detail="date должен быть YYYY-MM-DD")
|
||||
if date_from and date_to:
|
||||
try:
|
||||
start = datetime.fromisoformat(date_from.replace("Z", "+00:00"))
|
||||
end = datetime.fromisoformat(date_to.replace("Z", "+00:00"))
|
||||
if start.tzinfo is None:
|
||||
start = start.replace(tzinfo=tz)
|
||||
if end.tzinfo is None:
|
||||
end = end.replace(tzinfo=tz)
|
||||
if start >= end:
|
||||
raise HTTPException(status_code=400, detail="date_from должен быть раньше date_to")
|
||||
return start, end
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=400, detail=f"Неверный формат дат: {e}")
|
||||
end = datetime.now(tz).replace(hour=0, minute=0, second=0, microsecond=0)
|
||||
start = end - timedelta(days=1)
|
||||
return start, end
|
||||
|
||||
|
||||
@router.get("/audit-stats", response_model=AuditStatsResponse)
|
||||
async def get_audit_stats_endpoint(
|
||||
identity=Depends(verify_identity_admin),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
date: str | None = Query(None, description="Один день: YYYY-MM-DD"),
|
||||
date_from: str | None = Query(None, description="Начало периода (ISO)"),
|
||||
date_to: str | None = Query(None, description="Конец периода (ISO)"),
|
||||
):
|
||||
"""Статистика аудита за период: какие пути отрабатывают хорошо/плохо, воронка старт→оплата.
|
||||
Данные только из БД (события из Redis учитываются после drain)."""
|
||||
start, end = _parse_date_range(date=date, date_from=date_from, date_to=date_to)
|
||||
stats = await get_audit_stats(session, date_from=start, date_to=end)
|
||||
funnel = await get_audit_funnel(session, date_from=start, date_to=end)
|
||||
return AuditStatsResponse(
|
||||
summary=stats["summary"],
|
||||
by_path=stats["by_path"],
|
||||
funnel=funnel,
|
||||
)
|
||||
|
||||
|
||||
@router.get("/audit-events", response_model=AuditEventListResponse)
|
||||
async def get_audit_events_history(
|
||||
identity=Depends(verify_identity_admin),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
identity_id: str | None = Query(None, description="Фильтр по identity_id"),
|
||||
tg_id: int | None = Query(None, description="Фильтр по Telegram user id"),
|
||||
channel: str | None = Query(None, description="api или telegram"),
|
||||
event_type: str | None = Query(None, description="Точный event_type"),
|
||||
limit: int = Query(100, ge=1, le=500),
|
||||
offset: int = Query(0, ge=0),
|
||||
):
|
||||
"""История аудита клиента по identity_id и/или tg_id."""
|
||||
if identity_id is None and tg_id is None:
|
||||
raise HTTPException(status_code=400, detail="Укажите identity_id или tg_id")
|
||||
|
||||
events = await list_audit_events(
|
||||
session,
|
||||
identity_id=identity_id,
|
||||
tg_id=tg_id,
|
||||
channel=channel,
|
||||
event_type=event_type,
|
||||
limit=limit,
|
||||
offset=offset,
|
||||
)
|
||||
return AuditEventListResponse(
|
||||
items=[
|
||||
AuditEventResponse(
|
||||
id=getattr(event, "id", None),
|
||||
event_type=event.event_type,
|
||||
channel=event.channel,
|
||||
actor_identity_id=event.actor_identity_id,
|
||||
actor_tg_id=event.actor_tg_id,
|
||||
path_or_handler=event.path_or_handler,
|
||||
entity_type=event.entity_type,
|
||||
entity_id=event.entity_id,
|
||||
result=event.result,
|
||||
reason=event.reason,
|
||||
metadata=event.metadata_,
|
||||
request_id=event.request_id,
|
||||
created_at=event.created_at,
|
||||
)
|
||||
for event in events
|
||||
],
|
||||
limit=limit,
|
||||
offset=offset,
|
||||
)
|
||||
|
||||
|
||||
@router.post("/audit-drain")
|
||||
async def post_audit_drain(identity=Depends(verify_identity_admin_short)):
|
||||
"""Выгружает буфер аудита из Redis в БД. Для вызова по крону (например 0 0 * * * в 00:00)."""
|
||||
try:
|
||||
count = await drain_audit_redis_to_db(async_session_maker)
|
||||
return {"success": True, "drained": count}
|
||||
except Exception as exc:
|
||||
logger.warning("audit-drain failed: {}", exc)
|
||||
raise HTTPException(status_code=500, detail="Внутренняя ошибка при дренаже аудита") from exc
|
||||
|
||||
|
||||
@router.post("/broadcast")
|
||||
async def launch_broadcast(
|
||||
payload: BroadcastLaunchPayload,
|
||||
identity=Depends(verify_identity_admin_short),
|
||||
):
|
||||
"""Запуск рассылки по выбранной аудитории. Сессия БД не держится на время рассылки."""
|
||||
await _admin_rate_limit(identity, "broadcast", max_calls=5, window_sec=300)
|
||||
try:
|
||||
prepared = prepare_broadcast_payload(
|
||||
send_to=payload.send_to,
|
||||
text=payload.text,
|
||||
photo=payload.photo,
|
||||
cluster_name=payload.cluster_name,
|
||||
workers=payload.workers,
|
||||
messages_per_second=payload.messages_per_second,
|
||||
channel=payload.channel,
|
||||
)
|
||||
except ValueError as exc:
|
||||
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
||||
return await execute_broadcast_payload(prepared, bot=_get_broadcast_bot())
|
||||
|
||||
|
||||
@router.post("/broadcast/scheduled")
|
||||
async def create_broadcast_schedule(
|
||||
payload: ScheduledBroadcastCreatePayload,
|
||||
identity=Depends(verify_identity_admin_short),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
):
|
||||
try:
|
||||
prepared = prepare_broadcast_payload(
|
||||
send_to=payload.send_to,
|
||||
text=payload.text,
|
||||
photo=payload.photo,
|
||||
cluster_name=payload.cluster_name,
|
||||
workers=payload.workers,
|
||||
messages_per_second=payload.messages_per_second,
|
||||
channel=payload.channel,
|
||||
)
|
||||
except ValueError as exc:
|
||||
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
||||
broadcast = await create_scheduled_broadcast(
|
||||
session,
|
||||
created_by_tg_id=getattr(identity, "tg_id", None),
|
||||
send_to=prepared["send_to"],
|
||||
channel=prepared["channel"],
|
||||
cluster_name=prepared["cluster_name"],
|
||||
text=prepared["text"],
|
||||
photo=prepared["photo"],
|
||||
keyboard_json=prepared["keyboard_json"],
|
||||
scheduled_for=_require_future_schedule(payload.scheduled_for),
|
||||
workers=prepared["workers"],
|
||||
messages_per_second=prepared["messages_per_second"],
|
||||
)
|
||||
return {"success": True, "item": scheduled_broadcast_to_dict(broadcast)}
|
||||
|
||||
|
||||
@router.get("/broadcast/scheduled")
|
||||
async def list_broadcast_schedules(
|
||||
identity=Depends(verify_identity_admin),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
status: str | None = Query(None, description="Фильтр статусов через запятую"),
|
||||
limit: int = Query(20, ge=1, le=100),
|
||||
offset: int = Query(0, ge=0),
|
||||
):
|
||||
statuses = [item.strip() for item in (status or "").split(",") if item.strip()] or None
|
||||
items = await list_scheduled_broadcasts(session, statuses=statuses, limit=limit, offset=offset)
|
||||
return {"items": [scheduled_broadcast_to_dict(item) for item in items], "limit": limit, "offset": offset}
|
||||
|
||||
|
||||
@router.get("/broadcast/scheduled/{broadcast_id}")
|
||||
async def get_broadcast_schedule(
|
||||
broadcast_id: str,
|
||||
identity=Depends(verify_identity_admin),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
):
|
||||
item = await get_scheduled_broadcast(session, broadcast_id)
|
||||
if item is None:
|
||||
raise HTTPException(status_code=404, detail="Scheduled broadcast not found")
|
||||
return {"item": scheduled_broadcast_to_dict(item)}
|
||||
|
||||
|
||||
@router.patch("/broadcast/scheduled/{broadcast_id}")
|
||||
async def update_broadcast_schedule(
|
||||
broadcast_id: str,
|
||||
payload: ScheduledBroadcastUpdatePayload,
|
||||
identity=Depends(verify_identity_admin_short),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
):
|
||||
current = await get_scheduled_broadcast(session, broadcast_id)
|
||||
if current is None:
|
||||
raise HTTPException(status_code=404, detail="Scheduled broadcast not found")
|
||||
try:
|
||||
values = _resolve_update_payload(payload, current)
|
||||
except ValueError as exc:
|
||||
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
||||
updated = await update_scheduled_broadcast(session, broadcast_id, **values)
|
||||
if updated is None:
|
||||
raise HTTPException(status_code=409, detail="Scheduled broadcast can no longer be edited")
|
||||
return {"success": True, "item": scheduled_broadcast_to_dict(updated)}
|
||||
|
||||
|
||||
@router.post("/broadcast/scheduled/{broadcast_id}/cancel")
|
||||
async def cancel_broadcast_schedule(
|
||||
broadcast_id: str,
|
||||
identity=Depends(verify_identity_admin_short),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
):
|
||||
item = await cancel_scheduled_broadcast(session, broadcast_id)
|
||||
if item is None:
|
||||
raise HTTPException(status_code=409, detail="Scheduled broadcast can no longer be cancelled")
|
||||
return {"success": True, "item": scheduled_broadcast_to_dict(item)}
|
||||
|
||||
|
||||
@router.post("/broadcast/scheduled/{broadcast_id}/send-now")
|
||||
async def send_broadcast_schedule_now(
|
||||
broadcast_id: str,
|
||||
identity=Depends(verify_identity_admin_short),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
):
|
||||
await _admin_rate_limit(identity, "broadcast_now", max_calls=5, window_sec=300)
|
||||
item = await start_scheduled_broadcast(session, broadcast_id)
|
||||
if item is None:
|
||||
raise HTTPException(status_code=409, detail="Scheduled broadcast can no longer be sent now")
|
||||
try:
|
||||
result = await execute_scheduled_broadcast(item, bot=_get_broadcast_bot())
|
||||
except Exception as exc:
|
||||
logger.error("[Broadcast] send-now failed for {}: {}", broadcast_id, exc)
|
||||
await mark_scheduled_broadcast_failed(session, broadcast_id, str(exc))
|
||||
raise HTTPException(status_code=500, detail="Ошибка при выполнении рассылки") from exc
|
||||
if result.get("success"):
|
||||
item = await mark_scheduled_broadcast_sent(session, broadcast_id, result)
|
||||
else:
|
||||
item = await mark_scheduled_broadcast_failed(session, broadcast_id, result.get("message", "Broadcast failed"))
|
||||
return {"success": bool(result.get("success")), "item": scheduled_broadcast_to_dict(item), "result": result}
|
||||
@@ -1,165 +0,0 @@
|
||||
from fastapi import APIRouter, Depends, HTTPException, Path
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from api.depends import get_session, verify_identity_admin
|
||||
from api.v2.base_crud import generate_crud_router
|
||||
from api.v2.schemas import (
|
||||
BlockedUserResponse,
|
||||
ManualBanResponse,
|
||||
NotificationResponse,
|
||||
PaymentResponse,
|
||||
TemporaryDataResponse,
|
||||
TrackingSourceResponse,
|
||||
)
|
||||
from database import get_tracking_source_stats
|
||||
from database.access.resolution import resolve_user_optional
|
||||
from database.models import (
|
||||
BlockedUser,
|
||||
ManualBan,
|
||||
Notification,
|
||||
Payment,
|
||||
TemporaryData,
|
||||
TrackingSource,
|
||||
)
|
||||
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
router.include_router(
|
||||
generate_crud_router(
|
||||
model=Payment,
|
||||
schema_response=PaymentResponse,
|
||||
schema_create=None,
|
||||
schema_update=None,
|
||||
identifier_field="id",
|
||||
enabled_methods=["get_all", "get_one", "delete"],
|
||||
),
|
||||
prefix="/payments",
|
||||
tags=["Payments"],
|
||||
dependencies=[Depends(verify_identity_admin)],
|
||||
)
|
||||
|
||||
|
||||
@router.get("/payments/by_tg_id/{tg_id}", response_model=list[PaymentResponse], tags=["Payments"])
|
||||
async def get_payments_by_tg_id(
|
||||
tg_id: int = Path(...),
|
||||
identity=Depends(verify_identity_admin),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
):
|
||||
"""Список платежей по tg_id пользователя."""
|
||||
u = await resolve_user_optional(session, tg_id)
|
||||
if u is None:
|
||||
raise HTTPException(status_code=404, detail="Payments not found")
|
||||
result = await session.execute(select(Payment).where(Payment.user_id == u.id))
|
||||
payments = result.scalars().all()
|
||||
if not payments:
|
||||
raise HTTPException(status_code=404, detail="Payments not found")
|
||||
return payments
|
||||
|
||||
|
||||
router.include_router(
|
||||
generate_crud_router(
|
||||
model=Notification,
|
||||
schema_response=NotificationResponse,
|
||||
schema_create=None,
|
||||
schema_update=None,
|
||||
identifier_field="user_id",
|
||||
parameter_name="tg_id",
|
||||
telegram_path_to_user_id=True,
|
||||
enabled_methods=["get_all", "get_one", "delete"],
|
||||
),
|
||||
prefix="/notifications",
|
||||
tags=["Notifications"],
|
||||
dependencies=[Depends(verify_identity_admin)],
|
||||
)
|
||||
|
||||
router.include_router(
|
||||
generate_crud_router(
|
||||
model=ManualBan,
|
||||
schema_response=ManualBanResponse,
|
||||
schema_create=None,
|
||||
schema_update=None,
|
||||
identifier_field="user_id",
|
||||
parameter_name="tg_id",
|
||||
telegram_path_to_user_id=True,
|
||||
enabled_methods=["get_all", "get_one", "delete"],
|
||||
),
|
||||
prefix="/manual-bans",
|
||||
tags=["Bans"],
|
||||
dependencies=[Depends(verify_identity_admin)],
|
||||
)
|
||||
|
||||
router.include_router(
|
||||
generate_crud_router(
|
||||
model=BlockedUser,
|
||||
schema_response=BlockedUserResponse,
|
||||
schema_create=None,
|
||||
schema_update=None,
|
||||
identifier_field="user_id",
|
||||
parameter_name="tg_id",
|
||||
telegram_path_to_user_id=True,
|
||||
enabled_methods=["get_all", "get_one", "delete"],
|
||||
),
|
||||
prefix="/blocked-users",
|
||||
tags=["Bans"],
|
||||
dependencies=[Depends(verify_identity_admin)],
|
||||
)
|
||||
|
||||
router.include_router(
|
||||
generate_crud_router(
|
||||
model=TemporaryData,
|
||||
schema_response=TemporaryDataResponse,
|
||||
schema_create=None,
|
||||
schema_update=None,
|
||||
identifier_field="user_id",
|
||||
parameter_name="tg_id",
|
||||
telegram_path_to_user_id=True,
|
||||
enabled_methods=["get_all", "get_one", "delete"],
|
||||
),
|
||||
prefix="/temporary-data",
|
||||
tags=["TemporaryData"],
|
||||
dependencies=[Depends(verify_identity_admin)],
|
||||
)
|
||||
|
||||
router.include_router(
|
||||
generate_crud_router(
|
||||
model=TrackingSource,
|
||||
schema_response=TrackingSourceResponse,
|
||||
schema_create=None,
|
||||
schema_update=None,
|
||||
identifier_field="id",
|
||||
enabled_methods=["get_all", "delete"],
|
||||
),
|
||||
prefix="/tracking-sources",
|
||||
tags=["TrackingSources"],
|
||||
dependencies=[Depends(verify_identity_admin)],
|
||||
)
|
||||
|
||||
|
||||
@router.get(
|
||||
"/tracking-sources/{code}", response_model=TrackingSourceResponse, dependencies=[Depends(verify_identity_admin)]
|
||||
)
|
||||
async def get_tracking_source_with_stats(
|
||||
code: str,
|
||||
session: AsyncSession = Depends(get_session),
|
||||
):
|
||||
"""Источник по коду со статистикой регистраций и платежей."""
|
||||
result = await session.execute(select(TrackingSource).where(TrackingSource.code == code))
|
||||
source = result.scalar_one_or_none()
|
||||
if not source:
|
||||
raise HTTPException(status_code=404, detail="Tracking source not found")
|
||||
stats = await get_tracking_source_stats(session, code)
|
||||
return TrackingSourceResponse(
|
||||
id=source.id,
|
||||
name=source.name,
|
||||
code=source.code,
|
||||
type=source.type,
|
||||
created_by=source.created_by,
|
||||
created_at=source.created_at,
|
||||
registrations=(stats["registrations"] if stats else 0),
|
||||
trials=(stats["trials"] if stats else 0),
|
||||
payments=(stats["payments"] if stats else 0),
|
||||
total_amount=(float(stats["total_amount"]) if stats else 0.0),
|
||||
monthly=(stats["monthly"] if stats and "monthly" in stats else []),
|
||||
)
|
||||
@@ -1,125 +0,0 @@
|
||||
import pkgutil
|
||||
|
||||
from pathlib import Path
|
||||
from typing import Literal
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from pydantic import BaseModel
|
||||
|
||||
from api.depends import verify_identity_admin
|
||||
from core.executor import run_io
|
||||
from logger import logger
|
||||
from utils.modules_loader import _is_safe_module_name
|
||||
from utils.modules_manager import manager
|
||||
|
||||
|
||||
router = APIRouter(prefix="/modules", tags=["Modules"])
|
||||
|
||||
MODULES_DIR = Path(__file__).resolve().parents[3] / "modules"
|
||||
|
||||
|
||||
class ModuleAction(BaseModel):
|
||||
action: Literal["start", "stop", "restart"]
|
||||
|
||||
|
||||
def _available_module_names() -> list[str]:
|
||||
"""Имена модулей из папки modules, прошедшие проверку безопасности."""
|
||||
candidates: set[str] = set()
|
||||
if MODULES_DIR.is_dir():
|
||||
for _finder, name, _ispkg in pkgutil.iter_modules([str(MODULES_DIR)]):
|
||||
name = (name or "").strip()
|
||||
if name and _is_safe_module_name(name):
|
||||
candidates.add(name)
|
||||
return sorted(n for n in candidates if _is_safe_module_name(n))
|
||||
|
||||
|
||||
def _prune_missing_state(installed: set[str]) -> None:
|
||||
"""Удаляет из состояния менеджера модули, которых нет в файловой системе."""
|
||||
changed = False
|
||||
stale_disabled = {name for name in manager.disabled if name not in installed}
|
||||
if stale_disabled:
|
||||
for name in stale_disabled:
|
||||
manager.disabled.discard(name)
|
||||
changed = True
|
||||
stale_registry = [name for name in list(manager.registry.keys()) if name not in installed]
|
||||
if stale_registry:
|
||||
for name in stale_registry:
|
||||
manager.registry.pop(name, None)
|
||||
changed = True
|
||||
if changed:
|
||||
save_state = getattr(manager, "_save_state", None)
|
||||
if callable(save_state):
|
||||
save_state()
|
||||
|
||||
|
||||
def _module_state(name: str) -> dict:
|
||||
"""Состояние модуля: enabled, loaded, autostart."""
|
||||
normalized = name.strip()
|
||||
record = manager.registry.get(normalized)
|
||||
is_enabled = manager.is_enabled(normalized)
|
||||
return {
|
||||
"name": normalized,
|
||||
"enabled": is_enabled,
|
||||
"loaded": bool(record and record.enabled),
|
||||
"autostart": manager.should_autostart(normalized),
|
||||
}
|
||||
|
||||
|
||||
def _read_local_module_version(name: str) -> str | None:
|
||||
"""Читает VERSION из папки модуля."""
|
||||
version_file = MODULES_DIR / name / "VERSION"
|
||||
if not version_file.exists() or not version_file.is_file():
|
||||
return None
|
||||
try:
|
||||
with version_file.open("r", encoding="utf-8") as handle:
|
||||
for line in handle:
|
||||
value = line.strip()
|
||||
if value:
|
||||
return value
|
||||
except Exception:
|
||||
return None
|
||||
return None
|
||||
|
||||
|
||||
def sync_list_modules() -> list:
|
||||
"""Вся синхронная работа со списком модулей (файлы, состояние). Вызывать через run_io()."""
|
||||
refresh = getattr(manager, "refresh_state", None) or getattr(manager, "_load_state", None)
|
||||
if callable(refresh):
|
||||
refresh()
|
||||
module_names = _available_module_names()
|
||||
_prune_missing_state(set(module_names))
|
||||
modules = [_module_state(name) for name in module_names]
|
||||
for item in modules:
|
||||
name = str(item.get("name") or "").strip()
|
||||
item["local_version"] = _read_local_module_version(name)
|
||||
return modules
|
||||
|
||||
|
||||
@router.get("/")
|
||||
async def list_modules(identity=Depends(verify_identity_admin)):
|
||||
"""Список модулей с состоянием и локальной версией."""
|
||||
modules = await run_io(sync_list_modules)
|
||||
return {"items": modules}
|
||||
|
||||
|
||||
@router.post("/{module_name}/actions")
|
||||
async def control_module(module_name: str, payload: ModuleAction, identity=Depends(verify_identity_admin)):
|
||||
"""Запуск, остановка или перезапуск модуля."""
|
||||
name = (module_name or "").strip()
|
||||
if not _is_safe_module_name(name):
|
||||
raise HTTPException(status_code=404, detail="Module not found")
|
||||
try:
|
||||
if payload.action == "start":
|
||||
await manager.start(name)
|
||||
elif payload.action == "stop":
|
||||
await manager.stop(name)
|
||||
elif payload.action == "restart":
|
||||
await manager.restart(name)
|
||||
else:
|
||||
raise HTTPException(status_code=400, detail="Unsupported action")
|
||||
except ValueError as exc:
|
||||
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
||||
except RuntimeError as exc:
|
||||
logger.error("[Modules] action failed for {}: {}", name, exc)
|
||||
raise HTTPException(status_code=500, detail="Ошибка при выполнении операции модуля") from exc
|
||||
return {"item": _module_state(name)}
|
||||
@@ -1,112 +0,0 @@
|
||||
from fastapi import APIRouter, Depends, HTTPException, Path, Query
|
||||
from pydantic import BaseModel
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from api.depends import get_session, verify_identity_token
|
||||
from database import web_notifications as wn_db
|
||||
from database.models import Identity
|
||||
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
class PushSubscribeRequest(BaseModel):
|
||||
endpoint: str
|
||||
keys: dict
|
||||
|
||||
|
||||
class NotificationItem(BaseModel):
|
||||
id: str
|
||||
type: str
|
||||
title: str
|
||||
message: str
|
||||
read: bool
|
||||
created_at: str
|
||||
data: dict | None = None
|
||||
|
||||
|
||||
class NotificationsResponse(BaseModel):
|
||||
ok: bool = True
|
||||
notifications: list[NotificationItem]
|
||||
unread_count: int
|
||||
|
||||
|
||||
@router.post("/push/subscribe", tags=["Notifications"])
|
||||
async def push_subscribe(
|
||||
body: PushSubscribeRequest,
|
||||
session: AsyncSession = Depends(get_session),
|
||||
identity: Identity = Depends(verify_identity_token),
|
||||
):
|
||||
user_id = identity.tg_id or 0
|
||||
|
||||
await wn_db.upsert_push_subscription(
|
||||
session,
|
||||
user_id=user_id,
|
||||
identity_id=identity.id,
|
||||
endpoint=body.endpoint,
|
||||
keys_json=body.keys,
|
||||
)
|
||||
return {"ok": True}
|
||||
|
||||
|
||||
@router.get("/notifications", response_model=NotificationsResponse, tags=["Notifications"])
|
||||
async def get_notifications(
|
||||
limit: int = Query(20, ge=1, le=100),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
identity: Identity = Depends(verify_identity_token),
|
||||
):
|
||||
notifications = await wn_db.get_notifications_for_identity(
|
||||
session,
|
||||
identity.id,
|
||||
limit=limit,
|
||||
)
|
||||
unread_count = await wn_db.count_unread_for_identity(session, identity.id)
|
||||
|
||||
items = [
|
||||
NotificationItem(
|
||||
id=n.id,
|
||||
type=n.type,
|
||||
title=n.title,
|
||||
message=n.message,
|
||||
read=n.read,
|
||||
created_at=n.created_at.isoformat() if n.created_at else "",
|
||||
data=n.data,
|
||||
)
|
||||
for n in notifications
|
||||
]
|
||||
return NotificationsResponse(notifications=items, unread_count=unread_count)
|
||||
|
||||
|
||||
@router.post("/notifications/read-all", tags=["Notifications"])
|
||||
async def read_all_notifications(
|
||||
session: AsyncSession = Depends(get_session),
|
||||
identity: Identity = Depends(verify_identity_token),
|
||||
):
|
||||
count = await wn_db.mark_all_read_for_identity(session, identity.id)
|
||||
return {"ok": True, "updated": count}
|
||||
|
||||
|
||||
@router.post("/notifications/{notification_id}/read", tags=["Notifications"])
|
||||
async def read_one_notification(
|
||||
notification_id: str = Path(..., min_length=1, max_length=64),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
identity: Identity = Depends(verify_identity_token),
|
||||
):
|
||||
"""Пометить одно уведомление прочитанным. 404 если не найдено или не принадлежит юзеру."""
|
||||
ok = await wn_db.mark_one_read_for_identity(session, identity.id, notification_id)
|
||||
if not ok:
|
||||
raise HTTPException(status_code=404, detail="Уведомление не найдено")
|
||||
return {"ok": True}
|
||||
|
||||
|
||||
@router.delete("/notifications/{notification_id}", tags=["Notifications"])
|
||||
async def delete_one_notification(
|
||||
notification_id: str = Path(..., min_length=1, max_length=64),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
identity: Identity = Depends(verify_identity_token),
|
||||
):
|
||||
"""Удалить одно уведомление. 404 если не найдено или не принадлежит юзеру."""
|
||||
ok = await wn_db.delete_one_for_identity(session, identity.id, notification_id)
|
||||
if not ok:
|
||||
raise HTTPException(status_code=404, detail="Уведомление не найдено")
|
||||
return {"ok": True}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,272 +0,0 @@
|
||||
import asyncio
|
||||
import json
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Request
|
||||
from fastapi.responses import StreamingResponse
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from api.depends import get_session, verify_identity_token
|
||||
from api.v2.schemas.payment_links import PaymentLinkCreateRequest, PaymentLinkCreateResponse, PaymentLinkStatusResponse
|
||||
from config import REDIS_URL
|
||||
from database import (
|
||||
async_session_maker,
|
||||
get_payment_by_payment_id,
|
||||
get_payment_from_db_by_payment_id,
|
||||
identities as idb,
|
||||
)
|
||||
from database.temporary_data import create_temporary_data
|
||||
from logger import logger
|
||||
from services.payments.payment_events import payment_events_channel
|
||||
from services.payments.payment_links import PaymentLinkRequest, create_payment_link
|
||||
|
||||
|
||||
router = APIRouter(tags=["PaymentLinks"])
|
||||
|
||||
|
||||
async def _store_payment_intent(
|
||||
session: AsyncSession,
|
||||
billing_user_ref: int,
|
||||
metadata: dict | None,
|
||||
amount: int | float,
|
||||
) -> None:
|
||||
if not isinstance(metadata, dict):
|
||||
return
|
||||
payment_flow = str(metadata.get("payment_flow") or "").strip().lower()
|
||||
required_amount = int(round(float(amount)))
|
||||
if payment_flow == "tariff_purchase":
|
||||
tariff_id = metadata.get("tariff_id")
|
||||
if tariff_id in (None, ""):
|
||||
return
|
||||
payload: dict[str, int | str] = {
|
||||
"tariff_id": int(tariff_id),
|
||||
"required_amount": required_amount,
|
||||
"selected_price_rub": int(metadata.get("selected_price_rub") or required_amount),
|
||||
}
|
||||
selected_device_limit = metadata.get("selected_device_limit")
|
||||
if selected_device_limit not in (None, ""):
|
||||
payload["selected_device_limit"] = int(selected_device_limit)
|
||||
selected_traffic_gb = metadata.get("selected_traffic_gb")
|
||||
if selected_traffic_gb not in (None, ""):
|
||||
payload["selected_traffic_limit_gb"] = int(selected_traffic_gb)
|
||||
selected_duration_days = metadata.get("selected_duration_days")
|
||||
if selected_duration_days not in (None, ""):
|
||||
payload["selected_duration_days"] = int(selected_duration_days)
|
||||
coupon_id = metadata.get("coupon_id")
|
||||
if coupon_id not in (None, ""):
|
||||
payload["coupon_id"] = int(coupon_id)
|
||||
discount_rub = metadata.get("discount_rub")
|
||||
if discount_rub not in (None, ""):
|
||||
payload["discount_rub"] = int(discount_rub)
|
||||
base_price_rub = metadata.get("base_price_rub")
|
||||
if base_price_rub not in (None, ""):
|
||||
payload["base_price_rub"] = int(base_price_rub)
|
||||
applied_coupon_code = metadata.get("applied_coupon_code")
|
||||
if applied_coupon_code not in (None, ""):
|
||||
payload["applied_coupon_code"] = str(applied_coupon_code)
|
||||
await create_temporary_data(session, billing_user_ref, "waiting_for_payment", payload)
|
||||
return
|
||||
if payment_flow == "key_renewal":
|
||||
required_fields = ("tariff_id", "client_id", "email", "cost")
|
||||
if any(metadata.get(field) in (None, "") for field in required_fields):
|
||||
return
|
||||
payload: dict[str, int | str] = {
|
||||
"tariff_id": int(metadata["tariff_id"]),
|
||||
"client_id": str(metadata["client_id"]),
|
||||
"email": str(metadata["email"]),
|
||||
"cost": int(metadata["cost"]),
|
||||
"required_amount": required_amount,
|
||||
"selected_price_rub": int(metadata.get("selected_price_rub") or metadata["cost"]),
|
||||
}
|
||||
selected_duration_days = metadata.get("selected_duration_days")
|
||||
if selected_duration_days not in (None, ""):
|
||||
payload["selected_duration_days"] = int(selected_duration_days)
|
||||
selected_device_limit = metadata.get("selected_device_limit")
|
||||
if selected_device_limit not in (None, ""):
|
||||
payload["selected_device_limit"] = int(selected_device_limit)
|
||||
selected_traffic_limit = metadata.get("selected_traffic_limit")
|
||||
if selected_traffic_limit not in (None, ""):
|
||||
payload["selected_traffic_limit"] = int(selected_traffic_limit)
|
||||
total_gb = metadata.get("total_gb")
|
||||
if total_gb not in (None, ""):
|
||||
payload["total_gb"] = int(total_gb)
|
||||
coupon_id = metadata.get("coupon_id")
|
||||
if coupon_id not in (None, ""):
|
||||
payload["coupon_id"] = int(coupon_id)
|
||||
discount_rub = metadata.get("discount_rub")
|
||||
if discount_rub not in (None, ""):
|
||||
payload["discount_rub"] = int(discount_rub)
|
||||
base_price_rub = metadata.get("base_price_rub")
|
||||
if base_price_rub not in (None, ""):
|
||||
payload["base_price_rub"] = int(base_price_rub)
|
||||
applied_coupon_code = metadata.get("applied_coupon_code")
|
||||
if applied_coupon_code not in (None, ""):
|
||||
payload["applied_coupon_code"] = str(applied_coupon_code)
|
||||
await create_temporary_data(session, billing_user_ref, "waiting_for_renewal_payment", payload)
|
||||
return
|
||||
if payment_flow == "key_addons":
|
||||
required_fields = ("tariff_id", "email", "original_price")
|
||||
if any(metadata.get(field) in (None, "") for field in required_fields):
|
||||
return
|
||||
payload: dict[str, int | str] = {
|
||||
"tariff_id": int(metadata["tariff_id"]),
|
||||
"email": str(metadata["email"]),
|
||||
"original_price": int(metadata["original_price"]),
|
||||
"required_amount": required_amount,
|
||||
}
|
||||
selected_device_limit = metadata.get("selected_device_limit")
|
||||
if selected_device_limit not in (None, ""):
|
||||
payload["selected_device_limit"] = int(selected_device_limit)
|
||||
selected_traffic_gb = metadata.get("selected_traffic_gb")
|
||||
if selected_traffic_gb not in (None, ""):
|
||||
payload["selected_traffic_gb"] = int(selected_traffic_gb)
|
||||
current_device_limit = metadata.get("current_device_limit")
|
||||
if current_device_limit not in (None, ""):
|
||||
payload["current_device_limit"] = int(current_device_limit)
|
||||
current_traffic_gb = metadata.get("current_traffic_gb")
|
||||
if current_traffic_gb not in (None, ""):
|
||||
payload["current_traffic_gb"] = int(current_traffic_gb)
|
||||
coupon_id = metadata.get("coupon_id")
|
||||
if coupon_id not in (None, ""):
|
||||
payload["coupon_id"] = int(coupon_id)
|
||||
discount_rub = metadata.get("discount_rub")
|
||||
if discount_rub not in (None, ""):
|
||||
payload["discount_rub"] = int(discount_rub)
|
||||
base_price_rub = metadata.get("base_price_rub")
|
||||
if base_price_rub not in (None, ""):
|
||||
payload["base_price_rub"] = int(base_price_rub)
|
||||
applied_coupon_code = metadata.get("applied_coupon_code")
|
||||
if applied_coupon_code not in (None, ""):
|
||||
payload["applied_coupon_code"] = str(applied_coupon_code)
|
||||
await create_temporary_data(session, billing_user_ref, "waiting_for_addons_payment", payload)
|
||||
|
||||
|
||||
@router.post("/", response_model=PaymentLinkCreateResponse)
|
||||
async def create_link(
|
||||
body: PaymentLinkCreateRequest,
|
||||
http_request: Request,
|
||||
session: AsyncSession = Depends(get_session),
|
||||
identity=Depends(verify_identity_token),
|
||||
):
|
||||
"""Создаёт платёжную ссылку для текущего авторизованного пользователя."""
|
||||
billing_user_ref = await idb.ensure_billing_user_for_identity(session, identity)
|
||||
payment_request = PaymentLinkRequest(
|
||||
legacy_user_ref=billing_user_ref,
|
||||
amount=body.amount,
|
||||
currency=body.currency or "RUB",
|
||||
provider_id=body.provider_id,
|
||||
success_url=body.success_url,
|
||||
failure_url=body.failure_url,
|
||||
metadata=body.metadata,
|
||||
)
|
||||
result = await create_payment_link(session, payment_request)
|
||||
if result.success:
|
||||
await _store_payment_intent(
|
||||
session=session,
|
||||
billing_user_ref=billing_user_ref,
|
||||
metadata=body.metadata,
|
||||
amount=body.amount,
|
||||
)
|
||||
return PaymentLinkCreateResponse(
|
||||
success=result.success,
|
||||
payment_id=result.payment_id,
|
||||
payment_url=result.payment_url,
|
||||
error=result.error,
|
||||
)
|
||||
|
||||
|
||||
@router.get("/stream")
|
||||
async def payment_events_stream(
|
||||
request: Request,
|
||||
x_identity_id: str = "",
|
||||
token: str = "",
|
||||
):
|
||||
identity_id = str(request.headers.get("X-Identity-Id") or x_identity_id or "").strip()
|
||||
token = str(request.headers.get("X-Token") or token or "").strip()
|
||||
if not identity_id or not token:
|
||||
raise HTTPException(status_code=401, detail="Unauthorized")
|
||||
|
||||
async with async_session_maker() as session:
|
||||
identity = await idb.verify_identity_token(session, identity_id, token)
|
||||
if not identity:
|
||||
raise HTTPException(status_code=401, detail="Unauthorized")
|
||||
billing_user_ref = await idb.ensure_billing_user_for_identity(session, identity)
|
||||
await session.commit()
|
||||
|
||||
async def event_generator():
|
||||
redis_client = None
|
||||
pubsub = None
|
||||
channel = payment_events_channel(int(billing_user_ref))
|
||||
try:
|
||||
from redis.asyncio import from_url
|
||||
|
||||
redis_client = from_url(REDIS_URL, encoding="utf-8", decode_responses=True, max_connections=8)
|
||||
pubsub = redis_client.pubsub(ignore_subscribe_messages=True)
|
||||
await pubsub.subscribe(channel)
|
||||
logger.info(f"[Payments] SSE subscribed: user_ref={billing_user_ref}, channel={channel}")
|
||||
yield "retry: 1500\n\n"
|
||||
while True:
|
||||
if await request.is_disconnected():
|
||||
logger.info(f"[Payments] SSE disconnected by client: user_ref={billing_user_ref}")
|
||||
break
|
||||
message = await pubsub.get_message(ignore_subscribe_messages=True, timeout=15.0)
|
||||
if message and message.get("type") == "message":
|
||||
raw_data = message.get("data")
|
||||
payload = json.loads(raw_data) if isinstance(raw_data, str) else raw_data
|
||||
if isinstance(payload, dict):
|
||||
logger.info(
|
||||
f"[Payments] SSE emit: user_ref={billing_user_ref}, "
|
||||
f"status={payload.get('status')}, flow={payload.get('flow')}"
|
||||
)
|
||||
yield f"data: {json.dumps(payload, ensure_ascii=False)}\n\n"
|
||||
continue
|
||||
yield ": keepalive\n\n"
|
||||
await asyncio.sleep(0.1)
|
||||
finally:
|
||||
if pubsub is not None:
|
||||
try:
|
||||
await pubsub.unsubscribe(channel)
|
||||
await pubsub.close()
|
||||
except Exception:
|
||||
pass
|
||||
if redis_client is not None:
|
||||
try:
|
||||
await redis_client.aclose()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return StreamingResponse(
|
||||
event_generator(),
|
||||
media_type="text/event-stream",
|
||||
headers={
|
||||
"Cache-Control": "no-cache, no-transform",
|
||||
"Connection": "keep-alive",
|
||||
"X-Accel-Buffering": "no",
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@router.get("/{payment_id}", response_model=PaymentLinkStatusResponse)
|
||||
async def get_link_status(
|
||||
payment_id: str,
|
||||
session: AsyncSession = Depends(get_session),
|
||||
identity=Depends(verify_identity_token),
|
||||
):
|
||||
billing_user_ref = await idb.ensure_billing_user_for_identity(session, identity)
|
||||
payment = await get_payment_from_db_by_payment_id(session, payment_id)
|
||||
if payment is None:
|
||||
payment = await get_payment_by_payment_id(session, payment_id)
|
||||
if not payment:
|
||||
raise HTTPException(status_code=404, detail="Payment not found")
|
||||
owner_ref = payment.get("user_id")
|
||||
if owner_ref is None:
|
||||
owner_ref = payment.get("tg_id")
|
||||
if owner_ref is None or int(owner_ref) != int(billing_user_ref):
|
||||
raise HTTPException(status_code=404, detail="Payment not found")
|
||||
status = str(payment.get("status") or "").lower() or None
|
||||
return PaymentLinkStatusResponse(
|
||||
success=True,
|
||||
payment_id=payment_id,
|
||||
status=status,
|
||||
completed=status in {"success", "failed", "cancelled"},
|
||||
paid=status == "success",
|
||||
)
|
||||
@@ -1,250 +0,0 @@
|
||||
from base64 import b64encode
|
||||
from io import BytesIO
|
||||
from urllib.parse import urlsplit
|
||||
|
||||
import qrcode
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, Request
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from api.depends import get_session, verify_identity_token
|
||||
from api.v2.schemas.web_public import (
|
||||
ReferralApplyRequest,
|
||||
ReferralApplyResponse,
|
||||
ReferralConditionsResponse,
|
||||
ReferralListEntry,
|
||||
ReferralListResponse,
|
||||
ReferralQrResponse,
|
||||
ReferralTopEntryResponse,
|
||||
ReferralTopResponse,
|
||||
)
|
||||
from sqlalchemy import select
|
||||
from database.models import Referral
|
||||
from config import (
|
||||
CHECK_REFERRAL_REWARD_ISSUED,
|
||||
REFERRAL_BONUS_PERCENTAGES,
|
||||
REFERRAL_BUTTON,
|
||||
REFERRAL_QR,
|
||||
TOP_REFERRAL_BUTTON,
|
||||
)
|
||||
from core.bootstrap import BUTTONS_CONFIG
|
||||
from database import (
|
||||
add_referral,
|
||||
get_referral_by_referred_id,
|
||||
get_user_referral_count,
|
||||
identities as idb,
|
||||
)
|
||||
from database.access.resolution import resolve_user_optional
|
||||
from database.referrals import get_referral_position, get_top_referrals
|
||||
from utils.referral_codes import decode_referral_code, encode_referral_code
|
||||
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
def _normalize_referrer_code(value: str | None, fallback_tg_id: int | None) -> int | None:
|
||||
raw = str(value or "").strip()
|
||||
if raw:
|
||||
if "/referral/" in raw:
|
||||
raw = raw.split("/referral/", 1)[-1]
|
||||
if "start=referral_" in raw:
|
||||
raw = raw.split("start=referral_", 1)[-1]
|
||||
raw = raw.split("?", 1)[0].split("#", 1)[0].strip()
|
||||
parsed = decode_referral_code(raw)
|
||||
if parsed is not None:
|
||||
return parsed
|
||||
if fallback_tg_id is not None and int(fallback_tg_id) > 0:
|
||||
return int(fallback_tg_id)
|
||||
return None
|
||||
|
||||
|
||||
def _resolve_public_base_url(request: Request) -> str:
|
||||
origin = str(request.headers.get("origin") or "").strip()
|
||||
if origin.startswith(("http://", "https://")):
|
||||
return origin.rstrip("/")
|
||||
referer = str(request.headers.get("referer") or request.headers.get("referrer") or "").strip()
|
||||
if referer.startswith(("http://", "https://")):
|
||||
parsed = urlsplit(referer)
|
||||
if parsed.scheme and parsed.netloc:
|
||||
return f"{parsed.scheme}://{parsed.netloc}".rstrip("/")
|
||||
forwarded_host = str(request.headers.get("x-forwarded-host") or "").strip()
|
||||
host = forwarded_host or str(request.headers.get("host") or "").strip()
|
||||
forwarded_proto = str(request.headers.get("x-forwarded-proto") or "").split(",", 1)[0].strip().lower()
|
||||
scheme = forwarded_proto if forwarded_proto in {"http", "https"} else request.url.scheme
|
||||
if host:
|
||||
return f"{scheme}://{host}".rstrip("/")
|
||||
return str(request.base_url).rstrip("/")
|
||||
|
||||
|
||||
@router.post("/apply", response_model=ReferralApplyResponse, tags=["Referrals"])
|
||||
async def apply_referral(
|
||||
body: ReferralApplyRequest,
|
||||
session: AsyncSession = Depends(get_session),
|
||||
identity=Depends(verify_identity_token),
|
||||
):
|
||||
if not bool(BUTTONS_CONFIG.get("REFERRAL_BUTTON_ENABLED", REFERRAL_BUTTON)):
|
||||
raise HTTPException(status_code=403, detail="Реферальная программа отключена")
|
||||
billing_uid = await idb.ensure_billing_user_for_identity(session, identity)
|
||||
referrer_legacy = _normalize_referrer_code(body.referrer_code, body.referrer_tg_id)
|
||||
if referrer_legacy is None:
|
||||
raise HTTPException(status_code=400, detail="Приглашение недействительно")
|
||||
referrer_u = await resolve_user_optional(session, referrer_legacy)
|
||||
if referrer_u is None:
|
||||
raise HTTPException(status_code=400, detail="Приглашение недействительно")
|
||||
if billing_uid == referrer_u.id:
|
||||
raise HTTPException(status_code=400, detail="Нельзя использовать собственную ссылку")
|
||||
if await get_referral_by_referred_id(session, billing_uid):
|
||||
raise HTTPException(status_code=409, detail="Реферальная связь уже сохранена")
|
||||
await add_referral(session, billing_uid, referrer_u.id)
|
||||
referred_u = await resolve_user_optional(session, billing_uid)
|
||||
if referrer_u.tg_id is not None:
|
||||
try:
|
||||
from database.web_notifications import notify_web
|
||||
|
||||
await notify_web(
|
||||
session,
|
||||
tg_id=int(referrer_u.tg_id),
|
||||
type="referral_joined",
|
||||
title="Ваш реферал присоединился",
|
||||
message="Новый пользователь зарегистрировался по вашей реферальной ссылке.",
|
||||
data={
|
||||
"referred_tg_id": int(referred_u.tg_id) if referred_u and referred_u.tg_id else None,
|
||||
"referred_user_id": int(billing_uid),
|
||||
},
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
return ReferralApplyResponse(
|
||||
ok=True,
|
||||
message="Приглашение применено",
|
||||
referrer_code=str(referrer_u.id),
|
||||
referrer_user_id=int(referrer_u.id),
|
||||
referrer_tg_id=referrer_u.tg_id,
|
||||
referred_user_id=int(billing_uid),
|
||||
referred_tg_id=referred_u.tg_id if referred_u is not None else None,
|
||||
)
|
||||
|
||||
|
||||
@router.get("/top", response_model=ReferralTopResponse, tags=["Referrals"])
|
||||
async def referral_top(
|
||||
limit: int = Query(5, ge=1, le=20),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
identity=Depends(verify_identity_token),
|
||||
):
|
||||
if not bool(BUTTONS_CONFIG.get("REFERRAL_BUTTON_ENABLED", REFERRAL_BUTTON)):
|
||||
raise HTTPException(status_code=403, detail="Реферальная программа отключена")
|
||||
if not bool(BUTTONS_CONFIG.get("TOP_REFERRAL_BUTTON_ENABLE", TOP_REFERRAL_BUTTON)):
|
||||
raise HTTPException(status_code=403, detail="Топ рефералов отключен в настройках")
|
||||
billing_uid = await idb.ensure_billing_user_for_identity(session, identity)
|
||||
user_referral_count = int(await get_user_referral_count(session, billing_uid))
|
||||
user_position = int(await get_referral_position(session, user_referral_count)) if user_referral_count > 0 else None
|
||||
top_rows = await get_top_referrals(session, limit=limit)
|
||||
top: list[ReferralTopEntryResponse] = []
|
||||
for index, row in enumerate(top_rows, 1):
|
||||
referrer_user_id = int(row.get("referrer_user_id") or 0)
|
||||
referrals_count = int(row.get("referral_count") or 0)
|
||||
display_id = encode_referral_code(referrer_user_id)
|
||||
top.append(
|
||||
ReferralTopEntryResponse(
|
||||
position=index,
|
||||
referrer_user_id=referrer_user_id,
|
||||
referrals_count=referrals_count,
|
||||
display_id=display_id,
|
||||
)
|
||||
)
|
||||
return ReferralTopResponse(
|
||||
user_referrals_count=user_referral_count,
|
||||
user_position=user_position,
|
||||
top=top,
|
||||
)
|
||||
|
||||
|
||||
@router.get("/list", response_model=ReferralListResponse, tags=["Referrals"])
|
||||
async def referral_list(
|
||||
limit: int = Query(50, ge=1, le=200),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
identity=Depends(verify_identity_token),
|
||||
):
|
||||
if not bool(BUTTONS_CONFIG.get("REFERRAL_BUTTON_ENABLED", REFERRAL_BUTTON)):
|
||||
raise HTTPException(status_code=403, detail="Реферальная программа отключена")
|
||||
billing_uid = await idb.ensure_billing_user_for_identity(session, identity)
|
||||
rows_stmt = (
|
||||
select(Referral)
|
||||
.where(Referral.referrer_user_id == int(billing_uid))
|
||||
.limit(limit)
|
||||
)
|
||||
result = await session.execute(rows_stmt)
|
||||
rows = result.scalars().all()
|
||||
items = [
|
||||
ReferralListEntry(
|
||||
referred_user_id=int(r.referred_user_id),
|
||||
referred_tg_id=int(r.referred_tg_id) if r.referred_tg_id is not None else None,
|
||||
display_id=encode_referral_code(int(r.referred_user_id)),
|
||||
reward_issued=bool(r.reward_issued),
|
||||
)
|
||||
for r in rows
|
||||
]
|
||||
return ReferralListResponse(total=len(items), items=items)
|
||||
|
||||
|
||||
@router.get("/qr", response_model=ReferralQrResponse, tags=["Referrals"])
|
||||
async def referral_qr(
|
||||
request: Request,
|
||||
session: AsyncSession = Depends(get_session),
|
||||
identity=Depends(verify_identity_token),
|
||||
):
|
||||
if not bool(BUTTONS_CONFIG.get("REFERRAL_BUTTON_ENABLED", REFERRAL_BUTTON)):
|
||||
raise HTTPException(status_code=403, detail="Реферальная программа отключена")
|
||||
if not bool(BUTTONS_CONFIG.get("REFERRAL_QR_BUTTON_ENABLE", REFERRAL_QR)):
|
||||
raise HTTPException(status_code=403, detail="QR реферальной ссылки отключен в настройках")
|
||||
billing_uid = await idb.ensure_billing_user_for_identity(session, identity)
|
||||
base_url = _resolve_public_base_url(request)
|
||||
referral_link = f"{base_url}/referral/{encode_referral_code(int(billing_uid))}"
|
||||
qr = qrcode.QRCode(version=1, box_size=10, border=4)
|
||||
qr.add_data(referral_link)
|
||||
qr.make(fit=True)
|
||||
img = qr.make_image(fill_color="black", back_color="white")
|
||||
buffer = BytesIO()
|
||||
img.save(buffer, format="PNG")
|
||||
image_data = b64encode(buffer.getvalue()).decode("ascii")
|
||||
return ReferralQrResponse(
|
||||
ok=True,
|
||||
link=referral_link,
|
||||
image_data_url=f"data:image/png;base64,{image_data}",
|
||||
)
|
||||
|
||||
|
||||
@router.get("/conditions", response_model=ReferralConditionsResponse, tags=["Referrals"])
|
||||
async def referral_conditions(
|
||||
identity=Depends(verify_identity_token),
|
||||
):
|
||||
if not bool(BUTTONS_CONFIG.get("REFERRAL_BUTTON_ENABLED", REFERRAL_BUTTON)):
|
||||
raise HTTPException(status_code=403, detail="Реферальная программа отключена")
|
||||
del identity
|
||||
level_lines: list[str] = []
|
||||
for level in sorted(REFERRAL_BONUS_PERCENTAGES.keys()):
|
||||
value = REFERRAL_BONUS_PERCENTAGES[level]
|
||||
if isinstance(value, float):
|
||||
label = f"{int(value * 100)}% от суммы оплаты"
|
||||
else:
|
||||
label = f"{float(value):g} RUB"
|
||||
level_lines.append(f"{level} уровень: {label}")
|
||||
one_time_mode = bool(CHECK_REFERRAL_REWARD_ISSUED)
|
||||
bonus_mode = "one_time" if one_time_mode else "each_payment"
|
||||
bonus_mode_label = (
|
||||
"Бонус за первую успешную оплату реферала" if one_time_mode else "Бонус за каждую успешную оплату реферала"
|
||||
)
|
||||
rules = [
|
||||
"Бонус начисляется только за реальных приглашённых пользователей.",
|
||||
"Нельзя использовать собственную реферальную ссылку.",
|
||||
"Реферальную связь можно применить только один раз.",
|
||||
"Размер бонуса зависит от уровня реферальной программы.",
|
||||
]
|
||||
return ReferralConditionsResponse(
|
||||
title="Условия реферальной программы",
|
||||
summary=f"Режим начисления: {bonus_mode_label}.",
|
||||
bonus_mode=bonus_mode,
|
||||
bonus_mode_label=bonus_mode_label,
|
||||
level_lines=level_lines,
|
||||
rules=rules,
|
||||
)
|
||||
@@ -1,353 +0,0 @@
|
||||
import asyncio
|
||||
import os
|
||||
import re
|
||||
import time
|
||||
|
||||
import aiohttp
|
||||
|
||||
from fastapi import APIRouter, Depends, Query
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from api.depends import get_session
|
||||
|
||||
from config import (
|
||||
BALANCE_BUTTON,
|
||||
CAPTCHA_ENABLE,
|
||||
CHANNEL_EXISTS,
|
||||
CHANNEL_REQUIRED,
|
||||
DONATIONS_ENABLE,
|
||||
GIFT_BUTTON,
|
||||
HAPP_CRYPTOLINK,
|
||||
HWID_RESET_BUTTON,
|
||||
INSTRUCTIONS_BUTTON,
|
||||
PROJECT_NAME,
|
||||
REFERRAL_BUTTON,
|
||||
REFERRAL_QR,
|
||||
REMNAWAVE_WEBAPP,
|
||||
REMNAWAVE_WEBAPP_OPEN_IN_BROWSER,
|
||||
TELEGRAM_WEBAPP_DIRECT_LINK,
|
||||
TELEGRAM_WEBAPP_SHORT_NAME,
|
||||
TOP_REFERRAL_BUTTON,
|
||||
TRIAL_TIME_DISABLE,
|
||||
USERNAME_BOT,
|
||||
USE_COUNTRY_SELECTION,
|
||||
)
|
||||
from core.bootstrap import BUTTONS_CONFIG, MODES_CONFIG, MONEY_CONFIG, PAYMENTS_CONFIG
|
||||
from core.settings.money_config import get_currency_mode
|
||||
from core.settings.web_config import WEB_CONFIG
|
||||
from services.payments.providers import PROVIDERS_BASE, TELEGRAM_ONLY_PROVIDER_IDS, WEB_LINK_PROVIDER_IDS
|
||||
|
||||
|
||||
router = APIRouter(tags=["Root"])
|
||||
|
||||
|
||||
def _telegram_web_app_return_base() -> str | None:
|
||||
direct = str(TELEGRAM_WEBAPP_DIRECT_LINK or "").strip().rstrip("/")
|
||||
if direct:
|
||||
if direct.lower().startswith("http://"):
|
||||
direct = "https://" + direct[7:]
|
||||
if direct.lower().startswith("https://t.me/"):
|
||||
return direct
|
||||
bot = USERNAME_BOT.replace("@", "").strip()
|
||||
sn = str(TELEGRAM_WEBAPP_SHORT_NAME or "").strip()
|
||||
if bot and sn:
|
||||
return f"https://t.me/{bot}/{sn}"
|
||||
if bot:
|
||||
return f"https://t.me/{bot}"
|
||||
return None
|
||||
|
||||
|
||||
def _partner_feature_enabled() -> bool:
|
||||
try:
|
||||
from modules.partner_program import settings as partner_settings
|
||||
except Exception:
|
||||
return False
|
||||
for key in ("PARTNER_PROGRAM_ENABLED", "PARTNER_BUTTON_ENABLED", "PARTNER_ENABLED"):
|
||||
value = getattr(partner_settings, key, None)
|
||||
if isinstance(value, bool):
|
||||
return value
|
||||
return True
|
||||
|
||||
|
||||
@router.get("/api", include_in_schema=False)
|
||||
async def root():
|
||||
return {"message": "SoloBot API v2", "docs": "/api/docs"}
|
||||
|
||||
|
||||
@router.get("/api/version", include_in_schema=True)
|
||||
async def version():
|
||||
return {"version": 2, "api": "v2"}
|
||||
|
||||
|
||||
@router.get("/api/telegram-widget-bot", include_in_schema=True)
|
||||
async def telegram_widget_bot():
|
||||
"""Имя бота и имя проекта для веб-клиента."""
|
||||
bot_username = str(USERNAME_BOT or "").replace("@", "").strip()
|
||||
project_name = (PROJECT_NAME or "Solo").strip() if isinstance(PROJECT_NAME, str) else "Solo"
|
||||
telegram_client_id = ""
|
||||
try:
|
||||
from config import TELEGRAM_CLIENT_ID
|
||||
telegram_client_id = str(TELEGRAM_CLIENT_ID).strip()
|
||||
except ImportError:
|
||||
pass
|
||||
return {
|
||||
"bot_username": bot_username,
|
||||
"project_name": project_name,
|
||||
"telegram_client_id": telegram_client_id,
|
||||
}
|
||||
|
||||
|
||||
@router.get("/api/site/init-state", include_in_schema=True)
|
||||
async def site_init_state(session: AsyncSession = Depends(get_session)):
|
||||
"""Прошёл ли сайт первую настройку админом. Используется middleware веб-клиента."""
|
||||
from database.site_state import is_site_initialized
|
||||
|
||||
initialized = await is_site_initialized(session)
|
||||
return {"initialized": bool(initialized)}
|
||||
|
||||
|
||||
@router.get("/api/site/revision", include_in_schema=True)
|
||||
async def site_revision(session: AsyncSession = Depends(get_session)):
|
||||
"""Глобальный счётчик ревизии контента. Фронт опрашивает его в фоне и при
|
||||
изменении инвалидирует свои SWR-кэши, подтягивая свежие правки админа."""
|
||||
from database.site_revision import get_site_revision
|
||||
|
||||
revision = await get_site_revision(session)
|
||||
return {"revision": int(revision)}
|
||||
|
||||
|
||||
@router.get("/api/site-config", include_in_schema=True)
|
||||
async def site_config():
|
||||
"""Настройки витрины и кабинета для веб-клиента (флаги из runtime-конфигов бота)."""
|
||||
bot_username = USERNAME_BOT.replace("@", "").strip()
|
||||
pay_flags = {name: bool(PAYMENTS_CONFIG.get(name)) for name in PROVIDERS_BASE}
|
||||
any_pay = any(pay_flags.values())
|
||||
web_link_provider_ids = [provider_id for provider_id in WEB_LINK_PROVIDER_IDS if pay_flags.get(provider_id, False)]
|
||||
telegram_only_provider_ids = [
|
||||
provider_id for provider_id in TELEGRAM_ONLY_PROVIDER_IDS if pay_flags.get(provider_id, False)
|
||||
]
|
||||
currency_mode, currency_one_screen = get_currency_mode()
|
||||
try:
|
||||
cb_raw = MONEY_CONFIG.get("CASHBACK", 0)
|
||||
cashback_percent = float(cb_raw) if cb_raw not in (None, False) else 0.0
|
||||
except (TypeError, ValueError):
|
||||
cashback_percent = 0.0
|
||||
|
||||
webapp_short = str(TELEGRAM_WEBAPP_SHORT_NAME or "").strip() or None
|
||||
webapp_return_base = _telegram_web_app_return_base()
|
||||
return {
|
||||
"bot_username": bot_username or None,
|
||||
"telegram_web_app_short_name": webapp_short,
|
||||
"telegram_web_app_return_base": webapp_return_base,
|
||||
"project_name": (PROJECT_NAME or "Solo").strip() if isinstance(PROJECT_NAME, str) else "Solo",
|
||||
"site_mode": str(WEB_CONFIG.get("SITE_MODE", "full")).strip() or "full",
|
||||
"auth": {
|
||||
"telegram_login_enabled": bool(bot_username),
|
||||
"email_code_login_enabled": bool(MODES_CONFIG.get("WEB_EMAIL_CODE_LOGIN_ENABLED", True)),
|
||||
},
|
||||
"mobile": {
|
||||
"prefer_mini_app_on_telegram_mobile": bool(MODES_CONFIG.get("PREFER_MINI_APP_ON_TELEGRAM_MOBILE", False)),
|
||||
},
|
||||
"features": {
|
||||
"channel_enabled": bool(BUTTONS_CONFIG.get("CHANNEL_BUTTON_ENABLE", CHANNEL_EXISTS)),
|
||||
"donations_enabled": bool(BUTTONS_CONFIG.get("DONATIONS_BUTTON_ENABLE", DONATIONS_ENABLE)),
|
||||
"balance_enabled": bool(BUTTONS_CONFIG.get("BALANCE_BUTTON_ENABLE", BALANCE_BUTTON)),
|
||||
"referral_qr_enabled": bool(BUTTONS_CONFIG.get("REFERRAL_QR_BUTTON_ENABLE", REFERRAL_QR)),
|
||||
"instructions_enabled": bool(BUTTONS_CONFIG.get("INSTRUCTIONS_BUTTON_ENABLE", INSTRUCTIONS_BUTTON)),
|
||||
"gift_enabled": bool(BUTTONS_CONFIG.get("GIFT_BUTTON_ENABLE", GIFT_BUTTON)),
|
||||
"referral_enabled": bool(BUTTONS_CONFIG.get("REFERRAL_BUTTON_ENABLED", REFERRAL_BUTTON)),
|
||||
"top_referral_enabled": bool(BUTTONS_CONFIG.get("TOP_REFERRAL_BUTTON_ENABLE", TOP_REFERRAL_BUTTON)),
|
||||
"coupon_enabled": bool(BUTTONS_CONFIG.get("COUPON_BUTTON_ENABLE", True)),
|
||||
"qr_subscription_enabled": bool(MODES_CONFIG.get("HAPP_CRYPTOLINK_ENABLED", HAPP_CRYPTOLINK)),
|
||||
"hwid_reset_enabled": bool(BUTTONS_CONFIG.get("HWID_RESET_BUTTON_ENABLE", HWID_RESET_BUTTON)),
|
||||
"country_selection_enabled": bool(MODES_CONFIG.get("COUNTRY_SELECTION_ENABLED", USE_COUNTRY_SELECTION)),
|
||||
"captcha_enabled": bool(MODES_CONFIG.get("CAPTCHA_ENABLED", CAPTCHA_ENABLE)),
|
||||
"channel_check_enabled": bool(MODES_CONFIG.get("CHANNEL_CHECK_ENABLED", CHANNEL_REQUIRED)),
|
||||
"trial_enabled": not bool(MODES_CONFIG.get("TRIAL_TIME_DISABLED", TRIAL_TIME_DISABLE)),
|
||||
"mini_app_enabled": bool(MODES_CONFIG.get("REMNAWAVE_WEBAPP_ENABLED", REMNAWAVE_WEBAPP)),
|
||||
"mini_app_open_in_browser": bool(
|
||||
MODES_CONFIG.get("REMNAWAVE_WEBAPP_OPEN_IN_BROWSER", REMNAWAVE_WEBAPP_OPEN_IN_BROWSER)
|
||||
),
|
||||
"partner_enabled": bool(_partner_feature_enabled()),
|
||||
},
|
||||
"payments": {
|
||||
"any_enabled": any_pay,
|
||||
"any_web_link_enabled": bool(web_link_provider_ids),
|
||||
"any_telegram_only_enabled": bool(telegram_only_provider_ids),
|
||||
"web_link_provider_ids": web_link_provider_ids,
|
||||
"telegram_only_provider_ids": telegram_only_provider_ids,
|
||||
"yookassa_enabled": pay_flags.get("YOOKASSA", False),
|
||||
"yoomoney_enabled": pay_flags.get("YOOMONEY", False),
|
||||
"robokassa_enabled": pay_flags.get("ROBOKASSA", False),
|
||||
"kassai_cards_enabled": pay_flags.get("KASSAI_CARDS", False),
|
||||
"kassai_sbp_enabled": pay_flags.get("KASSAI_SBP", False),
|
||||
"tribute_enabled": pay_flags.get("TRIBUTE", False),
|
||||
"heleket_enabled": pay_flags.get("HELEKET", False),
|
||||
"cryptobot_enabled": pay_flags.get("CRYPTOBOT", False),
|
||||
"freekassa_enabled": pay_flags.get("FREEKASSA", False),
|
||||
"stars_enabled": pay_flags.get("STARS", False),
|
||||
},
|
||||
"money": {
|
||||
"currency_mode": currency_mode,
|
||||
"currency_one_screen": currency_one_screen,
|
||||
"cashback_enabled": cashback_percent > 0,
|
||||
"cashback_percent": cashback_percent,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
_UPDATE_CHECK_CACHE: dict[str, object] = {"fetched_at": 0.0, "data": None}
|
||||
_UPDATE_CHECK_LOCK = asyncio.Lock()
|
||||
_UPDATE_CHECK_TTL_SEC = 600
|
||||
_SEMVER_RE = re.compile(
|
||||
r"^v?(?P<major>\d+)\.(?P<minor>\d+)\.(?P<patch>\d+)(?:-(?P<pre>[0-9A-Za-z.-]+))?(?:\+[0-9A-Za-z.-]+)?$"
|
||||
)
|
||||
|
||||
|
||||
def _parse_semver(tag: str) -> tuple[int, int, int, int, tuple[tuple[int, int | str], ...]] | None:
|
||||
"""Returns a tuple comparable per semver spec.
|
||||
|
||||
Release > prerelease (second-to-last slot: 1 for release, 0 for prerelease).
|
||||
Last slot — tuple of prerelease identifiers; numeric ids compare numerically,
|
||||
alphanumeric ids compare lexically, numeric < alphanumeric.
|
||||
"""
|
||||
match = _SEMVER_RE.match(tag.strip())
|
||||
if not match:
|
||||
return None
|
||||
major = int(match.group("major"))
|
||||
minor = int(match.group("minor"))
|
||||
patch = int(match.group("patch"))
|
||||
pre_raw = match.group("pre")
|
||||
if not pre_raw:
|
||||
return (major, minor, patch, 1, ())
|
||||
identifiers: list[tuple[int, int | str]] = []
|
||||
for part in pre_raw.split("."):
|
||||
if part.isdigit():
|
||||
identifiers.append((0, int(part)))
|
||||
else:
|
||||
identifiers.append((1, part))
|
||||
return (major, minor, patch, 0, tuple(identifiers))
|
||||
|
||||
|
||||
async def _fetch_ghcr_tags(image: str) -> list[str]:
|
||||
"""Возвращает все теги образа в GHCR. Поддерживает paginate через Link header."""
|
||||
async with aiohttp.ClientSession(timeout=aiohttp.ClientTimeout(total=15)) as session:
|
||||
token_url = f"https://ghcr.io/token?scope=repository:{image}:pull"
|
||||
async with session.get(token_url) as token_resp:
|
||||
if token_resp.status != 200:
|
||||
return []
|
||||
token_data = await token_resp.json()
|
||||
token = str(token_data.get("token") or "").strip()
|
||||
if not token:
|
||||
return []
|
||||
headers = {"Authorization": f"Bearer {token}", "Accept": "application/json"}
|
||||
all_tags: list[str] = []
|
||||
next_url: str | None = f"https://ghcr.io/v2/{image}/tags/list?n=1000"
|
||||
guard = 0
|
||||
while next_url and guard < 20:
|
||||
guard += 1
|
||||
async with session.get(next_url, headers=headers) as tags_resp:
|
||||
if tags_resp.status != 200:
|
||||
break
|
||||
payload = await tags_resp.json()
|
||||
page_tags = payload.get("tags") or []
|
||||
if isinstance(page_tags, list):
|
||||
all_tags.extend(str(t) for t in page_tags)
|
||||
link_header = tags_resp.headers.get("Link") or ""
|
||||
next_url = None
|
||||
for part in link_header.split(","):
|
||||
part = part.strip()
|
||||
if not part or 'rel="next"' not in part:
|
||||
continue
|
||||
inner = part.split(";", 1)[0].strip()
|
||||
if inner.startswith("<") and inner.endswith(">"):
|
||||
inner = inner[1:-1]
|
||||
if inner.startswith("/"):
|
||||
next_url = f"https://ghcr.io{inner}"
|
||||
else:
|
||||
next_url = inner
|
||||
break
|
||||
return all_tags
|
||||
|
||||
|
||||
def _is_dev_version(v: str) -> bool:
|
||||
return "-dev" in v or "dev." in v
|
||||
|
||||
|
||||
def _latest_for_channel(tags: list[str], dev: bool) -> str | None:
|
||||
versions = []
|
||||
for raw in tags:
|
||||
parsed = _parse_semver(str(raw))
|
||||
if parsed is None:
|
||||
continue
|
||||
is_dev = _is_dev_version(str(raw))
|
||||
if is_dev == dev:
|
||||
versions.append((parsed, str(raw)))
|
||||
if not versions:
|
||||
return None
|
||||
versions.sort(key=lambda item: item[0], reverse=True)
|
||||
return versions[0][1]
|
||||
|
||||
|
||||
@router.get("/api/meta/update-check", include_in_schema=True)
|
||||
async def update_check(current: str | None = Query(default=None)):
|
||||
"""Сравнивает переданную версию с последним тегом в GHCR. Канал (dev/release) определяется по current."""
|
||||
current_v = (current or "").strip()
|
||||
image = (os.environ.get("GHCR_IMAGE") or "vladless/solo-brick").strip()
|
||||
if not image:
|
||||
return {"current": current_v or None, "latest": None, "hasUpdate": False}
|
||||
|
||||
now = time.time()
|
||||
is_dev = _is_dev_version(current_v) if current_v else True
|
||||
cache_key = f"data-{'dev' if is_dev else 'release'}"
|
||||
|
||||
cached = _UPDATE_CHECK_CACHE.get(cache_key)
|
||||
fetched_at = float(_UPDATE_CHECK_CACHE.get("fetched_at") or 0.0)
|
||||
if cached is not None and now - fetched_at < _UPDATE_CHECK_TTL_SEC:
|
||||
cached_resp = dict(cached)
|
||||
cached_resp["current"] = current_v or None
|
||||
if current_v:
|
||||
cur = _parse_semver(current_v)
|
||||
nxt = _parse_semver(str(cached_resp.get("latest") or ""))
|
||||
cached_resp["hasUpdate"] = bool(cur and nxt and nxt > cur)
|
||||
return cached_resp
|
||||
|
||||
latest: str | None = None
|
||||
async with _UPDATE_CHECK_LOCK:
|
||||
fetched_at = float(_UPDATE_CHECK_CACHE.get("fetched_at") or 0.0)
|
||||
if now - fetched_at < _UPDATE_CHECK_TTL_SEC:
|
||||
cached = _UPDATE_CHECK_CACHE.get(cache_key)
|
||||
if cached is not None:
|
||||
cached_resp = dict(cached)
|
||||
cached_resp["current"] = current_v or None
|
||||
if current_v:
|
||||
cur = _parse_semver(current_v)
|
||||
nxt = _parse_semver(str(cached_resp.get("latest") or ""))
|
||||
cached_resp["hasUpdate"] = bool(cur and nxt and nxt > cur)
|
||||
return cached_resp
|
||||
try:
|
||||
tags = await _fetch_ghcr_tags(image)
|
||||
latest_dev = _latest_for_channel(tags, dev=True)
|
||||
latest_rel = _latest_for_channel(tags, dev=False)
|
||||
_UPDATE_CHECK_CACHE["data-dev"] = {"latest": latest_dev, "hasUpdate": False, "image": image}
|
||||
_UPDATE_CHECK_CACHE["data-release"] = {"latest": latest_rel, "hasUpdate": False, "image": image}
|
||||
_UPDATE_CHECK_CACHE["fetched_at"] = now
|
||||
latest = latest_dev if is_dev else latest_rel
|
||||
except Exception:
|
||||
latest = None
|
||||
|
||||
has_update = False
|
||||
if current_v and latest:
|
||||
cur = _parse_semver(current_v)
|
||||
nxt = _parse_semver(latest)
|
||||
if cur and nxt:
|
||||
has_update = nxt > cur
|
||||
|
||||
return {
|
||||
"current": current_v or None,
|
||||
"latest": latest,
|
||||
"hasUpdate": has_update,
|
||||
"image": image or None,
|
||||
"channel": "dev" if is_dev else "release",
|
||||
"checkedAt": int(now),
|
||||
}
|
||||
@@ -1,16 +0,0 @@
|
||||
from fastapi import APIRouter
|
||||
|
||||
from api.v2.base_crud import generate_crud_router
|
||||
from api.v2.schemas import ServerBase, ServerResponse, ServerUpdate
|
||||
from database.models import Server
|
||||
|
||||
|
||||
router = generate_crud_router(
|
||||
model=Server,
|
||||
schema_response=ServerResponse,
|
||||
schema_create=ServerBase,
|
||||
schema_update=ServerUpdate,
|
||||
identifier_field="server_name",
|
||||
parameter_name="server_name",
|
||||
enabled_methods=["get_all", "get_one", "create", "update", "delete"],
|
||||
)
|
||||
@@ -1,147 +0,0 @@
|
||||
from typing import Any
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from pydantic import BaseModel
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from api.depends import get_session, verify_identity_admin
|
||||
from api.v2.schemas import SettingResponse, SettingUpsert
|
||||
from core.settings.buttons_config import BUTTONS_CONFIG, update_buttons_config
|
||||
from core.settings.modes_config import MODES_CONFIG, update_modes_config
|
||||
from core.settings.money_config import MONEY_CONFIG, update_money_config
|
||||
from core.settings.notifications_config import NOTIFICATIONS_CONFIG, update_notifications_config
|
||||
from core.settings.payments_config import PAYMENTS_CONFIG, update_payments_config
|
||||
from core.settings.providers_order_config import PROVIDERS_ORDER, update_providers_order
|
||||
from core.settings.tariffs_config import TARIFFS_CONFIG, update_tariffs_config
|
||||
from database.models import Setting
|
||||
from database.settings import set_setting
|
||||
from database.settings_cache import settings_cache
|
||||
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
class ConfigUpdatePayload(BaseModel):
|
||||
value: dict[str, Any] | None = None
|
||||
|
||||
|
||||
@router.get("/", response_model=list[SettingResponse])
|
||||
async def get_all_settings(identity=Depends(verify_identity_admin)):
|
||||
"""Список всех настроек (из кэша, без запроса к БД)."""
|
||||
return settings_cache.get_all()
|
||||
|
||||
|
||||
@router.get("/configs")
|
||||
async def get_configs(identity=Depends(verify_identity_admin)):
|
||||
"""Все конфиги (payments, buttons, notifications, modes, money, providers_order, tariffs)."""
|
||||
return {
|
||||
"payments": dict(PAYMENTS_CONFIG),
|
||||
"buttons": dict(BUTTONS_CONFIG),
|
||||
"notifications": dict(NOTIFICATIONS_CONFIG),
|
||||
"modes": dict(MODES_CONFIG),
|
||||
"money": dict(MONEY_CONFIG),
|
||||
"providers_order": dict(PROVIDERS_ORDER),
|
||||
"tariffs": dict(TARIFFS_CONFIG),
|
||||
}
|
||||
|
||||
|
||||
@router.post("/configs/{scope}")
|
||||
async def update_config_scope(
|
||||
scope: str,
|
||||
payload: ConfigUpdatePayload,
|
||||
identity=Depends(verify_identity_admin),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
):
|
||||
"""Обновление конфига по scope (payments, buttons, notifications, modes, money, providers_order, tariffs)."""
|
||||
data = dict(payload.value or {})
|
||||
normalized = scope.strip().lower().replace("-", "_")
|
||||
if normalized == "payments":
|
||||
cleaned = {key: bool(value) for key, value in data.items()}
|
||||
await update_payments_config(session, cleaned)
|
||||
return {"payments": dict(PAYMENTS_CONFIG)}
|
||||
if normalized == "buttons":
|
||||
cleaned = {key: bool(value) for key, value in data.items()}
|
||||
await update_buttons_config(session, cleaned)
|
||||
return {"buttons": dict(BUTTONS_CONFIG)}
|
||||
if normalized == "notifications":
|
||||
await update_notifications_config(session, data)
|
||||
return {"notifications": dict(NOTIFICATIONS_CONFIG)}
|
||||
if normalized == "modes":
|
||||
cleaned = {key: bool(value) for key, value in data.items()}
|
||||
await update_modes_config(session, cleaned)
|
||||
return {"modes": dict(MODES_CONFIG)}
|
||||
if normalized == "money":
|
||||
await update_money_config(session, data)
|
||||
return {"money": dict(MONEY_CONFIG)}
|
||||
if normalized == "providers_order":
|
||||
cleaned: dict[str, int] = {}
|
||||
for key, value in data.items():
|
||||
try:
|
||||
cleaned[key] = int(value)
|
||||
except (TypeError, ValueError):
|
||||
continue
|
||||
await update_providers_order(session, cleaned)
|
||||
return {"providers_order": dict(PROVIDERS_ORDER)}
|
||||
if normalized == "tariffs":
|
||||
cleaned = dict(data)
|
||||
if "ALLOW_DOWNGRADE" in cleaned:
|
||||
cleaned["ALLOW_DOWNGRADE"] = bool(cleaned.get("ALLOW_DOWNGRADE"))
|
||||
if "KEY_ADDONS_RECALC_PRICE" in cleaned:
|
||||
cleaned["KEY_ADDONS_RECALC_PRICE"] = bool(cleaned.get("KEY_ADDONS_RECALC_PRICE"))
|
||||
if "KEY_ADDONS_PACK_MODE" in cleaned:
|
||||
mode = str(cleaned.get("KEY_ADDONS_PACK_MODE") or "").strip().lower()
|
||||
cleaned["KEY_ADDONS_PACK_MODE"] = mode if mode in {"", "traffic", "devices", "all"} else ""
|
||||
await update_tariffs_config(session, cleaned)
|
||||
return {"tariffs": dict(TARIFFS_CONFIG)}
|
||||
raise HTTPException(status_code=404, detail="Unsupported config scope")
|
||||
|
||||
|
||||
@router.get("/{key}", response_model=SettingResponse)
|
||||
async def get_setting_by_key(key: str, identity=Depends(verify_identity_admin)):
|
||||
"""Настройка по ключу (из кэша, без запроса к БД)."""
|
||||
obj = settings_cache.get(key)
|
||||
if not obj:
|
||||
raise HTTPException(status_code=404, detail="Setting not found")
|
||||
return obj
|
||||
|
||||
|
||||
@router.post("/{key}", response_model=SettingResponse)
|
||||
async def upsert_setting(
|
||||
key: str,
|
||||
payload: SettingUpsert,
|
||||
identity=Depends(verify_identity_admin),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
):
|
||||
"""Создание или обновление настройки по ключу."""
|
||||
obj = await set_setting(
|
||||
session=session,
|
||||
key=key,
|
||||
value=payload.value,
|
||||
description=payload.description,
|
||||
)
|
||||
await session.refresh(obj)
|
||||
settings_cache.update(
|
||||
key,
|
||||
obj.value,
|
||||
obj.description,
|
||||
created_at=getattr(obj, "created_at", None),
|
||||
updated_at=getattr(obj, "updated_at", None),
|
||||
)
|
||||
return obj
|
||||
|
||||
|
||||
@router.delete("/{key}", response_model=dict)
|
||||
async def delete_setting(
|
||||
key: str,
|
||||
identity=Depends(verify_identity_admin),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
):
|
||||
"""Удаление настройки по ключу."""
|
||||
result = await session.execute(select(Setting).where(Setting.key == key))
|
||||
obj = result.scalar_one_or_none()
|
||||
if not obj:
|
||||
raise HTTPException(status_code=404, detail="Setting not found")
|
||||
await session.delete(obj)
|
||||
settings_cache.delete(key)
|
||||
return {"detail": "Setting deleted"}
|
||||
@@ -1,452 +0,0 @@
|
||||
from datetime import datetime, timedelta
|
||||
from math import ceil
|
||||
from urllib.parse import urlsplit
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, Request
|
||||
from pytz import timezone as tz_moscow
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from api.depends import get_session, validate_redirect_url, verify_identity_token
|
||||
from api.v2.base_crud import generate_crud_router
|
||||
from api.v2.routes.coupon_pricing import resolve_percent_coupon_pricing
|
||||
from api.v2.schemas import TariffBase, TariffResponse, TariffUpdate
|
||||
from api.v2.schemas.tariffs import TariffGroup, TariffPublic
|
||||
from api.v2.schemas.web_public import (
|
||||
TariffConfigPriceResponse,
|
||||
TariffPurchaseRequest,
|
||||
TariffPurchaseResponse,
|
||||
)
|
||||
from core.bootstrap import PAYMENTS_CONFIG
|
||||
from core.redis_cache import cache_get, cache_key, cache_set
|
||||
from database import (
|
||||
get_balance,
|
||||
identities as idb,
|
||||
)
|
||||
from database.coupons import mark_coupon_used
|
||||
from database.models import Tariff
|
||||
from database.tariffs import get_tariff_by_id
|
||||
from database.temporary_data import create_temporary_data
|
||||
from logger import logger
|
||||
from services.keys import create_vpn_key_headless
|
||||
from services.payments.payment_links import PaymentLinkRequest, create_payment_link
|
||||
from services.payments.providers import WEB_LINK_PROVIDER_IDS
|
||||
from services.tariffs import calculate_config_price
|
||||
|
||||
|
||||
def _tariff_to_public(t: Tariff) -> TariffPublic:
|
||||
dev_opts = getattr(t, "device_options", None)
|
||||
tr_opts = getattr(t, "traffic_options_gb", None)
|
||||
device_options: list[int] | None = None
|
||||
traffic_options_gb: list[int] | None = None
|
||||
if isinstance(dev_opts, list):
|
||||
device_options = []
|
||||
for x in dev_opts:
|
||||
try:
|
||||
device_options.append(int(x))
|
||||
except (TypeError, ValueError):
|
||||
continue
|
||||
if not device_options:
|
||||
device_options = None
|
||||
if isinstance(tr_opts, list):
|
||||
traffic_options_gb = []
|
||||
for x in tr_opts:
|
||||
try:
|
||||
traffic_options_gb.append(int(x))
|
||||
except (TypeError, ValueError):
|
||||
continue
|
||||
if not traffic_options_gb:
|
||||
traffic_options_gb = None
|
||||
return TariffPublic(
|
||||
id=t.id,
|
||||
name=t.name or "",
|
||||
group_code=t.group_code or "",
|
||||
duration_days=t.duration_days or 0,
|
||||
price_rub=t.price_rub or 0,
|
||||
traffic_limit=t.traffic_limit,
|
||||
device_limit=t.device_limit,
|
||||
subgroup_title=t.subgroup_title,
|
||||
sort_order=t.sort_order,
|
||||
vless=bool(getattr(t, "vless", False)),
|
||||
configurable=bool(getattr(t, "configurable", False)),
|
||||
device_options=device_options,
|
||||
traffic_options_gb=traffic_options_gb,
|
||||
)
|
||||
|
||||
|
||||
public_router = APIRouter()
|
||||
|
||||
|
||||
def _resolve_public_base_url(request: Request) -> str:
|
||||
origin = str(request.headers.get("origin") or "").strip()
|
||||
if origin.startswith(("http://", "https://")):
|
||||
return origin.rstrip("/")
|
||||
referer = str(request.headers.get("referer") or request.headers.get("referrer") or "").strip()
|
||||
if referer.startswith(("http://", "https://")):
|
||||
parsed = urlsplit(referer)
|
||||
if parsed.scheme and parsed.netloc:
|
||||
return f"{parsed.scheme}://{parsed.netloc}".rstrip("/")
|
||||
forwarded_host = str(request.headers.get("x-forwarded-host") or "").strip()
|
||||
host = forwarded_host or str(request.headers.get("host") or "").strip()
|
||||
forwarded_proto = str(request.headers.get("x-forwarded-proto") or "").split(",", 1)[0].strip().lower()
|
||||
scheme = forwarded_proto if forwarded_proto in {"http", "https"} else request.url.scheme
|
||||
if host:
|
||||
return f"{scheme}://{host}".rstrip("/")
|
||||
return str(request.base_url).rstrip("/")
|
||||
|
||||
|
||||
def _resolve_default_web_payment_provider() -> str | None:
|
||||
for provider_id in WEB_LINK_PROVIDER_IDS:
|
||||
if bool(PAYMENTS_CONFIG.get(provider_id)):
|
||||
return provider_id
|
||||
return WEB_LINK_PROVIDER_IDS[0] if WEB_LINK_PROVIDER_IDS else None
|
||||
|
||||
|
||||
def _public_tariffs_cache_key(
|
||||
group_code: str | None,
|
||||
tariff_ids: str | None,
|
||||
filter_vless: str | None,
|
||||
) -> str:
|
||||
normalized_group = (group_code or "").strip().lower()
|
||||
normalized_ids = ",".join(part.strip() for part in (tariff_ids or "").split(",") if part.strip())
|
||||
normalized_vless = (filter_vless or "").strip().lower()
|
||||
return cache_key("tariffs_public", normalized_group or "-", normalized_ids or "-", normalized_vless or "-")
|
||||
|
||||
|
||||
@public_router.get("/groups", response_model=list[TariffGroup])
|
||||
async def get_tariff_groups(session: AsyncSession = Depends(get_session)):
|
||||
"""Публичный список групп тарифов — уникальные значения колонки group_code."""
|
||||
q = (
|
||||
select(Tariff.group_code)
|
||||
.where(Tariff.is_active.is_(True), Tariff.group_code.isnot(None), Tariff.group_code != "")
|
||||
.distinct()
|
||||
.order_by(Tariff.group_code)
|
||||
)
|
||||
result = await session.execute(q)
|
||||
values = result.scalars().all()
|
||||
return [TariffGroup(group_code=v or "") for v in values]
|
||||
|
||||
|
||||
@public_router.get("/public", response_model=list[TariffPublic])
|
||||
async def get_tariffs_public(
|
||||
group_code: str | None = Query(None, description="Фильтр по группе тарифов"),
|
||||
tariff_ids: str | None = Query(None, description="ID тарифов через запятую (приоритет над группой)"),
|
||||
filter_vless: str | None = Query(
|
||||
None,
|
||||
description="vless: только для роутера (vless=True), app: только для приложения (vless=False), иначе все",
|
||||
),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
):
|
||||
"""Публичный список активных тарифов (без авторизации)."""
|
||||
cache_token = _public_tariffs_cache_key(group_code, tariff_ids, filter_vless)
|
||||
cached = await cache_get(cache_token)
|
||||
if isinstance(cached, list):
|
||||
return cached
|
||||
|
||||
q = (
|
||||
select(Tariff)
|
||||
.where(Tariff.is_active.is_(True))
|
||||
.order_by(Tariff.sort_order.asc().nulls_last(), Tariff.price_rub.asc())
|
||||
)
|
||||
if tariff_ids:
|
||||
try:
|
||||
ids = [int(x.strip()) for x in tariff_ids.split(",") if x.strip()]
|
||||
if not ids:
|
||||
return []
|
||||
q = q.where(Tariff.id.in_(ids))
|
||||
except ValueError:
|
||||
raise HTTPException(status_code=422, detail="Некорректный параметр tariff_ids")
|
||||
elif group_code:
|
||||
q = q.where(Tariff.group_code == group_code)
|
||||
if filter_vless == "router":
|
||||
q = q.where(Tariff.vless.is_(True))
|
||||
elif filter_vless == "app":
|
||||
q = q.where(Tariff.vless.is_(False))
|
||||
result = await session.execute(q)
|
||||
rows = result.scalars().all()
|
||||
payload = [_tariff_to_public(t).model_dump() for t in rows]
|
||||
await cache_set(cache_token, payload, 30)
|
||||
return payload
|
||||
|
||||
|
||||
@public_router.get("/config-price", response_model=TariffConfigPriceResponse)
|
||||
async def get_tariff_config_price(
|
||||
tariff_id: int = Query(..., ge=1),
|
||||
selected_device_limit: int | None = Query(None),
|
||||
selected_traffic_gb: int | None = Query(None),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
):
|
||||
tariff = await get_tariff_by_id(session, tariff_id)
|
||||
if not tariff or not tariff.get("is_active", True):
|
||||
raise HTTPException(status_code=404, detail="Тариф не найден")
|
||||
price = int(calculate_config_price(tariff, selected_device_limit, selected_traffic_gb))
|
||||
return TariffConfigPriceResponse(price_rub=price)
|
||||
|
||||
|
||||
user_tariff_router = APIRouter()
|
||||
|
||||
|
||||
@user_tariff_router.post("/purchase", response_model=TariffPurchaseResponse)
|
||||
async def purchase_tariff_with_balance(
|
||||
body: TariffPurchaseRequest,
|
||||
request: Request,
|
||||
preview: bool = Query(False),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
identity=Depends(verify_identity_token),
|
||||
):
|
||||
tg_id = await idb.ensure_billing_user_for_identity(session, identity)
|
||||
tariff = await get_tariff_by_id(session, body.tariff_id)
|
||||
if not tariff or not tariff.get("is_active", True):
|
||||
raise HTTPException(status_code=404, detail="Тариф не найден")
|
||||
price = int(calculate_config_price(tariff, body.selected_device_limit, body.selected_traffic_gb))
|
||||
if price <= 0:
|
||||
raise HTTPException(status_code=400, detail="Некорректная цена тарифа")
|
||||
final_price, discount_rub, coupon_id, applied_coupon_code = await resolve_percent_coupon_pricing(
|
||||
session=session,
|
||||
billing_user_id=int(tg_id),
|
||||
base_price_rub=int(price),
|
||||
coupon_code=body.coupon_code,
|
||||
)
|
||||
balance = float(await get_balance(session, tg_id))
|
||||
duration = int(tariff.get("duration_days") or 0)
|
||||
if duration <= 0:
|
||||
raise HTTPException(status_code=400, detail="Некорректная длительность тарифа")
|
||||
required_amount = int(max(0, ceil(float(final_price) - balance)))
|
||||
if preview:
|
||||
return TariffPurchaseResponse(
|
||||
ok=True,
|
||||
message="Расчет обновлен",
|
||||
key_email=None,
|
||||
charged_rub=0,
|
||||
base_price_rub=int(price),
|
||||
discount_rub=int(discount_rub),
|
||||
final_price_rub=int(final_price),
|
||||
applied_coupon_code=applied_coupon_code,
|
||||
payment_required=required_amount > 0,
|
||||
required_amount_rub=int(required_amount),
|
||||
payment_id=None,
|
||||
payment_url=None,
|
||||
)
|
||||
if required_amount > 0:
|
||||
provider_id = str(body.provider_id or _resolve_default_web_payment_provider() or "").strip().upper()
|
||||
if not provider_id:
|
||||
raise HTTPException(status_code=503, detail="Нет доступных провайдеров оплаты")
|
||||
base_url = _resolve_public_base_url(request)
|
||||
success_url = validate_redirect_url(str(body.success_url or ""), f"{base_url}/payment-success")
|
||||
failure_url = validate_redirect_url(str(body.failure_url or ""), f"{base_url}/payment-failure")
|
||||
payment_request = PaymentLinkRequest(
|
||||
legacy_user_ref=int(tg_id),
|
||||
amount=required_amount,
|
||||
currency="RUB",
|
||||
provider_id=provider_id,
|
||||
success_url=success_url,
|
||||
failure_url=failure_url,
|
||||
metadata={
|
||||
"payment_flow": "tariff_purchase",
|
||||
"tariff_id": int(body.tariff_id),
|
||||
"selected_device_limit": body.selected_device_limit,
|
||||
"selected_traffic_gb": body.selected_traffic_gb,
|
||||
"selected_duration_days": int(duration),
|
||||
"selected_price_rub": int(final_price),
|
||||
"base_price_rub": int(price),
|
||||
"discount_rub": int(discount_rub),
|
||||
"applied_coupon_code": applied_coupon_code,
|
||||
"coupon_id": int(coupon_id) if coupon_id is not None else None,
|
||||
},
|
||||
)
|
||||
payment_result = await create_payment_link(session, payment_request)
|
||||
if not payment_result.success or not payment_result.payment_url or not payment_result.payment_id:
|
||||
raise HTTPException(status_code=400, detail=payment_result.error or "Не удалось создать ссылку оплаты")
|
||||
await create_temporary_data(
|
||||
session,
|
||||
int(tg_id),
|
||||
"waiting_for_payment",
|
||||
{
|
||||
"tariff_id": int(body.tariff_id),
|
||||
"required_amount": int(required_amount),
|
||||
"selected_price_rub": int(final_price),
|
||||
"selected_device_limit": body.selected_device_limit,
|
||||
"selected_traffic_limit_gb": body.selected_traffic_gb,
|
||||
"selected_duration_days": int(duration),
|
||||
"base_price_rub": int(price),
|
||||
"discount_rub": int(discount_rub),
|
||||
"applied_coupon_code": applied_coupon_code,
|
||||
"coupon_id": int(coupon_id) if coupon_id is not None else None,
|
||||
},
|
||||
)
|
||||
return TariffPurchaseResponse(
|
||||
ok=True,
|
||||
message="Требуется оплата для оформления подписки",
|
||||
key_email=None,
|
||||
charged_rub=0,
|
||||
base_price_rub=int(price),
|
||||
discount_rub=int(discount_rub),
|
||||
final_price_rub=int(final_price),
|
||||
applied_coupon_code=applied_coupon_code,
|
||||
payment_required=True,
|
||||
required_amount_rub=required_amount,
|
||||
payment_id=payment_result.payment_id,
|
||||
payment_url=payment_result.payment_url,
|
||||
)
|
||||
moscow_tz = tz_moscow("Europe/Moscow")
|
||||
expiry = datetime.now(moscow_tz) + timedelta(days=duration)
|
||||
try:
|
||||
await create_vpn_key_headless(
|
||||
session=session,
|
||||
tg_id=tg_id,
|
||||
expiry_time=expiry,
|
||||
plan=body.tariff_id,
|
||||
selected_device_limit=body.selected_device_limit,
|
||||
selected_traffic_gb=body.selected_traffic_gb,
|
||||
selected_price_rub=final_price,
|
||||
)
|
||||
if coupon_id is not None:
|
||||
await mark_coupon_used(session, int(coupon_id), int(tg_id))
|
||||
except Exception:
|
||||
logger.exception("web tariff purchase failed")
|
||||
raise HTTPException(status_code=500, detail="Не удалось оформить подписку") from None
|
||||
return TariffPurchaseResponse(
|
||||
ok=True,
|
||||
message="Подписка оформлена. Ключ в разделе «Мои ключи».",
|
||||
key_email=None,
|
||||
charged_rub=final_price,
|
||||
base_price_rub=int(price),
|
||||
discount_rub=int(discount_rub),
|
||||
final_price_rub=int(final_price),
|
||||
applied_coupon_code=applied_coupon_code,
|
||||
)
|
||||
|
||||
|
||||
@user_tariff_router.post("/trial", response_model=TariffPurchaseResponse)
|
||||
async def activate_trial(
|
||||
request: Request,
|
||||
session: AsyncSession = Depends(get_session),
|
||||
identity=Depends(verify_identity_token),
|
||||
):
|
||||
"""Активация триала (бесплатного или платного). Доступно 1 раз."""
|
||||
from database import get_trial, update_trial
|
||||
from database.tariffs import get_tariffs
|
||||
|
||||
tg_id = await idb.ensure_billing_user_for_identity(session, identity)
|
||||
|
||||
trial_status = await get_trial(session, tg_id)
|
||||
if trial_status not in (0, -1):
|
||||
raise HTTPException(status_code=409, detail="Пробная подписка уже использована")
|
||||
|
||||
trial_tariffs = await get_tariffs(session, group_code="trial")
|
||||
if not trial_tariffs:
|
||||
raise HTTPException(status_code=404, detail="Пробный тариф не найден")
|
||||
|
||||
tariff = trial_tariffs[0]
|
||||
price = int(tariff.get("price_rub", 0) or 0)
|
||||
duration = int(tariff.get("duration_days") or 0)
|
||||
if duration <= 0:
|
||||
raise HTTPException(status_code=400, detail="Некорректная длительность триала")
|
||||
|
||||
if price <= 0:
|
||||
moscow_tz = tz_moscow("Europe/Moscow")
|
||||
expiry = datetime.now(moscow_tz) + timedelta(days=duration)
|
||||
try:
|
||||
await create_vpn_key_headless(
|
||||
session=session,
|
||||
tg_id=tg_id,
|
||||
expiry_time=expiry,
|
||||
plan=int(tariff["id"]),
|
||||
selected_price_rub=0,
|
||||
skip_balance_charge=True,
|
||||
is_trial=True,
|
||||
)
|
||||
await update_trial(session, tg_id, 1)
|
||||
except Exception:
|
||||
logger.exception("web trial activation failed")
|
||||
raise HTTPException(status_code=500, detail="Ошибка активации триала") from None
|
||||
return TariffPurchaseResponse(
|
||||
ok=True,
|
||||
message="Пробная подписка активирована!",
|
||||
charged_rub=0,
|
||||
base_price_rub=0,
|
||||
final_price_rub=0,
|
||||
)
|
||||
|
||||
balance = float(await get_balance(session, tg_id))
|
||||
required_amount = int(max(0, ceil(float(price) - balance)))
|
||||
|
||||
if required_amount <= 0:
|
||||
moscow_tz = tz_moscow("Europe/Moscow")
|
||||
expiry = datetime.now(moscow_tz) + timedelta(days=duration)
|
||||
try:
|
||||
await create_vpn_key_headless(
|
||||
session=session,
|
||||
tg_id=tg_id,
|
||||
expiry_time=expiry,
|
||||
plan=int(tariff["id"]),
|
||||
selected_price_rub=price,
|
||||
is_trial=True,
|
||||
)
|
||||
await update_trial(session, tg_id, 1)
|
||||
except Exception:
|
||||
logger.exception("web paid trial activation failed")
|
||||
raise HTTPException(status_code=500, detail="Ошибка активации триала") from None
|
||||
return TariffPurchaseResponse(
|
||||
ok=True,
|
||||
message="Пробная подписка активирована!",
|
||||
charged_rub=price,
|
||||
base_price_rub=price,
|
||||
final_price_rub=price,
|
||||
)
|
||||
|
||||
provider_id = str(_resolve_default_web_payment_provider() or "").strip().upper()
|
||||
if not provider_id:
|
||||
raise HTTPException(status_code=503, detail="Нет доступных провайдеров оплаты")
|
||||
base_url = _resolve_public_base_url(request)
|
||||
payment_request = PaymentLinkRequest(
|
||||
legacy_user_ref=int(tg_id),
|
||||
amount=required_amount,
|
||||
currency="RUB",
|
||||
provider_id=provider_id,
|
||||
success_url=f"{base_url}/payment-success",
|
||||
failure_url=f"{base_url}/payment-failure",
|
||||
metadata={
|
||||
"payment_flow": "trial_purchase",
|
||||
"tariff_id": int(tariff["id"]),
|
||||
"selected_price_rub": price,
|
||||
"selected_duration_days": duration,
|
||||
},
|
||||
)
|
||||
payment_result = await create_payment_link(session, payment_request)
|
||||
if not payment_result.success or not payment_result.payment_url or not payment_result.payment_id:
|
||||
raise HTTPException(status_code=400, detail=payment_result.error or "Не удалось создать ссылку оплаты")
|
||||
await create_temporary_data(
|
||||
session,
|
||||
int(tg_id),
|
||||
"waiting_for_payment",
|
||||
{
|
||||
"payment_flow": "trial_purchase",
|
||||
"tariff_id": int(tariff["id"]),
|
||||
"required_amount": required_amount,
|
||||
"selected_price_rub": price,
|
||||
"selected_duration_days": duration,
|
||||
},
|
||||
)
|
||||
return TariffPurchaseResponse(
|
||||
ok=True,
|
||||
message="Требуется оплата для активации пробной подписки",
|
||||
charged_rub=0,
|
||||
base_price_rub=price,
|
||||
final_price_rub=price,
|
||||
payment_required=True,
|
||||
required_amount_rub=required_amount,
|
||||
payment_id=payment_result.payment_id,
|
||||
payment_url=payment_result.payment_url,
|
||||
)
|
||||
|
||||
|
||||
router = generate_crud_router(
|
||||
model=Tariff,
|
||||
schema_response=TariffResponse,
|
||||
schema_create=TariffBase,
|
||||
schema_update=TariffUpdate,
|
||||
identifier_field="name",
|
||||
parameter_name="name",
|
||||
enabled_methods=["get_all", "get_one", "create", "update", "delete"],
|
||||
)
|
||||
@@ -1,63 +0,0 @@
|
||||
import asyncio
|
||||
|
||||
from fastapi import Depends, HTTPException, Path
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from api.depends import get_session, verify_identity_admin
|
||||
from api.v2.base_crud import generate_crud_router
|
||||
from api.v2.schemas import UserBase, UserResponse, UserUpdate
|
||||
from database import async_session_maker, delete_user_data, get_servers
|
||||
from database.access.resolution import resolve_user_optional
|
||||
from database.models import Key, User
|
||||
from logger import logger
|
||||
from services.operations import delete_key_from_cluster
|
||||
|
||||
|
||||
router = generate_crud_router(
|
||||
model=User,
|
||||
schema_response=UserResponse,
|
||||
schema_create=UserBase,
|
||||
schema_update=UserUpdate,
|
||||
identifier_field="tg_id",
|
||||
enabled_methods=["get_all", "get_one", "create", "update"],
|
||||
)
|
||||
|
||||
|
||||
@router.delete("/{tg_id}", response_model=dict)
|
||||
async def delete_user(
|
||||
tg_id: int = Path(..., description="Telegram ID пользователя"),
|
||||
identity=Depends(verify_identity_admin),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
):
|
||||
"""Удаляет пользователя и его ключи на серверах."""
|
||||
try:
|
||||
u = await resolve_user_optional(session, tg_id)
|
||||
if u is None:
|
||||
raise HTTPException(status_code=404, detail="Пользователь не найден")
|
||||
result = await session.execute(select(Key.email, Key.client_id).where(Key.user_id == u.id))
|
||||
key_records = result.all()
|
||||
|
||||
async with async_session_maker() as s:
|
||||
servers = await get_servers(session=s)
|
||||
cluster_ids = list(servers.keys())
|
||||
|
||||
async def _delete_one(cluster_id: str, email: str, client_id: str):
|
||||
async with async_session_maker() as s:
|
||||
await delete_key_from_cluster(cluster_id, email, client_id, s)
|
||||
|
||||
try:
|
||||
tasks = [
|
||||
_delete_one(cluster_id, email, client_id)
|
||||
for email, client_id in key_records
|
||||
for cluster_id in cluster_ids
|
||||
]
|
||||
await asyncio.gather(*tasks, return_exceptions=True)
|
||||
except Exception as e:
|
||||
logger.error(f"[DELETE] Ошибка при удалении ключей с серверов для пользователя {tg_id}: {e}")
|
||||
|
||||
await delete_user_data(session, tg_id)
|
||||
return {"detail": f"Пользователь {tg_id} и его ключи успешно удалены."}
|
||||
except Exception as e:
|
||||
logger.error(f"[DELETE] Ошибка при удалении пользователя {tg_id}: {e}")
|
||||
raise HTTPException(status_code=500, detail="Ошибка при удалении пользователя")
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,40 +0,0 @@
|
||||
from api.v1.schemas import (
|
||||
BlockedUserResponse,
|
||||
CouponBase,
|
||||
CouponResponse,
|
||||
CouponUpdate,
|
||||
CouponUsageResponse,
|
||||
GiftBase,
|
||||
GiftResponse,
|
||||
GiftUpdate,
|
||||
GiftUsageResponse,
|
||||
KeyDetailsResponse,
|
||||
KeyResponse,
|
||||
ManualBanResponse,
|
||||
NotificationResponse,
|
||||
PaymentResponse,
|
||||
ReferralResponse,
|
||||
ServerBase,
|
||||
ServerResponse,
|
||||
ServerUpdate,
|
||||
TariffBase,
|
||||
TariffResponse,
|
||||
TariffUpdate,
|
||||
TemporaryDataResponse,
|
||||
TrackingSourceResponse,
|
||||
UserBase,
|
||||
UserResponse,
|
||||
UserUpdate,
|
||||
)
|
||||
from api.v1.schemas.keys import KeyBase, KeyCreateRequest, KeyUpdate
|
||||
from api.v1.schemas.settings import SettingResponse, SettingUpsert
|
||||
from api.v2.schemas.web import (
|
||||
WebBlockResponse,
|
||||
WebPageResponse,
|
||||
WebPageUpdate,
|
||||
WebPageVariantCreate,
|
||||
WebPageVariantSummary,
|
||||
WebPageVariantUpdate,
|
||||
WebPageVariantsResponse,
|
||||
WebTheme,
|
||||
)
|
||||
@@ -1,58 +0,0 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
from typing import Any
|
||||
|
||||
from pydantic import BaseModel
|
||||
|
||||
|
||||
class AuditEventResponse(BaseModel):
|
||||
id: int | None = None
|
||||
event_type: str
|
||||
channel: str
|
||||
actor_identity_id: str | None
|
||||
actor_tg_id: int | None
|
||||
path_or_handler: str
|
||||
entity_type: str | None
|
||||
entity_id: str | None
|
||||
result: str
|
||||
reason: str | None
|
||||
metadata: dict[str, Any] | None
|
||||
request_id: str | None
|
||||
created_at: datetime | None
|
||||
|
||||
|
||||
class AuditEventListResponse(BaseModel):
|
||||
items: list[AuditEventResponse]
|
||||
limit: int
|
||||
offset: int
|
||||
|
||||
|
||||
class AuditPathStat(BaseModel):
|
||||
step: str
|
||||
label: str
|
||||
total: int
|
||||
success: int
|
||||
fail: int
|
||||
unique_users: int
|
||||
fail_rate_pct: float
|
||||
|
||||
|
||||
class AuditFunnelStep(BaseModel):
|
||||
step: str
|
||||
label: str
|
||||
count: int
|
||||
conversion_from_prev_pct: float | None
|
||||
|
||||
|
||||
class AuditStatsSummary(BaseModel):
|
||||
date_from: str
|
||||
date_to: str
|
||||
total_events: int
|
||||
unique_users: int
|
||||
|
||||
|
||||
class AuditStatsResponse(BaseModel):
|
||||
summary: AuditStatsSummary
|
||||
by_path: list[AuditPathStat]
|
||||
funnel: list[AuditFunnelStep]
|
||||
@@ -1,91 +0,0 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any, Literal
|
||||
|
||||
from pydantic import BaseModel, ConfigDict
|
||||
from pydantic.alias_generators import to_camel
|
||||
|
||||
|
||||
_FLOW_CONFIG = ConfigDict(populate_by_name=True, alias_generator=to_camel)
|
||||
|
||||
|
||||
class EdgeConditionSchema(BaseModel):
|
||||
model_config = _FLOW_CONFIG
|
||||
|
||||
field: str
|
||||
operator: str
|
||||
value: Any = None
|
||||
|
||||
|
||||
class EdgeConditionGroupSchema(BaseModel):
|
||||
model_config = _FLOW_CONFIG
|
||||
|
||||
logic: Literal["and", "or"] = "and"
|
||||
conditions: list[EdgeConditionSchema] = []
|
||||
|
||||
|
||||
class FlowEdgeSchema(BaseModel):
|
||||
model_config = _FLOW_CONFIG
|
||||
|
||||
id: str
|
||||
source: str
|
||||
target: str
|
||||
condition: EdgeConditionSchema | None = None
|
||||
condition_group: EdgeConditionGroupSchema | None = None
|
||||
label: str | None = None
|
||||
priority: int | None = None
|
||||
|
||||
|
||||
class FlowNodeActionConfigSchema(BaseModel):
|
||||
model_config = _FLOW_CONFIG
|
||||
|
||||
action_type: str
|
||||
params: dict[str, Any] = {}
|
||||
|
||||
|
||||
class FlowNodeSchema(BaseModel):
|
||||
model_config = _FLOW_CONFIG
|
||||
|
||||
id: str
|
||||
type: str
|
||||
label: str
|
||||
label_en: str | None = None
|
||||
enabled: bool = True
|
||||
page_slug: str | None = None
|
||||
cabinet_tab: str | None = None
|
||||
screen_group: str | None = None
|
||||
screen_id: str | None = None
|
||||
auto_skip_if: EdgeConditionSchema | EdgeConditionGroupSchema | None = None
|
||||
action_config: FlowNodeActionConfigSchema | None = None
|
||||
config: dict = {}
|
||||
position: dict
|
||||
|
||||
|
||||
class FlowResponse(BaseModel):
|
||||
model_config = _FLOW_CONFIG
|
||||
|
||||
id: str
|
||||
name: str
|
||||
nodes: list[FlowNodeSchema]
|
||||
edges: list[FlowEdgeSchema]
|
||||
entry_node_id: str | None
|
||||
version: int
|
||||
|
||||
|
||||
class FlowUpdate(BaseModel):
|
||||
model_config = _FLOW_CONFIG
|
||||
|
||||
name: str | None = None
|
||||
nodes: list[FlowNodeSchema]
|
||||
edges: list[FlowEdgeSchema]
|
||||
entry_node_id: str | None = None
|
||||
|
||||
|
||||
class FlowCreate(BaseModel):
|
||||
model_config = _FLOW_CONFIG
|
||||
|
||||
id: str
|
||||
name: str
|
||||
nodes: list[FlowNodeSchema] = []
|
||||
edges: list[FlowEdgeSchema] = []
|
||||
entry_node_id: str | None = None
|
||||
@@ -1,132 +0,0 @@
|
||||
from datetime import datetime
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
|
||||
class IdentityCreate(BaseModel):
|
||||
email: str | None = Field(None, description="Почта для привязки")
|
||||
tg_id: int | None = Field(None, description="Telegram ID для привязки")
|
||||
|
||||
|
||||
class IdentityResponse(BaseModel):
|
||||
id: str
|
||||
email: str | None
|
||||
tg_id: int | None
|
||||
is_admin: bool = False
|
||||
email_verified: bool = False
|
||||
password_set: bool = False
|
||||
onboarding_completed: bool = False
|
||||
onboarding_stage: str | None = None
|
||||
created_at: datetime | None
|
||||
updated_at: datetime | None
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
|
||||
class RegisterByEmailRequest(BaseModel):
|
||||
email: str = Field(..., min_length=1)
|
||||
password: str = Field(..., min_length=8, description="Пароль (минимум 8 символов)")
|
||||
referral_code: str | None = Field(None, min_length=1)
|
||||
turnstile_token: str | None = Field(default=None, description="Cloudflare Turnstile CAPTCHA token")
|
||||
|
||||
|
||||
class RegisterResponse(BaseModel):
|
||||
identity_id: str
|
||||
|
||||
|
||||
class LoginRequest(BaseModel):
|
||||
email: str = Field(..., min_length=1)
|
||||
password: str = Field(...)
|
||||
|
||||
|
||||
class SetPasswordRequest(BaseModel):
|
||||
password: str = Field(..., min_length=8, description="Новый пароль (минимум 8 символов)")
|
||||
password_confirm: str = Field(..., min_length=8)
|
||||
|
||||
|
||||
class ChangePasswordRequest(BaseModel):
|
||||
current_password: str = Field(...)
|
||||
password: str = Field(..., min_length=8, description="Новый пароль (минимум 8 символов)")
|
||||
password_confirm: str = Field(..., min_length=8)
|
||||
|
||||
|
||||
class LoginResponse(BaseModel):
|
||||
identity_id: str
|
||||
identity: IdentityResponse
|
||||
|
||||
|
||||
class SendLoginCodeRequest(BaseModel):
|
||||
email: str = Field(..., min_length=1)
|
||||
allow_register: bool = Field(
|
||||
default=True,
|
||||
description="Если true и email новый — создать идентичность и отправить код (passwordless flow)",
|
||||
)
|
||||
turnstile_token: str | None = Field(default=None, description="Cloudflare Turnstile CAPTCHA token")
|
||||
|
||||
|
||||
class LoginByCodeRequest(BaseModel):
|
||||
email: str = Field(..., min_length=1)
|
||||
code: str = Field(..., min_length=1)
|
||||
|
||||
|
||||
class ConfirmPasswordResetRequest(BaseModel):
|
||||
email: str = Field(..., min_length=1)
|
||||
code: str = Field(..., min_length=1)
|
||||
password: str = Field(..., min_length=8)
|
||||
password_confirm: str = Field(..., min_length=8)
|
||||
|
||||
|
||||
class LoginTelegramRequest(BaseModel):
|
||||
"""Данные от Telegram Login Widget (кнопка «Войти через Telegram»)."""
|
||||
|
||||
id: int = Field(..., description="Telegram user id (tg_id)")
|
||||
first_name: str = Field("")
|
||||
last_name: str | None = None
|
||||
username: str | None = None
|
||||
photo_url: str | None = None
|
||||
auth_date: int = Field(..., description="Unix timestamp от Telegram")
|
||||
hash: str = Field(..., description="HMAC подпись для проверки на бэкенде")
|
||||
|
||||
|
||||
class LinkTelegramRequest(BaseModel):
|
||||
"""Данные от Telegram Login Widget — обязательны для доказательства владения аккаунтом при привязке."""
|
||||
|
||||
id: int = Field(..., description="Telegram user id (tg_id)")
|
||||
first_name: str = Field("")
|
||||
last_name: str | None = None
|
||||
username: str | None = None
|
||||
photo_url: str | None = None
|
||||
auth_date: int = Field(..., description="Unix timestamp от Telegram")
|
||||
hash: str = Field(..., description="HMAC подпись для проверки на бэкенде")
|
||||
|
||||
|
||||
class IdentityAttachEmail(BaseModel):
|
||||
email: str = Field(..., min_length=1)
|
||||
|
||||
|
||||
class LinkEmailSendCodeRequest(BaseModel):
|
||||
email: str = Field(..., min_length=1)
|
||||
|
||||
|
||||
class LinkEmailConfirmRequest(BaseModel):
|
||||
email: str = Field(..., min_length=1)
|
||||
code: str = Field(..., min_length=1, max_length=16)
|
||||
|
||||
|
||||
class IdentityAttachTelegram(BaseModel):
|
||||
tg_id: int = Field(...)
|
||||
|
||||
|
||||
class IdentitySessionItem(BaseModel):
|
||||
id: str
|
||||
device_label: str | None = None
|
||||
ip: str | None = None
|
||||
created_at: datetime
|
||||
last_seen_at: datetime
|
||||
expires_at: datetime | None = None
|
||||
is_current: bool = False
|
||||
|
||||
|
||||
class IdentitySessionsResponse(BaseModel):
|
||||
sessions: list[IdentitySessionItem]
|
||||
@@ -1,32 +0,0 @@
|
||||
from typing import Any
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
|
||||
class PaymentLinkCreateRequest(BaseModel):
|
||||
tg_id: int | None = Field(None, description="Telegram ID пользователя (если не задан identity_id)")
|
||||
identity_id: str | None = Field(None, description="ID идентичности; tg_id будет взят из привязки")
|
||||
amount: int | float = Field(..., gt=0, description="Сумма оплаты")
|
||||
currency: str = Field(default="RUB", description="Валюта (например RUB)")
|
||||
provider_id: str | None = Field(
|
||||
default=None,
|
||||
description="Идентификатор кассы: ROBOKASSA, FREEKASSA, YOOKASSA, YOOMONEY, KASSAI_CARDS, KASSAI_SBP, HELEKET и др. Если не задан — берётся первый доступный.",
|
||||
)
|
||||
success_url: str | None = Field(None, description="URL перенаправления после успешной оплаты")
|
||||
failure_url: str | None = Field(None, description="URL перенаправления после неуспешной оплаты")
|
||||
metadata: dict[str, Any] | None = Field(None, description="Дополнительные данные")
|
||||
|
||||
|
||||
class PaymentLinkCreateResponse(BaseModel):
|
||||
success: bool
|
||||
payment_id: str | None = None
|
||||
payment_url: str | None = None
|
||||
error: str | None = None
|
||||
|
||||
|
||||
class PaymentLinkStatusResponse(BaseModel):
|
||||
success: bool
|
||||
payment_id: str
|
||||
status: str | None = None
|
||||
completed: bool = False
|
||||
paid: bool = False
|
||||
@@ -1,25 +0,0 @@
|
||||
from pydantic import BaseModel
|
||||
|
||||
|
||||
class TariffGroup(BaseModel):
|
||||
"""Группа тарифов (group_code) для выбора в лендинге и др."""
|
||||
|
||||
group_code: str
|
||||
|
||||
|
||||
class TariffPublic(BaseModel):
|
||||
"""Публичный список тарифов (без авторизации)."""
|
||||
|
||||
id: int
|
||||
name: str
|
||||
group_code: str
|
||||
duration_days: int
|
||||
price_rub: int
|
||||
traffic_limit: int | None
|
||||
device_limit: int | None
|
||||
subgroup_title: str | None
|
||||
sort_order: int | None
|
||||
vless: bool = False
|
||||
configurable: bool = False
|
||||
device_options: list[int] | None = None
|
||||
traffic_options_gb: list[int] | None = None
|
||||
@@ -1,125 +0,0 @@
|
||||
import json
|
||||
|
||||
from typing import Any
|
||||
|
||||
from pydantic import BaseModel, Field, model_validator
|
||||
|
||||
|
||||
_MAX_BLOCK_DATA_SIZE = 4 * 1024 * 1024
|
||||
|
||||
|
||||
class WebBlockBase(BaseModel):
|
||||
type: str = Field(..., max_length=64)
|
||||
order: int
|
||||
data: dict[str, Any]
|
||||
|
||||
@model_validator(mode="after")
|
||||
def _check_data_size(self) -> "WebBlockBase":
|
||||
if len(json.dumps(self.data, ensure_ascii=False)) > _MAX_BLOCK_DATA_SIZE:
|
||||
raise ValueError(f"Размер data блока не должен превышать {_MAX_BLOCK_DATA_SIZE // 1024} КБ")
|
||||
return self
|
||||
|
||||
|
||||
class WebBlockResponse(WebBlockBase):
|
||||
id: str
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
|
||||
class WebTheme(BaseModel):
|
||||
tokens: dict[str, Any]
|
||||
|
||||
|
||||
class WebPageThemeResponse(BaseModel):
|
||||
slug: str
|
||||
variant_key: str = "default"
|
||||
tokens: dict[str, Any] = Field(default_factory=dict)
|
||||
|
||||
|
||||
class WebPageThemeUpdate(BaseModel):
|
||||
tokens: dict[str, Any]
|
||||
|
||||
|
||||
class WebPageVariantSummary(BaseModel):
|
||||
key: str = Field(..., max_length=64)
|
||||
name: str = Field(..., max_length=255)
|
||||
is_active: bool = False
|
||||
|
||||
|
||||
class WebPageSaveResponse(BaseModel):
|
||||
slug: str
|
||||
variant_key: str = "default"
|
||||
active_variant_key: str = "default"
|
||||
variants: list[WebPageVariantSummary] = Field(default_factory=list)
|
||||
|
||||
|
||||
class WebPageResponse(BaseModel):
|
||||
slug: str
|
||||
blocks: list[WebBlockResponse]
|
||||
theme: WebTheme | None = None
|
||||
variant_key: str = "default"
|
||||
active_variant_key: str = "default"
|
||||
variants: list[WebPageVariantSummary] = Field(default_factory=list)
|
||||
|
||||
|
||||
class WebPageUpdate(BaseModel):
|
||||
blocks: list[WebBlockBase]
|
||||
theme: WebTheme | None = None
|
||||
|
||||
|
||||
class WebPageVariantCreate(BaseModel):
|
||||
key: str | None = Field(default=None, max_length=64)
|
||||
name: str | None = Field(default=None, max_length=255)
|
||||
from_variant_key: str | None = Field(default=None, max_length=64)
|
||||
|
||||
|
||||
class WebPageVariantUpdate(BaseModel):
|
||||
name: str | None = Field(default=None, max_length=255)
|
||||
make_active: bool | None = None
|
||||
|
||||
|
||||
class WebPageVariantsResponse(BaseModel):
|
||||
slug: str
|
||||
active_variant_key: str = "default"
|
||||
current_variant_key: str = "default"
|
||||
variants: list[WebPageVariantSummary] = Field(default_factory=list)
|
||||
|
||||
|
||||
class WebUploadResponse(BaseModel):
|
||||
url: str
|
||||
|
||||
|
||||
class FlowStepConfig(BaseModel):
|
||||
provider_ids: list[str] | None = None
|
||||
tariff_group_code: str | None = None
|
||||
tariff_ids: list[int] | None = None
|
||||
display_mode: str | None = None
|
||||
skippable: bool = False
|
||||
auto_advance_if_single: bool = False
|
||||
|
||||
|
||||
class FlowStepSchema(BaseModel):
|
||||
id: str = Field(..., max_length=64)
|
||||
type: str = Field(..., max_length=32)
|
||||
label: str = Field(..., max_length=255)
|
||||
label_en: str | None = Field(default=None, max_length=255)
|
||||
enabled: bool = True
|
||||
page_slug: str | None = Field(default=None, max_length=64)
|
||||
config: FlowStepConfig = Field(default_factory=FlowStepConfig)
|
||||
|
||||
|
||||
class FlowDefinitionSchema(BaseModel):
|
||||
id: str = Field(..., max_length=64)
|
||||
name: str = Field(..., max_length=255)
|
||||
steps: list[FlowStepSchema] = Field(default_factory=list)
|
||||
version: int = 1
|
||||
|
||||
|
||||
class FlowDefinitionResponse(FlowDefinitionSchema):
|
||||
pass
|
||||
|
||||
|
||||
class FlowDefinitionUpdate(BaseModel):
|
||||
name: str | None = Field(default=None, max_length=255)
|
||||
steps: list[FlowStepSchema] = Field(default_factory=list)
|
||||
@@ -1,479 +0,0 @@
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
|
||||
class AccountSummaryResponse(BaseModel):
|
||||
identity_id: str
|
||||
email: str | None = None
|
||||
tg_id: int | None = None
|
||||
linked_telegram: bool = False
|
||||
created_at: str | None = None
|
||||
password_set: bool = False
|
||||
referral_code: str = ""
|
||||
balance: float = 0.0
|
||||
trial_status: int = 0
|
||||
keys_total: int = 0
|
||||
referrals_total: int = 0
|
||||
referrals_active: int = 0
|
||||
referral_bonus_total: float = 0.0
|
||||
gifts_sent: int = 0
|
||||
gifts_claimed: int = 0
|
||||
coupons_used: int = 0
|
||||
partner_enabled: bool = False
|
||||
partner_code: str = ""
|
||||
partner_balance: float = 0.0
|
||||
partner_percent: float = 0.0
|
||||
partner_percent_custom: bool = False
|
||||
partner_referred_total: int = 0
|
||||
partner_referred_paid: int = 0
|
||||
partner_payout_method: str | None = None
|
||||
unread_notifications: int = 0
|
||||
|
||||
|
||||
class AccountKeyActionsAvailability(BaseModel):
|
||||
can_connect_device: bool = False
|
||||
can_connect_router: bool = False
|
||||
can_connect_tv: bool = False
|
||||
can_renew: bool = False
|
||||
can_addons: bool = False
|
||||
can_reset_hwid: bool = False
|
||||
can_qr: bool = False
|
||||
can_delete: bool = False
|
||||
can_change_location: bool = False
|
||||
|
||||
|
||||
class AccountKeyDetailsResponse(BaseModel):
|
||||
client_id: str
|
||||
email: str
|
||||
alias: str | None = None
|
||||
expiry_time: int = 0
|
||||
is_frozen: bool = False
|
||||
tariff_name: str = ""
|
||||
subgroup_title: str = ""
|
||||
traffic_limit_gb: int = 0
|
||||
used_traffic_gb: float | None = None
|
||||
device_limit: int = 0
|
||||
connected_devices: int = 0
|
||||
is_tariff_configurable: bool = False
|
||||
addons_devices_enabled: bool = False
|
||||
addons_traffic_enabled: bool = False
|
||||
|
||||
|
||||
class AccountKeyResponse(BaseModel):
|
||||
email: str
|
||||
alias: str | None = None
|
||||
client_id: str
|
||||
tariff_id: int | None = None
|
||||
server_id: str
|
||||
created_at: int = 0
|
||||
expiry_time: int = 0
|
||||
key: str | None = None
|
||||
remnawave_link: str | None = None
|
||||
is_frozen: bool = False
|
||||
actions: AccountKeyActionsAvailability | None = None
|
||||
|
||||
|
||||
class AccountKeyAliasUpdateRequest(BaseModel):
|
||||
alias: str = Field(..., min_length=1, max_length=10)
|
||||
|
||||
|
||||
class AccountKeyActionResponse(BaseModel):
|
||||
ok: bool = True
|
||||
message: str = ""
|
||||
|
||||
|
||||
class AccountKeyRenewRequest(BaseModel):
|
||||
provider_id: str | None = None
|
||||
success_url: str | None = None
|
||||
failure_url: str | None = None
|
||||
coupon_code: str | None = None
|
||||
|
||||
|
||||
class AccountKeyRenewResponse(AccountKeyActionResponse):
|
||||
client_id: str
|
||||
tariff_id: int
|
||||
charged_rub: int = 0
|
||||
balance_rub: float = 0.0
|
||||
base_price_rub: int = 0
|
||||
discount_rub: int = 0
|
||||
final_price_rub: int = 0
|
||||
applied_coupon_code: str | None = None
|
||||
payment_required: bool = False
|
||||
required_amount_rub: int = 0
|
||||
payment_id: str | None = None
|
||||
payment_url: str | None = None
|
||||
|
||||
|
||||
class AccountKeyResetHwidResponse(AccountKeyActionResponse):
|
||||
total_devices: int = 0
|
||||
reset_devices: int = 0
|
||||
|
||||
|
||||
class AccountKeyQrResponse(AccountKeyActionResponse):
|
||||
link: str = ""
|
||||
image_data_url: str = ""
|
||||
|
||||
|
||||
class AccountKeyLocationOptionResponse(BaseModel):
|
||||
server_name: str
|
||||
|
||||
|
||||
class AccountKeyLocationsResponse(BaseModel):
|
||||
client_id: str
|
||||
current_server: str = ""
|
||||
locations: list[AccountKeyLocationOptionResponse] = []
|
||||
|
||||
|
||||
class AccountKeyChangeLocationRequest(BaseModel):
|
||||
server_name: str = Field(..., min_length=1)
|
||||
|
||||
|
||||
class AccountKeyChangeLocationResponse(AccountKeyActionResponse):
|
||||
client_id: str
|
||||
server_id: str = ""
|
||||
link: str = ""
|
||||
remnawave_link: str | None = None
|
||||
|
||||
|
||||
class AccountKeyAddonOptionResponse(BaseModel):
|
||||
value: int
|
||||
label: str = ""
|
||||
|
||||
|
||||
class AccountKeyAddonsPreviewRequest(BaseModel):
|
||||
selected_device_limit: int | None = None
|
||||
selected_traffic_gb: int | None = None
|
||||
include_device: bool | None = None
|
||||
include_traffic: bool | None = None
|
||||
provider_id: str | None = None
|
||||
success_url: str | None = None
|
||||
failure_url: str | None = None
|
||||
coupon_code: str | None = None
|
||||
|
||||
|
||||
class AccountKeyAddonsPreviewResponse(BaseModel):
|
||||
client_id: str
|
||||
tariff_id: int
|
||||
addons_mode: str = ""
|
||||
has_device_option: bool = False
|
||||
has_traffic_option: bool = False
|
||||
current_device_limit: int | None = None
|
||||
current_traffic_gb: int | None = None
|
||||
selected_device_limit: int | None = None
|
||||
selected_traffic_gb: int | None = None
|
||||
device_options: list[AccountKeyAddonOptionResponse] = []
|
||||
traffic_options: list[AccountKeyAddonOptionResponse] = []
|
||||
total_price_rub: int = 0
|
||||
extra_price_rub: int = 0
|
||||
discount_rub: int = 0
|
||||
final_price_rub: int = 0
|
||||
applied_coupon_code: str | None = None
|
||||
balance_rub: float = 0.0
|
||||
|
||||
|
||||
class AccountKeyApplyAddonsResponse(AccountKeyActionResponse):
|
||||
client_id: str
|
||||
tariff_id: int
|
||||
total_price_rub: int = 0
|
||||
extra_price_rub: int = 0
|
||||
discount_rub: int = 0
|
||||
final_price_rub: int = 0
|
||||
applied_coupon_code: str | None = None
|
||||
charged_rub: int = 0
|
||||
balance_rub: float = 0.0
|
||||
payment_required: bool = False
|
||||
required_amount_rub: int = 0
|
||||
payment_id: str | None = None
|
||||
payment_url: str | None = None
|
||||
|
||||
|
||||
class AccountKeyActionsConfigResponse(BaseModel):
|
||||
renew_enabled: bool = True
|
||||
delete_enabled: bool = False
|
||||
qr_enabled: bool = False
|
||||
hwid_reset_enabled: bool = False
|
||||
country_change_enabled: bool = False
|
||||
instructions_enabled: bool = False
|
||||
addons_enabled: bool = False
|
||||
addons_mode: str = ""
|
||||
tv_connect_enabled: bool = False
|
||||
|
||||
|
||||
class AccountKeyConnectionResponse(BaseModel):
|
||||
client_id: str
|
||||
online: bool = False
|
||||
is_frozen: bool = False
|
||||
expiry_time: int = 0
|
||||
expires_in_days: int = 0
|
||||
server_name: str = ""
|
||||
cluster_name: str = ""
|
||||
panel_type: str = ""
|
||||
protocol: str = ""
|
||||
|
||||
|
||||
class AccountSearchHit(BaseModel):
|
||||
kind: str
|
||||
label: str
|
||||
sublabel: str = ""
|
||||
href: str = ""
|
||||
meta: str = ""
|
||||
|
||||
|
||||
class AccountSearchResponse(BaseModel):
|
||||
query: str
|
||||
hits: list[AccountSearchHit] = []
|
||||
total: int = 0
|
||||
|
||||
|
||||
class TariffConfigPriceResponse(BaseModel):
|
||||
price_rub: int
|
||||
|
||||
|
||||
class TariffPurchaseRequest(BaseModel):
|
||||
tariff_id: int = Field(..., ge=1)
|
||||
selected_device_limit: int | None = None
|
||||
selected_traffic_gb: int | None = None
|
||||
provider_id: str | None = None
|
||||
success_url: str | None = None
|
||||
failure_url: str | None = None
|
||||
coupon_code: str | None = None
|
||||
|
||||
|
||||
class TariffPurchaseResponse(BaseModel):
|
||||
ok: bool = True
|
||||
message: str = ""
|
||||
key_email: str | None = None
|
||||
charged_rub: int | None = None
|
||||
base_price_rub: int = 0
|
||||
discount_rub: int = 0
|
||||
final_price_rub: int = 0
|
||||
applied_coupon_code: str | None = None
|
||||
payment_required: bool = False
|
||||
required_amount_rub: int = 0
|
||||
payment_id: str | None = None
|
||||
payment_url: str | None = None
|
||||
|
||||
|
||||
class GiftCreateRequest(BaseModel):
|
||||
tariff_id: int = Field(..., ge=1)
|
||||
selected_device_limit: int | None = None
|
||||
selected_traffic_gb: int | None = None
|
||||
provider_id: str | None = None
|
||||
success_url: str | None = None
|
||||
failure_url: str | None = None
|
||||
|
||||
|
||||
class GiftCreatePreviewResponse(BaseModel):
|
||||
ok: bool = True
|
||||
price_rub: int = 0
|
||||
balance_rub: float = 0.0
|
||||
sufficient_funds: bool = True
|
||||
tariff_name: str = ""
|
||||
duration_days: int = 0
|
||||
|
||||
|
||||
class GiftCreateResponse(BaseModel):
|
||||
ok: bool = True
|
||||
message: str = ""
|
||||
gift_id: str = ""
|
||||
site_gift_link: str = ""
|
||||
tariff_name: str = ""
|
||||
duration_days: int = 0
|
||||
price_charged: int = 0
|
||||
balance_rub: float = 0.0
|
||||
payment_required: bool = False
|
||||
required_amount_rub: int = 0
|
||||
payment_id: str | None = None
|
||||
payment_url: str | None = None
|
||||
|
||||
|
||||
class GiftUsageEntry(BaseModel):
|
||||
user_id: int
|
||||
used_at: str | None = None
|
||||
|
||||
|
||||
class MyGiftItem(BaseModel):
|
||||
gift_id: str
|
||||
tariff_name: str = ""
|
||||
duration_days: int = 0
|
||||
price_rub: int = 0
|
||||
created_at: str | None = None
|
||||
expiry_time: str | None = None
|
||||
is_used: bool = False
|
||||
is_unlimited: bool = False
|
||||
max_usages: int | None = None
|
||||
site_gift_link: str = ""
|
||||
usages: list[GiftUsageEntry] = []
|
||||
|
||||
|
||||
class MyGiftsResponse(BaseModel):
|
||||
ok: bool = True
|
||||
gifts: list[MyGiftItem] = []
|
||||
total: int = 0
|
||||
limit: int = 20
|
||||
offset: int = 0
|
||||
|
||||
|
||||
class GiftRedeemRequest(BaseModel):
|
||||
gift_code: str = Field(..., min_length=1)
|
||||
|
||||
|
||||
class GiftRedeemResponse(BaseModel):
|
||||
ok: bool = True
|
||||
message: str = ""
|
||||
gift_id: str = ""
|
||||
tariff_id: int = 0
|
||||
duration_days: int = 0
|
||||
|
||||
|
||||
class ReferralApplyRequest(BaseModel):
|
||||
referrer_code: str | None = Field(None, min_length=1)
|
||||
referrer_tg_id: int | None = Field(None, ge=1)
|
||||
|
||||
|
||||
class ReferralApplyResponse(BaseModel):
|
||||
ok: bool = True
|
||||
message: str = ""
|
||||
referrer_code: str = ""
|
||||
referrer_user_id: int = 0
|
||||
referrer_tg_id: int | None = None
|
||||
referred_user_id: int = 0
|
||||
referred_tg_id: int | None = None
|
||||
|
||||
|
||||
class ReferralTopEntryResponse(BaseModel):
|
||||
position: int
|
||||
referrer_user_id: int
|
||||
referrals_count: int
|
||||
display_id: str
|
||||
|
||||
|
||||
class ReferralTopResponse(BaseModel):
|
||||
user_referrals_count: int = 0
|
||||
user_position: int | None = None
|
||||
top: list[ReferralTopEntryResponse] = []
|
||||
|
||||
|
||||
class ReferralListEntry(BaseModel):
|
||||
referred_user_id: int
|
||||
referred_tg_id: int | None = None
|
||||
display_id: str = ""
|
||||
reward_issued: bool = False
|
||||
|
||||
|
||||
class ReferralListResponse(BaseModel):
|
||||
total: int = 0
|
||||
items: list[ReferralListEntry] = []
|
||||
|
||||
|
||||
class ReferralQrResponse(BaseModel):
|
||||
ok: bool = True
|
||||
link: str = ""
|
||||
image_data_url: str = ""
|
||||
|
||||
|
||||
class ReferralConditionsResponse(BaseModel):
|
||||
title: str = ""
|
||||
summary: str = ""
|
||||
bonus_mode: str = ""
|
||||
bonus_mode_label: str = ""
|
||||
level_lines: list[str] = []
|
||||
rules: list[str] = []
|
||||
|
||||
|
||||
class PartnerConditionsResponse(BaseModel):
|
||||
title: str = ""
|
||||
summary: str = ""
|
||||
bonus_mode: str = ""
|
||||
bonus_mode_label: str = ""
|
||||
level_lines: list[str] = []
|
||||
rules: list[str] = []
|
||||
examples: list[str] = []
|
||||
min_payout_rub: float = 0.0
|
||||
payout_methods: list[str] = []
|
||||
custom_amount_enabled: bool = False
|
||||
|
||||
|
||||
class PartnerQrResponse(BaseModel):
|
||||
ok: bool = True
|
||||
link: str = ""
|
||||
image_data_url: str = ""
|
||||
|
||||
|
||||
class PartnerApplyRequest(BaseModel):
|
||||
partner_code: str | None = Field(None, min_length=1)
|
||||
partner_tg_id: int | None = Field(None, ge=1)
|
||||
|
||||
|
||||
class PartnerApplyResponse(BaseModel):
|
||||
ok: bool = True
|
||||
message: str = ""
|
||||
partner_code: str = ""
|
||||
partner_user_id: int = 0
|
||||
partner_tg_id: int | None = None
|
||||
joined_user_id: int = 0
|
||||
joined_tg_id: int | None = None
|
||||
|
||||
|
||||
class PartnerTopEntryResponse(BaseModel):
|
||||
position: int
|
||||
partner_user_id: int
|
||||
referred_count: int
|
||||
display_id: str
|
||||
|
||||
|
||||
class PartnerTopResponse(BaseModel):
|
||||
user_referred_count: int = 0
|
||||
user_position: int | None = None
|
||||
top: list[PartnerTopEntryResponse] = []
|
||||
|
||||
|
||||
class PartnerInvitedEntry(BaseModel):
|
||||
tg_id: int
|
||||
joined_at: str | None = None
|
||||
balance: float = 0.0
|
||||
keys_count: int = 0
|
||||
payments_count: int = 0
|
||||
|
||||
|
||||
class PartnerInvitedResponse(BaseModel):
|
||||
total: int = 0
|
||||
items: list[PartnerInvitedEntry] = []
|
||||
|
||||
|
||||
class CouponApplyRequest(BaseModel):
|
||||
code: str = Field(..., min_length=1, max_length=128)
|
||||
|
||||
|
||||
class CouponApplyResponse(BaseModel):
|
||||
ok: bool = True
|
||||
message: str = ""
|
||||
coupon_code: str = ""
|
||||
amount: int = 0
|
||||
balance: float = 0.0
|
||||
|
||||
|
||||
class PartnerPayoutRequestCreate(BaseModel):
|
||||
amount_rub: float = Field(..., gt=0)
|
||||
|
||||
|
||||
class PartnerPayoutRequestResponse(BaseModel):
|
||||
ok: bool = True
|
||||
message: str = ""
|
||||
request_id: int | None = None
|
||||
amount_rub: float = 0.0
|
||||
status: str = "pending"
|
||||
balance_rub: float = 0.0
|
||||
|
||||
|
||||
class PartnerPayoutEntryResponse(BaseModel):
|
||||
id: int
|
||||
amount_rub: float = 0.0
|
||||
status: str = ""
|
||||
created_at: str | None = None
|
||||
method: str | None = None
|
||||
destination: str | None = None
|
||||
|
||||
|
||||
class PartnerPayoutHistoryResponse(BaseModel):
|
||||
total: int = 0
|
||||
items: list[PartnerPayoutEntryResponse] = []
|
||||
-1156
File diff suppressed because it is too large
Load Diff
-450
@@ -1,450 +0,0 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Iterable
|
||||
|
||||
|
||||
def _get_bot_webhook_path() -> str:
|
||||
"""Путь вебхука бота из конфига (для исключения из шага «успешная оплата»)."""
|
||||
try:
|
||||
from config import WEBHOOK_PATH
|
||||
|
||||
return ((WEBHOOK_PATH or "").strip().lower()) or ""
|
||||
except ImportError:
|
||||
return ""
|
||||
|
||||
|
||||
def _is_bot_webhook_path(path: str) -> bool:
|
||||
"""True только если path — именно вебхук бота (точное совпадение сегмента пути), не касса."""
|
||||
bot_path = _get_bot_webhook_path()
|
||||
if not bot_path:
|
||||
return False
|
||||
p = (path or "").strip().lower()
|
||||
path_segment = p.split(" ", 1)[1] if " " in p else p
|
||||
return path_segment == bot_path or path_segment.rstrip("/") == bot_path.rstrip("/")
|
||||
|
||||
|
||||
AUDIT_STEP_LABELS: dict[str, str] = {
|
||||
"start": "Старт",
|
||||
"start_coupon": "Старт: купон",
|
||||
"start_gift": "Старт: подарок",
|
||||
"start_referral": "Старт: рефералка",
|
||||
"start_utm": "Старт: UTM",
|
||||
"profile": "Профиль",
|
||||
"about": "О VPN",
|
||||
"instructions": "Инструкции",
|
||||
"balance": "Баланс / история оплат",
|
||||
"view_keys": "Мои ключи",
|
||||
"buy_entry": "Оформление: вход",
|
||||
"tariff_config": "Оформление: выбор тарифа/конфига",
|
||||
"key_create": "Подписка оформлена (ключ создан)",
|
||||
"pay_start": "Оплата: вход / создание ссылки",
|
||||
"pay": "Успешная оплата (пополнение)",
|
||||
"key_view": "Ключ (карточка)",
|
||||
"connect": "Подключение: экран / инструкции / QR",
|
||||
"key_manage": "Управление подпиской",
|
||||
"renew": "Продление",
|
||||
"addons": "Аддоны",
|
||||
"referral": "Рефералы",
|
||||
"coupons": "Купоны",
|
||||
"register": "Регистрация (API)",
|
||||
"login": "Вход (API)",
|
||||
"api_other": "API прочее",
|
||||
"admin": "Админ-панель",
|
||||
"other": "Прочее",
|
||||
}
|
||||
|
||||
|
||||
DEFAULT_FUNNEL_STEPS = (
|
||||
"start",
|
||||
"profile",
|
||||
"view_keys",
|
||||
"buy_entry",
|
||||
"tariff_config",
|
||||
"key_create",
|
||||
"pay_start",
|
||||
"pay",
|
||||
"key_view",
|
||||
"connect",
|
||||
)
|
||||
|
||||
|
||||
_CALLBACK_EXACT: dict[str, str] = {
|
||||
"start": "start",
|
||||
"profile": "profile",
|
||||
"about_vpn": "about",
|
||||
"instructions": "instructions",
|
||||
"view_keys": "view_keys",
|
||||
"create_key": "buy_entry",
|
||||
"buy": "buy_entry",
|
||||
"pay": "pay_start",
|
||||
"balance": "balance",
|
||||
"balance_history": "balance",
|
||||
"activate_coupon": "coupons",
|
||||
"cancel_coupon_activation": "coupons",
|
||||
"exit_coupon_input": "coupons",
|
||||
"invite": "referral",
|
||||
"top_referrals": "referral",
|
||||
"check_subscription": "start",
|
||||
"back_to_tariff_group_list": "tariff_config",
|
||||
"back_to_subgroup_tariffs": "tariff_config",
|
||||
"cancel_and_back_to_view_keys": "view_keys",
|
||||
"fastflow_coupon": "coupons",
|
||||
"fastflow_coupon_back": "coupons",
|
||||
"fastflow_back": "pay_start",
|
||||
"pay_kassai": "pay_start",
|
||||
"pay_kassai_cards": "pay_start",
|
||||
"pay_kassai_sbp": "pay_start",
|
||||
"pay_heleket_crypto": "pay_start",
|
||||
"pay_freekassa": "pay_start",
|
||||
"pay_robokassa": "pay_start",
|
||||
}
|
||||
|
||||
|
||||
_CALLBACK_PREFIX: list[tuple[str, str]] = [
|
||||
("view_key|", "key_view"),
|
||||
("view_keys|", "view_keys"),
|
||||
("show_referral_qr|", "referral"),
|
||||
("tariff_subgroup_user|", "tariff_config"),
|
||||
("select_tariff_plan|", "tariff_config"),
|
||||
("cfg_user_devices|", "tariff_config"),
|
||||
("cfg_user_traffic|", "tariff_config"),
|
||||
("rename_key|", "key_manage"),
|
||||
("reset_hwid|", "key_manage"),
|
||||
("delete_key|", "key_manage"),
|
||||
("confirm_delete|", "key_manage"),
|
||||
("update_subscription|", "key_manage"),
|
||||
("change_location|", "key_manage"),
|
||||
("select_country|", "key_manage"),
|
||||
("connect_device|", "connect"),
|
||||
("connect_router|", "connect"),
|
||||
("connect_tv|", "connect"),
|
||||
("connect_pc|", "connect"),
|
||||
("connect_ios|", "connect"),
|
||||
("connect_android|", "connect"),
|
||||
("show_qr|", "connect"),
|
||||
("continue_tv|", "connect"),
|
||||
("pay_currency|", "pay_start"),
|
||||
("choose_payment_currency|", "pay_start"),
|
||||
("cfg_user_confirm|", "key_create"),
|
||||
("choose_payment_provider|", "pay_start"),
|
||||
("kassai_method|", "pay_start"),
|
||||
("kassai_cards_amount|", "pay_start"),
|
||||
("kassai_sbp_amount|", "pay_start"),
|
||||
("kassai_custom_amount|", "pay_start"),
|
||||
("heleket_method|", "pay_start"),
|
||||
("heleket_crypto_amount|", "pay_start"),
|
||||
("heleket_custom_amount|", "pay_start"),
|
||||
("freekassa_amount|", "pay_start"),
|
||||
("robokassa_", "pay_start"),
|
||||
("cfg_renew", "renew"),
|
||||
("key_addons", "addons"),
|
||||
("extend_key", "coupons"),
|
||||
]
|
||||
|
||||
|
||||
_CALLBACK_CONTAINS: list[tuple[str, str]] = [
|
||||
("connect_", "connect"),
|
||||
("renew", "renew"),
|
||||
("addon", "addons"),
|
||||
("referral", "referral"),
|
||||
("invite", "referral"),
|
||||
("coupon", "coupons"),
|
||||
("users_audit", "admin"),
|
||||
("users_editor", "admin"),
|
||||
("search_user", "admin"),
|
||||
("admin_panel", "admin"),
|
||||
]
|
||||
|
||||
|
||||
_MESSAGE_STEP_BY_COMMAND: dict[str, str] = {
|
||||
"/start": "start",
|
||||
"start": "start",
|
||||
"/buy": "buy_entry",
|
||||
"buy": "buy_entry",
|
||||
"/subs": "view_keys",
|
||||
"subs": "view_keys",
|
||||
"/profile": "profile",
|
||||
"profile": "profile",
|
||||
"/invite": "referral",
|
||||
"invite": "referral",
|
||||
"/instructions": "instructions",
|
||||
"instructions": "instructions",
|
||||
"/activate_coupon": "coupons",
|
||||
"activate_coupon": "coupons",
|
||||
}
|
||||
|
||||
_START_PAYLOAD_MAX_LEN = 256
|
||||
_START_PAYLOAD_MAX_PARTS = 20
|
||||
|
||||
|
||||
_IGNORED_CALLBACK_EXACT: set[str] = {
|
||||
" ",
|
||||
"back",
|
||||
"cancel",
|
||||
"back_to_pay",
|
||||
"back_to_currency",
|
||||
"back_to_tariff_group_list",
|
||||
"back_to_subgroup_tariffs",
|
||||
"cancel_and_back_to_view_keys",
|
||||
"cancel_coupon_activation",
|
||||
"exit_coupon_input",
|
||||
"fastflow_back",
|
||||
"fastflow_coupon_back",
|
||||
"cfg_back_menu",
|
||||
"cfg_cancel_input",
|
||||
"cancel_broadcast",
|
||||
}
|
||||
|
||||
|
||||
_IGNORED_CALLBACK_PREFIX: tuple[str, ...] = (
|
||||
"back:",
|
||||
"back_to_",
|
||||
"cancel_",
|
||||
"gifts_page|",
|
||||
)
|
||||
|
||||
_IGNORED_CALLBACK_EXACT_RULES: dict[str, str] = dict.fromkeys(_IGNORED_CALLBACK_EXACT, "ignore")
|
||||
_IGNORED_CALLBACK_PREFIX_RULES: tuple[tuple[str, str], ...] = tuple(
|
||||
(prefix, "ignore") for prefix in _IGNORED_CALLBACK_PREFIX
|
||||
)
|
||||
|
||||
|
||||
_HANDLER_CONTAINS: list[tuple[str, str] | tuple[str, str, str]] = [
|
||||
("process_start", "start"),
|
||||
("start_entry", "start"),
|
||||
("show_start_menu", "start"),
|
||||
("process_callback_view_profile", "profile"),
|
||||
("handle_about_vpn", "about"),
|
||||
("send_instructions", "instructions"),
|
||||
("process_callback_or_message_view_keys", "view_keys"),
|
||||
("key_view", "key_view", "key_create"),
|
||||
("handle_user_config_confirm", "key_create"),
|
||||
("finalize_config_and_purchase", "key_create"),
|
||||
("proceed_purchase_with_values", "tariff_config"),
|
||||
("key_create", "buy_entry"),
|
||||
("handle_key_creation", "buy_entry"),
|
||||
("complete_key_renewal", "renew"),
|
||||
("handle_connect_device", "connect"),
|
||||
("process_connect_", "connect"),
|
||||
("process_callback_connect", "connect"),
|
||||
("process_continue_tv", "connect"),
|
||||
("show_qr_code", "connect"),
|
||||
("balance_history", "balance"),
|
||||
("balance_handler", "balance"),
|
||||
("pay", "pay_start"),
|
||||
("choose_payment_provider", "pay_start"),
|
||||
("kassai_", "pay_start"),
|
||||
("heleket_", "pay_start"),
|
||||
("freekassa_", "pay_start"),
|
||||
("robokassa_", "pay_start"),
|
||||
("rename_key", "key_manage"),
|
||||
("reset_hwid", "key_manage"),
|
||||
("delete_key", "key_manage"),
|
||||
("change_location", "key_manage"),
|
||||
("select_country", "key_manage"),
|
||||
("renew", "renew"),
|
||||
("addon", "addons"),
|
||||
("referral", "referral"),
|
||||
("refferal", "referral"),
|
||||
("coupon", "coupons"),
|
||||
("admin_panel", "admin"),
|
||||
("users_audit", "admin"),
|
||||
("users_editor", "admin"),
|
||||
("search_user", "admin"),
|
||||
("auth/register", "register"),
|
||||
("auth/login", "login"),
|
||||
("auth/send-login", "login"),
|
||||
("auth/login-by-code", "login"),
|
||||
("auth/login-telegram", "login"),
|
||||
("auth/set-password", "login"),
|
||||
("auth/change-password", "login"),
|
||||
("auth/request-password-reset", "login"),
|
||||
("auth/confirm-password-reset", "login"),
|
||||
("auth/summary", "login"),
|
||||
("site-config", "api_other"),
|
||||
("tariffs/purchase", "pay_start"),
|
||||
("tariffs/config-price", "tariff_config"),
|
||||
("gifts/redeem", "key_create"),
|
||||
("referrals/apply", "referral"),
|
||||
]
|
||||
|
||||
|
||||
_API_CONTAINS: list[tuple[str, str]] = [
|
||||
("auth/register", "register"),
|
||||
("auth/login", "login"),
|
||||
("auth/send-login", "login"),
|
||||
("auth/login-by-code", "login"),
|
||||
("auth/login-telegram", "login"),
|
||||
("auth/set-password", "login"),
|
||||
("auth/change-password", "login"),
|
||||
("auth/request-password-reset", "login"),
|
||||
("auth/confirm-password-reset", "login"),
|
||||
("auth/summary", "login"),
|
||||
("site-config", "api_other"),
|
||||
("tariffs/purchase", "pay_start"),
|
||||
("tariffs/config-price", "tariff_config"),
|
||||
("gifts/redeem", "key_create"),
|
||||
("referrals/apply", "referral"),
|
||||
("/keys/create", "key_create"),
|
||||
]
|
||||
|
||||
|
||||
def _match_step_rules(
|
||||
value: str,
|
||||
*,
|
||||
exact: dict[str, str] | None = None,
|
||||
prefixes: Iterable[tuple[str, str]] | None = None,
|
||||
contains: Iterable[tuple[str, str] | tuple[str, str, str]] | None = None,
|
||||
) -> str | None:
|
||||
"""Универсальный matcher шага по exact/prefix/contains правилам."""
|
||||
normalized = (value or "").lower().strip()
|
||||
if not normalized:
|
||||
return None
|
||||
if exact:
|
||||
step = exact.get(normalized)
|
||||
if step:
|
||||
return step
|
||||
if prefixes:
|
||||
for prefix, step in prefixes:
|
||||
if normalized.startswith(prefix):
|
||||
return step
|
||||
if contains:
|
||||
for item in contains:
|
||||
substr, step = item[0], item[1]
|
||||
exclude = item[2] if len(item) > 2 else None
|
||||
if substr in normalized and (exclude is None or exclude not in normalized):
|
||||
return step
|
||||
return None
|
||||
|
||||
|
||||
def _is_ignored_analytics_event(path: str) -> bool:
|
||||
"""Исключает чисто навигационные события из аналитики шагов и воронки."""
|
||||
p = (path or "").lower().strip()
|
||||
if not p.startswith("callback:"):
|
||||
return False
|
||||
callback_data = p.split(":", 1)[-1]
|
||||
return (
|
||||
_match_step_rules(
|
||||
callback_data,
|
||||
exact=_IGNORED_CALLBACK_EXACT_RULES,
|
||||
prefixes=_IGNORED_CALLBACK_PREFIX_RULES,
|
||||
)
|
||||
is not None
|
||||
)
|
||||
|
||||
|
||||
def _message_command_step(path: str) -> str | None:
|
||||
"""Определяет шаг только по точной команде/первому токену сообщения."""
|
||||
steps = _message_command_steps(path)
|
||||
return steps[0] if steps else None
|
||||
|
||||
|
||||
def _message_command_steps(path: str) -> list[str]:
|
||||
"""Определяет один или несколько шагов из текстового сообщения."""
|
||||
p = (path or "").strip()
|
||||
if not p.lower().startswith("message:"):
|
||||
return []
|
||||
text = (p.split(":", 1)[-1] or "").strip().lower()
|
||||
if not text:
|
||||
return []
|
||||
parts = text.split(None, 1)
|
||||
token = parts[0]
|
||||
if token.startswith("/"):
|
||||
token = token.split("@", 1)[0]
|
||||
if token in ("/start", "start"):
|
||||
steps = ["start"]
|
||||
payload = parts[1].strip() if len(parts) > 1 else ""
|
||||
if payload:
|
||||
if len(payload) > _START_PAYLOAD_MAX_LEN:
|
||||
payload = payload[:_START_PAYLOAD_MAX_LEN]
|
||||
payload_parts = payload.split("-")
|
||||
if len(payload_parts) > _START_PAYLOAD_MAX_PARTS:
|
||||
payload_parts = payload_parts[:_START_PAYLOAD_MAX_PARTS]
|
||||
for part in payload_parts:
|
||||
part = part.strip()
|
||||
if not part:
|
||||
continue
|
||||
if "coupons" in part:
|
||||
steps.append("start_coupon")
|
||||
continue
|
||||
if "gift" in part:
|
||||
steps.append("start_gift")
|
||||
continue
|
||||
if "referral" in part:
|
||||
steps.append("start_referral")
|
||||
continue
|
||||
if "utm" in part:
|
||||
steps.append("start_utm")
|
||||
continue
|
||||
normalized_payload = "-".join(payload_parts).strip().lower()
|
||||
if normalized_payload in _MESSAGE_STEP_BY_COMMAND:
|
||||
steps.append(_MESSAGE_STEP_BY_COMMAND[normalized_payload])
|
||||
return list(dict.fromkeys(steps))
|
||||
return steps
|
||||
step = _MESSAGE_STEP_BY_COMMAND.get(token)
|
||||
return [step] if step else []
|
||||
|
||||
|
||||
def _callback_step(path: str) -> str | None:
|
||||
callback_data = path.split(":", 1)[-1]
|
||||
callback_key = callback_data.split("|")[0]
|
||||
return _match_step_rules(
|
||||
callback_key,
|
||||
exact=_CALLBACK_EXACT,
|
||||
) or _match_step_rules(
|
||||
callback_data,
|
||||
prefixes=_CALLBACK_PREFIX,
|
||||
contains=_CALLBACK_CONTAINS,
|
||||
)
|
||||
|
||||
|
||||
def _api_step(path: str) -> str:
|
||||
step = _match_step_rules(path, contains=_API_CONTAINS)
|
||||
if step:
|
||||
return step
|
||||
if "payment-links" in path or ("payment" in path and "webhook" not in path):
|
||||
return "pay_start"
|
||||
return "api_other"
|
||||
|
||||
|
||||
def _handler_step(path: str) -> str | None:
|
||||
return _match_step_rules(path, contains=_HANDLER_CONTAINS)
|
||||
|
||||
|
||||
def _funnel_step_counts(path: str, result: str, step: str) -> bool:
|
||||
"""Решает, считать ли событие достижением шага воронки."""
|
||||
if result != "success":
|
||||
return False
|
||||
p = (path or "").lower()
|
||||
if step == "pay_start":
|
||||
return True
|
||||
if step == "pay":
|
||||
return p.startswith("payment_success:")
|
||||
if step == "key_create":
|
||||
return "cfg_user_confirm" in p or "/keys/create" in p
|
||||
return True
|
||||
|
||||
|
||||
def _normalize_path_to_step(path: str) -> str:
|
||||
"""Сводит path_or_handler к шагу по правилам из маппингов."""
|
||||
steps = _normalize_path_to_steps(path)
|
||||
return steps[0] if steps else "other"
|
||||
|
||||
|
||||
def _normalize_path_to_steps(path: str) -> list[str]:
|
||||
"""Сводит path_or_handler к одному или нескольким шагам по правилам аудита."""
|
||||
if not path:
|
||||
return ["other"]
|
||||
p = path.lower().strip()
|
||||
if p.startswith("payment_success:"):
|
||||
return ["pay"]
|
||||
if p.startswith("callback:"):
|
||||
return [_callback_step(p) or "other"]
|
||||
message_steps = _message_command_steps(path)
|
||||
if message_steps:
|
||||
return message_steps
|
||||
if p.startswith("message:"):
|
||||
return ["other"]
|
||||
if p.startswith(("post ", "get ")):
|
||||
return [_api_step(p)]
|
||||
return [_handler_step(p) or "other"]
|
||||
@@ -1,40 +1,19 @@
|
||||
from importlib import import_module
|
||||
|
||||
|
||||
version = "0.5.3"
|
||||
|
||||
from aiogram import Bot, Dispatcher
|
||||
from aiogram.client.default import DefaultBotProperties
|
||||
from aiogram.enums import ParseMode
|
||||
from aiogram.fsm.storage.memory import MemoryStorage
|
||||
|
||||
from config import API_TOKEN, REDIS_URL
|
||||
from database import async_session_maker
|
||||
from config import API_TOKEN
|
||||
from filters.private import IsPrivateFilter
|
||||
from utils.button_icons import apply_button_icons_patch, set_button_icon_config
|
||||
from utils.custom_emojis import initialize_custom_emojis
|
||||
from utils.errors import setup_error_handlers
|
||||
from utils.modules_loader import load_modules_from_folder, modules_hub
|
||||
|
||||
|
||||
apply_button_icons_patch()
|
||||
|
||||
bot = Bot(token=API_TOKEN, default=DefaultBotProperties(parse_mode=ParseMode.HTML))
|
||||
|
||||
RedisStorage = import_module("aiogram.fsm.storage.redis").RedisStorage
|
||||
redis_from_url = import_module("redis.asyncio").from_url
|
||||
redis = redis_from_url(
|
||||
REDIS_URL,
|
||||
encoding="utf-8",
|
||||
decode_responses=True,
|
||||
max_connections=64,
|
||||
health_check_interval=30,
|
||||
socket_connect_timeout=5,
|
||||
socket_timeout=5,
|
||||
retry_on_timeout=True,
|
||||
)
|
||||
storage = RedisStorage(redis=redis)
|
||||
|
||||
storage = MemoryStorage()
|
||||
dp = Dispatcher(bot=bot, storage=storage)
|
||||
|
||||
dp.include_router(modules_hub)
|
||||
@@ -43,27 +22,10 @@ load_modules_from_folder()
|
||||
|
||||
from handlers.buttons import BUTTON_ICON_CONFIG
|
||||
|
||||
|
||||
set_button_icon_config(BUTTON_ICON_CONFIG)
|
||||
|
||||
dp.message.filter(IsPrivateFilter())
|
||||
dp.callback_query.filter(IsPrivateFilter())
|
||||
|
||||
|
||||
async def _on_dispatcher_startup(*_args, **_kwargs):
|
||||
from core.tasks import ensure_periodic_task_manager_started
|
||||
|
||||
await ensure_periodic_task_manager_started(bot, async_session_maker)
|
||||
|
||||
|
||||
async def _on_dispatcher_shutdown(*_args, **_kwargs):
|
||||
from core.tasks import ensure_periodic_task_manager_stopped
|
||||
|
||||
await ensure_periodic_task_manager_stopped()
|
||||
|
||||
|
||||
dp.startup.register(_on_dispatcher_startup)
|
||||
dp.shutdown.register(_on_dispatcher_shutdown)
|
||||
|
||||
setup_error_handlers(dp)
|
||||
initialize_custom_emojis()
|
||||
|
||||
+95
-2188
File diff suppressed because it is too large
Load Diff
@@ -1 +0,0 @@
|
||||
|
||||
|
||||
Binary file not shown.
@@ -1,8 +1,5 @@
|
||||
from logger import logger
|
||||
|
||||
from database import async_session_maker
|
||||
from database.db import warm_pool
|
||||
from database.settings_cache import settings_cache
|
||||
from database.tariffs import initialize_all_tariff_weights
|
||||
|
||||
from .settings.buttons_config import BUTTONS_CONFIG, load_buttons_config, update_buttons_config
|
||||
@@ -12,9 +9,7 @@ from .settings.money_config import MONEY_CONFIG, load_money_config, update_money
|
||||
from .settings.notifications_config import NOTIFICATIONS_CONFIG, load_notifications_config, update_notifications_config
|
||||
from .settings.payments_config import PAYMENTS_CONFIG, load_payments_config, update_payments_config
|
||||
from .settings.providers_order_config import PROVIDERS_ORDER, load_providers_order, update_providers_order
|
||||
from .settings.runtime_sync import publish_runtime_snapshot
|
||||
from .settings.tariffs_config import TARIFFS_CONFIG, load_tariffs_config, update_tariffs_config
|
||||
from .settings.web_config import WEB_CONFIG, load_web_config, update_web_config
|
||||
|
||||
|
||||
async def bootstrap() -> None:
|
||||
@@ -29,52 +24,4 @@ async def bootstrap() -> None:
|
||||
await load_money_config(session)
|
||||
await load_management_config(session)
|
||||
await load_tariffs_config(session)
|
||||
await load_web_config(session)
|
||||
await session.commit()
|
||||
await settings_cache.load(session)
|
||||
await publish_runtime_snapshot()
|
||||
|
||||
try:
|
||||
from api.v2.routes._data_uri_migration import run_startup_data_uri_migration
|
||||
|
||||
async with async_session_maker() as session:
|
||||
rows_updated, uris_replaced = await run_startup_data_uri_migration(session)
|
||||
if uris_replaced:
|
||||
await session.commit()
|
||||
logger.info(
|
||||
"[bootstrap] data: URI migration: rows_updated={} uris_replaced={}",
|
||||
rows_updated,
|
||||
uris_replaced,
|
||||
)
|
||||
except Exception as exc:
|
||||
logger.warning("[bootstrap] data: URI migration failed: {}", exc)
|
||||
|
||||
try:
|
||||
from config import API_TOKEN_TTL_DAYS
|
||||
from database.identity_sessions import cleanup_expired_sessions
|
||||
|
||||
async with async_session_maker() as session:
|
||||
if API_TOKEN_TTL_DAYS is None:
|
||||
from sqlalchemy import delete
|
||||
|
||||
from database.models import IdentitySession
|
||||
|
||||
result = await session.execute(delete(IdentitySession))
|
||||
removed = int(result.rowcount or 0)
|
||||
if removed:
|
||||
await session.commit()
|
||||
logger.info(
|
||||
"[bootstrap] API_TOKEN_TTL_DAYS=None → identity sessions wiped on restart: {}",
|
||||
removed,
|
||||
)
|
||||
else:
|
||||
removed = await cleanup_expired_sessions(session)
|
||||
if removed:
|
||||
await session.commit()
|
||||
logger.info(
|
||||
"[bootstrap] expired identity sessions removed (TTL={}d): {}",
|
||||
API_TOKEN_TTL_DAYS,
|
||||
removed,
|
||||
)
|
||||
except Exception as exc:
|
||||
logger.warning("[bootstrap] identity-session cleanup failed: {}", exc)
|
||||
|
||||
@@ -1,85 +0,0 @@
|
||||
UPDATE_STALE_AGE_SEC = 60
|
||||
|
||||
CONCURRENCY_MAX_WAIT_SEC = 300
|
||||
CONCURRENCY_LIMIT = 200
|
||||
|
||||
SUBSCRIPTION_CACHE_SUBSCRIBED_MAXSIZE = 200_000
|
||||
SUBSCRIPTION_CACHE_SUBSCRIBED_TTL_SEC = 600
|
||||
SUBSCRIPTION_CACHE_UNSUBSCRIBED_MAXSIZE = 100_000
|
||||
SUBSCRIPTION_CACHE_UNSUBSCRIBED_TTL_SEC = 60
|
||||
SUBSCRIPTION_GET_CHAT_MEMBER_TIMEOUT_SEC = 4.0
|
||||
|
||||
CONCURRENCY_REJECT_NOTICE_CACHE_MAXSIZE = 100_000
|
||||
CONCURRENCY_REJECT_NOTICE_TTL_SEC = 5
|
||||
|
||||
THROTTLE_CACHE_MAXSIZE = 50_000
|
||||
THROTTLE_CACHE_TTL_SEC = 1.0
|
||||
THROTTLE_NOTICE_CACHE_MAXSIZE = 50_000
|
||||
THROTTLE_NOTICE_TTL_SEC = 1.0
|
||||
|
||||
START_UTM_EXISTS_CACHE_MAXSIZE = 20_000
|
||||
START_UTM_EXISTS_TTL_SEC = 300
|
||||
|
||||
USER_SNAPSHOT_CACHE_MAXSIZE = 150_000
|
||||
USER_SNAPSHOT_CACHE_TTL_SEC = 30
|
||||
USER_EXISTS_CACHE_MAXSIZE = 150_000
|
||||
USER_EXISTS_CACHE_TTL_SEC = 60
|
||||
|
||||
BAN_CACHE_MAXSIZE = 50_000
|
||||
BAN_CACHE_TTL_SEC = 60
|
||||
|
||||
DIRECT_START_USER_EXISTS_CACHE_MAXSIZE = 50_000
|
||||
DIRECT_START_USER_EXISTS_CACHE_TTL_SEC = 20
|
||||
|
||||
ADMIN_CACHE_MAXSIZE = 10_000
|
||||
ADMIN_CACHE_TTL_SEC = 60
|
||||
|
||||
REMNAWAVE_SERVER_CACHE_MAXSIZE = 50_000
|
||||
REMNAWAVE_SERVER_CACHE_TTL_SEC = 300
|
||||
REMNAWAVE_PROFILE_CACHE_MAXSIZE = 200_000
|
||||
REMNAWAVE_PROFILE_CACHE_TTL_SEC = 20
|
||||
REMNAWAVE_PROFILE_TIMEOUT_SEC = 10.0
|
||||
REMNAWAVE_PROFILE_ERROR_CACHE_TTL_SEC = 45
|
||||
REMNAWAVE_ACTION_TIMEOUT_SEC = 5.0
|
||||
REMNAWAVE_MAX_CONCURRENCY = 20
|
||||
|
||||
RUNTIME_CONFIG_SYNC_PULL_INTERVAL_SEC = 1.0
|
||||
RUNTIME_CONFIG_SYNC_TTL_SEC = 86_400
|
||||
|
||||
SUBSCRIPTION_RESPONSE_CACHE_TTL_SEC = 20
|
||||
SUBSCRIPTION_HANDLER_CONCURRENCY = 80
|
||||
|
||||
SERVERS_CACHE_TTL_SEC = 60
|
||||
TARIFF_BY_ID_CACHE_TTL_SEC = 120
|
||||
TARIFFS_FOR_CLUSTER_CACHE_TTL_SEC = 120
|
||||
|
||||
KEY_DETAILS_CACHE_TTL_SEC = 45
|
||||
KEYS_LIST_CACHE_TTL_SEC = 25
|
||||
KEY_COUNT_CACHE_TTL_SEC = 25
|
||||
|
||||
BALANCE_CACHE_TTL_SEC = 25
|
||||
PROFILE_DATA_CACHE_TTL_SEC = 25
|
||||
|
||||
PAYMENT_PENDING_CACHE_TTL_SEC = 3600
|
||||
|
||||
|
||||
AUDIT_HISTORY_CACHE_TTL_SEC = 300
|
||||
|
||||
AUDIT_REDIS_BUFFER_ENABLED = True
|
||||
AUDIT_REDIS_FLUSH_KEY = "audit:flush"
|
||||
AUDIT_REDIS_USER_PREFIX = "audit:user:tg:"
|
||||
AUDIT_REDIS_IDENTITY_PREFIX = "audit:user:identity:"
|
||||
AUDIT_REDIS_USER_TTL_SEC = 25 * 3600
|
||||
AUDIT_REDIS_DRAIN_BATCH = 1000
|
||||
|
||||
BLOCKED_EVENTS_REDIS_KEY = "blocked:events"
|
||||
BLOCKED_DRAIN_BATCH = 500
|
||||
BLOCKED_DRAIN_INTERVAL_SEC = 30
|
||||
|
||||
ERROR_THROTTLE_WINDOW_SEC = 60
|
||||
ERROR_THROTTLE_MAX_KEYS = 500
|
||||
ERROR_THROTTLE_MESSAGE_MAX_LEN = 120
|
||||
|
||||
WEBHOOK_ABUSE_FAIL_THRESHOLD = 10
|
||||
WEBHOOK_ABUSE_FAIL_WINDOW_SEC = 60
|
||||
WEBHOOK_ABUSE_BLOCK_TTL_SEC = 300
|
||||
Binary file not shown.
@@ -1,109 +0,0 @@
|
||||
import asyncio
|
||||
import atexit
|
||||
import multiprocessing
|
||||
import signal
|
||||
|
||||
from collections.abc import Callable
|
||||
from concurrent.futures import ProcessPoolExecutor, ThreadPoolExecutor
|
||||
from typing import TypeVar
|
||||
|
||||
from logger import logger
|
||||
|
||||
|
||||
T = TypeVar("T")
|
||||
|
||||
_thread_pool: ThreadPoolExecutor | None = None
|
||||
_process_pool: ProcessPoolExecutor | None = None
|
||||
|
||||
|
||||
def _atexit_shutdown_pools() -> None:
|
||||
"""Очистка пулов при выходе из процесса (в т.ч. по atexit), уменьшает предупреждения resource_tracker."""
|
||||
shutdown_process_pool()
|
||||
shutdown_thread_pool()
|
||||
|
||||
|
||||
def _worker_ignore_sigint() -> None:
|
||||
"""Initializer воркера: игнорирует SIGINT, чтобы Ctrl+C не обрывал queue.get() с трейсбеком."""
|
||||
signal.signal(signal.SIGINT, signal.SIG_IGN)
|
||||
|
||||
|
||||
def get_thread_pool() -> ThreadPoolExecutor:
|
||||
"""Возвращает общий пул потоков (создаёт при первом вызове)."""
|
||||
global _thread_pool
|
||||
if _thread_pool is None:
|
||||
from config import EXECUTOR_POOL_SIZE
|
||||
|
||||
size = max(1, int(EXECUTOR_POOL_SIZE))
|
||||
_thread_pool = ThreadPoolExecutor(max_workers=size, thread_name_prefix="bot-thread")
|
||||
logger.debug("[Executor] Пул потоков: {} воркеров", size)
|
||||
return _thread_pool
|
||||
|
||||
|
||||
def shutdown_thread_pool() -> None:
|
||||
"""Останавливает пул потоков (вызывать при shutdown приложения)."""
|
||||
global _thread_pool
|
||||
if _thread_pool is not None:
|
||||
_thread_pool.shutdown(wait=True)
|
||||
_thread_pool = None
|
||||
logger.debug("[Executor] Пул потоков остановлен")
|
||||
|
||||
|
||||
def get_process_pool() -> ProcessPoolExecutor:
|
||||
"""
|
||||
Возвращает пул процессов для тяжёлых задач (бэкап и т.д.).
|
||||
Задачи выполняются в отдельных процессах и могут использовать другие ядра CPU.
|
||||
"""
|
||||
global _process_pool
|
||||
if _process_pool is None:
|
||||
from config import PROCESS_POOL_SIZE
|
||||
|
||||
size = max(1, min(int(PROCESS_POOL_SIZE), multiprocessing.cpu_count() or 4))
|
||||
ctx = multiprocessing.get_context("spawn")
|
||||
_process_pool = ProcessPoolExecutor(
|
||||
max_workers=size,
|
||||
mp_context=ctx,
|
||||
initializer=_worker_ignore_sigint,
|
||||
)
|
||||
atexit.register(_atexit_shutdown_pools)
|
||||
logger.debug("[Executor] Пул процессов: {} воркеров", size)
|
||||
return _process_pool
|
||||
|
||||
|
||||
def shutdown_process_pool() -> None:
|
||||
"""Останавливает пул процессов (вызывать при shutdown приложения)."""
|
||||
global _process_pool
|
||||
if _process_pool is not None:
|
||||
try:
|
||||
atexit.unregister(_atexit_shutdown_pools)
|
||||
except Exception:
|
||||
pass
|
||||
_process_pool.shutdown(wait=True)
|
||||
_process_pool = None
|
||||
logger.debug("[Executor] Пул процессов остановлен")
|
||||
|
||||
|
||||
def should_run_heavy_tasks_separately() -> bool:
|
||||
"""
|
||||
True, если есть запас по ядрам/потокам — тогда рассылка и уведомления
|
||||
можно выносить в отдельный поток/ядро.
|
||||
"""
|
||||
try:
|
||||
from config import EXECUTOR_POOL_SIZE
|
||||
|
||||
pool_size = max(1, int(EXECUTOR_POOL_SIZE))
|
||||
except Exception:
|
||||
pool_size = 1
|
||||
cpu_count = multiprocessing.cpu_count() or 1
|
||||
return cpu_count >= 2 or pool_size >= 2
|
||||
|
||||
|
||||
async def run_io[T](fn: Callable[..., T], *args: object) -> T:
|
||||
"""Выполняет fn(*args) в пуле потоков (I/O). Один вызов для всех блокирующих операций."""
|
||||
loop = asyncio.get_running_loop()
|
||||
return await loop.run_in_executor(get_thread_pool(), lambda: fn(*args))
|
||||
|
||||
|
||||
async def run_cpu[T](fn: Callable[..., T], *args: object) -> T:
|
||||
"""Выполняет fn(*args) в пуле процессов (CPU). fn — функция уровня модуля (для pickle)."""
|
||||
loop = asyncio.get_running_loop()
|
||||
return await loop.run_in_executor(get_process_pool(), fn, *args)
|
||||
Binary file not shown.
@@ -1,304 +0,0 @@
|
||||
import asyncio
|
||||
import json
|
||||
import os
|
||||
import time
|
||||
|
||||
from importlib import import_module
|
||||
from typing import Any
|
||||
|
||||
from config import REDIS_URL
|
||||
from logger import logger
|
||||
|
||||
|
||||
_REDIS_CLIENTS: dict[tuple[int, int], Any] = {}
|
||||
_REDIS_UNAVAILABLE_UNTIL = 0.0
|
||||
_REDIS_BACKOFF_SEC = 5.0
|
||||
|
||||
|
||||
def _now() -> float:
|
||||
return time.monotonic()
|
||||
|
||||
|
||||
def _client_key() -> tuple[int, int]:
|
||||
try:
|
||||
loop = asyncio.get_running_loop()
|
||||
loop_id = id(loop)
|
||||
except RuntimeError:
|
||||
loop_id = 0
|
||||
return os.getpid(), loop_id
|
||||
|
||||
|
||||
def _drop_client(client_key: tuple[int, int]) -> None:
|
||||
_REDIS_CLIENTS.pop(client_key, None)
|
||||
|
||||
|
||||
async def _get_redis() -> Any | None:
|
||||
global _REDIS_UNAVAILABLE_UNTIL
|
||||
|
||||
client_key = _client_key()
|
||||
client = _REDIS_CLIENTS.get(client_key)
|
||||
if client is not None:
|
||||
return client
|
||||
if _REDIS_UNAVAILABLE_UNTIL > _now():
|
||||
return None
|
||||
|
||||
try:
|
||||
redis_from_url = import_module("redis.asyncio").from_url
|
||||
client = redis_from_url(
|
||||
REDIS_URL,
|
||||
encoding="utf-8",
|
||||
decode_responses=True,
|
||||
max_connections=64,
|
||||
health_check_interval=30,
|
||||
socket_connect_timeout=5,
|
||||
socket_timeout=5,
|
||||
retry_on_timeout=True,
|
||||
)
|
||||
await client.ping()
|
||||
_REDIS_CLIENTS[client_key] = client
|
||||
return client
|
||||
except Exception as exc:
|
||||
url_display = REDIS_URL.split("@")[-1] if "@" in REDIS_URL else REDIS_URL
|
||||
logger.warning(f"[Redis] Подключение не удалось ({url_display}): {exc}. Повтор через {_REDIS_BACKOFF_SEC} с.")
|
||||
_REDIS_UNAVAILABLE_UNTIL = _now() + _REDIS_BACKOFF_SEC
|
||||
_drop_client(client_key)
|
||||
return None
|
||||
|
||||
|
||||
def cache_key(prefix: str, *parts: Any) -> str:
|
||||
tail = ":".join(str(p) for p in parts)
|
||||
return f"{prefix}:{tail}" if tail else prefix
|
||||
|
||||
|
||||
async def cache_get(key: str) -> Any | None:
|
||||
client = await _get_redis()
|
||||
if client is None:
|
||||
return None
|
||||
try:
|
||||
raw = await client.get(key)
|
||||
if raw is None:
|
||||
return None
|
||||
return json.loads(raw)
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
async def cache_mget(keys: list[str]) -> list[Any | None]:
|
||||
"""Возвращает значения для ключей (None для отсутствующих). Один round-trip в Redis."""
|
||||
if not keys:
|
||||
return []
|
||||
client = await _get_redis()
|
||||
if client is None:
|
||||
return [None] * len(keys)
|
||||
try:
|
||||
raw_list = await client.mget(keys)
|
||||
result = []
|
||||
for raw in raw_list:
|
||||
if raw is None:
|
||||
result.append(None)
|
||||
else:
|
||||
try:
|
||||
result.append(json.loads(raw))
|
||||
except Exception:
|
||||
result.append(None)
|
||||
return result
|
||||
except Exception:
|
||||
return [None] * len(keys)
|
||||
|
||||
|
||||
async def cache_set(key: str, value: Any, ttl_sec: float) -> bool:
|
||||
client = await _get_redis()
|
||||
if client is None:
|
||||
return False
|
||||
try:
|
||||
ttl = max(1, int(ttl_sec))
|
||||
await client.set(key, json.dumps(value, ensure_ascii=False), ex=ttl)
|
||||
return True
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
|
||||
async def cache_delete(key: str) -> None:
|
||||
client = await _get_redis()
|
||||
if client is None:
|
||||
return
|
||||
try:
|
||||
await client.delete(key)
|
||||
except Exception:
|
||||
return
|
||||
|
||||
|
||||
async def cache_setnx(key: str, value: Any, ttl_sec: float) -> bool:
|
||||
client = await _get_redis()
|
||||
if client is None:
|
||||
return False
|
||||
try:
|
||||
ttl = max(1, int(ttl_sec))
|
||||
return bool(await client.set(key, json.dumps(value, ensure_ascii=False), ex=ttl, nx=True))
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
|
||||
async def cache_incr(key: str, ttl_sec: float) -> int:
|
||||
client = await _get_redis()
|
||||
if client is None:
|
||||
return 1
|
||||
try:
|
||||
value = await client.incr(key)
|
||||
if value == 1:
|
||||
await client.expire(key, max(1, int(ttl_sec)))
|
||||
return int(value)
|
||||
except Exception:
|
||||
return 1
|
||||
|
||||
|
||||
async def cache_incr_checked(key: str, ttl_sec: float) -> tuple[int, bool]:
|
||||
"""Возвращает (value, redis_available). redis_available=False значит клиент
|
||||
должен применить fallback-логику (например, in-memory limiter).
|
||||
"""
|
||||
client = await _get_redis()
|
||||
if client is None:
|
||||
return 1, False
|
||||
try:
|
||||
value = await client.incr(key)
|
||||
if value == 1:
|
||||
await client.expire(key, max(1, int(ttl_sec)))
|
||||
return int(value), True
|
||||
except Exception:
|
||||
return 1, False
|
||||
|
||||
|
||||
async def cache_delete_pattern(pattern: str) -> int:
|
||||
client = await _get_redis()
|
||||
if client is None:
|
||||
return 0
|
||||
deleted = 0
|
||||
try:
|
||||
async for key in client.scan_iter(match=pattern, count=200):
|
||||
deleted += int(await client.delete(key))
|
||||
except Exception:
|
||||
return deleted
|
||||
return deleted
|
||||
|
||||
|
||||
async def cache_rpush(key: str, *values: Any) -> int:
|
||||
"""Добавляет значения в хвост списка. Значения сериализуются в JSON. Возвращает длину списка после или 0 при ошибке."""
|
||||
if not values:
|
||||
return 0
|
||||
client_key = _client_key()
|
||||
client = await _get_redis()
|
||||
if client is None:
|
||||
return 0
|
||||
try:
|
||||
raw = [json.dumps(v, ensure_ascii=False) for v in values]
|
||||
return int(await client.rpush(key, *raw))
|
||||
except Exception as exc:
|
||||
logger.warning(f"[Redis] rpush({key}) не удался: {exc}")
|
||||
_drop_client(client_key)
|
||||
return 0
|
||||
|
||||
|
||||
async def cache_expire(key: str, ttl_sec: int) -> bool:
|
||||
"""Устанавливает TTL для ключа. Возвращает True при успехе."""
|
||||
client = await _get_redis()
|
||||
if client is None:
|
||||
return False
|
||||
try:
|
||||
return await client.expire(key, max(1, int(ttl_sec)))
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
|
||||
async def cache_lrange(key: str, start: int, end: int) -> list[Any]:
|
||||
"""Возвращает срез списка. end=-1 — до конца. Элементы десериализуются из JSON."""
|
||||
client = await _get_redis()
|
||||
if client is None:
|
||||
return []
|
||||
try:
|
||||
raw_list = await client.lrange(key, start, end)
|
||||
out = []
|
||||
for raw in raw_list:
|
||||
try:
|
||||
out.append(json.loads(raw))
|
||||
except Exception:
|
||||
pass
|
||||
return out
|
||||
except Exception:
|
||||
return []
|
||||
|
||||
|
||||
async def cache_lpop_batch(key: str, count: int) -> list[Any]:
|
||||
"""Забирает до count элементов с головы списка (FIFO). Совместимо с Redis < 6.2."""
|
||||
if count <= 0:
|
||||
return []
|
||||
client = await _get_redis()
|
||||
if client is None:
|
||||
return []
|
||||
out = []
|
||||
try:
|
||||
for _ in range(count):
|
||||
raw = await client.lpop(key)
|
||||
if raw is None:
|
||||
break
|
||||
try:
|
||||
out.append(json.loads(raw))
|
||||
except Exception:
|
||||
pass
|
||||
return out
|
||||
except Exception:
|
||||
return out
|
||||
|
||||
|
||||
async def cache_lmove_batch(source: str, destination: str, count: int) -> list[Any]:
|
||||
"""Атомарно переносит до count элементов из головы source в хвост destination."""
|
||||
if count <= 0:
|
||||
return []
|
||||
client = await _get_redis()
|
||||
if client is None:
|
||||
return []
|
||||
try:
|
||||
raw_list = await client.eval(
|
||||
"""
|
||||
local moved = {}
|
||||
local count = tonumber(ARGV[1])
|
||||
for i = 1, count do
|
||||
local item = redis.call('LPOP', KEYS[1])
|
||||
if not item then
|
||||
break
|
||||
end
|
||||
redis.call('RPUSH', KEYS[2], item)
|
||||
table.insert(moved, item)
|
||||
end
|
||||
return moved
|
||||
""",
|
||||
2,
|
||||
source,
|
||||
destination,
|
||||
int(count),
|
||||
)
|
||||
out = []
|
||||
for raw in raw_list or []:
|
||||
try:
|
||||
out.append(json.loads(raw))
|
||||
except Exception:
|
||||
pass
|
||||
return out
|
||||
except Exception as exc:
|
||||
logger.warning(f"[Redis] lmove_batch({source}->{destination}) не удался: {exc}")
|
||||
return []
|
||||
|
||||
|
||||
async def cache_publish(channel: str, payload: Any) -> int:
|
||||
client = await _get_redis()
|
||||
if client is None:
|
||||
return 0
|
||||
try:
|
||||
raw = json.dumps(payload, ensure_ascii=False) if not isinstance(payload, (str, bytes)) else payload
|
||||
return int(await client.publish(channel, raw))
|
||||
except Exception as exc:
|
||||
logger.warning(f"[Redis] publish({channel}) не удался: {exc}")
|
||||
return 0
|
||||
|
||||
|
||||
async def redis_connection_ok() -> bool:
|
||||
return await _get_redis() is not None
|
||||
Binary file not shown.
@@ -3,16 +3,12 @@ from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from database.models import Setting
|
||||
|
||||
from database.settings_cache import settings_cache
|
||||
from ..defaults import DEFAULT_BUTTONS_CONFIG
|
||||
from .runtime_sync import publish_runtime_config, register_runtime_config
|
||||
|
||||
|
||||
BUTTONS_CONFIG: dict[str, bool] = DEFAULT_BUTTONS_CONFIG.copy()
|
||||
BUTTONS_CONFIG.pop("TOGGLE_CLIENT_BUTTON_ENABLE", None)
|
||||
BUTTONS_CONFIG.setdefault("ANDROID_TV_BUTTON_ENABLE", False)
|
||||
BUTTONS_CONFIG.setdefault("COUPON_BUTTON_ENABLE", True)
|
||||
register_runtime_config("BUTTONS_CONFIG", BUTTONS_CONFIG)
|
||||
|
||||
|
||||
async def load_buttons_config(session: AsyncSession) -> None:
|
||||
@@ -22,7 +18,6 @@ async def load_buttons_config(session: AsyncSession) -> None:
|
||||
|
||||
if setting is None:
|
||||
buttons_config = DEFAULT_BUTTONS_CONFIG.copy()
|
||||
buttons_config.pop("TOGGLE_CLIENT_BUTTON_ENABLE", None)
|
||||
buttons_config.setdefault("ANDROID_TV_BUTTON_ENABLE", False)
|
||||
buttons_config.setdefault("COUPON_BUTTON_ENABLE", True)
|
||||
setting = Setting(
|
||||
@@ -35,7 +30,6 @@ async def load_buttons_config(session: AsyncSession) -> None:
|
||||
stored = setting.value or {}
|
||||
buttons_config = DEFAULT_BUTTONS_CONFIG.copy()
|
||||
buttons_config.update(stored)
|
||||
buttons_config.pop("TOGGLE_CLIENT_BUTTON_ENABLE", None)
|
||||
buttons_config.setdefault("ANDROID_TV_BUTTON_ENABLE", False)
|
||||
buttons_config.setdefault("COUPON_BUTTON_ENABLE", True)
|
||||
setting.value = buttons_config
|
||||
@@ -64,11 +58,8 @@ async def update_buttons_config(session: AsyncSession, new_values: dict[str, boo
|
||||
|
||||
buttons_config = DEFAULT_BUTTONS_CONFIG.copy()
|
||||
buttons_config.update(new_values)
|
||||
buttons_config.pop("TOGGLE_CLIENT_BUTTON_ENABLE", None)
|
||||
buttons_config.setdefault("ANDROID_TV_BUTTON_ENABLE", False)
|
||||
buttons_config.setdefault("COUPON_BUTTON_ENABLE", True)
|
||||
|
||||
BUTTONS_CONFIG.clear()
|
||||
BUTTONS_CONFIG.update(buttons_config)
|
||||
settings_cache.update("BUTTONS_CONFIG", buttons_config)
|
||||
await publish_runtime_config("BUTTONS_CONFIG", buttons_config)
|
||||
|
||||
@@ -5,14 +5,11 @@ from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from database.models import Setting
|
||||
|
||||
from database.settings_cache import settings_cache
|
||||
from ..defaults import DEFAULT_MANAGEMENT_CONFIG
|
||||
from .runtime_sync import publish_runtime_config, register_runtime_config
|
||||
|
||||
|
||||
MANAGEMENT_CONFIG: dict[str, Any] = DEFAULT_MANAGEMENT_CONFIG.copy()
|
||||
MANAGEMENT_SETTING_KEY = "MANAGEGENT_CONFIG"
|
||||
register_runtime_config(MANAGEMENT_SETTING_KEY, MANAGEMENT_CONFIG)
|
||||
|
||||
|
||||
async def load_management_config(session: AsyncSession) -> None:
|
||||
@@ -61,5 +58,3 @@ async def update_management_config(session: AsyncSession, new_values: dict[str,
|
||||
|
||||
MANAGEMENT_CONFIG.clear()
|
||||
MANAGEMENT_CONFIG.update(management_config)
|
||||
settings_cache.update(MANAGEMENT_SETTING_KEY, management_config)
|
||||
await publish_runtime_config(MANAGEMENT_SETTING_KEY, management_config)
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user