Compare commits
1678 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| c5c8c68825 | |||
| 166077b14e | |||
| ef8874d323 | |||
| fb478dff38 | |||
| 8bc9f3ba0e | |||
| 075ad515af | |||
| c09470fe49 | |||
| 6ebaff08e8 | |||
| bf09fbac26 | |||
| 50de5d7af6 | |||
| 06eabd8f26 | |||
| aa086a57d2 | |||
| 3a7bf7430d | |||
| 55bebbd924 | |||
| 0893b8cc7a | |||
| f9edc64b23 | |||
| 15c0f12c31 | |||
| 9c9dafb459 | |||
| c0edb9fe9b | |||
| 4da12d850a | |||
| 954326b48f | |||
| 92b74af0ac | |||
| db14ba6dac | |||
| d46a1a4e3a | |||
| c6e421bcce | |||
| 4dd2f5f245 | |||
| f1f8df4715 | |||
| b1f51dbf21 | |||
| 5c144229b2 | |||
| c6d316f4c8 | |||
| 6151e25974 | |||
| b50738209e | |||
| ee69075195 | |||
| 11f122a13a | |||
| 88e947beb9 | |||
| 68ea6db6c7 | |||
| 4a60fb18c0 | |||
| 884b5558fc | |||
| 789acdf9a2 | |||
| 31d008efbb | |||
| bfd628b24e | |||
| ed30a21842 | |||
| 469f8c8050 | |||
| 0b17346cb6 | |||
| cc2c9fbdba | |||
| 425a10423f | |||
| 57c26ff344 | |||
| 6fd335d71c | |||
| 3b913956e9 | |||
| eabbdeee8f | |||
| 752ee206f2 | |||
| 5b4437a115 | |||
| 7dc17b821d | |||
| 4bd6ce4970 | |||
| 0821b69879 | |||
| 4cb84ceacc | |||
| 11b5a8f4bd | |||
| 76cf9df266 | |||
| 19feb4cb80 | |||
| a55c591f52 | |||
| 5742f1d68d | |||
| 9624e3d5c4 | |||
| 9f140542d9 | |||
| 995e2f8016 | |||
| 674ce2d609 | |||
| 1e1275f3c7 | |||
| 083e3a59dd | |||
| e9e3c6596b | |||
| fe27132102 | |||
| 46f7ad768a | |||
| 6da28ef3e9 | |||
| 6f9b6f4e8e | |||
| 39dd2432cc | |||
| 0e8b0d04f0 | |||
| 9521578cae | |||
| 358725db1e | |||
| ce01d48501 | |||
| d2b9b1edbb | |||
| dfab5f9bc0 | |||
| c693c28ee7 | |||
| c1b9b20ea3 | |||
| 3b67715ac0 | |||
| fec4e37901 | |||
| 350ed85839 | |||
| 6ced5ea4e4 | |||
| 14dcb72be8 | |||
| 4bb4914a59 | |||
| 19ae3d1611 | |||
| bffa1adece | |||
| e036851440 | |||
| bc558ea54f | |||
| 9e0c4b199d | |||
| 352bc22be2 | |||
| 3a304afc49 | |||
| a9dad6a099 | |||
| 3b4309e001 | |||
| 6d341a9f57 | |||
| 77f927660b | |||
| 11d11b2fee | |||
| 31f008e9e5 | |||
| 2d571d4d97 | |||
| 1acf239458 | |||
| 10beddfc6b | |||
| 11d0e57541 | |||
| d0f7695097 | |||
| d3dc53e9d5 | |||
| 33a6da852e | |||
| 6882a4adfe | |||
| b448b918c9 | |||
| 630bf5069c | |||
| 0e4dfbf369 | |||
| 18a72f9410 | |||
| 5709dca63c | |||
| 2fdcb283eb | |||
| 220c5ce2e4 | |||
| 69b05cb9a5 | |||
| 6f76608b04 | |||
| 405c3fef1b | |||
| b8906cd3e3 | |||
| b46c3b6912 | |||
| fc0ae90f33 | |||
| 51ad7cb0cc | |||
| d3c1b4a71a | |||
| b17ebc32e2 | |||
| 78fa0ee82e | |||
| 4b1a9a2567 | |||
| aed63de100 | |||
| e702d21c33 | |||
| 417566a202 | |||
| 0b9f223b91 | |||
| 60eb47f920 | |||
| 05a19f01b1 | |||
| 2580835f55 | |||
| 49de94069f | |||
| d3500af899 | |||
| ec84972591 | |||
| e193c920e6 | |||
| ada1aed98c | |||
| 30fd6ce55d | |||
| 9a921132c4 | |||
| 842bad1d80 | |||
| a2c29b0697 | |||
| 4df02fdefc | |||
| f8cee4752a | |||
| c5fab1ccec | |||
| f6ff238ca7 | |||
| 8d0f18c929 | |||
| c87de32d71 | |||
| 4e4a51fc72 | |||
| a35b233928 | |||
| 7faa7e7409 | |||
| 48e981c7c6 | |||
| 78cbd0e0de | |||
| b62be683e1 | |||
| cc00522390 | |||
| 33271eeea6 | |||
| 40c0e1ce51 | |||
| bd0aac897b | |||
| aded285612 | |||
| 4fb6c5afd5 | |||
| 7943227c1c | |||
| d8a4d3206d | |||
| 349db285f6 | |||
| d30cf0b8a3 | |||
| 2ff179d68a | |||
| 920d805789 | |||
| b29e71e474 | |||
| 302c2033a8 | |||
| 18fb66c1a4 | |||
| 50fbd1841b | |||
| da01520d13 | |||
| 8b2d79e49c | |||
| ec29051b13 | |||
| b3c2894618 | |||
| 19aaedf417 | |||
| 5a25caf55f | |||
| 554083b284 | |||
| 4d2e0f59a0 | |||
| 59450667ed | |||
| 056d6f50ec | |||
| 0d3fc00bc9 | |||
| 7af380538e | |||
| 16a8d88bd0 | |||
| 6b226fb0de | |||
| 1f164a6951 | |||
| da354e55b9 | |||
| f7fdef06e5 | |||
| 73214b2cc9 | |||
| 13e653a43a | |||
| 53d478b9f1 | |||
| 06bee21873 | |||
| 78cbe1b0af | |||
| fcb786399b | |||
| 17928f8fbf | |||
| 3ab49099be | |||
| d3180360a1 | |||
| 0f0df91d86 | |||
| a8b207b059 | |||
| 4209dcc919 | |||
| e827ad9811 | |||
| fa8a7c8026 | |||
| 782078313a | |||
| be725c5ebb | |||
| 9d8d4d597b | |||
| 562c0f8d25 | |||
| 0fa5c6b682 | |||
| c39e315662 | |||
| 430c247cf5 | |||
| 623de6085f | |||
| cc6b7ef783 | |||
| e3fe8cf5d3 | |||
| 9d02ad6fbc | |||
| c7563096e9 | |||
| 7032e0eca2 | |||
| fb1b8e4858 | |||
| 4e4ceb8b03 | |||
| 3e0974941f | |||
| 883eca14b8 | |||
| ccef6afb7c | |||
| a081804268 | |||
| d48713b8ba | |||
| 24bb2596c5 | |||
| 03e1d6d496 | |||
| 8527ddc268 | |||
| 73a1ea8e1b | |||
| 39f91d0d19 | |||
| f9023ef975 | |||
| 6bcbd90980 | |||
| d3ad2edfcf | |||
| d0c1daf3cc | |||
| 7914b3502b | |||
| c098566c32 | |||
| 13e002aba9 | |||
| 9a51aa19a2 | |||
| 0cf72d5f30 | |||
| b4005b2786 | |||
| 4ccd95207b | |||
| af5657f490 | |||
| 54c8b8cee9 | |||
| 82c664fa68 | |||
| cf8259e623 | |||
| 222de4d822 | |||
| 33d4299bd5 | |||
| c5e16cc402 | |||
| dfbf640fb3 | |||
| 3bb36c7a66 | |||
| 2e940c1407 | |||
| 0710a3464c | |||
| 954747ff5d | |||
| e6ead41f84 | |||
| a8754fc40e | |||
| 2e30d82736 | |||
| d72907470c | |||
| c661a9b2f6 | |||
| 4e3520ee22 | |||
| 313bdfdc62 | |||
| c4c036dd88 | |||
| 158fb4a51e | |||
| 7ea0c29cdc | |||
| 008643c62e | |||
| 22a0eced2b | |||
| 17547cd323 | |||
| 570df97d33 | |||
| 19c895e4f9 | |||
| 40d2aa8e92 | |||
| 5a0fae165a | |||
| 132b26c9af | |||
| 827f63428e | |||
| 5532f37370 | |||
| d550ac78cd | |||
| 5d059c16fb | |||
| fedc89e362 | |||
| 773f05d113 | |||
| b249f3c694 | |||
| eae52bb799 | |||
| 9472aff2bf | |||
| 48f0316897 | |||
| d84f06a941 | |||
| 3b4754ebe8 | |||
| d82d453ce3 | |||
| 634ca4a6ad | |||
| 45229441c3 | |||
| a345f4416c | |||
| f5cf6a5398 | |||
| 726ba8dd66 | |||
| bef5469e40 | |||
| 57e33dd2ec | |||
| e018fb11d4 | |||
| 754f0beafc | |||
| 6a0371fc49 | |||
| acd56e2ef9 | |||
| d6152bc3fd | |||
| 04bfd537a2 | |||
| 6835d89e21 | |||
| ce29a37990 | |||
| 78e1a4d63b | |||
| e9ff036934 | |||
| 050e4ae139 | |||
| a7c4e06102 | |||
| 5e230e4286 | |||
| a027d0b1a9 | |||
| 08509052c0 | |||
| 7e587e5ae0 | |||
| 2766dc9d00 | |||
| e2090b00ed | |||
| 560fb64010 | |||
| 3cb22a5c7d | |||
| 30de90851b | |||
| 807b32d87c | |||
| 33d573daa1 | |||
| 93aaaa8f05 | |||
| cc36f16e43 | |||
| 026922fc88 | |||
| 5ab7e80e64 | |||
| 4ed43150f5 | |||
| 97b582d02e | |||
| 7488eac897 | |||
| c1d0b06eb3 | |||
| 284e20c839 | |||
| 8ffab98ec8 | |||
| 2a0005248a | |||
| f9d39aa736 | |||
| fa271581fe | |||
| d1caf97d15 | |||
| e3fd60d8d5 | |||
| 54f5a6a3e2 | |||
| 6772a370bc | |||
| 5d5adfd82a | |||
| 73b6edff9f | |||
| f94a26c54e | |||
| 1084fac243 | |||
| bb21aabbe3 | |||
| 1a04fa03d8 | |||
| 9feeb0e1d5 | |||
| 5da236ce5d | |||
| 978abc8acb | |||
| 4e859c90df | |||
| 827701a9d6 | |||
| 97eb7226fa | |||
| e1825603cd | |||
| f2c6f79592 | |||
| a041ef76b2 | |||
| 7d68bbc273 | |||
| f69bea787a | |||
| a4d71682bf | |||
| 5e5c3f77c7 | |||
| 608b520b72 | |||
| 8e8dcab230 | |||
| 91c0db6e3c | |||
| 2b3c301760 | |||
| db009d52a0 | |||
| 6f3252c0d1 | |||
| d148d5efd5 | |||
| 6123269404 | |||
| 4840d8b10c | |||
| 5dffe96f80 | |||
| eaa98c9329 | |||
| d51a0eafe8 | |||
| f14b8fb127 | |||
| 00dcd95523 | |||
| cf280444b2 | |||
| 1118105b7c | |||
| 77707efce8 | |||
| 9f6717bf82 | |||
| ef32c71668 | |||
| eb1fb341fa | |||
| b0eb63441e | |||
| fdda700a46 | |||
| 06b4dad525 | |||
| 4cd74b058d | |||
| 82d07736a1 | |||
| 9eb56101be | |||
| 7737b253e5 | |||
| 2a8ec56fc4 | |||
| f4633e82b0 | |||
| 10214cae85 | |||
| 5246e3609a | |||
| 31b615be3e | |||
| 49aeb918dd | |||
| 3cbb6a904d | |||
| 1b4f1fb192 | |||
| 5a55324b2e | |||
| d4a8f0921e | |||
| 7b54fcf3ad | |||
| 9be986d627 | |||
| cc54baa4ca | |||
| 93387c81d0 | |||
| 2cb66f52c8 | |||
| bb3775be4f | |||
| e72bcde45b | |||
| 41b7423dc9 | |||
| a54310da27 | |||
| 8e55225724 | |||
| feae5d3162 | |||
| 3b0090d1ee | |||
| 60c6cb4c5f | |||
| 934fb3b325 | |||
| 8e68502b2b | |||
| 6df6a21446 | |||
| a52c629480 | |||
| ab8efe8529 | |||
| d8ff76b64d | |||
| a5a4e16990 | |||
| 09468ba5a9 | |||
| 857aef1c4c | |||
| 313cc97861 | |||
| 4b58ddc639 | |||
| 56e5f6824e | |||
| 3ac36f39d7 | |||
| dc5453c100 | |||
| ad0997a19d | |||
| 027d5cc0cf | |||
| d7edddcaf4 | |||
| dd8154c58b | |||
| b4a99c6c3f | |||
| 4cad021b0b | |||
| e3546703d9 | |||
| 9bfea3f480 | |||
| 97aab0e265 | |||
| d5048fde38 | |||
| e90e7dbe5f | |||
| 9d8fa33ce8 | |||
| 9d1760c7ca | |||
| a833b11b8e | |||
| 35ef17660c | |||
| 2d52fd1409 | |||
| c87f3915ca | |||
| 5c45aced3f | |||
| e696ee08a5 | |||
| 9fa6d20a73 | |||
| 7c16491548 | |||
| 618291a466 | |||
| 221b797dad | |||
| 99759c3326 | |||
| 530e63d70c | |||
| 1565ec4820 | |||
| de61c04f4a | |||
| ca96ffc9d9 | |||
| 33670143b7 | |||
| 3dbbc804c9 | |||
| a644316349 | |||
| 957d56b32d | |||
| 9da72b2575 | |||
| 7ed533413c | |||
| 1873284771 | |||
| 27140d2f95 | |||
| 8dce80b3a5 | |||
| cca97f697a | |||
| e269dc64c2 | |||
| 39380d503a | |||
| 978e22cf1f | |||
| 356061f7ef | |||
| b76da13a9b | |||
| 9cc9ce4856 | |||
| 94f818ddd2 | |||
| cad9c50c80 | |||
| a90c607dab | |||
| dd93d201f0 | |||
| 481eda1e4a | |||
| 8d1f2c1d63 | |||
| ad21e7e697 | |||
| 07d8be2cb1 | |||
| 7016fe6bd4 | |||
| 5ddeca9f27 | |||
| 2b610ca866 | |||
| 730c245163 | |||
| 077806eb80 | |||
| 18df153ee0 | |||
| e209bb5ace | |||
| b32a7ac20b | |||
| 3647e52475 | |||
| 9b3f176179 | |||
| ed8aafe33a | |||
| 5b1c827413 | |||
| 688bbe04bd | |||
| 5828132be0 | |||
| 7a677e48ff | |||
| 47b22623fa | |||
| 319e34fb10 | |||
| 8670a21624 | |||
| 142ef03813 | |||
| 0e8bfd46ad | |||
| f3be55e037 | |||
| 1bc3e544ed | |||
| 842cd14e78 | |||
| a7f150e1b9 | |||
| 04450e9c83 | |||
| 81f6227754 | |||
| d3998fe379 | |||
| f136ce322e | |||
| b16072d6e0 | |||
| 8354aac254 | |||
| 604ea7f706 | |||
| b44302deb3 | |||
| 16cf8bb1d5 | |||
| a8bc620989 | |||
| 01e3a8f456 | |||
| e2ad136dd7 | |||
| 36e1541af1 | |||
| 54763cd18f | |||
| 6a0f4f8509 | |||
| dd4a28edbb | |||
| a02a17838e | |||
| 7caee34147 | |||
| f08d53362b | |||
| 537fe1b480 | |||
| e1e85de286 | |||
| c3eb68f325 | |||
| b324df29bd | |||
| 11ab7b5fb6 | |||
| 38eeeab300 | |||
| c3c5ba7f73 | |||
| 7e256ad380 | |||
| 9dd99c5f97 | |||
| 5d6e9b418e | |||
| df57d71b4c | |||
| 9b51324c80 | |||
| 6920a3f845 | |||
| 6a4fafbdf0 | |||
| fc0079770d | |||
| c151d8c66d | |||
| eb0aa3ca37 | |||
| 11c9a0b61e | |||
| 2aed629daa | |||
| 7e129e534e | |||
| 4e0f7fb2ee | |||
| b720680af0 | |||
| f261d5647a | |||
| 4664e968b5 | |||
| e047306190 | |||
| ca156c1271 | |||
| 0c5208b7fa | |||
| 4359c36444 | |||
| 84afe54894 | |||
| 30d8ef4bee | |||
| 0dd6dcda65 | |||
| f81fc74f17 | |||
| 6c47e3751d | |||
| ebc6a510b4 | |||
| 2b2ba6b647 | |||
| 41fdd111a7 | |||
| 056a1ac07b | |||
| aa74e6fccb | |||
| 1ffaf84f5f | |||
| e06b0ef1bc | |||
| cebdeb78fe | |||
| 40ccd8060a | |||
| 902a051dc1 | |||
| 973436167b | |||
| aaea5b4dbe | |||
| 0e43ad9694 | |||
| 8b858fbb43 | |||
| 40982f1ca1 | |||
| 1bdbac6f2d | |||
| ef6b347bf5 | |||
| 33b31a411c | |||
| 9a5b41911d | |||
| 2526e7c515 | |||
| 181b141c15 | |||
| 85d26f2be7 | |||
| 47f8654adc | |||
| 5b2e2557c4 | |||
| e20a94617c | |||
| 8b97da6082 | |||
| b76b524183 | |||
| e93d7e0baf | |||
| d97ca64ab5 | |||
| 6d53bf5e4d | |||
| 9350cab0bf | |||
| 42f858e1b4 | |||
| 5fdeee6b0f | |||
| 64c40ae749 | |||
| f267103a5e | |||
| 3eb760a972 | |||
| 029e7d027b | |||
| 66489c55e8 | |||
| c19a6afda9 | |||
| 3891eff224 | |||
| e974ab4093 | |||
| a6b4420708 | |||
| f62dbaa766 | |||
| b0d6c3fe4c | |||
| f64c6b401a | |||
| b59434332d | |||
| 74f96f568f | |||
| cbcc4b65c5 | |||
| 7cd99073d0 | |||
| 8a9a48fed0 | |||
| fd6bfcea0b | |||
| 040f3f1cda | |||
| 2e02629cbb | |||
| 63e789d112 | |||
| 6802605331 | |||
| 64cadac2f6 | |||
| 1ab33e9dc8 | |||
| 0f4ee2a57c | |||
| 78f292ac86 | |||
| 40758a335e | |||
| 2d580db31f | |||
| 30bb953dcd | |||
| 8e88316efd | |||
| 723474e150 | |||
| ffb6d5f99e | |||
| 03487342f7 | |||
| dcb101d19d | |||
| d35cf5dd74 | |||
| 942bdd8b61 | |||
| b574275672 | |||
| 571a170c84 | |||
| 4e5499a173 | |||
| 56155435ab | |||
| d33153247b | |||
| fa60336c5a | |||
| a70a0d25b2 | |||
| c9d424364e | |||
| e5d709ff64 | |||
| 651e1c2d32 | |||
| 64049ce9c2 | |||
| 44a64ee557 | |||
| f4e31690c1 | |||
| 48a579f506 | |||
| d427d20eb4 | |||
| 984fc5dc76 | |||
| b0be4f396f | |||
| 2ea7471fba | |||
| ca5a4a3b90 | |||
| 611f9bfb14 | |||
| a01e6ee48e | |||
| 5571652a9a | |||
| 8dc01f2643 | |||
| 3af3fa2c66 | |||
| 819c814c95 | |||
| f275683be1 | |||
| 354ba30168 | |||
| 9fd47594fc | |||
| 6586940158 | |||
| 637751f554 | |||
| fe7183334e | |||
| ab47a4f2cd | |||
| 05a16c8497 | |||
| 73830f448e | |||
| 5a0458a1ee | |||
| 516d02dcc7 | |||
| 5f792dc1f5 | |||
| 0aed149404 | |||
| 91df7c5466 | |||
| 84a6fb46b6 | |||
| 99effd8878 | |||
| 24f1941e45 | |||
| d9e0f46b98 | |||
| de13bd7664 | |||
| 129dd8ced8 | |||
| bf460e6a43 | |||
| be69447178 | |||
| f6a2d05b93 | |||
| fd0340b1b2 | |||
| 3fd66d580b | |||
| b18a2f81c0 | |||
| fd56e45532 | |||
| 4931e5671a | |||
| 3757bb6082 | |||
| 2196bb53a0 | |||
| 5929dc4c8f | |||
| 23234460d5 | |||
| 0832a5ca72 | |||
| 197a7a836c | |||
| bf1c3bbd6c | |||
| c4a7978f65 | |||
| 8a81efdd3a | |||
| e4aa757b6c | |||
| 3434f0c589 | |||
| 066fd40767 | |||
| 589ece52b0 | |||
| 186e961165 | |||
| a8379b0e6a | |||
| bc6325bc1d | |||
| 0a96757ccd | |||
| 9f045d1f00 | |||
| b3c770e94a | |||
| 780135922c | |||
| 3186367ad9 | |||
| 18fbb710dd | |||
| df33fed0b0 | |||
| 8bd4d2ea78 | |||
| 65f821a356 | |||
| 837acb35b4 | |||
| e2b22d131b | |||
| 2f33da4b4a | |||
| df4ecc7630 | |||
| 4c742c21ac | |||
| 9eb450cbef | |||
| 4dd7b7c551 | |||
| 4946a972c1 | |||
| e6157cedfe | |||
| 8916c13c15 | |||
| 427f9743a7 | |||
| c78b0502da | |||
| d87e897a56 | |||
| b027d329dd | |||
| 811f7276c8 | |||
| e083f2e4a9 | |||
| b5d51d6967 | |||
| fa3307a6f6 | |||
| ac715622ba | |||
| 3d85a1cf56 | |||
| c3e4c911c8 | |||
| 9cf30b3953 | |||
| 4c377a9768 | |||
| 6b17b0dc6d | |||
| ab0a128375 | |||
| 8cccb1db88 | |||
| 16d09cbe22 | |||
| 787f8e638f | |||
| 73b2785884 | |||
| 4268bed784 | |||
| 8a57b3ea2f | |||
| d16997dff6 | |||
| 3ffac1269f | |||
| 2742cc2ce4 | |||
| e1f5ee0e82 | |||
| 422ce15dca | |||
| 37d34e32df | |||
| 2655ccaa93 | |||
| 644d7cc812 | |||
| fd4025e51f | |||
| a37582dcb0 | |||
| 46096f89d5 | |||
| 9a174051c2 | |||
| 0f817920da | |||
| bc1981ff03 | |||
| 82a0dd1c86 | |||
| aa7832fb0d | |||
| e10bf92101 | |||
| cc3ce3a8bb | |||
| ef29a8e822 | |||
| bc5d466bc9 | |||
| 1eb79bec6d | |||
| 8dcab16f5a | |||
| 2dbf1ca508 | |||
| 26411ef982 | |||
| 9ae5cf2660 | |||
| adb8e56303 | |||
| 16cac60af8 | |||
| 236346da75 | |||
| dcbf121157 | |||
| 284412ecc5 | |||
| 45fd8fe221 | |||
| 6be48eb828 | |||
| e0212866b4 | |||
| 446b15ec08 | |||
| c794f3dcda | |||
| 889af1df40 | |||
| 86c28d0938 | |||
| 1246511af3 | |||
| eaf3ea0b98 | |||
| 75d50f890b | |||
| dcc64ad945 | |||
| 0a44c2da57 | |||
| 2c7356f0b9 | |||
| c4895e21b4 | |||
| 5e72f9d416 | |||
| 299c4cca87 | |||
| fe1d9bbfd7 | |||
| 7a27c942d8 | |||
| b1f373aca3 | |||
| c138011b5a | |||
| 0a8997d8e1 | |||
| 704381ab91 | |||
| 2fe0de2a26 | |||
| 7024161591 | |||
| 3d398a3816 | |||
| 34210dc808 | |||
| 706a003370 | |||
| 1fbf2a8f8c | |||
| 822ada54e2 | |||
| 8840aa6d49 | |||
| 97b32699ab | |||
| 2ce2816492 | |||
| 305c3576e3 | |||
| 10afee1194 | |||
| e5c569ab13 | |||
| 7d902900c3 | |||
| 0198622ddc | |||
| 6866e3c9e3 | |||
| d998d69b65 | |||
| 3c3834b5c7 | |||
| 5cc92af46a | |||
| 195ad5fdce | |||
| aee5bbcee4 | |||
| 252c072cec | |||
| d4e4bbabe8 | |||
| b836bcdc70 | |||
| ad7ed3e0b0 | |||
| 9dc00d815b | |||
| fca235ba98 | |||
| 44f79a52ca | |||
| 517e69948c | |||
| ca8a1b85f1 | |||
| 46aa5c46d4 | |||
| 6d6989e3c3 | |||
| 137c5e0640 | |||
| 10601761e6 | |||
| d4c623fb53 | |||
| 261ffea860 | |||
| 0aee5000ee | |||
| 3988759040 | |||
| bd332aed46 | |||
| 006e3c50b5 | |||
| fb568d2eea | |||
| c7e3dc65ef | |||
| b6e2a1d268 | |||
| b33a87b89e | |||
| ae2574fa0f | |||
| 10853ee28f | |||
| 95861462e0 | |||
| 8c27e43c52 | |||
| 860dbe1315 | |||
| 07abf08c02 | |||
| 420b20d126 | |||
| 4f2cdef7f9 | |||
| f2427d655e | |||
| fc70df9abd | |||
| c02b3bd438 | |||
| aaf3ec6496 | |||
| b71157514d | |||
| cb52ab235e | |||
| 589a841c71 | |||
| a5513a5014 | |||
| ca2b671be9 | |||
| 738590a265 | |||
| 6f5ec883f0 | |||
| 21350c43ec | |||
| 4b8cb77a37 | |||
| b2b5a57b57 | |||
| 1cad70a72a | |||
| e8d4537f08 | |||
| a278ad8a56 | |||
| 2cdc85ae4c | |||
| 5bbe946d64 | |||
| 2133ca422a | |||
| 89847f6332 | |||
| b21e9ea32a | |||
| 737776c557 | |||
| 6673bd59da | |||
| 4007d14c64 | |||
| af19e17b62 | |||
| 5fbfd69c5f | |||
| e4a0d7039b | |||
| ed9fc56e17 | |||
| ddbc37ed13 | |||
| 68c1c32f1a | |||
| fa935ae503 | |||
| 076da78981 | |||
| 6195dfd08d | |||
| 864ebfaba2 | |||
| 9916146097 | |||
| b66b90b25d | |||
| fcca168052 | |||
| 3fd7865197 | |||
| ffe193c004 | |||
| 6e783d8876 | |||
| a075964417 | |||
| aa50546bbd | |||
| b8aec9ebe9 | |||
| db8001f207 | |||
| 1623c9264d | |||
| 070aaf001d | |||
| f3809e3d2a | |||
| ee29b918f6 | |||
| 6ce6acb155 | |||
| 2b4b32ddd9 | |||
| edeadba25e | |||
| a9111ac107 | |||
| ca536838e6 | |||
| bcf93f2c95 | |||
| 46aae26658 | |||
| 585d0805e1 | |||
| 30738a6a00 | |||
| 816bf046ec | |||
| a2693393d3 | |||
| e85b884985 | |||
| 8df8c48875 | |||
| 9b557f22d1 | |||
| c2ddb0117d | |||
| d5793d1825 | |||
| d39d9718f6 | |||
| 2d937d13d7 | |||
| 20ccabb228 | |||
| 7b9fc32715 | |||
| 4ac8b6ce08 | |||
| 23d675dedc | |||
| 79efa40ef0 | |||
| 19658260aa | |||
| a665c6e9a1 | |||
| 93fc0f45c4 | |||
| 972c8fbe0a | |||
| 82e1b875ad | |||
| f808e44548 | |||
| f4e6796f83 | |||
| df2f7535e2 | |||
| cafcb499cd | |||
| 5bb8b86f87 | |||
| e81992ccb3 | |||
| 82e3f7d6cd | |||
| da7b63ac46 | |||
| 0502ec6c6c | |||
| b486f6b186 | |||
| cd9865caa7 | |||
| ec5f96c375 | |||
| 5ccdc7f68b | |||
| 913d1d3a97 | |||
| 0657136ae5 | |||
| c1dc6efb4c | |||
| 968c258029 | |||
| b08040f46b | |||
| e3a9ff4dfd | |||
| 6c3714b3dd | |||
| 4bf1a82bf0 | |||
| 69c5a1a52f | |||
| b36f76cd78 | |||
| d0e3b260d0 | |||
| 95ddee8673 | |||
| 5876a69288 | |||
| 02dd042599 | |||
| 5df29af11d | |||
| c5301c4d77 | |||
| 964708dee6 | |||
| a3b9a93c46 | |||
| e7040de838 | |||
| a61d93ddcc | |||
| 96d738aa9a | |||
| f87d0c9529 | |||
| 753c408f3e | |||
| cc0fc74f82 | |||
| 4a3883ee71 | |||
| 3a459b1f61 | |||
| 0c0c431e76 | |||
| 521a930911 | |||
| 3e31b82d55 | |||
| 196a5204bb | |||
| 061d7870e4 | |||
| 54923177ac | |||
| 94f7f65cc3 | |||
| 0236b08c31 | |||
| 9c2399f4f1 | |||
| b6e7e2e5b2 | |||
| 71bbf83c09 | |||
| c8d7cdba74 | |||
| 8ca4a5c15e | |||
| 002f8dc5ae | |||
| 516ec94d0c | |||
| 469d2c9afe | |||
| 4a14515a63 | |||
| 1b0a414ad6 | |||
| fa8c644520 | |||
| 1dc8980310 | |||
| 9eaf1446ef | |||
| da5962e0e2 | |||
| 0da8771a52 | |||
| e37857da31 | |||
| 659462ef93 | |||
| ef14852457 | |||
| 74494ae34b | |||
| 092e2b95cf | |||
| feaaa108f0 | |||
| f06171b659 | |||
| f5afec997c | |||
| cd23569e24 | |||
| ab91832ea8 | |||
| 13325fab43 | |||
| 9d4cd6ccb5 | |||
| aa3a8096de | |||
| d64c927101 | |||
| 4894faa37a | |||
| 70b8aec368 | |||
| ce20961fb2 | |||
| a5ec03bafb | |||
| e40dae764a | |||
| c8503a1457 | |||
| 6bf4ee5a41 | |||
| 1b17f0fd71 | |||
| 8518dcba3b | |||
| 51b4ebb677 | |||
| e850b598eb | |||
| b855e366aa | |||
| dcb534b9c4 | |||
| 01ed329e2d | |||
| bf00eefd40 | |||
| d4b4ea75e5 | |||
| 0d937d8e81 | |||
| 14ac2cca2a | |||
| d73a8eacc9 | |||
| 419e8a6597 | |||
| 3de20a5734 | |||
| eea9e88f0f | |||
| d83ade8d51 | |||
| eb03b6014d | |||
| f042c5fd43 | |||
| 70bf8b98e9 | |||
| 85f26425ed | |||
| ba692f5b73 | |||
| 993c79e9db | |||
| 8390775b6b | |||
| 3d1791cde7 | |||
| c8e907edd1 | |||
| 6a98f4fb43 | |||
| db1a0d4db7 | |||
| 23c6c903ee | |||
| f9a3931ba0 | |||
| 2b006ecae4 | |||
| dbb8f2db96 | |||
| c9afe25c52 | |||
| 838bb0762c | |||
| c0251b0021 | |||
| 1fad6cae3c | |||
| f0e78baa19 | |||
| a484498d7e | |||
| cdd233a7c6 | |||
| 83870a2022 | |||
| 4f1cad2df3 | |||
| c107bc2be6 | |||
| 65bc868346 | |||
| 2122233676 | |||
| de45f5d377 | |||
| 32ba7b0a67 | |||
| af2b818186 | |||
| 7327a96730 | |||
| 6bffaab94f | |||
| 8ce7ee0ce0 | |||
| 5f2b40b2d2 | |||
| a757852598 | |||
| 62ad721262 | |||
| ee155db444 | |||
| 873b178151 | |||
| ca2a720bfc | |||
| 46ec8fef50 | |||
| 0890d326a4 | |||
| 39b998e6df | |||
| 5a0b157529 | |||
| 5be5cd33f7 | |||
| 589851ae2c | |||
| f83a3a15a9 | |||
| 73a365eeb6 | |||
| 4cfaf41980 | |||
| 7dafb47c38 | |||
| 906601f5cd | |||
| e42a66dde7 | |||
| 31dd3d6654 | |||
| a9d1c0b75a | |||
| c850698703 | |||
| 70e9bfc2ca | |||
| 533e1283ea | |||
| 17d519d440 | |||
| 4dcfa31331 | |||
| 2d638b0c82 | |||
| 311c48edcd | |||
| 845a8c8671 | |||
| b78e1148d1 | |||
| cc7d15390d | |||
| 3af4afd159 | |||
| 65a7e7f9b0 | |||
| c87ec273e5 | |||
| a8f3187e84 | |||
| eb666ca3a4 | |||
| e9671baf65 | |||
| 8667ae3a23 | |||
| acb73101c0 | |||
| ffc18f4056 | |||
| 7802af7d41 | |||
| 84831a7290 | |||
| b01e4db2b2 | |||
| 775a6f855c | |||
| f07b46ac75 | |||
| 82a2e63461 | |||
| dc9c33c1f5 | |||
| cbf53f0a50 | |||
| 689ba3579a | |||
| c4eec4572e | |||
| df64692c9d | |||
| 1067e437d4 | |||
| a47bb0aeae | |||
| 069f2fa983 | |||
| f6a369c907 | |||
| 42c3dd7906 | |||
| 6d9180063a | |||
| 347c459c7c | |||
| 0ca0d47a7b | |||
| 75d3102c79 | |||
| 381f3e0b67 | |||
| 466c2dcf31 | |||
| 327c37bfc9 | |||
| cb9db91acc | |||
| 2d6bd5fb04 | |||
| 949de2b94e | |||
| 260618dcc2 | |||
| 092047e8c7 | |||
| 4556c01635 | |||
| 0ca55040bd | |||
| 1abb114b6e | |||
| 661ed8895b | |||
| 0354b68d1f | |||
| 26ef2c4e5a | |||
| c85e20f814 | |||
| 2f44b273bc | |||
| 5e5c12b310 | |||
| 4509ae84cb | |||
| 1e9f178587 | |||
| 4135dd514a | |||
| c218a3d9a1 | |||
| b2f793fa70 | |||
| 5884e0c213 | |||
| f314fbd9cc | |||
| b81de05d13 | |||
| d0608a46aa | |||
| 7c6e316521 | |||
| 296437ab4e | |||
| dddd1d2af7 | |||
| 820a86d049 | |||
| bcd7a5d59d | |||
| a23d7fffdc | |||
| 4b436a3d12 | |||
| 4b2e28d15d | |||
| a626a1d882 | |||
| 7e8a36d7ad | |||
| 418725ba74 | |||
| 722a0f4913 | |||
| 0391bd90e9 | |||
| c0cbc17c1e | |||
| 2e97cbb5ee | |||
| a8002f3d04 | |||
| 92889baa93 | |||
| bd0dea9e96 | |||
| a78df82596 | |||
| f41c1e6414 | |||
| 36a4ee339c | |||
| c81fea54e9 | |||
| dbf05cfc7c | |||
| 86acd6b822 | |||
| 0a822265d3 | |||
| 520fad20e5 | |||
| 3c0a94a3f8 | |||
| 023186bd93 | |||
| 921cade931 | |||
| d247ef81df | |||
| 4371acaab4 | |||
| 1e0261d809 | |||
| 88aae8e650 | |||
| 3e5edf18b3 | |||
| a018a42f2c | |||
| 5c70f8c35f | |||
| 3ac9435e58 | |||
| 84c17a9358 | |||
| c57e557755 | |||
| 3c58a36d19 | |||
| b177d51f9b | |||
| 94c5bfe8bf | |||
| a45a00e55b | |||
| 365e1f6d61 | |||
| 2bddedf692 | |||
| 2b23e5aa42 | |||
| f6ac5ea8ac | |||
| c70798f0d5 | |||
| 5073286506 | |||
| 636912070d | |||
| 812fa87f26 | |||
| 61e4eaf6ea | |||
| 8128106c17 | |||
| 9ddbb407d0 | |||
| 1156374ba8 | |||
| 567ad98397 | |||
| db8f263128 | |||
| 80779e161e | |||
| b4bd0176e5 | |||
| fe70664a23 | |||
| dbb804becb | |||
| 1ee1ee3b0e | |||
| d2fc1eb256 | |||
| 79602d593b | |||
| 682543374a | |||
| 0df1c98407 | |||
| 729e5ab87a | |||
| 711156f34a | |||
| b4df8d6d86 | |||
| 03d494b657 | |||
| fa374bc670 | |||
| f51df67dc4 | |||
| 83dd78c3dd | |||
| 5143f8eb09 | |||
| 761ee488e2 | |||
| e60797c1e0 | |||
| c1728d111b | |||
| a004449dd2 | |||
| 97091f4875 | |||
| 710770d39d | |||
| 0a0f943517 | |||
| c8f9ebc443 | |||
| 8f4af59ab5 | |||
| c38a252053 | |||
| fdbecde455 | |||
| b1c0ecb43a | |||
| 52507b2a2b | |||
| 8f5fb33fce | |||
| fa82cd30b5 | |||
| 7946e7a4f8 | |||
| 35e81b2e2d | |||
| 5af2e31c66 | |||
| 4becc056dc | |||
| 198cfd48f3 | |||
| 51e37e3bec | |||
| 0f0a1ab9f0 | |||
| ef2441c1e9 | |||
| a0c25e8d67 | |||
| e50b59f753 | |||
| 142c1c15e7 | |||
| ba11c8fe0e | |||
| 6aa6d9132a | |||
| dbd06691c9 | |||
| afb037c7f5 | |||
| f0b2c40a8a | |||
| bfa9c3dd5a | |||
| ee92a28f8a | |||
| 95b13dfbdc | |||
| 1a2c6697b5 | |||
| f31e1bb5f6 | |||
| 095a225673 | |||
| dd79c6a709 | |||
| 9233fab758 | |||
| 7a11c51e1f | |||
| 1a68512af5 | |||
| 9c08e8a126 | |||
| 95716cdeab | |||
| 4a16643880 | |||
| 95e2ccf7b0 | |||
| a19690c17f | |||
| af53834d5d | |||
| 297262c749 | |||
| bd43ec0f6d | |||
| 98512a288f | |||
| a750dfae4f | |||
| b7a515271c | |||
| 97f4bc8104 | |||
| e44f7b1cd1 | |||
| 0fedc45eca | |||
| b13af49ece | |||
| 21e858c410 | |||
| 7d4d7a37cb | |||
| 42b8ca5eec | |||
| 8843ff9a7e | |||
| a93cfbaa20 | |||
| 3e6aa31e17 | |||
| ac78a86bf1 | |||
| 4899e5315d | |||
| a8fa45c3f1 | |||
| 8dcca13405 | |||
| b9768ec4e0 | |||
| e98dd9a533 | |||
| 698d6ef233 | |||
| f4f1a5596c | |||
| a0fd960347 | |||
| efdb9a11dc | |||
| 4239f9b9cf | |||
| 5fa1ce99fd | |||
| 24211f6756 | |||
| 0f638f2d21 | |||
| 46e0288da3 | |||
| fb7e683f12 | |||
| d8e499bcfb | |||
| 69fd2b9f8e | |||
| 50bbc17b0a | |||
| c21906da23 | |||
| 319ea2b63b | |||
| 66d4ab22b6 | |||
| 44bae2b4e6 | |||
| 2dd3d3e2a7 | |||
| 00efebc377 | |||
| 2b4e3182c7 | |||
| 5eb4671cc5 | |||
| d088d4dbc2 | |||
| 4283470d82 | |||
| fca313a627 | |||
| 909ea8901e | |||
| d38a98cabd | |||
| c189e88edf | |||
| 34fe87d9ac | |||
| 80f2314c9e | |||
| af5c9215a0 | |||
| f97faccf95 | |||
| c1fdf21dae | |||
| 52d9888f8d | |||
| ec46b96906 | |||
| 1549f01699 | |||
| c793bcad26 | |||
| 91b079bab1 | |||
| 3e18be8aba | |||
| ef85afbc79 | |||
| 54baf9d406 | |||
| 6c29f6d164 | |||
| 09b763b4a3 | |||
| 43fd32f7be | |||
| 783ac6f705 | |||
| b0b690b78f | |||
| f629e83972 | |||
| 9eb12ac053 | |||
| e76af1948b | |||
| fa17dea8fe | |||
| e3e409aa5c | |||
| 13a99d5e32 | |||
| 8e3e5d66b0 | |||
| b10da8022c | |||
| f25332f19d | |||
| 5ffb7bc71c | |||
| 2288f28475 | |||
| e8a0308e56 | |||
| 6b712de25c | |||
| 2f799f0bad | |||
| adb509f1e1 | |||
| 7093291a2b | |||
| 44cc25718b | |||
| 414ba695ef | |||
| 12a8fb92f9 | |||
| 1b599d5eef | |||
| 48149f14cd | |||
| 57e701362f | |||
| d2cbfcda93 | |||
| 25c7d5f43d | |||
| 95192032de | |||
| 30efc7a1c7 | |||
| 45469da513 | |||
| b5e1de3589 | |||
| b2c0d75fda | |||
| 6d106bcfb2 | |||
| 1bc6224f6b | |||
| d1b7a01b4f | |||
| 034c1246bd | |||
| a260e6f060 | |||
| 5460029b16 | |||
| fff4ef4790 | |||
| 54a8d380e8 | |||
| 4fbffd2c88 | |||
| 998bf7f7c6 | |||
| 512c87160d | |||
| 480bcf28ab | |||
| 742cbc0bee | |||
| 936eb36acb | |||
| 13d74ce239 | |||
| 94e1923c0d | |||
| b7049e37b1 | |||
| bafb176c3f | |||
| eb6c59002f | |||
| fa1f8b2ed6 | |||
| 232d40c6b6 | |||
| af5f0eef59 | |||
| f5d578d5a3 | |||
| 7e1d1402e1 | |||
| a568edad63 | |||
| 909df80d7b | |||
| f38d169c37 | |||
| d2eb493720 | |||
| 3b853a1edf | |||
| 5cb8784332 | |||
| abf9462271 | |||
| 0afba0fa9c | |||
| 0b8564684e | |||
| 8d1dfd909d | |||
| 28b344b9c4 | |||
| e9237e5f1c | |||
| 2e7d6f98c6 | |||
| 39db3e2e64 | |||
| 853840475c | |||
| 599cdfed38 | |||
| 1939f41a1c | |||
| 474f611fcc | |||
| 69992725b8 | |||
| ca08152b03 | |||
| e9523059b2 | |||
| c81db7973e | |||
| 31d35b7eff | |||
| 5a61a29368 | |||
| b2f29ff4f2 | |||
| 75b5d2f9cc | |||
| 4dba5736e1 | |||
| fe7690ac02 | |||
| e951dd9573 | |||
| b3d933e98d | |||
| 536511b08f | |||
| d362e232d7 | |||
| a51807bd57 | |||
| ed759cdf62 | |||
| 5ece667357 | |||
| f585b82cd6 | |||
| 0c5d63c6e4 | |||
| 737f057cae | |||
| b011639c0e | |||
| 8a0f013b5f | |||
| 83647655ca | |||
| 9e89f77ed4 | |||
| 91453a66b4 | |||
| 028ef92b4c | |||
| 4714c9e60c | |||
| 4594900b26 | |||
| 789643ee3e | |||
| 0e6fecef2d | |||
| fb28a0881c | |||
| 69d96284c3 | |||
| d37b1d12c6 | |||
| 737f64763b | |||
| 003063c140 | |||
| c296243032 | |||
| 92c6e870d9 | |||
| 318efc7cf7 | |||
| 37da9639a6 | |||
| 91e1d24122 | |||
| c12c1fd174 | |||
| 2ec69c7f14 | |||
| 7cccc53f55 | |||
| ad315ed7c5 | |||
| df0f9b4480 | |||
| 5cf01834db | |||
| cf2ce1283d | |||
| 0855d3a5e7 | |||
| c753a7bdf5 | |||
| 83c88f42db | |||
| 48fc9f506e | |||
| 6bd6689d21 | |||
| 57a840d4a3 | |||
| aaf3eda76e | |||
| 6ba7b6ba70 | |||
| b7324f256b | |||
| 928fe4a3cb | |||
| 8bb5cf43b4 | |||
| 18556e6a75 | |||
| 0999029567 | |||
| b3aa1a8296 | |||
| dea06788cf | |||
| 4336826986 | |||
| 3adc7fc59f | |||
| 454b839fae | |||
| 2ea073db4a | |||
| 8684496d08 | |||
| e23676b74d | |||
| bd051f81a6 | |||
| 10e1504e6f | |||
| daec9814c8 | |||
| bb55151e20 | |||
| 6a30f342db | |||
| a1fb2bfb09 | |||
| 47c514a55b | |||
| 1caaaf9ae8 | |||
| 7bf22c8c2b | |||
| 9f8a366aa3 | |||
| 45ac41c01a | |||
| 7bc4c9ef6e | |||
| 38f02bbcb6 | |||
| c01c888f0e | |||
| 9731e87ded | |||
| 4df89ea7a9 | |||
| 3aeada3f33 | |||
| a4ca53eda9 | |||
| 2e9eb4a627 | |||
| c5c3b4949b | |||
| e740f8658d | |||
| 223636d38a | |||
| 15497f28fc | |||
| 71563cac94 | |||
| f7b5ec93e6 | |||
| 397758ab9b | |||
| 83eb59f663 | |||
| 282c7e83d5 | |||
| 24351883a6 | |||
| 1daa85ecc5 | |||
| 77ea53fda2 | |||
| c2ce824c0e | |||
| 4e7a3d5d28 | |||
| c3bfa7b1c9 | |||
| 69620db5cd | |||
| a853e53b9f | |||
| 4789246da2 | |||
| 2343187621 | |||
| d6412249c8 | |||
| ca056b0add | |||
| b036727e3b | |||
| eddaadd1cc | |||
| 7092f1dd7b | |||
| 0f374e38ae | |||
| e524e439b1 | |||
| 6f5aa1e3f4 | |||
| 0697873742 | |||
| 7fb5fb9844 | |||
| a791314e1f | |||
| e49065fe7a | |||
| 7222dc327d | |||
| dfb576969f | |||
| 8033fdc27d | |||
| 05a324e4d6 | |||
| 6ca254e705 | |||
| 3a41a522d7 | |||
| 43ba2aab01 | |||
| 7b9e5914c0 | |||
| 76dd0fd1f6 | |||
| ff2c66784d | |||
| 7ca92c1627 | |||
| c85583d26a | |||
| c68a5794af | |||
| 489a36d092 | |||
| dc69c225b6 | |||
| dc61a13894 | |||
| 9ac1af9203 | |||
| 6a1683f082 | |||
| 4e8c949409 | |||
| bc1ac54e9c | |||
| bb6e609a1e | |||
| 78959dc65e | |||
| 58f9e8279c | |||
| 9c8d7d1442 | |||
| 52b6baeb3c | |||
| f5ac31919b | |||
| 06d851ce5f | |||
| 7f3bf67bba | |||
| f66caed616 | |||
| 53a56e00cb | |||
| d5b3a94ea9 | |||
| 26040dc3f2 | |||
| e3720e9be9 | |||
| 57e554bbab | |||
| 60ba811a35 | |||
| fa5fa839cc | |||
| 7cf43f064c | |||
| 4efdae4f9a | |||
| 4baf4cfbe1 | |||
| 8d2821a001 | |||
| e0f30ae22c | |||
| 19b826ff74 | |||
| c47de57e18 | |||
| 072740523c | |||
| d1df5aab77 | |||
| e0e75f83ba | |||
| 450b6a4173 | |||
| eb1afaf43f | |||
| 6c4a403c6e | |||
| 09356c1766 | |||
| f5aed3c686 | |||
| f55c72f3b5 | |||
| b8e3096c4e | |||
| 701521a3ff | |||
| 1437686f55 | |||
| cc677fff20 | |||
| 57ec859b94 | |||
| fe6ce1e4ae | |||
| 821d6d0cbe | |||
| e62133cb58 | |||
| 0341acf1c4 | |||
| 791824041b | |||
| 033f664903 | |||
| 25cd2167a5 | |||
| 2c8dc22770 | |||
| ed55459e37 | |||
| e6df11b6f0 | |||
| 3fba9fa9a0 | |||
| 137ff46f20 | |||
| c7540417b0 | |||
| 004e611066 | |||
| bbe54a933d | |||
| 544b85b479 | |||
| f6c9f57e8a | |||
| 690f72df19 | |||
| d5a08b1183 | |||
| 5e69e9d1d8 | |||
| 12bc7afedf | |||
| 975b8c7b2d | |||
| 34b8a27c36 | |||
| 8471da220b | |||
| eba39e4c3e | |||
| f347146be6 | |||
| 49f326af61 | |||
| b08d8c7f16 | |||
| c23f7ce091 | |||
| 1d879b18cb | |||
| e0aa14b5e9 | |||
| b731d7dbf6 | |||
| 0ae3d27532 | |||
| 3e93a50ea7 | |||
| 1c19bf6205 | |||
| 3074a7e5cf | |||
| 374965ad3c | |||
| 6571689c18 | |||
| edc00c6687 | |||
| 12b865ea0d | |||
| d4d4b94d45 | |||
| 864a464f7b | |||
| db06db6dc8 | |||
| bd3f36e99e | |||
| 46569a4320 | |||
| cc027e1b3f | |||
| bd8611f989 | |||
| 6f14182c29 | |||
| dd006dbcdc | |||
| 4d607475a3 | |||
| f9666597ac | |||
| 536409ba73 | |||
| dd3b36a7c1 | |||
| 38be3f883e | |||
| ca1376228d | |||
| f0976a2c9c | |||
| 601024b5bd | |||
| a6ff07b9eb | |||
| a32870bc40 | |||
| 42395ac414 | |||
| f5a99dcd7b | |||
| 5c5f4bd41b | |||
| 9ead4cfd7c | |||
| 3571c69e24 | |||
| e0b14d60a2 | |||
| 42d5390564 | |||
| 3a12586954 | |||
| 3341b8bddf | |||
| 046b60a3d7 | |||
| e48cb2c714 | |||
| b7ff35eaa2 | |||
| 36efa8d98d | |||
| 50ae45de46 | |||
| df3d6cf730 | |||
| e57e66b7f4 | |||
| 7612c10b3e | |||
| 2dff6cf884 | |||
| a4af65ed07 | |||
| 37f6165f10 | |||
| 1d9919f866 | |||
| 93c69eed8b | |||
| d54f05dc77 | |||
| a04be3da93 | |||
| a85e703c97 | |||
| 01cbc5f9b7 | |||
| 407c0aafa0 | |||
| cfe03f962a | |||
| ab3dd272b3 | |||
| bc2839a3cd | |||
| 213a829a08 | |||
| c1db2c127a | |||
| 62703bd08c | |||
| b00a85faa7 | |||
| 9475cdab1a | |||
| be6483db5b | |||
| 11ddada01e | |||
| 4546a4879d | |||
| b64fd4efca | |||
| a3af2cdb9e | |||
| b2f1f0d79b | |||
| 4cca688510 | |||
| 98514637cb | |||
| 9f2ae3b22e | |||
| cd49d3cbc1 | |||
| 202bb329c5 | |||
| b7692a361e | |||
| d339377181 | |||
| 4ad82b8c60 | |||
| 9d318aa245 | |||
| fca465c174 | |||
| d9522db74c | |||
| 4d2f40d36c | |||
| 944aeae040 | |||
| 84f1049371 | |||
| 5b8477affc | |||
| 35b35b8b44 | |||
| f82512c168 | |||
| f36be88c01 | |||
| 75289fddc5 | |||
| 95fbb67489 | |||
| 9e46bfee97 | |||
| 2f304fda78 | |||
| 914d5a5bf8 | |||
| ba3a2c7765 | |||
| 856bf546c5 | |||
| a6ef7f2f68 | |||
| 520988f013 | |||
| 0f51f45e9a | |||
| ca31d34949 | |||
| 2b2529a5d1 | |||
| 2a06cb6105 |
@@ -1,23 +1,17 @@
|
||||
#!/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
|
||||
+28
-8
@@ -27,15 +27,19 @@ 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
|
||||
@@ -51,13 +55,29 @@ handlers/texts.py
|
||||
Thumbs.db
|
||||
|
||||
nginx.conf
|
||||
scripts
|
||||
/scripts/load_balancer.py
|
||||
/scripts/__pycache__
|
||||
.csv
|
||||
/logs
|
||||
setup.py
|
||||
.ruff_cache
|
||||
.github/workflows/
|
||||
modules/
|
||||
storage/
|
||||
static/web_uploads/
|
||||
|
||||
.license_state
|
||||
.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*
|
||||
|
||||
+3
-1
@@ -20,7 +20,9 @@ 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 && chown -R appuser:appuser /app
|
||||
RUN adduser --disabled-password --gecos "" appuser \
|
||||
&& mkdir -p /app/backups \
|
||||
&& chown -R appuser:appuser /app
|
||||
USER appuser
|
||||
|
||||
CMD ["/app/venv/bin/python", "main.py"]
|
||||
|
||||
@@ -8,3 +8,12 @@ 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"
|
||||
|
||||
+231
-4
@@ -1,27 +1,60 @@
|
||||
import hashlib
|
||||
|
||||
from collections.abc import AsyncGenerator
|
||||
from urllib.parse import urlparse
|
||||
|
||||
from fastapi import Depends, HTTPException, Header, Query
|
||||
from fastapi import Depends, HTTPException, Header, Query, Request, Response
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from database import async_session_maker
|
||||
from database.models import Admin
|
||||
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
|
||||
|
||||
|
||||
async def get_session() -> AsyncGenerator[AsyncSession, None]:
|
||||
async with async_session_maker() as session:
|
||||
yield session
|
||||
try:
|
||||
yield session
|
||||
await session.commit()
|
||||
except Exception:
|
||||
await session.rollback()
|
||||
raise
|
||||
|
||||
|
||||
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)
|
||||
@@ -29,4 +62,198 @@ 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
|
||||
|
||||
+199
-44
@@ -1,64 +1,219 @@
|
||||
import asyncio
|
||||
import hashlib
|
||||
import os
|
||||
from time import perf_counter
|
||||
|
||||
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 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 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="SoloBot API (Alpha)",
|
||||
version="0.5.2",
|
||||
title=f"SoloBot API (Alpha) — API v{API_DOC_VERSION}",
|
||||
version=API_DOC_VERSION,
|
||||
description=f"Версия API: **v{API_DOC_VERSION}**.",
|
||||
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):
|
||||
if not API_LOGGING:
|
||||
return await call_next(request)
|
||||
|
||||
context = ensure_api_context(request)
|
||||
started = perf_counter()
|
||||
response = await call_next(request)
|
||||
duration_ms = int((perf_counter() - started) * 1000)
|
||||
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}"
|
||||
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
|
||||
|
||||
logger.info(
|
||||
f'[API] {client_ip} "{request.method} {path_qs}" {response.status_code} {duration_ms}ms'
|
||||
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
|
||||
|
||||
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,
|
||||
)
|
||||
)
|
||||
return response
|
||||
|
||||
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"])
|
||||
|
||||
@app.get("/api/health", include_in_schema=False)
|
||||
async def health():
|
||||
return {"status": "ok"}
|
||||
|
||||
|
||||
@app.get("/api", include_in_schema=False)
|
||||
async def root():
|
||||
return {"message": "Welcome to SoloBot API. Docs: /api/docs"}
|
||||
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")
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
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
|
||||
@@ -1,219 +0,0 @@
|
||||
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,
|
||||
}
|
||||
@@ -1,54 +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.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="Ошибка при удалении пользователя")
|
||||
@@ -1,9 +0,0 @@
|
||||
from pydantic import BaseModel
|
||||
|
||||
|
||||
class MaintenanceUpdate(BaseModel):
|
||||
enabled: bool
|
||||
|
||||
|
||||
class DomainChange(BaseModel):
|
||||
domain: str
|
||||
@@ -0,0 +1,6 @@
|
||||
from api.v1.router import router
|
||||
|
||||
|
||||
VERSION = "1.0.0"
|
||||
|
||||
__all__ = ("router", "VERSION")
|
||||
@@ -0,0 +1,38 @@
|
||||
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"])
|
||||
@@ -0,0 +1,206 @@
|
||||
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,7 +1,7 @@
|
||||
from fastapi import APIRouter
|
||||
|
||||
from api.routes.base_crud import generate_crud_router
|
||||
from api.schemas import CouponBase, CouponResponse, CouponUpdate
|
||||
from api.v1.routes.base_crud import generate_crud_router
|
||||
from api.v1.schemas import CouponBase, CouponResponse, CouponUpdate
|
||||
from database.models import Coupon
|
||||
|
||||
|
||||
@@ -3,8 +3,9 @@ from sqlalchemy import delete, 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 import GiftBase, GiftResponse, GiftUpdate, GiftUsageResponse
|
||||
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 database.models import Admin, Gift, GiftUsage
|
||||
|
||||
|
||||
@@ -29,7 +30,10 @@ async def get_gifts_by_tg_id(
|
||||
admin: Admin = Depends(verify_admin_token),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
):
|
||||
result = await session.execute(select(Gift).where(Gift.sender_tg_id == 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")
|
||||
@@ -61,5 +65,4 @@ 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,11 +5,12 @@ 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.keys import KeyBase, KeyCreateRequest, KeyResponse, KeyUpdate
|
||||
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 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(
|
||||
@@ -43,7 +44,6 @@ 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,7 +63,10 @@ async def get_router_keys_by_tg_id(
|
||||
if not tariff_ids:
|
||||
return []
|
||||
|
||||
keys_result = await session.execute(select(Key).where(Key.tg_id == tg_id, Key.tariff_id.in_(tariff_ids)))
|
||||
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 = keys_result.scalars().all()
|
||||
return keys
|
||||
|
||||
@@ -80,7 +83,7 @@ async def edit_key_by_email(
|
||||
if not db_key:
|
||||
raise HTTPException(status_code=404, detail="Ключ не найден")
|
||||
|
||||
for field, value in key_update.dict(exclude_unset=True).items():
|
||||
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
|
||||
@@ -104,7 +107,6 @@ 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,375 @@
|
||||
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}
|
||||
@@ -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.routes.base_crud import generate_crud_router
|
||||
from api.schemas import (
|
||||
from api.v1.routes.base_crud import generate_crud_router
|
||||
from api.v1.schemas import (
|
||||
BlockedUserResponse,
|
||||
ManualBanResponse,
|
||||
NotificationResponse,
|
||||
@@ -13,6 +13,7 @@ from api.schemas import (
|
||||
TrackingSourceResponse,
|
||||
)
|
||||
from database import get_tracking_source_stats
|
||||
from database.access.resolution import resolve_user_optional
|
||||
from database.models import (
|
||||
Admin,
|
||||
BlockedUser,
|
||||
@@ -47,7 +48,10 @@ async def get_payments_by_tg_id(
|
||||
admin: Admin = Depends(verify_admin_token),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
):
|
||||
result = await session.execute(select(Payment).where(Payment.tg_id == 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")
|
||||
@@ -60,7 +64,8 @@ router.include_router(
|
||||
schema_response=NotificationResponse,
|
||||
schema_create=None,
|
||||
schema_update=None,
|
||||
identifier_field="tg_id",
|
||||
identifier_field="user_id",
|
||||
telegram_path_to_user_id=True,
|
||||
enabled_methods=["get_all", "get_one", "delete"],
|
||||
),
|
||||
prefix="/notifications",
|
||||
@@ -75,7 +80,9 @@ router.include_router(
|
||||
schema_response=ManualBanResponse,
|
||||
schema_create=None,
|
||||
schema_update=None,
|
||||
identifier_field="tg_id",
|
||||
identifier_field="user_id",
|
||||
parameter_name="tg_id",
|
||||
telegram_path_to_user_id=True,
|
||||
enabled_methods=["get_all", "get_one", "delete"],
|
||||
),
|
||||
prefix="/manual-bans",
|
||||
@@ -89,7 +96,9 @@ router.include_router(
|
||||
schema_response=BlockedUserResponse,
|
||||
schema_create=None,
|
||||
schema_update=None,
|
||||
identifier_field="tg_id",
|
||||
identifier_field="user_id",
|
||||
parameter_name="tg_id",
|
||||
telegram_path_to_user_id=True,
|
||||
enabled_methods=["get_all", "get_one", "delete"],
|
||||
),
|
||||
prefix="/blocked-users",
|
||||
@@ -103,7 +112,9 @@ router.include_router(
|
||||
schema_response=TemporaryDataResponse,
|
||||
schema_create=None,
|
||||
schema_update=None,
|
||||
identifier_field="tg_id",
|
||||
identifier_field="user_id",
|
||||
parameter_name="tg_id",
|
||||
telegram_path_to_user_id=True,
|
||||
enabled_methods=["get_all", "get_one", "delete"],
|
||||
),
|
||||
prefix="/temporary-data",
|
||||
@@ -1,124 +1,125 @@
|
||||
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)}
|
||||
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)}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -3,8 +3,9 @@ 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 import ReferralResponse
|
||||
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 database.models import Admin, Referral
|
||||
|
||||
|
||||
@@ -13,7 +14,9 @@ router = generate_crud_router(
|
||||
schema_response=ReferralResponse,
|
||||
schema_create=None,
|
||||
schema_update=None,
|
||||
identifier_field="referrer_tg_id",
|
||||
identifier_field="referrer_user_id",
|
||||
parameter_name="referrer_tg_id",
|
||||
telegram_path_to_user_id=True,
|
||||
enabled_methods=["get_all", "get_one", "get_all_by_field"],
|
||||
)
|
||||
|
||||
@@ -25,12 +28,18 @@ 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_tg_id == referrer_tg_id, Referral.referred_tg_id == referred_tg_id)
|
||||
select(Referral).where(
|
||||
Referral.referrer_user_id == ru_ref.id,
|
||||
Referral.referred_user_id == rd_ref.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.routes.base_crud import generate_crud_router
|
||||
from api.schemas import ServerBase, ServerResponse, ServerUpdate
|
||||
from api.v1.routes.base_crud import generate_crud_router
|
||||
from api.v1.schemas import ServerBase, ServerResponse, ServerUpdate
|
||||
from database.models import Server
|
||||
|
||||
|
||||
@@ -1,13 +1,12 @@
|
||||
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.schemas.settings import SettingResponse, SettingUpsert
|
||||
from database.models import Setting
|
||||
from database.settings import set_setting
|
||||
from api.v1.schemas.settings 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
|
||||
@@ -15,7 +14,9 @@ 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 pydantic import BaseModel
|
||||
from database.models import Setting
|
||||
from database.settings import set_setting
|
||||
from database.settings_cache import settings_cache
|
||||
|
||||
|
||||
router = APIRouter()
|
||||
@@ -26,12 +27,9 @@ class ConfigUpdatePayload(BaseModel):
|
||||
|
||||
|
||||
@router.get("/", response_model=list[SettingResponse])
|
||||
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()
|
||||
async def get_all_settings(admin=Depends(verify_admin_token)):
|
||||
"""Список всех настроек (из кэша, без запроса к БД)."""
|
||||
return settings_cache.get_all()
|
||||
|
||||
|
||||
@router.get("/configs")
|
||||
@@ -106,13 +104,9 @@ async def update_config_scope(
|
||||
|
||||
|
||||
@router.get("/{key}", response_model=SettingResponse)
|
||||
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()
|
||||
async def get_setting_by_key(key: str, admin=Depends(verify_admin_token)):
|
||||
"""Настройка по ключу (из кэша, без запроса к БД)."""
|
||||
obj = settings_cache.get(key)
|
||||
if not obj:
|
||||
raise HTTPException(status_code=404, detail="Setting not found")
|
||||
return obj
|
||||
@@ -131,8 +125,14 @@ 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,7 +147,5 @@ async def delete_setting(
|
||||
if not obj:
|
||||
raise HTTPException(status_code=404, detail="Setting not found")
|
||||
await session.delete(obj)
|
||||
await session.commit()
|
||||
settings_cache.delete(key)
|
||||
return {"detail": "Setting deleted"}
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
from fastapi import APIRouter
|
||||
|
||||
from api.routes.base_crud import generate_crud_router
|
||||
from api.schemas import TariffBase, TariffResponse, TariffUpdate
|
||||
from api.v1.routes.base_crud import generate_crud_router
|
||||
from api.v1.schemas import TariffBase, TariffResponse, TariffUpdate
|
||||
from database.models import Tariff
|
||||
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
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="Ошибка при удалении пользователя")
|
||||
@@ -4,11 +4,13 @@ from pydantic import BaseModel
|
||||
|
||||
|
||||
class GiftBase(BaseModel):
|
||||
sender_tg_id: int
|
||||
recipient_tg_id: int | None = None
|
||||
sender_user_id: int
|
||||
recipient_user_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
|
||||
@@ -33,10 +35,12 @@ class GiftUsageResponse(BaseModel):
|
||||
|
||||
|
||||
class GiftUpdate(BaseModel):
|
||||
recipient_tg_id: int | None = None
|
||||
recipient_user_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,8 +2,9 @@ from pydantic import BaseModel, Field
|
||||
|
||||
|
||||
class KeyBase(BaseModel):
|
||||
tg_id: int
|
||||
user_id: int
|
||||
client_id: str
|
||||
tg_id: int | None = None
|
||||
email: str | None = None
|
||||
created_at: int | None = None
|
||||
expiry_time: int
|
||||
@@ -4,7 +4,8 @@ from pydantic import BaseModel
|
||||
|
||||
|
||||
class PaymentBase(BaseModel):
|
||||
tg_id: int
|
||||
user_id: int
|
||||
tg_id: int | None = None
|
||||
amount: float
|
||||
payment_system: str
|
||||
status: str
|
||||
@@ -19,8 +20,8 @@ class PaymentResponse(PaymentBase):
|
||||
|
||||
|
||||
class ReferralResponse(BaseModel):
|
||||
referred_tg_id: int
|
||||
referrer_tg_id: int
|
||||
referred_user_id: int
|
||||
referrer_user_id: int
|
||||
reward_issued: bool = False
|
||||
|
||||
class Config:
|
||||
@@ -37,11 +38,13 @@ class NotificationResponse(BaseModel):
|
||||
|
||||
|
||||
class GiftBase(BaseModel):
|
||||
sender_tg_id: int
|
||||
recipient_tg_id: int | None = None
|
||||
sender_user_id: int
|
||||
recipient_user_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
|
||||
@@ -66,7 +69,8 @@ class GiftUsageResponse(BaseModel):
|
||||
|
||||
|
||||
class ManualBanResponse(BaseModel):
|
||||
tg_id: int
|
||||
user_id: int
|
||||
tg_id: int | None = None
|
||||
banned_at: datetime
|
||||
reason: str
|
||||
banned_by: int
|
||||
@@ -77,7 +81,8 @@ class ManualBanResponse(BaseModel):
|
||||
|
||||
|
||||
class TemporaryDataResponse(BaseModel):
|
||||
tg_id: int
|
||||
user_id: int
|
||||
tg_id: int | None = None
|
||||
state: str
|
||||
data: dict
|
||||
updated_at: datetime
|
||||
@@ -87,7 +92,8 @@ class TemporaryDataResponse(BaseModel):
|
||||
|
||||
|
||||
class BlockedUserResponse(BaseModel):
|
||||
tg_id: int
|
||||
user_id: int
|
||||
tg_id: int | None = None
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
@@ -2,8 +2,8 @@ from pydantic import BaseModel
|
||||
|
||||
|
||||
class ReferralResponse(BaseModel):
|
||||
referred_tg_id: int
|
||||
referrer_tg_id: int
|
||||
referred_user_id: int
|
||||
referrer_user_id: int
|
||||
reward_issued: bool = False
|
||||
|
||||
class Config:
|
||||
@@ -0,0 +1,10 @@
|
||||
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)
|
||||
@@ -5,25 +5,14 @@ from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy.orm.attributes import InstrumentedAttribute
|
||||
|
||||
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)
|
||||
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
|
||||
|
||||
|
||||
def generate_crud_router(
|
||||
@@ -35,18 +24,28 @@ 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(
|
||||
admin: Admin = Depends(verify_admin_token),
|
||||
identity=Depends(verify_identity_admin),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
):
|
||||
result = await session.execute(select(model))
|
||||
result = await session.execute(_apply_user_relationship_loader(model, select(model)))
|
||||
items = result.scalars().all()
|
||||
for item in items:
|
||||
normalize_outgoing_object(item)
|
||||
@@ -57,7 +56,7 @@ def generate_crud_router(
|
||||
@router.get("/by_email", response_model=schema_response)
|
||||
async def get_by_email(
|
||||
email: str = Query(...),
|
||||
admin: Admin = Depends(verify_admin_token),
|
||||
identity=Depends(verify_identity_admin),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
):
|
||||
result = await session.execute(select(model).where(model.email == email))
|
||||
@@ -71,12 +70,14 @@ def generate_crud_router(
|
||||
@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),
|
||||
identity=Depends(verify_identity_admin),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
):
|
||||
field = getattr(model, identifier_field)
|
||||
casted = cast_identifier_type(field, value)
|
||||
result = await session.execute(select(model).where(field == casted))
|
||||
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")
|
||||
@@ -87,12 +88,14 @@ 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),
|
||||
admin: Admin = Depends(verify_admin_token),
|
||||
identity=Depends(verify_identity_admin),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
):
|
||||
field = getattr(model, identifier_field)
|
||||
casted = cast_identifier_type(field, value)
|
||||
result = await session.execute(select(model).where(field == casted))
|
||||
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")
|
||||
@@ -105,7 +108,7 @@ def generate_crud_router(
|
||||
@router.post("/", response_model=schema_response)
|
||||
async def create(
|
||||
payload: Any = Body(...),
|
||||
admin: Admin = Depends(verify_admin_token),
|
||||
identity=Depends(verify_identity_admin),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
):
|
||||
validated = schema_create.model_validate(payload)
|
||||
@@ -124,20 +127,20 @@ def generate_crud_router(
|
||||
async def update(
|
||||
payload: Any = Body(...),
|
||||
value: int | str = Path(..., alias=parameter_name),
|
||||
admin: Admin = Depends(verify_admin_token),
|
||||
identity=Depends(verify_identity_admin),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
):
|
||||
field = getattr(model, identifier_field)
|
||||
casted = cast_identifier_type(field, value)
|
||||
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.commit()
|
||||
await session.refresh(obj)
|
||||
return to_schema(schema_response, obj)
|
||||
@@ -147,18 +150,22 @@ def generate_crud_router(
|
||||
@router.delete(f"/{{{parameter_name}}}", response_model=dict)
|
||||
async def delete(
|
||||
value: int | str = Path(..., alias=parameter_name),
|
||||
admin: Admin = Depends(verify_admin_token),
|
||||
identity=Depends(verify_identity_admin),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
):
|
||||
field = getattr(model, identifier_field)
|
||||
casted = cast_identifier_type(field, value)
|
||||
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)
|
||||
await session.commit()
|
||||
return {"detail": f"{model.__name__} deleted"}
|
||||
|
||||
return router
|
||||
|
||||
|
||||
__all__ = ("generate_crud_router", "to_schema", "normalize_outgoing_object", "cast_identifier_type")
|
||||
@@ -0,0 +1,49 @@
|
||||
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"])
|
||||
@@ -0,0 +1,9 @@
|
||||
__all__ = ("root_router",)
|
||||
|
||||
|
||||
def __getattr__(name: str):
|
||||
if name == "root_router":
|
||||
from api.v2.routes.root import router
|
||||
|
||||
return router
|
||||
raise AttributeError(name)
|
||||
@@ -0,0 +1,115 @@
|
||||
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
|
||||
@@ -0,0 +1,15 @@
|
||||
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"]
|
||||
@@ -0,0 +1,144 @@
|
||||
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
|
||||
@@ -0,0 +1,44 @@
|
||||
"""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)
|
||||
@@ -0,0 +1,78 @@
|
||||
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}
|
||||
@@ -0,0 +1,210 @@
|
||||
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()}
|
||||
@@ -0,0 +1,124 @@
|
||||
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)
|
||||
@@ -0,0 +1,430 @@
|
||||
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)
|
||||
@@ -0,0 +1,601 @@
|
||||
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}
|
||||
@@ -0,0 +1,236 @@
|
||||
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)
|
||||
@@ -0,0 +1,207 @@
|
||||
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()}
|
||||
@@ -0,0 +1,31 @@
|
||||
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,
|
||||
)
|
||||
@@ -0,0 +1,73 @@
|
||||
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="Ошибка активации купона")
|
||||
@@ -0,0 +1,117 @@
|
||||
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)
|
||||
@@ -0,0 +1,337 @@
|
||||
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": "Подарок и связанные использования удалены"}
|
||||
@@ -0,0 +1,101 @@
|
||||
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)
|
||||
@@ -0,0 +1,5 @@
|
||||
from . import admin, user # noqa: F401 — import triggers endpoint registration
|
||||
from ._common import router, user_router
|
||||
|
||||
|
||||
__all__ = ["router", "user_router"]
|
||||
@@ -0,0 +1,271 @@
|
||||
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
|
||||
@@ -0,0 +1,115 @@
|
||||
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="Ошибка при создании ключа")
|
||||
@@ -0,0 +1 @@
|
||||
from . import addons, core, hwid, location, renew # noqa: F401 — trigger registration
|
||||
@@ -0,0 +1,662 @@
|
||||
"""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))),
|
||||
)
|
||||
@@ -0,0 +1,299 @@
|
||||
"""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="Подписка удалена")
|
||||
@@ -0,0 +1,73 @@
|
||||
"""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),
|
||||
)
|
||||
@@ -0,0 +1,225 @@
|
||||
"""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),
|
||||
)
|
||||
@@ -0,0 +1,192 @@
|
||||
"""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,
|
||||
)
|
||||
@@ -0,0 +1,514 @@
|
||||
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}
|
||||
@@ -0,0 +1,165 @@
|
||||
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 []),
|
||||
)
|
||||
@@ -0,0 +1,125 @@
|
||||
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)}
|
||||
@@ -0,0 +1,112 @@
|
||||
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
@@ -0,0 +1,272 @@
|
||||
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",
|
||||
)
|
||||
@@ -0,0 +1,250 @@
|
||||
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,
|
||||
)
|
||||
@@ -0,0 +1,353 @@
|
||||
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),
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
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"],
|
||||
)
|
||||
@@ -0,0 +1,147 @@
|
||||
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"}
|
||||
@@ -0,0 +1,452 @@
|
||||
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"],
|
||||
)
|
||||
@@ -0,0 +1,63 @@
|
||||
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
@@ -0,0 +1,40 @@
|
||||
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,
|
||||
)
|
||||
@@ -0,0 +1,58 @@
|
||||
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]
|
||||
@@ -0,0 +1,91 @@
|
||||
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
|
||||
@@ -0,0 +1,132 @@
|
||||
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]
|
||||
@@ -0,0 +1,32 @@
|
||||
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
|
||||
@@ -0,0 +1,25 @@
|
||||
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
|
||||
@@ -0,0 +1,125 @@
|
||||
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)
|
||||
@@ -0,0 +1,479 @@
|
||||
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
@@ -0,0 +1,450 @@
|
||||
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,19 +1,40 @@
|
||||
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
|
||||
from config import API_TOKEN, REDIS_URL
|
||||
from database import async_session_maker
|
||||
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))
|
||||
storage = MemoryStorage()
|
||||
|
||||
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)
|
||||
|
||||
dp = Dispatcher(bot=bot, storage=storage)
|
||||
|
||||
dp.include_router(modules_hub)
|
||||
@@ -22,10 +43,27 @@ 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()
|
||||
|
||||
Executable → Regular
+2249
-121
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1 @@
|
||||
|
||||
|
||||
Binary file not shown.
@@ -1,5 +1,8 @@
|
||||
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
|
||||
@@ -9,7 +12,9 @@ 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:
|
||||
@@ -24,4 +29,52 @@ 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)
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
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.
@@ -0,0 +1,109 @@
|
||||
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.
@@ -0,0 +1,304 @@
|
||||
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,12 +3,16 @@ 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:
|
||||
@@ -18,6 +22,7 @@ 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(
|
||||
@@ -30,6 +35,7 @@ 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
|
||||
@@ -58,8 +64,11 @@ 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,11 +5,14 @@ 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:
|
||||
@@ -58,3 +61,5 @@ 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