Compare commits
788 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 02fbf89d09 | |||
| 305fbde885 | |||
| 3d94037d4e | |||
| 4a5aae06c3 | |||
| 19e476aa3f | |||
| 5c62ed8d92 | |||
| f138572b38 | |||
| f907906d04 | |||
| 097ce51b2c | |||
| 1fa553f246 | |||
| 32c65b3194 | |||
| a4d11f67fa | |||
| 1775d90bab | |||
| fa2d3eaa0a | |||
| 148e99ea6f | |||
| 64f99ac660 | |||
| bded99263a | |||
| aded5606a8 | |||
| 79982f5aa6 | |||
| dc27a6e69b | |||
| 8af59ffbfe | |||
| 013b26ecc8 | |||
| 7df831c084 | |||
| 507e0b5e1d | |||
| f2a12b8d98 | |||
| 1d089cce7d | |||
| ba89c054fd | |||
| 33801f58c4 | |||
| a963f4ad12 | |||
| b8472ee428 | |||
| 769f5eaf9a | |||
| c9e791fa76 | |||
| a32d9d422f | |||
| 39baa1640c | |||
| 37ebf4cdee | |||
| bb0085a438 | |||
| b05e57fb16 | |||
| 4c977d07ce | |||
| bd39825cf9 | |||
| e01a7efe4a | |||
| 8b159ab75d | |||
| 316bd191a2 | |||
| 29f6fe40e0 | |||
| 40f1f1b14e | |||
| 31822ee634 | |||
| 773db02746 | |||
| 2ba9615151 | |||
| 19c4c0496a | |||
| 903cde9a78 | |||
| 3592eccea9 | |||
| 4afd1668c9 | |||
| f799dd8766 | |||
| 254c489010 | |||
| 53bc6f6167 | |||
| b48cb94b12 | |||
| 0813ea2897 | |||
| 770f40f4ff | |||
| dba8752436 | |||
| 4b503ce31a | |||
| 8b9c00f53e | |||
| 55811aff56 | |||
| 310aa9bce6 | |||
| 2dcd1e65db | |||
| 4f840c171a | |||
| bfefdef304 | |||
| 3de049e436 | |||
| 74bb6b3f6d | |||
| 56e6dc3ae6 | |||
| c34626aec0 | |||
| d4187149b8 | |||
| e982a5b718 | |||
| a9a17bd693 | |||
| aef1973720 | |||
| 5a77f29bd5 | |||
| 0789b3fe9d | |||
| c82f64980b | |||
| 7623f339bc | |||
| 3de1635efa | |||
| eb824162d2 | |||
| 54d5df72af | |||
| 74c991cf85 | |||
| 46e6102412 | |||
| 698b8a9484 | |||
| 94f3c00628 | |||
| 630cf61f84 | |||
| d3b384aa61 | |||
| d209cf11c7 | |||
| 49eb0e68a1 | |||
| 3e7bebf189 | |||
| 73fbc22324 | |||
| 9774db74e5 | |||
| 82d8b50956 | |||
| 587bf76c61 | |||
| c2e4719f18 | |||
| 7b4bddfa28 | |||
| 8be57829fb | |||
| ceb3e160da | |||
| 007f3a7bcf | |||
| cc42900142 | |||
| 1009bb4001 | |||
| e4727dd0a4 | |||
| 3d6acf51f6 | |||
| 1634588be9 | |||
| c379011fd2 | |||
| 8483ef1df0 | |||
| 9be291c1e8 | |||
| 6871f2d15e | |||
| 60c9a4f1e6 | |||
| 5a894e0e10 | |||
| 45b9c56282 | |||
| 88c4d940e8 | |||
| 3741e4f3bf | |||
| 28e7c78601 | |||
| 37fbd35e8f | |||
| c41b77d365 | |||
| 1d19d8b744 | |||
| b04bc4dca5 | |||
| 4dd4d829d7 | |||
| bf35156455 | |||
| e6a1b55045 | |||
| 455ab76c8a | |||
| 720c145aa3 | |||
| 580ce90442 | |||
| 239426f6d5 | |||
| 3c4ae34997 | |||
| 899da825cb | |||
| 59e0d965da | |||
| b70e27932d | |||
| 69dcef073e | |||
| 89394ef630 | |||
| 79a9a95ffc | |||
| ee9e65d692 | |||
| 9bc135a51a | |||
| fabdfc6ab6 | |||
| dff378ad81 | |||
| b1739cf4ea | |||
| d47952fe3e | |||
| 74d58ce310 | |||
| 5f43bb6e55 | |||
| f7c906f608 | |||
| e97e3060f7 | |||
| b6ebfd5c1e | |||
| 3d67311e61 | |||
| 7bfbf89054 | |||
| 43959a996e | |||
| 591370ce60 | |||
| bb803ab145 | |||
| 07d14a301b | |||
| 56af0a7885 | |||
| c2ba4e9ab6 | |||
| 7b372e095e | |||
| 33f98276ce | |||
| 8818d91232 | |||
| 0aab2096a5 | |||
| 5a3f54c3a7 | |||
| 79ebe6b6bc | |||
| 6c323dc7df | |||
| 94980d4524 | |||
| 78c00b5454 | |||
| 9437ee9c90 | |||
| 4addb772a0 | |||
| a1d051a3f1 | |||
| c4d3c921bd | |||
| 1596c85bee | |||
| 3d4a72d3fd | |||
| ad9c49f841 | |||
| cc2b9b52c5 | |||
| 5a1d4c1b24 | |||
| 99cc4b26bc | |||
| 970511ca1d | |||
| b81a0f289f | |||
| 13259b435b | |||
| 44cadc29ba | |||
| a2c71a49f2 | |||
| e7d54ae317 | |||
| 1459bdacfa | |||
| a9e111bd4f | |||
| b63c3fb115 | |||
| a0a6686058 | |||
| 4c4a1a297c | |||
| fc4bb88db8 | |||
| e17cde7c72 | |||
| 3d7684b72b | |||
| 51558aeeda | |||
| d83da5daaa | |||
| 67b2da3447 | |||
| eeb330b21f | |||
| efff92b2b3 | |||
| 4d5cc954b5 | |||
| 1e204a3e29 | |||
| 98d0c03aee | |||
| 1a29bb6dd5 | |||
| 0959742f5b | |||
| 1c505b74d5 | |||
| 96a6612124 | |||
| 78181e7230 | |||
| 66bba43ec4 | |||
| 4140fbae78 | |||
| e4a4d68758 | |||
| 478d2ee0e6 | |||
| 588c32453d | |||
| ce6186fa17 | |||
| a617b940c1 | |||
| c370d32154 | |||
| 89f329458c | |||
| 4df9140cf8 | |||
| 8e74c12bca | |||
| 4e280d1b3e | |||
| c158c1339f | |||
| 9db8fdf79b | |||
| b6184d4c90 | |||
| 981dc0db01 | |||
| 0403ad5688 | |||
| f05b7416ef | |||
| 1d433d2cfd | |||
| 17a0fa0b21 | |||
| 7154374fdc | |||
| 34b1927c22 | |||
| 1e9367ab93 | |||
| a7d0cca66c | |||
| e3bb7f6663 | |||
| 450b8e17f0 | |||
| f176a11ff9 | |||
| aadd2edf45 | |||
| a02062a4b4 | |||
| 0092764f8f | |||
| 55b065f784 | |||
| eafe0a78d2 | |||
| 9da808a024 | |||
| 3d295743e2 | |||
| 6f8bc2b08d | |||
| 32d0ac81bd | |||
| 35f214a45f | |||
| 8fdc78c2e8 | |||
| 6943794e50 | |||
| 086e3fe5b0 | |||
| d247b764f7 | |||
| 283016f043 | |||
| 7c19492c05 | |||
| 054d710d6c | |||
| f11b014acf | |||
| ab27328cb7 | |||
| 9b98faedfd | |||
| e957fbdfa1 | |||
| 94273b5ee2 | |||
| 87d92b7dae | |||
| 9a823eddf3 | |||
| 52d124c879 | |||
| 287d0a635b | |||
| 1057fd6ef6 | |||
| d935a9cd49 | |||
| 97d598a293 | |||
| c9664c136b | |||
| 0b0fca8648 | |||
| 9577c8272f | |||
| 0d82294bdb | |||
| d40c845d62 | |||
| 70e3f5c714 | |||
| 8a15d0320c | |||
| 73bf682f97 | |||
| ba53e9e199 | |||
| e13937b965 | |||
| 7758415ddf | |||
| a19c325e15 | |||
| 10df771a15 | |||
| 6cd7e8acee | |||
| d8944a9c52 | |||
| 930ce0fcdc | |||
| fceff14d0a | |||
| 16e31d4abb | |||
| 5289548fd9 | |||
| 681fd7d92c | |||
| 932ef51de4 | |||
| 080a99cd55 | |||
| 93ac01592f | |||
| 1742519659 | |||
| 954c6b6ae7 | |||
| 50811afa30 | |||
| afc913a2ff | |||
| bab6a807f3 | |||
| 6329dfd3ef | |||
| 6484bf2c6e | |||
| 1e64817e3f | |||
| 15fef698d9 | |||
| c108ec4b2d | |||
| 5d70576582 | |||
| 177cbb8881 | |||
| 7d8050ce1c | |||
| fadafecb43 | |||
| ab6af88114 | |||
| 3b7bb5436b | |||
| bfe8ed8b3c | |||
| 95b338a1c2 | |||
| 072a90a028 | |||
| 933c8100e4 | |||
| 4951a5cf78 | |||
| 5e904f3045 | |||
| 0840a07ed9 | |||
| 2becee7f02 | |||
| ae22950ce3 | |||
| 169b276ef1 | |||
| 5966555ae9 | |||
| de27fa24d4 | |||
| 0b0b002c82 | |||
| 0e4f59a242 | |||
| 051714e613 | |||
| c780b1ffd7 | |||
| 88caa0bab3 | |||
| e6b071f64d | |||
| 9a809aa646 | |||
| 52bdf10e0e | |||
| 1642bde410 | |||
| 2fdb29d517 | |||
| ad8d28f7f0 | |||
| a4776d7682 | |||
| 3c7451e8fc | |||
| f65ed3eada | |||
| b4c47faac9 | |||
| 217bb457a1 | |||
| ce79caa58d | |||
| 76d72ba203 | |||
| 035ef47619 | |||
| 4792abc6d5 | |||
| 42ac740589 | |||
| d5371e4c04 | |||
| 7c883b07f9 | |||
| fa681fac92 | |||
| d1a9691c87 | |||
| b31c9d2e10 | |||
| 11176a16bc | |||
| 39aaa88532 | |||
| cf6fcdbb76 | |||
| 4e09e04dd9 | |||
| e0639c7116 | |||
| 1d915f849c | |||
| 404d502d2f | |||
| bcfb4c7942 | |||
| bd5cc56441 | |||
| bc42b0e52b | |||
| 71e549866b | |||
| ca9dd17450 | |||
| 91f12fe627 | |||
| 1406e87ab3 | |||
| 9398199f25 | |||
| 6ed83097de | |||
| 4d38ab0d3a | |||
| d955a6ccfc | |||
| 82da0cbf53 | |||
| 360d73dd26 | |||
| 14400018c9 | |||
| f845baa3d0 | |||
| 54e6c2aaaa | |||
| 240ad42beb | |||
| a8d8972855 | |||
| b70b432d05 | |||
| 77d6b8c67c | |||
| 9251087d2c | |||
| 5f2f3ad683 | |||
| 0f507aa101 | |||
| 99574ce751 | |||
| 441e7ef696 | |||
| 8395c29ae2 | |||
| ff37c48579 | |||
| 8e778eb7af | |||
| 2f6b85d4a6 | |||
| bb034ba6c6 | |||
| d86acbfb9d | |||
| baf0e2d695 | |||
| b58079d86f | |||
| ee2ae3f14d | |||
| 5420c38592 | |||
| 2a3d2098c9 | |||
| 36a44f3669 | |||
| ccbe82a140 | |||
| 8b6afcf571 | |||
| 74353143cf | |||
| 7f48f5b7b5 | |||
| 5bb83fe904 | |||
| bfdf5f9f41 | |||
| d7d0e3da42 | |||
| 11c84eae82 | |||
| cd7db1330e | |||
| 24c1f8b9cb | |||
| 8c62573664 | |||
| fc1dda5597 | |||
| 9e899f4ec6 | |||
| bb2fea1396 | |||
| 9a05ff82e4 | |||
| 21e82eed79 | |||
| ca1a279144 | |||
| 4e6eb4ea7f | |||
| e3787a041c | |||
| cd411c6b49 | |||
| 9acc9d8304 | |||
| cb19c3829f | |||
| b42cc6b484 | |||
| 4cf1f5dad8 | |||
| d863eb1ed9 | |||
| cbd0902e42 | |||
| fa2b45104c | |||
| bac2dbaf85 | |||
| f9e29f4fa9 | |||
| 82985aa96e | |||
| d34f49c9b2 | |||
| bbe355008a | |||
| ae115ba13b | |||
| dc7da4441a | |||
| d91465c435 | |||
| 9cb9b9441a | |||
| ac3a4a397f | |||
| 995964f70a | |||
| 708b0be31f | |||
| 052124d483 | |||
| b40bf3f833 | |||
| 11d7005954 | |||
| 7893be6ef8 | |||
| 18ca2b275c | |||
| bc333b12c2 | |||
| c3e052f73c | |||
| 29f606736b | |||
| ce17181be5 | |||
| 041c8e1511 | |||
| 270f01511d | |||
| a352901d85 | |||
| 8e5ce5768a | |||
| 97c03d0396 | |||
| 1b9b3e6729 | |||
| 3f336919ad | |||
| 8f8e89667d | |||
| 88b05547de | |||
| c35d067306 | |||
| 06489967f8 | |||
| 47e45cf596 | |||
| bc77c6a5ff | |||
| 31eb551b6d | |||
| 122b82253e | |||
| 5c79302cd6 | |||
| 34a99ceb98 | |||
| 576a315baa | |||
| 73965b64dd | |||
| 9673f10af7 | |||
| 8a9825f629 | |||
| fc830a427c | |||
| 26e8e91cb7 | |||
| 4cae37ef33 | |||
| a04a6bd5d2 | |||
| ac632a5e96 | |||
| 6701222697 | |||
| f42fe4236e | |||
| 3041e0a208 | |||
| 61bf3cd129 | |||
| fb34af114f | |||
| 56678abbb4 | |||
| 347805a8ae | |||
| 2438ac58e1 | |||
| 23b60389c0 | |||
| a58e45133a | |||
| 6fc26754bf | |||
| 8bfdc6e319 | |||
| 8d4e32c86c | |||
| f16226e48e | |||
| 05bef3d389 | |||
| d811ba5623 | |||
| abd6c03ba0 | |||
| 84a7c1c930 | |||
| 96b8ad8555 | |||
| e4f9301591 | |||
| 1eb60dc0c2 | |||
| cbcae665e4 | |||
| 4da82282de | |||
| 1548907ead | |||
| 854c6fa536 | |||
| 09ca65de0c | |||
| 52613d9feb | |||
| 4878cc2eab | |||
| 7e2f56f20b | |||
| 6b2914bff4 | |||
| 10b45ec226 | |||
| 23ec7e3ccb | |||
| 3aaa8d1ea3 | |||
| a46e8d3a5a | |||
| 6f9ceb1a89 | |||
| d5dbb39444 | |||
| 0f0af0c194 | |||
| 545345a020 | |||
| 0766cda29e | |||
| 6563b7dc67 | |||
| 5b8b950aaf | |||
| 8020fd9a7d | |||
| 8836dc577e | |||
| a2be86b134 | |||
| e9bc47680a | |||
| da62ab5a90 | |||
| dd84c8dd3a | |||
| 7e40a1f96c | |||
| 69f880af2b | |||
| 7c93c8cc31 | |||
| c2d06e10b8 | |||
| 9c8fafabb1 | |||
| c63708cb83 | |||
| 8f9b84277b | |||
| 8363d319c2 | |||
| a353d41738 | |||
| 0cfd656d86 | |||
| 5001fc94fe | |||
| a014596d51 | |||
| 64b4cfd1b2 | |||
| dcc7d5e218 | |||
| ca8c5691d3 | |||
| b257d8c66d | |||
| 601598dfd2 | |||
| aef9c344ab | |||
| f6f1227dd8 | |||
| 6d750c91f8 | |||
| 533d4fa64c | |||
| 0be6ed0631 | |||
| 346dfaee2e | |||
| e2796d22e2 | |||
| b96c42c153 | |||
| 83b5a8608a | |||
| 1abe1c7166 | |||
| c35bd82ebe | |||
| b6ed49ad82 | |||
| e70a2d74ba | |||
| d3f00acd5c | |||
| 00b0823eb3 | |||
| ab488ed0a3 | |||
| 12046b9c5c | |||
| a457598109 | |||
| 1f2f6ee82a | |||
| 8d41b19767 | |||
| cb59a1d176 | |||
| 2961477ecf | |||
| 5db30c05f7 | |||
| 90aa095621 | |||
| c7d5ff94c0 | |||
| 136832b22a | |||
| 79c723ec06 | |||
| 43883a3c06 | |||
| 0eba072826 | |||
| 0604675be6 | |||
| 75147eb5a1 | |||
| 5dd1fab7e9 | |||
| 31fa90aef1 | |||
| 4f0df51abd | |||
| 584fec2dc8 | |||
| 5744d03ad9 | |||
| 4aa521c6b9 | |||
| 545ceac0b8 | |||
| bd699b5700 | |||
| cb7684b015 | |||
| 567c04d74e | |||
| ac3451a209 | |||
| 8f4403accd | |||
| 8b19cf0956 | |||
| 5db5c8377b | |||
| 59ca46908e | |||
| efd56e513d | |||
| 6857e2b9c8 | |||
| 72438fea7e | |||
| 7a4aa5099d | |||
| 13efb0cc45 | |||
| 0c65d378cd | |||
| e73b8e6538 | |||
| 5bbc248a4b | |||
| b37c724aa0 | |||
| b89b0fe1ce | |||
| 577592479b | |||
| 20031eb843 | |||
| 35939d89b8 | |||
| 2ae03cb6b0 | |||
| 47be269ddf | |||
| ee30f5ab95 | |||
| e473988750 | |||
| 2a768568a9 | |||
| 1b45580390 | |||
| 37f8cf9881 | |||
| 6c84c873cf | |||
| a2a3d85cf5 | |||
| 59db7344a0 | |||
| 8f2d027a83 | |||
| 0a37596471 | |||
| 2f0208a905 | |||
| af8ecffb6d | |||
| 08d38e651b | |||
| 0db832473a | |||
| e7f682f02d | |||
| e002035518 | |||
| c16f2e8c72 | |||
| d588726838 | |||
| b5c8f4fa28 | |||
| dab1a8d754 | |||
| 27f5b9d43a | |||
| 4bf5b3a87d | |||
| adaae41826 | |||
| aaa754f66f | |||
| a294f123d2 | |||
| 617e599e74 | |||
| 426e2a881a | |||
| 96c9792dce | |||
| 0864a8a1ce | |||
| 5963cdc854 | |||
| 04c7616661 | |||
| ca68eb58d3 | |||
| b8b6f29c48 | |||
| 1d9aa3b432 | |||
| b12bb33242 | |||
| 1f7d0a263f | |||
| 34e213cc78 | |||
| 6651034413 | |||
| 800a86c500 | |||
| 1806a3db4b | |||
| 6a1f3f810e | |||
| cf923e1e9a | |||
| d122b59c6d | |||
| 2bd4d802a3 | |||
| 012ed787e8 | |||
| 6ada73b6cb | |||
| 719865fe83 | |||
| c34c5ef7af | |||
| 9c85ae31e5 | |||
| 987602551e | |||
| 9dcbc7d736 | |||
| 92fbe6ced5 | |||
| d79d7004ec | |||
| 4d1a75939e | |||
| 1c9af07a7d | |||
| 8f16fa4c1a | |||
| fe2ae18f20 | |||
| aa73bdd066 | |||
| a48bc4a0d8 | |||
| 3461f47cfd | |||
| 38d99b2a47 | |||
| 25a51549ac | |||
| bca136a55e | |||
| 1953e7c0cb | |||
| 6754a7da93 | |||
| 8fb3b1691f | |||
| 91bc384508 | |||
| 666c3b34af | |||
| 38817c82f7 | |||
| 10a6179b7c | |||
| 411647585d | |||
| f5c15546e2 | |||
| 109a968941 | |||
| b32cf0a9a0 | |||
| bbd68becfc | |||
| c4400466dd | |||
| da4ed7dd22 | |||
| c0fc972ce5 | |||
| cdcfcb7987 | |||
| 48528e611f | |||
| 0e982e8361 | |||
| cc9715ca7b | |||
| 6cb6aa4c67 | |||
| 040e32a50a | |||
| 40092c7085 | |||
| 1bd138e78b | |||
| fae10faf7e | |||
| 51d0b71e1d | |||
| 9cf29cc59c | |||
| 3e680fd31d | |||
| 2e5d581fb5 | |||
| 8a235e129d | |||
| d899ddf663 | |||
| cd6d05320e | |||
| 4ef0ce9d19 | |||
| c78fee4a6b | |||
| abc46bf172 | |||
| 3680d0606a | |||
| 45e4b2f696 | |||
| f11e3f255e | |||
| 3915f37e55 | |||
| 0a4e1e8c50 | |||
| 0d390bb9cc | |||
| 15f3e89e61 | |||
| 869a0e8472 | |||
| 4f0c64b786 | |||
| 10e10b7157 | |||
| b192b43bd3 | |||
| e129a06d9b | |||
| c678a9bfce | |||
| c8cdfc980b | |||
| 146512b2ab | |||
| 30c03677fe | |||
| 2baf63dccd | |||
| 363bf80bf4 | |||
| 50146be4a6 | |||
| 09673a145c | |||
| 16561610b6 | |||
| a71afc554e | |||
| 7512868219 | |||
| 9008ba3b99 | |||
| 83d589fff5 | |||
| 5cb07aa68a | |||
| dd5463881d | |||
| 77084b1ff2 | |||
| e965652e1f | |||
| 1c3d24527f | |||
| 9bfb39f1ae | |||
| 0c46a70f53 | |||
| dedeac394b | |||
| 57e813e3d4 | |||
| 02847dbb93 | |||
| 470a05018e | |||
| 649d8060df | |||
| 5a04b360db | |||
| 76d2f28128 | |||
| 37561c086f | |||
| 262b7c9489 | |||
| 73cfe74c1c | |||
| 68f6994b94 | |||
| 57ecdbbda5 | |||
| 3ff07e4c76 | |||
| 8d4bec79b3 | |||
| 546f4c52cb | |||
| b223b4463a | |||
| 258f8fe2f7 | |||
| c697498cb4 | |||
| 4b1e8b555a | |||
| ec4217fc4f | |||
| 49fa3693ad | |||
| 845a7a287b | |||
| b5928722eb | |||
| 9f0d0acb53 | |||
| b4af296384 | |||
| 3466157550 | |||
| 754a33cbc7 | |||
| 8538050fb5 | |||
| 165e2955e0 | |||
| bcce80a52d | |||
| 811ca4ff30 | |||
| 80249609af | |||
| 63039e3537 | |||
| def9d5a016 | |||
| 41dd64f7c8 | |||
| a0bada3e76 | |||
| a405f0a5ad | |||
| 7bd484528a | |||
| 2c6b2b9784 | |||
| 41ca9b704f | |||
| 06a1360714 | |||
| 6583f2e6cc | |||
| eae768615c | |||
| 10b4562982 | |||
| ed5a129b6a | |||
| dc661dabcc | |||
| c5e9cb9b18 | |||
| 576a389028 | |||
| 709c1064a5 | |||
| 8588b89421 | |||
| f7886dcfea | |||
| 60fa6cb4f1 | |||
| 98832d7ade | |||
| dbfed0dc2a | |||
| a674c321de | |||
| d91e9180ea | |||
| fbe7e9f820 | |||
| c7fb1045c2 | |||
| 4ba3e14961 | |||
| 7746835832 | |||
| 264d6186fa | |||
| 3f5c449817 | |||
| d03907b43c | |||
| 56bd72c3bc | |||
| f42ca88e01 | |||
| 87095772fe | |||
| bfca706b31 | |||
| bba0e3ebdb | |||
| 4958061a19 | |||
| 8aec92fe43 | |||
| f84293f270 | |||
| aa830c9a37 | |||
| 58357f2f8b | |||
| 19f40c8fad | |||
| e1aadf9781 | |||
| 3198cd13f4 | |||
| 67b85478aa | |||
| 08cbeceea4 | |||
| 60d7e89452 | |||
| 3937cb105c | |||
| 16c776bf82 | |||
| 467557fb59 | |||
| 140020838b | |||
| 18f329b529 | |||
| 271ea09380 | |||
| d6dc9ab365 | |||
| 376cd07b5c |
@@ -1,36 +0,0 @@
|
||||
---
|
||||
name: Issue
|
||||
about: Сообщить об ошибке, предложить улучшение или указать на уязвимость
|
||||
title: "[ISSUE] "
|
||||
labels: triage
|
||||
assignees: ''
|
||||
---
|
||||
|
||||
### Тип
|
||||
- [ ] Баг
|
||||
- [ ] Фича
|
||||
- [ ] Безопасность
|
||||
|
||||
### Кратко
|
||||
Опишите суть задачи.
|
||||
|
||||
### Шаги воспроизведения (для багов/безопасности)
|
||||
1.
|
||||
2.
|
||||
3.
|
||||
|
||||
### Ожидаемое / Фактическое
|
||||
-
|
||||
|
||||
### Окружение
|
||||
- Ветка: main/dev
|
||||
- Коммит:
|
||||
- ОС:
|
||||
- Python:
|
||||
|
||||
### Дополнительно (для фич)
|
||||
Опишите мотивацию, альтернативы.
|
||||
|
||||
### Примечание по безопасности
|
||||
Не публикуйте PoC публично.
|
||||
Для сообщений об уязвимостях свяжитесь напрямую: [@solonet_sup](https://t.me/solonet_sup).
|
||||
@@ -1,17 +0,0 @@
|
||||
#!/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
|
||||
|
||||
exit 0
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 96 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 85 KiB |
@@ -1,21 +0,0 @@
|
||||
### Описание изменений
|
||||
Опишите, что изменилось и зачем.
|
||||
|
||||
### Ветка назначения
|
||||
- [ ] dev (основная ветка разработки)
|
||||
- [ ] main (только для критических исправлений безопасности)
|
||||
|
||||
### Тип изменений
|
||||
- [ ] Новая функциональность
|
||||
- [ ] Исправление ошибки
|
||||
- [ ] Рефакторинг
|
||||
- [ ] Документация
|
||||
- [ ] Другое
|
||||
|
||||
### Чек-лист
|
||||
- [ ] Изменения протестированы локально
|
||||
- [ ] Документация обновлена (если требуется)
|
||||
- [ ] Нет лишних изменений в коде
|
||||
|
||||
### Связанные задачи
|
||||
Укажите номер issue, если PR его закрывает (например: Closes #123).
|
||||
+9
-32
@@ -7,7 +7,6 @@ __pycache__/
|
||||
dist/
|
||||
build/
|
||||
*.egg-info/
|
||||
alembic/
|
||||
|
||||
# Virtual environments
|
||||
venv/
|
||||
@@ -27,19 +26,14 @@ env/
|
||||
*.db
|
||||
|
||||
# Sensitive configuration files
|
||||
/config.py
|
||||
/config.ini
|
||||
/alembic.ini
|
||||
/.env
|
||||
config.py
|
||||
config.ini
|
||||
.env
|
||||
|
||||
# Backup files
|
||||
*.bak
|
||||
*.swp
|
||||
*~
|
||||
backups/
|
||||
backup_bot/
|
||||
Solo_backup/
|
||||
.cursor/
|
||||
|
||||
# Specific project files
|
||||
vpn_users.db
|
||||
@@ -47,6 +41,7 @@ database.db
|
||||
bot_old.py
|
||||
bot_old_2.py
|
||||
backup_pg.sh
|
||||
docker-compose.yml
|
||||
config copy.py
|
||||
handlers/texts.py
|
||||
|
||||
@@ -55,29 +50,11 @@ handlers/texts.py
|
||||
Thumbs.db
|
||||
|
||||
nginx.conf
|
||||
/scripts/load_balancer.py
|
||||
/scripts/__pycache__
|
||||
scripts
|
||||
models.py
|
||||
Dockerfile
|
||||
.csv
|
||||
/logs
|
||||
setup.py
|
||||
.ruff_cache
|
||||
modules/
|
||||
storage/
|
||||
static/web_uploads/
|
||||
|
||||
.license_state
|
||||
Solo_backup/
|
||||
|
||||
# Node / Next.js artifacts (root — web-app has its own)
|
||||
node_modules/
|
||||
.next/
|
||||
.vite/
|
||||
.claude/
|
||||
|
||||
.cursor/hooks.json
|
||||
.cursor/hooks/after-agent-response.cjs
|
||||
.cursor/hooks/before-submit-prompt.cjs
|
||||
.cursor/hooks/after-agent-response.js
|
||||
.cursor/hooks/before-submit-prompt.js
|
||||
.cursor/cursor-notifier.json
|
||||
.cursor/cursor-notifier-start.json
|
||||
nuitka-crash-report*
|
||||
.github/workflows/
|
||||
@@ -0,0 +1 @@
|
||||
3.12
|
||||
@@ -1,25 +0,0 @@
|
||||
# Code of Conduct
|
||||
|
||||
## Общие правила
|
||||
|
||||
- Уважайте друг друга. Недопустимы оскорбления, дискриминация или токсичное поведение.
|
||||
- Обсуждения должны оставаться по теме и в конструктивной форме.
|
||||
- Личные и конфиденциальные данные публиковать запрещено.
|
||||
- Нарушения могут привести к предупреждению, ограничению участия или блокировке по усмотрению мейнтейнеров.
|
||||
|
||||
## Участники GitHub
|
||||
|
||||
- Могут предлагать изменения через **Issues** и **Pull Requests**.
|
||||
- Их вклад рассматривается и принимается по решению мейнтейнеров.
|
||||
- Дискуссии ограничены рамками кода и технических вопросов.
|
||||
|
||||
## Участники сообщества в Telegram
|
||||
|
||||
- Имеют приоритетное взаимодействие с разработчиком.
|
||||
- Могут обсуждать проект шире: идеи, улучшения, новые направления.
|
||||
- Влияние на развитие кода выше, чем у участников извне.
|
||||
- Общение остаётся в рамках уважения и конструктивности.
|
||||
|
||||
## Контакты
|
||||
|
||||
По вопросам поведения используйте Issues с меткой `conduct` или обращайтесь через сообщество в Telegram.
|
||||
@@ -1,29 +0,0 @@
|
||||
# Contributing Guidelines
|
||||
|
||||
Спасибо, что хотите внести вклад в развитие проекта!
|
||||
|
||||
## Как помочь
|
||||
|
||||
- Создавайте **Issues** для конкретных целей или описания найденных багов.
|
||||
- Отправляйте **Pull Requests** с улучшениями и исправлениями. Каждое предложение будет рассмотрено, и мы примем решение о включении.
|
||||
- Делайте **форки** репозитория и дорабатывайте код — лучшие практики мы обязательно объединим в общее дело.
|
||||
|
||||
## Стиль кода
|
||||
|
||||
- Соблюдайте стандарт **PEP8**.
|
||||
- Для автоматической проверки можно использовать **ruff**.
|
||||
|
||||
## Правила коммитов
|
||||
|
||||
- Сообщения должны быть осмысленными и отражать суть изменений.
|
||||
- Если в одном коммите несколько изменений, перечисляйте их через `/`.
|
||||
|
||||
## Лицензия
|
||||
|
||||
Creative Commons Attribution-NonCommercial 4.0 International Public License © Vladless, 2024.
|
||||
|
||||
By exercising the Licensed Rights (defined below), You accept and agree to be bound by the terms and conditions of this Creative Commons Attribution-NonCommercial 4.0 International Public License ("Public License"). To the extent this Public License may be interpreted as a contract, You are granted the Licensed Rights in consideration of Your acceptance of these terms and conditions, and the Licensor grants You such rights in consideration of benefits the Licensor receives from making the Licensed Material available under these terms and conditions.
|
||||
|
||||
Внося изменения, вы соглашаетесь, что ваш вклад распространяется на условиях этой лицензии. Перепродажа или коммерческое распространение этого кода без явного письменного согласия правообладателя не допускаются.
|
||||
---
|
||||
Ваш вклад помогает проекту расти и становиться лучше.
|
||||
-28
@@ -1,28 +0,0 @@
|
||||
FROM python:3.12-slim
|
||||
|
||||
ENV PYTHONDONTWRITEBYTECODE=1
|
||||
ENV PYTHONUNBUFFERED=1
|
||||
ENV PIP_NO_CACHE_DIR=1
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
ca-certificates \
|
||||
tzdata \
|
||||
git \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
COPY requirements.txt .
|
||||
COPY . .
|
||||
|
||||
RUN rm -rf /app/venv \
|
||||
&& python -m venv /app/venv \
|
||||
&& /app/venv/bin/pip install --upgrade pip \
|
||||
&& /app/venv/bin/pip install -r requirements.txt
|
||||
|
||||
RUN adduser --disabled-password --gecos "" appuser \
|
||||
&& mkdir -p /app/backups \
|
||||
&& chown -R appuser:appuser /app
|
||||
USER appuser
|
||||
|
||||
CMD ["/app/venv/bin/python", "main.py"]
|
||||
@@ -1,19 +1,7 @@
|
||||
format:
|
||||
formatting:
|
||||
@echo "Running Ruff format..." && ruff format . --config pyproject.toml --exclude main.py,handlers/payments
|
||||
|
||||
@echo "Running Ruff..." && ruff check . --config pyproject.toml --exclude main.py,handlers/payments --fix
|
||||
|
||||
lint:
|
||||
@echo "Running Ruff checks..." && ruff check . --config pyproject.toml --exclude main.py,handlers/payments
|
||||
|
||||
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"
|
||||
@echo "Running Ruff checks..." && ruff check . --config pyproject.toml --exclude main.py,handlers/payments
|
||||
@@ -1,211 +1,106 @@
|
||||
<div align="center" style="max-width:980px;margin:0 auto;padding:0 12px">
|
||||
# 🚀 SoloBot
|
||||
|
||||

|
||||
**SoloBot** — ваш идеальный помощник для управления API 3x-UI VPN на протоколе VLESS.
|
||||
|
||||
<h1 style="margin:0;font-weight:900;letter-spacing:.3px">SoloBot</h1>
|
||||
<p style="margin:6px 0 0;font-size:16px;opacity:.9"><b>Идеальный помощник для 3x-UI и Remnawave (VLESS)</b></p>
|
||||
<img src="./img/preview.gif" alt="SoloBot" width="1600">
|
||||
|
||||
# Описание
|
||||
Бот, предоставляющий инструменты под различные реализации. Хорошая кастомизация и подстройка под свой бренд.
|
||||
|
||||
# Основные возможности
|
||||
|
||||
**SoloBot** реализует множество функций, включая:
|
||||
|
||||
### Управление подписками
|
||||
- Выдача подписок на различные периоды (**1 месяц, 3 месяца, полгода, год**).
|
||||
- Использование **тестового периода** подписки.
|
||||
- Продление ключей на указанные периоды.
|
||||
|
||||
### Полный контроль клиента над своими ключами
|
||||
- Просмотр информации о ключе (**сервер, оставшееся время, сам ключ**).
|
||||
- Продление ключей.
|
||||
- Удаление ключей.
|
||||
- Просмотр полной информации вплоть до минут истечения
|
||||
- Смена локации (**перемещение ключа между серверами**).
|
||||
- Поддержка **нескольких ключей и устройств** для одного клиента.
|
||||
|
||||
### Реферальная программа
|
||||
- Уникальная ссылка для приглашений.
|
||||
- Инлайн режим или обычные сообщения
|
||||
|
||||
### Интеграция платёжных систем
|
||||
В боте реализована интеграция касс как для физических лиц, так и для самозанятых и ИП:
|
||||
- **Юкасса** (Самозанятость и ИП)
|
||||
- **Юмани** (Физические Лица) (TrackLine)
|
||||
- **Robokassa** (Физические Лица)
|
||||
- **Cryptobot** (Криптовалюта) (izzzzzi)
|
||||
- **Звезды Telegram**
|
||||
|
||||
### Безопасность и стабильность
|
||||
- **Периодические бэкапы** базы данных клиентов и их восстановление.
|
||||
- **Смена домена** в случае переезда.
|
||||
|
||||
### Уведомления
|
||||
- **Произвольные сообщения** через панель администратора.
|
||||
- Уведомления о **неиспользованных пробниках**.
|
||||
- Уведомления об **истекающих ключах** (_за сутки, за 6 часов и в момент истечения_).
|
||||
- **Воронка продаж**. Если клиент не взял пробник предлагаются более выгодные условия.
|
||||
- **Рассылка лично или всем.** С сохранением форматирования и отправкой картинок
|
||||
|
||||
### Серверы
|
||||
- **Мультисерверность** — добавление сервера прямо в админке.
|
||||
- **Проверка на доступность** — бот следит за серверами и их откликом.
|
||||
- **Балансировка** — бот выдает подписку на наименее загруженный сервер.
|
||||
- **Сихронизация** — перемещайте клиентов на другие сервера бесшовно.
|
||||
|
||||
### Удобная админ-панель
|
||||
- **Поиск клиента** — по айди, нику или ключу.
|
||||
- **Управление подпиской клиента** — перевыпуск, изменение баланса и остальное.
|
||||
- **Управление ботом** — перезагрузка, списки банов, смена домена.
|
||||
- *А также рассылки, создание купонов, просмотр детальной статистики и многое другое*
|
||||
|
||||
|
||||
<p style="margin:16px 0 0">
|
||||
<a href="https://pocomacho.ru/solonetbot/" target="_blank">
|
||||
<img height="46" src="https://img.shields.io/badge/➡_Website-visit-ff6a00?style=for-the-badge&labelColor=1f1f1f&logo=firefox&logoColor=ff6a00" alt="Website">
|
||||
</a>
|
||||
<a href="https://t.me/SoloNetVPN_bot" target="_blank">
|
||||
<img height="46" src="https://img.shields.io/badge/Try_in_Telegram-demo-ff6a00?style=for-the-badge&labelColor=1f1f1f&logo=telegram&logoColor=ff6a00" alt="Telegram">
|
||||
</a>
|
||||
<a href="https://pocomacho.ru/solonetbot/features" target="_blank">
|
||||
<img height="46" src="https://img.shields.io/badge/Features-overview-ff6a00?style=for-the-badge&labelColor=1f1f1f&logo=readthedocs&logoColor=ff6a00" alt="Features">
|
||||
</a>
|
||||
<a href="https://github.com/Vladless/Solo_bot/releases" target="_blank">
|
||||
<img height="46" src="https://img.shields.io/badge/Releases-history-ff6a00?style=for-the-badge&labelColor=1f1f1f&logo=github&logoColor=ff6a00" alt="Releases">
|
||||
</a>
|
||||
</p>
|
||||
<img src="./img/preview_2.gif" alt="SoloBot" width="1600">
|
||||
|
||||
<p style="margin:10px 0 0">
|
||||
<a href="LICENSE" target="_blank">
|
||||
<img height="32" src="https://img.shields.io/badge/License-CC_BY--NC_4.0-ff6a00?style=for-the-badge&labelColor=1f1f1f&logo=creative-commons&logoColor=ff6a00" alt="License">
|
||||
</a>
|
||||
</p>
|
||||
### Кастомизация
|
||||
- **Пробник** — Сами решаете будет или нет. Количество дней пробника также кастомизируется.
|
||||
- **Реферальная программа** — процент с каждого пополнения или фиксированная сумма.
|
||||
- **Все меню** — меняйте все меню как вам захочется, описания и тексты.
|
||||
- **Кешбэк** — начисляйте кэшбек за пополнения, если хотите.
|
||||
- **Настройки** — капча, подписка на канал, купоны и прочее.
|
||||
- **Автоматика** — удалять ли ключи после продления, продлевать ли их и остальное.
|
||||
|
||||
<hr style="height:1px;border:0;background:#222;margin:18px 0 16px">
|
||||
<p><b>Многофункциональный бот, кастомизируемый под ваш бренд.</b></p>
|
||||
</div>
|
||||
### *Настраивайте как вам удобно!*
|
||||
|
||||
|
||||
<h2 align="center">Основные возможности</h2>
|
||||
## Список версий
|
||||
|
||||
<table align="center" style="max-width:980px;width:100%">
|
||||
<tr>
|
||||
<th align="left" style="width:30%">📌 Раздел</th>
|
||||
<th align="left">💡 Возможности</th>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td><b>Мультипанель</b></td>
|
||||
<td>3x-ui<br>Remnawave<br>Совместимый режим</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td><b>Подписки</b></td>
|
||||
<td>Произвольный срок и триал<br>Продление по тарифу<br>Happ / Hiddify / v2RayTun<br>Кастом заголовков</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td><b>Тарификация</b></td>
|
||||
<td>Лимиты устройств<br>Лимиты трафика<br>Лимиты серверов в подписках</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td><b>Клиент</b></td>
|
||||
<td>Ключ / сервер / TTL<br>Продление / удаление / начисление дней / отключение<br>Смена локации<br>Несколько устройств</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td><b>Поддержка роутеров</b></td>
|
||||
<td>Выдача VLESS подписки за отдельную цену</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td><b>Рефералы</b></td>
|
||||
<td>Уникальные ссылки<br>Инлайн и обычные сообщения<br>% или фикс</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td><b>UTM-аналитика</b></td>
|
||||
<td>Трекинг переходов<br>Привязка к рефералам / купонам / триалам<br>Конверсии<br>Просмотр в админке</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td><b>Оплата</b></td>
|
||||
<td>YooKassa (ИП/Самозанятые)<br>YooMoney (Физ. лица) <a href="https://github.com/TrackLine">@TrackLine</a><br>Robokassa (ИП)<br>Telegram Stars<br>Heleket (Физ. лица) <a href="https://github.com/JustYay">@JustYay</a><br>Wata (Физ. лица) <a href="https://github.com/TrackLine">@TrackLine</a><br>Kassai (Физ. лица) <a href="https://github.com/JustYay">@JustYay</a><br>И другие <a href="https://pocomacho.ru/solonetbot/modules/">см. Модули</a></td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td><b>Валютность</b></td>
|
||||
<td>Оплата в разных валютах</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td><b>Надёжность</b></td>
|
||||
<td>Бэкапы<br>Смена домена<br>Проверка доступности<br>Уведомления об аптайме</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td><b>Уведомления</b></td>
|
||||
<td>О скором окончании подписки<br>О непотраченном трафике</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td><b>Рассылки</b></td>
|
||||
<td>По заданным группам<br>С картинками или видео</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td><b>Серверы</b></td>
|
||||
<td>Мультисерверность<br>Одна локация или подписка<br>Автопроверка и балансировка<br>Синхронизация и лимиты<br>Вкл/выкл</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td><b>Воронка продаж</b></td>
|
||||
<td>Доп. дни к триалу: не взял триал → оффер → активация<br>Горячие лиды: платил → перестал → таргет/КП → возврат</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td><b>Админка</b></td>
|
||||
<td>Поиск TG/username/ключ/email<br>Баланс / подписки / заморозки<br>Перезагрузка<br>Бан-лист · купоны · UTM · статистика<br>Синхронизация / импорт из панели</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td><b>Модульность</b></td>
|
||||
<td>Подключаемые модули<br>Вкл/выкл и перезапуск из админки<br>Обновления и версии<br>Возможность дорабатывать код не теряя обновления</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td><b>Кастомизация</b></td>
|
||||
<td>Гибкая настройка функций и логики<br>Возможность отключения отдельных функций</td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
<div style="height:22px"></div>
|
||||
<div style="height:1px;background:linear-gradient(90deg,rgba(0,0,0,0),#ff6a00,rgba(0,0,0,0));filter:drop-shadow(0 0 10px rgba(255,106,0,.6))"></div>
|
||||
<div style="height:22px"></div>
|
||||
|
||||
<hr style="height:1px;border:0;background:#222;margin:18px 0 16px">
|
||||
|
||||
<h2 align="center">Обновления</h2>
|
||||
<p align="center">Регулярные релизы. Обновление через CLI. Ветки: <code>main</code> — стабильные версии, <code>dev</code> — бета.</p>
|
||||
<p align="center">
|
||||
<a href="https://github.com/Vladless/Solo_bot/releases" target="_blank">
|
||||
<img height="46" src="https://img.shields.io/badge/➡_Все_версии-смотреть-ff6a00?style=for-the-badge&labelColor=1f1f1f&logo=github&logoColor=ff6a00" alt="Все версии">
|
||||
</a>
|
||||
</p>
|
||||
|
||||
<div style="height:22px"></div>
|
||||
<div style="height:1px;background:linear-gradient(90deg,rgba(0,0,0,0),#ff6a00,rgba(0,0,0,0));filter:drop-shadow(0 0 10px rgba(255,106,0,.6))"></div>
|
||||
<div style="height:22px"></div>
|
||||
|
||||
<hr style="height:1px;border:0;background:#222;margin:18px 0 16px">
|
||||
|
||||
<h2 align="center">Гайды и расширения</h2>
|
||||
<p align="center" style="max-width:900px;margin:8px auto 14px">
|
||||
На сайте всегда лежат актуальные файлы для запуска разных версий бота. Есть собственное Wiki с пошаговыми инструкциями по установке и обновлениям. Доступна система модулей: бот легко дополняется готовыми расширениями и вашим кастомным функционалом.
|
||||
</p>
|
||||
<p align="center">
|
||||
<img src="https://github.com/user-attachments/assets/7d0a954d-41b7-4d7c-8618-aee1f4459792" alt="Guides" width="100%">
|
||||
</p>
|
||||
<p align="center">
|
||||
<a href="https://pocomacho.ru/solonetbot/" target="_blank">
|
||||
<img height="44" src="https://img.shields.io/badge/➡_Сайт_SoloBot-перейти-ff6a00?style=for-the-badge&labelColor=1f1f1f&logo=firefox&logoColor=ff6a00" alt="Site">
|
||||
</a>
|
||||
<a href="https://pocomacho.ru/solonetbot/reviews/" target="_blank">
|
||||
<img height="44" src="https://img.shields.io/badge/Отзывы-на_сайте-ff6a00?style=for-the-badge&labelColor=1f1f1f&logo=discourse&logoColor=ff6a00" alt="Reviews">
|
||||
</a>
|
||||
</p>
|
||||
|
||||
<div style="height:22px"></div>
|
||||
<div style="height:1px;background:linear-gradient(90deg,rgba(0,0,0,0),#ff6a00,rgba(0,0,0,0));filter:drop-shadow(0 0 10px rgba(255,106,0,.6))"></div>
|
||||
<div style="height:22px"></div>
|
||||
<hr style="height:1px;border:0;background:#222;margin:18px 0 16px">
|
||||
|
||||
<h2 align="center">Полная версия</h2>
|
||||
<p align="center">
|
||||
<img src="https://github.com/user-attachments/assets/597e6c4e-68be-4d8f-826b-35754c682a30" alt="Community" width="100%">
|
||||
</p>
|
||||
<p align="center" style="max-width:900px;margin:8px auto 14px">
|
||||
SoloBot уже помог более чем 500 пользователям из сообщества. Присоединяйтесь: попробуйте бота или напишите в поддержку, чтобы получить доступ и консультацию.
|
||||
</p>
|
||||
<p align="center">
|
||||
<a href="https://t.me/SoloNetVPN_bot" target="_blank">
|
||||
<img height="46" src="https://img.shields.io/badge/Попробовать_бота-открыть_Telegram-ff6a00?style=for-the-badge&labelColor=1f1f1f&logo=telegram&logoColor=ff6a00" alt="Try bot">
|
||||
</a>
|
||||
<a href="https://t.me/solonet_sup" target="_blank">
|
||||
<img height="46" src="https://img.shields.io/badge/Написать_в_поддержку-получить_доступ-ff6a00?style=for-the-badge&labelColor=1f1f1f&logo=telegram&logoColor=ff6a00" alt="Support">
|
||||
</a>
|
||||
</p>
|
||||
<hr style="height:1px;border:0;background:#222;margin:18px 0 16px">
|
||||
|
||||
<h2 align="center">Лицензия</h2>
|
||||
<table align="center" style="max-width:900px;width:100%">
|
||||
<tr>
|
||||
<td style="width:8%;text-align:center">❗</td>
|
||||
<td><b>Этот проект распространяется по лицензии <a href="LICENSE">CC BY-NC 4.0</a></b></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="text-align:center">⛔</td>
|
||||
<td><b>Перепродажа кода запрещена.</b> Нельзя продавать или перепродавать код без разрешения автора.</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="text-align:center">✅</td>
|
||||
<td><b>Для личного использования.</b> Код можно использовать и модифицировать для личных проектов.</td>
|
||||
</tr>
|
||||
</table>
|
||||
<hr style="height:1px;border:0;background:#222;margin:18px 0 16px">
|
||||
|
||||
<h2 align="center">Участники</h2>
|
||||
<p align="center">
|
||||
<a href="https://github.com/Vladless/Solo_bot/graphs/contributors">
|
||||
<img src="https://contrib.rocks/image?repo=Vladless/Solo_bot" alt="Contributors">
|
||||
</a>
|
||||
</p>
|
||||
#### Бот постоянно развивается, обрастая новыми возможностями.
|
||||
#### Для полного рассмотрения каждой версии ознакомьтесь с [**релизами**](https://github.com/Vladless/Solo_bot/releases).
|
||||
|
||||
|
||||
## 🔗 SoloBot в Telegram и Полная версия
|
||||
|
||||
#### Наш [**➡ сайт**](https://pocomacho.ru/solonetbot/):
|
||||
|
||||
Всегда актуальные гайды по установке, файлы для запуска и ссылка на общий чат:
|
||||

|
||||
|
||||
|
||||
Попробовать SoloBot прямо сейчас в Telegram [**➡ Попробовать**](https://t.me/SoloNetVPN_bot).
|
||||
|
||||
#### Отзывы пользователей:
|
||||
SoloBot уже помог сотням пользователей в нашем сообществе:
|
||||

|
||||
|
||||
|
||||
**Читать** [**➡ Отзывы**](https://pocomacho.ru/solonetbot/reviews/)
|
||||
|
||||
Связаться с нами через [**➡ поддержку**](https://t.me/solonet_sup). Там вы сможете купить полную версию и получить логин
|
||||
и пароль от сайта, получить доступ в наш чат сообщества, а также задать необходимые вопросы!
|
||||
|
||||
## Права на использование
|
||||
|
||||
#### Перепродажа кода запрещена, ознакомьтесь с лицензией
|
||||
|
||||
[Этот проект использует лицензию CC BY-NC 4.0](LICENSE)
|
||||
|
||||
-32
@@ -1,32 +0,0 @@
|
||||
# Security Policy
|
||||
|
||||
## Поддерживаемые ветки
|
||||
- Рассматриваются только `main` (последняя стабильная) и `dev` (текущая разработка).
|
||||
- Другие ветки и теги не поддерживаются.
|
||||
|
||||
## Как сообщить об уязвимости
|
||||
- Не создавайте публичные Issues или Pull Requests.
|
||||
- Сообщите напрямую владельцу: [@solonet_sup](https://t.me/solonet_sup).
|
||||
- Также можно уведомить через сообщество в Telegram (доступно после вступления).
|
||||
- Укажите: ветку (`main`/`dev`), версию/коммит, шаги воспроизведения, ожидаемое и фактическое поведение, возможное влияние, логи или скриншоты.
|
||||
|
||||
## Процесс
|
||||
1. Подтверждаем получение отчёта.
|
||||
2. Анализируем и определяем критичность.
|
||||
3. Исправления выпускаются в `dev`.
|
||||
4. Если уязвимость критическая — фикс публикуется напрямую в `main`.
|
||||
5. После релиза уведомляем сообщество и при необходимости публикуем рекомендации по обновлению.
|
||||
|
||||
## Область действия
|
||||
- Бот (aiogram), веб-админка (flask), внутренние модули/плагины и API проекта.
|
||||
- Вне области: сторонние сервисы и библиотеки (платёжные провайдеры, Telegram, панели и т. п.). Для них обращайтесь к поставщику напрямую.
|
||||
|
||||
## Ответственное раскрытие
|
||||
- Просим не публиковать детали до выхода исправления.
|
||||
- Тесты проводите только на своих данных и аккаунтах.
|
||||
- Атаки на инфраструктуру или пользователей строго запрещены.
|
||||
|
||||
## Лучшие практики при отчёте
|
||||
- Минимальный PoC без риска для данных и доступности.
|
||||
- Чёткие и воспроизводимые шаги.
|
||||
- Возможные предложения по снижению риска до исправления.
|
||||
-259
@@ -1,259 +0,0 @@
|
||||
import hashlib
|
||||
|
||||
from collections.abc import AsyncGenerator
|
||||
from urllib.parse import urlparse
|
||||
|
||||
from fastapi import Depends, HTTPException, Header, Query, Request, Response
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from datetime import datetime
|
||||
|
||||
from audit import set_api_actor
|
||||
from database import (
|
||||
async_session_maker,
|
||||
identities as idb,
|
||||
identity_sessions as idsess,
|
||||
)
|
||||
from database.access.resolution import ResolvedActor, resolve_actor_from_identity
|
||||
from database.models import Admin, Identity
|
||||
|
||||
|
||||
async def get_session() -> AsyncGenerator[AsyncSession, None]:
|
||||
async with async_session_maker() as session:
|
||||
try:
|
||||
yield session
|
||||
await session.commit()
|
||||
except Exception:
|
||||
await session.rollback()
|
||||
raise
|
||||
|
||||
|
||||
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)
|
||||
result = await session.execute(select(Admin).where(Admin.tg_id == admin_id, Admin.token == hashed))
|
||||
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
|
||||
-219
@@ -1,219 +0,0 @@
|
||||
import asyncio
|
||||
import hashlib
|
||||
import os
|
||||
from time import perf_counter
|
||||
|
||||
from fastapi import Depends, FastAPI, Request
|
||||
from fastapi.responses import ORJSONResponse
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from starlette.middleware.cors import CORSMiddleware
|
||||
from starlette.middleware.gzip import GZipMiddleware
|
||||
from starlette.responses import Response as StarletteResponse
|
||||
from starlette.staticfiles import StaticFiles
|
||||
|
||||
from audit import ensure_api_context, log_api_access, record_api_access_event_background
|
||||
from config import API_LOGGING, API_VERSION, API_CORS_ORIGINS
|
||||
from database import async_session_maker
|
||||
from logger import logger
|
||||
|
||||
if API_VERSION == 1:
|
||||
from api.v1 import router as api_router, VERSION as API_DOC_VERSION
|
||||
else:
|
||||
from api.v2 import VERSION as API_DOC_VERSION
|
||||
from api.v2.router import router as api_router
|
||||
|
||||
app = FastAPI(
|
||||
title=f"SoloBot API (Alpha) — API v{API_DOC_VERSION}",
|
||||
version=API_DOC_VERSION,
|
||||
description=f"Версия API: **v{API_DOC_VERSION}**.",
|
||||
docs_url="/api/docs",
|
||||
redoc_url="/api/redoc",
|
||||
openapi_url="/api/openapi.json",
|
||||
default_response_class=ORJSONResponse,
|
||||
)
|
||||
|
||||
_cors_origins = API_CORS_ORIGINS if API_CORS_ORIGINS != ["*"] else API_CORS_ORIGINS
|
||||
_cors_credentials = API_CORS_ORIGINS != ["*"]
|
||||
|
||||
app.add_middleware(
|
||||
CORSMiddleware,
|
||||
allow_origins=_cors_origins,
|
||||
allow_credentials=_cors_credentials,
|
||||
allow_methods=["GET", "POST", "PUT", "DELETE", "PATCH", "OPTIONS"],
|
||||
allow_headers=["X-Identity-Id", "X-Token", "Content-Type", "Authorization"],
|
||||
)
|
||||
|
||||
app.add_middleware(GZipMiddleware, minimum_size=1024, compresslevel=6)
|
||||
|
||||
|
||||
@app.exception_handler(Exception)
|
||||
async def _generic_exception_handler(request: Request, exc: Exception):
|
||||
from audit import ensure_api_context
|
||||
|
||||
context = ensure_api_context(request)
|
||||
logger.exception("[API] Unhandled exception at {} {}: {}", request.method, request.url.path, exc)
|
||||
return ORJSONResponse(
|
||||
status_code=500,
|
||||
content={"detail": "Внутренняя ошибка сервера", "request_id": context.request_id},
|
||||
)
|
||||
|
||||
|
||||
_ETAG_MAX_BODY_BYTES = 256 * 1024
|
||||
|
||||
|
||||
@app.middleware("http")
|
||||
async def security_and_cache_middleware(request: Request, call_next):
|
||||
response = await call_next(request)
|
||||
response.headers.setdefault("X-Content-Type-Options", "nosniff")
|
||||
response.headers.setdefault("X-Frame-Options", "DENY")
|
||||
response.headers.setdefault("Referrer-Policy", "strict-origin-when-cross-origin")
|
||||
response.headers.setdefault("X-XSS-Protection", "1; mode=block")
|
||||
|
||||
content_type = response.headers.get("content-type", "")
|
||||
path = request.url.path
|
||||
|
||||
if path.startswith("/api/web/uploads/") and request.method == "GET" and response.status_code == 200:
|
||||
response.headers.setdefault("Cache-Control", "public, max-age=31536000, immutable")
|
||||
return response
|
||||
|
||||
if request.method == "GET" and response.status_code == 200 and "application/json" in content_type:
|
||||
content_length_header = response.headers.get("content-length")
|
||||
try:
|
||||
cl = int(content_length_header) if content_length_header is not None else None
|
||||
except (TypeError, ValueError):
|
||||
cl = None
|
||||
if cl is not None and cl > _ETAG_MAX_BODY_BYTES:
|
||||
response.headers.setdefault("Cache-Control", "no-cache")
|
||||
return response
|
||||
chunks: list[bytes] = []
|
||||
total = 0
|
||||
too_big = False
|
||||
async for chunk in response.body_iterator:
|
||||
total += len(chunk)
|
||||
if total > _ETAG_MAX_BODY_BYTES:
|
||||
chunks.append(chunk)
|
||||
too_big = True
|
||||
async for remaining in response.body_iterator:
|
||||
chunks.append(remaining)
|
||||
break
|
||||
chunks.append(chunk)
|
||||
body = b"".join(chunks)
|
||||
if too_big:
|
||||
headers = dict(response.headers)
|
||||
headers["Cache-Control"] = "no-cache"
|
||||
headers.pop("content-length", None)
|
||||
return StarletteResponse(content=body, status_code=200, headers=headers, media_type=response.media_type)
|
||||
etag = '"' + hashlib.md5(body).hexdigest() + '"'
|
||||
if_none_match = request.headers.get("if-none-match", "")
|
||||
client_etags = [t.strip() for t in if_none_match.split(",") if t.strip()]
|
||||
if etag in client_etags or if_none_match.strip() == "*":
|
||||
return StarletteResponse(status_code=304, headers={"ETag": etag, "Cache-Control": "no-cache"})
|
||||
headers = dict(response.headers)
|
||||
headers["ETag"] = etag
|
||||
headers["Cache-Control"] = "no-cache"
|
||||
return StarletteResponse(content=body, status_code=200, headers=headers, media_type=response.media_type)
|
||||
|
||||
response.headers.setdefault("Cache-Control", "no-store")
|
||||
return response
|
||||
|
||||
|
||||
@app.middleware("http")
|
||||
async def api_access_log_middleware(request: Request, call_next):
|
||||
context = ensure_api_context(request)
|
||||
started = perf_counter()
|
||||
if not API_LOGGING:
|
||||
response = await call_next(request)
|
||||
response.headers["X-Request-Id"] = context.request_id
|
||||
response.headers["X-Response-Time"] = f"{int((perf_counter() - started) * 1000)}ms"
|
||||
return response
|
||||
|
||||
try:
|
||||
response = await call_next(request)
|
||||
except Exception as exc:
|
||||
duration_ms = int((perf_counter() - started) * 1000)
|
||||
log_api_access(
|
||||
request,
|
||||
status_code=500,
|
||||
duration_ms=duration_ms,
|
||||
result="fail",
|
||||
reason=type(exc).__name__,
|
||||
)
|
||||
asyncio.create_task(
|
||||
record_api_access_event_background(
|
||||
async_session_maker,
|
||||
request,
|
||||
result="fail",
|
||||
reason=type(exc).__name__,
|
||||
status_code=500,
|
||||
)
|
||||
)
|
||||
raise
|
||||
|
||||
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.get("/api/health", include_in_schema=False)
|
||||
async def health():
|
||||
return {"status": "ok"}
|
||||
|
||||
|
||||
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")
|
||||
@@ -1,62 +0,0 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from fastapi import HTTPException, Request
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from api.depends import _identity_from_cookie
|
||||
|
||||
|
||||
async def enforce_rate_limit(
|
||||
request: Request,
|
||||
session: AsyncSession,
|
||||
*,
|
||||
bucket: str,
|
||||
max_per_window: int,
|
||||
window_sec: int,
|
||||
identity_aware: bool = True,
|
||||
) -> None:
|
||||
try:
|
||||
from api.v2.routes.auth._fallback_limiter import check_and_increment
|
||||
from core.redis_cache import cache_incr_checked
|
||||
except Exception:
|
||||
return
|
||||
|
||||
owner = "anon"
|
||||
if identity_aware:
|
||||
try:
|
||||
identity = await _identity_from_cookie(session, request)
|
||||
if identity is not None and getattr(identity, "id", None):
|
||||
owner = f"id:{identity.id}"
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
if owner == "anon":
|
||||
ip = (request.client.host if request.client else "") or "unknown"
|
||||
owner = f"ip:{ip}"
|
||||
|
||||
key = f"rl:{bucket}:{owner}"
|
||||
try:
|
||||
count, redis_ok = await cache_incr_checked(key, window_sec)
|
||||
if not redis_ok:
|
||||
count = check_and_increment(key, max_per_window, window_sec)
|
||||
except Exception:
|
||||
return
|
||||
|
||||
if count > max_per_window:
|
||||
raise HTTPException(status_code=429, detail="Слишком много запросов, подождите и попробуйте снова")
|
||||
|
||||
|
||||
def rate_limit_dependency(*, bucket: str, max_per_window: int, window_sec: int):
|
||||
from api.depends import get_session
|
||||
from fastapi import Depends
|
||||
|
||||
async def _dep(request: Request, session: AsyncSession = Depends(get_session)) -> None:
|
||||
await enforce_rate_limit(
|
||||
request,
|
||||
session,
|
||||
bucket=bucket,
|
||||
max_per_window=max_per_window,
|
||||
window_sec=window_sec,
|
||||
)
|
||||
|
||||
return _dep
|
||||
@@ -1,6 +0,0 @@
|
||||
from api.v1.router import router
|
||||
|
||||
|
||||
VERSION = "1.0.0"
|
||||
|
||||
__all__ = ("router", "VERSION")
|
||||
@@ -1,38 +0,0 @@
|
||||
from fastapi import APIRouter
|
||||
|
||||
from api.v1.routes import (
|
||||
coupons,
|
||||
gifts,
|
||||
keys,
|
||||
management,
|
||||
misc,
|
||||
modules,
|
||||
partners,
|
||||
referrals,
|
||||
servers,
|
||||
settings,
|
||||
tariffs,
|
||||
users,
|
||||
)
|
||||
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.get("/api", include_in_schema=False)
|
||||
async def root():
|
||||
return {"message": "Welcome to SoloBot API", "docs": "/api/docs"}
|
||||
|
||||
|
||||
router.include_router(users.router, prefix="/api/users", tags=["Users"])
|
||||
router.include_router(keys.router, prefix="/api/keys", tags=["Keys"])
|
||||
router.include_router(coupons.router, prefix="/api/coupons", tags=["Coupons"])
|
||||
router.include_router(servers.router, prefix="/api/servers", tags=["Servers"])
|
||||
router.include_router(tariffs.router, prefix="/api/tariffs", tags=["Tariffs"])
|
||||
router.include_router(gifts.router, prefix="/api/gifts", tags=["Gifts"])
|
||||
router.include_router(referrals.router, prefix="/api/referrals", tags=["Referrals"])
|
||||
router.include_router(partners.router, prefix="/api/partners", tags=["Partners"])
|
||||
router.include_router(misc.router, prefix="/api")
|
||||
router.include_router(modules.router, prefix="/api")
|
||||
router.include_router(management.router, prefix="/api/management", tags=["Management"])
|
||||
router.include_router(settings.router, prefix="/api/settings", tags=["Settings"])
|
||||
@@ -1,206 +0,0 @@
|
||||
from typing import Any
|
||||
|
||||
from fastapi import APIRouter, Body, Depends, HTTPException, Path, Query
|
||||
from sqlalchemy import (
|
||||
inspect as sa_inspect,
|
||||
select,
|
||||
)
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy.orm import selectinload
|
||||
from sqlalchemy.orm.attributes import InstrumentedAttribute
|
||||
|
||||
from api.depends import get_session, verify_admin_token
|
||||
from database.access.resolution import resolve_user_optional
|
||||
from database.models import Admin
|
||||
from handlers.texts import get_site_gift_link, get_telegram_gift_link
|
||||
|
||||
|
||||
def _apply_user_relationship_loader(model: type, stmt):
|
||||
if model.__name__ in ("ManualBan", "BlockedUser", "TemporaryData"):
|
||||
return stmt.options(selectinload(model.user))
|
||||
return stmt
|
||||
|
||||
|
||||
def cast_identifier_type(field: InstrumentedAttribute, value: int | str):
|
||||
column_type = type(field.property.columns[0].type).__name__
|
||||
if column_type in ("Integer", "BigInteger"):
|
||||
return int(value)
|
||||
return value
|
||||
|
||||
|
||||
def normalize_outgoing_object(obj: object) -> None:
|
||||
if hasattr(obj, "vless") and obj.vless is None:
|
||||
obj.vless = False
|
||||
cls_name = type(obj).__name__
|
||||
if cls_name == "Gift":
|
||||
gift_id = getattr(obj, "gift_id", None)
|
||||
if gift_id:
|
||||
obj.telegram_gift_link = get_telegram_gift_link(gift_id)
|
||||
obj.site_gift_link = get_site_gift_link(gift_id)
|
||||
if cls_name in ("ManualBan", "BlockedUser", "TemporaryData"):
|
||||
insp = sa_inspect(obj)
|
||||
stored = getattr(obj, "tg_id", None)
|
||||
if "user" in insp.unloaded:
|
||||
obj.tg_id = stored
|
||||
return
|
||||
rel = getattr(obj, "user", None)
|
||||
rel_tg = getattr(rel, "tg_id", None) if rel is not None else None
|
||||
obj.tg_id = stored if stored is not None else rel_tg
|
||||
|
||||
|
||||
def to_schema(schema_response: type, obj: object):
|
||||
normalize_outgoing_object(obj)
|
||||
return schema_response.model_validate(obj, from_attributes=True)
|
||||
|
||||
|
||||
def generate_crud_router(
|
||||
*,
|
||||
model: type,
|
||||
schema_response: type,
|
||||
schema_create: type,
|
||||
schema_update: type,
|
||||
identifier_field: str = "tg_id",
|
||||
parameter_name: str = "tg_id",
|
||||
extra_get_by_email: bool = False,
|
||||
telegram_path_to_user_id: bool = False,
|
||||
enabled_methods: list[str] = ("get_all", "get_one", "get_by_email", "create", "update", "delete"),
|
||||
) -> APIRouter:
|
||||
router = APIRouter()
|
||||
|
||||
async def _path_filter(session: AsyncSession, value: int | str):
|
||||
if telegram_path_to_user_id:
|
||||
u = await resolve_user_optional(session, int(value))
|
||||
if u is None:
|
||||
return None
|
||||
return model.user_id, u.id
|
||||
field = getattr(model, identifier_field)
|
||||
return field, cast_identifier_type(field, value)
|
||||
|
||||
if "get_all" in enabled_methods:
|
||||
|
||||
@router.get("/", response_model=list[schema_response])
|
||||
async def get_all(
|
||||
admin: Admin = Depends(verify_admin_token),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
):
|
||||
result = await session.execute(_apply_user_relationship_loader(model, select(model)))
|
||||
items = result.scalars().all()
|
||||
for item in items:
|
||||
normalize_outgoing_object(item)
|
||||
return [schema_response.model_validate(item, from_attributes=True) for item in items]
|
||||
|
||||
if "get_by_email" in enabled_methods and extra_get_by_email:
|
||||
|
||||
@router.get("/by_email", response_model=schema_response)
|
||||
async def get_by_email(
|
||||
email: str = Query(...),
|
||||
admin: Admin = Depends(verify_admin_token),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
):
|
||||
result = await session.execute(select(model).where(model.email == email))
|
||||
obj = result.scalar_one_or_none()
|
||||
if not obj:
|
||||
raise HTTPException(status_code=404, detail="Not found by email")
|
||||
return to_schema(schema_response, obj)
|
||||
|
||||
if "get_one" in enabled_methods:
|
||||
|
||||
@router.get(f"/{{{parameter_name}}}", response_model=schema_response)
|
||||
async def get_one(
|
||||
value: int | str = Path(..., alias=parameter_name),
|
||||
admin: Admin = Depends(verify_admin_token),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
):
|
||||
resolved = await _path_filter(session, value)
|
||||
if resolved is None:
|
||||
raise HTTPException(status_code=404, detail=f"{model.__name__} not found")
|
||||
field, casted = resolved
|
||||
result = await session.execute(_apply_user_relationship_loader(model, select(model).where(field == casted)))
|
||||
obj = result.scalar_one_or_none()
|
||||
if not obj:
|
||||
raise HTTPException(status_code=404, detail=f"{model.__name__} not found")
|
||||
return to_schema(schema_response, obj)
|
||||
|
||||
if "get_all_by_field" in enabled_methods:
|
||||
|
||||
@router.get(f"/all/{{{parameter_name}}}", response_model=list[schema_response])
|
||||
async def get_all_by_field(
|
||||
value: int | str = Path(..., alias=parameter_name),
|
||||
admin: Admin = Depends(verify_admin_token),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
):
|
||||
resolved = await _path_filter(session, value)
|
||||
if resolved is None:
|
||||
raise HTTPException(status_code=404, detail=f"{model.__name__} not found")
|
||||
field, casted = resolved
|
||||
result = await session.execute(_apply_user_relationship_loader(model, select(model).where(field == casted)))
|
||||
objs = result.scalars().all()
|
||||
if not objs:
|
||||
raise HTTPException(status_code=404, detail=f"{model.__name__} not found")
|
||||
for obj in objs:
|
||||
normalize_outgoing_object(obj)
|
||||
return [schema_response.model_validate(obj, from_attributes=True) for obj in objs]
|
||||
|
||||
if "create" in enabled_methods:
|
||||
|
||||
@router.post("/", response_model=schema_response)
|
||||
async def create(
|
||||
payload: Any = Body(...),
|
||||
admin: Admin = Depends(verify_admin_token),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
):
|
||||
validated = schema_create.model_validate(payload)
|
||||
data = validated.model_dump(exclude_unset=True)
|
||||
if "days" in data and data["days"] == 0:
|
||||
data["days"] = None
|
||||
obj = model(**data)
|
||||
session.add(obj)
|
||||
await session.refresh(obj)
|
||||
return to_schema(schema_response, obj)
|
||||
|
||||
if "update" in enabled_methods:
|
||||
|
||||
@router.patch(f"/{{{parameter_name}}}", response_model=schema_response)
|
||||
async def update(
|
||||
payload: Any = Body(...),
|
||||
value: int | str = Path(..., alias=parameter_name),
|
||||
admin: Admin = Depends(verify_admin_token),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
):
|
||||
resolved = await _path_filter(session, value)
|
||||
if resolved is None:
|
||||
raise HTTPException(status_code=404, detail=f"{model.__name__} not found")
|
||||
field, casted = resolved
|
||||
result = await session.execute(select(model).where(field == casted))
|
||||
obj = result.scalar_one_or_none()
|
||||
if not obj:
|
||||
raise HTTPException(status_code=404, detail=f"{model.__name__} not found")
|
||||
|
||||
validated = schema_update.model_validate(payload)
|
||||
for k, v in validated.model_dump(exclude_unset=True).items():
|
||||
setattr(obj, k, v)
|
||||
|
||||
await session.refresh(obj)
|
||||
return to_schema(schema_response, obj)
|
||||
|
||||
if "delete" in enabled_methods:
|
||||
|
||||
@router.delete(f"/{{{parameter_name}}}", response_model=dict)
|
||||
async def delete(
|
||||
value: int | str = Path(..., alias=parameter_name),
|
||||
admin: Admin = Depends(verify_admin_token),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
):
|
||||
resolved = await _path_filter(session, value)
|
||||
if resolved is None:
|
||||
raise HTTPException(status_code=404, detail=f"{model.__name__} not found")
|
||||
field, casted = resolved
|
||||
result = await session.execute(select(model).where(field == casted))
|
||||
obj = result.scalar_one_or_none()
|
||||
if not obj:
|
||||
raise HTTPException(status_code=404, detail=f"{model.__name__} not found")
|
||||
|
||||
await session.delete(obj)
|
||||
return {"detail": f"{model.__name__} deleted"}
|
||||
|
||||
return router
|
||||
@@ -1,16 +0,0 @@
|
||||
from fastapi import APIRouter
|
||||
|
||||
from api.v1.routes.base_crud import generate_crud_router
|
||||
from api.v1.schemas import CouponBase, CouponResponse, CouponUpdate
|
||||
from database.models import Coupon
|
||||
|
||||
|
||||
router: APIRouter = 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"],
|
||||
)
|
||||
@@ -1,68 +0,0 @@
|
||||
from fastapi import APIRouter, Depends, HTTPException, Path
|
||||
from sqlalchemy import delete, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from api.depends import get_session, verify_admin_token
|
||||
from api.v1.routes.base_crud import generate_crud_router
|
||||
from api.v1.schemas import GiftBase, GiftResponse, GiftUpdate, GiftUsageResponse
|
||||
from database.access.resolution import resolve_user_optional
|
||||
from database.models import Admin, Gift, GiftUsage
|
||||
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
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.get("/by_tg_id/{tg_id}", response_model=list[GiftResponse], tags=["Gifts"])
|
||||
async def get_gifts_by_tg_id(
|
||||
tg_id: int = Path(...),
|
||||
admin: Admin = Depends(verify_admin_token),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
):
|
||||
u = await resolve_user_optional(session, tg_id)
|
||||
if u is None:
|
||||
raise HTTPException(status_code=404, detail="Gifts not found")
|
||||
result = await session.execute(select(Gift).where(Gift.sender_user_id == u.id))
|
||||
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=["GiftUsages"])
|
||||
router.include_router(gift_usage_router, prefix="/usages", tags=["Gifts"])
|
||||
|
||||
|
||||
@router.delete("/{gift_id}", response_model=dict, tags=["Gifts"])
|
||||
async def delete_gift_with_usages(
|
||||
gift_id: str = Path(..., description="ID подарка"),
|
||||
admin: Admin = Depends(verify_admin_token),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
):
|
||||
result = await session.execute(select(Gift).where(Gift.gift_id == gift_id))
|
||||
gift = result.scalar_one_or_none()
|
||||
if not gift:
|
||||
raise HTTPException(status_code=404, detail="Gift not found")
|
||||
|
||||
await session.execute(delete(GiftUsage).where(GiftUsage.gift_id == gift_id))
|
||||
await session.delete(gift)
|
||||
return {"message": "Подарок и связанные использования удалены"}
|
||||
@@ -1,143 +0,0 @@
|
||||
from datetime import datetime
|
||||
|
||||
from fastapi import Body, Depends, HTTPException, Path, status
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from api.depends import get_session, verify_admin_token
|
||||
from api.v1.routes.base_crud import generate_crud_router
|
||||
from api.v1.schemas.keys import KeyBase, KeyCreateRequest, KeyResponse, KeyUpdate
|
||||
from database.access.resolution import resolve_user_optional
|
||||
from database.models import Admin, Key, Tariff
|
||||
from logger import logger
|
||||
from services.operations import create_key_on_cluster, delete_key_from_cluster, renew_key_in_cluster
|
||||
|
||||
|
||||
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"],
|
||||
)
|
||||
|
||||
|
||||
@router.delete("/by_email/{email}", response_model=dict)
|
||||
async def delete_key_by_email(
|
||||
email: str = Path(..., description="Email клиента"),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
admin: Admin = Depends(verify_admin_token),
|
||||
):
|
||||
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),
|
||||
admin: Admin = Depends(verify_admin_token),
|
||||
):
|
||||
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)))
|
||||
keys = keys_result.scalars().all()
|
||||
return keys
|
||||
|
||||
|
||||
@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),
|
||||
admin: Admin = Depends(verify_admin_token),
|
||||
):
|
||||
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),
|
||||
admin: Admin = Depends(verify_admin_token),
|
||||
):
|
||||
try:
|
||||
await create_key_on_cluster(
|
||||
cluster_id=payload.cluster_id,
|
||||
tg_id=payload.tg_id,
|
||||
client_id=payload.client_id,
|
||||
email=payload.email or f"{payload.tg_id}_key",
|
||||
expiry_timestamp=payload.expiry_timestamp,
|
||||
plan=payload.tariff_id,
|
||||
session=session,
|
||||
remnawave_link=payload.remnawave_link,
|
||||
hwid_limit=payload.hwid_limit,
|
||||
traffic_limit_bytes=payload.traffic_limit_bytes,
|
||||
is_trial=payload.is_trial or False,
|
||||
)
|
||||
return {"message": "Ключ успешно создан"}
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"[API] Ошибка при создании ключа: {e}")
|
||||
raise HTTPException(status_code=500, detail="Ошибка при создании ключа")
|
||||
@@ -1,375 +0,0 @@
|
||||
import asyncio
|
||||
import os
|
||||
import re
|
||||
import subprocess
|
||||
import sys
|
||||
|
||||
from datetime import datetime, timezone
|
||||
from typing import Literal
|
||||
|
||||
import psutil
|
||||
|
||||
from aiogram import Bot
|
||||
from aiogram.client.default import DefaultBotProperties
|
||||
from aiogram.enums import ParseMode
|
||||
from fastapi import APIRouter, BackgroundTasks, Depends, HTTPException, Query
|
||||
from pydantic import BaseModel
|
||||
from sqlalchemy import distinct, exists, func, select, update
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from api.depends import get_session, verify_admin_token, verify_admin_token_short
|
||||
from config import API_TOKEN, BOT_SERVICE
|
||||
from core.bootstrap import MANAGEMENT_CONFIG
|
||||
from core.executor import run_io
|
||||
from core.settings.management_config import update_management_config
|
||||
from database import async_session_maker
|
||||
from database.models import Key, ScheduledBroadcast, Server, User
|
||||
from database.scheduled_broadcasts import (
|
||||
cancel_scheduled_broadcast,
|
||||
create_scheduled_broadcast,
|
||||
get_scheduled_broadcast,
|
||||
list_scheduled_broadcasts,
|
||||
mark_scheduled_broadcast_failed,
|
||||
mark_scheduled_broadcast_sent,
|
||||
start_scheduled_broadcast,
|
||||
update_scheduled_broadcast,
|
||||
)
|
||||
from handlers.admin.sender.scheduled_service import (
|
||||
ensure_utc_datetime,
|
||||
execute_broadcast_payload,
|
||||
execute_scheduled_broadcast,
|
||||
prepare_broadcast_payload,
|
||||
scheduled_broadcast_to_dict,
|
||||
)
|
||||
from logger import logger
|
||||
from utils.backup import backup_database
|
||||
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
class MaintenanceUpdate(BaseModel):
|
||||
enabled: bool
|
||||
|
||||
|
||||
class DomainChange(BaseModel):
|
||||
domain: str
|
||||
|
||||
|
||||
class BroadcastLaunchPayload(BaseModel):
|
||||
send_to: Literal["all", "subscribed", "unsubscribed", "untrial", "trial", "hotleads", "cluster"] = "all"
|
||||
channel: Literal["bot", "site", "both"] = "both"
|
||||
text: str
|
||||
photo: str | None = None
|
||||
cluster_name: str | None = None
|
||||
workers: int = 5
|
||||
messages_per_second: int = 35
|
||||
|
||||
|
||||
class ScheduledBroadcastCreatePayload(BroadcastLaunchPayload):
|
||||
scheduled_for: datetime
|
||||
|
||||
|
||||
class ScheduledBroadcastUpdatePayload(BaseModel):
|
||||
send_to: Literal["all", "subscribed", "unsubscribed", "untrial", "trial", "hotleads", "cluster"] | None = None
|
||||
channel: Literal["bot", "site", "both"] | None = None
|
||||
text: str | None = None
|
||||
photo: str | None = None
|
||||
cluster_name: str | None = None
|
||||
workers: int | None = None
|
||||
messages_per_second: int | None = None
|
||||
scheduled_for: datetime | None = None
|
||||
|
||||
|
||||
_broadcast_bot: Bot | None = None
|
||||
|
||||
|
||||
def _get_broadcast_bot() -> Bot:
|
||||
global _broadcast_bot
|
||||
if _broadcast_bot is None:
|
||||
_broadcast_bot = Bot(token=API_TOKEN, default=DefaultBotProperties(parse_mode=ParseMode.HTML))
|
||||
return _broadcast_bot
|
||||
|
||||
|
||||
def _require_future_schedule(value: datetime) -> datetime:
|
||||
scheduled_for = ensure_utc_datetime(value)
|
||||
if scheduled_for <= datetime.now(timezone.utc):
|
||||
raise HTTPException(status_code=400, detail="scheduled_for must be in the future")
|
||||
return scheduled_for
|
||||
|
||||
|
||||
def _resolve_update_payload(
|
||||
payload: ScheduledBroadcastUpdatePayload,
|
||||
current: ScheduledBroadcast,
|
||||
) -> dict:
|
||||
fields = payload.model_fields_set
|
||||
text_changed = "text" in fields
|
||||
send_to = payload.send_to if "send_to" in fields else current.send_to
|
||||
channel = payload.channel if "channel" in fields else current.channel
|
||||
text = payload.text if "text" in fields else current.text
|
||||
photo = payload.photo if "photo" in fields else current.photo
|
||||
cluster_name = payload.cluster_name if "cluster_name" in fields else current.cluster_name
|
||||
workers = payload.workers if "workers" in fields else current.workers
|
||||
messages_per_second = (
|
||||
payload.messages_per_second if "messages_per_second" in fields else current.messages_per_second
|
||||
)
|
||||
prepared = prepare_broadcast_payload(
|
||||
send_to=send_to,
|
||||
text=text,
|
||||
photo=photo,
|
||||
cluster_name=cluster_name,
|
||||
workers=workers,
|
||||
messages_per_second=messages_per_second,
|
||||
channel=channel,
|
||||
)
|
||||
if not text_changed:
|
||||
prepared["text"] = current.text
|
||||
prepared["keyboard_json"] = current.keyboard_json
|
||||
if "scheduled_for" in fields:
|
||||
prepared["scheduled_for"] = _require_future_schedule(payload.scheduled_for)
|
||||
return prepared
|
||||
|
||||
|
||||
async def _restart_bot() -> None:
|
||||
await asyncio.sleep(1)
|
||||
try:
|
||||
parent = psutil.Process(os.getpid()).parent()
|
||||
is_systemd = parent and "systemd" in parent.name().lower()
|
||||
|
||||
if is_systemd:
|
||||
await run_io(lambda: subprocess.run(["sudo", "systemctl", "restart", BOT_SERVICE], check=True))
|
||||
else:
|
||||
python_exe = sys.executable
|
||||
script_path = os.path.abspath(sys.argv[0])
|
||||
os.execv(python_exe, [python_exe, script_path] + sys.argv[1:])
|
||||
except Exception:
|
||||
os._exit(1)
|
||||
|
||||
|
||||
@router.get("/status")
|
||||
async def get_status(admin=Depends(verify_admin_token)):
|
||||
return {
|
||||
"maintenance_enabled": bool(MANAGEMENT_CONFIG.get("MAINTENANCE_ENABLED", False)),
|
||||
"management": dict(MANAGEMENT_CONFIG or {}),
|
||||
}
|
||||
|
||||
|
||||
@router.post("/maintenance")
|
||||
async def set_maintenance(
|
||||
payload: MaintenanceUpdate,
|
||||
admin=Depends(verify_admin_token),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
):
|
||||
current_config = dict(MANAGEMENT_CONFIG or {})
|
||||
current_config["MAINTENANCE_ENABLED"] = bool(payload.enabled)
|
||||
await update_management_config(session, current_config)
|
||||
return {"maintenance_enabled": bool(MANAGEMENT_CONFIG.get("MAINTENANCE_ENABLED", False))}
|
||||
|
||||
|
||||
@router.post("/restart")
|
||||
async def restart_bot(
|
||||
background: BackgroundTasks,
|
||||
admin=Depends(verify_admin_token),
|
||||
):
|
||||
background.add_task(_restart_bot)
|
||||
return {"status": "restarting"}
|
||||
|
||||
|
||||
@router.post("/change-domain")
|
||||
async def change_domain(
|
||||
payload: DomainChange,
|
||||
admin=Depends(verify_admin_token),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
):
|
||||
domain = payload.domain.strip()
|
||||
if not domain or " " in domain or not re.fullmatch(r"[a-zA-Z0-9.-]+", domain):
|
||||
raise HTTPException(status_code=400, detail="Invalid domain")
|
||||
|
||||
new_domain_url = f"https://{domain}"
|
||||
|
||||
stmt = (
|
||||
update(Key)
|
||||
.values(
|
||||
key=func.regexp_replace(Key.key, r"^https://[^/]+", new_domain_url),
|
||||
remnawave_link=func.regexp_replace(Key.remnawave_link, r"^https://[^/]+", new_domain_url),
|
||||
)
|
||||
.where(
|
||||
(Key.key.startswith("https://") & ~Key.key.startswith(new_domain_url))
|
||||
| (Key.remnawave_link.startswith("https://") & ~Key.remnawave_link.startswith(new_domain_url))
|
||||
)
|
||||
)
|
||||
result = await session.execute(stmt)
|
||||
|
||||
return {"updated": result.rowcount or 0}
|
||||
|
||||
|
||||
@router.post("/restore-trials")
|
||||
async def restore_trials(
|
||||
admin=Depends(verify_admin_token),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
):
|
||||
stmt = (
|
||||
update(User)
|
||||
.where(
|
||||
User.trial == 1,
|
||||
~exists(select(Key.user_id).where(Key.user_id == User.id)),
|
||||
)
|
||||
.values(trial=0)
|
||||
)
|
||||
result = await session.execute(stmt)
|
||||
|
||||
return {"restored": result.rowcount or 0}
|
||||
|
||||
|
||||
@router.post("/backup")
|
||||
async def trigger_backup(admin=Depends(verify_admin_token)):
|
||||
async def _run_backup() -> None:
|
||||
exception = await backup_database()
|
||||
if exception:
|
||||
logger.error(f"[Management] Backup finished with error: {exception}")
|
||||
|
||||
asyncio.create_task(_run_backup())
|
||||
return {"status": "backup_started"}
|
||||
|
||||
|
||||
@router.get("/broadcast/clusters")
|
||||
async def get_broadcast_clusters(
|
||||
admin=Depends(verify_admin_token),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
):
|
||||
result = await session.execute(select(distinct(Server.cluster_name)).where(Server.cluster_name.is_not(None)))
|
||||
clusters = sorted([row[0] for row in result.all() if row and row[0]])
|
||||
return {"clusters": clusters}
|
||||
|
||||
|
||||
@router.post("/broadcast")
|
||||
async def launch_broadcast(
|
||||
payload: BroadcastLaunchPayload,
|
||||
admin=Depends(verify_admin_token_short),
|
||||
):
|
||||
"""Запуск рассылки. Сессия БД не держится на время рассылки."""
|
||||
try:
|
||||
prepared = prepare_broadcast_payload(
|
||||
send_to=payload.send_to,
|
||||
text=payload.text,
|
||||
photo=payload.photo,
|
||||
cluster_name=payload.cluster_name,
|
||||
workers=payload.workers,
|
||||
messages_per_second=payload.messages_per_second,
|
||||
channel=payload.channel,
|
||||
)
|
||||
except ValueError as exc:
|
||||
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
||||
return await execute_broadcast_payload(prepared, bot=_get_broadcast_bot())
|
||||
|
||||
|
||||
@router.post("/broadcast/scheduled")
|
||||
async def create_broadcast_schedule(
|
||||
payload: ScheduledBroadcastCreatePayload,
|
||||
admin=Depends(verify_admin_token_short),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
):
|
||||
try:
|
||||
prepared = prepare_broadcast_payload(
|
||||
send_to=payload.send_to,
|
||||
text=payload.text,
|
||||
photo=payload.photo,
|
||||
cluster_name=payload.cluster_name,
|
||||
workers=payload.workers,
|
||||
messages_per_second=payload.messages_per_second,
|
||||
channel=payload.channel,
|
||||
)
|
||||
except ValueError as exc:
|
||||
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
||||
broadcast = await create_scheduled_broadcast(
|
||||
session,
|
||||
created_by_tg_id=getattr(admin, "tg_id", None),
|
||||
send_to=prepared["send_to"],
|
||||
channel=prepared["channel"],
|
||||
cluster_name=prepared["cluster_name"],
|
||||
text=prepared["text"],
|
||||
photo=prepared["photo"],
|
||||
keyboard_json=prepared["keyboard_json"],
|
||||
scheduled_for=_require_future_schedule(payload.scheduled_for),
|
||||
workers=prepared["workers"],
|
||||
messages_per_second=prepared["messages_per_second"],
|
||||
)
|
||||
return {"success": True, "item": scheduled_broadcast_to_dict(broadcast)}
|
||||
|
||||
|
||||
@router.get("/broadcast/scheduled")
|
||||
async def list_broadcast_schedules(
|
||||
admin=Depends(verify_admin_token),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
status: str | None = Query(None),
|
||||
limit: int = Query(20, ge=1, le=100),
|
||||
offset: int = Query(0, ge=0),
|
||||
):
|
||||
statuses = [item.strip() for item in (status or "").split(",") if item.strip()] or None
|
||||
items = await list_scheduled_broadcasts(session, statuses=statuses, limit=limit, offset=offset)
|
||||
return {"items": [scheduled_broadcast_to_dict(item) for item in items], "limit": limit, "offset": offset}
|
||||
|
||||
|
||||
@router.get("/broadcast/scheduled/{broadcast_id}")
|
||||
async def get_broadcast_schedule(
|
||||
broadcast_id: str,
|
||||
admin=Depends(verify_admin_token),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
):
|
||||
item = await get_scheduled_broadcast(session, broadcast_id)
|
||||
if item is None:
|
||||
raise HTTPException(status_code=404, detail="Scheduled broadcast not found")
|
||||
return {"item": scheduled_broadcast_to_dict(item)}
|
||||
|
||||
|
||||
@router.patch("/broadcast/scheduled/{broadcast_id}")
|
||||
async def update_broadcast_schedule(
|
||||
broadcast_id: str,
|
||||
payload: ScheduledBroadcastUpdatePayload,
|
||||
admin=Depends(verify_admin_token_short),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
):
|
||||
current = await get_scheduled_broadcast(session, broadcast_id)
|
||||
if current is None:
|
||||
raise HTTPException(status_code=404, detail="Scheduled broadcast not found")
|
||||
try:
|
||||
values = _resolve_update_payload(payload, current)
|
||||
except ValueError as exc:
|
||||
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
||||
updated = await update_scheduled_broadcast(session, broadcast_id, **values)
|
||||
if updated is None:
|
||||
raise HTTPException(status_code=409, detail="Scheduled broadcast can no longer be edited")
|
||||
return {"success": True, "item": scheduled_broadcast_to_dict(updated)}
|
||||
|
||||
|
||||
@router.post("/broadcast/scheduled/{broadcast_id}/cancel")
|
||||
async def cancel_broadcast_schedule(
|
||||
broadcast_id: str,
|
||||
admin=Depends(verify_admin_token_short),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
):
|
||||
item = await cancel_scheduled_broadcast(session, broadcast_id)
|
||||
if item is None:
|
||||
raise HTTPException(status_code=409, detail="Scheduled broadcast can no longer be cancelled")
|
||||
return {"success": True, "item": scheduled_broadcast_to_dict(item)}
|
||||
|
||||
|
||||
@router.post("/broadcast/scheduled/{broadcast_id}/send-now")
|
||||
async def send_broadcast_schedule_now(
|
||||
broadcast_id: str,
|
||||
admin=Depends(verify_admin_token_short),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
):
|
||||
item = await start_scheduled_broadcast(session, broadcast_id)
|
||||
if item is None:
|
||||
raise HTTPException(status_code=409, detail="Scheduled broadcast can no longer be sent now")
|
||||
try:
|
||||
result = await execute_scheduled_broadcast(item, bot=_get_broadcast_bot())
|
||||
except Exception as exc:
|
||||
await mark_scheduled_broadcast_failed(session, broadcast_id, str(exc))
|
||||
raise HTTPException(status_code=500, detail=str(exc)) from exc
|
||||
if result.get("success"):
|
||||
item = await mark_scheduled_broadcast_sent(session, broadcast_id, result)
|
||||
else:
|
||||
item = await mark_scheduled_broadcast_failed(session, broadcast_id, result.get("message", "Broadcast failed"))
|
||||
return {"success": bool(result.get("success")), "item": scheduled_broadcast_to_dict(item), "result": result}
|
||||
@@ -1,166 +0,0 @@
|
||||
from fastapi import APIRouter, Depends, HTTPException, Path
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from api.depends import get_session, verify_admin_token
|
||||
from api.v1.routes.base_crud import generate_crud_router
|
||||
from api.v1.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 (
|
||||
Admin,
|
||||
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_admin_token)],
|
||||
)
|
||||
|
||||
|
||||
@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(...),
|
||||
admin: Admin = Depends(verify_admin_token),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
):
|
||||
u = await resolve_user_optional(session, tg_id)
|
||||
if u is None:
|
||||
raise HTTPException(status_code=404, detail="Payments not found")
|
||||
result = await session.execute(select(Payment).where(Payment.user_id == u.id))
|
||||
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",
|
||||
telegram_path_to_user_id=True,
|
||||
enabled_methods=["get_all", "get_one", "delete"],
|
||||
),
|
||||
prefix="/notifications",
|
||||
tags=["Notifications"],
|
||||
dependencies=[Depends(verify_admin_token)],
|
||||
)
|
||||
|
||||
|
||||
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_admin_token)],
|
||||
)
|
||||
|
||||
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_admin_token)],
|
||||
)
|
||||
|
||||
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_admin_token)],
|
||||
)
|
||||
|
||||
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_admin_token)],
|
||||
)
|
||||
|
||||
|
||||
@router.get(
|
||||
"/tracking-sources/{code}", response_model=TrackingSourceResponse, dependencies=[Depends(verify_admin_token)]
|
||||
)
|
||||
async def get_tracking_source_with_stats(
|
||||
code: str,
|
||||
session: AsyncSession = Depends(get_session),
|
||||
):
|
||||
result = await session.execute(select(TrackingSource).where(TrackingSource.code == code))
|
||||
source = result.scalar_one_or_none()
|
||||
if not source:
|
||||
raise HTTPException(status_code=404, detail="Tracking source not found")
|
||||
|
||||
stats = await get_tracking_source_stats(session, code)
|
||||
|
||||
return TrackingSourceResponse(
|
||||
id=source.id,
|
||||
name=source.name,
|
||||
code=source.code,
|
||||
type=source.type,
|
||||
created_by=source.created_by,
|
||||
created_at=source.created_at,
|
||||
registrations=(stats["registrations"] if stats else 0),
|
||||
trials=(stats["trials"] if stats else 0),
|
||||
payments=(stats["payments"] if stats else 0),
|
||||
total_amount=(float(stats["total_amount"]) if stats else 0.0),
|
||||
monthly=(stats["monthly"] if stats and "monthly" in stats else []),
|
||||
)
|
||||
@@ -1,125 +0,0 @@
|
||||
import pkgutil
|
||||
|
||||
from pathlib import Path
|
||||
from typing import Literal
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from pydantic import BaseModel
|
||||
|
||||
from api.depends import verify_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)}
|
||||
@@ -1,954 +0,0 @@
|
||||
import csv
|
||||
|
||||
from datetime import datetime
|
||||
from io import StringIO
|
||||
|
||||
from fastapi import APIRouter, Depends, Path, Query
|
||||
from fastapi.responses import ORJSONResponse, StreamingResponse
|
||||
from sqlalchemy import text
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from api.depends import get_session, verify_admin_token
|
||||
|
||||
|
||||
try:
|
||||
from modules.partner_program.settings import PARTNER_BONUS_PERCENTAGES
|
||||
except Exception:
|
||||
PARTNER_BONUS_PERCENTAGES = {1: 0.0}
|
||||
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
def _parse_percent(value: float) -> float | None:
|
||||
"""Normalize percent input to 0-100 range."""
|
||||
try:
|
||||
val = float(value)
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
|
||||
if 0.0 <= val <= 1.0:
|
||||
val *= 100.0
|
||||
|
||||
if 0.0 <= val <= 100.0:
|
||||
return val
|
||||
return None
|
||||
|
||||
|
||||
def _default_partner_percent() -> float:
|
||||
try:
|
||||
return float(PARTNER_BONUS_PERCENTAGES.get(1, 0.0)) * 100.0
|
||||
except Exception:
|
||||
return 0.0
|
||||
|
||||
|
||||
def _row_dt_iso(value) -> str | None:
|
||||
if isinstance(value, datetime):
|
||||
return value.isoformat()
|
||||
return None
|
||||
|
||||
|
||||
@router.get("/all")
|
||||
async def get_all_partners(
|
||||
limit: int = Query(1000, ge=1, le=10000, description="Лимит результатов"),
|
||||
offset: int = Query(0, ge=0, description="Смещение"),
|
||||
admin=Depends(verify_admin_token),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
):
|
||||
"""Возвращает список всех партнёров со статистикой.
|
||||
|
||||
Структура ответа:
|
||||
{
|
||||
"total": int,
|
||||
"items": [
|
||||
{
|
||||
"tg_id": int,
|
||||
"balance": float,
|
||||
"percent": float,
|
||||
"code": str | None,
|
||||
"method": str | None,
|
||||
"referred_count": int
|
||||
}
|
||||
]
|
||||
}
|
||||
"""
|
||||
|
||||
partners_sql = text(
|
||||
"""
|
||||
SELECT
|
||||
p.partner_tg_id AS tg_id,
|
||||
COALESCE(u.partner_balance, 0) AS partner_balance,
|
||||
u.partner_percent,
|
||||
COALESCE(u.partner_percent_custom, false) AS partner_percent_custom,
|
||||
u.partner_code,
|
||||
u.payout_method,
|
||||
COUNT(p.joined_tg_id) as joined_count
|
||||
FROM partners p
|
||||
LEFT JOIN users u ON u.tg_id = p.partner_tg_id
|
||||
WHERE p.partner_tg_id IS NOT NULL
|
||||
GROUP BY p.partner_tg_id, u.partner_balance, u.partner_percent, u.partner_percent_custom, u.partner_code, u.payout_method
|
||||
ORDER BY partner_balance DESC
|
||||
LIMIT :limit OFFSET :offset
|
||||
"""
|
||||
)
|
||||
|
||||
count_sql = text(
|
||||
"""
|
||||
SELECT COUNT(DISTINCT partner_tg_id) FROM partners
|
||||
WHERE partner_tg_id IS NOT NULL
|
||||
"""
|
||||
)
|
||||
|
||||
result = await session.execute(partners_sql, {"limit": limit, "offset": offset})
|
||||
partners = result.fetchall()
|
||||
|
||||
count_result = await session.execute(count_sql)
|
||||
total = count_result.scalar() or 0
|
||||
|
||||
partners_list = []
|
||||
default_percent = _default_partner_percent()
|
||||
for partner in partners:
|
||||
percent_value = partner[2]
|
||||
percent_custom = bool(partner[3])
|
||||
if percent_custom and percent_value is not None:
|
||||
percent = float(percent_value)
|
||||
else:
|
||||
percent = float(default_percent)
|
||||
|
||||
partners_list.append({
|
||||
"tg_id": int(partner[0]),
|
||||
"balance": float(partner[1] or 0),
|
||||
"percent": percent,
|
||||
"code": partner[4] or None,
|
||||
"method": partner[5] or None,
|
||||
"referred_count": int(partner[6] or 0),
|
||||
})
|
||||
|
||||
return ORJSONResponse(content={"total": total, "items": partners_list})
|
||||
|
||||
|
||||
@router.get("/stats/all")
|
||||
async def get_partners_stats(
|
||||
admin=Depends(verify_admin_token),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
):
|
||||
"""Возвращает общую статистику партнёрской программы.
|
||||
|
||||
Структура ответа:
|
||||
{
|
||||
"total_partners": int,
|
||||
"partners_today": int,
|
||||
"total_referred": int,
|
||||
"total_balance": float,
|
||||
"top_partner_tg_id": int,
|
||||
"top_partner_refs": int
|
||||
}
|
||||
"""
|
||||
|
||||
stats_sql = text(
|
||||
"""
|
||||
WITH partner_refs AS (
|
||||
SELECT partner_tg_id, COUNT(DISTINCT joined_tg_id) AS ref_count
|
||||
FROM partners
|
||||
WHERE partner_tg_id IS NOT NULL
|
||||
GROUP BY partner_tg_id
|
||||
)
|
||||
SELECT
|
||||
(SELECT COUNT(*) FROM partner_refs) AS total_partners,
|
||||
(
|
||||
SELECT COUNT(DISTINCT partner_tg_id)
|
||||
FROM partners
|
||||
WHERE partner_tg_id IS NOT NULL
|
||||
AND DATE(created_at) = CURRENT_DATE
|
||||
) AS partners_today,
|
||||
(SELECT COUNT(DISTINCT joined_tg_id) FROM partners WHERE partner_tg_id IS NOT NULL) AS total_referred,
|
||||
(
|
||||
SELECT COALESCE(SUM(u.partner_balance), 0.0)
|
||||
FROM users u
|
||||
WHERE u.tg_id IN (SELECT partner_tg_id FROM partner_refs)
|
||||
) AS total_balance,
|
||||
(SELECT partner_tg_id FROM partner_refs ORDER BY ref_count DESC LIMIT 1) AS top_partner_tg_id,
|
||||
(SELECT ref_count FROM partner_refs ORDER BY ref_count DESC LIMIT 1) AS top_partner_refs
|
||||
"""
|
||||
)
|
||||
|
||||
stats_result = await session.execute(stats_sql)
|
||||
stats_row = stats_result.fetchone()
|
||||
|
||||
if stats_row:
|
||||
stats = {
|
||||
"total_partners": int(stats_row[0] or 0),
|
||||
"partners_today": int(stats_row[1] or 0),
|
||||
"total_referred": int(stats_row[2] or 0),
|
||||
"total_balance": float(stats_row[3] or 0.0),
|
||||
"top_partner_tg_id": int(stats_row[4] or 0),
|
||||
"top_partner_refs": int(stats_row[5] or 0),
|
||||
}
|
||||
else:
|
||||
stats = {
|
||||
"total_partners": 0,
|
||||
"partners_today": 0,
|
||||
"total_referred": 0,
|
||||
"total_balance": 0.0,
|
||||
"top_partner_tg_id": 0,
|
||||
"top_partner_refs": 0,
|
||||
}
|
||||
|
||||
return ORJSONResponse(content=stats)
|
||||
|
||||
|
||||
@router.patch("/{tg_id}")
|
||||
async def update_partner(
|
||||
tg_id: int = Path(..., description="Telegram ID партнёра"),
|
||||
balance: float = Query(..., description="Новый баланс партнёра"),
|
||||
percent: float = Query(..., description="Новый процент партнёра"),
|
||||
admin=Depends(verify_admin_token),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
):
|
||||
"""Обновляет данные партнёра (баланс и процент).
|
||||
|
||||
Структура ответа:
|
||||
{
|
||||
"success": bool,
|
||||
"message": str
|
||||
}
|
||||
"""
|
||||
|
||||
try:
|
||||
stmt = text(
|
||||
"""
|
||||
UPDATE users
|
||||
SET partner_balance = :balance, partner_percent = :percent
|
||||
WHERE tg_id = :tg_id
|
||||
"""
|
||||
)
|
||||
|
||||
result = await session.execute(stmt, {"tg_id": tg_id, "balance": balance, "percent": percent})
|
||||
|
||||
if result.rowcount > 0:
|
||||
return ORJSONResponse(
|
||||
content={"success": True, "message": f"Партнёр {tg_id} успешно обновлён"},
|
||||
status_code=200,
|
||||
)
|
||||
else:
|
||||
return ORJSONResponse(
|
||||
content={"success": False, "message": "Партнёр не найден"},
|
||||
status_code=404,
|
||||
)
|
||||
except Exception as e:
|
||||
await session.rollback()
|
||||
return ORJSONResponse(
|
||||
content={"success": False, "message": str(e)},
|
||||
status_code=500,
|
||||
)
|
||||
|
||||
|
||||
@router.get("/{tg_id}")
|
||||
async def get_partner_data(
|
||||
tg_id: int = Path(..., description="Telegram ID партнёра"),
|
||||
admin=Depends(verify_admin_token),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
):
|
||||
"""Возвращает партнёрские данные для указанного `tg_id`.
|
||||
|
||||
Структура ответа:
|
||||
{
|
||||
"tg_id": int,
|
||||
"partner_balance": float,
|
||||
"partner_percent": float,
|
||||
"partner_code": str | None,
|
||||
"payout_method": str | None,
|
||||
"invited": [
|
||||
{ "tg_id": int, "joined_at": str | None, "balance": float, "subs_count": int, "payments_count": int }
|
||||
]
|
||||
}
|
||||
"""
|
||||
|
||||
meta_sql = text(
|
||||
"""
|
||||
SELECT
|
||||
COALESCE(u.partner_balance, 0) AS partner_balance,
|
||||
u.partner_percent,
|
||||
COALESCE(u.partner_percent_custom, false) AS partner_percent_custom,
|
||||
u.partner_code,
|
||||
u.payout_method
|
||||
FROM users u
|
||||
WHERE u.tg_id = :tg_id
|
||||
"""
|
||||
)
|
||||
|
||||
invited_sql = text(
|
||||
"""
|
||||
SELECT
|
||||
pr.joined_tg_id,
|
||||
pr.created_at,
|
||||
COALESCE(u.balance, 0) AS user_balance,
|
||||
(
|
||||
SELECT COUNT(*) FROM keys k
|
||||
WHERE k.tg_id = pr.joined_tg_id
|
||||
) AS subs_count,
|
||||
(
|
||||
SELECT COUNT(*) FROM payments pay
|
||||
WHERE pay.tg_id = pr.joined_tg_id
|
||||
AND lower(pay.status) = 'success'
|
||||
) AS payments_count
|
||||
FROM partners pr
|
||||
LEFT JOIN users u ON u.tg_id = pr.joined_tg_id
|
||||
WHERE pr.partner_tg_id = :tg_id
|
||||
ORDER BY pr.created_at DESC
|
||||
"""
|
||||
)
|
||||
|
||||
meta_res = await session.execute(meta_sql, {"tg_id": tg_id})
|
||||
meta_row = meta_res.fetchone()
|
||||
|
||||
invited_res = await session.execute(invited_sql, {"tg_id": tg_id})
|
||||
invited_rows = invited_res.fetchall()
|
||||
|
||||
default_percent = _default_partner_percent()
|
||||
percent = default_percent
|
||||
if meta_row:
|
||||
percent_value = meta_row[1]
|
||||
percent_custom = bool(meta_row[2])
|
||||
if percent_custom and percent_value is not None:
|
||||
percent = float(percent_value)
|
||||
|
||||
response = {
|
||||
"tg_id": tg_id,
|
||||
"partner_balance": float(meta_row[0] or 0) if meta_row else 0.0,
|
||||
"partner_percent": percent,
|
||||
"partner_code": meta_row[3] if meta_row else None,
|
||||
"payout_method": meta_row[4] if meta_row else None,
|
||||
"invited": [
|
||||
{
|
||||
"tg_id": row[0],
|
||||
"joined_at": row[1].isoformat() if isinstance(row[1], datetime) else None,
|
||||
"balance": float(row[2] or 0),
|
||||
"subs_count": int(row[3] or 0),
|
||||
"payments_count": int(row[4] or 0),
|
||||
}
|
||||
for row in invited_rows
|
||||
],
|
||||
}
|
||||
|
||||
return ORJSONResponse(content=response)
|
||||
|
||||
|
||||
@router.post("/{tg_id}/invited")
|
||||
async def add_partner_invited(
|
||||
tg_id: int = Path(..., description="Telegram ID партнёра"),
|
||||
joined_tg_id: int = Query(..., description="Telegram ID приглашённого"),
|
||||
admin=Depends(verify_admin_token),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
):
|
||||
"""Добавляет приглашённого пользователю партнёра."""
|
||||
|
||||
if joined_tg_id == tg_id:
|
||||
return ORJSONResponse(
|
||||
content={"success": False, "message": "Нельзя привязать пользователя к самому себе"},
|
||||
status_code=400,
|
||||
)
|
||||
|
||||
try:
|
||||
partner_exists = await session.execute(
|
||||
text("SELECT 1 FROM users WHERE tg_id = :tg_id"),
|
||||
{"tg_id": tg_id},
|
||||
)
|
||||
if not partner_exists.scalar():
|
||||
return ORJSONResponse(
|
||||
content={"success": False, "message": "Партнёр не найден"},
|
||||
status_code=404,
|
||||
)
|
||||
|
||||
invited_exists = await session.execute(
|
||||
text("SELECT 1 FROM users WHERE tg_id = :joined_tg_id"),
|
||||
{"joined_tg_id": joined_tg_id},
|
||||
)
|
||||
if not invited_exists.scalar():
|
||||
return ORJSONResponse(
|
||||
content={"success": False, "message": "Приглашённый пользователь не найден"},
|
||||
status_code=404,
|
||||
)
|
||||
|
||||
existing = await session.execute(
|
||||
text("SELECT partner_tg_id FROM partners WHERE joined_tg_id = :joined_tg_id"),
|
||||
{"joined_tg_id": joined_tg_id},
|
||||
)
|
||||
existing_partner = existing.scalar()
|
||||
if existing_partner is not None:
|
||||
return ORJSONResponse(
|
||||
content={
|
||||
"success": False,
|
||||
"message": f"Пользователь уже привязан к партнёру {existing_partner}",
|
||||
},
|
||||
status_code=409,
|
||||
)
|
||||
|
||||
await session.execute(
|
||||
text(
|
||||
"""
|
||||
INSERT INTO partners (partner_tg_id, joined_tg_id)
|
||||
VALUES (:partner_tg_id, :joined_tg_id)
|
||||
"""
|
||||
),
|
||||
{"partner_tg_id": tg_id, "joined_tg_id": joined_tg_id},
|
||||
)
|
||||
return ORJSONResponse(
|
||||
content={
|
||||
"success": True,
|
||||
"message": "Приглашённый добавлен",
|
||||
"partner_tg_id": tg_id,
|
||||
"joined_tg_id": joined_tg_id,
|
||||
},
|
||||
status_code=201,
|
||||
)
|
||||
except Exception as e:
|
||||
await session.rollback()
|
||||
return ORJSONResponse(content={"success": False, "message": str(e)}, status_code=500)
|
||||
|
||||
|
||||
@router.delete("/{tg_id}/invited/{joined_tg_id}")
|
||||
async def delete_partner_invited(
|
||||
tg_id: int = Path(..., description="Telegram ID партнёра"),
|
||||
joined_tg_id: int = Path(..., description="Telegram ID приглашённого"),
|
||||
admin=Depends(verify_admin_token),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
):
|
||||
"""Удаляет приглашённого у партнёра."""
|
||||
|
||||
try:
|
||||
result = await session.execute(
|
||||
text(
|
||||
"""
|
||||
DELETE FROM partners
|
||||
WHERE partner_tg_id = :partner_tg_id
|
||||
AND joined_tg_id = :joined_tg_id
|
||||
"""
|
||||
),
|
||||
{"partner_tg_id": tg_id, "joined_tg_id": joined_tg_id},
|
||||
)
|
||||
|
||||
if result.rowcount > 0:
|
||||
return ORJSONResponse(
|
||||
content={
|
||||
"success": True,
|
||||
"message": "Приглашённый удалён",
|
||||
"partner_tg_id": tg_id,
|
||||
"joined_tg_id": joined_tg_id,
|
||||
},
|
||||
status_code=200,
|
||||
)
|
||||
return ORJSONResponse(
|
||||
content={"success": False, "message": "Связка партнёр-приглашённый не найдена"},
|
||||
status_code=404,
|
||||
)
|
||||
except Exception as e:
|
||||
await session.rollback()
|
||||
return ORJSONResponse(content={"success": False, "message": str(e)}, status_code=500)
|
||||
|
||||
|
||||
@router.patch("/{tg_id}/percent")
|
||||
async def update_partner_percent(
|
||||
tg_id: int = Path(..., description="Telegram ID партнёра"),
|
||||
percent: float = Query(..., description="Новый персональный процент (0-100 или 0.0-1.0)"),
|
||||
admin=Depends(verify_admin_token),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
):
|
||||
"""Обновляет персональный процент партнёра."""
|
||||
|
||||
normalized = _parse_percent(percent)
|
||||
if normalized is None:
|
||||
return ORJSONResponse(
|
||||
content={"success": False, "message": "Неверный процент. Допустимо 0-100 или 0.0-1.0"},
|
||||
status_code=400,
|
||||
)
|
||||
|
||||
try:
|
||||
result = await session.execute(
|
||||
text(
|
||||
"""
|
||||
UPDATE users
|
||||
SET partner_percent = :percent, partner_percent_custom = true
|
||||
WHERE tg_id = :tg_id
|
||||
"""
|
||||
),
|
||||
{"tg_id": tg_id, "percent": normalized},
|
||||
)
|
||||
|
||||
if result.rowcount > 0:
|
||||
return ORJSONResponse(
|
||||
content={"success": True, "message": "Процент обновлён", "percent": normalized},
|
||||
status_code=200,
|
||||
)
|
||||
return ORJSONResponse(content={"success": False, "message": "Партнёр не найден"}, status_code=404)
|
||||
except Exception as e:
|
||||
await session.rollback()
|
||||
return ORJSONResponse(content={"success": False, "message": str(e)}, status_code=500)
|
||||
|
||||
|
||||
@router.patch("/{tg_id}/balance")
|
||||
async def update_partner_balance(
|
||||
tg_id: int = Path(..., description="Telegram ID партнёра"),
|
||||
amount: float = Query(..., description="Сумма операции"),
|
||||
mode: str = Query("set", description="Режим: set, add, subtract"),
|
||||
admin=Depends(verify_admin_token),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
):
|
||||
"""Изменяет баланс партнёрской программы."""
|
||||
|
||||
mode_normalized = (mode or "set").strip().lower()
|
||||
if mode_normalized not in {"set", "add", "subtract"}:
|
||||
return ORJSONResponse(
|
||||
content={"success": False, "message": "Неверный режим. Используйте set, add или subtract"},
|
||||
status_code=400,
|
||||
)
|
||||
|
||||
try:
|
||||
amount_val = float(amount)
|
||||
except (TypeError, ValueError):
|
||||
return ORJSONResponse(
|
||||
content={"success": False, "message": "Неверная сумма"},
|
||||
status_code=400,
|
||||
)
|
||||
|
||||
if amount_val < 0:
|
||||
return ORJSONResponse(
|
||||
content={"success": False, "message": "Сумма не может быть отрицательной"},
|
||||
status_code=400,
|
||||
)
|
||||
|
||||
try:
|
||||
current_res = await session.execute(
|
||||
text("SELECT partner_balance FROM users WHERE tg_id = :tg_id"),
|
||||
{"tg_id": tg_id},
|
||||
)
|
||||
current_balance = current_res.scalar()
|
||||
if current_balance is None:
|
||||
return ORJSONResponse(content={"success": False, "message": "Партнёр не найден"}, status_code=404)
|
||||
|
||||
current_balance = float(current_balance or 0.0)
|
||||
|
||||
if mode_normalized == "set":
|
||||
new_balance = amount_val
|
||||
elif mode_normalized == "add":
|
||||
new_balance = current_balance + amount_val
|
||||
else:
|
||||
if current_balance < amount_val:
|
||||
return ORJSONResponse(
|
||||
content={"success": False, "message": "Недостаточно средств"},
|
||||
status_code=400,
|
||||
)
|
||||
new_balance = current_balance - amount_val
|
||||
|
||||
await session.execute(
|
||||
text("UPDATE users SET partner_balance = :balance WHERE tg_id = :tg_id"),
|
||||
{"tg_id": tg_id, "balance": new_balance},
|
||||
)
|
||||
|
||||
return ORJSONResponse(
|
||||
content={"success": True, "message": "Баланс обновлён", "balance": new_balance},
|
||||
status_code=200,
|
||||
)
|
||||
except Exception as e:
|
||||
await session.rollback()
|
||||
return ORJSONResponse(content={"success": False, "message": str(e)}, status_code=500)
|
||||
|
||||
|
||||
@router.get("/{tg_id}/invited")
|
||||
async def get_partner_invited(
|
||||
tg_id: int = Path(..., description="Telegram ID партнёра"),
|
||||
admin=Depends(verify_admin_token),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
):
|
||||
"""Возвращает список приглашённых пользователей конкретного партнёра.
|
||||
|
||||
Структура ответа:
|
||||
[
|
||||
{ "tg_id": int, "joined_at": str | None, "balance": float, "subs_count": int, "payments_count": int }
|
||||
]
|
||||
"""
|
||||
|
||||
invited_sql = text(
|
||||
"""
|
||||
SELECT
|
||||
pr.joined_tg_id,
|
||||
pr.created_at,
|
||||
COALESCE(u.balance, 0) AS user_balance,
|
||||
(
|
||||
SELECT COUNT(*) FROM keys k
|
||||
WHERE k.tg_id = pr.joined_tg_id
|
||||
) AS subs_count,
|
||||
(
|
||||
SELECT COUNT(*) FROM payments pay
|
||||
WHERE pay.tg_id = pr.joined_tg_id
|
||||
AND lower(pay.status) = 'success'
|
||||
) AS payments_count
|
||||
FROM partners pr
|
||||
LEFT JOIN users u ON u.tg_id = pr.joined_tg_id
|
||||
WHERE pr.partner_tg_id = :tg_id
|
||||
ORDER BY pr.created_at DESC
|
||||
"""
|
||||
)
|
||||
|
||||
invited_res = await session.execute(invited_sql, {"tg_id": tg_id})
|
||||
invited_rows = invited_res.fetchall()
|
||||
|
||||
invited_list = [
|
||||
{
|
||||
"tg_id": row[0],
|
||||
"joined_at": row[1].isoformat() if isinstance(row[1], datetime) else None,
|
||||
"balance": float(row[2] or 0),
|
||||
"subs_count": int(row[3] or 0),
|
||||
"payments_count": int(row[4] or 0),
|
||||
}
|
||||
for row in invited_rows
|
||||
]
|
||||
|
||||
return ORJSONResponse(content=invited_list)
|
||||
|
||||
|
||||
@router.get("/payouts/pending")
|
||||
async def get_partner_payouts_pending(
|
||||
limit: int = Query(50, ge=1, le=200, description="Лимит результатов"),
|
||||
offset: int = Query(0, ge=0, description="Смещение"),
|
||||
partner_tg_id: int | None = Query(None, description="Фильтр по TG ID партнёра"),
|
||||
admin=Depends(verify_admin_token),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
):
|
||||
"""Возвращает список ожидающих заявок на вывод."""
|
||||
|
||||
where_clause = "WHERE pr.status = 'pending'"
|
||||
params = {"limit": limit, "offset": offset}
|
||||
if partner_tg_id is not None:
|
||||
where_clause += " AND pr.tg_id = :partner_tg_id"
|
||||
params["partner_tg_id"] = partner_tg_id
|
||||
|
||||
count_sql = text(f"SELECT COUNT(*) FROM payout_requests pr {where_clause}")
|
||||
rows_sql = text(
|
||||
f"""
|
||||
SELECT
|
||||
pr.id,
|
||||
pr.tg_id,
|
||||
pr.amount,
|
||||
pr.status,
|
||||
pr.created_at,
|
||||
COALESCE(pr.method, u.payout_method) AS method,
|
||||
COALESCE(pr.destination, u.card_number) AS destination
|
||||
FROM payout_requests pr
|
||||
LEFT JOIN users u ON u.tg_id = pr.tg_id
|
||||
{where_clause}
|
||||
ORDER BY pr.created_at ASC, pr.id ASC
|
||||
LIMIT :limit OFFSET :offset
|
||||
"""
|
||||
)
|
||||
|
||||
total = await session.scalar(count_sql) or 0
|
||||
result = await session.execute(rows_sql, params)
|
||||
items = []
|
||||
for row in result.fetchall():
|
||||
items.append({
|
||||
"id": int(row[0]),
|
||||
"tg_id": int(row[1]),
|
||||
"amount": float(row[2] or 0.0),
|
||||
"status": row[3] or "pending",
|
||||
"created_at": _row_dt_iso(row[4]),
|
||||
"method": row[5] or None,
|
||||
"destination": row[6] or None,
|
||||
})
|
||||
|
||||
return ORJSONResponse(content={"total": int(total), "items": items})
|
||||
|
||||
|
||||
@router.get("/payouts/history")
|
||||
async def get_partner_payouts_history(
|
||||
limit: int = Query(50, ge=1, le=200, description="Лимит результатов"),
|
||||
offset: int = Query(0, ge=0, description="Смещение"),
|
||||
partner_tg_id: int | None = Query(None, description="Фильтр по TG ID партнёра"),
|
||||
admin=Depends(verify_admin_token),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
):
|
||||
"""Возвращает историю выплат (approved/rejected)."""
|
||||
|
||||
where_clause = "WHERE pr.status IN ('approved','rejected')"
|
||||
params = {"limit": limit, "offset": offset}
|
||||
if partner_tg_id is not None:
|
||||
where_clause += " AND pr.tg_id = :partner_tg_id"
|
||||
params["partner_tg_id"] = partner_tg_id
|
||||
|
||||
count_sql = text(f"SELECT COUNT(*) FROM payout_requests pr {where_clause}")
|
||||
rows_sql = text(
|
||||
f"""
|
||||
SELECT
|
||||
pr.id,
|
||||
pr.tg_id,
|
||||
pr.amount,
|
||||
pr.status,
|
||||
pr.created_at,
|
||||
COALESCE(pr.method, u.payout_method) AS method,
|
||||
COALESCE(pr.destination, u.card_number) AS destination
|
||||
FROM payout_requests pr
|
||||
LEFT JOIN users u ON u.tg_id = pr.tg_id
|
||||
{where_clause}
|
||||
ORDER BY pr.created_at DESC, pr.id DESC
|
||||
LIMIT :limit OFFSET :offset
|
||||
"""
|
||||
)
|
||||
|
||||
total = await session.scalar(count_sql) or 0
|
||||
result = await session.execute(rows_sql, params)
|
||||
items = []
|
||||
for row in result.fetchall():
|
||||
items.append({
|
||||
"id": int(row[0]),
|
||||
"tg_id": int(row[1]),
|
||||
"amount": float(row[2] or 0.0),
|
||||
"status": row[3] or "—",
|
||||
"created_at": _row_dt_iso(row[4]),
|
||||
"method": row[5] or None,
|
||||
"destination": row[6] or None,
|
||||
})
|
||||
|
||||
return ORJSONResponse(content={"total": int(total), "items": items})
|
||||
|
||||
|
||||
@router.post("/payouts/{payout_id}/approve")
|
||||
async def approve_partner_payout(
|
||||
payout_id: int = Path(..., description="ID заявки"),
|
||||
admin=Depends(verify_admin_token),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
):
|
||||
"""Одобряет заявку на вывод."""
|
||||
|
||||
req_row = await session.execute(
|
||||
text("SELECT id, tg_id, amount FROM payout_requests WHERE id = :id AND status = 'pending'"),
|
||||
{"id": payout_id},
|
||||
)
|
||||
req = req_row.fetchone()
|
||||
if not req:
|
||||
return ORJSONResponse(
|
||||
content={"success": False, "message": "Заявка не найдена или уже обработана"},
|
||||
status_code=404,
|
||||
)
|
||||
|
||||
user_row = await session.execute(
|
||||
text("SELECT payout_method, card_number FROM users WHERE tg_id = :tg_id"),
|
||||
{"tg_id": req[1]},
|
||||
)
|
||||
user = user_row.fetchone()
|
||||
payout_method = (user[0] if user else None) or "card"
|
||||
destination = (user[1] if user else None) or None
|
||||
destination = (destination or "").strip() or None
|
||||
|
||||
await session.execute(
|
||||
text(
|
||||
"""
|
||||
UPDATE payout_requests
|
||||
SET status = 'approved', method = :method, destination = :destination
|
||||
WHERE id = :id
|
||||
"""
|
||||
),
|
||||
{"id": payout_id, "method": payout_method, "destination": destination},
|
||||
)
|
||||
|
||||
return ORJSONResponse(content={"success": True, "message": "Заявка одобрена"}, status_code=200)
|
||||
|
||||
|
||||
@router.post("/payouts/{payout_id}/reject")
|
||||
async def reject_partner_payout(
|
||||
payout_id: int = Path(..., description="ID заявки"),
|
||||
admin=Depends(verify_admin_token),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
):
|
||||
"""Отклоняет заявку на вывод и возвращает сумму на баланс."""
|
||||
|
||||
req_row = await session.execute(
|
||||
text("SELECT id, tg_id, amount FROM payout_requests WHERE id = :id AND status = 'pending'"),
|
||||
{"id": payout_id},
|
||||
)
|
||||
req = req_row.fetchone()
|
||||
if not req:
|
||||
return ORJSONResponse(
|
||||
content={"success": False, "message": "Заявка не найдена или уже обработана"},
|
||||
status_code=404,
|
||||
)
|
||||
|
||||
user_row = await session.execute(
|
||||
text("SELECT payout_method, card_number, partner_balance FROM users WHERE tg_id = :tg_id"),
|
||||
{"tg_id": req[1]},
|
||||
)
|
||||
user = user_row.fetchone()
|
||||
payout_method = (user[0] if user else None) or "card"
|
||||
destination = (user[1] if user else None) or None
|
||||
destination = (destination or "").strip() or None
|
||||
|
||||
await session.execute(
|
||||
text(
|
||||
"""
|
||||
UPDATE payout_requests
|
||||
SET status = 'rejected', method = :method, destination = :destination
|
||||
WHERE id = :id
|
||||
"""
|
||||
),
|
||||
{"id": payout_id, "method": payout_method, "destination": destination},
|
||||
)
|
||||
|
||||
if user is not None:
|
||||
current_balance = float(user[2] or 0.0)
|
||||
await session.execute(
|
||||
text("UPDATE users SET partner_balance = :balance WHERE tg_id = :tg_id"),
|
||||
{"balance": current_balance + float(req[2] or 0.0), "tg_id": req[1]},
|
||||
)
|
||||
|
||||
|
||||
return ORJSONResponse(content={"success": True, "message": "Заявка отклонена"}, status_code=200)
|
||||
|
||||
|
||||
@router.patch("/{tg_id}/percent/reset")
|
||||
async def reset_partner_percent(
|
||||
tg_id: int = Path(..., description="Telegram ID партнёра"),
|
||||
admin=Depends(verify_admin_token),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
):
|
||||
"""Сбрасывает персональный процент партнёра к дефолту."""
|
||||
|
||||
result = await session.execute(
|
||||
text(
|
||||
"""
|
||||
UPDATE users
|
||||
SET partner_percent = NULL, partner_percent_custom = false
|
||||
WHERE tg_id = :tg_id
|
||||
"""
|
||||
),
|
||||
{"tg_id": tg_id},
|
||||
)
|
||||
|
||||
if result.rowcount > 0:
|
||||
return ORJSONResponse(content={"success": True, "message": "Процент сброшен"}, status_code=200)
|
||||
return ORJSONResponse(content={"success": False, "message": "Партнёр не найден"}, status_code=404)
|
||||
|
||||
|
||||
@router.patch("/{tg_id}/code")
|
||||
async def update_partner_code(
|
||||
tg_id: int = Path(..., description="Telegram ID партнёра"),
|
||||
code: str = Query(..., description="Новый код партнёра (латиница/цифры/_)"),
|
||||
admin=Depends(verify_admin_token),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
):
|
||||
"""Обновляет код партнёрской ссылки."""
|
||||
|
||||
raw = (code or "").strip().lower()
|
||||
if not raw:
|
||||
return ORJSONResponse(content={"success": False, "message": "Код не может быть пустым"}, status_code=400)
|
||||
|
||||
import re
|
||||
|
||||
if not re.fullmatch(r"[a-z0-9_]{3,32}", raw):
|
||||
return ORJSONResponse(
|
||||
content={"success": False, "message": "Неверный код. Разрешены a-z, 0-9, _ (3-32 символа)"},
|
||||
status_code=400,
|
||||
)
|
||||
|
||||
exists = await session.execute(
|
||||
text("SELECT 1 FROM users WHERE partner_code = :code AND tg_id != :tg_id"),
|
||||
{"code": raw, "tg_id": tg_id},
|
||||
)
|
||||
if exists.first():
|
||||
return ORJSONResponse(content={"success": False, "message": "Такой код уже занят"}, status_code=409)
|
||||
|
||||
result = await session.execute(
|
||||
text("UPDATE users SET partner_code = :code WHERE tg_id = :tg_id"),
|
||||
{"code": raw, "tg_id": tg_id},
|
||||
)
|
||||
|
||||
if result.rowcount > 0:
|
||||
return ORJSONResponse(content={"success": True, "message": "Код обновлён", "code": raw}, status_code=200)
|
||||
return ORJSONResponse(content={"success": False, "message": "Партнёр не найден"}, status_code=404)
|
||||
|
||||
|
||||
@router.post("/reset-disabled-methods")
|
||||
async def reset_disabled_payout_methods(
|
||||
admin=Depends(verify_admin_token),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
):
|
||||
"""Сбрасывает реквизиты для отключённых способов вывода."""
|
||||
|
||||
try:
|
||||
from modules.partner_program import buttons as B
|
||||
from modules.partner_program.settings import (
|
||||
ENABLE_PAYOUT_CARD,
|
||||
ENABLE_PAYOUT_SBP,
|
||||
ENABLE_PAYOUT_TON,
|
||||
ENABLE_PAYOUT_USDT,
|
||||
)
|
||||
except Exception:
|
||||
ENABLE_PAYOUT_CARD = True
|
||||
ENABLE_PAYOUT_USDT = True
|
||||
ENABLE_PAYOUT_TON = True
|
||||
ENABLE_PAYOUT_SBP = True
|
||||
B = None
|
||||
|
||||
disabled = []
|
||||
if not ENABLE_PAYOUT_CARD and B:
|
||||
disabled.append(B.METHOD_CARD)
|
||||
if not ENABLE_PAYOUT_USDT and B:
|
||||
disabled.append(B.METHOD_USDT)
|
||||
if not ENABLE_PAYOUT_TON and B:
|
||||
disabled.append(B.METHOD_TON)
|
||||
if not ENABLE_PAYOUT_SBP and B:
|
||||
disabled.append(B.METHOD_SBP)
|
||||
|
||||
if not disabled:
|
||||
return ORJSONResponse(content={"success": True, "message": "Отключённых методов нет"}, status_code=200)
|
||||
|
||||
await session.execute(
|
||||
text(
|
||||
"""
|
||||
UPDATE users
|
||||
SET card_number = NULL
|
||||
WHERE payout_method = ANY(:methods)
|
||||
"""
|
||||
),
|
||||
{"methods": disabled},
|
||||
)
|
||||
|
||||
return ORJSONResponse(content={"success": True, "message": "Отключённые методы сброшены"}, status_code=200)
|
||||
|
||||
|
||||
@router.get("/{tg_id}/export")
|
||||
async def export_partner_invites_csv(
|
||||
tg_id: int = Path(..., description="Telegram ID партнёра"),
|
||||
admin=Depends(verify_admin_token),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
):
|
||||
"""Экспортирует приглашённых партнёром в CSV."""
|
||||
|
||||
rows = await session.execute(
|
||||
text(
|
||||
"""
|
||||
SELECT joined_tg_id, created_at
|
||||
FROM partners
|
||||
WHERE partner_tg_id = :tg_id
|
||||
ORDER BY created_at ASC
|
||||
"""
|
||||
),
|
||||
{"tg_id": tg_id},
|
||||
)
|
||||
data = rows.fetchall()
|
||||
|
||||
if not data:
|
||||
return ORJSONResponse(content={"success": False, "message": "Нет приглашённых"}, status_code=404)
|
||||
|
||||
buffer = StringIO()
|
||||
writer = csv.writer(buffer, delimiter=";")
|
||||
writer.writerow(["joined_tg_id", "created_at"])
|
||||
for joined_tg_id, created_at in data:
|
||||
writer.writerow([int(joined_tg_id), created_at.isoformat() if created_at else ""])
|
||||
|
||||
content = buffer.getvalue().encode("utf-8-sig")
|
||||
filename = f"partner_invites_{tg_id}.csv"
|
||||
|
||||
return StreamingResponse(
|
||||
iter([content]),
|
||||
media_type="text/csv",
|
||||
headers={"Content-Disposition": f"attachment; filename={filename}"},
|
||||
)
|
||||
@@ -1,45 +0,0 @@
|
||||
from fastapi import Depends, HTTPException, Query
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from api.depends import get_session, verify_admin_token
|
||||
from api.v1.routes.base_crud import generate_crud_router
|
||||
from api.v1.schemas import ReferralResponse
|
||||
from database.access.resolution import resolve_user_optional
|
||||
from database.models import Admin, Referral
|
||||
|
||||
|
||||
router = generate_crud_router(
|
||||
model=Referral,
|
||||
schema_response=ReferralResponse,
|
||||
schema_create=None,
|
||||
schema_update=None,
|
||||
identifier_field="referrer_user_id",
|
||||
parameter_name="referrer_tg_id",
|
||||
telegram_path_to_user_id=True,
|
||||
enabled_methods=["get_all", "get_one", "get_all_by_field"],
|
||||
)
|
||||
|
||||
|
||||
@router.delete("/one")
|
||||
async def delete_one_referral(
|
||||
referrer_tg_id: int = Query(..., description="ID пригласившего"),
|
||||
referred_tg_id: int = Query(..., description="ID приглашённого"),
|
||||
admin: Admin = Depends(verify_admin_token),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
):
|
||||
ru_ref = await resolve_user_optional(session, referrer_tg_id)
|
||||
rd_ref = await resolve_user_optional(session, referred_tg_id)
|
||||
if ru_ref is None or rd_ref is None:
|
||||
raise HTTPException(status_code=404, detail="Referral not found")
|
||||
result = await session.execute(
|
||||
select(Referral).where(
|
||||
Referral.referrer_user_id == ru_ref.id,
|
||||
Referral.referred_user_id == rd_ref.id,
|
||||
)
|
||||
)
|
||||
obj = result.scalar_one_or_none()
|
||||
if not obj:
|
||||
raise HTTPException(status_code=404, detail="Referral not found")
|
||||
await session.delete(obj)
|
||||
return {"status": "deleted_one"}
|
||||
@@ -1,16 +0,0 @@
|
||||
from fastapi import APIRouter
|
||||
|
||||
from api.v1.routes.base_crud import generate_crud_router
|
||||
from api.v1.schemas import ServerBase, ServerResponse, ServerUpdate
|
||||
from database.models import Server
|
||||
|
||||
|
||||
router: APIRouter = generate_crud_router(
|
||||
model=Server,
|
||||
schema_response=ServerResponse,
|
||||
schema_create=ServerBase,
|
||||
schema_update=ServerUpdate,
|
||||
identifier_field="server_name",
|
||||
parameter_name="server_name",
|
||||
enabled_methods=["get_all", "get_one", "create", "update", "delete"],
|
||||
)
|
||||
@@ -1,151 +0,0 @@
|
||||
from typing import Any
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from pydantic import BaseModel
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from api.depends import get_session, verify_admin_token
|
||||
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
|
||||
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(admin=Depends(verify_admin_token)):
|
||||
"""Список всех настроек (из кэша, без запроса к БД)."""
|
||||
return settings_cache.get_all()
|
||||
|
||||
|
||||
@router.get("/configs")
|
||||
async def get_configs(admin=Depends(verify_admin_token)):
|
||||
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,
|
||||
admin=Depends(verify_admin_token),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
):
|
||||
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, admin=Depends(verify_admin_token)):
|
||||
"""Настройка по ключу (из кэша, без запроса к БД)."""
|
||||
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,
|
||||
admin=Depends(verify_admin_token),
|
||||
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,
|
||||
admin=Depends(verify_admin_token),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
):
|
||||
result = await session.execute(select(Setting).where(Setting.key == key))
|
||||
obj = result.scalar_one_or_none()
|
||||
if not obj:
|
||||
raise HTTPException(status_code=404, detail="Setting not found")
|
||||
await session.delete(obj)
|
||||
settings_cache.delete(key)
|
||||
return {"detail": "Setting deleted"}
|
||||
@@ -1,16 +0,0 @@
|
||||
from fastapi import APIRouter
|
||||
|
||||
from api.v1.routes.base_crud import generate_crud_router
|
||||
from api.v1.schemas import TariffBase, TariffResponse, TariffUpdate
|
||||
from database.models import Tariff
|
||||
|
||||
|
||||
router: APIRouter = generate_crud_router(
|
||||
model=Tariff,
|
||||
schema_response=TariffResponse,
|
||||
schema_create=TariffBase,
|
||||
schema_update=TariffUpdate,
|
||||
identifier_field="name",
|
||||
parameter_name="name",
|
||||
enabled_methods=["get_all", "get_one", "create", "update", "delete"],
|
||||
)
|
||||
@@ -1,64 +0,0 @@
|
||||
import asyncio
|
||||
|
||||
from fastapi import Depends, HTTPException, Path
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from api.depends import get_session, verify_admin_token
|
||||
from api.v1.routes.base_crud import generate_crud_router
|
||||
from api.v1.schemas.users import UserBase, UserResponse, UserUpdate
|
||||
from database import async_session_maker, delete_user_data, get_servers
|
||||
from database.access.resolution import resolve_user_optional
|
||||
from database.models import Key, User
|
||||
from logger import logger
|
||||
from services.operations import delete_key_from_cluster
|
||||
|
||||
|
||||
router = generate_crud_router(
|
||||
model=User,
|
||||
schema_response=UserResponse,
|
||||
schema_create=UserBase,
|
||||
schema_update=UserUpdate,
|
||||
identifier_field="tg_id",
|
||||
enabled_methods=["get_all", "get_one", "get_by_email", "create", "update"],
|
||||
)
|
||||
|
||||
|
||||
@router.delete("/{tg_id}", response_model=dict)
|
||||
async def delete_user(
|
||||
tg_id: int = Path(..., description="Telegram ID пользователя"),
|
||||
admin=Depends(verify_admin_token),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
):
|
||||
try:
|
||||
u = await resolve_user_optional(session, tg_id)
|
||||
if u is None:
|
||||
raise HTTPException(status_code=404, detail="Пользователь не найден")
|
||||
result = await session.execute(select(Key.email, Key.client_id).where(Key.user_id == u.id))
|
||||
key_records = result.all()
|
||||
|
||||
async with async_session_maker() as s:
|
||||
servers = await get_servers(session=s)
|
||||
cluster_ids = list(servers.keys())
|
||||
|
||||
async def _delete_one(cluster_id: str, email: str, client_id: str):
|
||||
async with async_session_maker() as s:
|
||||
await delete_key_from_cluster(cluster_id, email, client_id, s)
|
||||
|
||||
try:
|
||||
tasks = [
|
||||
_delete_one(cluster_id, email, client_id)
|
||||
for email, client_id in key_records
|
||||
for cluster_id in cluster_ids
|
||||
]
|
||||
await asyncio.gather(*tasks, return_exceptions=True)
|
||||
except Exception as e:
|
||||
logger.error(f"[DELETE] Ошибка при удалении ключей с серверов для пользователя {tg_id}: {e}")
|
||||
|
||||
await delete_user_data(session, tg_id)
|
||||
|
||||
return {"detail": f"Пользователь {tg_id} и его ключи успешно удалены."}
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"[DELETE] Ошибка при удалении пользователя {tg_id}: {e}")
|
||||
raise HTTPException(status_code=500, detail="Ошибка при удалении пользователя")
|
||||
@@ -1,16 +0,0 @@
|
||||
from .coupons import CouponBase, CouponResponse, CouponUpdate, CouponUsageResponse
|
||||
from .gifts import GiftBase, GiftResponse, GiftUpdate, GiftUsageResponse
|
||||
from .keys import KeyDetailsResponse, KeyResponse
|
||||
from .misc import (
|
||||
BlockedUserResponse,
|
||||
ManualBanResponse,
|
||||
NotificationResponse,
|
||||
PaymentResponse,
|
||||
TemporaryDataResponse,
|
||||
TrackingSourceResponse,
|
||||
)
|
||||
from .referrals import ReferralResponse
|
||||
from .servers import ServerBase, ServerResponse, ServerUpdate
|
||||
from .settings import SettingResponse, SettingUpsert
|
||||
from .tariffs import TariffBase, TariffResponse, TariffUpdate
|
||||
from .users import UserBase, UserResponse, UserUpdate
|
||||
@@ -1,97 +0,0 @@
|
||||
from datetime import datetime
|
||||
|
||||
from pydantic import BaseModel, Field, model_validator
|
||||
|
||||
|
||||
class CouponBase(BaseModel):
|
||||
code: str
|
||||
amount: int | None = Field(default=None)
|
||||
usage_limit: int
|
||||
usage_count: int = 0
|
||||
is_used: bool = False
|
||||
days: int | None = Field(default=None)
|
||||
|
||||
percent: int | None = Field(default=None)
|
||||
max_discount_amount: int | None = Field(default=None)
|
||||
min_order_amount: int | None = Field(default=None)
|
||||
|
||||
new_users_only: bool = False
|
||||
|
||||
@model_validator(mode="after")
|
||||
def check_coupon_type(self) -> "CouponBase":
|
||||
has_amount = self.amount not in (None, 0)
|
||||
has_days = self.days is not None
|
||||
has_percent = self.percent is not None
|
||||
|
||||
provided = int(has_amount) + int(has_days) + int(has_percent)
|
||||
if provided != 1:
|
||||
raise ValueError("Coupon must have exactly one of: 'amount', 'days', 'percent'")
|
||||
|
||||
if has_days and self.days is not None and self.days <= 0:
|
||||
raise ValueError("'days' must be > 0")
|
||||
|
||||
if has_percent and self.percent is not None and not (1 <= self.percent <= 100):
|
||||
raise ValueError("'percent' must be between 1 and 100")
|
||||
|
||||
if has_percent:
|
||||
if self.min_order_amount is not None and self.min_order_amount < 0:
|
||||
raise ValueError("'min_order_amount' must be >= 0")
|
||||
if self.max_discount_amount is not None and self.max_discount_amount < 0:
|
||||
raise ValueError("'max_discount_amount' must be >= 0")
|
||||
|
||||
return self
|
||||
|
||||
|
||||
class CouponResponse(CouponBase):
|
||||
id: int
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
|
||||
class CouponUpdate(BaseModel):
|
||||
code: str | None = None
|
||||
amount: int | None = None
|
||||
usage_limit: int | None = None
|
||||
usage_count: int | None = None
|
||||
is_used: bool | None = None
|
||||
days: int | None = Field(default=None)
|
||||
|
||||
percent: int | None = None
|
||||
max_discount_amount: int | None = None
|
||||
min_order_amount: int | None = None
|
||||
|
||||
new_users_only: bool | None = None
|
||||
|
||||
@model_validator(mode="after")
|
||||
def validate_coupon_update(self) -> "CouponUpdate":
|
||||
has_amount = self.amount not in (None, 0)
|
||||
has_days = self.days is not None
|
||||
has_percent = self.percent is not None
|
||||
|
||||
provided = int(has_amount) + int(has_days) + int(has_percent)
|
||||
if provided > 1:
|
||||
raise ValueError("Specify only one of: 'amount', 'days', 'percent'")
|
||||
|
||||
if has_days and self.days is not None and self.days <= 0:
|
||||
raise ValueError("'days' must be > 0")
|
||||
|
||||
if has_percent and self.percent is not None and not (1 <= self.percent <= 100):
|
||||
raise ValueError("'percent' must be between 1 and 100")
|
||||
|
||||
if has_percent:
|
||||
if self.min_order_amount is not None and self.min_order_amount < 0:
|
||||
raise ValueError("'min_order_amount' must be >= 0")
|
||||
if self.max_discount_amount is not None and self.max_discount_amount < 0:
|
||||
raise ValueError("'max_discount_amount' must be >= 0")
|
||||
|
||||
return self
|
||||
|
||||
|
||||
class CouponUsageResponse(BaseModel):
|
||||
coupon_id: int
|
||||
user_id: int
|
||||
used_at: datetime
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
@@ -1,50 +0,0 @@
|
||||
from datetime import datetime
|
||||
|
||||
from pydantic import BaseModel
|
||||
|
||||
|
||||
class GiftBase(BaseModel):
|
||||
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
|
||||
tariff_id: int | None = None
|
||||
|
||||
|
||||
class GiftResponse(GiftBase):
|
||||
gift_id: str
|
||||
created_at: datetime
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
|
||||
class GiftUsageResponse(BaseModel):
|
||||
gift_id: str
|
||||
tg_id: int
|
||||
used_at: datetime
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
|
||||
class GiftUpdate(BaseModel):
|
||||
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
|
||||
tariff_id: int | None = None
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
@@ -1,98 +0,0 @@
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
|
||||
class KeyBase(BaseModel):
|
||||
user_id: int
|
||||
client_id: str
|
||||
tg_id: int | None = None
|
||||
email: str | None = None
|
||||
created_at: int | None = None
|
||||
expiry_time: int
|
||||
key: str | None = None
|
||||
server_id: str | None = None
|
||||
remnawave_link: str | None = None
|
||||
tariff_id: int | None = None
|
||||
is_frozen: bool | None = False
|
||||
alias: str | None = None
|
||||
notified: bool | None = False
|
||||
notified_24h: bool | None = False
|
||||
|
||||
selected_device_limit: int | None = None
|
||||
selected_traffic_limit: int | None = None
|
||||
selected_price_rub: int | None = None
|
||||
|
||||
current_device_limit: int | None = None
|
||||
current_traffic_limit: int | None = None
|
||||
|
||||
|
||||
class KeyResponse(KeyBase):
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
|
||||
class KeyDetailsResponse(BaseModel):
|
||||
key: str | None
|
||||
remnawave_link: str | None
|
||||
server_id: str | None
|
||||
created_at: int | None
|
||||
expiry_time: int | None
|
||||
client_id: str
|
||||
tg_id: int
|
||||
email: str | None
|
||||
is_frozen: bool
|
||||
balance: float
|
||||
alias: str | None
|
||||
expiry_date: str
|
||||
days_left_message: str
|
||||
link: str | None
|
||||
cluster_name: str | None
|
||||
location_name: str | None
|
||||
tariff_id: int | None
|
||||
|
||||
selected_device_limit: int | None = None
|
||||
selected_traffic_limit: int | None = None
|
||||
selected_price_rub: int | None = None
|
||||
|
||||
current_device_limit: int | None = None
|
||||
current_traffic_limit: int | None = None
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
|
||||
class KeyUpdate(BaseModel):
|
||||
email: str | None = None
|
||||
expiry_time: int | None = None
|
||||
key: str | None = None
|
||||
server_id: str | None = None
|
||||
remnawave_link: str | None = None
|
||||
tariff_id: int | None = None
|
||||
is_frozen: bool | None = None
|
||||
alias: str | None = None
|
||||
notified: bool | None = None
|
||||
notified_24h: bool | None = None
|
||||
|
||||
selected_device_limit: int | None = None
|
||||
selected_traffic_limit: int | None = None
|
||||
selected_price_rub: int | None = None
|
||||
|
||||
current_device_limit: int | None = None
|
||||
current_traffic_limit: int | None = None
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
|
||||
class KeyCreateRequest(BaseModel):
|
||||
tg_id: int = Field(..., description="Telegram ID пользователя")
|
||||
cluster_id: str = Field(..., description="Имя кластера или сервера")
|
||||
tariff_id: int = Field(..., description="ID тарифа из базы данных")
|
||||
client_id: str = Field(..., description="UUID клиента (уникальный)")
|
||||
expiry_timestamp: int = Field(..., description="Срок окончания в миллисекундах")
|
||||
|
||||
email: str | None = Field(None, description="Условное имя подписки")
|
||||
alias: str | None = Field(None, description="пользовательское имя")
|
||||
remnawave_link: str | None = Field(None, description="Ссылка на подписку Remnawave")
|
||||
hwid_limit: int | None = Field(None, description="Ограничение по HWID")
|
||||
traffic_limit_bytes: int | None = Field(None, description="Ограничение трафика в байтах")
|
||||
is_trial: bool | None = Field(False, description="Флаг триального ключа")
|
||||
@@ -1,128 +0,0 @@
|
||||
from datetime import datetime
|
||||
|
||||
from pydantic import BaseModel
|
||||
|
||||
|
||||
class PaymentBase(BaseModel):
|
||||
user_id: int
|
||||
tg_id: int | None = None
|
||||
amount: float
|
||||
payment_system: str
|
||||
status: str
|
||||
|
||||
|
||||
class PaymentResponse(PaymentBase):
|
||||
id: int
|
||||
created_at: datetime
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
|
||||
class ReferralResponse(BaseModel):
|
||||
referred_user_id: int
|
||||
referrer_user_id: int
|
||||
reward_issued: bool = False
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
|
||||
class NotificationResponse(BaseModel):
|
||||
tg_id: int
|
||||
notification_type: str
|
||||
last_notification_time: datetime
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
|
||||
class GiftBase(BaseModel):
|
||||
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
|
||||
tariff_id: int | None = None
|
||||
|
||||
|
||||
class GiftResponse(GiftBase):
|
||||
gift_id: str
|
||||
created_at: datetime
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
|
||||
class GiftUsageResponse(BaseModel):
|
||||
gift_id: str
|
||||
tg_id: int
|
||||
used_at: datetime
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
|
||||
class ManualBanResponse(BaseModel):
|
||||
user_id: int
|
||||
tg_id: int | None = None
|
||||
banned_at: datetime
|
||||
reason: str
|
||||
banned_by: int
|
||||
until: datetime | None = None
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
|
||||
class TemporaryDataResponse(BaseModel):
|
||||
user_id: int
|
||||
tg_id: int | None = None
|
||||
state: str
|
||||
data: dict
|
||||
updated_at: datetime
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
|
||||
class BlockedUserResponse(BaseModel):
|
||||
user_id: int
|
||||
tg_id: int | None = None
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
|
||||
class MonthlyStats(BaseModel):
|
||||
month: str
|
||||
registrations: int
|
||||
trials: int
|
||||
new_purchases_count: int
|
||||
new_purchases_amount: float
|
||||
repeat_purchases_count: int
|
||||
repeat_purchases_amount: float
|
||||
|
||||
|
||||
class TrackingSourceResponse(BaseModel):
|
||||
id: int
|
||||
name: str
|
||||
code: str
|
||||
type: str
|
||||
created_by: int
|
||||
created_at: datetime
|
||||
|
||||
registrations: int = 0
|
||||
trials: int = 0
|
||||
payments: int = 0
|
||||
total_amount: float = 0.0
|
||||
|
||||
monthly: list[MonthlyStats] = []
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
@@ -1,10 +0,0 @@
|
||||
from pydantic import BaseModel
|
||||
|
||||
|
||||
class ReferralResponse(BaseModel):
|
||||
referred_user_id: int
|
||||
referrer_user_id: int
|
||||
reward_issued: bool = False
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
@@ -1,35 +0,0 @@
|
||||
from pydantic import BaseModel
|
||||
|
||||
|
||||
class ServerBase(BaseModel):
|
||||
cluster_name: str
|
||||
server_name: str
|
||||
api_url: str
|
||||
subscription_url: str | None = None
|
||||
inbound_id: str
|
||||
panel_type: str
|
||||
max_keys: int | None = None
|
||||
tariff_group: str | None = ""
|
||||
enabled: bool = True
|
||||
|
||||
|
||||
class ServerResponse(ServerBase):
|
||||
id: int
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
|
||||
class ServerUpdate(BaseModel):
|
||||
cluster_name: str | None = None
|
||||
server_name: str | None = None
|
||||
api_url: str | None = None
|
||||
subscription_url: str | None = None
|
||||
inbound_id: str | None = None
|
||||
panel_type: str | None = None
|
||||
max_keys: int | None = None
|
||||
tariff_group: str | None = None
|
||||
enabled: bool | None = None
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
@@ -1,20 +0,0 @@
|
||||
from datetime import datetime
|
||||
from typing import Any
|
||||
|
||||
from pydantic import BaseModel
|
||||
|
||||
|
||||
class SettingUpsert(BaseModel):
|
||||
value: Any | None = None
|
||||
description: str | None = None
|
||||
|
||||
|
||||
class SettingResponse(BaseModel):
|
||||
key: str
|
||||
value: Any | None = None
|
||||
description: str | None = None
|
||||
created_at: datetime | None = None
|
||||
updated_at: datetime | None = None
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
@@ -1,66 +0,0 @@
|
||||
from datetime import datetime
|
||||
from typing import Any
|
||||
|
||||
from pydantic import BaseModel
|
||||
|
||||
|
||||
class TariffBase(BaseModel):
|
||||
name: str
|
||||
group_code: str
|
||||
duration_days: int
|
||||
price_rub: int
|
||||
traffic_limit: int | None = None
|
||||
device_limit: int | None = None
|
||||
is_active: bool = True
|
||||
subgroup_title: str | None = None
|
||||
sort_order: int | None = None
|
||||
vless: bool = False
|
||||
external_squad: str | None = None
|
||||
|
||||
configurable: bool = False
|
||||
|
||||
device_options: list[int] | None = None
|
||||
traffic_options_gb: list[int] | None = None
|
||||
|
||||
device_step_rub: int | None = None
|
||||
device_overrides: dict[str, int] | None = None
|
||||
|
||||
traffic_step_rub: int | None = None
|
||||
traffic_overrides: dict[str, int] | None = None
|
||||
|
||||
|
||||
class TariffResponse(TariffBase):
|
||||
id: int
|
||||
created_at: datetime | None = None
|
||||
updated_at: datetime | None = None
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
|
||||
class TariffUpdate(BaseModel):
|
||||
name: str | None = None
|
||||
group_code: str | None = None
|
||||
duration_days: int | None = None
|
||||
price_rub: int | None = None
|
||||
traffic_limit: int | None = None
|
||||
device_limit: int | None = None
|
||||
is_active: bool | None = None
|
||||
subgroup_title: str | None = None
|
||||
sort_order: int | None = None
|
||||
vless: bool | None = None
|
||||
external_squad: str | None = None
|
||||
|
||||
configurable: bool | None = None
|
||||
|
||||
device_options: list[int] | None = None
|
||||
traffic_options_gb: list[int] | None = None
|
||||
|
||||
device_step_rub: int | None = None
|
||||
device_overrides: dict[str, int] | None = None
|
||||
|
||||
traffic_step_rub: int | None = None
|
||||
traffic_overrides: dict[str, int] | None = None
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
@@ -1,37 +0,0 @@
|
||||
from datetime import datetime
|
||||
|
||||
from pydantic import BaseModel
|
||||
|
||||
|
||||
class UserBase(BaseModel):
|
||||
tg_id: int
|
||||
username: str | None = None
|
||||
first_name: str | None = None
|
||||
last_name: str | None = None
|
||||
language_code: str | None = None
|
||||
is_bot: bool | None = False
|
||||
balance: float | None = 0.0
|
||||
trial: int | None = 0
|
||||
source_code: str | None = None
|
||||
|
||||
|
||||
class UserResponse(UserBase):
|
||||
created_at: datetime | None
|
||||
updated_at: datetime | None
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
|
||||
class UserUpdate(BaseModel):
|
||||
username: str | None = None
|
||||
first_name: str | None = None
|
||||
last_name: str | None = None
|
||||
language_code: str | None = None
|
||||
is_bot: bool | None = None
|
||||
balance: float | None = None
|
||||
trial: int | None = None
|
||||
source_code: str | None = None
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
@@ -1,10 +0,0 @@
|
||||
VERSION = "2.0.0"
|
||||
__all__ = ("router", "VERSION")
|
||||
|
||||
|
||||
def __getattr__(name: str):
|
||||
if name == "router":
|
||||
from api.v2.router import router
|
||||
|
||||
return router
|
||||
raise AttributeError(name)
|
||||
@@ -1,171 +0,0 @@
|
||||
from typing import Any
|
||||
|
||||
from fastapi import APIRouter, Body, Depends, HTTPException, Path, Query
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy.orm.attributes import InstrumentedAttribute
|
||||
|
||||
from api.depends import get_session, verify_identity_admin
|
||||
from api.v1.routes.base_crud import (
|
||||
_apply_user_relationship_loader,
|
||||
cast_identifier_type,
|
||||
normalize_outgoing_object,
|
||||
to_schema,
|
||||
)
|
||||
from database.access.resolution import resolve_user_optional
|
||||
|
||||
|
||||
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(
|
||||
identity=Depends(verify_identity_admin),
|
||||
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(...),
|
||||
identity=Depends(verify_identity_admin),
|
||||
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),
|
||||
identity=Depends(verify_identity_admin),
|
||||
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),
|
||||
identity=Depends(verify_identity_admin),
|
||||
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(...),
|
||||
identity=Depends(verify_identity_admin),
|
||||
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.commit()
|
||||
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),
|
||||
identity=Depends(verify_identity_admin),
|
||||
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.commit()
|
||||
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),
|
||||
identity=Depends(verify_identity_admin),
|
||||
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)
|
||||
await session.commit()
|
||||
return {"detail": f"{model.__name__} deleted"}
|
||||
|
||||
return router
|
||||
|
||||
|
||||
__all__ = ("generate_crud_router", "to_schema", "normalize_outgoing_object", "cast_identifier_type")
|
||||
@@ -1,49 +0,0 @@
|
||||
from fastapi import APIRouter
|
||||
|
||||
from api.v2.routes import (
|
||||
auth,
|
||||
coupons,
|
||||
flows,
|
||||
gifts,
|
||||
identities,
|
||||
keys,
|
||||
management,
|
||||
misc,
|
||||
modules,
|
||||
notifications,
|
||||
partners,
|
||||
payment_links,
|
||||
referrals,
|
||||
root_router,
|
||||
servers,
|
||||
settings,
|
||||
tariffs,
|
||||
users,
|
||||
web,
|
||||
)
|
||||
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
router.include_router(root_router)
|
||||
router.include_router(auth.router, prefix="/api")
|
||||
router.include_router(users.router, prefix="/api/users", tags=["Users"])
|
||||
router.include_router(keys.user_router, prefix="/api/keys", tags=["Keys"])
|
||||
router.include_router(keys.router, prefix="/api/admin/keys", tags=["AdminKeys"])
|
||||
router.include_router(coupons.router, prefix="/api/coupons", tags=["Coupons"])
|
||||
router.include_router(servers.router, prefix="/api/servers", tags=["Servers"])
|
||||
router.include_router(tariffs.public_router, prefix="/api/tariffs", tags=["Tariffs"])
|
||||
router.include_router(tariffs.user_tariff_router, prefix="/api/tariffs", tags=["Tariffs"])
|
||||
router.include_router(tariffs.router, prefix="/api/tariffs", tags=["Tariffs"])
|
||||
router.include_router(gifts.router, prefix="/api/gifts", tags=["Gifts"])
|
||||
router.include_router(referrals.router, prefix="/api/referrals", tags=["Referrals"])
|
||||
router.include_router(partners.router, prefix="/api/partners", tags=["Partners"])
|
||||
router.include_router(payment_links.router, prefix="/api/payment-links", tags=["PaymentLinks"])
|
||||
router.include_router(identities.router, prefix="/api/identities", tags=["Identities"])
|
||||
router.include_router(misc.router, prefix="/api")
|
||||
router.include_router(modules.router, prefix="/api")
|
||||
router.include_router(management.router, prefix="/api/management", tags=["Management"])
|
||||
router.include_router(settings.router, prefix="/api/settings", tags=["Settings"])
|
||||
router.include_router(web.router, prefix="", tags=["Web"])
|
||||
router.include_router(flows.router, prefix="/api", tags=["Flows"])
|
||||
router.include_router(notifications.router, prefix="/api", tags=["Notifications"])
|
||||
@@ -1,9 +0,0 @@
|
||||
__all__ = ("root_router",)
|
||||
|
||||
|
||||
def __getattr__(name: str):
|
||||
if name == "root_router":
|
||||
from api.v2.routes.root import router
|
||||
|
||||
return router
|
||||
raise AttributeError(name)
|
||||
@@ -1,115 +0,0 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import re
|
||||
import uuid
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
|
||||
UPLOAD_DIR = Path("static/web_uploads")
|
||||
DATA_URI_THRESHOLD_BYTES = 2048
|
||||
|
||||
_DATA_URI_RE = re.compile(r"^data:([\w./+-]+);base64,(.+)$", re.DOTALL)
|
||||
|
||||
_MIME_TO_EXT = {
|
||||
"image/png": ".png",
|
||||
"image/jpeg": ".jpg",
|
||||
"image/jpg": ".jpg",
|
||||
"image/gif": ".gif",
|
||||
"image/webp": ".webp",
|
||||
"image/svg+xml": ".svg",
|
||||
"video/mp4": ".mp4",
|
||||
"video/webm": ".webm",
|
||||
}
|
||||
|
||||
|
||||
def _save_data_uri_to_file(data_uri: str) -> str | None:
|
||||
match = _DATA_URI_RE.match(data_uri)
|
||||
if not match:
|
||||
return None
|
||||
mime = match.group(1).strip().lower()
|
||||
payload = match.group(2)
|
||||
ext = _MIME_TO_EXT.get(mime)
|
||||
if not ext:
|
||||
return None
|
||||
try:
|
||||
cleaned = "".join(payload.split())
|
||||
decoded = base64.b64decode(cleaned, validate=False)
|
||||
except Exception:
|
||||
return None
|
||||
if not decoded:
|
||||
return None
|
||||
UPLOAD_DIR.mkdir(parents=True, exist_ok=True)
|
||||
name = f"{uuid.uuid4().hex}{ext}"
|
||||
(UPLOAD_DIR / name).write_bytes(decoded)
|
||||
return f"/api/web/uploads/{name}"
|
||||
|
||||
|
||||
def migrate_json_data_uris(value: Any) -> tuple[Any, int]:
|
||||
replaced = 0
|
||||
|
||||
def walk(node: Any) -> Any:
|
||||
nonlocal replaced
|
||||
if isinstance(node, str):
|
||||
if not node.startswith("data:"):
|
||||
return node
|
||||
if len(node) < DATA_URI_THRESHOLD_BYTES:
|
||||
return node
|
||||
url = _save_data_uri_to_file(node)
|
||||
if url is None:
|
||||
return node
|
||||
replaced += 1
|
||||
return url
|
||||
if isinstance(node, list):
|
||||
return [walk(item) for item in node]
|
||||
if isinstance(node, dict):
|
||||
return {key: walk(item) for key, item in node.items()}
|
||||
return node
|
||||
|
||||
return walk(value), replaced
|
||||
|
||||
|
||||
async def run_startup_data_uri_migration(session: AsyncSession) -> tuple[int, int]:
|
||||
from database.models import (
|
||||
WebBlock,
|
||||
WebPageVariant,
|
||||
WebPageVariantBlock,
|
||||
WebTheme,
|
||||
)
|
||||
|
||||
rows_updated = 0
|
||||
uris_replaced = 0
|
||||
|
||||
for theme in (await session.execute(select(WebTheme))).scalars().all():
|
||||
cleaned, replaced = migrate_json_data_uris(theme.tokens or {})
|
||||
if replaced:
|
||||
theme.tokens = cleaned
|
||||
rows_updated += 1
|
||||
uris_replaced += replaced
|
||||
|
||||
for variant in (await session.execute(select(WebPageVariant))).scalars().all():
|
||||
cleaned, replaced = migrate_json_data_uris(variant.theme_tokens or {})
|
||||
if replaced:
|
||||
variant.theme_tokens = cleaned
|
||||
rows_updated += 1
|
||||
uris_replaced += replaced
|
||||
|
||||
for block in (await session.execute(select(WebBlock))).scalars().all():
|
||||
cleaned, replaced = migrate_json_data_uris(block.data or {})
|
||||
if replaced:
|
||||
block.data = cleaned
|
||||
rows_updated += 1
|
||||
uris_replaced += replaced
|
||||
|
||||
for block in (await session.execute(select(WebPageVariantBlock))).scalars().all():
|
||||
cleaned, replaced = migrate_json_data_uris(block.data or {})
|
||||
if replaced:
|
||||
block.data = cleaned
|
||||
rows_updated += 1
|
||||
uris_replaced += replaced
|
||||
|
||||
return rows_updated, uris_replaced
|
||||
@@ -1,15 +0,0 @@
|
||||
from fastapi import APIRouter
|
||||
|
||||
from api.v2.routes.auth import email_verify, google, link, password, session, telegram, yandex
|
||||
|
||||
|
||||
router = APIRouter(prefix="/auth", tags=["Auth"])
|
||||
router.include_router(password.router)
|
||||
router.include_router(telegram.router)
|
||||
router.include_router(google.router)
|
||||
router.include_router(yandex.router)
|
||||
router.include_router(link.router)
|
||||
router.include_router(email_verify.router)
|
||||
router.include_router(session.router)
|
||||
|
||||
__all__ = ["router"]
|
||||
@@ -1,144 +0,0 @@
|
||||
from fastapi import Request
|
||||
from sqlalchemy import text
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from api.v2.schemas.identities import IdentityResponse, LoginResponse
|
||||
from config import API_TOKEN_TTL_DAYS
|
||||
from logger import logger
|
||||
from utils.referral_codes import encode_partner_code
|
||||
|
||||
|
||||
TOKEN_TTL_HINT = "бессрочно" if API_TOKEN_TTL_DAYS is None else f"{API_TOKEN_TTL_DAYS} дн."
|
||||
TELEGRAM_LOGIN_MAX_AGE = 86400
|
||||
|
||||
|
||||
def build_login_response(identity) -> LoginResponse:
|
||||
return LoginResponse(
|
||||
identity_id=identity.id,
|
||||
identity=IdentityResponse.model_validate(identity),
|
||||
)
|
||||
|
||||
_TRUSTED_PROXY_CIDRS: list[str] = []
|
||||
|
||||
|
||||
def _client_ip(request: Request) -> str:
|
||||
client_host = (request.client.host if request.client else "") or ""
|
||||
forwarded = request.headers.get("x-forwarded-for") or request.headers.get("X-Forwarded-For")
|
||||
if not forwarded:
|
||||
return client_host
|
||||
if not _TRUSTED_PROXY_CIDRS and client_host not in ("127.0.0.1", "::1"):
|
||||
return client_host
|
||||
return forwarded.split(",")[0].strip() or client_host
|
||||
|
||||
|
||||
async def _resolve_partner_snapshot(session: AsyncSession, billing_user_id: int) -> dict[str, object]:
|
||||
partner_feature_enabled = False
|
||||
default_percent = 0.0
|
||||
try:
|
||||
from modules.partner_program import settings as partner_settings
|
||||
|
||||
partner_feature_enabled = True
|
||||
raw_percent = getattr(partner_settings, "PARTNER_BONUS_PERCENTAGES", {}).get(1, 0.0)
|
||||
default_percent = float(raw_percent) * 100.0
|
||||
except Exception:
|
||||
partner_feature_enabled = False
|
||||
default_percent = 0.0
|
||||
payload: dict[str, object] = {
|
||||
"partner_enabled": partner_feature_enabled,
|
||||
"partner_code": "",
|
||||
"partner_balance": 0.0,
|
||||
"partner_percent": default_percent,
|
||||
"partner_percent_custom": False,
|
||||
"partner_referred_total": 0,
|
||||
"partner_referred_paid": 0,
|
||||
"partner_payout_method": None,
|
||||
}
|
||||
try:
|
||||
partner_row = (
|
||||
await session.execute(
|
||||
text(
|
||||
"""
|
||||
SELECT
|
||||
tg_id,
|
||||
COALESCE(partner_balance, 0),
|
||||
partner_percent,
|
||||
COALESCE(partner_percent_custom, false),
|
||||
partner_code,
|
||||
payout_method
|
||||
FROM users
|
||||
WHERE id = :user_id
|
||||
LIMIT 1
|
||||
"""
|
||||
),
|
||||
{"user_id": int(billing_user_id)},
|
||||
)
|
||||
).first()
|
||||
except Exception:
|
||||
partner_row = None
|
||||
if partner_row is None:
|
||||
return payload
|
||||
tg_id = int(partner_row[0]) if partner_row[0] is not None else None
|
||||
balance = float(partner_row[1] or 0.0)
|
||||
percent_raw = partner_row[2]
|
||||
percent_custom = bool(partner_row[3])
|
||||
percent_value = float(percent_raw) if (percent_custom and percent_raw is not None) else float(default_percent)
|
||||
code = str(partner_row[4] or "").strip()
|
||||
if (not code or code.isdigit() or code.startswith("r1_")) and int(billing_user_id) > 0:
|
||||
generated_code = encode_partner_code(int(billing_user_id))
|
||||
code = generated_code
|
||||
try:
|
||||
await session.execute(
|
||||
text("UPDATE users SET partner_code = :code WHERE id = :id"),
|
||||
{"code": generated_code, "id": int(billing_user_id)},
|
||||
)
|
||||
await session.flush()
|
||||
except Exception as e:
|
||||
logger.warning("[Auth] Ошибка сохранения partner_code для billing_user_id={}: {}", billing_user_id, e)
|
||||
payout_method = str(partner_row[5] or "").strip() or None
|
||||
referred_total = 0
|
||||
referred_paid = 0
|
||||
if tg_id is not None:
|
||||
try:
|
||||
referred_total = int(
|
||||
(
|
||||
await session.execute(
|
||||
text("SELECT COUNT(*) FROM partners WHERE partner_tg_id = :tg_id"),
|
||||
{"tg_id": int(tg_id)},
|
||||
)
|
||||
).scalar()
|
||||
or 0
|
||||
)
|
||||
except Exception:
|
||||
referred_total = 0
|
||||
try:
|
||||
referred_paid = int(
|
||||
(
|
||||
await session.execute(
|
||||
text(
|
||||
"SELECT COUNT(DISTINCT pr.joined_tg_id) "
|
||||
"FROM partners pr "
|
||||
"WHERE pr.partner_tg_id = :tg_id "
|
||||
"AND EXISTS ("
|
||||
" SELECT 1 FROM payments pay "
|
||||
" WHERE pay.tg_id = pr.joined_tg_id "
|
||||
" AND lower(pay.status) = 'success'"
|
||||
")"
|
||||
),
|
||||
{"tg_id": int(tg_id)},
|
||||
)
|
||||
).scalar()
|
||||
or 0
|
||||
)
|
||||
except Exception:
|
||||
referred_paid = 0
|
||||
payload.update({
|
||||
"partner_enabled": bool(partner_feature_enabled or code or referred_total > 0 or balance > 0),
|
||||
"partner_code": code,
|
||||
"partner_balance": balance,
|
||||
"partner_percent": percent_value,
|
||||
"partner_percent_custom": percent_custom,
|
||||
"partner_referred_total": referred_total,
|
||||
"partner_referred_paid": referred_paid,
|
||||
"partner_payout_method": payout_method,
|
||||
})
|
||||
return payload
|
||||
@@ -1,44 +0,0 @@
|
||||
"""In-memory rate limit fallback для случаев когда Redis недоступен.
|
||||
|
||||
Используется только когда Redis не ответил — чтобы критичные auth-эндпоинты
|
||||
не теряли защиту при кратковременных Redis-сбоях. Per-process, не шарится
|
||||
между репликами — поэтому на нескольких инстансах лимит будет N*limit.
|
||||
"""
|
||||
|
||||
import time
|
||||
|
||||
from collections import deque
|
||||
from threading import Lock
|
||||
|
||||
|
||||
_BUCKETS: dict[str, deque[float]] = {}
|
||||
_LOCK = Lock()
|
||||
_MAX_KEYS = 10000
|
||||
|
||||
|
||||
def _prune(bucket: deque[float], window_sec: int) -> None:
|
||||
threshold = time.monotonic() - window_sec
|
||||
while bucket and bucket[0] < threshold:
|
||||
bucket.popleft()
|
||||
|
||||
|
||||
def _evict_if_full() -> None:
|
||||
if len(_BUCKETS) < _MAX_KEYS:
|
||||
return
|
||||
now = time.monotonic()
|
||||
dead = [k for k, b in _BUCKETS.items() if not b or b[-1] < now - 3600]
|
||||
for k in dead:
|
||||
del _BUCKETS[k]
|
||||
if len(_BUCKETS) >= _MAX_KEYS:
|
||||
oldest = min(_BUCKETS.keys(), key=lambda k: _BUCKETS[k][0] if _BUCKETS[k] else 0)
|
||||
del _BUCKETS[oldest]
|
||||
|
||||
|
||||
def check_and_increment(key: str, limit: int, window_sec: int) -> int:
|
||||
"""Возвращает текущее значение счётчика после инкремента. Если >= limit — превышение."""
|
||||
with _LOCK:
|
||||
_evict_if_full()
|
||||
bucket = _BUCKETS.setdefault(key, deque())
|
||||
_prune(bucket, window_sec)
|
||||
bucket.append(time.monotonic())
|
||||
return len(bucket)
|
||||
@@ -1,78 +0,0 @@
|
||||
import secrets
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Request
|
||||
from pydantic import (
|
||||
BaseModel,
|
||||
Field as PydanticField,
|
||||
)
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from api.depends import get_session, verify_identity_token
|
||||
from api.v2.routes.auth._common import _client_ip
|
||||
from mail import send_email_verify_code_email, smtp_configured
|
||||
from utils import web_email_verify_code as verify_util
|
||||
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
class VerifyEmailRequest(BaseModel):
|
||||
code: str = PydanticField(..., min_length=1, max_length=10)
|
||||
|
||||
|
||||
@router.post("/send-verify-code")
|
||||
async def send_email_verify_code(
|
||||
request: Request,
|
||||
session: AsyncSession = Depends(get_session),
|
||||
identity=Depends(verify_identity_token),
|
||||
):
|
||||
"""Отправить код подтверждения email. Требует авторизации."""
|
||||
if not smtp_configured():
|
||||
raise HTTPException(status_code=503, detail="Почтовый сервер не настроен")
|
||||
email = (identity.email or "").strip().lower()
|
||||
if not email:
|
||||
raise HTTPException(status_code=400, detail="Email не привязан к аккаунту")
|
||||
if getattr(identity, "email_verified", False):
|
||||
return {"ok": True, "detail": "Email уже подтверждён"}
|
||||
if not await verify_util.redis_ready():
|
||||
raise HTTPException(status_code=503, detail="Сервис временно недоступен")
|
||||
ip = _client_ip(request)
|
||||
if not await verify_util.try_consume_ip_send_budget(ip):
|
||||
raise HTTPException(status_code=429, detail="Слишком много запросов, попробуйте позже")
|
||||
if not await verify_util.try_consume_email_send_budget(email):
|
||||
raise HTTPException(status_code=429, detail="Слишком много запросов на этот email")
|
||||
if not await verify_util.try_acquire_resend_cooldown(email):
|
||||
raise HTTPException(status_code=429, detail="Подождите минуту перед повторной отправкой")
|
||||
code = f"{secrets.randbelow(900000) + 100000}"
|
||||
await verify_util.store_code(email, code)
|
||||
try:
|
||||
await send_email_verify_code_email(email, code)
|
||||
except Exception:
|
||||
await verify_util.delete_code(email)
|
||||
raise HTTPException(status_code=503, detail="Не удалось отправить письмо")
|
||||
return {"ok": True}
|
||||
|
||||
|
||||
@router.post("/verify-email")
|
||||
async def verify_email(
|
||||
body: VerifyEmailRequest,
|
||||
request: Request,
|
||||
session: AsyncSession = Depends(get_session),
|
||||
identity=Depends(verify_identity_token),
|
||||
):
|
||||
"""Подтвердить email по коду."""
|
||||
email = (identity.email or "").strip().lower()
|
||||
if not email:
|
||||
raise HTTPException(status_code=400, detail="Email не привязан к аккаунту")
|
||||
if getattr(identity, "email_verified", False):
|
||||
return {"ok": True, "detail": "Email уже подтверждён"}
|
||||
if not await verify_util.try_consume_verify_budget(email):
|
||||
raise HTTPException(status_code=429, detail="Слишком много попыток, попробуйте позже")
|
||||
if not await verify_util.verify_and_consume_code(email, body.code.strip()):
|
||||
raise HTTPException(status_code=400, detail="Неверный или просроченный код")
|
||||
from sqlalchemy import update
|
||||
|
||||
from database.models import Identity as IdentityModel
|
||||
|
||||
await session.execute(update(IdentityModel).where(IdentityModel.id == identity.id).values(email_verified=True))
|
||||
return {"ok": True}
|
||||
@@ -1,210 +0,0 @@
|
||||
import base64
|
||||
import hashlib
|
||||
import hmac
|
||||
import secrets
|
||||
import time
|
||||
|
||||
from urllib.parse import urlencode
|
||||
|
||||
import httpx
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, Request, Response
|
||||
from fastapi.responses import RedirectResponse
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from api.depends import (
|
||||
bind_identity_actor,
|
||||
get_session,
|
||||
set_auth_cookie,
|
||||
set_is_admin_cookie,
|
||||
)
|
||||
from api.v2.routes.auth._common import _client_ip
|
||||
from database import identities as idb
|
||||
from logger import logger
|
||||
|
||||
|
||||
try:
|
||||
from config import GOOGLE_CLIENT_ID as _GOOGLE_CLIENT_ID
|
||||
except ImportError:
|
||||
_GOOGLE_CLIENT_ID = ""
|
||||
try:
|
||||
from config import GOOGLE_CLIENT_SECRET as _GOOGLE_CLIENT_SECRET
|
||||
except ImportError:
|
||||
_GOOGLE_CLIENT_SECRET = ""
|
||||
try:
|
||||
from config import GOOGLE_REDIRECT_URI as _GOOGLE_REDIRECT_URI
|
||||
except ImportError:
|
||||
_GOOGLE_REDIRECT_URI = ""
|
||||
try:
|
||||
from config import OAUTH_SUCCESS_URI as _OAUTH_SUCCESS_URI
|
||||
except ImportError:
|
||||
_OAUTH_SUCCESS_URI = "/dashboard"
|
||||
try:
|
||||
from config import API_TOKEN as _GOOGLE_STATE_SECRET
|
||||
except ImportError:
|
||||
_GOOGLE_STATE_SECRET = "solo-google-state-fallback"
|
||||
|
||||
|
||||
GOOGLE_AUTH_ENDPOINT = "https://accounts.google.com/o/oauth2/v2/auth"
|
||||
GOOGLE_TOKEN_ENDPOINT = "https://oauth2.googleapis.com/token"
|
||||
GOOGLE_USERINFO_ENDPOINT = "https://openidconnect.googleapis.com/v1/userinfo"
|
||||
STATE_TTL_SECONDS = 600
|
||||
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
def google_configured() -> bool:
|
||||
return bool(_GOOGLE_CLIENT_ID and _GOOGLE_CLIENT_SECRET and _GOOGLE_REDIRECT_URI)
|
||||
|
||||
|
||||
def _sign_state(payload: str) -> str:
|
||||
mac = hmac.new(str(_GOOGLE_STATE_SECRET).encode(), payload.encode(), hashlib.sha256).digest()
|
||||
return base64.urlsafe_b64encode(mac).decode().rstrip("=")
|
||||
|
||||
|
||||
def _make_state(return_to: str) -> str:
|
||||
nonce = secrets.token_urlsafe(16)
|
||||
ts = str(int(time.time()))
|
||||
payload = f"{nonce}.{ts}.{return_to}"
|
||||
sig = _sign_state(payload)
|
||||
raw = f"{payload}.{sig}".encode()
|
||||
return base64.urlsafe_b64encode(raw).decode().rstrip("=")
|
||||
|
||||
|
||||
def _verify_state(state: str) -> str | None:
|
||||
try:
|
||||
padded = state + "=" * (-len(state) % 4)
|
||||
raw = base64.urlsafe_b64decode(padded).decode()
|
||||
except Exception:
|
||||
return None
|
||||
parts = raw.rsplit(".", 1)
|
||||
if len(parts) != 2:
|
||||
return None
|
||||
payload, sig = parts
|
||||
expected = _sign_state(payload)
|
||||
if not hmac.compare_digest(sig, expected):
|
||||
return None
|
||||
chunks = payload.split(".", 2)
|
||||
if len(chunks) != 3:
|
||||
return None
|
||||
_nonce, ts, return_to = chunks
|
||||
try:
|
||||
if int(time.time()) - int(ts) > STATE_TTL_SECONDS:
|
||||
return None
|
||||
except Exception:
|
||||
return None
|
||||
return return_to or _OAUTH_SUCCESS_URI
|
||||
|
||||
|
||||
@router.get("/google/authorize")
|
||||
async def google_authorize(
|
||||
request: Request,
|
||||
return_to: str = Query(default=""),
|
||||
):
|
||||
"""Начинает OAuth-флоу Google: редиректит юзера на Google consent screen."""
|
||||
if not google_configured():
|
||||
raise HTTPException(status_code=503, detail="Google Sign-In не настроен на этом сервере")
|
||||
safe_return = return_to if return_to.startswith("/") else _OAUTH_SUCCESS_URI
|
||||
state = _make_state(safe_return)
|
||||
params = {
|
||||
"client_id": _GOOGLE_CLIENT_ID,
|
||||
"redirect_uri": _GOOGLE_REDIRECT_URI,
|
||||
"response_type": "code",
|
||||
"scope": "openid email profile",
|
||||
"state": state,
|
||||
"access_type": "online",
|
||||
"prompt": "select_account",
|
||||
}
|
||||
url = f"{GOOGLE_AUTH_ENDPOINT}?{urlencode(params)}"
|
||||
logger.info("[Auth] Google authorize: ip={}", _client_ip(request))
|
||||
return RedirectResponse(url, status_code=302)
|
||||
|
||||
|
||||
@router.get("/google/callback")
|
||||
async def google_callback(
|
||||
request: Request,
|
||||
response: Response,
|
||||
code: str = Query(default=""),
|
||||
state: str = Query(default=""),
|
||||
error: str = Query(default=""),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
):
|
||||
"""Коллбек Google: обмен code → token → userinfo → identity."""
|
||||
if not google_configured():
|
||||
raise HTTPException(status_code=503, detail="Google Sign-In не настроен на этом сервере")
|
||||
if error:
|
||||
logger.warning("[Auth] Google callback error: {} ip={}", error, _client_ip(request))
|
||||
return RedirectResponse(f"/login?error=google_{error}", status_code=302)
|
||||
if not code or not state:
|
||||
raise HTTPException(status_code=400, detail="Отсутствует code или state")
|
||||
return_to = _verify_state(state)
|
||||
if return_to is None:
|
||||
logger.warning("[Auth] Google callback: invalid/expired state ip={}", _client_ip(request))
|
||||
raise HTTPException(status_code=400, detail="Неверный или просроченный state")
|
||||
|
||||
async with httpx.AsyncClient(timeout=10.0) as client:
|
||||
try:
|
||||
token_res = await client.post(
|
||||
GOOGLE_TOKEN_ENDPOINT,
|
||||
data={
|
||||
"code": code,
|
||||
"client_id": _GOOGLE_CLIENT_ID,
|
||||
"client_secret": _GOOGLE_CLIENT_SECRET,
|
||||
"redirect_uri": _GOOGLE_REDIRECT_URI,
|
||||
"grant_type": "authorization_code",
|
||||
},
|
||||
headers={"Accept": "application/json"},
|
||||
)
|
||||
except httpx.HTTPError as e:
|
||||
logger.warning("[Auth] Google token exchange network error: {}", e)
|
||||
raise HTTPException(status_code=502, detail="Не удалось связаться с Google") from e
|
||||
if token_res.status_code != 200:
|
||||
logger.warning("[Auth] Google token exchange failed: {} {}", token_res.status_code, token_res.text[:200])
|
||||
raise HTTPException(status_code=401, detail="Google отклонил токен")
|
||||
token_payload = token_res.json()
|
||||
access_token = token_payload.get("access_token")
|
||||
if not access_token:
|
||||
raise HTTPException(status_code=401, detail="Google не вернул access_token")
|
||||
|
||||
try:
|
||||
info_res = await client.get(
|
||||
GOOGLE_USERINFO_ENDPOINT,
|
||||
headers={"Authorization": f"Bearer {access_token}"},
|
||||
)
|
||||
except httpx.HTTPError as e:
|
||||
logger.warning("[Auth] Google userinfo network error: {}", e)
|
||||
raise HTTPException(status_code=502, detail="Не удалось получить профиль Google") from e
|
||||
|
||||
if info_res.status_code != 200:
|
||||
raise HTTPException(status_code=401, detail="Google не вернул профиль")
|
||||
info = info_res.json()
|
||||
google_sub = str(info.get("sub") or "").strip()
|
||||
email = (info.get("email") or "").strip().lower() or None
|
||||
email_verified = bool(info.get("email_verified"))
|
||||
if not google_sub:
|
||||
raise HTTPException(status_code=401, detail="Google не вернул идентификатор пользователя")
|
||||
|
||||
identity = await idb.get_or_create_identity_for_google(
|
||||
session,
|
||||
google_sub=google_sub,
|
||||
email=email if (email and email_verified) else None,
|
||||
)
|
||||
await bind_identity_actor(request, session, identity)
|
||||
token = await idb.issue_token_for_identity(session, identity, request=request)
|
||||
logger.info(
|
||||
"[Auth] Login success: identity={}, google_sub={}, ip={}, method=google",
|
||||
identity.id,
|
||||
google_sub,
|
||||
_client_ip(request),
|
||||
)
|
||||
redirect = RedirectResponse(return_to, status_code=302)
|
||||
set_auth_cookie(redirect, token, request)
|
||||
set_is_admin_cookie(redirect, identity, request)
|
||||
return redirect
|
||||
|
||||
|
||||
@router.get("/google/status")
|
||||
async def google_status():
|
||||
"""Позволяет фронтенду узнать, настроен ли Google-вход на этом сервере."""
|
||||
return {"enabled": google_configured()}
|
||||
@@ -1,124 +0,0 @@
|
||||
import secrets
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Request, Response
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from api.depends import (
|
||||
bind_identity_actor,
|
||||
get_session,
|
||||
set_is_admin_cookie,
|
||||
verify_identity_token,
|
||||
)
|
||||
from api.v2.routes.auth._common import _client_ip
|
||||
from api.v2.schemas.identities import (
|
||||
IdentityResponse,
|
||||
LinkEmailConfirmRequest,
|
||||
LinkEmailSendCodeRequest,
|
||||
)
|
||||
from database import identities as idb
|
||||
from mail import send_email_link_code_email, smtp_configured
|
||||
from utils import web_email_link_code as email_link_code
|
||||
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.post("/link-email/send-code")
|
||||
async def link_email_send_code(
|
||||
body: LinkEmailSendCodeRequest,
|
||||
request: Request,
|
||||
session: AsyncSession = Depends(get_session),
|
||||
identity=Depends(verify_identity_token),
|
||||
):
|
||||
email_norm = email_link_code.normalize_email(body.email)
|
||||
if not email_norm:
|
||||
raise HTTPException(status_code=400, detail="Укажите корректный email")
|
||||
if identity.email and str(identity.email).strip().lower() == email_norm:
|
||||
raise HTTPException(status_code=409, detail="Этот email уже привязан к аккаунту")
|
||||
if not smtp_configured():
|
||||
raise HTTPException(
|
||||
status_code=503,
|
||||
detail="Отправка кода недоступна: почта не настроена на сервере",
|
||||
)
|
||||
if not await email_link_code.redis_ready():
|
||||
raise HTTPException(
|
||||
status_code=503,
|
||||
detail="Сервис временно недоступен. Попробуйте позже.",
|
||||
)
|
||||
existing = await idb.get_identity_by_email(session, email_norm)
|
||||
if existing and existing.id != identity.id:
|
||||
our_tg = identity.tg_id
|
||||
their_tg = existing.tg_id
|
||||
can_merge = their_tg is None or (our_tg is not None and int(their_tg) == int(our_tg))
|
||||
if not can_merge:
|
||||
raise HTTPException(
|
||||
status_code=409,
|
||||
detail="Этот email уже привязан к другому аккаунту",
|
||||
)
|
||||
ip = _client_ip(request)
|
||||
if not await email_link_code.try_consume_ip_budget(ip):
|
||||
raise HTTPException(
|
||||
status_code=429,
|
||||
detail="Слишком много запросов с вашего адреса. Попробуйте позже.",
|
||||
)
|
||||
if not await email_link_code.try_consume_email_send_budget(email_norm):
|
||||
raise HTTPException(
|
||||
status_code=429,
|
||||
detail="Слишком много запросов для этого адреса. Попробуйте позже.",
|
||||
)
|
||||
if not await email_link_code.try_acquire_cooldown(email_norm):
|
||||
raise HTTPException(
|
||||
status_code=429,
|
||||
detail="Код уже отправлен. Подождите перед повторной отправкой.",
|
||||
)
|
||||
code = "".join(secrets.choice("0123456789") for _ in range(6))
|
||||
if not await email_link_code.store_code(email_norm, code):
|
||||
await email_link_code.release_cooldown(email_norm)
|
||||
raise HTTPException(
|
||||
status_code=503,
|
||||
detail="Не удалось сохранить код. Попробуйте позже.",
|
||||
)
|
||||
try:
|
||||
await send_email_link_code_email(email_norm, code)
|
||||
except Exception:
|
||||
await email_link_code.release_cooldown(email_norm)
|
||||
await email_link_code.delete_code(email_norm)
|
||||
raise HTTPException(
|
||||
status_code=503,
|
||||
detail="Не удалось отправить письмо. Попробуйте позже.",
|
||||
) from None
|
||||
return {"ok": True, "message": "Код подтверждения отправлен на почту"}
|
||||
|
||||
|
||||
@router.post("/link-email/confirm", response_model=IdentityResponse)
|
||||
async def link_email_confirm(
|
||||
body: LinkEmailConfirmRequest,
|
||||
request: Request,
|
||||
response: Response,
|
||||
session: AsyncSession = Depends(get_session),
|
||||
identity=Depends(verify_identity_token),
|
||||
):
|
||||
email_norm = email_link_code.normalize_email(body.email)
|
||||
if not email_norm or not body.code or not str(body.code).strip():
|
||||
raise HTTPException(status_code=400, detail="Email и код обязательны")
|
||||
if not await email_link_code.redis_ready():
|
||||
raise HTTPException(
|
||||
status_code=503,
|
||||
detail="Сервис временно недоступен. Попробуйте позже.",
|
||||
)
|
||||
if not await email_link_code.try_consume_email_verify_budget(email_norm):
|
||||
raise HTTPException(
|
||||
status_code=429,
|
||||
detail="Слишком много попыток. Запросите новый код.",
|
||||
)
|
||||
if not await email_link_code.verify_and_consume_code(email_norm, str(body.code).strip()):
|
||||
raise HTTPException(status_code=401, detail="Неверный код или срок действия истёк")
|
||||
result = await idb.attach_email(session, identity.id, email_norm)
|
||||
if not result:
|
||||
raise HTTPException(
|
||||
status_code=409,
|
||||
detail="Этот email уже привязан к другой идентичности",
|
||||
)
|
||||
await bind_identity_actor(request, session, result)
|
||||
set_is_admin_cookie(response, result, request)
|
||||
return IdentityResponse.model_validate(result)
|
||||
@@ -1,430 +0,0 @@
|
||||
import secrets
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Request, Response
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from api.depends import (
|
||||
bind_identity_actor,
|
||||
get_session,
|
||||
set_auth_cookie,
|
||||
set_is_admin_cookie,
|
||||
)
|
||||
from api.v2.routes.auth._common import TOKEN_TTL_HINT, _client_ip, build_login_response
|
||||
from api.v2.schemas.identities import (
|
||||
ConfirmPasswordResetRequest,
|
||||
LoginByCodeRequest,
|
||||
LoginRequest,
|
||||
LoginResponse,
|
||||
RegisterByEmailRequest,
|
||||
RegisterResponse,
|
||||
SendLoginCodeRequest,
|
||||
)
|
||||
from database import (
|
||||
add_referral,
|
||||
get_referral_by_referred_id,
|
||||
identities as idb,
|
||||
)
|
||||
from database.access.resolution import resolve_user_optional
|
||||
from logger import logger
|
||||
from mail import (
|
||||
send_email_verify_code_email,
|
||||
send_login_code_email,
|
||||
send_password_reset_code_email,
|
||||
smtp_configured,
|
||||
)
|
||||
from utils import (
|
||||
web_email_verify_code as email_verify,
|
||||
web_password_reset_code as pwd_reset,
|
||||
)
|
||||
from utils.disposable_emails import is_disposable_email
|
||||
from utils.referral_codes import decode_referral_code
|
||||
from utils.turnstile import turnstile_enabled, verify_turnstile_token
|
||||
from utils.web_login_code import (
|
||||
delete_code,
|
||||
normalize_login_email,
|
||||
redis_ready_for_login_codes,
|
||||
release_resend_cooldown,
|
||||
store_code,
|
||||
try_acquire_resend_cooldown,
|
||||
try_consume_email_send_budget,
|
||||
try_consume_email_verify_budget,
|
||||
try_consume_ip_send_budget,
|
||||
verify_and_consume_code,
|
||||
)
|
||||
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
_RESET_OK_MESSAGE = {
|
||||
"ok": True,
|
||||
"message": "Если для этого адреса есть аккаунт с паролем, мы отправили код. Проверьте почту.",
|
||||
}
|
||||
|
||||
|
||||
@router.post("/register", response_model=RegisterResponse)
|
||||
async def register_by_email(
|
||||
body: RegisterByEmailRequest,
|
||||
request: Request,
|
||||
response: Response,
|
||||
session: AsyncSession = Depends(get_session),
|
||||
):
|
||||
(
|
||||
"""Регистрация по почте и паролю: создаётся идентичность, выдаётся токен. Срок действия токена: """
|
||||
+ TOKEN_TTL_HINT
|
||||
+ "."
|
||||
)
|
||||
ip = _client_ip(request)
|
||||
try:
|
||||
from api.v2.routes.auth._fallback_limiter import check_and_increment
|
||||
from core.redis_cache import cache_incr_checked
|
||||
|
||||
count, redis_ok = await cache_incr_checked(f"register_rate:{ip}", 3600)
|
||||
if not redis_ok:
|
||||
count = check_and_increment(f"register_rate:{ip}", 5, 3600)
|
||||
if count > 5:
|
||||
raise HTTPException(status_code=429, detail="Слишком много регистраций с этого IP. Попробуйте позже.")
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception:
|
||||
pass
|
||||
if turnstile_enabled():
|
||||
if not await verify_turnstile_token(body.turnstile_token, ip):
|
||||
raise HTTPException(status_code=400, detail="Проверка CAPTCHA не пройдена")
|
||||
email = body.email.strip().lower()
|
||||
if not email:
|
||||
raise HTTPException(status_code=400, detail="Email обязателен")
|
||||
if is_disposable_email(email):
|
||||
raise HTTPException(status_code=400, detail="Одноразовые email-адреса не поддерживаются")
|
||||
if not body.password or len(body.password) < 8:
|
||||
raise HTTPException(status_code=400, detail="Пароль минимум 8 символов")
|
||||
existing = await idb.get_identity_by_email(session, email)
|
||||
if existing:
|
||||
raise HTTPException(status_code=409, detail="Идентичность с таким email уже существует")
|
||||
raw_referral = str(body.referral_code or "").strip()
|
||||
if "/referral/" in raw_referral:
|
||||
raw_referral = raw_referral.split("/referral/", 1)[-1]
|
||||
if "start=referral_" in raw_referral:
|
||||
raw_referral = raw_referral.split("start=referral_", 1)[-1]
|
||||
raw_referral = raw_referral.split("?", 1)[0].split("#", 1)[0].strip()
|
||||
referrer_legacy = decode_referral_code(raw_referral)
|
||||
referrer_user = None
|
||||
if body.referral_code and referrer_legacy is None:
|
||||
raise HTTPException(status_code=400, detail="Код приглашения недействителен")
|
||||
if referrer_legacy is not None:
|
||||
referrer_user = await resolve_user_optional(session, referrer_legacy)
|
||||
if referrer_user is None:
|
||||
raise HTTPException(status_code=400, detail="Код приглашения недействителен")
|
||||
identity, token = await idb.create_identity_with_token(
|
||||
session, email=email, password=body.password, request=request
|
||||
)
|
||||
await bind_identity_actor(request, session, identity)
|
||||
billing_user_id = await idb.ensure_billing_user_for_identity(session, identity)
|
||||
if referrer_user is not None and not await get_referral_by_referred_id(session, billing_user_id):
|
||||
await add_referral(session, billing_user_id, referrer_user.id)
|
||||
if referrer_user.tg_id is not None:
|
||||
try:
|
||||
from database.web_notifications import notify_web
|
||||
|
||||
await notify_web(
|
||||
session,
|
||||
tg_id=int(referrer_user.tg_id),
|
||||
type="referral_joined",
|
||||
title="Ваш реферал присоединился",
|
||||
message="Новый пользователь зарегистрировался по вашей реферальной ссылке.",
|
||||
data={"referred_user_id": int(billing_user_id)},
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
if smtp_configured():
|
||||
try:
|
||||
code = f"{secrets.randbelow(900000) + 100000}"
|
||||
await email_verify.store_code(email, code)
|
||||
await send_email_verify_code_email(email, code)
|
||||
except Exception as e:
|
||||
logger.warning("[Auth] Не удалось отправить код подтверждения email при регистрации: {}", e)
|
||||
logger.info("[Auth] Register success: identity={}, email={}, ip={}", identity.id, email, _client_ip(request))
|
||||
set_auth_cookie(response, token, request)
|
||||
set_is_admin_cookie(response, identity, request)
|
||||
return RegisterResponse(identity_id=identity.id)
|
||||
|
||||
|
||||
@router.post("/login", response_model=LoginResponse)
|
||||
async def login(
|
||||
body: LoginRequest,
|
||||
request: Request,
|
||||
response: Response,
|
||||
session: AsyncSession = Depends(get_session),
|
||||
):
|
||||
"""Вход по email и паролю. Возвращает identity_id и новый токен. Срок действия токена: """ + TOKEN_TTL_HINT + "."
|
||||
email = body.email.strip().lower()
|
||||
if not email:
|
||||
raise HTTPException(status_code=400, detail="Email обязателен")
|
||||
ip = _client_ip(request)
|
||||
try:
|
||||
from api.v2.routes.auth._fallback_limiter import check_and_increment
|
||||
from core.redis_cache import cache_get, cache_incr_checked
|
||||
|
||||
lockout_key = f"login_lockout:{email}"
|
||||
locked = await cache_get(lockout_key)
|
||||
if locked:
|
||||
raise HTTPException(status_code=429, detail="Аккаунт временно заблокирован. Попробуйте через 15 минут.")
|
||||
rkey = f"login_pwd_rate:{ip}"
|
||||
count, redis_ok = await cache_incr_checked(rkey, 900)
|
||||
if not redis_ok:
|
||||
count = check_and_increment(rkey, 10, 900)
|
||||
if count > 10:
|
||||
raise HTTPException(status_code=429, detail="Слишком много попыток. Попробуйте позже.")
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.warning("[Auth] Ошибка rate-limit проверки для email-логина: {}", e)
|
||||
result = await idb.login_by_email(session, email, body.password, request=request)
|
||||
if not result:
|
||||
from database.setup.web_admin_bootstrap import ensure_web_admin
|
||||
|
||||
try:
|
||||
await ensure_web_admin(session)
|
||||
await session.flush()
|
||||
result = await idb.login_by_email(session, email, body.password, request=request)
|
||||
except Exception as exc:
|
||||
logger.warning("[Auth] lazy web-admin bootstrap failed: {}", exc)
|
||||
if not result:
|
||||
try:
|
||||
from core.redis_cache import cache_incr, cache_set
|
||||
|
||||
fail_key = f"login_fail:{email}"
|
||||
fails = await cache_incr(fail_key, 900)
|
||||
if fails >= 10:
|
||||
await cache_set(f"login_lockout:{email}", "1", 900)
|
||||
except Exception:
|
||||
pass
|
||||
raise HTTPException(status_code=401, detail="Неверный email или пароль")
|
||||
try:
|
||||
from core.redis_cache import cache_delete
|
||||
|
||||
await cache_delete(f"login_fail:{email}")
|
||||
except Exception:
|
||||
pass
|
||||
identity, token = result
|
||||
await bind_identity_actor(request, session, identity)
|
||||
if getattr(identity, "is_admin", False):
|
||||
from database.site_state import mark_site_initialized
|
||||
|
||||
await mark_site_initialized(session)
|
||||
logger.info("[Auth] Login success: identity={}, email={}, ip={}, method=password", identity.id, email, ip)
|
||||
set_auth_cookie(response, token, request)
|
||||
set_is_admin_cookie(response, identity, request)
|
||||
return build_login_response(identity)
|
||||
|
||||
|
||||
@router.post("/send-login-code")
|
||||
async def send_login_code(
|
||||
body: SendLoginCodeRequest,
|
||||
request: Request,
|
||||
session: AsyncSession = Depends(get_session),
|
||||
):
|
||||
"""Отправить код входа на email (SMTP + Redis)."""
|
||||
ip = _client_ip(request)
|
||||
try:
|
||||
from api.v2.routes.auth._fallback_limiter import check_and_increment
|
||||
from core.redis_cache import cache_incr_checked
|
||||
|
||||
count, redis_ok = await cache_incr_checked(f"send_code_rate:{ip}", 3600)
|
||||
if not redis_ok:
|
||||
count = check_and_increment(f"send_code_rate:{ip}", 10, 3600)
|
||||
if count > 10:
|
||||
raise HTTPException(status_code=429, detail="Слишком много запросов кодов. Попробуйте позже.")
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception:
|
||||
pass
|
||||
if turnstile_enabled():
|
||||
if not await verify_turnstile_token(body.turnstile_token, ip):
|
||||
raise HTTPException(status_code=400, detail="Проверка CAPTCHA не пройдена")
|
||||
email_norm = normalize_login_email(body.email)
|
||||
if not email_norm:
|
||||
raise HTTPException(status_code=400, detail="Email обязателен")
|
||||
if is_disposable_email(email_norm):
|
||||
raise HTTPException(status_code=400, detail="Одноразовые email-адреса не поддерживаются")
|
||||
if not smtp_configured():
|
||||
raise HTTPException(
|
||||
status_code=503,
|
||||
detail="Отправка кода недоступна: почта не настроена на сервере",
|
||||
)
|
||||
if not await redis_ready_for_login_codes():
|
||||
raise HTTPException(
|
||||
status_code=503,
|
||||
detail="Сервис временно недоступен. Попробуйте позже.",
|
||||
)
|
||||
identity = await idb.get_identity_by_email(session, email_norm)
|
||||
if not identity:
|
||||
if not body.allow_register:
|
||||
return {"ok": True, "message": "Код отправлен на почту"}
|
||||
identity = await idb.create_identity(session, email=email_norm)
|
||||
ip = _client_ip(request)
|
||||
if not await try_consume_ip_send_budget(ip):
|
||||
raise HTTPException(
|
||||
status_code=429,
|
||||
detail="Слишком много запросов с вашего адреса. Попробуйте позже.",
|
||||
)
|
||||
if not await try_consume_email_send_budget(email_norm):
|
||||
raise HTTPException(
|
||||
status_code=429,
|
||||
detail="Слишком много запросов для этого адреса. Попробуйте позже.",
|
||||
)
|
||||
if not await try_acquire_resend_cooldown(email_norm):
|
||||
raise HTTPException(
|
||||
status_code=429,
|
||||
detail="Код уже отправлен. Подождите перед повторной отправкой.",
|
||||
)
|
||||
code = "".join(secrets.choice("0123456789") for _ in range(6))
|
||||
if not await store_code(email_norm, code):
|
||||
await release_resend_cooldown(email_norm)
|
||||
raise HTTPException(
|
||||
status_code=503,
|
||||
detail="Не удалось сохранить код. Попробуйте позже.",
|
||||
)
|
||||
try:
|
||||
await send_login_code_email(email_norm, code)
|
||||
except Exception:
|
||||
await release_resend_cooldown(email_norm)
|
||||
await delete_code(email_norm)
|
||||
raise HTTPException(
|
||||
status_code=503,
|
||||
detail="Не удалось отправить письмо. Попробуйте позже.",
|
||||
) from None
|
||||
return {"ok": True, "message": "Код отправлен на почту"}
|
||||
|
||||
|
||||
@router.post("/login-by-code", response_model=LoginResponse)
|
||||
async def login_by_code(
|
||||
body: LoginByCodeRequest,
|
||||
request: Request,
|
||||
response: Response,
|
||||
session: AsyncSession = Depends(get_session),
|
||||
):
|
||||
"""Вход по email и коду из письма."""
|
||||
email_norm = normalize_login_email(body.email)
|
||||
if not email_norm or not body.code or not body.code.strip():
|
||||
raise HTTPException(status_code=400, detail="Email и код обязательны")
|
||||
if not await redis_ready_for_login_codes():
|
||||
raise HTTPException(
|
||||
status_code=503,
|
||||
detail="Сервис временно недоступен. Попробуйте позже.",
|
||||
)
|
||||
if not await try_consume_email_verify_budget(email_norm):
|
||||
raise HTTPException(
|
||||
status_code=429,
|
||||
detail="Слишком много попыток. Запросите новый код.",
|
||||
)
|
||||
if not await verify_and_consume_code(email_norm, body.code.strip()):
|
||||
raise HTTPException(status_code=401, detail="Неверный код или срок действия истёк")
|
||||
identity = await idb.get_identity_by_email(session, email_norm)
|
||||
if not identity:
|
||||
raise HTTPException(status_code=401, detail="Аккаунт не найден")
|
||||
if not getattr(identity, "email_verified", False):
|
||||
from sqlalchemy import update as sa_update
|
||||
|
||||
from database.models import Identity as IdentityModel
|
||||
|
||||
await session.execute(
|
||||
sa_update(IdentityModel).where(IdentityModel.id == identity.id).values(email_verified=True)
|
||||
)
|
||||
await bind_identity_actor(request, session, identity)
|
||||
token = await idb.issue_token_for_identity(session, identity, request=request)
|
||||
if getattr(identity, "is_admin", False):
|
||||
from database.site_state import mark_site_initialized
|
||||
|
||||
await mark_site_initialized(session)
|
||||
logger.info("[Auth] Login success: identity={}, email={}, method=code", identity.id, email_norm)
|
||||
set_auth_cookie(response, token, request)
|
||||
set_is_admin_cookie(response, identity, request)
|
||||
return build_login_response(identity)
|
||||
|
||||
|
||||
@router.post("/request-password-reset")
|
||||
async def request_password_reset(
|
||||
body: SendLoginCodeRequest,
|
||||
request: Request,
|
||||
session: AsyncSession = Depends(get_session),
|
||||
):
|
||||
email_norm = normalize_login_email(body.email)
|
||||
if not email_norm:
|
||||
raise HTTPException(status_code=400, detail="Email обязателен")
|
||||
if not smtp_configured() or not await pwd_reset.redis_ready():
|
||||
return _RESET_OK_MESSAGE
|
||||
identity = await idb.get_identity_by_email(session, email_norm)
|
||||
if not identity or not identity.password_hash:
|
||||
return _RESET_OK_MESSAGE
|
||||
ip = _client_ip(request)
|
||||
if not await pwd_reset.try_consume_ip_budget(ip):
|
||||
raise HTTPException(
|
||||
status_code=429,
|
||||
detail="Слишком много запросов с вашего адреса. Попробуйте позже.",
|
||||
)
|
||||
if not await pwd_reset.try_consume_email_send_budget(email_norm):
|
||||
raise HTTPException(
|
||||
status_code=429,
|
||||
detail="Слишком много запросов для этого адреса. Попробуйте позже.",
|
||||
)
|
||||
if not await pwd_reset.try_acquire_cooldown(email_norm):
|
||||
raise HTTPException(
|
||||
status_code=429,
|
||||
detail="Код уже отправлен. Подождите перед повторной отправкой.",
|
||||
)
|
||||
code = "".join(secrets.choice("0123456789") for _ in range(6))
|
||||
if not await pwd_reset.store_code(email_norm, code):
|
||||
await pwd_reset.release_cooldown(email_norm)
|
||||
raise HTTPException(
|
||||
status_code=503,
|
||||
detail="Не удалось сохранить код. Попробуйте позже.",
|
||||
)
|
||||
try:
|
||||
await send_password_reset_code_email(email_norm, code)
|
||||
except Exception:
|
||||
await pwd_reset.release_cooldown(email_norm)
|
||||
await pwd_reset.delete_code(email_norm)
|
||||
raise HTTPException(
|
||||
status_code=503,
|
||||
detail="Не удалось отправить письмо. Попробуйте позже.",
|
||||
) from None
|
||||
return _RESET_OK_MESSAGE
|
||||
|
||||
|
||||
@router.post("/confirm-password-reset", response_model=LoginResponse)
|
||||
async def confirm_password_reset(
|
||||
body: ConfirmPasswordResetRequest,
|
||||
request: Request,
|
||||
response: Response,
|
||||
session: AsyncSession = Depends(get_session),
|
||||
):
|
||||
email_norm = normalize_login_email(body.email)
|
||||
if not email_norm or not body.code or not body.code.strip():
|
||||
raise HTTPException(status_code=400, detail="Email и код обязательны")
|
||||
if body.password != body.password_confirm:
|
||||
raise HTTPException(status_code=400, detail="Пароли не совпадают")
|
||||
if len(body.password) < 8:
|
||||
raise HTTPException(status_code=400, detail="Пароль минимум 8 символов")
|
||||
if not await pwd_reset.redis_ready():
|
||||
raise HTTPException(
|
||||
status_code=503,
|
||||
detail="Сервис временно недоступен. Попробуйте позже.",
|
||||
)
|
||||
if not await pwd_reset.try_consume_email_verify_budget(email_norm):
|
||||
raise HTTPException(
|
||||
status_code=429,
|
||||
detail="Слишком много попыток. Запросите новый код.",
|
||||
)
|
||||
if not await pwd_reset.verify_and_consume_code(email_norm, body.code.strip()):
|
||||
raise HTTPException(status_code=401, detail="Неверный код или срок действия истёк")
|
||||
identity = await idb.get_identity_by_email(session, email_norm)
|
||||
if not identity:
|
||||
raise HTTPException(status_code=400, detail="Аккаунт не найден")
|
||||
updated = await idb.set_password_for_identity(session, identity.id, body.password)
|
||||
if not updated:
|
||||
raise HTTPException(status_code=400, detail="Не удалось обновить пароль")
|
||||
await bind_identity_actor(request, session, updated)
|
||||
token = await idb.issue_token_for_identity(session, updated, request=request)
|
||||
set_auth_cookie(response, token, request)
|
||||
set_is_admin_cookie(response, updated, request)
|
||||
return build_login_response(updated)
|
||||
@@ -1,601 +0,0 @@
|
||||
import re
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Path, Request, Response
|
||||
from fastapi.responses import HTMLResponse
|
||||
from pydantic import BaseModel
|
||||
from sqlalchemy import String, func, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from api.depends import (
|
||||
bind_identity_actor,
|
||||
clear_auth_cookie,
|
||||
get_request_actor,
|
||||
get_session,
|
||||
hash_token,
|
||||
verify_identity_token,
|
||||
)
|
||||
from api.depends import AUTH_COOKIE_NAME
|
||||
from api.v2.routes.auth._common import _resolve_partner_snapshot
|
||||
from api.v2.schemas.identities import (
|
||||
ChangePasswordRequest,
|
||||
IdentityResponse,
|
||||
IdentitySessionItem,
|
||||
IdentitySessionsResponse,
|
||||
SetPasswordRequest,
|
||||
)
|
||||
from api.v2.schemas.web_public import (
|
||||
AccountSearchHit,
|
||||
AccountSearchResponse,
|
||||
AccountSummaryResponse,
|
||||
)
|
||||
from database import (
|
||||
get_balance,
|
||||
get_keys,
|
||||
get_trial,
|
||||
identities as idb,
|
||||
identity_sessions as idsess,
|
||||
)
|
||||
from database.models import CouponUsage, Gift, GiftUsage, IdentityNotifPref, Key, Payment, WebNotification
|
||||
from database.referrals import get_referral_stats
|
||||
from database.web_notifications import count_unread_for_identity
|
||||
from utils.referral_codes import encode_referral_code
|
||||
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.get("/me", response_model=IdentityResponse)
|
||||
async def me(
|
||||
identity=Depends(verify_identity_token),
|
||||
):
|
||||
"""Текущая идентичность по HttpOnly cookie `auth_token`."""
|
||||
return IdentityResponse.model_validate(identity)
|
||||
|
||||
|
||||
def _current_token_hash(request: Request) -> str | None:
|
||||
raw = request.cookies.get(AUTH_COOKIE_NAME)
|
||||
if not raw or not raw.strip():
|
||||
return None
|
||||
return hash_token(raw.strip())
|
||||
|
||||
|
||||
@router.get("/sessions", response_model=IdentitySessionsResponse)
|
||||
async def list_my_sessions(
|
||||
request: Request,
|
||||
session: AsyncSession = Depends(get_session),
|
||||
identity=Depends(verify_identity_token),
|
||||
):
|
||||
"""Возвращает активные сессии текущей identity (все устройства)."""
|
||||
current_hash = _current_token_hash(request)
|
||||
rows = await idsess.list_sessions_for_identity(session, identity.id)
|
||||
items = [
|
||||
IdentitySessionItem(
|
||||
id=row.id,
|
||||
device_label=row.device_label,
|
||||
ip=row.ip,
|
||||
created_at=row.created_at,
|
||||
last_seen_at=row.last_seen_at,
|
||||
expires_at=row.expires_at,
|
||||
is_current=bool(current_hash and row.token_hash == current_hash),
|
||||
)
|
||||
for row in rows
|
||||
]
|
||||
return IdentitySessionsResponse(sessions=items)
|
||||
|
||||
|
||||
@router.delete("/sessions/{session_id}")
|
||||
async def revoke_my_session(
|
||||
session_id: str,
|
||||
request: Request,
|
||||
response: Response,
|
||||
session: AsyncSession = Depends(get_session),
|
||||
identity=Depends(verify_identity_token),
|
||||
):
|
||||
"""Удаляет одну сессию текущей identity. Если удалена текущая — очищаем cookie."""
|
||||
ok = await idsess.delete_session_by_id(
|
||||
session, session_id=session_id, identity_id=identity.id
|
||||
)
|
||||
if not ok:
|
||||
raise HTTPException(status_code=404, detail="Сессия не найдена")
|
||||
current_hash = _current_token_hash(request)
|
||||
rows = await idsess.list_sessions_for_identity(session, identity.id)
|
||||
if current_hash and not any(r.token_hash == current_hash for r in rows):
|
||||
clear_auth_cookie(response, request)
|
||||
return {"ok": True}
|
||||
|
||||
|
||||
@router.post("/sessions/revoke-others")
|
||||
async def revoke_other_sessions(
|
||||
request: Request,
|
||||
session: AsyncSession = Depends(get_session),
|
||||
identity=Depends(verify_identity_token),
|
||||
):
|
||||
"""Удаляет все сессии текущей identity кроме текущей."""
|
||||
current_hash = _current_token_hash(request)
|
||||
if not current_hash:
|
||||
raise HTTPException(status_code=400, detail="Текущая сессия не определена")
|
||||
removed = await idsess.delete_other_sessions(
|
||||
session, identity_id=identity.id, keep_token_hash=current_hash
|
||||
)
|
||||
return {"ok": True, "removed": removed}
|
||||
|
||||
|
||||
@router.post("/logout")
|
||||
async def logout(
|
||||
request: Request,
|
||||
response: Response,
|
||||
session: AsyncSession = Depends(get_session),
|
||||
):
|
||||
"""Удаляет текущую сессию из БД и очищает auth cookie. Не требует валидной сессии."""
|
||||
raw = request.cookies.get(AUTH_COOKIE_NAME)
|
||||
if raw and raw.strip():
|
||||
try:
|
||||
await idsess.delete_session_by_token_hash(session, hash_token(raw.strip()))
|
||||
except Exception:
|
||||
pass
|
||||
clear_auth_cookie(response, request)
|
||||
return {"ok": True}
|
||||
|
||||
|
||||
@router.post("/me/onboarding/complete", response_model=IdentityResponse)
|
||||
async def onboarding_complete(
|
||||
session: AsyncSession = Depends(get_session),
|
||||
identity=Depends(verify_identity_token),
|
||||
):
|
||||
"""Отмечает, что админ прошёл/скипнул онбординг-тур."""
|
||||
from datetime import datetime as _dt
|
||||
|
||||
if identity.onboarding_completed_at is None:
|
||||
identity.onboarding_completed_at = _dt.utcnow()
|
||||
return IdentityResponse.model_validate(identity)
|
||||
|
||||
|
||||
@router.post("/me/onboarding/reset", response_model=IdentityResponse)
|
||||
async def onboarding_reset(
|
||||
session: AsyncSession = Depends(get_session),
|
||||
identity=Depends(verify_identity_token),
|
||||
):
|
||||
"""Сбрасывает флаг онбординга — туториал запустится снова."""
|
||||
identity.onboarding_completed_at = None
|
||||
identity.onboarding_stage = "landing"
|
||||
return IdentityResponse.model_validate(identity)
|
||||
|
||||
|
||||
_ONBOARDING_STAGES = {"landing", "header", "cabinet", "flow", "elements", "done"}
|
||||
|
||||
|
||||
@router.post("/me/onboarding/stage", response_model=IdentityResponse)
|
||||
async def onboarding_set_stage(
|
||||
body: dict,
|
||||
session: AsyncSession = Depends(get_session),
|
||||
identity=Depends(verify_identity_token),
|
||||
):
|
||||
"""Переводит админа на указанный этап онбординга."""
|
||||
from datetime import datetime as _dt
|
||||
|
||||
stage = str(body.get("stage") or "").strip()
|
||||
if stage not in _ONBOARDING_STAGES:
|
||||
raise HTTPException(status_code=400, detail="Неизвестный этап онбординга")
|
||||
identity.onboarding_stage = stage
|
||||
if stage == "done" and identity.onboarding_completed_at is None:
|
||||
identity.onboarding_completed_at = _dt.utcnow()
|
||||
return IdentityResponse.model_validate(identity)
|
||||
|
||||
|
||||
@router.get("/summary", response_model=AccountSummaryResponse)
|
||||
async def auth_summary(
|
||||
request: Request,
|
||||
session: AsyncSession = Depends(get_session),
|
||||
identity=Depends(verify_identity_token),
|
||||
):
|
||||
actor = get_request_actor(request)
|
||||
billing_user_id = actor.billing_user_id if actor and actor.billing_user_id is not None else None
|
||||
if billing_user_id is None:
|
||||
billing_user_id = await idb.ensure_billing_user_for_identity(session, identity)
|
||||
balance = float(await get_balance(session, billing_user_id))
|
||||
trial_status = await get_trial(session, billing_user_id)
|
||||
keys = await get_keys(session, billing_user_id)
|
||||
keys_total = len(keys) if keys else 0
|
||||
gifts_sent_r = await session.execute(
|
||||
select(func.count()).select_from(Gift).where(Gift.sender_user_id == billing_user_id)
|
||||
)
|
||||
gifts_sent = gifts_sent_r.scalar_one() or 0
|
||||
gifts_claimed_r = await session.execute(
|
||||
select(func.count()).select_from(GiftUsage).where(GiftUsage.user_id == billing_user_id)
|
||||
)
|
||||
gifts_claimed = gifts_claimed_r.scalar_one() or 0
|
||||
coupons_r = await session.execute(
|
||||
select(func.count()).select_from(CouponUsage).where(CouponUsage.user_id == billing_user_id)
|
||||
)
|
||||
coupons_used = coupons_r.scalar_one() or 0
|
||||
ref = await get_referral_stats(session, billing_user_id)
|
||||
partner = await _resolve_partner_snapshot(session, int(billing_user_id))
|
||||
unread_notifications = await count_unread_for_identity(session, identity.id)
|
||||
return AccountSummaryResponse(
|
||||
identity_id=identity.id,
|
||||
email=identity.email,
|
||||
tg_id=identity.tg_id,
|
||||
linked_telegram=identity.tg_id is not None,
|
||||
created_at=identity.created_at.isoformat() if identity.created_at else None,
|
||||
password_set=bool(identity.password_set),
|
||||
referral_code=encode_referral_code(int(billing_user_id)),
|
||||
balance=balance,
|
||||
trial_status=int(trial_status),
|
||||
keys_total=keys_total,
|
||||
referrals_total=int(ref.get("total_referrals") or 0),
|
||||
referrals_active=int(ref.get("active_referrals") or 0),
|
||||
referral_bonus_total=float(ref.get("total_referral_bonus") or 0),
|
||||
gifts_sent=int(gifts_sent),
|
||||
gifts_claimed=int(gifts_claimed),
|
||||
coupons_used=int(coupons_used),
|
||||
partner_enabled=bool(partner.get("partner_enabled", False)),
|
||||
partner_code=str(partner.get("partner_code") or ""),
|
||||
partner_balance=float(partner.get("partner_balance") or 0.0),
|
||||
partner_percent=float(partner.get("partner_percent") or 0.0),
|
||||
partner_percent_custom=bool(partner.get("partner_percent_custom", False)),
|
||||
partner_referred_total=int(partner.get("partner_referred_total") or 0),
|
||||
partner_referred_paid=int(partner.get("partner_referred_paid") or 0),
|
||||
partner_payout_method=partner.get("partner_payout_method"),
|
||||
unread_notifications=int(unread_notifications),
|
||||
)
|
||||
|
||||
|
||||
class MyPaymentItem(BaseModel):
|
||||
id: int
|
||||
payment_id: str | None
|
||||
amount: float
|
||||
currency: str
|
||||
status: str
|
||||
provider: str
|
||||
created_at: str | None
|
||||
purpose: str | None
|
||||
|
||||
|
||||
class MyPaymentsResponse(BaseModel):
|
||||
ok: bool = True
|
||||
payments: list[MyPaymentItem]
|
||||
|
||||
|
||||
@router.get("/me/payments", response_model=MyPaymentsResponse)
|
||||
async def my_payments(
|
||||
request: Request,
|
||||
session: AsyncSession = Depends(get_session),
|
||||
identity=Depends(verify_identity_token),
|
||||
limit: int = 50,
|
||||
):
|
||||
"""История платежей текущего юзера. Привязка через Identity → User → Payment."""
|
||||
actor = get_request_actor(request)
|
||||
billing_user_id = actor.billing_user_id if actor and actor.billing_user_id is not None else None
|
||||
if billing_user_id is None:
|
||||
billing_user_id = await idb.ensure_billing_user_for_identity(session, identity)
|
||||
if billing_user_id is None:
|
||||
return MyPaymentsResponse(ok=True, payments=[])
|
||||
safe_limit = max(1, min(200, int(limit) if limit else 50))
|
||||
rows = await session.execute(
|
||||
select(Payment)
|
||||
.where(Payment.user_id == billing_user_id)
|
||||
.order_by(Payment.created_at.desc())
|
||||
.limit(safe_limit)
|
||||
)
|
||||
payments = rows.scalars().all()
|
||||
items: list[MyPaymentItem] = []
|
||||
for p in payments:
|
||||
meta = p.metadata_ if isinstance(p.metadata_, dict) else None
|
||||
purpose = None
|
||||
if meta:
|
||||
purpose = meta.get("purpose") or meta.get("description") or meta.get("tariff_name")
|
||||
if purpose is not None:
|
||||
purpose = str(purpose)
|
||||
items.append(
|
||||
MyPaymentItem(
|
||||
id=int(p.id),
|
||||
payment_id=str(p.payment_id) if p.payment_id else None,
|
||||
amount=float(p.amount or 0),
|
||||
currency=str(p.currency or "RUB"),
|
||||
status=str(p.status or ""),
|
||||
provider=str(p.payment_system or ""),
|
||||
created_at=p.created_at.isoformat() if p.created_at else None,
|
||||
purpose=purpose,
|
||||
)
|
||||
)
|
||||
return MyPaymentsResponse(ok=True, payments=items)
|
||||
|
||||
|
||||
def _esc(value: object) -> str:
|
||||
s = "" if value is None else str(value)
|
||||
return (
|
||||
s.replace("&", "&")
|
||||
.replace("<", "<")
|
||||
.replace(">", ">")
|
||||
.replace('"', """)
|
||||
.replace("'", "'")
|
||||
)
|
||||
|
||||
|
||||
@router.get("/me/payments/{payment_id}/invoice", response_class=HTMLResponse)
|
||||
async def get_my_payment_invoice(
|
||||
payment_id: int = Path(..., ge=1),
|
||||
request: Request = None, # type: ignore[assignment]
|
||||
session: AsyncSession = Depends(get_session),
|
||||
identity=Depends(verify_identity_token),
|
||||
):
|
||||
"""HTML-инвойс по конкретному платежу. Браузер может сохранить как PDF (Cmd+P → Save as PDF)."""
|
||||
actor = get_request_actor(request) if request is not None else None
|
||||
billing_user_id = actor.billing_user_id if actor and actor.billing_user_id is not None else None
|
||||
if billing_user_id is None:
|
||||
billing_user_id = await idb.ensure_billing_user_for_identity(session, identity)
|
||||
if billing_user_id is None:
|
||||
raise HTTPException(status_code=404, detail="Платёж не найден")
|
||||
payment = (
|
||||
await session.execute(
|
||||
select(Payment).where(Payment.id == payment_id, Payment.user_id == billing_user_id).limit(1)
|
||||
)
|
||||
).scalar_one_or_none()
|
||||
if payment is None:
|
||||
raise HTTPException(status_code=404, detail="Платёж не найден")
|
||||
meta = payment.metadata_ if isinstance(payment.metadata_, dict) else {}
|
||||
purpose = ""
|
||||
if meta:
|
||||
v = meta.get("purpose") or meta.get("description") or meta.get("tariff_name")
|
||||
if v is not None:
|
||||
purpose = str(v)
|
||||
created = payment.created_at.strftime("%d.%m.%Y %H:%M") if payment.created_at else "—"
|
||||
amount_value = float(payment.amount or 0)
|
||||
currency = str(payment.currency or "RUB").upper()
|
||||
status_raw = str(payment.status or "")
|
||||
status_norm = status_raw.lower()
|
||||
status_label = "ОПЛАЧЕН" if status_norm in {"completed", "success", "paid"} else "ОЖИДАЕТ" if status_norm in {"pending", "processing"} else "ОТКЛОНЁН"
|
||||
provider = str(payment.payment_system or "").upper() or "—"
|
||||
user_label = identity.email or (f"tg · {identity.tg_id}" if identity.tg_id else identity.id)
|
||||
payment_identifier = str(payment.payment_id).strip() if payment.payment_id else ""
|
||||
title_suffix = payment_identifier if payment_identifier else f"#{payment.id}"
|
||||
html = f"""<!DOCTYPE html>
|
||||
<html lang=\"ru\">
|
||||
<head>
|
||||
<meta charset=\"utf-8\" />
|
||||
<title>Квитанция {_esc(title_suffix)}</title>
|
||||
<style>
|
||||
@page {{ size: A4; margin: 18mm; }}
|
||||
body {{ font-family: 'JetBrains Mono', ui-monospace, monospace; color: #111; background: #fff; max-width: 720px; margin: 0 auto; padding: 24px; }}
|
||||
h1 {{ font-size: 24px; letter-spacing: -0.02em; margin: 0 0 4px; text-transform: uppercase; }}
|
||||
.sub {{ color: #888; font-size: 11px; letter-spacing: 0.18em; text-transform: uppercase; margin-bottom: 32px; }}
|
||||
table {{ width: 100%; border-collapse: collapse; font-size: 13px; }}
|
||||
td {{ padding: 11px 0; border-bottom: 1px dashed #ddd; vertical-align: top; }}
|
||||
td.k {{ color: #888; width: 35%; letter-spacing: 0.08em; text-transform: uppercase; font-size: 11px; }}
|
||||
td.v {{ font-weight: 600; }}
|
||||
.amount {{ font-size: 32px; font-weight: 800; letter-spacing: -0.02em; margin: 24px 0 8px; }}
|
||||
.badge {{ display: inline-block; padding: 4px 10px; border: 1px solid #111; font-size: 11px; letter-spacing: 0.14em; text-transform: uppercase; }}
|
||||
.footer {{ margin-top: 48px; font-size: 10px; color: #aaa; letter-spacing: 0.12em; text-transform: uppercase; text-align: center; }}
|
||||
@media print {{ .no-print {{ display: none; }} }}
|
||||
.print-btn {{ position: fixed; top: 16px; right: 16px; padding: 10px 16px; background: #111; color: #fff; border: 0; cursor: pointer; font-family: inherit; font-size: 12px; letter-spacing: 0.1em; text-transform: uppercase; }}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<button class=\"print-btn no-print\" onclick=\"window.print()\">Сохранить PDF</button>
|
||||
<h1>Квитанция {_esc(title_suffix)}</h1>
|
||||
<div class=\"sub\">// {_esc(created)}</div>
|
||||
<div class=\"amount\">{amount_value:,.2f} {_esc(currency)}</div>
|
||||
<span class=\"badge\">{_esc(status_label)}</span>
|
||||
<table>
|
||||
<tr><td class=\"k\">Назначение</td><td class=\"v\">{_esc(purpose) or "—"}</td></tr>
|
||||
<tr><td class=\"k\">Провайдер</td><td class=\"v\">{_esc(provider)}</td></tr>
|
||||
<tr><td class=\"k\">Дата</td><td class=\"v\">{_esc(created)}</td></tr>
|
||||
<tr><td class=\"k\">Получатель</td><td class=\"v\">{_esc(user_label)}</td></tr>
|
||||
<tr><td class=\"k\">Идентификатор платежа</td><td class=\"v\" style=\"font-size:11px;color:#666\">{_esc(payment_identifier) if payment_identifier else "—"}</td></tr>
|
||||
</table>
|
||||
<div class=\"footer\">Документ сгенерирован автоматически. Не требует подписи и печати.</div>
|
||||
</body>
|
||||
</html>"""
|
||||
return HTMLResponse(content=html, status_code=200)
|
||||
|
||||
|
||||
class NotifChannelPref(BaseModel):
|
||||
channel: str
|
||||
enabled: bool
|
||||
|
||||
|
||||
class NotifChannelPrefsResponse(BaseModel):
|
||||
ok: bool = True
|
||||
channels: list[NotifChannelPref]
|
||||
|
||||
|
||||
class NotifChannelPrefsUpdateRequest(BaseModel):
|
||||
channels: list[NotifChannelPref]
|
||||
|
||||
|
||||
_NOTIF_CHANNEL_RE = re.compile(r"^[a-zA-Z0-9_-]{1,32}$")
|
||||
|
||||
|
||||
@router.get("/me/notification-prefs", response_model=NotifChannelPrefsResponse)
|
||||
async def get_my_notification_prefs(
|
||||
session: AsyncSession = Depends(get_session),
|
||||
identity=Depends(verify_identity_token),
|
||||
):
|
||||
rows = (
|
||||
await session.execute(
|
||||
select(IdentityNotifPref).where(IdentityNotifPref.identity_id == identity.id)
|
||||
)
|
||||
).scalars().all()
|
||||
return NotifChannelPrefsResponse(
|
||||
ok=True,
|
||||
channels=[NotifChannelPref(channel=str(r.channel), enabled=bool(r.enabled)) for r in rows],
|
||||
)
|
||||
|
||||
|
||||
@router.put("/me/notification-prefs", response_model=NotifChannelPrefsResponse)
|
||||
async def set_my_notification_prefs(
|
||||
body: NotifChannelPrefsUpdateRequest,
|
||||
session: AsyncSession = Depends(get_session),
|
||||
identity=Depends(verify_identity_token),
|
||||
):
|
||||
for entry in body.channels:
|
||||
channel = str(entry.channel or "").strip()
|
||||
if not channel or not _NOTIF_CHANNEL_RE.match(channel):
|
||||
raise HTTPException(status_code=422, detail=f"Некорректный канал: {channel!r}")
|
||||
existing = (
|
||||
await session.execute(
|
||||
select(IdentityNotifPref).where(
|
||||
IdentityNotifPref.identity_id == identity.id,
|
||||
IdentityNotifPref.channel == channel,
|
||||
)
|
||||
)
|
||||
).scalar_one_or_none()
|
||||
if existing is None:
|
||||
session.add(
|
||||
IdentityNotifPref(identity_id=identity.id, channel=channel, enabled=bool(entry.enabled))
|
||||
)
|
||||
else:
|
||||
existing.enabled = bool(entry.enabled)
|
||||
await session.flush()
|
||||
rows = (
|
||||
await session.execute(
|
||||
select(IdentityNotifPref).where(IdentityNotifPref.identity_id == identity.id)
|
||||
)
|
||||
).scalars().all()
|
||||
return NotifChannelPrefsResponse(
|
||||
ok=True,
|
||||
channels=[NotifChannelPref(channel=str(r.channel), enabled=bool(r.enabled)) for r in rows],
|
||||
)
|
||||
|
||||
|
||||
@router.get("/me/search", response_model=AccountSearchResponse)
|
||||
async def my_search(
|
||||
q: str,
|
||||
request: Request,
|
||||
session: AsyncSession = Depends(get_session),
|
||||
identity=Depends(verify_identity_token),
|
||||
limit: int = 8,
|
||||
):
|
||||
"""Поиск по подпискам, платежам, уведомлениям текущего user'а. Простое ILIKE."""
|
||||
query_raw = (q or "").strip()
|
||||
if len(query_raw) < 2:
|
||||
return AccountSearchResponse(query=query_raw, hits=[], total=0)
|
||||
actor = get_request_actor(request)
|
||||
billing_user_id = actor.billing_user_id if actor and actor.billing_user_id is not None else None
|
||||
if billing_user_id is None:
|
||||
billing_user_id = await idb.ensure_billing_user_for_identity(session, identity)
|
||||
if billing_user_id is None:
|
||||
return AccountSearchResponse(query=query_raw, hits=[], total=0)
|
||||
safe_limit = max(1, min(20, int(limit) if limit else 8))
|
||||
pattern = f"%{query_raw.lower()}%"
|
||||
hits: list[AccountSearchHit] = []
|
||||
|
||||
# Keys: alias / email / server_id
|
||||
keys_rows = (
|
||||
await session.execute(
|
||||
select(Key)
|
||||
.where(Key.user_id == billing_user_id)
|
||||
.where(
|
||||
func.lower(func.coalesce(Key.alias, ""))
|
||||
.like(pattern)
|
||||
| func.lower(func.coalesce(Key.email, "")).like(pattern)
|
||||
| func.lower(func.coalesce(Key.server_id, "")).like(pattern)
|
||||
| func.lower(func.coalesce(Key.client_id, "")).like(pattern)
|
||||
)
|
||||
.limit(safe_limit)
|
||||
)
|
||||
).scalars().all()
|
||||
for k in keys_rows:
|
||||
label = (k.alias or k.email or k.client_id or "").strip() or "—"
|
||||
sublabel = (k.server_id or "").strip() or "—"
|
||||
hits.append(AccountSearchHit(kind="subscription", label=label, sublabel=sublabel, href="/dashboard/keys", meta=str(k.client_id)))
|
||||
|
||||
# Payments: provider / metadata.purpose
|
||||
payments_rows = (
|
||||
await session.execute(
|
||||
select(Payment)
|
||||
.where(Payment.user_id == billing_user_id)
|
||||
.where(
|
||||
func.lower(func.coalesce(Payment.payment_system, "")).like(pattern)
|
||||
| func.cast(Payment.metadata_, String).ilike(pattern)
|
||||
)
|
||||
.order_by(Payment.created_at.desc())
|
||||
.limit(safe_limit)
|
||||
)
|
||||
).scalars().all()
|
||||
for p in payments_rows:
|
||||
meta = p.metadata_ if isinstance(p.metadata_, dict) else None
|
||||
purpose = ""
|
||||
if meta:
|
||||
v = meta.get("purpose") or meta.get("description") or meta.get("tariff_name")
|
||||
if v is not None:
|
||||
purpose = str(v)
|
||||
amount_label = f"{float(p.amount or 0):,.0f} {(p.currency or 'RUB').upper()}"
|
||||
hits.append(AccountSearchHit(
|
||||
kind="payment",
|
||||
label=purpose or amount_label,
|
||||
sublabel=f"{(p.payment_system or '').upper()} · {amount_label}",
|
||||
href="/dashboard",
|
||||
meta=str(p.id),
|
||||
))
|
||||
|
||||
# Notifications: title / message
|
||||
notif_rows = (
|
||||
await session.execute(
|
||||
select(WebNotification)
|
||||
.where(WebNotification.identity_id == identity.id)
|
||||
.where(
|
||||
func.lower(WebNotification.title).like(pattern)
|
||||
| func.lower(WebNotification.message).like(pattern)
|
||||
)
|
||||
.order_by(WebNotification.created_at.desc())
|
||||
.limit(safe_limit)
|
||||
)
|
||||
).scalars().all()
|
||||
for n in notif_rows:
|
||||
hits.append(AccountSearchHit(
|
||||
kind="notification",
|
||||
label=str(n.title or "—"),
|
||||
sublabel=(str(n.message or "")[:80]),
|
||||
href="/dashboard/notifications",
|
||||
meta=str(n.id),
|
||||
))
|
||||
|
||||
return AccountSearchResponse(query=query_raw, hits=hits, total=len(hits))
|
||||
|
||||
|
||||
@router.post("/set-password")
|
||||
async def set_password(
|
||||
body: SetPasswordRequest,
|
||||
request: Request,
|
||||
session: AsyncSession = Depends(get_session),
|
||||
identity=Depends(verify_identity_token),
|
||||
):
|
||||
if body.password != body.password_confirm:
|
||||
raise HTTPException(status_code=400, detail="Пароли не совпадают")
|
||||
updated = await idb.set_initial_password(session, identity.id, body.password)
|
||||
if not updated:
|
||||
raise HTTPException(
|
||||
status_code=409,
|
||||
detail="Пароль уже установлен или аккаунт недоступен",
|
||||
)
|
||||
await bind_identity_actor(request, session, updated)
|
||||
return {"ok": True}
|
||||
|
||||
|
||||
@router.post("/change-password")
|
||||
async def change_password(
|
||||
body: ChangePasswordRequest,
|
||||
request: Request,
|
||||
session: AsyncSession = Depends(get_session),
|
||||
identity=Depends(verify_identity_token),
|
||||
):
|
||||
if body.password != body.password_confirm:
|
||||
raise HTTPException(status_code=400, detail="Новые пароли не совпадают")
|
||||
err = await idb.change_identity_password(
|
||||
session,
|
||||
identity.id,
|
||||
body.current_password,
|
||||
body.password,
|
||||
)
|
||||
if err == "no_password":
|
||||
raise HTTPException(
|
||||
status_code=409,
|
||||
detail="Пароль ещё не установлен. Сначала задайте пароль в кабинете.",
|
||||
)
|
||||
if err == "wrong_password":
|
||||
raise HTTPException(status_code=401, detail="Неверный текущий пароль")
|
||||
refreshed = await idb.get_identity_by_id(session, identity.id)
|
||||
if refreshed:
|
||||
await bind_identity_actor(request, session, refreshed)
|
||||
return {"ok": True}
|
||||
@@ -1,236 +0,0 @@
|
||||
from fastapi import APIRouter, Depends, HTTPException, Request, Response
|
||||
from pydantic import (
|
||||
BaseModel,
|
||||
Field as PydanticField,
|
||||
)
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from api.depends import (
|
||||
bind_identity_actor,
|
||||
get_session,
|
||||
set_auth_cookie,
|
||||
set_is_admin_cookie,
|
||||
verify_identity_token,
|
||||
)
|
||||
from api.v2.routes.auth._common import TELEGRAM_LOGIN_MAX_AGE, TOKEN_TTL_HINT, _client_ip, build_login_response
|
||||
from api.v2.schemas.identities import (
|
||||
IdentityResponse,
|
||||
LinkTelegramRequest,
|
||||
LoginResponse,
|
||||
LoginTelegramRequest,
|
||||
)
|
||||
from config import API_TOKEN
|
||||
from database import identities as idb
|
||||
from logger import logger
|
||||
from utils.telegram_login import verify_telegram_login
|
||||
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
def _get_oidc_credentials() -> tuple[str, str]:
|
||||
try:
|
||||
from config import TELEGRAM_CLIENT_ID, TELEGRAM_CLIENT_SECRET
|
||||
cid = str(TELEGRAM_CLIENT_ID).strip()
|
||||
secret = str(TELEGRAM_CLIENT_SECRET).strip()
|
||||
if cid and secret:
|
||||
return cid, secret
|
||||
except ImportError:
|
||||
pass
|
||||
return "", ""
|
||||
|
||||
|
||||
class LoginTelegramWebAppRequest(BaseModel):
|
||||
init_data: str = PydanticField(..., min_length=1)
|
||||
|
||||
|
||||
@router.post("/login-telegram", response_model=LoginResponse)
|
||||
async def login_telegram(
|
||||
body: LoginTelegramRequest,
|
||||
request: Request,
|
||||
response: Response,
|
||||
session: AsyncSession = Depends(get_session),
|
||||
):
|
||||
(
|
||||
"""Вход через Telegram Login Widget (кнопка на сайте). По tg_id находим или создаём Identity, выдаём токен. Срок действия токена: """
|
||||
+ TOKEN_TTL_HINT
|
||||
+ "."
|
||||
)
|
||||
payload = body.model_dump(mode="json")
|
||||
if not verify_telegram_login(payload, API_TOKEN, max_age_seconds=TELEGRAM_LOGIN_MAX_AGE):
|
||||
raise HTTPException(status_code=401, detail="Неверная подпись или устаревшие данные от Telegram")
|
||||
identity = await idb.get_or_create_identity_for_tg(session, body.id)
|
||||
await bind_identity_actor(request, session, identity)
|
||||
token = await idb.issue_token_for_identity(session, identity, request=request)
|
||||
logger.info(
|
||||
"[Auth] Login success: identity={}, tg_id={}, ip={}, method=telegram_widget",
|
||||
identity.id,
|
||||
body.id,
|
||||
_client_ip(request),
|
||||
)
|
||||
set_auth_cookie(response, token, request)
|
||||
set_is_admin_cookie(response, identity, request)
|
||||
return build_login_response(identity)
|
||||
|
||||
|
||||
@router.post("/login-telegram-webapp", response_model=LoginResponse)
|
||||
async def login_telegram_webapp(
|
||||
body: LoginTelegramWebAppRequest,
|
||||
request: Request,
|
||||
response: Response,
|
||||
session: AsyncSession = Depends(get_session),
|
||||
):
|
||||
"""Вход через Telegram WebApp initData. Валидирует HMAC, находит/создаёт Identity по tg_id."""
|
||||
from utils.telegram_login import verify_webapp_init_data
|
||||
|
||||
result = verify_webapp_init_data(body.init_data, API_TOKEN)
|
||||
if not result:
|
||||
raise HTTPException(status_code=401, detail="Неверная подпись initData")
|
||||
tg_id = result.get("user_id")
|
||||
if not tg_id:
|
||||
raise HTTPException(status_code=401, detail="Не удалось определить пользователя из initData")
|
||||
identity = await idb.get_or_create_identity_for_tg(session, int(tg_id))
|
||||
await bind_identity_actor(request, session, identity)
|
||||
token = await idb.issue_token_for_identity(session, identity, request=request)
|
||||
logger.info(
|
||||
"[Auth] Login success: identity={}, tg_id={}, ip={}, method=telegram_webapp",
|
||||
identity.id,
|
||||
tg_id,
|
||||
_client_ip(request),
|
||||
)
|
||||
set_auth_cookie(response, token, request)
|
||||
set_is_admin_cookie(response, identity, request)
|
||||
return build_login_response(identity)
|
||||
|
||||
|
||||
class LoginTelegramOIDCRequest(BaseModel):
|
||||
code: str = PydanticField(..., min_length=1)
|
||||
redirect_uri: str = PydanticField(..., min_length=1)
|
||||
code_verifier: str = PydanticField(default="", description="PKCE code_verifier")
|
||||
|
||||
|
||||
@router.post("/login-telegram-oidc", response_model=LoginResponse)
|
||||
async def login_telegram_oidc(
|
||||
body: LoginTelegramOIDCRequest,
|
||||
request: Request,
|
||||
response: Response,
|
||||
session: AsyncSession = Depends(get_session),
|
||||
):
|
||||
"""Вход через Telegram OIDC (authorization code flow). Обменивает code на id_token, верифицирует JWT."""
|
||||
import base64
|
||||
|
||||
import aiohttp
|
||||
import jwt as pyjwt
|
||||
|
||||
client_id, client_secret = _get_oidc_credentials()
|
||||
if not client_id or not client_secret:
|
||||
logger.warning("[Auth] Telegram OIDC credentials missing in config")
|
||||
raise HTTPException(status_code=503, detail="Вход через Telegram временно недоступен")
|
||||
|
||||
token_data = {
|
||||
"grant_type": "authorization_code",
|
||||
"code": body.code,
|
||||
"redirect_uri": body.redirect_uri,
|
||||
}
|
||||
if body.code_verifier:
|
||||
token_data["code_verifier"] = body.code_verifier
|
||||
|
||||
basic = base64.b64encode(f"{client_id}:{client_secret}".encode()).decode()
|
||||
headers = {"Authorization": f"Basic {basic}", "Content-Type": "application/x-www-form-urlencoded"}
|
||||
|
||||
async with aiohttp.ClientSession() as http:
|
||||
async with http.post("https://oauth.telegram.org/token", data=token_data, headers=headers) as resp:
|
||||
if resp.status != 200:
|
||||
err_text = await resp.text()
|
||||
logger.warning("[Auth] Telegram OIDC token exchange failed: {} {}", resp.status, err_text[:200])
|
||||
raise HTTPException(status_code=401, detail="Не удалось обменять код авторизации")
|
||||
token_response = await resp.json()
|
||||
|
||||
id_token = token_response.get("id_token")
|
||||
if not id_token:
|
||||
raise HTTPException(status_code=401, detail="Telegram не вернул id_token")
|
||||
|
||||
async with aiohttp.ClientSession() as http:
|
||||
async with http.get("https://oauth.telegram.org/.well-known/jwks.json") as resp:
|
||||
jwks_data = await resp.json()
|
||||
|
||||
try:
|
||||
signing_key = pyjwt.PyJWKClient.__new__(pyjwt.PyJWKClient)
|
||||
from jwt.api_jwk import PyJWKSet
|
||||
jwk_set = PyJWKSet.from_dict(jwks_data)
|
||||
unverified_header = pyjwt.get_unverified_header(id_token)
|
||||
kid = unverified_header.get("kid")
|
||||
key = None
|
||||
for k in jwk_set.keys:
|
||||
if k.key_id == kid:
|
||||
key = k
|
||||
break
|
||||
if not key:
|
||||
raise HTTPException(status_code=401, detail="Ключ подписи не найден в JWKS")
|
||||
|
||||
claims = pyjwt.decode(
|
||||
id_token,
|
||||
key.key,
|
||||
algorithms=["RS256"],
|
||||
audience=client_id,
|
||||
issuer="https://oauth.telegram.org",
|
||||
)
|
||||
except pyjwt.ExpiredSignatureError:
|
||||
raise HTTPException(status_code=401, detail="ID токен истёк")
|
||||
except pyjwt.InvalidTokenError as exc:
|
||||
logger.warning("[Auth] Telegram OIDC JWT invalid: {}", exc)
|
||||
raise HTTPException(status_code=401, detail="Невалидный ID токен")
|
||||
|
||||
logger.info("[Auth] Telegram OIDC claims: {}", {k: v for k, v in claims.items() if k not in ("iat", "exp", "iss", "aud")})
|
||||
|
||||
tg_id = claims.get("id") or claims.get("telegram_id")
|
||||
if not tg_id:
|
||||
raise HTTPException(status_code=401, detail="Не удалось определить пользователя из id_token")
|
||||
|
||||
try:
|
||||
tg_id_int = int(tg_id)
|
||||
except (TypeError, ValueError):
|
||||
raise HTTPException(status_code=401, detail="Не удалось определить пользователя") from None
|
||||
if tg_id_int <= 0 or tg_id_int > 2**53:
|
||||
raise HTTPException(status_code=401, detail="Не удалось определить пользователя")
|
||||
|
||||
identity = await idb.get_or_create_identity_for_tg(session, tg_id_int)
|
||||
await bind_identity_actor(request, session, identity)
|
||||
token = await idb.issue_token_for_identity(session, identity, request=request)
|
||||
|
||||
if getattr(identity, "is_admin", False):
|
||||
from database.site_state import mark_site_initialized
|
||||
await mark_site_initialized(session)
|
||||
|
||||
logger.info(
|
||||
"[Auth] Login success: identity={}, tg_id={}, ip={}, method=telegram_oidc",
|
||||
identity.id,
|
||||
tg_id,
|
||||
_client_ip(request),
|
||||
)
|
||||
set_auth_cookie(response, token, request)
|
||||
set_is_admin_cookie(response, identity, request)
|
||||
return build_login_response(identity)
|
||||
|
||||
|
||||
@router.post("/link-telegram", response_model=IdentityResponse)
|
||||
async def link_telegram(
|
||||
body: LinkTelegramRequest,
|
||||
request: Request,
|
||||
response: Response,
|
||||
session: AsyncSession = Depends(get_session),
|
||||
identity=Depends(verify_identity_token),
|
||||
):
|
||||
"""Привязывает Telegram к текущей идентичности. Требуется подпись от Telegram Login Widget (доказательство владения аккаунтом)."""
|
||||
payload = body.model_dump(mode="json")
|
||||
if not verify_telegram_login(payload, API_TOKEN, max_age_seconds=TELEGRAM_LOGIN_MAX_AGE):
|
||||
raise HTTPException(status_code=401, detail="Неверная подпись или устаревшие данные от Telegram")
|
||||
result = await idb.attach_telegram(session, identity.id, body.id)
|
||||
if not result:
|
||||
raise HTTPException(
|
||||
status_code=409,
|
||||
detail="Этот Telegram уже привязан к другой идентичности",
|
||||
)
|
||||
await bind_identity_actor(request, session, result)
|
||||
set_is_admin_cookie(response, result, request)
|
||||
return IdentityResponse.model_validate(result)
|
||||
@@ -1,207 +0,0 @@
|
||||
import base64
|
||||
import hashlib
|
||||
import hmac
|
||||
import secrets
|
||||
import time
|
||||
|
||||
from urllib.parse import urlencode
|
||||
|
||||
import httpx
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, Request, Response
|
||||
from fastapi.responses import RedirectResponse
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from api.depends import (
|
||||
bind_identity_actor,
|
||||
get_session,
|
||||
set_auth_cookie,
|
||||
set_is_admin_cookie,
|
||||
)
|
||||
from api.v2.routes.auth._common import _client_ip
|
||||
from database import identities as idb
|
||||
from logger import logger
|
||||
|
||||
|
||||
try:
|
||||
from config import YANDEX_CLIENT_ID as _YANDEX_CLIENT_ID
|
||||
except ImportError:
|
||||
_YANDEX_CLIENT_ID = ""
|
||||
try:
|
||||
from config import YANDEX_CLIENT_SECRET as _YANDEX_CLIENT_SECRET
|
||||
except ImportError:
|
||||
_YANDEX_CLIENT_SECRET = ""
|
||||
try:
|
||||
from config import YANDEX_REDIRECT_URI as _YANDEX_REDIRECT_URI
|
||||
except ImportError:
|
||||
_YANDEX_REDIRECT_URI = ""
|
||||
try:
|
||||
from config import OAUTH_SUCCESS_URI as _OAUTH_SUCCESS_URI
|
||||
except ImportError:
|
||||
_OAUTH_SUCCESS_URI = "/dashboard"
|
||||
try:
|
||||
from config import API_TOKEN as _YANDEX_STATE_SECRET
|
||||
except ImportError:
|
||||
_YANDEX_STATE_SECRET = "solo-yandex-state-fallback"
|
||||
|
||||
|
||||
YANDEX_AUTH_ENDPOINT = "https://oauth.yandex.ru/authorize"
|
||||
YANDEX_TOKEN_ENDPOINT = "https://oauth.yandex.ru/token"
|
||||
YANDEX_USERINFO_ENDPOINT = "https://login.yandex.ru/info"
|
||||
STATE_TTL_SECONDS = 600
|
||||
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
def yandex_configured() -> bool:
|
||||
return bool(_YANDEX_CLIENT_ID and _YANDEX_CLIENT_SECRET and _YANDEX_REDIRECT_URI)
|
||||
|
||||
|
||||
def _sign_state(payload: str) -> str:
|
||||
mac = hmac.new(str(_YANDEX_STATE_SECRET).encode(), payload.encode(), hashlib.sha256).digest()
|
||||
return base64.urlsafe_b64encode(mac).decode().rstrip("=")
|
||||
|
||||
|
||||
def _make_state(return_to: str) -> str:
|
||||
nonce = secrets.token_urlsafe(16)
|
||||
ts = str(int(time.time()))
|
||||
payload = f"{nonce}.{ts}.{return_to}"
|
||||
sig = _sign_state(payload)
|
||||
raw = f"{payload}.{sig}".encode()
|
||||
return base64.urlsafe_b64encode(raw).decode().rstrip("=")
|
||||
|
||||
|
||||
def _verify_state(state: str) -> str | None:
|
||||
try:
|
||||
padded = state + "=" * (-len(state) % 4)
|
||||
raw = base64.urlsafe_b64decode(padded).decode()
|
||||
except Exception:
|
||||
return None
|
||||
parts = raw.rsplit(".", 1)
|
||||
if len(parts) != 2:
|
||||
return None
|
||||
payload, sig = parts
|
||||
expected = _sign_state(payload)
|
||||
if not hmac.compare_digest(sig, expected):
|
||||
return None
|
||||
chunks = payload.split(".", 2)
|
||||
if len(chunks) != 3:
|
||||
return None
|
||||
_nonce, ts, return_to = chunks
|
||||
try:
|
||||
if int(time.time()) - int(ts) > STATE_TTL_SECONDS:
|
||||
return None
|
||||
except Exception:
|
||||
return None
|
||||
return return_to or _OAUTH_SUCCESS_URI
|
||||
|
||||
|
||||
@router.get("/yandex/authorize")
|
||||
async def yandex_authorize(
|
||||
request: Request,
|
||||
return_to: str = Query(default=""),
|
||||
):
|
||||
"""Начинает OAuth-флоу Яндекс ID: редиректит юзера на consent screen."""
|
||||
if not yandex_configured():
|
||||
raise HTTPException(status_code=503, detail="Вход через Яндекс не настроен на этом сервере")
|
||||
safe_return = return_to if return_to.startswith("/") else _OAUTH_SUCCESS_URI
|
||||
state = _make_state(safe_return)
|
||||
params = {
|
||||
"client_id": _YANDEX_CLIENT_ID,
|
||||
"redirect_uri": _YANDEX_REDIRECT_URI,
|
||||
"response_type": "code",
|
||||
"state": state,
|
||||
"force_confirm": "yes",
|
||||
}
|
||||
url = f"{YANDEX_AUTH_ENDPOINT}?{urlencode(params)}"
|
||||
logger.info("[Auth] Yandex authorize: ip={}", _client_ip(request))
|
||||
return RedirectResponse(url, status_code=302)
|
||||
|
||||
|
||||
@router.get("/yandex/callback")
|
||||
async def yandex_callback(
|
||||
request: Request,
|
||||
response: Response,
|
||||
code: str = Query(default=""),
|
||||
state: str = Query(default=""),
|
||||
error: str = Query(default=""),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
):
|
||||
"""Коллбек Яндекс ID: обмен code → token → userinfo → identity."""
|
||||
if not yandex_configured():
|
||||
raise HTTPException(status_code=503, detail="Вход через Яндекс не настроен на этом сервере")
|
||||
if error:
|
||||
logger.warning("[Auth] Yandex callback error: {} ip={}", error, _client_ip(request))
|
||||
return RedirectResponse(f"/login?error=yandex_{error}", status_code=302)
|
||||
if not code or not state:
|
||||
raise HTTPException(status_code=400, detail="Отсутствует code или state")
|
||||
return_to = _verify_state(state)
|
||||
if return_to is None:
|
||||
logger.warning("[Auth] Yandex callback: invalid/expired state ip={}", _client_ip(request))
|
||||
raise HTTPException(status_code=400, detail="Неверный или просроченный state")
|
||||
|
||||
async with httpx.AsyncClient(timeout=10.0) as client:
|
||||
try:
|
||||
token_res = await client.post(
|
||||
YANDEX_TOKEN_ENDPOINT,
|
||||
data={
|
||||
"grant_type": "authorization_code",
|
||||
"code": code,
|
||||
"client_id": _YANDEX_CLIENT_ID,
|
||||
"client_secret": _YANDEX_CLIENT_SECRET,
|
||||
},
|
||||
headers={"Accept": "application/json"},
|
||||
)
|
||||
except httpx.HTTPError as e:
|
||||
logger.warning("[Auth] Yandex token exchange network error: {}", e)
|
||||
raise HTTPException(status_code=502, detail="Не удалось связаться с Яндекс") from e
|
||||
if token_res.status_code != 200:
|
||||
logger.warning("[Auth] Yandex token exchange failed: {} {}", token_res.status_code, token_res.text[:200])
|
||||
raise HTTPException(status_code=401, detail="Яндекс отклонил токен")
|
||||
token_payload = token_res.json()
|
||||
access_token = token_payload.get("access_token")
|
||||
if not access_token:
|
||||
raise HTTPException(status_code=401, detail="Яндекс не вернул access_token")
|
||||
|
||||
try:
|
||||
info_res = await client.get(
|
||||
YANDEX_USERINFO_ENDPOINT,
|
||||
headers={"Authorization": f"OAuth {access_token}"},
|
||||
params={"format": "json"},
|
||||
)
|
||||
except httpx.HTTPError as e:
|
||||
logger.warning("[Auth] Yandex userinfo network error: {}", e)
|
||||
raise HTTPException(status_code=502, detail="Не удалось получить профиль Яндекс") from e
|
||||
|
||||
if info_res.status_code != 200:
|
||||
raise HTTPException(status_code=401, detail="Яндекс не вернул профиль")
|
||||
info = info_res.json()
|
||||
yandex_sub = str(info.get("id") or "").strip()
|
||||
email = (info.get("default_email") or "").strip().lower() or None
|
||||
if not yandex_sub:
|
||||
raise HTTPException(status_code=401, detail="Яндекс не вернул идентификатор пользователя")
|
||||
|
||||
identity = await idb.get_or_create_identity_for_yandex(
|
||||
session,
|
||||
yandex_sub=yandex_sub,
|
||||
email=email,
|
||||
)
|
||||
await bind_identity_actor(request, session, identity)
|
||||
token = await idb.issue_token_for_identity(session, identity, request=request)
|
||||
logger.info(
|
||||
"[Auth] Login success: identity={}, yandex_sub={}, ip={}, method=yandex",
|
||||
identity.id,
|
||||
yandex_sub,
|
||||
_client_ip(request),
|
||||
)
|
||||
redirect = RedirectResponse(return_to, status_code=302)
|
||||
set_auth_cookie(redirect, token, request)
|
||||
set_is_admin_cookie(redirect, identity, request)
|
||||
return redirect
|
||||
|
||||
|
||||
@router.get("/yandex/status")
|
||||
async def yandex_status():
|
||||
"""Позволяет фронтенду узнать, настроен ли вход через Яндекс на этом сервере."""
|
||||
return {"enabled": yandex_configured()}
|
||||
@@ -1,31 +0,0 @@
|
||||
from fastapi import HTTPException
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from services.coupons import resolve_percent_coupon
|
||||
from services.errors import ServiceError
|
||||
|
||||
|
||||
async def resolve_percent_coupon_pricing(
|
||||
session: AsyncSession,
|
||||
billing_user_id: int,
|
||||
base_price_rub: int,
|
||||
coupon_code: str | None,
|
||||
) -> tuple[int, int, int | None, str | None]:
|
||||
"""Применяет процентный купон. Бросает HTTPException при ошибке."""
|
||||
try:
|
||||
return await resolve_percent_coupon(
|
||||
session=session,
|
||||
billing_user_id=billing_user_id,
|
||||
base_price_rub=base_price_rub,
|
||||
coupon_code=coupon_code,
|
||||
)
|
||||
except ServiceError as e:
|
||||
status_map = {
|
||||
"not_found": 404,
|
||||
"limit_exceeded": 409,
|
||||
"validation_error": 400,
|
||||
}
|
||||
raise HTTPException(
|
||||
status_code=status_map.get(e.code, 400),
|
||||
detail=e.message,
|
||||
)
|
||||
@@ -1,73 +0,0 @@
|
||||
from fastapi import Depends, HTTPException, Request
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from api.depends import get_request_actor, get_session, verify_identity_token
|
||||
from api.v2.base_crud import generate_crud_router
|
||||
from api.v2.schemas import CouponBase, CouponResponse, CouponUpdate
|
||||
from api.v2.schemas.web_public import CouponApplyRequest, CouponApplyResponse
|
||||
from database import identities as idb
|
||||
from database.models import Coupon
|
||||
from services.coupons import apply_fixed_coupon
|
||||
from services.errors import LimitExceededError, NotFoundError, ServiceError, ValidationError
|
||||
|
||||
|
||||
router = generate_crud_router(
|
||||
model=Coupon,
|
||||
schema_response=CouponResponse,
|
||||
schema_create=CouponBase,
|
||||
schema_update=CouponUpdate,
|
||||
identifier_field="code",
|
||||
parameter_name="code",
|
||||
enabled_methods=["get_all", "get_one", "create", "update", "delete"],
|
||||
)
|
||||
|
||||
|
||||
async def _resolve_coupon_user_id(session: AsyncSession, request: Request, identity) -> tuple[int, int | None]:
|
||||
actor = get_request_actor(request)
|
||||
billing_user_id = actor.billing_user_id if actor and actor.billing_user_id is not None else None
|
||||
if billing_user_id is None:
|
||||
billing_user_id = await idb.ensure_billing_user_for_identity(session, identity)
|
||||
tg_id = actor.telegram_chat_id if actor else None
|
||||
return int(billing_user_id), tg_id
|
||||
|
||||
|
||||
def _service_error_to_http(e: ServiceError) -> HTTPException:
|
||||
status_map = {
|
||||
"not_found": 404,
|
||||
"limit_exceeded": 409,
|
||||
"validation_error": 400,
|
||||
"forbidden": 403,
|
||||
}
|
||||
return HTTPException(status_code=status_map.get(e.code, 400), detail=e.message)
|
||||
|
||||
|
||||
@router.post("/apply", response_model=CouponApplyResponse)
|
||||
async def apply_coupon(
|
||||
body: CouponApplyRequest,
|
||||
request: Request,
|
||||
session: AsyncSession = Depends(get_session),
|
||||
identity=Depends(verify_identity_token),
|
||||
):
|
||||
from api.ratelimit import enforce_rate_limit
|
||||
await enforce_rate_limit(request, session, bucket="coupon_apply", max_per_window=10, window_sec=60)
|
||||
user_id, tg_id = await _resolve_coupon_user_id(session, request, identity)
|
||||
try:
|
||||
result = await apply_fixed_coupon(
|
||||
session=session,
|
||||
user_id=user_id,
|
||||
tg_id=tg_id,
|
||||
code=str(body.code or ""),
|
||||
)
|
||||
return CouponApplyResponse(
|
||||
ok=True,
|
||||
message="Купон успешно активирован",
|
||||
coupon_code=result.coupon_code,
|
||||
amount=result.amount,
|
||||
balance=result.balance,
|
||||
)
|
||||
except ServiceError as e:
|
||||
await session.rollback()
|
||||
raise _service_error_to_http(e)
|
||||
except Exception:
|
||||
await session.rollback()
|
||||
raise HTTPException(status_code=500, detail="Ошибка активации купона")
|
||||
@@ -1,117 +0,0 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import UTC, datetime
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from api.depends import get_session, verify_identity_admin
|
||||
from api.v2.schemas.flows import FlowCreate, FlowResponse, FlowUpdate
|
||||
from database.models import WebFlow
|
||||
from database.site_revision import bump_site_revision
|
||||
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
def _flow_to_response(flow: WebFlow) -> FlowResponse:
|
||||
return FlowResponse(
|
||||
id=flow.id,
|
||||
name=flow.name,
|
||||
nodes=flow.nodes or [],
|
||||
edges=flow.edges or [],
|
||||
entry_node_id=flow.entry_node_id,
|
||||
version=flow.version,
|
||||
)
|
||||
|
||||
|
||||
@router.get("/flows/{flow_id}", response_model=FlowResponse, response_model_by_alias=True)
|
||||
async def get_flow_public(flow_id: str, session: AsyncSession = Depends(get_session)):
|
||||
flow = await session.get(WebFlow, flow_id)
|
||||
if not flow:
|
||||
raise HTTPException(404, "Flow not found")
|
||||
return _flow_to_response(flow)
|
||||
|
||||
|
||||
@router.get("/admin/flows", response_model=list[FlowResponse], response_model_by_alias=True)
|
||||
async def list_flows(
|
||||
session: AsyncSession = Depends(get_session),
|
||||
_identity=Depends(verify_identity_admin),
|
||||
):
|
||||
result = await session.execute(select(WebFlow))
|
||||
return [_flow_to_response(f) for f in result.scalars().all()]
|
||||
|
||||
|
||||
@router.get("/admin/flows/{flow_id}", response_model=FlowResponse, response_model_by_alias=True)
|
||||
async def get_flow_admin(
|
||||
flow_id: str,
|
||||
session: AsyncSession = Depends(get_session),
|
||||
_identity=Depends(verify_identity_admin),
|
||||
):
|
||||
flow = await session.get(WebFlow, flow_id)
|
||||
if not flow:
|
||||
raise HTTPException(404, "Flow not found")
|
||||
return _flow_to_response(flow)
|
||||
|
||||
|
||||
@router.post("/admin/flows", response_model=FlowResponse, status_code=201, response_model_by_alias=True)
|
||||
async def create_flow(
|
||||
body: FlowCreate,
|
||||
session: AsyncSession = Depends(get_session),
|
||||
_identity=Depends(verify_identity_admin),
|
||||
):
|
||||
existing = await session.get(WebFlow, body.id)
|
||||
if existing:
|
||||
raise HTTPException(409, "Flow with this ID already exists")
|
||||
|
||||
flow = WebFlow(
|
||||
id=body.id,
|
||||
name=body.name,
|
||||
nodes=[n.model_dump() for n in body.nodes],
|
||||
edges=[e.model_dump() for e in body.edges],
|
||||
entry_node_id=body.entry_node_id,
|
||||
version=1,
|
||||
updated_at=datetime.now(UTC),
|
||||
)
|
||||
session.add(flow)
|
||||
await bump_site_revision(session)
|
||||
await session.refresh(flow)
|
||||
return _flow_to_response(flow)
|
||||
|
||||
|
||||
@router.put("/admin/flows/{flow_id}", response_model=FlowResponse, response_model_by_alias=True)
|
||||
async def update_flow(
|
||||
flow_id: str,
|
||||
body: FlowUpdate,
|
||||
session: AsyncSession = Depends(get_session),
|
||||
_identity=Depends(verify_identity_admin),
|
||||
):
|
||||
flow = await session.get(WebFlow, flow_id)
|
||||
if not flow:
|
||||
raise HTTPException(404, "Flow not found")
|
||||
|
||||
if body.name is not None:
|
||||
flow.name = body.name
|
||||
flow.nodes = [n.model_dump() for n in body.nodes]
|
||||
flow.edges = [e.model_dump() for e in body.edges]
|
||||
flow.entry_node_id = body.entry_node_id
|
||||
flow.version = flow.version + 1
|
||||
flow.updated_at = datetime.now(UTC)
|
||||
|
||||
await bump_site_revision(session)
|
||||
await session.refresh(flow)
|
||||
return _flow_to_response(flow)
|
||||
|
||||
|
||||
@router.delete("/admin/flows/{flow_id}", status_code=204)
|
||||
async def delete_flow(
|
||||
flow_id: str,
|
||||
session: AsyncSession = Depends(get_session),
|
||||
_identity=Depends(verify_identity_admin),
|
||||
):
|
||||
flow = await session.get(WebFlow, flow_id)
|
||||
if not flow:
|
||||
raise HTTPException(404, "Flow not found")
|
||||
await session.delete(flow)
|
||||
await bump_site_revision(session)
|
||||
@@ -1,337 +0,0 @@
|
||||
from math import ceil
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Path, Query, Request
|
||||
from sqlalchemy import delete, func, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from api.depends import (
|
||||
get_request_actor,
|
||||
get_session,
|
||||
validate_redirect_url,
|
||||
verify_identity_admin,
|
||||
verify_identity_token,
|
||||
)
|
||||
from api.v2.base_crud import generate_crud_router
|
||||
from api.v2.routes.tariffs import _resolve_default_web_payment_provider, _resolve_public_base_url
|
||||
from api.v2.schemas import GiftBase, GiftResponse, GiftUpdate, GiftUsageResponse
|
||||
from api.v2.schemas.web_public import (
|
||||
GiftCreatePreviewResponse,
|
||||
GiftCreateRequest,
|
||||
GiftCreateResponse,
|
||||
GiftRedeemRequest,
|
||||
GiftRedeemResponse,
|
||||
GiftUsageEntry,
|
||||
MyGiftItem,
|
||||
MyGiftsResponse,
|
||||
)
|
||||
from config import GIFT_BUTTON
|
||||
from core.bootstrap import BUTTONS_CONFIG
|
||||
from database import (
|
||||
get_balance,
|
||||
identities as idb,
|
||||
)
|
||||
from database.access.resolution import resolve_user_optional
|
||||
from database.models import Gift, GiftUsage, Tariff
|
||||
from database.tariffs import get_tariff_by_id
|
||||
from database.temporary_data import create_temporary_data
|
||||
from services.errors import NotFoundError, ValidationError
|
||||
from services.formatting import get_site_gift_link
|
||||
from services.gifts import (
|
||||
create_gift as service_create_gift,
|
||||
redeem_gift as service_redeem_gift,
|
||||
)
|
||||
from services.payments.payment_links import PaymentLinkRequest, create_payment_link
|
||||
from services.tariffs import calculate_config_price
|
||||
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
def _check_gifts_enabled():
|
||||
if not bool(BUTTONS_CONFIG.get("GIFT_BUTTON_ENABLE", GIFT_BUTTON)):
|
||||
raise HTTPException(status_code=403, detail="Подарки отключены")
|
||||
|
||||
|
||||
@router.post("/create", tags=["Gifts"])
|
||||
async def create_gift_for_user(
|
||||
body: GiftCreateRequest,
|
||||
request: Request,
|
||||
preview: bool = Query(False),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
identity=Depends(verify_identity_token),
|
||||
):
|
||||
from api.ratelimit import enforce_rate_limit
|
||||
if not preview:
|
||||
await enforce_rate_limit(request, session, bucket="gift_create", max_per_window=10, window_sec=60)
|
||||
_check_gifts_enabled()
|
||||
actor = get_request_actor(request)
|
||||
billing_user_id = actor.billing_user_id if actor and actor.billing_user_id is not None else None
|
||||
if billing_user_id is None:
|
||||
billing_user_id = await idb.ensure_billing_user_for_identity(session, identity)
|
||||
|
||||
tariff = await get_tariff_by_id(session, body.tariff_id)
|
||||
if not tariff or tariff.get("group_code") != "gifts" or not tariff.get("is_active", True):
|
||||
raise HTTPException(status_code=404, detail="Тариф не найден")
|
||||
|
||||
price = int(calculate_config_price(tariff, body.selected_device_limit, body.selected_traffic_gb))
|
||||
balance = float(await get_balance(session, billing_user_id))
|
||||
|
||||
required_amount = int(max(0, ceil(float(price) - balance)))
|
||||
|
||||
if preview:
|
||||
return GiftCreatePreviewResponse(
|
||||
ok=True,
|
||||
price_rub=price,
|
||||
balance_rub=balance,
|
||||
sufficient_funds=balance >= price,
|
||||
tariff_name=str(tariff.get("name", "")),
|
||||
duration_days=int(tariff.get("duration_days") or 0),
|
||||
)
|
||||
|
||||
if required_amount > 0:
|
||||
provider_id = str(body.provider_id or _resolve_default_web_payment_provider() or "").strip().upper()
|
||||
if not provider_id:
|
||||
raise HTTPException(status_code=503, detail="Нет доступных провайдеров оплаты")
|
||||
base_url = _resolve_public_base_url(request)
|
||||
success_url = validate_redirect_url(str(body.success_url or ""), f"{base_url}/payment-success")
|
||||
failure_url = validate_redirect_url(str(body.failure_url or ""), f"{base_url}/payment-failure")
|
||||
payment_request = PaymentLinkRequest(
|
||||
legacy_user_ref=int(billing_user_id),
|
||||
amount=required_amount,
|
||||
currency="RUB",
|
||||
provider_id=provider_id,
|
||||
success_url=success_url,
|
||||
failure_url=failure_url,
|
||||
metadata={
|
||||
"payment_flow": "gift_create",
|
||||
"tariff_id": int(body.tariff_id),
|
||||
"selected_device_limit": body.selected_device_limit,
|
||||
"selected_traffic_gb": body.selected_traffic_gb,
|
||||
"selected_price_rub": int(price),
|
||||
},
|
||||
)
|
||||
payment_result = await create_payment_link(session, payment_request)
|
||||
if not payment_result.success or not payment_result.payment_url or not payment_result.payment_id:
|
||||
raise HTTPException(status_code=400, detail=payment_result.error or "Не удалось создать ссылку оплаты")
|
||||
await create_temporary_data(
|
||||
session,
|
||||
int(billing_user_id),
|
||||
"waiting_for_payment",
|
||||
{
|
||||
"payment_flow": "gift_create",
|
||||
"tariff_id": int(body.tariff_id),
|
||||
"required_amount": int(required_amount),
|
||||
"selected_price_rub": int(price),
|
||||
"selected_device_limit": body.selected_device_limit,
|
||||
"selected_traffic_gb": body.selected_traffic_gb,
|
||||
},
|
||||
)
|
||||
return GiftCreateResponse(
|
||||
ok=True,
|
||||
message="Требуется оплата для создания подарка",
|
||||
payment_required=True,
|
||||
required_amount_rub=required_amount,
|
||||
payment_id=payment_result.payment_id,
|
||||
payment_url=payment_result.payment_url,
|
||||
)
|
||||
|
||||
from services.errors import InsufficientFundsError, NotFoundError
|
||||
|
||||
try:
|
||||
result = await service_create_gift(
|
||||
session=session,
|
||||
sender_user_ref=billing_user_id,
|
||||
tariff_id=body.tariff_id,
|
||||
selected_device_limit=body.selected_device_limit,
|
||||
selected_traffic_gb=body.selected_traffic_gb,
|
||||
selected_price_rub=price,
|
||||
)
|
||||
except InsufficientFundsError as e:
|
||||
raise HTTPException(status_code=400, detail=str(e)) from None
|
||||
except NotFoundError as e:
|
||||
raise HTTPException(status_code=404, detail=str(e)) from None
|
||||
|
||||
new_balance = float(await get_balance(session, billing_user_id))
|
||||
return GiftCreateResponse(
|
||||
ok=True,
|
||||
message=f"Подарок создан — {result.tariff_name} на {result.duration_text}",
|
||||
gift_id=result.gift_id,
|
||||
site_gift_link=result.site_gift_link,
|
||||
tariff_name=result.tariff_name,
|
||||
duration_days=result.duration_days,
|
||||
price_charged=result.price_charged,
|
||||
balance_rub=new_balance,
|
||||
)
|
||||
|
||||
|
||||
@router.get("/my", response_model=MyGiftsResponse, tags=["Gifts"])
|
||||
async def get_my_gifts(
|
||||
request: Request,
|
||||
limit: int = Query(20, ge=1, le=100),
|
||||
offset: int = Query(0, ge=0),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
identity=Depends(verify_identity_token),
|
||||
):
|
||||
_check_gifts_enabled()
|
||||
actor = get_request_actor(request)
|
||||
billing_user_id = actor.billing_user_id if actor and actor.billing_user_id is not None else None
|
||||
if billing_user_id is None:
|
||||
billing_user_id = await idb.ensure_billing_user_for_identity(session, identity)
|
||||
|
||||
base_filter = Gift.sender_user_id == billing_user_id
|
||||
total = (await session.execute(select(func.count()).select_from(Gift).where(base_filter))).scalar_one()
|
||||
|
||||
result = await session.execute(
|
||||
select(Gift).where(base_filter).order_by(Gift.created_at.desc()).limit(limit).offset(offset)
|
||||
)
|
||||
gifts = result.scalars().all()
|
||||
|
||||
tariff_ids = {g.tariff_id for g in gifts if g.tariff_id}
|
||||
tariff_map: dict[int, str] = {}
|
||||
duration_map: dict[int, int] = {}
|
||||
if tariff_ids:
|
||||
tariff_rows = await session.execute(select(Tariff).where(Tariff.id.in_(tariff_ids)))
|
||||
for t in tariff_rows.scalars().all():
|
||||
tariff_map[t.id] = t.name or ""
|
||||
duration_map[t.id] = int(t.duration_days or 0)
|
||||
|
||||
gift_ids = [g.gift_id for g in gifts]
|
||||
usages_map: dict[str, list[GiftUsageEntry]] = {gid: [] for gid in gift_ids}
|
||||
if gift_ids:
|
||||
usage_rows = await session.execute(select(GiftUsage).where(GiftUsage.gift_id.in_(gift_ids)))
|
||||
for u in usage_rows.scalars().all():
|
||||
usages_map.setdefault(u.gift_id, []).append(
|
||||
GiftUsageEntry(
|
||||
user_id=int(u.user_id),
|
||||
used_at=u.used_at.isoformat() if u.used_at else None,
|
||||
)
|
||||
)
|
||||
|
||||
items = []
|
||||
for g in gifts:
|
||||
items.append(
|
||||
MyGiftItem(
|
||||
gift_id=g.gift_id,
|
||||
tariff_name=tariff_map.get(g.tariff_id, ""),
|
||||
duration_days=duration_map.get(g.tariff_id, 0),
|
||||
price_rub=int(g.selected_price_rub or 0),
|
||||
created_at=g.created_at.isoformat() if g.created_at else None,
|
||||
expiry_time=g.expiry_time.isoformat() if g.expiry_time else None,
|
||||
is_used=bool(g.is_used),
|
||||
is_unlimited=bool(g.is_unlimited),
|
||||
max_usages=g.max_usages,
|
||||
site_gift_link=get_site_gift_link(g.gift_id),
|
||||
usages=usages_map.get(g.gift_id, []),
|
||||
)
|
||||
)
|
||||
|
||||
return MyGiftsResponse(ok=True, gifts=items, total=total, limit=limit, offset=offset)
|
||||
|
||||
|
||||
@router.delete("/my/{gift_id}", response_model=dict, tags=["Gifts"])
|
||||
async def delete_my_gift(
|
||||
request: Request,
|
||||
gift_id: str = Path(...),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
identity=Depends(verify_identity_token),
|
||||
):
|
||||
"""Удаляет свой подарок."""
|
||||
actor = get_request_actor(request)
|
||||
billing_user_id = actor.billing_user_id if actor and actor.billing_user_id is not None else None
|
||||
if billing_user_id is None:
|
||||
billing_user_id = await idb.ensure_billing_user_for_identity(session, identity)
|
||||
|
||||
result = await session.execute(select(Gift).where(Gift.gift_id == gift_id))
|
||||
gift = result.scalar_one_or_none()
|
||||
if not gift or gift.sender_user_id != billing_user_id:
|
||||
raise HTTPException(status_code=404, detail="Подарок не найден")
|
||||
await session.execute(delete(GiftUsage).where(GiftUsage.gift_id == gift_id))
|
||||
await session.delete(gift)
|
||||
return {"ok": True, "message": "Подарок удалён"}
|
||||
|
||||
|
||||
gift_router = generate_crud_router(
|
||||
model=Gift,
|
||||
schema_response=GiftResponse,
|
||||
schema_create=GiftBase,
|
||||
schema_update=GiftUpdate,
|
||||
identifier_field="gift_id",
|
||||
parameter_name="gift_id",
|
||||
enabled_methods=["get_all", "get_one", "create", "update"],
|
||||
)
|
||||
router.include_router(gift_router, prefix="", tags=["Gifts"])
|
||||
|
||||
|
||||
@router.post("/redeem", response_model=GiftRedeemResponse, tags=["Gifts"])
|
||||
async def redeem_gift(
|
||||
body: GiftRedeemRequest,
|
||||
request: Request,
|
||||
session: AsyncSession = Depends(get_session),
|
||||
identity=Depends(verify_identity_token),
|
||||
):
|
||||
from api.ratelimit import enforce_rate_limit
|
||||
await enforce_rate_limit(request, session, bucket="gift_redeem", max_per_window=10, window_sec=60)
|
||||
_check_gifts_enabled()
|
||||
actor = get_request_actor(request)
|
||||
billing_user_id = actor.billing_user_id if actor and actor.billing_user_id is not None else None
|
||||
if billing_user_id is None:
|
||||
billing_user_id = await idb.ensure_billing_user_for_identity(session, identity)
|
||||
try:
|
||||
result = await service_redeem_gift(session, body.gift_code, billing_user_id)
|
||||
except ValidationError as e:
|
||||
raise HTTPException(status_code=400, detail=str(e)) from None
|
||||
except NotFoundError as e:
|
||||
raise HTTPException(status_code=404, detail=str(e)) from None
|
||||
except Exception:
|
||||
raise HTTPException(status_code=500, detail="Не удалось активировать подарок") from None
|
||||
return GiftRedeemResponse(
|
||||
ok=True,
|
||||
message=result.message,
|
||||
gift_id=result.gift_id,
|
||||
tariff_id=result.tariff_id,
|
||||
duration_days=result.duration_days,
|
||||
)
|
||||
|
||||
|
||||
@router.get("/by_tg_id/{tg_id}", response_model=list[GiftResponse], tags=["Gifts"])
|
||||
async def get_gifts_by_tg_id(
|
||||
tg_id: int = Path(...),
|
||||
identity=Depends(verify_identity_admin),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
):
|
||||
"""Список подарков по tg_id отправителя."""
|
||||
u = await resolve_user_optional(session, tg_id)
|
||||
if u is None:
|
||||
raise HTTPException(status_code=404, detail="Gifts not found")
|
||||
result = await session.execute(select(Gift).where(Gift.sender_user_id == u.id))
|
||||
gifts = result.scalars().all()
|
||||
if not gifts:
|
||||
raise HTTPException(status_code=404, detail="Gifts not found")
|
||||
return gifts
|
||||
|
||||
|
||||
gift_usage_router = generate_crud_router(
|
||||
model=GiftUsage,
|
||||
schema_response=GiftUsageResponse,
|
||||
schema_create=None,
|
||||
schema_update=None,
|
||||
identifier_field="gift_id",
|
||||
enabled_methods=["get_all", "get_one", "delete"],
|
||||
)
|
||||
router.include_router(gift_usage_router, prefix="/usages", tags=["Gifts", "GiftUsages"])
|
||||
|
||||
|
||||
@router.delete("/{gift_id}", response_model=dict, tags=["Gifts"])
|
||||
async def delete_gift_with_usages(
|
||||
gift_id: str = Path(..., description="ID подарка"),
|
||||
identity=Depends(verify_identity_admin),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
):
|
||||
"""Удаляет подарок и все его использования."""
|
||||
result = await session.execute(select(Gift).where(Gift.gift_id == gift_id))
|
||||
gift = result.scalar_one_or_none()
|
||||
if not gift:
|
||||
raise HTTPException(status_code=404, detail="Gift not found")
|
||||
await session.execute(delete(GiftUsage).where(GiftUsage.gift_id == gift_id))
|
||||
await session.delete(gift)
|
||||
return {"message": "Подарок и связанные использования удалены"}
|
||||
@@ -1,101 +0,0 @@
|
||||
from fastapi import APIRouter, Depends, HTTPException, Path, Query
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from api.depends import get_session, verify_identity_admin
|
||||
from api.v2.schemas.identities import (
|
||||
IdentityAttachEmail,
|
||||
IdentityAttachTelegram,
|
||||
IdentityCreate,
|
||||
IdentityResponse,
|
||||
)
|
||||
from database import identities as idb
|
||||
|
||||
|
||||
router = APIRouter(tags=["Identities"])
|
||||
|
||||
|
||||
@router.post("/", response_model=IdentityResponse)
|
||||
async def create_identity(
|
||||
body: IdentityCreate,
|
||||
session: AsyncSession = Depends(get_session),
|
||||
identity=Depends(verify_identity_admin),
|
||||
):
|
||||
"""Создаёт идентичность; можно указать email и/или tg_id."""
|
||||
email = body.email.strip().lower() if body.email and body.email.strip() else None
|
||||
if not email and not body.tg_id:
|
||||
raise HTTPException(status_code=400, detail="Укажите email и/или tg_id")
|
||||
identity = await idb.create_identity(session, email=email, tg_id=body.tg_id)
|
||||
return IdentityResponse.model_validate(identity)
|
||||
|
||||
|
||||
@router.get("/{identity_id}", response_model=IdentityResponse)
|
||||
async def get_identity(
|
||||
identity_id: str = Path(...),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
identity=Depends(verify_identity_admin),
|
||||
):
|
||||
"""Возвращает идентичность по id."""
|
||||
identity = await idb.get_identity_by_id(session, identity_id)
|
||||
if not identity:
|
||||
raise HTTPException(status_code=404, detail="Identity not found")
|
||||
return IdentityResponse.model_validate(identity)
|
||||
|
||||
|
||||
@router.get("/by/email", response_model=IdentityResponse)
|
||||
async def get_identity_by_email(
|
||||
email: str = Query(..., min_length=1),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
identity=Depends(verify_identity_admin),
|
||||
):
|
||||
"""Возвращает идентичность по email."""
|
||||
identity = await idb.get_identity_by_email(session, email)
|
||||
if not identity:
|
||||
raise HTTPException(status_code=404, detail="Identity not found")
|
||||
return IdentityResponse.model_validate(identity)
|
||||
|
||||
|
||||
@router.get("/by/tg_id/{tg_id}", response_model=IdentityResponse)
|
||||
async def get_identity_by_tg_id(
|
||||
tg_id: int = Path(...),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
identity=Depends(verify_identity_admin),
|
||||
):
|
||||
"""Возвращает идентичность по Telegram ID."""
|
||||
identity = await idb.get_identity_by_tg_id(session, tg_id)
|
||||
if not identity:
|
||||
raise HTTPException(status_code=404, detail="Identity not found")
|
||||
return IdentityResponse.model_validate(identity)
|
||||
|
||||
|
||||
@router.patch("/{identity_id}/attach-email", response_model=IdentityResponse)
|
||||
async def attach_email(
|
||||
identity_id: str = Path(...),
|
||||
body: IdentityAttachEmail = ...,
|
||||
session: AsyncSession = Depends(get_session),
|
||||
identity=Depends(verify_identity_admin),
|
||||
):
|
||||
"""Привязывает email к идентичности."""
|
||||
identity = await idb.attach_email(session, identity_id, body.email)
|
||||
if not identity:
|
||||
raise HTTPException(
|
||||
status_code=404,
|
||||
detail="Identity not found или email уже привязан к другой идентичности",
|
||||
)
|
||||
return IdentityResponse.model_validate(identity)
|
||||
|
||||
|
||||
@router.patch("/{identity_id}/attach-telegram", response_model=IdentityResponse)
|
||||
async def attach_telegram(
|
||||
identity_id: str = Path(...),
|
||||
body: IdentityAttachTelegram = ...,
|
||||
session: AsyncSession = Depends(get_session),
|
||||
identity=Depends(verify_identity_admin),
|
||||
):
|
||||
"""Привязывает Telegram (tg_id) к идентичности."""
|
||||
identity = await idb.attach_telegram(session, identity_id, body.tg_id)
|
||||
if not identity:
|
||||
raise HTTPException(
|
||||
status_code=404,
|
||||
detail="Identity not found или tg_id уже привязан к другой идентичности",
|
||||
)
|
||||
return IdentityResponse.model_validate(identity)
|
||||
@@ -1,5 +0,0 @@
|
||||
from . import admin, user # noqa: F401 — import triggers endpoint registration
|
||||
from ._common import router, user_router
|
||||
|
||||
|
||||
__all__ = ["router", "user_router"]
|
||||
@@ -1,271 +0,0 @@
|
||||
import asyncio
|
||||
import re
|
||||
|
||||
from base64 import b64encode
|
||||
from datetime import datetime
|
||||
from io import BytesIO
|
||||
from math import ceil
|
||||
from typing import Any
|
||||
from urllib.parse import urlsplit
|
||||
|
||||
import qrcode
|
||||
|
||||
from fastapi import APIRouter, Body, Depends, HTTPException, Path, Query, Request, status
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from api.depends import (
|
||||
get_request_actor,
|
||||
get_session,
|
||||
validate_redirect_url,
|
||||
verify_identity_admin,
|
||||
verify_identity_token,
|
||||
)
|
||||
from api.v2.base_crud import generate_crud_router
|
||||
from api.v2.routes.coupon_pricing import resolve_percent_coupon_pricing
|
||||
from api.v2.schemas import KeyBase, KeyCreateRequest, KeyResponse, KeyUpdate
|
||||
from api.v2.schemas.web_public import (
|
||||
AccountKeyActionResponse,
|
||||
AccountKeyActionsAvailability,
|
||||
AccountKeyActionsConfigResponse,
|
||||
AccountKeyAddonOptionResponse,
|
||||
AccountKeyAddonsPreviewRequest,
|
||||
AccountKeyAddonsPreviewResponse,
|
||||
AccountKeyAliasUpdateRequest,
|
||||
AccountKeyApplyAddonsResponse,
|
||||
AccountKeyChangeLocationRequest,
|
||||
AccountKeyChangeLocationResponse,
|
||||
AccountKeyConnectionResponse,
|
||||
AccountKeyDetailsResponse,
|
||||
AccountKeyLocationOptionResponse,
|
||||
AccountKeyLocationsResponse,
|
||||
AccountKeyQrResponse,
|
||||
AccountKeyRenewRequest,
|
||||
AccountKeyRenewResponse,
|
||||
AccountKeyResetHwidResponse,
|
||||
AccountKeyResponse,
|
||||
)
|
||||
from config import (
|
||||
ENABLE_DELETE_KEY_BUTTON,
|
||||
HWID_RESET_BUTTON,
|
||||
INSTRUCTIONS_BUTTON,
|
||||
QRCODE,
|
||||
REMNAWAVE_LOGIN,
|
||||
REMNAWAVE_PASSWORD,
|
||||
USE_COUNTRY_SELECTION,
|
||||
)
|
||||
from core.bootstrap import BUTTONS_CONFIG, MODES_CONFIG, PAYMENTS_CONFIG, TARIFFS_CONFIG
|
||||
from core.settings.tariffs_config import normalize_tariff_config
|
||||
from database import (
|
||||
check_server_name_by_cluster,
|
||||
filter_cluster_by_subgroup,
|
||||
get_balance,
|
||||
get_key_details,
|
||||
get_keys,
|
||||
get_tariff_by_id,
|
||||
identities as idb,
|
||||
save_key_config_with_mode,
|
||||
update_balance,
|
||||
)
|
||||
from database.access.resolution import resolve_user_optional
|
||||
from database.coupons import mark_coupon_used
|
||||
from database.models import Key, Server, ServerSpecialgroup, Tariff
|
||||
from database.temporary_data import create_temporary_data
|
||||
from handlers.buttons import CONNECT_DEVICE, ROUTER_BUTTON, TV_BUTTON
|
||||
from handlers.keys.key_view import build_key_view_payload
|
||||
from handlers.tariffs.addons.key_addons_pack import calc_pack_full_price_rub, get_pack_flags
|
||||
from handlers.tariffs.addons.utils import calc_remaining_ratio_seconds, is_not_downgrade
|
||||
from handlers.utils import ALLOWED_GROUP_CODES, is_full_remnawave_cluster
|
||||
from logger import logger
|
||||
from panels._3xui import delete_client, get_xui_instance
|
||||
from panels.remnawave import RemnawaveAPI, get_vless_link_for_remnawave_by_username
|
||||
from panels.remnawave_runtime import get_remnawave_profile, invalidate_remnawave_profile, with_remnawave_api
|
||||
from services.operations import (
|
||||
create_client_on_server,
|
||||
create_key_on_cluster,
|
||||
delete_key_from_cluster,
|
||||
renew_key_in_cluster,
|
||||
)
|
||||
from services.operations.aggregated_links import make_aggregated_link
|
||||
from services.payments.payment_links import PaymentLinkRequest, create_payment_link
|
||||
from services.payments.providers import WEB_LINK_PROVIDER_IDS
|
||||
from services.tariffs import calculate_config_price
|
||||
from services.tariffs.tariff_display import GB, get_effective_limits_for_key, get_key_tariff_addons_state
|
||||
|
||||
|
||||
router = generate_crud_router(
|
||||
model=Key,
|
||||
schema_response=KeyResponse,
|
||||
schema_create=KeyBase,
|
||||
schema_update=KeyUpdate,
|
||||
identifier_field="tg_id",
|
||||
extra_get_by_email=True,
|
||||
enabled_methods=["get_all", "get_one", "get_by_email", "get_all_by_field"],
|
||||
)
|
||||
user_router = APIRouter()
|
||||
|
||||
|
||||
def _key_actions_config() -> AccountKeyActionsConfigResponse:
|
||||
addons_mode = str(TARIFFS_CONFIG.get("KEY_ADDONS_PACK_MODE", "") or "").strip().lower()
|
||||
if addons_mode not in {"", "traffic", "devices", "all"}:
|
||||
addons_mode = ""
|
||||
addons_enabled_default = addons_mode in {"", "traffic", "devices", "all"}
|
||||
return AccountKeyActionsConfigResponse(
|
||||
renew_enabled=True,
|
||||
delete_enabled=bool(BUTTONS_CONFIG.get("DELETE_KEY_BUTTON_ENABLE", ENABLE_DELETE_KEY_BUTTON)),
|
||||
qr_enabled=bool(BUTTONS_CONFIG.get("QRCODE_BUTTON_ENABLE", QRCODE)),
|
||||
hwid_reset_enabled=bool(BUTTONS_CONFIG.get("HWID_RESET_BUTTON_ENABLE", HWID_RESET_BUTTON)),
|
||||
country_change_enabled=bool(MODES_CONFIG.get("COUNTRY_SELECTION_ENABLED", USE_COUNTRY_SELECTION)),
|
||||
instructions_enabled=bool(BUTTONS_CONFIG.get("INSTRUCTIONS_BUTTON_ENABLE", INSTRUCTIONS_BUTTON)),
|
||||
addons_enabled=addons_enabled_default,
|
||||
addons_mode=addons_mode,
|
||||
tv_connect_enabled=bool(BUTTONS_CONFIG.get("ANDROID_TV_BUTTON_ENABLE")),
|
||||
)
|
||||
|
||||
|
||||
def _extract_key_actions_from_markup(markup) -> AccountKeyActionsAvailability:
|
||||
actions = AccountKeyActionsAvailability()
|
||||
rows = getattr(markup, "inline_keyboard", None) or []
|
||||
for row in rows:
|
||||
for button in row:
|
||||
callback_data = str(getattr(button, "callback_data", "") or "")
|
||||
text = str(getattr(button, "text", "") or "")
|
||||
has_url = bool(getattr(button, "url", None))
|
||||
has_web_app = bool(getattr(button, "web_app", None))
|
||||
if callback_data.startswith("connect_router|") or text == ROUTER_BUTTON:
|
||||
actions.can_connect_router = True
|
||||
if callback_data.startswith("connect_tv|") or text == TV_BUTTON:
|
||||
actions.can_connect_tv = True
|
||||
if callback_data.startswith("connect_device|") or (text == CONNECT_DEVICE and (has_url or has_web_app)):
|
||||
actions.can_connect_device = True
|
||||
if callback_data.startswith("renew_key|"):
|
||||
actions.can_renew = True
|
||||
if callback_data.startswith("key_addons|"):
|
||||
actions.can_addons = True
|
||||
if callback_data.startswith("reset_hwid|"):
|
||||
actions.can_reset_hwid = True
|
||||
if callback_data.startswith("show_qr|"):
|
||||
actions.can_qr = True
|
||||
if callback_data.startswith("delete_key|"):
|
||||
actions.can_delete = True
|
||||
if callback_data.startswith("change_location|"):
|
||||
actions.can_change_location = True
|
||||
return actions
|
||||
|
||||
|
||||
async def _resolve_available_location_servers(session: AsyncSession, db_key: Key) -> list[str]:
|
||||
current_server = str(getattr(db_key, "server_id", "") or "")
|
||||
if not current_server:
|
||||
return []
|
||||
cluster_info = await check_server_name_by_cluster(session, current_server)
|
||||
if not cluster_info:
|
||||
return []
|
||||
cluster_name = str(cluster_info.get("cluster_name") or "")
|
||||
if not cluster_name:
|
||||
return []
|
||||
q = (
|
||||
select(
|
||||
Server.id,
|
||||
Server.server_name,
|
||||
Server.api_url,
|
||||
Server.panel_type,
|
||||
Server.enabled,
|
||||
Server.max_keys,
|
||||
)
|
||||
.where(Server.cluster_name == cluster_name)
|
||||
.where(Server.server_name != current_server)
|
||||
)
|
||||
servers = [dict(m) for m in (await session.execute(q)).mappings().all()]
|
||||
if not servers:
|
||||
return []
|
||||
server_ids = [s["id"] for s in servers if s.get("id") is not None]
|
||||
groups_map: dict[int, list[str]] = {}
|
||||
if server_ids:
|
||||
r = await session.execute(
|
||||
select(ServerSpecialgroup.server_id, ServerSpecialgroup.group_code).where(
|
||||
ServerSpecialgroup.server_id.in_(server_ids)
|
||||
)
|
||||
)
|
||||
for sid, gc in r.all():
|
||||
groups_map.setdefault(int(sid), []).append(gc)
|
||||
for server in servers:
|
||||
sid_raw = server.get("id")
|
||||
sid = int(sid_raw) if sid_raw is not None else -1
|
||||
server["special_groups"] = [g for g in groups_map.get(sid, []) if g in ALLOWED_GROUP_CODES]
|
||||
key_tariff_id = getattr(db_key, "tariff_id", None)
|
||||
subgroup_title = None
|
||||
tariff_dict = None
|
||||
if key_tariff_id:
|
||||
tariff_dict = await get_tariff_by_id(session, int(key_tariff_id))
|
||||
if tariff_dict:
|
||||
subgroup_title = tariff_dict.get("subgroup_title")
|
||||
available_servers = [s for s in servers if bool(s.get("enabled", True))]
|
||||
if subgroup_title and available_servers:
|
||||
filtered_servers = await filter_cluster_by_subgroup(
|
||||
session=session,
|
||||
cluster=available_servers,
|
||||
target_subgroup=str(subgroup_title).strip(),
|
||||
cluster_id=cluster_name,
|
||||
tariff_id=int(key_tariff_id) if key_tariff_id else None,
|
||||
)
|
||||
if filtered_servers:
|
||||
available_servers = filtered_servers
|
||||
else:
|
||||
available_servers = []
|
||||
if available_servers and tariff_dict:
|
||||
special = None
|
||||
gc = str(tariff_dict.get("group_code") or "").lower()
|
||||
if gc and gc in ALLOWED_GROUP_CODES:
|
||||
special = gc
|
||||
if special:
|
||||
bound_servers = [s for s in available_servers if special in (s.get("special_groups") or [])]
|
||||
if bound_servers:
|
||||
available_servers = bound_servers
|
||||
names = sorted({
|
||||
str(s.get("server_name") or "").strip() for s in available_servers if str(s.get("server_name") or "").strip()
|
||||
})
|
||||
return names
|
||||
|
||||
|
||||
async def _resolve_billing_user_id(request: Request, identity, session: AsyncSession) -> int:
|
||||
actor = get_request_actor(request)
|
||||
billing_user_id = actor.billing_user_id if actor and actor.billing_user_id is not None else None
|
||||
if billing_user_id is None:
|
||||
billing_user_id = await idb.ensure_billing_user_for_identity(session, identity)
|
||||
return int(billing_user_id)
|
||||
|
||||
|
||||
def _resolve_public_base_url(request: Request) -> str:
|
||||
origin = str(request.headers.get("origin") or "").strip()
|
||||
if origin.startswith(("http://", "https://")):
|
||||
return origin.rstrip("/")
|
||||
referer = str(request.headers.get("referer") or request.headers.get("referrer") or "").strip()
|
||||
if referer.startswith(("http://", "https://")):
|
||||
parsed = urlsplit(referer)
|
||||
if parsed.scheme and parsed.netloc:
|
||||
return f"{parsed.scheme}://{parsed.netloc}".rstrip("/")
|
||||
forwarded_host = str(request.headers.get("x-forwarded-host") or "").strip()
|
||||
host = forwarded_host or str(request.headers.get("host") or "").strip()
|
||||
forwarded_proto = str(request.headers.get("x-forwarded-proto") or "").split(",", 1)[0].strip().lower()
|
||||
scheme = forwarded_proto if forwarded_proto in {"http", "https"} else request.url.scheme
|
||||
if host:
|
||||
return f"{scheme}://{host}".rstrip("/")
|
||||
return str(request.base_url).rstrip("/")
|
||||
|
||||
|
||||
def _resolve_default_web_payment_provider() -> str | None:
|
||||
for provider_id in WEB_LINK_PROVIDER_IDS:
|
||||
if bool(PAYMENTS_CONFIG.get(provider_id)):
|
||||
return provider_id
|
||||
return WEB_LINK_PROVIDER_IDS[0] if WEB_LINK_PROVIDER_IDS else None
|
||||
|
||||
|
||||
def _normalize_expiry_ms(raw_value: int | float | None) -> int:
|
||||
if not raw_value:
|
||||
return 0
|
||||
value = int(raw_value)
|
||||
if value > 10**13:
|
||||
value //= 1000
|
||||
elif value < 10**10:
|
||||
value *= 1000
|
||||
return value
|
||||
@@ -1,115 +0,0 @@
|
||||
from ._common import * # noqa: F401,F403
|
||||
from ._common import router, user_router # noqa: F401
|
||||
|
||||
|
||||
@router.delete("/by_email/{email}", response_model=dict)
|
||||
async def delete_key_by_email(
|
||||
email: str = Path(..., description="Email клиента"),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
identity=Depends(verify_identity_admin),
|
||||
):
|
||||
"""Удаляет ключ по email с кластера и из БД."""
|
||||
result = await session.execute(select(Key).where(Key.email == email))
|
||||
db_key = result.scalar_one_or_none()
|
||||
if not db_key:
|
||||
raise HTTPException(status_code=404, detail="Ключ не найден")
|
||||
try:
|
||||
await delete_key_from_cluster(
|
||||
session=session,
|
||||
email=db_key.email,
|
||||
client_id=db_key.client_id,
|
||||
cluster_id=db_key.server_id,
|
||||
)
|
||||
await session.delete(db_key)
|
||||
logger.info(f"[API] Ключ удалён: {db_key.client_id}")
|
||||
return {"message": "Ключ успешно удалён"}
|
||||
except Exception as e:
|
||||
logger.error(f"[API] Ошибка при удалении ключа: {e}")
|
||||
raise HTTPException(status_code=500, detail="Ошибка при удалении ключа")
|
||||
|
||||
|
||||
@router.get("/routers/{tg_id}", response_model=list[KeyResponse])
|
||||
async def get_router_keys_by_tg_id(
|
||||
tg_id: int = Path(..., description="Telegram ID пользователя"),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
identity=Depends(verify_identity_admin),
|
||||
):
|
||||
"""Список ключей пользователя с тарифами группы routers."""
|
||||
tariffs_result = await session.execute(select(Tariff.id).where(Tariff.group_code == "routers"))
|
||||
tariff_ids = [row[0] for row in tariffs_result.all()]
|
||||
if not tariff_ids:
|
||||
return []
|
||||
u = await resolve_user_optional(session, tg_id)
|
||||
if u is None:
|
||||
return []
|
||||
keys_result = await session.execute(select(Key).where(Key.user_id == u.id, Key.tariff_id.in_(tariff_ids)))
|
||||
return keys_result.scalars().all()
|
||||
|
||||
|
||||
@router.patch("/edit/by_email/{email}", response_model=KeyResponse)
|
||||
async def edit_key_by_email(
|
||||
email: str = Path(..., description="Email клиента"),
|
||||
key_update: KeyUpdate = Body(...),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
identity=Depends(verify_identity_admin),
|
||||
):
|
||||
"""Обновляет ключ по email и синхронизирует с кластером."""
|
||||
result = await session.execute(select(Key).where(Key.email == email))
|
||||
db_key = result.scalar_one_or_none()
|
||||
if not db_key:
|
||||
raise HTTPException(status_code=404, detail="Ключ не найден")
|
||||
for field, value in key_update.model_dump(exclude_unset=True).items():
|
||||
if field == "expiry_time" and value is not None:
|
||||
if isinstance(value, int):
|
||||
ms = value
|
||||
elif isinstance(value, datetime):
|
||||
ms = int(value.timestamp() * 1000)
|
||||
else:
|
||||
raise HTTPException(status_code=400, detail="Некорректный формат времени")
|
||||
setattr(db_key, field, ms)
|
||||
else:
|
||||
setattr(db_key, field, value)
|
||||
try:
|
||||
new_expiry_time = db_key.expiry_time
|
||||
await renew_key_in_cluster(
|
||||
cluster_id=db_key.server_id,
|
||||
email=db_key.email,
|
||||
client_id=db_key.client_id,
|
||||
new_expiry_time=new_expiry_time,
|
||||
total_gb=getattr(db_key, "traffic_limit", None),
|
||||
session=session,
|
||||
hwid_device_limit=getattr(db_key, "device_limit", None),
|
||||
reset_traffic=True,
|
||||
)
|
||||
logger.info(f"[API] Ключ обновлён: {db_key.client_id}")
|
||||
return db_key
|
||||
except Exception as e:
|
||||
logger.error(f"[API] Ошибка при обновлении ключа: {e}")
|
||||
raise HTTPException(status_code=500, detail="Ошибка при обновлении ключа")
|
||||
|
||||
|
||||
@router.post("/create", response_model=dict, status_code=status.HTTP_201_CREATED)
|
||||
async def create_key_api(
|
||||
payload: KeyCreateRequest = Body(...),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
identity=Depends(verify_identity_admin),
|
||||
):
|
||||
"""Создаёт ключ на кластере."""
|
||||
try:
|
||||
await create_key_on_cluster(
|
||||
cluster_id=payload.cluster_id,
|
||||
tg_id=payload.tg_id,
|
||||
client_id=payload.client_id,
|
||||
email=payload.email or f"{payload.tg_id}_key",
|
||||
expiry_timestamp=payload.expiry_timestamp,
|
||||
plan=payload.tariff_id,
|
||||
session=session,
|
||||
remnawave_link=payload.remnawave_link,
|
||||
hwid_limit=payload.hwid_limit,
|
||||
traffic_limit_bytes=payload.traffic_limit_bytes,
|
||||
is_trial=payload.is_trial or False,
|
||||
)
|
||||
return {"message": "Ключ успешно создан"}
|
||||
except Exception as e:
|
||||
logger.error(f"[API] Ошибка при создании ключа: {e}")
|
||||
raise HTTPException(status_code=500, detail="Ошибка при создании ключа")
|
||||
@@ -1 +0,0 @@
|
||||
from . import addons, core, hwid, location, renew # noqa: F401 — trigger registration
|
||||
@@ -1,662 +0,0 @@
|
||||
"""User-facing key endpoints (/api/keys/*).
|
||||
|
||||
Регистрирует эндпоинты на ``user_router`` из ``_common``. Импорт этого модуля
|
||||
из ``__init__.py`` запускает регистрацию декораторов.
|
||||
"""
|
||||
|
||||
from .._common import * # noqa: F401,F403 — подтягиваем все имена для endpoints
|
||||
from .._common import (
|
||||
_key_actions_config,
|
||||
_normalize_expiry_ms,
|
||||
_resolve_available_location_servers,
|
||||
_resolve_billing_user_id,
|
||||
_resolve_default_web_payment_provider,
|
||||
_resolve_public_base_url,
|
||||
router,
|
||||
user_router,
|
||||
)
|
||||
|
||||
|
||||
@user_router.get("/{client_id}/addons-preview", response_model=AccountKeyAddonsPreviewResponse)
|
||||
async def user_key_addons_preview(
|
||||
client_id: str,
|
||||
request: Request,
|
||||
selected_device_limit: int | None = Query(None),
|
||||
selected_traffic_gb: int | None = Query(None),
|
||||
include_device: bool | None = Query(None),
|
||||
include_traffic: bool | None = Query(None),
|
||||
coupon_code: str | None = Query(None),
|
||||
force_web: bool = Query(False),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
identity=Depends(verify_identity_token),
|
||||
):
|
||||
actions = _key_actions_config()
|
||||
if not force_web and not actions.addons_enabled:
|
||||
raise HTTPException(status_code=403, detail="Доп. опции отключены в настройках")
|
||||
billing_user_id = await _resolve_billing_user_id(request, identity, session)
|
||||
db_key = (
|
||||
await session.execute(select(Key).where(Key.user_id == billing_user_id, Key.client_id == client_id).limit(1))
|
||||
).scalar_one_or_none()
|
||||
if db_key is None:
|
||||
raise HTTPException(status_code=404, detail="Подписка не найдена")
|
||||
tariff_id = getattr(db_key, "tariff_id", None)
|
||||
if not tariff_id:
|
||||
raise HTTPException(status_code=400, detail="Для подписки не назначен тариф")
|
||||
tariff = await get_tariff_by_id(session, int(tariff_id))
|
||||
if not tariff:
|
||||
raise HTTPException(status_code=404, detail="Тариф не найден")
|
||||
key_details = await get_key_details(session, str(getattr(db_key, "email", "") or ""))
|
||||
if not key_details:
|
||||
raise HTTPException(status_code=404, detail="Подписка не найдена")
|
||||
(
|
||||
_tariff_name,
|
||||
_subgroup_title,
|
||||
_traffic_limit_gb,
|
||||
_device_limit,
|
||||
_panel,
|
||||
is_tariff_configurable,
|
||||
addons_devices_enabled,
|
||||
addons_traffic_enabled,
|
||||
) = await get_key_tariff_addons_state(session=session, key_record=key_details, db_key=db_key)
|
||||
if not is_tariff_configurable:
|
||||
raise HTTPException(status_code=400, detail="Тариф не поддерживает доп. опции")
|
||||
cfg = normalize_tariff_config(tariff)
|
||||
raw_device_options = cfg.get("device_options") or tariff.get("device_options") or []
|
||||
raw_traffic_options = cfg.get("traffic_options_gb") or tariff.get("traffic_options_gb") or []
|
||||
device_options: list[int] = []
|
||||
for value in raw_device_options:
|
||||
try:
|
||||
device_options.append(int(value))
|
||||
except (TypeError, ValueError):
|
||||
continue
|
||||
traffic_options: list[int] = []
|
||||
for value in raw_traffic_options:
|
||||
try:
|
||||
traffic_options.append(int(value))
|
||||
except (TypeError, ValueError):
|
||||
continue
|
||||
device_options = sorted(set(device_options), key=lambda val: (int(val == 0), val))
|
||||
traffic_options = sorted(set(traffic_options), key=lambda val: (int(val == 0), val))
|
||||
has_device_option = bool(device_options) and bool(addons_devices_enabled)
|
||||
has_traffic_option = bool(traffic_options) and bool(addons_traffic_enabled)
|
||||
pack_devices, pack_traffic, pack_mode = get_pack_flags()
|
||||
if pack_mode:
|
||||
has_device_option = has_device_option and bool(pack_devices)
|
||||
has_traffic_option = has_traffic_option and bool(pack_traffic)
|
||||
if not has_device_option:
|
||||
device_options = []
|
||||
if not has_traffic_option:
|
||||
traffic_options = []
|
||||
if not has_device_option and not has_traffic_option:
|
||||
raise HTTPException(status_code=400, detail="Доп. опции для этой подписки недоступны")
|
||||
selected_device_limit_db = key_details.get("selected_device_limit")
|
||||
selected_traffic_limit_db = key_details.get("selected_traffic_limit")
|
||||
current_device_limit_db = key_details.get("current_device_limit")
|
||||
current_traffic_limit_db = key_details.get("current_traffic_limit")
|
||||
base_devices = tariff.get("device_limit")
|
||||
base_devices = int(base_devices) if base_devices is not None else None
|
||||
base_traffic_bytes = tariff.get("traffic_limit")
|
||||
base_traffic_gb_from_tariff = int(base_traffic_bytes / GB) if base_traffic_bytes else None
|
||||
current_device_limit = (
|
||||
int(current_device_limit_db)
|
||||
if current_device_limit_db is not None
|
||||
else (int(selected_device_limit_db) if selected_device_limit_db is not None else base_devices)
|
||||
)
|
||||
current_traffic_gb = (
|
||||
int(current_traffic_limit_db)
|
||||
if current_traffic_limit_db is not None
|
||||
else (int(selected_traffic_limit_db) if selected_traffic_limit_db is not None else base_traffic_gb_from_tariff)
|
||||
)
|
||||
if pack_mode and current_device_limit is not None and int(current_device_limit) == 0:
|
||||
has_device_option = False
|
||||
device_options = []
|
||||
if pack_mode and current_traffic_gb is not None and int(current_traffic_gb) == 0:
|
||||
has_traffic_option = False
|
||||
traffic_options = []
|
||||
if not has_device_option and not has_traffic_option:
|
||||
raise HTTPException(status_code=400, detail="Доп. опции для этой подписки недоступны")
|
||||
if pack_mode:
|
||||
include_device_effective = (
|
||||
bool(include_device) if include_device is not None else selected_device_limit is not None
|
||||
)
|
||||
include_traffic_effective = (
|
||||
bool(include_traffic) if include_traffic is not None else selected_traffic_gb is not None
|
||||
)
|
||||
selected_device = selected_device_limit if selected_device_limit is not None else None
|
||||
selected_traffic = selected_traffic_gb if selected_traffic_gb is not None else None
|
||||
else:
|
||||
include_device_effective = has_device_option
|
||||
include_traffic_effective = has_traffic_option
|
||||
selected_device = selected_device_limit if selected_device_limit is not None else current_device_limit
|
||||
selected_traffic = selected_traffic_gb if selected_traffic_gb is not None else current_traffic_gb
|
||||
if (
|
||||
has_device_option
|
||||
and include_device_effective
|
||||
and selected_device is not None
|
||||
and int(selected_device) not in device_options
|
||||
):
|
||||
raise HTTPException(status_code=400, detail="Выбранный пакет устройств недоступен")
|
||||
if (
|
||||
has_traffic_option
|
||||
and include_traffic_effective
|
||||
and selected_traffic is not None
|
||||
and int(selected_traffic) not in traffic_options
|
||||
):
|
||||
raise HTTPException(status_code=400, detail="Выбранный пакет трафика недоступен")
|
||||
current_devices_for_price = int(current_device_limit) if current_device_limit is not None else None
|
||||
current_traffic_for_price = int(current_traffic_gb) if current_traffic_gb is not None else None
|
||||
base_price_for_current = int(
|
||||
calculate_config_price(
|
||||
tariff=tariff,
|
||||
selected_device_limit=current_devices_for_price,
|
||||
selected_traffic_gb=current_traffic_for_price,
|
||||
)
|
||||
)
|
||||
if pack_mode:
|
||||
diff_full = int(
|
||||
calc_pack_full_price_rub(
|
||||
tariff=tariff,
|
||||
has_device_option=bool(has_device_option and include_device_effective),
|
||||
has_traffic_option=bool(has_traffic_option and include_traffic_effective),
|
||||
selected_devices=int(selected_device)
|
||||
if has_device_option and include_device_effective and selected_device is not None
|
||||
else None,
|
||||
selected_traffic_gb=int(selected_traffic)
|
||||
if has_traffic_option and include_traffic_effective and selected_traffic is not None
|
||||
else None,
|
||||
)
|
||||
)
|
||||
recalc_enabled = bool(
|
||||
MODES_CONFIG.get(
|
||||
"KEY_ADDONS_RECALC_PRICE",
|
||||
TARIFFS_CONFIG.get("KEY_ADDONS_RECALC_PRICE", False),
|
||||
)
|
||||
)
|
||||
if recalc_enabled:
|
||||
remaining_seconds, total_seconds = calc_remaining_ratio_seconds(
|
||||
key_details.get("expiry_time"),
|
||||
tariff,
|
||||
)
|
||||
extra_price_rub = int((diff_full * remaining_seconds + total_seconds - 1) // total_seconds)
|
||||
else:
|
||||
extra_price_rub = diff_full
|
||||
total_price_rub = int(base_price_for_current + diff_full)
|
||||
else:
|
||||
total_price_rub = int(
|
||||
calculate_config_price(
|
||||
tariff=tariff,
|
||||
selected_device_limit=int(selected_device)
|
||||
if has_device_option and include_device_effective and selected_device is not None
|
||||
else None,
|
||||
selected_traffic_gb=int(selected_traffic)
|
||||
if has_traffic_option and include_traffic_effective and selected_traffic is not None
|
||||
else None,
|
||||
)
|
||||
)
|
||||
extra_price_rub = int(max(0, total_price_rub - base_price_for_current))
|
||||
final_extra_price_rub, discount_rub, _coupon_id, applied_coupon_code = await resolve_percent_coupon_pricing(
|
||||
session=session,
|
||||
billing_user_id=int(billing_user_id),
|
||||
base_price_rub=int(max(0, extra_price_rub)),
|
||||
coupon_code=coupon_code,
|
||||
)
|
||||
return AccountKeyAddonsPreviewResponse(
|
||||
client_id=str(getattr(db_key, "client_id", "") or ""),
|
||||
tariff_id=int(tariff_id),
|
||||
addons_mode=str(pack_mode or ""),
|
||||
has_device_option=bool(has_device_option),
|
||||
has_traffic_option=bool(has_traffic_option),
|
||||
current_device_limit=int(current_device_limit) if current_device_limit is not None else None,
|
||||
current_traffic_gb=int(current_traffic_gb) if current_traffic_gb is not None else None,
|
||||
selected_device_limit=int(selected_device)
|
||||
if has_device_option and include_device_effective and selected_device is not None
|
||||
else None,
|
||||
selected_traffic_gb=int(selected_traffic)
|
||||
if has_traffic_option and include_traffic_effective and selected_traffic is not None
|
||||
else None,
|
||||
device_options=[
|
||||
AccountKeyAddonOptionResponse(
|
||||
value=int(val),
|
||||
label=(
|
||||
"Безлимит устройств"
|
||||
if int(val) <= 0
|
||||
else (f"+{int(val)} устройств" if pack_mode else f"{int(val)} устройств")
|
||||
),
|
||||
)
|
||||
for val in device_options
|
||||
],
|
||||
traffic_options=[
|
||||
AccountKeyAddonOptionResponse(
|
||||
value=int(val),
|
||||
label=("Безлимит трафика" if int(val) <= 0 else (f"+{int(val)} ГБ" if pack_mode else f"{int(val)} ГБ")),
|
||||
)
|
||||
for val in traffic_options
|
||||
],
|
||||
total_price_rub=int(total_price_rub),
|
||||
extra_price_rub=int(max(0, extra_price_rub)),
|
||||
discount_rub=int(discount_rub),
|
||||
final_price_rub=int(max(0, final_extra_price_rub)),
|
||||
applied_coupon_code=applied_coupon_code,
|
||||
balance_rub=float(await get_balance(session, int(billing_user_id))),
|
||||
)
|
||||
|
||||
|
||||
@user_router.post("/{client_id}/apply-addons", response_model=AccountKeyApplyAddonsResponse)
|
||||
async def user_key_apply_addons(
|
||||
client_id: str,
|
||||
body: AccountKeyAddonsPreviewRequest,
|
||||
request: Request,
|
||||
force_web: bool = Query(False),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
identity=Depends(verify_identity_token),
|
||||
):
|
||||
actions = _key_actions_config()
|
||||
if not force_web and not actions.addons_enabled:
|
||||
raise HTTPException(status_code=403, detail="Доп. опции отключены в настройках")
|
||||
billing_user_id = await _resolve_billing_user_id(request, identity, session)
|
||||
db_key = (
|
||||
await session.execute(select(Key).where(Key.user_id == billing_user_id, Key.client_id == client_id).limit(1))
|
||||
).scalar_one_or_none()
|
||||
if db_key is None:
|
||||
raise HTTPException(status_code=404, detail="Подписка не найдена")
|
||||
tariff_id = getattr(db_key, "tariff_id", None)
|
||||
if not tariff_id:
|
||||
raise HTTPException(status_code=400, detail="Для подписки не назначен тариф")
|
||||
tariff = await get_tariff_by_id(session, int(tariff_id))
|
||||
if not tariff:
|
||||
raise HTTPException(status_code=404, detail="Тариф не найден")
|
||||
key_details = await get_key_details(session, str(getattr(db_key, "email", "") or ""))
|
||||
if not key_details:
|
||||
raise HTTPException(status_code=404, detail="Подписка не найдена")
|
||||
(
|
||||
_tariff_name,
|
||||
_subgroup_title,
|
||||
_traffic_limit_gb,
|
||||
_device_limit,
|
||||
_panel,
|
||||
is_tariff_configurable,
|
||||
addons_devices_enabled,
|
||||
addons_traffic_enabled,
|
||||
) = await get_key_tariff_addons_state(session=session, key_record=key_details, db_key=db_key)
|
||||
if not is_tariff_configurable:
|
||||
raise HTTPException(status_code=400, detail="Тариф не поддерживает доп. опции")
|
||||
cfg = normalize_tariff_config(tariff)
|
||||
raw_device_options = cfg.get("device_options") or tariff.get("device_options") or []
|
||||
raw_traffic_options = cfg.get("traffic_options_gb") or tariff.get("traffic_options_gb") or []
|
||||
device_options: list[int] = []
|
||||
for value in raw_device_options:
|
||||
try:
|
||||
device_options.append(int(value))
|
||||
except (TypeError, ValueError):
|
||||
continue
|
||||
traffic_options: list[int] = []
|
||||
for value in raw_traffic_options:
|
||||
try:
|
||||
traffic_options.append(int(value))
|
||||
except (TypeError, ValueError):
|
||||
continue
|
||||
device_options = sorted(set(device_options), key=lambda val: (int(val == 0), val))
|
||||
traffic_options = sorted(set(traffic_options), key=lambda val: (int(val == 0), val))
|
||||
has_device_option = bool(device_options) and bool(addons_devices_enabled)
|
||||
has_traffic_option = bool(traffic_options) and bool(addons_traffic_enabled)
|
||||
pack_devices, pack_traffic, pack_mode = get_pack_flags()
|
||||
if pack_mode:
|
||||
has_device_option = has_device_option and bool(pack_devices)
|
||||
has_traffic_option = has_traffic_option and bool(pack_traffic)
|
||||
if not has_device_option:
|
||||
device_options = []
|
||||
if not has_traffic_option:
|
||||
traffic_options = []
|
||||
if not has_device_option and not has_traffic_option:
|
||||
raise HTTPException(status_code=400, detail="Доп. опции для этой подписки недоступны")
|
||||
selected_device_limit_db = key_details.get("selected_device_limit")
|
||||
selected_traffic_limit_db = key_details.get("selected_traffic_limit")
|
||||
current_device_limit_db = key_details.get("current_device_limit")
|
||||
current_traffic_limit_db = key_details.get("current_traffic_limit")
|
||||
base_devices = tariff.get("device_limit")
|
||||
base_devices = int(base_devices) if base_devices is not None else None
|
||||
base_traffic_bytes = tariff.get("traffic_limit")
|
||||
base_traffic_gb_from_tariff = int(base_traffic_bytes / GB) if base_traffic_bytes else None
|
||||
current_device_limit = (
|
||||
int(current_device_limit_db)
|
||||
if current_device_limit_db is not None
|
||||
else (int(selected_device_limit_db) if selected_device_limit_db is not None else base_devices)
|
||||
)
|
||||
current_traffic_gb = (
|
||||
int(current_traffic_limit_db)
|
||||
if current_traffic_limit_db is not None
|
||||
else (int(selected_traffic_limit_db) if selected_traffic_limit_db is not None else base_traffic_gb_from_tariff)
|
||||
)
|
||||
if pack_mode and current_device_limit is not None and int(current_device_limit) == 0:
|
||||
has_device_option = False
|
||||
device_options = []
|
||||
if pack_mode and current_traffic_gb is not None and int(current_traffic_gb) == 0:
|
||||
has_traffic_option = False
|
||||
traffic_options = []
|
||||
if not has_device_option and not has_traffic_option:
|
||||
raise HTTPException(status_code=400, detail="Доп. опции для этой подписки недоступны")
|
||||
if pack_mode:
|
||||
include_device_effective = (
|
||||
bool(body.include_device) if body.include_device is not None else body.selected_device_limit is not None
|
||||
)
|
||||
include_traffic_effective = (
|
||||
bool(body.include_traffic) if body.include_traffic is not None else body.selected_traffic_gb is not None
|
||||
)
|
||||
selected_device = body.selected_device_limit if body.selected_device_limit is not None else None
|
||||
selected_traffic = body.selected_traffic_gb if body.selected_traffic_gb is not None else None
|
||||
else:
|
||||
include_device_effective = has_device_option
|
||||
include_traffic_effective = has_traffic_option
|
||||
selected_device = body.selected_device_limit if body.selected_device_limit is not None else current_device_limit
|
||||
selected_traffic = body.selected_traffic_gb if body.selected_traffic_gb is not None else current_traffic_gb
|
||||
if (
|
||||
has_device_option
|
||||
and include_device_effective
|
||||
and selected_device is not None
|
||||
and int(selected_device) not in device_options
|
||||
):
|
||||
raise HTTPException(status_code=400, detail="Выбранный пакет устройств недоступен")
|
||||
if (
|
||||
has_traffic_option
|
||||
and include_traffic_effective
|
||||
and selected_traffic is not None
|
||||
and int(selected_traffic) not in traffic_options
|
||||
):
|
||||
raise HTTPException(status_code=400, detail="Выбранный пакет трафика недоступен")
|
||||
current_devices_for_price = int(current_device_limit) if current_device_limit is not None else None
|
||||
current_traffic_for_price = int(current_traffic_gb) if current_traffic_gb is not None else None
|
||||
base_price_for_current = int(
|
||||
calculate_config_price(
|
||||
tariff=tariff,
|
||||
selected_device_limit=current_devices_for_price,
|
||||
selected_traffic_gb=current_traffic_for_price,
|
||||
)
|
||||
)
|
||||
total_price_after_purchase = base_price_for_current
|
||||
if pack_mode:
|
||||
diff_full = int(
|
||||
calc_pack_full_price_rub(
|
||||
tariff=tariff,
|
||||
has_device_option=bool(has_device_option and include_device_effective),
|
||||
has_traffic_option=bool(has_traffic_option and include_traffic_effective),
|
||||
selected_devices=int(selected_device)
|
||||
if has_device_option and include_device_effective and selected_device is not None
|
||||
else None,
|
||||
selected_traffic_gb=int(selected_traffic)
|
||||
if has_traffic_option and include_traffic_effective and selected_traffic is not None
|
||||
else None,
|
||||
)
|
||||
)
|
||||
recalc_enabled = bool(
|
||||
MODES_CONFIG.get(
|
||||
"KEY_ADDONS_RECALC_PRICE",
|
||||
TARIFFS_CONFIG.get("KEY_ADDONS_RECALC_PRICE", False),
|
||||
)
|
||||
)
|
||||
if recalc_enabled:
|
||||
remaining_seconds, total_seconds = calc_remaining_ratio_seconds(
|
||||
key_details.get("expiry_time"),
|
||||
tariff,
|
||||
)
|
||||
extra_price_rub = int((diff_full * remaining_seconds + total_seconds - 1) // total_seconds)
|
||||
else:
|
||||
extra_price_rub = diff_full
|
||||
total_price_after_purchase = int(base_price_for_current + diff_full)
|
||||
else:
|
||||
selected_total_price = int(
|
||||
calculate_config_price(
|
||||
tariff=tariff,
|
||||
selected_device_limit=int(selected_device)
|
||||
if has_device_option and selected_device is not None
|
||||
else None,
|
||||
selected_traffic_gb=int(selected_traffic)
|
||||
if has_traffic_option and selected_traffic is not None
|
||||
else None,
|
||||
)
|
||||
)
|
||||
extra_price_rub = int(max(0, selected_total_price - base_price_for_current))
|
||||
total_price_after_purchase = selected_total_price
|
||||
allow_downgrade = bool(TARIFFS_CONFIG.get("ALLOW_DOWNGRADE", True))
|
||||
device_downgrade = (
|
||||
allow_downgrade
|
||||
and has_device_option
|
||||
and current_device_limit is not None
|
||||
and selected_device is not None
|
||||
and not is_not_downgrade(current_device_limit, selected_device)
|
||||
)
|
||||
traffic_downgrade = (
|
||||
allow_downgrade
|
||||
and has_traffic_option
|
||||
and current_traffic_gb is not None
|
||||
and selected_traffic is not None
|
||||
and not is_not_downgrade(current_traffic_gb, selected_traffic)
|
||||
)
|
||||
if device_downgrade or traffic_downgrade:
|
||||
raise HTTPException(status_code=400, detail="Снижение параметров через сайт пока не поддерживается")
|
||||
final_extra_price_rub, discount_rub, coupon_id, applied_coupon_code = await resolve_percent_coupon_pricing(
|
||||
session=session,
|
||||
billing_user_id=int(billing_user_id),
|
||||
base_price_rub=int(max(0, extra_price_rub)),
|
||||
coupon_code=body.coupon_code,
|
||||
)
|
||||
balance = float(await get_balance(session, int(billing_user_id)))
|
||||
required_amount = int(max(0, ceil(float(final_extra_price_rub) - balance)))
|
||||
if extra_price_rub <= 0:
|
||||
return AccountKeyApplyAddonsResponse(
|
||||
ok=True,
|
||||
message="Доплата не требуется",
|
||||
client_id=str(getattr(db_key, "client_id", "") or ""),
|
||||
tariff_id=int(tariff_id),
|
||||
total_price_rub=int(total_price_after_purchase),
|
||||
extra_price_rub=0,
|
||||
discount_rub=0,
|
||||
final_price_rub=0,
|
||||
applied_coupon_code=None,
|
||||
charged_rub=0,
|
||||
balance_rub=balance,
|
||||
)
|
||||
expiry_time = int(getattr(db_key, "expiry_time", 0) or 0)
|
||||
email = str(getattr(db_key, "email", "") or "")
|
||||
server_id = str(getattr(db_key, "server_id", "") or "")
|
||||
if not email or not server_id:
|
||||
raise HTTPException(status_code=400, detail="Некорректные данные подписки")
|
||||
if required_amount > 0:
|
||||
provider_id = str(body.provider_id or _resolve_default_web_payment_provider() or "").strip().upper()
|
||||
if not provider_id:
|
||||
raise HTTPException(status_code=503, detail="Нет доступных провайдеров оплаты")
|
||||
base_url = _resolve_public_base_url(request)
|
||||
success_url = validate_redirect_url(str(body.success_url or ""), f"{base_url}/payment-success")
|
||||
failure_url = validate_redirect_url(str(body.failure_url or ""), f"{base_url}/payment-failure")
|
||||
payment_request = PaymentLinkRequest(
|
||||
legacy_user_ref=int(billing_user_id),
|
||||
amount=required_amount,
|
||||
currency="RUB",
|
||||
provider_id=provider_id,
|
||||
success_url=success_url,
|
||||
failure_url=failure_url,
|
||||
metadata={
|
||||
"payment_flow": "key_addons",
|
||||
"tariff_id": int(tariff_id),
|
||||
"email": email,
|
||||
"selected_device_limit": int(selected_device)
|
||||
if has_device_option and include_device_effective and selected_device is not None
|
||||
else None,
|
||||
"selected_traffic_gb": int(selected_traffic)
|
||||
if has_traffic_option and include_traffic_effective and selected_traffic is not None
|
||||
else None,
|
||||
"current_device_limit": int(current_device_limit) if current_device_limit is not None else None,
|
||||
"current_traffic_gb": int(current_traffic_gb) if current_traffic_gb is not None else None,
|
||||
"original_price": int(base_price_for_current),
|
||||
"base_price_rub": int(max(0, extra_price_rub)),
|
||||
"discount_rub": int(discount_rub),
|
||||
"applied_coupon_code": applied_coupon_code,
|
||||
"coupon_id": int(coupon_id) if coupon_id is not None else None,
|
||||
},
|
||||
)
|
||||
payment_result = await create_payment_link(session, payment_request)
|
||||
if not payment_result.success or not payment_result.payment_url or not payment_result.payment_id:
|
||||
raise HTTPException(status_code=400, detail=payment_result.error or "Не удалось создать ссылку оплаты")
|
||||
await create_temporary_data(
|
||||
session,
|
||||
int(billing_user_id),
|
||||
"waiting_for_addons_payment",
|
||||
{
|
||||
"tariff_id": int(tariff_id),
|
||||
"email": email,
|
||||
"required_amount": int(required_amount),
|
||||
"selected_device_limit": int(selected_device)
|
||||
if has_device_option and include_device_effective and selected_device is not None
|
||||
else None,
|
||||
"selected_traffic_gb": int(selected_traffic)
|
||||
if has_traffic_option and include_traffic_effective and selected_traffic is not None
|
||||
else None,
|
||||
"current_device_limit": int(current_device_limit) if current_device_limit is not None else None,
|
||||
"current_traffic_gb": int(current_traffic_gb) if current_traffic_gb is not None else None,
|
||||
"original_price": int(base_price_for_current),
|
||||
"base_price_rub": int(max(0, extra_price_rub)),
|
||||
"discount_rub": int(discount_rub),
|
||||
"applied_coupon_code": applied_coupon_code,
|
||||
"coupon_id": int(coupon_id) if coupon_id is not None else None,
|
||||
},
|
||||
)
|
||||
return AccountKeyApplyAddonsResponse(
|
||||
ok=True,
|
||||
message="Требуется оплата для применения доп. опций",
|
||||
client_id=str(getattr(db_key, "client_id", "") or ""),
|
||||
tariff_id=int(tariff_id),
|
||||
total_price_rub=int(total_price_after_purchase),
|
||||
extra_price_rub=int(extra_price_rub),
|
||||
discount_rub=int(discount_rub),
|
||||
final_price_rub=int(final_extra_price_rub),
|
||||
applied_coupon_code=applied_coupon_code,
|
||||
charged_rub=0,
|
||||
balance_rub=balance,
|
||||
payment_required=True,
|
||||
required_amount_rub=required_amount,
|
||||
payment_id=payment_result.payment_id,
|
||||
payment_url=payment_result.payment_url,
|
||||
)
|
||||
target_subgroup = tariff.get("subgroup_title")
|
||||
current_subgroup = None
|
||||
current_tariff_id = key_details.get("tariff_id")
|
||||
if current_tariff_id:
|
||||
current_tariff = await get_tariff_by_id(session, int(current_tariff_id))
|
||||
if current_tariff:
|
||||
current_subgroup = current_tariff.get("subgroup_title")
|
||||
if pack_mode:
|
||||
device_limit_effective_current, traffic_limit_bytes_effective_current = await get_effective_limits_for_key(
|
||||
session=session,
|
||||
tariff_id=int(tariff_id),
|
||||
selected_device_limit=int(current_device_limit) if current_device_limit is not None else None,
|
||||
selected_traffic_gb=int(current_traffic_gb) if current_traffic_gb is not None else None,
|
||||
)
|
||||
traffic_limit_gb_effective_current = (
|
||||
int(traffic_limit_bytes_effective_current / GB) if traffic_limit_bytes_effective_current else 0
|
||||
)
|
||||
new_device_limit_effective = device_limit_effective_current
|
||||
new_traffic_limit_gb_effective = traffic_limit_gb_effective_current
|
||||
if has_device_option and include_device_effective and selected_device is not None:
|
||||
pack_devices_val = int(selected_device)
|
||||
if pack_devices_val <= 0 or (
|
||||
new_device_limit_effective is not None and int(new_device_limit_effective) <= 0
|
||||
):
|
||||
new_device_limit_effective = 0
|
||||
else:
|
||||
if new_device_limit_effective is None:
|
||||
new_device_limit_effective = pack_devices_val
|
||||
else:
|
||||
new_device_limit_effective = int(new_device_limit_effective) + pack_devices_val
|
||||
if has_traffic_option and include_traffic_effective and selected_traffic is not None:
|
||||
pack_traffic_val = int(selected_traffic)
|
||||
if pack_traffic_val <= 0 or int(new_traffic_limit_gb_effective) <= 0:
|
||||
new_traffic_limit_gb_effective = 0
|
||||
else:
|
||||
new_traffic_limit_gb_effective = int(new_traffic_limit_gb_effective) + pack_traffic_val
|
||||
await renew_key_in_cluster(
|
||||
cluster_id=server_id,
|
||||
email=email,
|
||||
client_id=str(getattr(db_key, "client_id", "") or ""),
|
||||
new_expiry_time=expiry_time,
|
||||
total_gb=int(new_traffic_limit_gb_effective),
|
||||
session=session,
|
||||
hwid_device_limit=int(new_device_limit_effective) if new_device_limit_effective is not None else 0,
|
||||
reset_traffic=False,
|
||||
target_subgroup=target_subgroup,
|
||||
old_subgroup=current_subgroup,
|
||||
plan=int(tariff_id),
|
||||
)
|
||||
await save_key_config_with_mode(
|
||||
session=session,
|
||||
email=email,
|
||||
selected_devices=int(new_device_limit_effective) if new_device_limit_effective is not None else None,
|
||||
selected_traffic_gb=int(new_traffic_limit_gb_effective)
|
||||
if new_traffic_limit_gb_effective is not None
|
||||
else None,
|
||||
total_price=int(total_price_after_purchase),
|
||||
has_device_choice=bool(has_device_option and include_device_effective),
|
||||
has_traffic_choice=bool(has_traffic_option and include_traffic_effective),
|
||||
config_mode="pack",
|
||||
)
|
||||
else:
|
||||
selected_device_for_effective = (
|
||||
int(selected_device)
|
||||
if has_device_option and include_device_effective and selected_device is not None
|
||||
else None
|
||||
)
|
||||
selected_traffic_for_effective = (
|
||||
int(selected_traffic)
|
||||
if has_traffic_option and include_traffic_effective and selected_traffic is not None
|
||||
else 0
|
||||
)
|
||||
device_limit_effective_new, traffic_limit_bytes_effective_new = await get_effective_limits_for_key(
|
||||
session=session,
|
||||
tariff_id=int(tariff_id),
|
||||
selected_device_limit=selected_device_for_effective,
|
||||
selected_traffic_gb=selected_traffic_for_effective,
|
||||
)
|
||||
traffic_limit_gb_effective = (
|
||||
int(traffic_limit_bytes_effective_new / GB) if traffic_limit_bytes_effective_new else 0
|
||||
)
|
||||
await renew_key_in_cluster(
|
||||
cluster_id=server_id,
|
||||
email=email,
|
||||
client_id=str(getattr(db_key, "client_id", "") or ""),
|
||||
new_expiry_time=expiry_time,
|
||||
total_gb=int(traffic_limit_gb_effective),
|
||||
session=session,
|
||||
hwid_device_limit=int(device_limit_effective_new) if device_limit_effective_new is not None else 0,
|
||||
reset_traffic=False,
|
||||
target_subgroup=target_subgroup,
|
||||
old_subgroup=current_subgroup,
|
||||
plan=int(tariff_id),
|
||||
)
|
||||
await save_key_config_with_mode(
|
||||
session=session,
|
||||
email=email,
|
||||
selected_devices=int(selected_device)
|
||||
if has_device_option and include_device_effective and selected_device is not None
|
||||
else None,
|
||||
selected_traffic_gb=int(selected_traffic)
|
||||
if has_traffic_option and include_traffic_effective and selected_traffic is not None
|
||||
else None,
|
||||
total_price=int(total_price_after_purchase),
|
||||
has_device_choice=bool(has_device_option and include_device_effective),
|
||||
has_traffic_choice=bool(has_traffic_option and include_traffic_effective),
|
||||
config_mode="addon",
|
||||
)
|
||||
await update_balance(session, int(billing_user_id), -int(final_extra_price_rub))
|
||||
if coupon_id is not None:
|
||||
await mark_coupon_used(session, int(coupon_id), int(billing_user_id))
|
||||
return AccountKeyApplyAddonsResponse(
|
||||
ok=True,
|
||||
message="Доп. опции применены",
|
||||
client_id=str(getattr(db_key, "client_id", "") or ""),
|
||||
tariff_id=int(tariff_id),
|
||||
total_price_rub=int(total_price_after_purchase),
|
||||
extra_price_rub=int(extra_price_rub),
|
||||
discount_rub=int(discount_rub),
|
||||
final_price_rub=int(final_extra_price_rub),
|
||||
applied_coupon_code=applied_coupon_code,
|
||||
charged_rub=int(final_extra_price_rub),
|
||||
balance_rub=float(await get_balance(session, int(billing_user_id))),
|
||||
)
|
||||
@@ -1,299 +0,0 @@
|
||||
"""User-facing key endpoints (/api/keys/*).
|
||||
|
||||
Регистрирует эндпоинты на ``user_router`` из ``_common``. Импорт этого модуля
|
||||
из ``__init__.py`` запускает регистрацию декораторов.
|
||||
"""
|
||||
|
||||
import time
|
||||
|
||||
from .._common import * # noqa: F401,F403 — подтягиваем все имена для endpoints
|
||||
from .._common import (
|
||||
_key_actions_config,
|
||||
_normalize_expiry_ms,
|
||||
_resolve_available_location_servers,
|
||||
_resolve_billing_user_id,
|
||||
_resolve_default_web_payment_provider,
|
||||
_resolve_public_base_url,
|
||||
router,
|
||||
user_router,
|
||||
)
|
||||
|
||||
|
||||
@user_router.get("", response_model=list[AccountKeyResponse])
|
||||
async def user_keys(
|
||||
request: Request,
|
||||
session: AsyncSession = Depends(get_session),
|
||||
identity=Depends(verify_identity_token),
|
||||
):
|
||||
billing_user_id = await _resolve_billing_user_id(request, identity, session)
|
||||
keys = await get_keys(session, billing_user_id)
|
||||
result: list[AccountKeyResponse] = []
|
||||
for key in keys:
|
||||
key_actions = AccountKeyActionsAvailability()
|
||||
try:
|
||||
key_ref = str(getattr(key, "client_id", "") or getattr(key, "email", "") or "")
|
||||
_, markup, _ = await build_key_view_payload(session, int(billing_user_id), key_ref)
|
||||
key_actions = _extract_key_actions_from_markup(markup)
|
||||
except Exception:
|
||||
key_actions = AccountKeyActionsAvailability()
|
||||
result.append(
|
||||
AccountKeyResponse(
|
||||
email=str(getattr(key, "email", "") or ""),
|
||||
alias=getattr(key, "alias", None),
|
||||
client_id=str(getattr(key, "client_id", "") or ""),
|
||||
tariff_id=getattr(key, "tariff_id", None),
|
||||
server_id=str(getattr(key, "server_id", "") or ""),
|
||||
created_at=int(getattr(key, "created_at", 0) or 0),
|
||||
expiry_time=int(getattr(key, "expiry_time", 0) or 0),
|
||||
key=getattr(key, "key", None),
|
||||
remnawave_link=getattr(key, "remnawave_link", None),
|
||||
is_frozen=bool(getattr(key, "is_frozen", False)),
|
||||
actions=key_actions,
|
||||
)
|
||||
)
|
||||
return result
|
||||
|
||||
|
||||
@user_router.get("/actions-config", response_model=AccountKeyActionsConfigResponse)
|
||||
async def user_keys_actions_config(
|
||||
identity=Depends(verify_identity_token),
|
||||
):
|
||||
_ = identity
|
||||
return _key_actions_config()
|
||||
|
||||
|
||||
@user_router.get("/{client_id}/connection", response_model=AccountKeyConnectionResponse)
|
||||
async def user_key_connection(
|
||||
client_id: str,
|
||||
request: Request,
|
||||
session: AsyncSession = Depends(get_session),
|
||||
identity=Depends(verify_identity_token),
|
||||
):
|
||||
"""Лёгкая инфо о текущей подписке: онлайн/offline, сервер, протокол, дни до окончания."""
|
||||
billing_user_id = await _resolve_billing_user_id(request, identity, session)
|
||||
db_key = (
|
||||
await session.execute(select(Key).where(Key.user_id == billing_user_id, Key.client_id == client_id).limit(1))
|
||||
).scalar_one_or_none()
|
||||
if db_key is None:
|
||||
raise HTTPException(status_code=404, detail="Подписка не найдена")
|
||||
server_name = str(getattr(db_key, "server_id", "") or "")
|
||||
cluster_name = ""
|
||||
panel_type = ""
|
||||
if server_name:
|
||||
srv = (
|
||||
await session.execute(
|
||||
select(Server).where(Server.server_name == server_name).limit(1)
|
||||
)
|
||||
).scalar_one_or_none()
|
||||
if srv is not None:
|
||||
cluster_name = str(getattr(srv, "cluster_name", "") or "")
|
||||
panel_type = str(getattr(srv, "panel_type", "") or "").lower()
|
||||
expiry_ms = int(getattr(db_key, "expiry_time", 0) or 0)
|
||||
is_frozen = bool(getattr(db_key, "is_frozen", False))
|
||||
now_ms = int(time.time() * 1000)
|
||||
online = not is_frozen and expiry_ms > now_ms
|
||||
expires_in_days = max(0, int((expiry_ms - now_ms) / (1000 * 60 * 60 * 24))) if expiry_ms > 0 else 0
|
||||
if panel_type == "remnawave":
|
||||
protocol = "VLESS"
|
||||
elif panel_type == "marzban":
|
||||
protocol = "VLESS"
|
||||
elif panel_type == "3xui":
|
||||
protocol = "VLESS"
|
||||
else:
|
||||
protocol = panel_type.upper() or "VLESS"
|
||||
return AccountKeyConnectionResponse(
|
||||
client_id=str(getattr(db_key, "client_id", "") or ""),
|
||||
online=online,
|
||||
is_frozen=is_frozen,
|
||||
expiry_time=expiry_ms,
|
||||
expires_in_days=expires_in_days,
|
||||
server_name=server_name,
|
||||
cluster_name=cluster_name,
|
||||
panel_type=panel_type,
|
||||
protocol=protocol,
|
||||
)
|
||||
|
||||
|
||||
@user_router.get("/{client_id}/details", response_model=AccountKeyDetailsResponse)
|
||||
async def user_key_details(
|
||||
client_id: str,
|
||||
request: Request,
|
||||
session: AsyncSession = Depends(get_session),
|
||||
identity=Depends(verify_identity_token),
|
||||
):
|
||||
billing_user_id = await _resolve_billing_user_id(request, identity, session)
|
||||
db_key = (
|
||||
await session.execute(select(Key).where(Key.user_id == billing_user_id, Key.client_id == client_id).limit(1))
|
||||
).scalar_one_or_none()
|
||||
if db_key is None:
|
||||
raise HTTPException(status_code=404, detail="Подписка не найдена")
|
||||
key_details = await get_key_details(session, str(getattr(db_key, "email", "") or ""))
|
||||
if not key_details:
|
||||
raise HTTPException(status_code=404, detail="Подписка не найдена")
|
||||
tariff_name = ""
|
||||
subgroup_title = ""
|
||||
traffic_limit_gb = 0
|
||||
device_limit = 0
|
||||
is_tariff_configurable = False
|
||||
addons_devices_enabled = False
|
||||
addons_traffic_enabled = False
|
||||
(
|
||||
tariff_name,
|
||||
subgroup_title,
|
||||
traffic_limit_gb,
|
||||
device_limit,
|
||||
_,
|
||||
is_tariff_configurable,
|
||||
addons_devices_enabled,
|
||||
addons_traffic_enabled,
|
||||
) = await get_key_tariff_addons_state(
|
||||
session=session,
|
||||
key_record=key_details,
|
||||
db_key=db_key,
|
||||
)
|
||||
connected_devices = 0
|
||||
used_traffic_gb = None
|
||||
try:
|
||||
profile = await get_remnawave_profile(
|
||||
session,
|
||||
str(getattr(db_key, "server_id", "") or ""),
|
||||
client_id,
|
||||
fallback_any=True,
|
||||
)
|
||||
if profile:
|
||||
connected_devices = int(profile.get("hwid_count") or 0)
|
||||
used_raw = profile.get("used_gb")
|
||||
used_traffic_gb = float(used_raw) if used_raw is not None else None
|
||||
traffic_limit_bytes_actual = profile.get("traffic_limit_bytes")
|
||||
if traffic_limit_bytes_actual is not None:
|
||||
try:
|
||||
traffic_limit_bytes_actual = int(traffic_limit_bytes_actual)
|
||||
traffic_limit_gb = int(traffic_limit_bytes_actual / GB) if traffic_limit_bytes_actual > 0 else 0
|
||||
except (TypeError, ValueError):
|
||||
pass
|
||||
except Exception:
|
||||
connected_devices = 0
|
||||
used_traffic_gb = None
|
||||
return AccountKeyDetailsResponse(
|
||||
client_id=str(getattr(db_key, "client_id", "") or ""),
|
||||
email=str(getattr(db_key, "email", "") or ""),
|
||||
alias=getattr(db_key, "alias", None),
|
||||
expiry_time=int(getattr(db_key, "expiry_time", 0) or 0),
|
||||
is_frozen=bool(getattr(db_key, "is_frozen", False)),
|
||||
tariff_name=str(tariff_name or ""),
|
||||
subgroup_title=str(subgroup_title or ""),
|
||||
traffic_limit_gb=int(traffic_limit_gb or 0),
|
||||
used_traffic_gb=used_traffic_gb,
|
||||
device_limit=int(device_limit or 0),
|
||||
connected_devices=int(connected_devices or 0),
|
||||
is_tariff_configurable=bool(is_tariff_configurable),
|
||||
addons_devices_enabled=bool(addons_devices_enabled),
|
||||
addons_traffic_enabled=bool(addons_traffic_enabled),
|
||||
)
|
||||
|
||||
|
||||
@user_router.get("/{client_id}/qr", response_model=AccountKeyQrResponse)
|
||||
async def user_key_qr(
|
||||
client_id: str,
|
||||
request: Request,
|
||||
force_web: bool = Query(False),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
identity=Depends(verify_identity_token),
|
||||
):
|
||||
from api.ratelimit import enforce_rate_limit
|
||||
await enforce_rate_limit(request, session, bucket="key_qr", max_per_window=30, window_sec=60)
|
||||
actions = _key_actions_config()
|
||||
if not force_web and not actions.qr_enabled:
|
||||
raise HTTPException(status_code=403, detail="QR для подписок отключен в настройках")
|
||||
billing_user_id = await _resolve_billing_user_id(request, identity, session)
|
||||
db_key = (
|
||||
await session.execute(select(Key).where(Key.user_id == billing_user_id, Key.client_id == client_id).limit(1))
|
||||
).scalar_one_or_none()
|
||||
if db_key is None:
|
||||
raise HTTPException(status_code=404, detail="Подписка не найдена")
|
||||
qr_data = str(getattr(db_key, "key", "") or "").strip() or str(getattr(db_key, "remnawave_link", "") or "").strip()
|
||||
if not qr_data:
|
||||
raise HTTPException(status_code=400, detail="Ссылка для подключения отсутствует")
|
||||
qr = qrcode.QRCode(version=1, box_size=10, border=4)
|
||||
qr.add_data(qr_data)
|
||||
qr.make(fit=True)
|
||||
img = qr.make_image(fill_color="black", back_color="white")
|
||||
buffer = BytesIO()
|
||||
img.save(buffer, format="PNG")
|
||||
image_data = b64encode(buffer.getvalue()).decode("ascii")
|
||||
return AccountKeyQrResponse(
|
||||
ok=True,
|
||||
message="QR-код готов",
|
||||
link=qr_data,
|
||||
image_data_url=f"data:image/png;base64,{image_data}",
|
||||
)
|
||||
|
||||
|
||||
@user_router.patch("/{client_id}/alias", response_model=AccountKeyResponse)
|
||||
async def user_key_update_alias(
|
||||
client_id: str,
|
||||
body: AccountKeyAliasUpdateRequest,
|
||||
request: Request,
|
||||
session: AsyncSession = Depends(get_session),
|
||||
identity=Depends(verify_identity_token),
|
||||
):
|
||||
from api.ratelimit import enforce_rate_limit
|
||||
await enforce_rate_limit(request, session, bucket="key_alias", max_per_window=20, window_sec=60)
|
||||
alias = str(body.alias or "").strip()
|
||||
if not alias:
|
||||
raise HTTPException(status_code=400, detail="Укажите alias")
|
||||
if len(alias) > 10:
|
||||
raise HTTPException(status_code=400, detail="Alias должен быть не длиннее 10 символов")
|
||||
if not re.match(r"^[a-zA-Zа-яА-ЯёЁ0-9@._-]+$", alias):
|
||||
raise HTTPException(status_code=400, detail="Alias содержит недопустимые символы")
|
||||
billing_user_id = await _resolve_billing_user_id(request, identity, session)
|
||||
db_key = (
|
||||
await session.execute(select(Key).where(Key.user_id == billing_user_id, Key.client_id == client_id).limit(1))
|
||||
).scalar_one_or_none()
|
||||
if db_key is None:
|
||||
raise HTTPException(status_code=404, detail="Подписка не найдена")
|
||||
db_key.alias = alias
|
||||
return AccountKeyResponse(
|
||||
email=str(getattr(db_key, "email", "") or ""),
|
||||
alias=getattr(db_key, "alias", None),
|
||||
client_id=str(getattr(db_key, "client_id", "") or ""),
|
||||
tariff_id=getattr(db_key, "tariff_id", None),
|
||||
server_id=str(getattr(db_key, "server_id", "") or ""),
|
||||
created_at=int(getattr(db_key, "created_at", 0) or 0),
|
||||
expiry_time=int(getattr(db_key, "expiry_time", 0) or 0),
|
||||
key=getattr(db_key, "key", None),
|
||||
remnawave_link=getattr(db_key, "remnawave_link", None),
|
||||
is_frozen=bool(getattr(db_key, "is_frozen", False)),
|
||||
)
|
||||
|
||||
|
||||
@user_router.delete("/{client_id}", response_model=AccountKeyActionResponse)
|
||||
async def user_key_delete(
|
||||
client_id: str,
|
||||
request: Request,
|
||||
force_web: bool = Query(False),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
identity=Depends(verify_identity_token),
|
||||
):
|
||||
from api.ratelimit import enforce_rate_limit
|
||||
await enforce_rate_limit(request, session, bucket="key_delete", max_per_window=10, window_sec=60)
|
||||
actions = _key_actions_config()
|
||||
if not force_web and not actions.delete_enabled:
|
||||
raise HTTPException(status_code=403, detail="Удаление подписки отключено в настройках")
|
||||
billing_user_id = await _resolve_billing_user_id(request, identity, session)
|
||||
db_key = (
|
||||
await session.execute(select(Key).where(Key.user_id == billing_user_id, Key.client_id == client_id).limit(1))
|
||||
).scalar_one_or_none()
|
||||
if db_key is None:
|
||||
raise HTTPException(status_code=404, detail="Подписка не найдена")
|
||||
cluster_id = str(getattr(db_key, "server_id", "") or "")
|
||||
email = str(getattr(db_key, "email", "") or "")
|
||||
if cluster_id and email:
|
||||
await delete_key_from_cluster(
|
||||
cluster_id=cluster_id,
|
||||
email=email,
|
||||
client_id=client_id,
|
||||
session=session,
|
||||
)
|
||||
await session.delete(db_key)
|
||||
return AccountKeyActionResponse(ok=True, message="Подписка удалена")
|
||||
@@ -1,73 +0,0 @@
|
||||
"""User-facing key endpoints (/api/keys/*).
|
||||
|
||||
Регистрирует эндпоинты на ``user_router`` из ``_common``. Импорт этого модуля
|
||||
из ``__init__.py`` запускает регистрацию декораторов.
|
||||
"""
|
||||
|
||||
from .._common import * # noqa: F401,F403 — подтягиваем все имена для endpoints
|
||||
from .._common import (
|
||||
_key_actions_config,
|
||||
_normalize_expiry_ms,
|
||||
_resolve_available_location_servers,
|
||||
_resolve_billing_user_id,
|
||||
_resolve_default_web_payment_provider,
|
||||
_resolve_public_base_url,
|
||||
router,
|
||||
user_router,
|
||||
)
|
||||
|
||||
|
||||
@user_router.post("/{client_id}/reset-hwid", response_model=AccountKeyResetHwidResponse)
|
||||
async def user_key_reset_hwid(
|
||||
client_id: str,
|
||||
request: Request,
|
||||
force_web: bool = Query(False),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
identity=Depends(verify_identity_token),
|
||||
):
|
||||
actions = _key_actions_config()
|
||||
if not force_web and not actions.hwid_reset_enabled:
|
||||
raise HTTPException(status_code=403, detail="Сброс устройств отключен в настройках")
|
||||
billing_user_id = await _resolve_billing_user_id(request, identity, session)
|
||||
db_key = (
|
||||
await session.execute(select(Key).where(Key.user_id == billing_user_id, Key.client_id == client_id).limit(1))
|
||||
).scalar_one_or_none()
|
||||
if db_key is None:
|
||||
raise HTTPException(status_code=404, detail="Подписка не найдена")
|
||||
server_id = str(getattr(db_key, "server_id", "") or "")
|
||||
if not server_id:
|
||||
raise HTTPException(status_code=400, detail="У подписки не указан сервер")
|
||||
|
||||
async def _reset_devices(api):
|
||||
devices = await api.get_user_hwid_devices(client_id)
|
||||
if not devices:
|
||||
return 0, 0
|
||||
reset_local = 0
|
||||
for device in devices:
|
||||
hwid = device.get("hwid")
|
||||
if hwid and await api.delete_user_hwid_device(client_id, hwid):
|
||||
reset_local += 1
|
||||
return len(devices), reset_local
|
||||
|
||||
reset_result = await with_remnawave_api(
|
||||
session,
|
||||
server_id,
|
||||
_reset_devices,
|
||||
fallback_any=True,
|
||||
timeout_sec=12.0,
|
||||
)
|
||||
if reset_result is None:
|
||||
raise HTTPException(status_code=502, detail="Не удалось выполнить сброс устройств")
|
||||
total_devices, reset_devices = reset_result
|
||||
await invalidate_remnawave_profile(
|
||||
session,
|
||||
server_id,
|
||||
str(client_id),
|
||||
fallback_any=True,
|
||||
)
|
||||
return AccountKeyResetHwidResponse(
|
||||
ok=True,
|
||||
message="Устройства сброшены" if total_devices > 0 else "Устройства не были привязаны",
|
||||
total_devices=int(total_devices),
|
||||
reset_devices=int(reset_devices),
|
||||
)
|
||||
@@ -1,225 +0,0 @@
|
||||
"""User-facing key endpoints (/api/keys/*).
|
||||
|
||||
Регистрирует эндпоинты на ``user_router`` из ``_common``. Импорт этого модуля
|
||||
из ``__init__.py`` запускает регистрацию декораторов.
|
||||
"""
|
||||
|
||||
from .._common import * # noqa: F401,F403 — подтягиваем все имена для endpoints
|
||||
from .._common import (
|
||||
_key_actions_config,
|
||||
_normalize_expiry_ms,
|
||||
_resolve_available_location_servers,
|
||||
_resolve_billing_user_id,
|
||||
_resolve_default_web_payment_provider,
|
||||
_resolve_public_base_url,
|
||||
router,
|
||||
user_router,
|
||||
)
|
||||
|
||||
|
||||
@user_router.get("/{client_id}/locations", response_model=AccountKeyLocationsResponse)
|
||||
async def user_key_locations(
|
||||
client_id: str,
|
||||
request: Request,
|
||||
force_web: bool = Query(False),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
identity=Depends(verify_identity_token),
|
||||
):
|
||||
actions = _key_actions_config()
|
||||
if not force_web and not actions.country_change_enabled:
|
||||
raise HTTPException(status_code=403, detail="Смена локации отключена в настройках")
|
||||
billing_user_id = await _resolve_billing_user_id(request, identity, session)
|
||||
db_key = (
|
||||
await session.execute(select(Key).where(Key.user_id == billing_user_id, Key.client_id == client_id).limit(1))
|
||||
).scalar_one_or_none()
|
||||
if db_key is None:
|
||||
raise HTTPException(status_code=404, detail="Подписка не найдена")
|
||||
names = await _resolve_available_location_servers(session, db_key)
|
||||
return AccountKeyLocationsResponse(
|
||||
client_id=str(getattr(db_key, "client_id", "") or ""),
|
||||
current_server=str(getattr(db_key, "server_id", "") or ""),
|
||||
locations=[AccountKeyLocationOptionResponse(server_name=name) for name in names],
|
||||
)
|
||||
|
||||
|
||||
@user_router.post("/{client_id}/change-location", response_model=AccountKeyChangeLocationResponse)
|
||||
async def user_key_change_location(
|
||||
client_id: str,
|
||||
body: AccountKeyChangeLocationRequest,
|
||||
request: Request,
|
||||
force_web: bool = Query(False),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
identity=Depends(verify_identity_token),
|
||||
):
|
||||
actions = _key_actions_config()
|
||||
if not force_web and not actions.country_change_enabled:
|
||||
raise HTTPException(status_code=403, detail="Смена локации отключена в настройках")
|
||||
target_server = str(body.server_name or "").strip()
|
||||
if not target_server:
|
||||
raise HTTPException(status_code=400, detail="Укажите целевую локацию")
|
||||
billing_user_id = await _resolve_billing_user_id(request, identity, session)
|
||||
db_key = (
|
||||
await session.execute(select(Key).where(Key.user_id == billing_user_id, Key.client_id == client_id).limit(1))
|
||||
).scalar_one_or_none()
|
||||
if db_key is None:
|
||||
raise HTTPException(status_code=404, detail="Подписка не найдена")
|
||||
current_server = str(getattr(db_key, "server_id", "") or "")
|
||||
if not current_server:
|
||||
raise HTTPException(status_code=400, detail="У подписки не указан текущий сервер")
|
||||
if current_server == target_server:
|
||||
raise HTTPException(status_code=400, detail="Подписка уже в этой локации")
|
||||
available_names = await _resolve_available_location_servers(session, db_key)
|
||||
if target_server not in available_names:
|
||||
raise HTTPException(status_code=400, detail="Выбранная локация недоступна")
|
||||
email = str(getattr(db_key, "email", "") or "")
|
||||
if not email:
|
||||
raise HTTPException(status_code=400, detail="У подписки отсутствует email")
|
||||
key_details = await get_key_details(session, email)
|
||||
if not key_details:
|
||||
raise HTTPException(status_code=404, detail="Подписка не найдена")
|
||||
old_server_info = (
|
||||
await session.execute(select(Server).where(Server.server_name == current_server).limit(1))
|
||||
).scalar_one_or_none()
|
||||
if old_server_info:
|
||||
old_panel_type = str(getattr(old_server_info, "panel_type", "") or "").lower()
|
||||
try:
|
||||
if old_panel_type == "3x-ui":
|
||||
xui = await get_xui_instance(str(getattr(old_server_info, "api_url", "") or ""))
|
||||
await delete_client(
|
||||
xui,
|
||||
int(getattr(old_server_info, "inbound_id", 0) or 0),
|
||||
email,
|
||||
str(getattr(db_key, "client_id", "") or ""),
|
||||
)
|
||||
elif old_panel_type == "remnawave":
|
||||
remna_del = RemnawaveAPI(str(getattr(old_server_info, "api_url", "") or ""))
|
||||
if await remna_del.login(REMNAWAVE_LOGIN, REMNAWAVE_PASSWORD):
|
||||
await remna_del.delete_user(str(getattr(db_key, "client_id", "") or ""))
|
||||
except Exception:
|
||||
pass
|
||||
target_server_info = (
|
||||
await session.execute(select(Server).where(Server.server_name == target_server).limit(1))
|
||||
).scalar_one_or_none()
|
||||
if target_server_info is None:
|
||||
raise HTTPException(status_code=404, detail="Целевая локация не найдена")
|
||||
tariff_id = getattr(db_key, "tariff_id", None)
|
||||
tariff = await get_tariff_by_id(session, int(tariff_id)) if tariff_id else None
|
||||
need_vless_key = bool(tariff.get("vless")) if tariff else False
|
||||
external_squad_uuid = (tariff.get("external_squad") if tariff else None) or None
|
||||
selected_traffic_gb = getattr(db_key, "selected_traffic_limit", None)
|
||||
selected_device_limit = getattr(db_key, "selected_device_limit", None)
|
||||
if selected_traffic_gb is not None:
|
||||
traffic_limit_bytes = int(selected_traffic_gb) * GB
|
||||
else:
|
||||
raw_traffic_limit = int(tariff.get("traffic_limit") or 0) if tariff else 0
|
||||
traffic_limit_bytes = raw_traffic_limit * GB if raw_traffic_limit > 0 else 0
|
||||
if selected_device_limit is not None:
|
||||
device_limit = int(selected_device_limit)
|
||||
else:
|
||||
device_limit = int(tariff.get("device_limit") or 0) if tariff else 0
|
||||
key_client_id = str(getattr(db_key, "client_id", "") or "")
|
||||
expiry_timestamp = int(getattr(db_key, "expiry_time", 0) or 0)
|
||||
target_cluster_info = await check_server_name_by_cluster(session, target_server)
|
||||
target_cluster_name = str((target_cluster_info or {}).get("cluster_name") or "")
|
||||
full_remnawave_cluster = (
|
||||
await is_full_remnawave_cluster(target_cluster_name, session) if target_cluster_name else False
|
||||
)
|
||||
panel_type = str(getattr(target_server_info, "panel_type", "") or "").lower()
|
||||
remnawave_link = None
|
||||
if panel_type == "remnawave" or full_remnawave_cluster:
|
||||
remna = RemnawaveAPI(str(getattr(target_server_info, "api_url", "") or ""))
|
||||
if not await remna.login(REMNAWAVE_LOGIN, REMNAWAVE_PASSWORD):
|
||||
raise HTTPException(status_code=502, detail="Не удалось авторизоваться в Remnawave")
|
||||
expire_at = datetime.utcfromtimestamp(expiry_timestamp / 1000).isoformat() + "Z"
|
||||
user_data: dict[str, Any] = {
|
||||
"username": email,
|
||||
"trafficLimitStrategy": "NO_RESET",
|
||||
"expireAt": expire_at,
|
||||
"telegramId": int(key_details.get("tg_id") or 0),
|
||||
"activeInternalSquads": [getattr(target_server_info, "inbound_id", None)],
|
||||
"uuid": key_client_id,
|
||||
}
|
||||
if traffic_limit_bytes > 0:
|
||||
user_data["trafficLimitBytes"] = traffic_limit_bytes
|
||||
if device_limit > 0:
|
||||
user_data["hwidDeviceLimit"] = device_limit
|
||||
if external_squad_uuid:
|
||||
user_data["externalSquadUuid"] = external_squad_uuid
|
||||
result = await remna.create_user(user_data)
|
||||
if not result:
|
||||
raise HTTPException(status_code=502, detail="Не удалось создать подписку в новой локации")
|
||||
key_client_id = str(result.get("uuid") or result.get("id") or key_client_id)
|
||||
if need_vless_key:
|
||||
try:
|
||||
remnawave_link = await get_vless_link_for_remnawave_by_username(remna, email, email)
|
||||
except Exception:
|
||||
remnawave_link = None
|
||||
if not remnawave_link:
|
||||
try:
|
||||
sub = await remna.get_subscription_by_username(email)
|
||||
except Exception:
|
||||
sub = None
|
||||
if sub:
|
||||
links = sub.get("links") or []
|
||||
remnawave_link = (
|
||||
next(
|
||||
(link for link in links if isinstance(link, str) and link.lower().startswith("vless://")),
|
||||
None,
|
||||
)
|
||||
if need_vless_key
|
||||
else None
|
||||
)
|
||||
if not remnawave_link:
|
||||
remnawave_link = sub.get("subscriptionUrl")
|
||||
if panel_type == "3x-ui":
|
||||
await create_client_on_server(
|
||||
{
|
||||
"api_url": str(getattr(target_server_info, "api_url", "") or ""),
|
||||
"inbound_id": getattr(target_server_info, "inbound_id", None),
|
||||
"server_name": str(getattr(target_server_info, "server_name", "") or ""),
|
||||
"panel_type": str(getattr(target_server_info, "panel_type", "") or ""),
|
||||
},
|
||||
int(key_details.get("tg_id") or 0),
|
||||
key_client_id,
|
||||
email,
|
||||
expiry_timestamp,
|
||||
asyncio.Semaphore(1),
|
||||
plan=int(tariff_id) if tariff_id else None,
|
||||
session=session,
|
||||
is_trial=False,
|
||||
total_traffic_limit_bytes=traffic_limit_bytes,
|
||||
device_limit_value=device_limit,
|
||||
)
|
||||
subgroup_code = tariff.get("subgroup_title") if tariff and tariff.get("subgroup_title") else None
|
||||
public_link = await make_aggregated_link(
|
||||
session=session,
|
||||
cluster_all=[
|
||||
{
|
||||
"server_name": str(getattr(target_server_info, "server_name", "") or ""),
|
||||
"api_url": str(getattr(target_server_info, "api_url", "") or ""),
|
||||
"panel_type": str(getattr(target_server_info, "panel_type", "") or ""),
|
||||
"inbound_id": getattr(target_server_info, "inbound_id", None),
|
||||
"enabled": True,
|
||||
"max_keys": getattr(target_server_info, "max_keys", None),
|
||||
}
|
||||
],
|
||||
cluster_id=target_cluster_name or target_server,
|
||||
email=email,
|
||||
client_id=key_client_id,
|
||||
tg_id=int(key_details.get("tg_id") or 0),
|
||||
subgroup_code=subgroup_code,
|
||||
remna_link_override=remnawave_link,
|
||||
plan=int(tariff_id) if tariff_id else None,
|
||||
)
|
||||
db_key.server_id = target_server
|
||||
db_key.client_id = key_client_id
|
||||
db_key.key = public_link if isinstance(public_link, str) and public_link.strip() else None
|
||||
db_key.remnawave_link = remnawave_link
|
||||
return AccountKeyChangeLocationResponse(
|
||||
ok=True,
|
||||
message="Локация успешно изменена",
|
||||
client_id=str(getattr(db_key, "client_id", "") or ""),
|
||||
server_id=str(getattr(db_key, "server_id", "") or ""),
|
||||
link=str(getattr(db_key, "key", "") or ""),
|
||||
remnawave_link=getattr(db_key, "remnawave_link", None),
|
||||
)
|
||||
@@ -1,192 +0,0 @@
|
||||
"""User-facing key endpoints (/api/keys/*).
|
||||
|
||||
Регистрирует эндпоинты на ``user_router`` из ``_common``. Импорт этого модуля
|
||||
из ``__init__.py`` запускает регистрацию декораторов.
|
||||
"""
|
||||
|
||||
from .._common import * # noqa: F401,F403 — подтягиваем все имена для endpoints
|
||||
from .._common import (
|
||||
_key_actions_config,
|
||||
_normalize_expiry_ms,
|
||||
_resolve_available_location_servers,
|
||||
_resolve_billing_user_id,
|
||||
_resolve_default_web_payment_provider,
|
||||
_resolve_public_base_url,
|
||||
router,
|
||||
user_router,
|
||||
)
|
||||
|
||||
|
||||
@user_router.post("/{client_id}/renew", response_model=AccountKeyRenewResponse)
|
||||
async def user_key_renew(
|
||||
client_id: str,
|
||||
body: AccountKeyRenewRequest,
|
||||
request: Request,
|
||||
force_web: bool = Query(False),
|
||||
preview: bool = Query(False),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
identity=Depends(verify_identity_token),
|
||||
):
|
||||
from api.ratelimit import enforce_rate_limit
|
||||
from services.errors import ServiceError
|
||||
from services.keys import (
|
||||
calculate_renewal_pricing,
|
||||
execute_renewal,
|
||||
normalize_expiry_ms as _svc_normalize_expiry,
|
||||
)
|
||||
|
||||
if not preview:
|
||||
await enforce_rate_limit(request, session, bucket="key_renew", max_per_window=10, window_sec=60)
|
||||
|
||||
actions = _key_actions_config()
|
||||
if not force_web and not actions.renew_enabled:
|
||||
raise HTTPException(status_code=403, detail="Продление подписки отключено в настройках")
|
||||
billing_user_id = await _resolve_billing_user_id(request, identity, session)
|
||||
db_key = (
|
||||
await session.execute(select(Key).where(Key.user_id == billing_user_id, Key.client_id == client_id).limit(1))
|
||||
).scalar_one_or_none()
|
||||
if db_key is None:
|
||||
raise HTTPException(status_code=404, detail="Подписка не найдена")
|
||||
if bool(getattr(db_key, "is_frozen", False)):
|
||||
raise HTTPException(status_code=400, detail="Продление для замороженной подписки недоступно")
|
||||
tariff_id = getattr(db_key, "tariff_id", None)
|
||||
if not tariff_id:
|
||||
raise HTTPException(status_code=400, detail="Для подписки не назначен тариф")
|
||||
key_email = str(getattr(db_key, "email", "") or "")
|
||||
key_server_id = str(getattr(db_key, "server_id", "") or "")
|
||||
|
||||
try:
|
||||
pricing = await calculate_renewal_pricing(
|
||||
session=session,
|
||||
billing_user_id=int(billing_user_id),
|
||||
key_email=key_email,
|
||||
tariff_id=int(tariff_id),
|
||||
coupon_code=body.coupon_code,
|
||||
)
|
||||
except ServiceError as e:
|
||||
raise HTTPException(status_code=400, detail=e.message)
|
||||
|
||||
if preview:
|
||||
return AccountKeyRenewResponse(
|
||||
ok=True,
|
||||
message="Расчет обновлен",
|
||||
client_id=str(client_id),
|
||||
tariff_id=int(tariff_id),
|
||||
charged_rub=0,
|
||||
balance_rub=pricing.balance,
|
||||
base_price_rub=pricing.base_price_rub,
|
||||
discount_rub=pricing.discount_rub,
|
||||
final_price_rub=pricing.final_price_rub,
|
||||
applied_coupon_code=pricing.applied_coupon_code,
|
||||
payment_required=pricing.payment_required,
|
||||
required_amount_rub=pricing.required_amount,
|
||||
payment_id=None,
|
||||
payment_url=None,
|
||||
)
|
||||
if pricing.payment_required:
|
||||
provider_id = str(body.provider_id or _resolve_default_web_payment_provider() or "").strip().upper()
|
||||
if not provider_id:
|
||||
raise HTTPException(status_code=503, detail="Нет доступных провайдеров оплаты")
|
||||
base_url = _resolve_public_base_url(request)
|
||||
success_url = validate_redirect_url(str(body.success_url or ""), f"{base_url}/payment-success")
|
||||
failure_url = validate_redirect_url(str(body.failure_url or ""), f"{base_url}/payment-failure")
|
||||
payment_request = PaymentLinkRequest(
|
||||
legacy_user_ref=int(billing_user_id),
|
||||
amount=pricing.required_amount,
|
||||
currency="RUB",
|
||||
provider_id=provider_id,
|
||||
success_url=success_url,
|
||||
failure_url=failure_url,
|
||||
metadata={
|
||||
"payment_flow": "key_renewal",
|
||||
"tariff_id": int(tariff_id),
|
||||
"client_id": str(client_id),
|
||||
"email": key_email,
|
||||
"cost": pricing.final_price_rub,
|
||||
"selected_duration_days": pricing.duration_days,
|
||||
"selected_device_limit": pricing.selected_device_limit,
|
||||
"selected_traffic_limit": pricing.selected_traffic_limit,
|
||||
"selected_price_rub": pricing.final_price_rub,
|
||||
"total_gb": pricing.total_gb,
|
||||
"base_price_rub": pricing.base_price_rub,
|
||||
"discount_rub": pricing.discount_rub,
|
||||
"applied_coupon_code": pricing.applied_coupon_code,
|
||||
"coupon_id": pricing.coupon_id,
|
||||
},
|
||||
)
|
||||
payment_result = await create_payment_link(session, payment_request)
|
||||
if not payment_result.success or not payment_result.payment_url or not payment_result.payment_id:
|
||||
raise HTTPException(status_code=400, detail=payment_result.error or "Не удалось создать ссылку оплаты")
|
||||
await create_temporary_data(
|
||||
session,
|
||||
int(billing_user_id),
|
||||
"waiting_for_renewal_payment",
|
||||
{
|
||||
"tariff_id": int(tariff_id),
|
||||
"client_id": str(client_id),
|
||||
"email": key_email,
|
||||
"cost": pricing.final_price_rub,
|
||||
"required_amount": pricing.required_amount,
|
||||
"selected_duration_days": pricing.duration_days,
|
||||
"selected_device_limit": pricing.selected_device_limit,
|
||||
"selected_traffic_limit": pricing.selected_traffic_limit,
|
||||
"selected_price_rub": pricing.final_price_rub,
|
||||
"total_gb": pricing.total_gb,
|
||||
"base_price_rub": pricing.base_price_rub,
|
||||
"discount_rub": pricing.discount_rub,
|
||||
"applied_coupon_code": pricing.applied_coupon_code,
|
||||
"coupon_id": pricing.coupon_id,
|
||||
},
|
||||
)
|
||||
return AccountKeyRenewResponse(
|
||||
ok=True,
|
||||
message="Требуется оплата для продления подписки",
|
||||
client_id=str(client_id),
|
||||
tariff_id=int(tariff_id),
|
||||
charged_rub=0,
|
||||
balance_rub=pricing.balance,
|
||||
base_price_rub=pricing.base_price_rub,
|
||||
discount_rub=pricing.discount_rub,
|
||||
final_price_rub=pricing.final_price_rub,
|
||||
applied_coupon_code=pricing.applied_coupon_code,
|
||||
payment_required=True,
|
||||
required_amount_rub=pricing.required_amount,
|
||||
payment_id=payment_result.payment_id,
|
||||
payment_url=payment_result.payment_url,
|
||||
)
|
||||
expiry_raw = _normalize_expiry_ms(getattr(db_key, "expiry_time", None))
|
||||
now_ms = int(datetime.utcnow().timestamp() * 1000)
|
||||
base_expiry = now_ms if expiry_raw <= now_ms else expiry_raw
|
||||
new_expiry_time = int(base_expiry + pricing.duration_days * 24 * 60 * 60 * 1000)
|
||||
if not key_email or not key_server_id:
|
||||
raise HTTPException(status_code=400, detail="Некорректные данные подписки")
|
||||
try:
|
||||
result = await execute_renewal(
|
||||
session=session,
|
||||
billing_user_id=int(billing_user_id),
|
||||
client_id=str(client_id),
|
||||
key_email=key_email,
|
||||
key_server_id=key_server_id,
|
||||
tariff_id=int(tariff_id),
|
||||
new_expiry_time=new_expiry_time,
|
||||
total_gb=pricing.total_gb,
|
||||
cost=float(pricing.final_price_rub),
|
||||
selected_device_limit=pricing.selected_device_limit,
|
||||
selected_traffic_limit=pricing.selected_traffic_limit,
|
||||
selected_price_rub=pricing.final_price_rub,
|
||||
coupon_id=pricing.coupon_id,
|
||||
)
|
||||
except ServiceError as e:
|
||||
raise HTTPException(status_code=400, detail=e.message)
|
||||
return AccountKeyRenewResponse(
|
||||
ok=True,
|
||||
message="Подписка продлена",
|
||||
client_id=result.client_id,
|
||||
tariff_id=result.tariff_id,
|
||||
charged_rub=result.charged_rub,
|
||||
balance_rub=result.balance_rub,
|
||||
base_price_rub=pricing.base_price_rub,
|
||||
discount_rub=pricing.discount_rub,
|
||||
final_price_rub=pricing.final_price_rub,
|
||||
applied_coupon_code=pricing.applied_coupon_code,
|
||||
)
|
||||
@@ -1,514 +0,0 @@
|
||||
import asyncio
|
||||
import os
|
||||
import re
|
||||
import subprocess
|
||||
import sys
|
||||
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from typing import Literal
|
||||
|
||||
import psutil
|
||||
|
||||
from aiogram import Bot
|
||||
from aiogram.client.default import DefaultBotProperties
|
||||
from aiogram.enums import ParseMode
|
||||
from fastapi import APIRouter, BackgroundTasks, Depends, HTTPException, Query
|
||||
from pydantic import BaseModel
|
||||
from sqlalchemy import distinct, exists, func, select, update
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from api.depends import get_session, verify_identity_admin, verify_identity_admin_short
|
||||
from api.v2.schemas.audit import (
|
||||
AuditEventListResponse,
|
||||
AuditEventResponse,
|
||||
AuditStatsResponse,
|
||||
)
|
||||
from audit import drain_audit_redis_to_db, get_audit_funnel, get_audit_stats, list_audit_events
|
||||
from config import API_TOKEN, BOT_SERVICE
|
||||
from core.bootstrap import MANAGEMENT_CONFIG
|
||||
from core.executor import run_io
|
||||
from core.redis_cache import cache_incr
|
||||
from core.settings.management_config import update_management_config
|
||||
from database import async_session_maker
|
||||
from database.models import Key, ScheduledBroadcast, Server, User
|
||||
from database.scheduled_broadcasts import (
|
||||
cancel_scheduled_broadcast,
|
||||
create_scheduled_broadcast,
|
||||
get_scheduled_broadcast,
|
||||
list_scheduled_broadcasts,
|
||||
mark_scheduled_broadcast_failed,
|
||||
mark_scheduled_broadcast_sent,
|
||||
start_scheduled_broadcast,
|
||||
update_scheduled_broadcast,
|
||||
)
|
||||
from handlers.admin.sender.scheduled_service import (
|
||||
ensure_utc_datetime,
|
||||
execute_broadcast_payload,
|
||||
execute_scheduled_broadcast,
|
||||
prepare_broadcast_payload,
|
||||
scheduled_broadcast_to_dict,
|
||||
)
|
||||
from logger import logger
|
||||
from utils.backup import backup_database
|
||||
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
async def _admin_rate_limit(request_or_identity, action: str, max_calls: int, window_sec: int) -> None:
|
||||
identity_id = getattr(request_or_identity, "id", "unknown")
|
||||
key = f"admin_rl:{action}:{identity_id}"
|
||||
count = await cache_incr(key, window_sec)
|
||||
if count > max_calls:
|
||||
raise HTTPException(status_code=429, detail="Слишком много запросов. Попробуйте позже.")
|
||||
|
||||
|
||||
class MaintenanceUpdate(BaseModel):
|
||||
enabled: bool
|
||||
|
||||
|
||||
class DomainChange(BaseModel):
|
||||
domain: str
|
||||
|
||||
|
||||
class BroadcastLaunchPayload(BaseModel):
|
||||
send_to: Literal["all", "subscribed", "unsubscribed", "untrial", "trial", "hotleads", "cluster"] = "all"
|
||||
channel: Literal["bot", "site", "both"] = "both"
|
||||
text: str
|
||||
photo: str | None = None
|
||||
cluster_name: str | None = None
|
||||
workers: int = 5
|
||||
messages_per_second: int = 35
|
||||
|
||||
|
||||
class ScheduledBroadcastCreatePayload(BroadcastLaunchPayload):
|
||||
scheduled_for: datetime
|
||||
|
||||
|
||||
class ScheduledBroadcastUpdatePayload(BaseModel):
|
||||
send_to: Literal["all", "subscribed", "unsubscribed", "untrial", "trial", "hotleads", "cluster"] | None = None
|
||||
channel: Literal["bot", "site", "both"] | None = None
|
||||
text: str | None = None
|
||||
photo: str | None = None
|
||||
cluster_name: str | None = None
|
||||
workers: int | None = None
|
||||
messages_per_second: int | None = None
|
||||
scheduled_for: datetime | None = None
|
||||
|
||||
|
||||
_broadcast_bot: Bot | None = None
|
||||
|
||||
|
||||
def _get_broadcast_bot() -> Bot:
|
||||
"""Возвращает экземпляр бота для рассылки."""
|
||||
global _broadcast_bot
|
||||
if _broadcast_bot is None:
|
||||
_broadcast_bot = Bot(token=API_TOKEN, default=DefaultBotProperties(parse_mode=ParseMode.HTML))
|
||||
return _broadcast_bot
|
||||
|
||||
|
||||
def _require_future_schedule(value: datetime) -> datetime:
|
||||
scheduled_for = ensure_utc_datetime(value)
|
||||
if scheduled_for <= datetime.now(timezone.utc):
|
||||
raise HTTPException(status_code=400, detail="scheduled_for must be in the future")
|
||||
return scheduled_for
|
||||
|
||||
|
||||
def _resolve_update_payload(
|
||||
payload: ScheduledBroadcastUpdatePayload,
|
||||
current: ScheduledBroadcast,
|
||||
) -> dict:
|
||||
fields = payload.model_fields_set
|
||||
text_changed = "text" in fields
|
||||
send_to = payload.send_to if "send_to" in fields else current.send_to
|
||||
channel = payload.channel if "channel" in fields else current.channel
|
||||
text = payload.text if "text" in fields else current.text
|
||||
photo = payload.photo if "photo" in fields else current.photo
|
||||
cluster_name = payload.cluster_name if "cluster_name" in fields else current.cluster_name
|
||||
workers = payload.workers if "workers" in fields else current.workers
|
||||
messages_per_second = (
|
||||
payload.messages_per_second if "messages_per_second" in fields else current.messages_per_second
|
||||
)
|
||||
prepared = prepare_broadcast_payload(
|
||||
send_to=send_to,
|
||||
text=text,
|
||||
photo=photo,
|
||||
cluster_name=cluster_name,
|
||||
workers=workers,
|
||||
messages_per_second=messages_per_second,
|
||||
channel=channel,
|
||||
)
|
||||
if not text_changed:
|
||||
prepared["text"] = current.text
|
||||
prepared["keyboard_json"] = current.keyboard_json
|
||||
if "scheduled_for" in fields:
|
||||
prepared["scheduled_for"] = _require_future_schedule(payload.scheduled_for)
|
||||
return prepared
|
||||
|
||||
|
||||
async def _restart_bot() -> None:
|
||||
"""Перезапуск процесса бота (systemctl или execv)."""
|
||||
await asyncio.sleep(1)
|
||||
try:
|
||||
parent = psutil.Process(os.getpid()).parent()
|
||||
is_systemd = parent and "systemd" in parent.name().lower()
|
||||
if is_systemd:
|
||||
await run_io(lambda: subprocess.run(["sudo", "systemctl", "restart", BOT_SERVICE], check=True))
|
||||
else:
|
||||
python_exe = sys.executable
|
||||
script_path = os.path.abspath(sys.argv[0])
|
||||
os.execv(python_exe, [python_exe, script_path] + sys.argv[1:])
|
||||
except Exception:
|
||||
os._exit(1)
|
||||
|
||||
|
||||
@router.get("/status")
|
||||
async def get_status(identity=Depends(verify_identity_admin)):
|
||||
"""Текущий статус: maintenance и management config."""
|
||||
return {
|
||||
"maintenance_enabled": bool(MANAGEMENT_CONFIG.get("MAINTENANCE_ENABLED", False)),
|
||||
"management": dict(MANAGEMENT_CONFIG or {}),
|
||||
}
|
||||
|
||||
|
||||
@router.post("/maintenance")
|
||||
async def set_maintenance(
|
||||
payload: MaintenanceUpdate,
|
||||
identity=Depends(verify_identity_admin),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
):
|
||||
"""Включение/выключение режима обслуживания."""
|
||||
current_config = dict(MANAGEMENT_CONFIG or {})
|
||||
current_config["MAINTENANCE_ENABLED"] = bool(payload.enabled)
|
||||
await update_management_config(session, current_config)
|
||||
return {"maintenance_enabled": bool(MANAGEMENT_CONFIG.get("MAINTENANCE_ENABLED", False))}
|
||||
|
||||
|
||||
@router.post("/restart")
|
||||
async def restart_bot(
|
||||
background: BackgroundTasks,
|
||||
identity=Depends(verify_identity_admin),
|
||||
):
|
||||
"""Запуск перезапуска бота в фоне."""
|
||||
await _admin_rate_limit(identity, "restart", max_calls=3, window_sec=60)
|
||||
background.add_task(_restart_bot)
|
||||
return {"status": "restarting"}
|
||||
|
||||
|
||||
@router.post("/change-domain")
|
||||
async def change_domain(
|
||||
payload: DomainChange,
|
||||
identity=Depends(verify_identity_admin),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
):
|
||||
"""Массовая замена домена в ключах и remnawave_link."""
|
||||
await _admin_rate_limit(identity, "change_domain", max_calls=3, window_sec=300)
|
||||
domain = payload.domain.strip()
|
||||
if not domain or " " in domain or not re.fullmatch(r"[a-zA-Z0-9.-]+", domain):
|
||||
raise HTTPException(status_code=400, detail="Invalid domain")
|
||||
new_domain_url = f"https://{domain}"
|
||||
stmt = (
|
||||
update(Key)
|
||||
.values(
|
||||
key=func.regexp_replace(Key.key, r"^https://[^/]+", new_domain_url),
|
||||
remnawave_link=func.regexp_replace(Key.remnawave_link, r"^https://[^/]+", new_domain_url),
|
||||
)
|
||||
.where(
|
||||
(Key.key.startswith("https://") & ~Key.key.startswith(new_domain_url))
|
||||
| (Key.remnawave_link.startswith("https://") & ~Key.remnawave_link.startswith(new_domain_url))
|
||||
)
|
||||
)
|
||||
result = await session.execute(stmt)
|
||||
return {"updated": result.rowcount or 0}
|
||||
|
||||
|
||||
@router.post("/restore-trials")
|
||||
async def restore_trials(
|
||||
identity=Depends(verify_identity_admin),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
):
|
||||
"""Сбрасывает trial=0 у пользователей без ключей."""
|
||||
await _admin_rate_limit(identity, "restore_trials", max_calls=3, window_sec=300)
|
||||
stmt = (
|
||||
update(User)
|
||||
.where(
|
||||
User.trial == 1,
|
||||
~exists(select(Key.user_id).where(Key.user_id == User.id)),
|
||||
)
|
||||
.values(trial=0)
|
||||
)
|
||||
result = await session.execute(stmt)
|
||||
return {"restored": result.rowcount or 0}
|
||||
|
||||
|
||||
@router.post("/backup")
|
||||
async def trigger_backup(identity=Depends(verify_identity_admin)):
|
||||
"""Запуск бэкапа БД в фоне."""
|
||||
await _admin_rate_limit(identity, "backup", max_calls=2, window_sec=300)
|
||||
|
||||
async def _run_backup() -> None:
|
||||
exception = await backup_database()
|
||||
if exception:
|
||||
logger.error(f"[Management] Backup finished with error: {exception}")
|
||||
|
||||
asyncio.create_task(_run_backup())
|
||||
return {"status": "backup_started"}
|
||||
|
||||
|
||||
@router.get("/broadcast/clusters")
|
||||
async def get_broadcast_clusters(
|
||||
identity=Depends(verify_identity_admin),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
):
|
||||
"""Список кластеров для рассылки по кластеру."""
|
||||
result = await session.execute(select(distinct(Server.cluster_name)).where(Server.cluster_name.is_not(None)))
|
||||
clusters = sorted([row[0] for row in result.all() if row and row[0]])
|
||||
return {"clusters": clusters}
|
||||
|
||||
|
||||
def _parse_date_range(
|
||||
date: str | None = None,
|
||||
date_from: str | None = None,
|
||||
date_to: str | None = None,
|
||||
) -> tuple[datetime, datetime]:
|
||||
"""Возвращает (date_from, date_to) в UTC. Либо date=YYYY-MM-DD (один день), либо date_from + date_to."""
|
||||
tz = timezone.utc
|
||||
if date:
|
||||
try:
|
||||
d = datetime.strptime(date, "%Y-%m-%d").date()
|
||||
start = datetime(d.year, d.month, d.day, 0, 0, 0, tzinfo=tz)
|
||||
end = start + timedelta(days=1)
|
||||
return start, end
|
||||
except ValueError:
|
||||
raise HTTPException(status_code=400, detail="date должен быть YYYY-MM-DD")
|
||||
if date_from and date_to:
|
||||
try:
|
||||
start = datetime.fromisoformat(date_from.replace("Z", "+00:00"))
|
||||
end = datetime.fromisoformat(date_to.replace("Z", "+00:00"))
|
||||
if start.tzinfo is None:
|
||||
start = start.replace(tzinfo=tz)
|
||||
if end.tzinfo is None:
|
||||
end = end.replace(tzinfo=tz)
|
||||
if start >= end:
|
||||
raise HTTPException(status_code=400, detail="date_from должен быть раньше date_to")
|
||||
return start, end
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=400, detail=f"Неверный формат дат: {e}")
|
||||
end = datetime.now(tz).replace(hour=0, minute=0, second=0, microsecond=0)
|
||||
start = end - timedelta(days=1)
|
||||
return start, end
|
||||
|
||||
|
||||
@router.get("/audit-stats", response_model=AuditStatsResponse)
|
||||
async def get_audit_stats_endpoint(
|
||||
identity=Depends(verify_identity_admin),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
date: str | None = Query(None, description="Один день: YYYY-MM-DD"),
|
||||
date_from: str | None = Query(None, description="Начало периода (ISO)"),
|
||||
date_to: str | None = Query(None, description="Конец периода (ISO)"),
|
||||
):
|
||||
"""Статистика аудита за период: какие пути отрабатывают хорошо/плохо, воронка старт→оплата.
|
||||
Данные только из БД (события из Redis учитываются после drain)."""
|
||||
start, end = _parse_date_range(date=date, date_from=date_from, date_to=date_to)
|
||||
stats = await get_audit_stats(session, date_from=start, date_to=end)
|
||||
funnel = await get_audit_funnel(session, date_from=start, date_to=end)
|
||||
return AuditStatsResponse(
|
||||
summary=stats["summary"],
|
||||
by_path=stats["by_path"],
|
||||
funnel=funnel,
|
||||
)
|
||||
|
||||
|
||||
@router.get("/audit-events", response_model=AuditEventListResponse)
|
||||
async def get_audit_events_history(
|
||||
identity=Depends(verify_identity_admin),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
identity_id: str | None = Query(None, description="Фильтр по identity_id"),
|
||||
tg_id: int | None = Query(None, description="Фильтр по Telegram user id"),
|
||||
channel: str | None = Query(None, description="api или telegram"),
|
||||
event_type: str | None = Query(None, description="Точный event_type"),
|
||||
limit: int = Query(100, ge=1, le=500),
|
||||
offset: int = Query(0, ge=0),
|
||||
):
|
||||
"""История аудита клиента по identity_id и/или tg_id."""
|
||||
if identity_id is None and tg_id is None:
|
||||
raise HTTPException(status_code=400, detail="Укажите identity_id или tg_id")
|
||||
|
||||
events = await list_audit_events(
|
||||
session,
|
||||
identity_id=identity_id,
|
||||
tg_id=tg_id,
|
||||
channel=channel,
|
||||
event_type=event_type,
|
||||
limit=limit,
|
||||
offset=offset,
|
||||
)
|
||||
return AuditEventListResponse(
|
||||
items=[
|
||||
AuditEventResponse(
|
||||
id=getattr(event, "id", None),
|
||||
event_type=event.event_type,
|
||||
channel=event.channel,
|
||||
actor_identity_id=event.actor_identity_id,
|
||||
actor_tg_id=event.actor_tg_id,
|
||||
path_or_handler=event.path_or_handler,
|
||||
entity_type=event.entity_type,
|
||||
entity_id=event.entity_id,
|
||||
result=event.result,
|
||||
reason=event.reason,
|
||||
metadata=event.metadata_,
|
||||
request_id=event.request_id,
|
||||
created_at=event.created_at,
|
||||
)
|
||||
for event in events
|
||||
],
|
||||
limit=limit,
|
||||
offset=offset,
|
||||
)
|
||||
|
||||
|
||||
@router.post("/audit-drain")
|
||||
async def post_audit_drain(identity=Depends(verify_identity_admin_short)):
|
||||
"""Выгружает буфер аудита из Redis в БД. Для вызова по крону (например 0 0 * * * в 00:00)."""
|
||||
try:
|
||||
count = await drain_audit_redis_to_db(async_session_maker)
|
||||
return {"success": True, "drained": count}
|
||||
except Exception as exc:
|
||||
logger.warning("audit-drain failed: {}", exc)
|
||||
raise HTTPException(status_code=500, detail="Внутренняя ошибка при дренаже аудита") from exc
|
||||
|
||||
|
||||
@router.post("/broadcast")
|
||||
async def launch_broadcast(
|
||||
payload: BroadcastLaunchPayload,
|
||||
identity=Depends(verify_identity_admin_short),
|
||||
):
|
||||
"""Запуск рассылки по выбранной аудитории. Сессия БД не держится на время рассылки."""
|
||||
await _admin_rate_limit(identity, "broadcast", max_calls=5, window_sec=300)
|
||||
try:
|
||||
prepared = prepare_broadcast_payload(
|
||||
send_to=payload.send_to,
|
||||
text=payload.text,
|
||||
photo=payload.photo,
|
||||
cluster_name=payload.cluster_name,
|
||||
workers=payload.workers,
|
||||
messages_per_second=payload.messages_per_second,
|
||||
channel=payload.channel,
|
||||
)
|
||||
except ValueError as exc:
|
||||
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
||||
return await execute_broadcast_payload(prepared, bot=_get_broadcast_bot())
|
||||
|
||||
|
||||
@router.post("/broadcast/scheduled")
|
||||
async def create_broadcast_schedule(
|
||||
payload: ScheduledBroadcastCreatePayload,
|
||||
identity=Depends(verify_identity_admin_short),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
):
|
||||
try:
|
||||
prepared = prepare_broadcast_payload(
|
||||
send_to=payload.send_to,
|
||||
text=payload.text,
|
||||
photo=payload.photo,
|
||||
cluster_name=payload.cluster_name,
|
||||
workers=payload.workers,
|
||||
messages_per_second=payload.messages_per_second,
|
||||
channel=payload.channel,
|
||||
)
|
||||
except ValueError as exc:
|
||||
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
||||
broadcast = await create_scheduled_broadcast(
|
||||
session,
|
||||
created_by_tg_id=getattr(identity, "tg_id", None),
|
||||
send_to=prepared["send_to"],
|
||||
channel=prepared["channel"],
|
||||
cluster_name=prepared["cluster_name"],
|
||||
text=prepared["text"],
|
||||
photo=prepared["photo"],
|
||||
keyboard_json=prepared["keyboard_json"],
|
||||
scheduled_for=_require_future_schedule(payload.scheduled_for),
|
||||
workers=prepared["workers"],
|
||||
messages_per_second=prepared["messages_per_second"],
|
||||
)
|
||||
return {"success": True, "item": scheduled_broadcast_to_dict(broadcast)}
|
||||
|
||||
|
||||
@router.get("/broadcast/scheduled")
|
||||
async def list_broadcast_schedules(
|
||||
identity=Depends(verify_identity_admin),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
status: str | None = Query(None, description="Фильтр статусов через запятую"),
|
||||
limit: int = Query(20, ge=1, le=100),
|
||||
offset: int = Query(0, ge=0),
|
||||
):
|
||||
statuses = [item.strip() for item in (status or "").split(",") if item.strip()] or None
|
||||
items = await list_scheduled_broadcasts(session, statuses=statuses, limit=limit, offset=offset)
|
||||
return {"items": [scheduled_broadcast_to_dict(item) for item in items], "limit": limit, "offset": offset}
|
||||
|
||||
|
||||
@router.get("/broadcast/scheduled/{broadcast_id}")
|
||||
async def get_broadcast_schedule(
|
||||
broadcast_id: str,
|
||||
identity=Depends(verify_identity_admin),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
):
|
||||
item = await get_scheduled_broadcast(session, broadcast_id)
|
||||
if item is None:
|
||||
raise HTTPException(status_code=404, detail="Scheduled broadcast not found")
|
||||
return {"item": scheduled_broadcast_to_dict(item)}
|
||||
|
||||
|
||||
@router.patch("/broadcast/scheduled/{broadcast_id}")
|
||||
async def update_broadcast_schedule(
|
||||
broadcast_id: str,
|
||||
payload: ScheduledBroadcastUpdatePayload,
|
||||
identity=Depends(verify_identity_admin_short),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
):
|
||||
current = await get_scheduled_broadcast(session, broadcast_id)
|
||||
if current is None:
|
||||
raise HTTPException(status_code=404, detail="Scheduled broadcast not found")
|
||||
try:
|
||||
values = _resolve_update_payload(payload, current)
|
||||
except ValueError as exc:
|
||||
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
||||
updated = await update_scheduled_broadcast(session, broadcast_id, **values)
|
||||
if updated is None:
|
||||
raise HTTPException(status_code=409, detail="Scheduled broadcast can no longer be edited")
|
||||
return {"success": True, "item": scheduled_broadcast_to_dict(updated)}
|
||||
|
||||
|
||||
@router.post("/broadcast/scheduled/{broadcast_id}/cancel")
|
||||
async def cancel_broadcast_schedule(
|
||||
broadcast_id: str,
|
||||
identity=Depends(verify_identity_admin_short),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
):
|
||||
item = await cancel_scheduled_broadcast(session, broadcast_id)
|
||||
if item is None:
|
||||
raise HTTPException(status_code=409, detail="Scheduled broadcast can no longer be cancelled")
|
||||
return {"success": True, "item": scheduled_broadcast_to_dict(item)}
|
||||
|
||||
|
||||
@router.post("/broadcast/scheduled/{broadcast_id}/send-now")
|
||||
async def send_broadcast_schedule_now(
|
||||
broadcast_id: str,
|
||||
identity=Depends(verify_identity_admin_short),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
):
|
||||
await _admin_rate_limit(identity, "broadcast_now", max_calls=5, window_sec=300)
|
||||
item = await start_scheduled_broadcast(session, broadcast_id)
|
||||
if item is None:
|
||||
raise HTTPException(status_code=409, detail="Scheduled broadcast can no longer be sent now")
|
||||
try:
|
||||
result = await execute_scheduled_broadcast(item, bot=_get_broadcast_bot())
|
||||
except Exception as exc:
|
||||
logger.error("[Broadcast] send-now failed for {}: {}", broadcast_id, exc)
|
||||
await mark_scheduled_broadcast_failed(session, broadcast_id, str(exc))
|
||||
raise HTTPException(status_code=500, detail="Ошибка при выполнении рассылки") from exc
|
||||
if result.get("success"):
|
||||
item = await mark_scheduled_broadcast_sent(session, broadcast_id, result)
|
||||
else:
|
||||
item = await mark_scheduled_broadcast_failed(session, broadcast_id, result.get("message", "Broadcast failed"))
|
||||
return {"success": bool(result.get("success")), "item": scheduled_broadcast_to_dict(item), "result": result}
|
||||
@@ -1,165 +0,0 @@
|
||||
from fastapi import APIRouter, Depends, HTTPException, Path
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from api.depends import get_session, verify_identity_admin
|
||||
from api.v2.base_crud import generate_crud_router
|
||||
from api.v2.schemas import (
|
||||
BlockedUserResponse,
|
||||
ManualBanResponse,
|
||||
NotificationResponse,
|
||||
PaymentResponse,
|
||||
TemporaryDataResponse,
|
||||
TrackingSourceResponse,
|
||||
)
|
||||
from database import get_tracking_source_stats
|
||||
from database.access.resolution import resolve_user_optional
|
||||
from database.models import (
|
||||
BlockedUser,
|
||||
ManualBan,
|
||||
Notification,
|
||||
Payment,
|
||||
TemporaryData,
|
||||
TrackingSource,
|
||||
)
|
||||
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
router.include_router(
|
||||
generate_crud_router(
|
||||
model=Payment,
|
||||
schema_response=PaymentResponse,
|
||||
schema_create=None,
|
||||
schema_update=None,
|
||||
identifier_field="id",
|
||||
enabled_methods=["get_all", "get_one", "delete"],
|
||||
),
|
||||
prefix="/payments",
|
||||
tags=["Payments"],
|
||||
dependencies=[Depends(verify_identity_admin)],
|
||||
)
|
||||
|
||||
|
||||
@router.get("/payments/by_tg_id/{tg_id}", response_model=list[PaymentResponse], tags=["Payments"])
|
||||
async def get_payments_by_tg_id(
|
||||
tg_id: int = Path(...),
|
||||
identity=Depends(verify_identity_admin),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
):
|
||||
"""Список платежей по tg_id пользователя."""
|
||||
u = await resolve_user_optional(session, tg_id)
|
||||
if u is None:
|
||||
raise HTTPException(status_code=404, detail="Payments not found")
|
||||
result = await session.execute(select(Payment).where(Payment.user_id == u.id))
|
||||
payments = result.scalars().all()
|
||||
if not payments:
|
||||
raise HTTPException(status_code=404, detail="Payments not found")
|
||||
return payments
|
||||
|
||||
|
||||
router.include_router(
|
||||
generate_crud_router(
|
||||
model=Notification,
|
||||
schema_response=NotificationResponse,
|
||||
schema_create=None,
|
||||
schema_update=None,
|
||||
identifier_field="user_id",
|
||||
parameter_name="tg_id",
|
||||
telegram_path_to_user_id=True,
|
||||
enabled_methods=["get_all", "get_one", "delete"],
|
||||
),
|
||||
prefix="/notifications",
|
||||
tags=["Notifications"],
|
||||
dependencies=[Depends(verify_identity_admin)],
|
||||
)
|
||||
|
||||
router.include_router(
|
||||
generate_crud_router(
|
||||
model=ManualBan,
|
||||
schema_response=ManualBanResponse,
|
||||
schema_create=None,
|
||||
schema_update=None,
|
||||
identifier_field="user_id",
|
||||
parameter_name="tg_id",
|
||||
telegram_path_to_user_id=True,
|
||||
enabled_methods=["get_all", "get_one", "delete"],
|
||||
),
|
||||
prefix="/manual-bans",
|
||||
tags=["Bans"],
|
||||
dependencies=[Depends(verify_identity_admin)],
|
||||
)
|
||||
|
||||
router.include_router(
|
||||
generate_crud_router(
|
||||
model=BlockedUser,
|
||||
schema_response=BlockedUserResponse,
|
||||
schema_create=None,
|
||||
schema_update=None,
|
||||
identifier_field="user_id",
|
||||
parameter_name="tg_id",
|
||||
telegram_path_to_user_id=True,
|
||||
enabled_methods=["get_all", "get_one", "delete"],
|
||||
),
|
||||
prefix="/blocked-users",
|
||||
tags=["Bans"],
|
||||
dependencies=[Depends(verify_identity_admin)],
|
||||
)
|
||||
|
||||
router.include_router(
|
||||
generate_crud_router(
|
||||
model=TemporaryData,
|
||||
schema_response=TemporaryDataResponse,
|
||||
schema_create=None,
|
||||
schema_update=None,
|
||||
identifier_field="user_id",
|
||||
parameter_name="tg_id",
|
||||
telegram_path_to_user_id=True,
|
||||
enabled_methods=["get_all", "get_one", "delete"],
|
||||
),
|
||||
prefix="/temporary-data",
|
||||
tags=["TemporaryData"],
|
||||
dependencies=[Depends(verify_identity_admin)],
|
||||
)
|
||||
|
||||
router.include_router(
|
||||
generate_crud_router(
|
||||
model=TrackingSource,
|
||||
schema_response=TrackingSourceResponse,
|
||||
schema_create=None,
|
||||
schema_update=None,
|
||||
identifier_field="id",
|
||||
enabled_methods=["get_all", "delete"],
|
||||
),
|
||||
prefix="/tracking-sources",
|
||||
tags=["TrackingSources"],
|
||||
dependencies=[Depends(verify_identity_admin)],
|
||||
)
|
||||
|
||||
|
||||
@router.get(
|
||||
"/tracking-sources/{code}", response_model=TrackingSourceResponse, dependencies=[Depends(verify_identity_admin)]
|
||||
)
|
||||
async def get_tracking_source_with_stats(
|
||||
code: str,
|
||||
session: AsyncSession = Depends(get_session),
|
||||
):
|
||||
"""Источник по коду со статистикой регистраций и платежей."""
|
||||
result = await session.execute(select(TrackingSource).where(TrackingSource.code == code))
|
||||
source = result.scalar_one_or_none()
|
||||
if not source:
|
||||
raise HTTPException(status_code=404, detail="Tracking source not found")
|
||||
stats = await get_tracking_source_stats(session, code)
|
||||
return TrackingSourceResponse(
|
||||
id=source.id,
|
||||
name=source.name,
|
||||
code=source.code,
|
||||
type=source.type,
|
||||
created_by=source.created_by,
|
||||
created_at=source.created_at,
|
||||
registrations=(stats["registrations"] if stats else 0),
|
||||
trials=(stats["trials"] if stats else 0),
|
||||
payments=(stats["payments"] if stats else 0),
|
||||
total_amount=(float(stats["total_amount"]) if stats else 0.0),
|
||||
monthly=(stats["monthly"] if stats and "monthly" in stats else []),
|
||||
)
|
||||
@@ -1,125 +0,0 @@
|
||||
import pkgutil
|
||||
|
||||
from pathlib import Path
|
||||
from typing import Literal
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from pydantic import BaseModel
|
||||
|
||||
from api.depends import verify_identity_admin
|
||||
from core.executor import run_io
|
||||
from logger import logger
|
||||
from utils.modules_loader import _is_safe_module_name
|
||||
from utils.modules_manager import manager
|
||||
|
||||
|
||||
router = APIRouter(prefix="/modules", tags=["Modules"])
|
||||
|
||||
MODULES_DIR = Path(__file__).resolve().parents[3] / "modules"
|
||||
|
||||
|
||||
class ModuleAction(BaseModel):
|
||||
action: Literal["start", "stop", "restart"]
|
||||
|
||||
|
||||
def _available_module_names() -> list[str]:
|
||||
"""Имена модулей из папки modules, прошедшие проверку безопасности."""
|
||||
candidates: set[str] = set()
|
||||
if MODULES_DIR.is_dir():
|
||||
for _finder, name, _ispkg in pkgutil.iter_modules([str(MODULES_DIR)]):
|
||||
name = (name or "").strip()
|
||||
if name and _is_safe_module_name(name):
|
||||
candidates.add(name)
|
||||
return sorted(n for n in candidates if _is_safe_module_name(n))
|
||||
|
||||
|
||||
def _prune_missing_state(installed: set[str]) -> None:
|
||||
"""Удаляет из состояния менеджера модули, которых нет в файловой системе."""
|
||||
changed = False
|
||||
stale_disabled = {name for name in manager.disabled if name not in installed}
|
||||
if stale_disabled:
|
||||
for name in stale_disabled:
|
||||
manager.disabled.discard(name)
|
||||
changed = True
|
||||
stale_registry = [name for name in list(manager.registry.keys()) if name not in installed]
|
||||
if stale_registry:
|
||||
for name in stale_registry:
|
||||
manager.registry.pop(name, None)
|
||||
changed = True
|
||||
if changed:
|
||||
save_state = getattr(manager, "_save_state", None)
|
||||
if callable(save_state):
|
||||
save_state()
|
||||
|
||||
|
||||
def _module_state(name: str) -> dict:
|
||||
"""Состояние модуля: enabled, loaded, autostart."""
|
||||
normalized = name.strip()
|
||||
record = manager.registry.get(normalized)
|
||||
is_enabled = manager.is_enabled(normalized)
|
||||
return {
|
||||
"name": normalized,
|
||||
"enabled": is_enabled,
|
||||
"loaded": bool(record and record.enabled),
|
||||
"autostart": manager.should_autostart(normalized),
|
||||
}
|
||||
|
||||
|
||||
def _read_local_module_version(name: str) -> str | None:
|
||||
"""Читает VERSION из папки модуля."""
|
||||
version_file = MODULES_DIR / name / "VERSION"
|
||||
if not version_file.exists() or not version_file.is_file():
|
||||
return None
|
||||
try:
|
||||
with version_file.open("r", encoding="utf-8") as handle:
|
||||
for line in handle:
|
||||
value = line.strip()
|
||||
if value:
|
||||
return value
|
||||
except Exception:
|
||||
return None
|
||||
return None
|
||||
|
||||
|
||||
def sync_list_modules() -> list:
|
||||
"""Вся синхронная работа со списком модулей (файлы, состояние). Вызывать через run_io()."""
|
||||
refresh = getattr(manager, "refresh_state", None) or getattr(manager, "_load_state", None)
|
||||
if callable(refresh):
|
||||
refresh()
|
||||
module_names = _available_module_names()
|
||||
_prune_missing_state(set(module_names))
|
||||
modules = [_module_state(name) for name in module_names]
|
||||
for item in modules:
|
||||
name = str(item.get("name") or "").strip()
|
||||
item["local_version"] = _read_local_module_version(name)
|
||||
return modules
|
||||
|
||||
|
||||
@router.get("/")
|
||||
async def list_modules(identity=Depends(verify_identity_admin)):
|
||||
"""Список модулей с состоянием и локальной версией."""
|
||||
modules = await run_io(sync_list_modules)
|
||||
return {"items": modules}
|
||||
|
||||
|
||||
@router.post("/{module_name}/actions")
|
||||
async def control_module(module_name: str, payload: ModuleAction, identity=Depends(verify_identity_admin)):
|
||||
"""Запуск, остановка или перезапуск модуля."""
|
||||
name = (module_name or "").strip()
|
||||
if not _is_safe_module_name(name):
|
||||
raise HTTPException(status_code=404, detail="Module not found")
|
||||
try:
|
||||
if payload.action == "start":
|
||||
await manager.start(name)
|
||||
elif payload.action == "stop":
|
||||
await manager.stop(name)
|
||||
elif payload.action == "restart":
|
||||
await manager.restart(name)
|
||||
else:
|
||||
raise HTTPException(status_code=400, detail="Unsupported action")
|
||||
except ValueError as exc:
|
||||
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
||||
except RuntimeError as exc:
|
||||
logger.error("[Modules] action failed for {}: {}", name, exc)
|
||||
raise HTTPException(status_code=500, detail="Ошибка при выполнении операции модуля") from exc
|
||||
return {"item": _module_state(name)}
|
||||
@@ -1,112 +0,0 @@
|
||||
from fastapi import APIRouter, Depends, HTTPException, Path, Query
|
||||
from pydantic import BaseModel
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from api.depends import get_session, verify_identity_token
|
||||
from database import web_notifications as wn_db
|
||||
from database.models import Identity
|
||||
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
class PushSubscribeRequest(BaseModel):
|
||||
endpoint: str
|
||||
keys: dict
|
||||
|
||||
|
||||
class NotificationItem(BaseModel):
|
||||
id: str
|
||||
type: str
|
||||
title: str
|
||||
message: str
|
||||
read: bool
|
||||
created_at: str
|
||||
data: dict | None = None
|
||||
|
||||
|
||||
class NotificationsResponse(BaseModel):
|
||||
ok: bool = True
|
||||
notifications: list[NotificationItem]
|
||||
unread_count: int
|
||||
|
||||
|
||||
@router.post("/push/subscribe", tags=["Notifications"])
|
||||
async def push_subscribe(
|
||||
body: PushSubscribeRequest,
|
||||
session: AsyncSession = Depends(get_session),
|
||||
identity: Identity = Depends(verify_identity_token),
|
||||
):
|
||||
user_id = identity.tg_id or 0
|
||||
|
||||
await wn_db.upsert_push_subscription(
|
||||
session,
|
||||
user_id=user_id,
|
||||
identity_id=identity.id,
|
||||
endpoint=body.endpoint,
|
||||
keys_json=body.keys,
|
||||
)
|
||||
return {"ok": True}
|
||||
|
||||
|
||||
@router.get("/notifications", response_model=NotificationsResponse, tags=["Notifications"])
|
||||
async def get_notifications(
|
||||
limit: int = Query(20, ge=1, le=100),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
identity: Identity = Depends(verify_identity_token),
|
||||
):
|
||||
notifications = await wn_db.get_notifications_for_identity(
|
||||
session,
|
||||
identity.id,
|
||||
limit=limit,
|
||||
)
|
||||
unread_count = await wn_db.count_unread_for_identity(session, identity.id)
|
||||
|
||||
items = [
|
||||
NotificationItem(
|
||||
id=n.id,
|
||||
type=n.type,
|
||||
title=n.title,
|
||||
message=n.message,
|
||||
read=n.read,
|
||||
created_at=n.created_at.isoformat() if n.created_at else "",
|
||||
data=n.data,
|
||||
)
|
||||
for n in notifications
|
||||
]
|
||||
return NotificationsResponse(notifications=items, unread_count=unread_count)
|
||||
|
||||
|
||||
@router.post("/notifications/read-all", tags=["Notifications"])
|
||||
async def read_all_notifications(
|
||||
session: AsyncSession = Depends(get_session),
|
||||
identity: Identity = Depends(verify_identity_token),
|
||||
):
|
||||
count = await wn_db.mark_all_read_for_identity(session, identity.id)
|
||||
return {"ok": True, "updated": count}
|
||||
|
||||
|
||||
@router.post("/notifications/{notification_id}/read", tags=["Notifications"])
|
||||
async def read_one_notification(
|
||||
notification_id: str = Path(..., min_length=1, max_length=64),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
identity: Identity = Depends(verify_identity_token),
|
||||
):
|
||||
"""Пометить одно уведомление прочитанным. 404 если не найдено или не принадлежит юзеру."""
|
||||
ok = await wn_db.mark_one_read_for_identity(session, identity.id, notification_id)
|
||||
if not ok:
|
||||
raise HTTPException(status_code=404, detail="Уведомление не найдено")
|
||||
return {"ok": True}
|
||||
|
||||
|
||||
@router.delete("/notifications/{notification_id}", tags=["Notifications"])
|
||||
async def delete_one_notification(
|
||||
notification_id: str = Path(..., min_length=1, max_length=64),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
identity: Identity = Depends(verify_identity_token),
|
||||
):
|
||||
"""Удалить одно уведомление. 404 если не найдено или не принадлежит юзеру."""
|
||||
ok = await wn_db.delete_one_for_identity(session, identity.id, notification_id)
|
||||
if not ok:
|
||||
raise HTTPException(status_code=404, detail="Уведомление не найдено")
|
||||
return {"ok": True}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,272 +0,0 @@
|
||||
import asyncio
|
||||
import json
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Request
|
||||
from fastapi.responses import StreamingResponse
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from api.depends import get_session, verify_identity_token
|
||||
from api.v2.schemas.payment_links import PaymentLinkCreateRequest, PaymentLinkCreateResponse, PaymentLinkStatusResponse
|
||||
from config import REDIS_URL
|
||||
from database import (
|
||||
async_session_maker,
|
||||
get_payment_by_payment_id,
|
||||
get_payment_from_db_by_payment_id,
|
||||
identities as idb,
|
||||
)
|
||||
from database.temporary_data import create_temporary_data
|
||||
from logger import logger
|
||||
from services.payments.payment_events import payment_events_channel
|
||||
from services.payments.payment_links import PaymentLinkRequest, create_payment_link
|
||||
|
||||
|
||||
router = APIRouter(tags=["PaymentLinks"])
|
||||
|
||||
|
||||
async def _store_payment_intent(
|
||||
session: AsyncSession,
|
||||
billing_user_ref: int,
|
||||
metadata: dict | None,
|
||||
amount: int | float,
|
||||
) -> None:
|
||||
if not isinstance(metadata, dict):
|
||||
return
|
||||
payment_flow = str(metadata.get("payment_flow") or "").strip().lower()
|
||||
required_amount = int(round(float(amount)))
|
||||
if payment_flow == "tariff_purchase":
|
||||
tariff_id = metadata.get("tariff_id")
|
||||
if tariff_id in (None, ""):
|
||||
return
|
||||
payload: dict[str, int | str] = {
|
||||
"tariff_id": int(tariff_id),
|
||||
"required_amount": required_amount,
|
||||
"selected_price_rub": int(metadata.get("selected_price_rub") or required_amount),
|
||||
}
|
||||
selected_device_limit = metadata.get("selected_device_limit")
|
||||
if selected_device_limit not in (None, ""):
|
||||
payload["selected_device_limit"] = int(selected_device_limit)
|
||||
selected_traffic_gb = metadata.get("selected_traffic_gb")
|
||||
if selected_traffic_gb not in (None, ""):
|
||||
payload["selected_traffic_limit_gb"] = int(selected_traffic_gb)
|
||||
selected_duration_days = metadata.get("selected_duration_days")
|
||||
if selected_duration_days not in (None, ""):
|
||||
payload["selected_duration_days"] = int(selected_duration_days)
|
||||
coupon_id = metadata.get("coupon_id")
|
||||
if coupon_id not in (None, ""):
|
||||
payload["coupon_id"] = int(coupon_id)
|
||||
discount_rub = metadata.get("discount_rub")
|
||||
if discount_rub not in (None, ""):
|
||||
payload["discount_rub"] = int(discount_rub)
|
||||
base_price_rub = metadata.get("base_price_rub")
|
||||
if base_price_rub not in (None, ""):
|
||||
payload["base_price_rub"] = int(base_price_rub)
|
||||
applied_coupon_code = metadata.get("applied_coupon_code")
|
||||
if applied_coupon_code not in (None, ""):
|
||||
payload["applied_coupon_code"] = str(applied_coupon_code)
|
||||
await create_temporary_data(session, billing_user_ref, "waiting_for_payment", payload)
|
||||
return
|
||||
if payment_flow == "key_renewal":
|
||||
required_fields = ("tariff_id", "client_id", "email", "cost")
|
||||
if any(metadata.get(field) in (None, "") for field in required_fields):
|
||||
return
|
||||
payload: dict[str, int | str] = {
|
||||
"tariff_id": int(metadata["tariff_id"]),
|
||||
"client_id": str(metadata["client_id"]),
|
||||
"email": str(metadata["email"]),
|
||||
"cost": int(metadata["cost"]),
|
||||
"required_amount": required_amount,
|
||||
"selected_price_rub": int(metadata.get("selected_price_rub") or metadata["cost"]),
|
||||
}
|
||||
selected_duration_days = metadata.get("selected_duration_days")
|
||||
if selected_duration_days not in (None, ""):
|
||||
payload["selected_duration_days"] = int(selected_duration_days)
|
||||
selected_device_limit = metadata.get("selected_device_limit")
|
||||
if selected_device_limit not in (None, ""):
|
||||
payload["selected_device_limit"] = int(selected_device_limit)
|
||||
selected_traffic_limit = metadata.get("selected_traffic_limit")
|
||||
if selected_traffic_limit not in (None, ""):
|
||||
payload["selected_traffic_limit"] = int(selected_traffic_limit)
|
||||
total_gb = metadata.get("total_gb")
|
||||
if total_gb not in (None, ""):
|
||||
payload["total_gb"] = int(total_gb)
|
||||
coupon_id = metadata.get("coupon_id")
|
||||
if coupon_id not in (None, ""):
|
||||
payload["coupon_id"] = int(coupon_id)
|
||||
discount_rub = metadata.get("discount_rub")
|
||||
if discount_rub not in (None, ""):
|
||||
payload["discount_rub"] = int(discount_rub)
|
||||
base_price_rub = metadata.get("base_price_rub")
|
||||
if base_price_rub not in (None, ""):
|
||||
payload["base_price_rub"] = int(base_price_rub)
|
||||
applied_coupon_code = metadata.get("applied_coupon_code")
|
||||
if applied_coupon_code not in (None, ""):
|
||||
payload["applied_coupon_code"] = str(applied_coupon_code)
|
||||
await create_temporary_data(session, billing_user_ref, "waiting_for_renewal_payment", payload)
|
||||
return
|
||||
if payment_flow == "key_addons":
|
||||
required_fields = ("tariff_id", "email", "original_price")
|
||||
if any(metadata.get(field) in (None, "") for field in required_fields):
|
||||
return
|
||||
payload: dict[str, int | str] = {
|
||||
"tariff_id": int(metadata["tariff_id"]),
|
||||
"email": str(metadata["email"]),
|
||||
"original_price": int(metadata["original_price"]),
|
||||
"required_amount": required_amount,
|
||||
}
|
||||
selected_device_limit = metadata.get("selected_device_limit")
|
||||
if selected_device_limit not in (None, ""):
|
||||
payload["selected_device_limit"] = int(selected_device_limit)
|
||||
selected_traffic_gb = metadata.get("selected_traffic_gb")
|
||||
if selected_traffic_gb not in (None, ""):
|
||||
payload["selected_traffic_gb"] = int(selected_traffic_gb)
|
||||
current_device_limit = metadata.get("current_device_limit")
|
||||
if current_device_limit not in (None, ""):
|
||||
payload["current_device_limit"] = int(current_device_limit)
|
||||
current_traffic_gb = metadata.get("current_traffic_gb")
|
||||
if current_traffic_gb not in (None, ""):
|
||||
payload["current_traffic_gb"] = int(current_traffic_gb)
|
||||
coupon_id = metadata.get("coupon_id")
|
||||
if coupon_id not in (None, ""):
|
||||
payload["coupon_id"] = int(coupon_id)
|
||||
discount_rub = metadata.get("discount_rub")
|
||||
if discount_rub not in (None, ""):
|
||||
payload["discount_rub"] = int(discount_rub)
|
||||
base_price_rub = metadata.get("base_price_rub")
|
||||
if base_price_rub not in (None, ""):
|
||||
payload["base_price_rub"] = int(base_price_rub)
|
||||
applied_coupon_code = metadata.get("applied_coupon_code")
|
||||
if applied_coupon_code not in (None, ""):
|
||||
payload["applied_coupon_code"] = str(applied_coupon_code)
|
||||
await create_temporary_data(session, billing_user_ref, "waiting_for_addons_payment", payload)
|
||||
|
||||
|
||||
@router.post("/", response_model=PaymentLinkCreateResponse)
|
||||
async def create_link(
|
||||
body: PaymentLinkCreateRequest,
|
||||
http_request: Request,
|
||||
session: AsyncSession = Depends(get_session),
|
||||
identity=Depends(verify_identity_token),
|
||||
):
|
||||
"""Создаёт платёжную ссылку для текущего авторизованного пользователя."""
|
||||
billing_user_ref = await idb.ensure_billing_user_for_identity(session, identity)
|
||||
payment_request = PaymentLinkRequest(
|
||||
legacy_user_ref=billing_user_ref,
|
||||
amount=body.amount,
|
||||
currency=body.currency or "RUB",
|
||||
provider_id=body.provider_id,
|
||||
success_url=body.success_url,
|
||||
failure_url=body.failure_url,
|
||||
metadata=body.metadata,
|
||||
)
|
||||
result = await create_payment_link(session, payment_request)
|
||||
if result.success:
|
||||
await _store_payment_intent(
|
||||
session=session,
|
||||
billing_user_ref=billing_user_ref,
|
||||
metadata=body.metadata,
|
||||
amount=body.amount,
|
||||
)
|
||||
return PaymentLinkCreateResponse(
|
||||
success=result.success,
|
||||
payment_id=result.payment_id,
|
||||
payment_url=result.payment_url,
|
||||
error=result.error,
|
||||
)
|
||||
|
||||
|
||||
@router.get("/stream")
|
||||
async def payment_events_stream(
|
||||
request: Request,
|
||||
x_identity_id: str = "",
|
||||
token: str = "",
|
||||
):
|
||||
identity_id = str(request.headers.get("X-Identity-Id") or x_identity_id or "").strip()
|
||||
token = str(request.headers.get("X-Token") or token or "").strip()
|
||||
if not identity_id or not token:
|
||||
raise HTTPException(status_code=401, detail="Unauthorized")
|
||||
|
||||
async with async_session_maker() as session:
|
||||
identity = await idb.verify_identity_token(session, identity_id, token)
|
||||
if not identity:
|
||||
raise HTTPException(status_code=401, detail="Unauthorized")
|
||||
billing_user_ref = await idb.ensure_billing_user_for_identity(session, identity)
|
||||
await session.commit()
|
||||
|
||||
async def event_generator():
|
||||
redis_client = None
|
||||
pubsub = None
|
||||
channel = payment_events_channel(int(billing_user_ref))
|
||||
try:
|
||||
from redis.asyncio import from_url
|
||||
|
||||
redis_client = from_url(REDIS_URL, encoding="utf-8", decode_responses=True, max_connections=8)
|
||||
pubsub = redis_client.pubsub(ignore_subscribe_messages=True)
|
||||
await pubsub.subscribe(channel)
|
||||
logger.info(f"[Payments] SSE subscribed: user_ref={billing_user_ref}, channel={channel}")
|
||||
yield "retry: 1500\n\n"
|
||||
while True:
|
||||
if await request.is_disconnected():
|
||||
logger.info(f"[Payments] SSE disconnected by client: user_ref={billing_user_ref}")
|
||||
break
|
||||
message = await pubsub.get_message(ignore_subscribe_messages=True, timeout=15.0)
|
||||
if message and message.get("type") == "message":
|
||||
raw_data = message.get("data")
|
||||
payload = json.loads(raw_data) if isinstance(raw_data, str) else raw_data
|
||||
if isinstance(payload, dict):
|
||||
logger.info(
|
||||
f"[Payments] SSE emit: user_ref={billing_user_ref}, "
|
||||
f"status={payload.get('status')}, flow={payload.get('flow')}"
|
||||
)
|
||||
yield f"data: {json.dumps(payload, ensure_ascii=False)}\n\n"
|
||||
continue
|
||||
yield ": keepalive\n\n"
|
||||
await asyncio.sleep(0.1)
|
||||
finally:
|
||||
if pubsub is not None:
|
||||
try:
|
||||
await pubsub.unsubscribe(channel)
|
||||
await pubsub.close()
|
||||
except Exception:
|
||||
pass
|
||||
if redis_client is not None:
|
||||
try:
|
||||
await redis_client.aclose()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return StreamingResponse(
|
||||
event_generator(),
|
||||
media_type="text/event-stream",
|
||||
headers={
|
||||
"Cache-Control": "no-cache, no-transform",
|
||||
"Connection": "keep-alive",
|
||||
"X-Accel-Buffering": "no",
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@router.get("/{payment_id}", response_model=PaymentLinkStatusResponse)
|
||||
async def get_link_status(
|
||||
payment_id: str,
|
||||
session: AsyncSession = Depends(get_session),
|
||||
identity=Depends(verify_identity_token),
|
||||
):
|
||||
billing_user_ref = await idb.ensure_billing_user_for_identity(session, identity)
|
||||
payment = await get_payment_from_db_by_payment_id(session, payment_id)
|
||||
if payment is None:
|
||||
payment = await get_payment_by_payment_id(session, payment_id)
|
||||
if not payment:
|
||||
raise HTTPException(status_code=404, detail="Payment not found")
|
||||
owner_ref = payment.get("user_id")
|
||||
if owner_ref is None:
|
||||
owner_ref = payment.get("tg_id")
|
||||
if owner_ref is None or int(owner_ref) != int(billing_user_ref):
|
||||
raise HTTPException(status_code=404, detail="Payment not found")
|
||||
status = str(payment.get("status") or "").lower() or None
|
||||
return PaymentLinkStatusResponse(
|
||||
success=True,
|
||||
payment_id=payment_id,
|
||||
status=status,
|
||||
completed=status in {"success", "failed", "cancelled"},
|
||||
paid=status == "success",
|
||||
)
|
||||
@@ -1,250 +0,0 @@
|
||||
from base64 import b64encode
|
||||
from io import BytesIO
|
||||
from urllib.parse import urlsplit
|
||||
|
||||
import qrcode
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, Request
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from api.depends import get_session, verify_identity_token
|
||||
from api.v2.schemas.web_public import (
|
||||
ReferralApplyRequest,
|
||||
ReferralApplyResponse,
|
||||
ReferralConditionsResponse,
|
||||
ReferralListEntry,
|
||||
ReferralListResponse,
|
||||
ReferralQrResponse,
|
||||
ReferralTopEntryResponse,
|
||||
ReferralTopResponse,
|
||||
)
|
||||
from sqlalchemy import select
|
||||
from database.models import Referral
|
||||
from config import (
|
||||
CHECK_REFERRAL_REWARD_ISSUED,
|
||||
REFERRAL_BONUS_PERCENTAGES,
|
||||
REFERRAL_BUTTON,
|
||||
REFERRAL_QR,
|
||||
TOP_REFERRAL_BUTTON,
|
||||
)
|
||||
from core.bootstrap import BUTTONS_CONFIG
|
||||
from database import (
|
||||
add_referral,
|
||||
get_referral_by_referred_id,
|
||||
get_user_referral_count,
|
||||
identities as idb,
|
||||
)
|
||||
from database.access.resolution import resolve_user_optional
|
||||
from database.referrals import get_referral_position, get_top_referrals
|
||||
from utils.referral_codes import decode_referral_code, encode_referral_code
|
||||
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
def _normalize_referrer_code(value: str | None, fallback_tg_id: int | None) -> int | None:
|
||||
raw = str(value or "").strip()
|
||||
if raw:
|
||||
if "/referral/" in raw:
|
||||
raw = raw.split("/referral/", 1)[-1]
|
||||
if "start=referral_" in raw:
|
||||
raw = raw.split("start=referral_", 1)[-1]
|
||||
raw = raw.split("?", 1)[0].split("#", 1)[0].strip()
|
||||
parsed = decode_referral_code(raw)
|
||||
if parsed is not None:
|
||||
return parsed
|
||||
if fallback_tg_id is not None and int(fallback_tg_id) > 0:
|
||||
return int(fallback_tg_id)
|
||||
return None
|
||||
|
||||
|
||||
def _resolve_public_base_url(request: Request) -> str:
|
||||
origin = str(request.headers.get("origin") or "").strip()
|
||||
if origin.startswith(("http://", "https://")):
|
||||
return origin.rstrip("/")
|
||||
referer = str(request.headers.get("referer") or request.headers.get("referrer") or "").strip()
|
||||
if referer.startswith(("http://", "https://")):
|
||||
parsed = urlsplit(referer)
|
||||
if parsed.scheme and parsed.netloc:
|
||||
return f"{parsed.scheme}://{parsed.netloc}".rstrip("/")
|
||||
forwarded_host = str(request.headers.get("x-forwarded-host") or "").strip()
|
||||
host = forwarded_host or str(request.headers.get("host") or "").strip()
|
||||
forwarded_proto = str(request.headers.get("x-forwarded-proto") or "").split(",", 1)[0].strip().lower()
|
||||
scheme = forwarded_proto if forwarded_proto in {"http", "https"} else request.url.scheme
|
||||
if host:
|
||||
return f"{scheme}://{host}".rstrip("/")
|
||||
return str(request.base_url).rstrip("/")
|
||||
|
||||
|
||||
@router.post("/apply", response_model=ReferralApplyResponse, tags=["Referrals"])
|
||||
async def apply_referral(
|
||||
body: ReferralApplyRequest,
|
||||
session: AsyncSession = Depends(get_session),
|
||||
identity=Depends(verify_identity_token),
|
||||
):
|
||||
if not bool(BUTTONS_CONFIG.get("REFERRAL_BUTTON_ENABLED", REFERRAL_BUTTON)):
|
||||
raise HTTPException(status_code=403, detail="Реферальная программа отключена")
|
||||
billing_uid = await idb.ensure_billing_user_for_identity(session, identity)
|
||||
referrer_legacy = _normalize_referrer_code(body.referrer_code, body.referrer_tg_id)
|
||||
if referrer_legacy is None:
|
||||
raise HTTPException(status_code=400, detail="Приглашение недействительно")
|
||||
referrer_u = await resolve_user_optional(session, referrer_legacy)
|
||||
if referrer_u is None:
|
||||
raise HTTPException(status_code=400, detail="Приглашение недействительно")
|
||||
if billing_uid == referrer_u.id:
|
||||
raise HTTPException(status_code=400, detail="Нельзя использовать собственную ссылку")
|
||||
if await get_referral_by_referred_id(session, billing_uid):
|
||||
raise HTTPException(status_code=409, detail="Реферальная связь уже сохранена")
|
||||
await add_referral(session, billing_uid, referrer_u.id)
|
||||
referred_u = await resolve_user_optional(session, billing_uid)
|
||||
if referrer_u.tg_id is not None:
|
||||
try:
|
||||
from database.web_notifications import notify_web
|
||||
|
||||
await notify_web(
|
||||
session,
|
||||
tg_id=int(referrer_u.tg_id),
|
||||
type="referral_joined",
|
||||
title="Ваш реферал присоединился",
|
||||
message="Новый пользователь зарегистрировался по вашей реферальной ссылке.",
|
||||
data={
|
||||
"referred_tg_id": int(referred_u.tg_id) if referred_u and referred_u.tg_id else None,
|
||||
"referred_user_id": int(billing_uid),
|
||||
},
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
return ReferralApplyResponse(
|
||||
ok=True,
|
||||
message="Приглашение применено",
|
||||
referrer_code=str(referrer_u.id),
|
||||
referrer_user_id=int(referrer_u.id),
|
||||
referrer_tg_id=referrer_u.tg_id,
|
||||
referred_user_id=int(billing_uid),
|
||||
referred_tg_id=referred_u.tg_id if referred_u is not None else None,
|
||||
)
|
||||
|
||||
|
||||
@router.get("/top", response_model=ReferralTopResponse, tags=["Referrals"])
|
||||
async def referral_top(
|
||||
limit: int = Query(5, ge=1, le=20),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
identity=Depends(verify_identity_token),
|
||||
):
|
||||
if not bool(BUTTONS_CONFIG.get("REFERRAL_BUTTON_ENABLED", REFERRAL_BUTTON)):
|
||||
raise HTTPException(status_code=403, detail="Реферальная программа отключена")
|
||||
if not bool(BUTTONS_CONFIG.get("TOP_REFERRAL_BUTTON_ENABLE", TOP_REFERRAL_BUTTON)):
|
||||
raise HTTPException(status_code=403, detail="Топ рефералов отключен в настройках")
|
||||
billing_uid = await idb.ensure_billing_user_for_identity(session, identity)
|
||||
user_referral_count = int(await get_user_referral_count(session, billing_uid))
|
||||
user_position = int(await get_referral_position(session, user_referral_count)) if user_referral_count > 0 else None
|
||||
top_rows = await get_top_referrals(session, limit=limit)
|
||||
top: list[ReferralTopEntryResponse] = []
|
||||
for index, row in enumerate(top_rows, 1):
|
||||
referrer_user_id = int(row.get("referrer_user_id") or 0)
|
||||
referrals_count = int(row.get("referral_count") or 0)
|
||||
display_id = encode_referral_code(referrer_user_id)
|
||||
top.append(
|
||||
ReferralTopEntryResponse(
|
||||
position=index,
|
||||
referrer_user_id=referrer_user_id,
|
||||
referrals_count=referrals_count,
|
||||
display_id=display_id,
|
||||
)
|
||||
)
|
||||
return ReferralTopResponse(
|
||||
user_referrals_count=user_referral_count,
|
||||
user_position=user_position,
|
||||
top=top,
|
||||
)
|
||||
|
||||
|
||||
@router.get("/list", response_model=ReferralListResponse, tags=["Referrals"])
|
||||
async def referral_list(
|
||||
limit: int = Query(50, ge=1, le=200),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
identity=Depends(verify_identity_token),
|
||||
):
|
||||
if not bool(BUTTONS_CONFIG.get("REFERRAL_BUTTON_ENABLED", REFERRAL_BUTTON)):
|
||||
raise HTTPException(status_code=403, detail="Реферальная программа отключена")
|
||||
billing_uid = await idb.ensure_billing_user_for_identity(session, identity)
|
||||
rows_stmt = (
|
||||
select(Referral)
|
||||
.where(Referral.referrer_user_id == int(billing_uid))
|
||||
.limit(limit)
|
||||
)
|
||||
result = await session.execute(rows_stmt)
|
||||
rows = result.scalars().all()
|
||||
items = [
|
||||
ReferralListEntry(
|
||||
referred_user_id=int(r.referred_user_id),
|
||||
referred_tg_id=int(r.referred_tg_id) if r.referred_tg_id is not None else None,
|
||||
display_id=encode_referral_code(int(r.referred_user_id)),
|
||||
reward_issued=bool(r.reward_issued),
|
||||
)
|
||||
for r in rows
|
||||
]
|
||||
return ReferralListResponse(total=len(items), items=items)
|
||||
|
||||
|
||||
@router.get("/qr", response_model=ReferralQrResponse, tags=["Referrals"])
|
||||
async def referral_qr(
|
||||
request: Request,
|
||||
session: AsyncSession = Depends(get_session),
|
||||
identity=Depends(verify_identity_token),
|
||||
):
|
||||
if not bool(BUTTONS_CONFIG.get("REFERRAL_BUTTON_ENABLED", REFERRAL_BUTTON)):
|
||||
raise HTTPException(status_code=403, detail="Реферальная программа отключена")
|
||||
if not bool(BUTTONS_CONFIG.get("REFERRAL_QR_BUTTON_ENABLE", REFERRAL_QR)):
|
||||
raise HTTPException(status_code=403, detail="QR реферальной ссылки отключен в настройках")
|
||||
billing_uid = await idb.ensure_billing_user_for_identity(session, identity)
|
||||
base_url = _resolve_public_base_url(request)
|
||||
referral_link = f"{base_url}/referral/{encode_referral_code(int(billing_uid))}"
|
||||
qr = qrcode.QRCode(version=1, box_size=10, border=4)
|
||||
qr.add_data(referral_link)
|
||||
qr.make(fit=True)
|
||||
img = qr.make_image(fill_color="black", back_color="white")
|
||||
buffer = BytesIO()
|
||||
img.save(buffer, format="PNG")
|
||||
image_data = b64encode(buffer.getvalue()).decode("ascii")
|
||||
return ReferralQrResponse(
|
||||
ok=True,
|
||||
link=referral_link,
|
||||
image_data_url=f"data:image/png;base64,{image_data}",
|
||||
)
|
||||
|
||||
|
||||
@router.get("/conditions", response_model=ReferralConditionsResponse, tags=["Referrals"])
|
||||
async def referral_conditions(
|
||||
identity=Depends(verify_identity_token),
|
||||
):
|
||||
if not bool(BUTTONS_CONFIG.get("REFERRAL_BUTTON_ENABLED", REFERRAL_BUTTON)):
|
||||
raise HTTPException(status_code=403, detail="Реферальная программа отключена")
|
||||
del identity
|
||||
level_lines: list[str] = []
|
||||
for level in sorted(REFERRAL_BONUS_PERCENTAGES.keys()):
|
||||
value = REFERRAL_BONUS_PERCENTAGES[level]
|
||||
if isinstance(value, float):
|
||||
label = f"{int(value * 100)}% от суммы оплаты"
|
||||
else:
|
||||
label = f"{float(value):g} RUB"
|
||||
level_lines.append(f"{level} уровень: {label}")
|
||||
one_time_mode = bool(CHECK_REFERRAL_REWARD_ISSUED)
|
||||
bonus_mode = "one_time" if one_time_mode else "each_payment"
|
||||
bonus_mode_label = (
|
||||
"Бонус за первую успешную оплату реферала" if one_time_mode else "Бонус за каждую успешную оплату реферала"
|
||||
)
|
||||
rules = [
|
||||
"Бонус начисляется только за реальных приглашённых пользователей.",
|
||||
"Нельзя использовать собственную реферальную ссылку.",
|
||||
"Реферальную связь можно применить только один раз.",
|
||||
"Размер бонуса зависит от уровня реферальной программы.",
|
||||
]
|
||||
return ReferralConditionsResponse(
|
||||
title="Условия реферальной программы",
|
||||
summary=f"Режим начисления: {bonus_mode_label}.",
|
||||
bonus_mode=bonus_mode,
|
||||
bonus_mode_label=bonus_mode_label,
|
||||
level_lines=level_lines,
|
||||
rules=rules,
|
||||
)
|
||||
@@ -1,353 +0,0 @@
|
||||
import asyncio
|
||||
import os
|
||||
import re
|
||||
import time
|
||||
|
||||
import aiohttp
|
||||
|
||||
from fastapi import APIRouter, Depends, Query
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from api.depends import get_session
|
||||
|
||||
from config import (
|
||||
BALANCE_BUTTON,
|
||||
CAPTCHA_ENABLE,
|
||||
CHANNEL_EXISTS,
|
||||
CHANNEL_REQUIRED,
|
||||
DONATIONS_ENABLE,
|
||||
GIFT_BUTTON,
|
||||
HAPP_CRYPTOLINK,
|
||||
HWID_RESET_BUTTON,
|
||||
INSTRUCTIONS_BUTTON,
|
||||
PROJECT_NAME,
|
||||
REFERRAL_BUTTON,
|
||||
REFERRAL_QR,
|
||||
REMNAWAVE_WEBAPP,
|
||||
REMNAWAVE_WEBAPP_OPEN_IN_BROWSER,
|
||||
TELEGRAM_WEBAPP_DIRECT_LINK,
|
||||
TELEGRAM_WEBAPP_SHORT_NAME,
|
||||
TOP_REFERRAL_BUTTON,
|
||||
TRIAL_TIME_DISABLE,
|
||||
USERNAME_BOT,
|
||||
USE_COUNTRY_SELECTION,
|
||||
)
|
||||
from core.bootstrap import BUTTONS_CONFIG, MODES_CONFIG, MONEY_CONFIG, PAYMENTS_CONFIG
|
||||
from core.settings.money_config import get_currency_mode
|
||||
from core.settings.web_config import WEB_CONFIG
|
||||
from services.payments.providers import PROVIDERS_BASE, TELEGRAM_ONLY_PROVIDER_IDS, WEB_LINK_PROVIDER_IDS
|
||||
|
||||
|
||||
router = APIRouter(tags=["Root"])
|
||||
|
||||
|
||||
def _telegram_web_app_return_base() -> str | None:
|
||||
direct = str(TELEGRAM_WEBAPP_DIRECT_LINK or "").strip().rstrip("/")
|
||||
if direct:
|
||||
if direct.lower().startswith("http://"):
|
||||
direct = "https://" + direct[7:]
|
||||
if direct.lower().startswith("https://t.me/"):
|
||||
return direct
|
||||
bot = USERNAME_BOT.replace("@", "").strip()
|
||||
sn = str(TELEGRAM_WEBAPP_SHORT_NAME or "").strip()
|
||||
if bot and sn:
|
||||
return f"https://t.me/{bot}/{sn}"
|
||||
if bot:
|
||||
return f"https://t.me/{bot}"
|
||||
return None
|
||||
|
||||
|
||||
def _partner_feature_enabled() -> bool:
|
||||
try:
|
||||
from modules.partner_program import settings as partner_settings
|
||||
except Exception:
|
||||
return False
|
||||
for key in ("PARTNER_PROGRAM_ENABLED", "PARTNER_BUTTON_ENABLED", "PARTNER_ENABLED"):
|
||||
value = getattr(partner_settings, key, None)
|
||||
if isinstance(value, bool):
|
||||
return value
|
||||
return True
|
||||
|
||||
|
||||
@router.get("/api", include_in_schema=False)
|
||||
async def root():
|
||||
return {"message": "SoloBot API v2", "docs": "/api/docs"}
|
||||
|
||||
|
||||
@router.get("/api/version", include_in_schema=True)
|
||||
async def version():
|
||||
return {"version": 2, "api": "v2"}
|
||||
|
||||
|
||||
@router.get("/api/telegram-widget-bot", include_in_schema=True)
|
||||
async def telegram_widget_bot():
|
||||
"""Имя бота и имя проекта для веб-клиента."""
|
||||
bot_username = str(USERNAME_BOT or "").replace("@", "").strip()
|
||||
project_name = (PROJECT_NAME or "Solo").strip() if isinstance(PROJECT_NAME, str) else "Solo"
|
||||
telegram_client_id = ""
|
||||
try:
|
||||
from config import TELEGRAM_CLIENT_ID
|
||||
telegram_client_id = str(TELEGRAM_CLIENT_ID).strip()
|
||||
except ImportError:
|
||||
pass
|
||||
return {
|
||||
"bot_username": bot_username,
|
||||
"project_name": project_name,
|
||||
"telegram_client_id": telegram_client_id,
|
||||
}
|
||||
|
||||
|
||||
@router.get("/api/site/init-state", include_in_schema=True)
|
||||
async def site_init_state(session: AsyncSession = Depends(get_session)):
|
||||
"""Прошёл ли сайт первую настройку админом. Используется middleware веб-клиента."""
|
||||
from database.site_state import is_site_initialized
|
||||
|
||||
initialized = await is_site_initialized(session)
|
||||
return {"initialized": bool(initialized)}
|
||||
|
||||
|
||||
@router.get("/api/site/revision", include_in_schema=True)
|
||||
async def site_revision(session: AsyncSession = Depends(get_session)):
|
||||
"""Глобальный счётчик ревизии контента. Фронт опрашивает его в фоне и при
|
||||
изменении инвалидирует свои SWR-кэши, подтягивая свежие правки админа."""
|
||||
from database.site_revision import get_site_revision
|
||||
|
||||
revision = await get_site_revision(session)
|
||||
return {"revision": int(revision)}
|
||||
|
||||
|
||||
@router.get("/api/site-config", include_in_schema=True)
|
||||
async def site_config():
|
||||
"""Настройки витрины и кабинета для веб-клиента (флаги из runtime-конфигов бота)."""
|
||||
bot_username = USERNAME_BOT.replace("@", "").strip()
|
||||
pay_flags = {name: bool(PAYMENTS_CONFIG.get(name)) for name in PROVIDERS_BASE}
|
||||
any_pay = any(pay_flags.values())
|
||||
web_link_provider_ids = [provider_id for provider_id in WEB_LINK_PROVIDER_IDS if pay_flags.get(provider_id, False)]
|
||||
telegram_only_provider_ids = [
|
||||
provider_id for provider_id in TELEGRAM_ONLY_PROVIDER_IDS if pay_flags.get(provider_id, False)
|
||||
]
|
||||
currency_mode, currency_one_screen = get_currency_mode()
|
||||
try:
|
||||
cb_raw = MONEY_CONFIG.get("CASHBACK", 0)
|
||||
cashback_percent = float(cb_raw) if cb_raw not in (None, False) else 0.0
|
||||
except (TypeError, ValueError):
|
||||
cashback_percent = 0.0
|
||||
|
||||
webapp_short = str(TELEGRAM_WEBAPP_SHORT_NAME or "").strip() or None
|
||||
webapp_return_base = _telegram_web_app_return_base()
|
||||
return {
|
||||
"bot_username": bot_username or None,
|
||||
"telegram_web_app_short_name": webapp_short,
|
||||
"telegram_web_app_return_base": webapp_return_base,
|
||||
"project_name": (PROJECT_NAME or "Solo").strip() if isinstance(PROJECT_NAME, str) else "Solo",
|
||||
"site_mode": str(WEB_CONFIG.get("SITE_MODE", "full")).strip() or "full",
|
||||
"auth": {
|
||||
"telegram_login_enabled": bool(bot_username),
|
||||
"email_code_login_enabled": bool(MODES_CONFIG.get("WEB_EMAIL_CODE_LOGIN_ENABLED", True)),
|
||||
},
|
||||
"mobile": {
|
||||
"prefer_mini_app_on_telegram_mobile": bool(MODES_CONFIG.get("PREFER_MINI_APP_ON_TELEGRAM_MOBILE", False)),
|
||||
},
|
||||
"features": {
|
||||
"channel_enabled": bool(BUTTONS_CONFIG.get("CHANNEL_BUTTON_ENABLE", CHANNEL_EXISTS)),
|
||||
"donations_enabled": bool(BUTTONS_CONFIG.get("DONATIONS_BUTTON_ENABLE", DONATIONS_ENABLE)),
|
||||
"balance_enabled": bool(BUTTONS_CONFIG.get("BALANCE_BUTTON_ENABLE", BALANCE_BUTTON)),
|
||||
"referral_qr_enabled": bool(BUTTONS_CONFIG.get("REFERRAL_QR_BUTTON_ENABLE", REFERRAL_QR)),
|
||||
"instructions_enabled": bool(BUTTONS_CONFIG.get("INSTRUCTIONS_BUTTON_ENABLE", INSTRUCTIONS_BUTTON)),
|
||||
"gift_enabled": bool(BUTTONS_CONFIG.get("GIFT_BUTTON_ENABLE", GIFT_BUTTON)),
|
||||
"referral_enabled": bool(BUTTONS_CONFIG.get("REFERRAL_BUTTON_ENABLED", REFERRAL_BUTTON)),
|
||||
"top_referral_enabled": bool(BUTTONS_CONFIG.get("TOP_REFERRAL_BUTTON_ENABLE", TOP_REFERRAL_BUTTON)),
|
||||
"coupon_enabled": bool(BUTTONS_CONFIG.get("COUPON_BUTTON_ENABLE", True)),
|
||||
"qr_subscription_enabled": bool(MODES_CONFIG.get("HAPP_CRYPTOLINK_ENABLED", HAPP_CRYPTOLINK)),
|
||||
"hwid_reset_enabled": bool(BUTTONS_CONFIG.get("HWID_RESET_BUTTON_ENABLE", HWID_RESET_BUTTON)),
|
||||
"country_selection_enabled": bool(MODES_CONFIG.get("COUNTRY_SELECTION_ENABLED", USE_COUNTRY_SELECTION)),
|
||||
"captcha_enabled": bool(MODES_CONFIG.get("CAPTCHA_ENABLED", CAPTCHA_ENABLE)),
|
||||
"channel_check_enabled": bool(MODES_CONFIG.get("CHANNEL_CHECK_ENABLED", CHANNEL_REQUIRED)),
|
||||
"trial_enabled": not bool(MODES_CONFIG.get("TRIAL_TIME_DISABLED", TRIAL_TIME_DISABLE)),
|
||||
"mini_app_enabled": bool(MODES_CONFIG.get("REMNAWAVE_WEBAPP_ENABLED", REMNAWAVE_WEBAPP)),
|
||||
"mini_app_open_in_browser": bool(
|
||||
MODES_CONFIG.get("REMNAWAVE_WEBAPP_OPEN_IN_BROWSER", REMNAWAVE_WEBAPP_OPEN_IN_BROWSER)
|
||||
),
|
||||
"partner_enabled": bool(_partner_feature_enabled()),
|
||||
},
|
||||
"payments": {
|
||||
"any_enabled": any_pay,
|
||||
"any_web_link_enabled": bool(web_link_provider_ids),
|
||||
"any_telegram_only_enabled": bool(telegram_only_provider_ids),
|
||||
"web_link_provider_ids": web_link_provider_ids,
|
||||
"telegram_only_provider_ids": telegram_only_provider_ids,
|
||||
"yookassa_enabled": pay_flags.get("YOOKASSA", False),
|
||||
"yoomoney_enabled": pay_flags.get("YOOMONEY", False),
|
||||
"robokassa_enabled": pay_flags.get("ROBOKASSA", False),
|
||||
"kassai_cards_enabled": pay_flags.get("KASSAI_CARDS", False),
|
||||
"kassai_sbp_enabled": pay_flags.get("KASSAI_SBP", False),
|
||||
"tribute_enabled": pay_flags.get("TRIBUTE", False),
|
||||
"heleket_enabled": pay_flags.get("HELEKET", False),
|
||||
"cryptobot_enabled": pay_flags.get("CRYPTOBOT", False),
|
||||
"freekassa_enabled": pay_flags.get("FREEKASSA", False),
|
||||
"stars_enabled": pay_flags.get("STARS", False),
|
||||
},
|
||||
"money": {
|
||||
"currency_mode": currency_mode,
|
||||
"currency_one_screen": currency_one_screen,
|
||||
"cashback_enabled": cashback_percent > 0,
|
||||
"cashback_percent": cashback_percent,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
_UPDATE_CHECK_CACHE: dict[str, object] = {"fetched_at": 0.0, "data": None}
|
||||
_UPDATE_CHECK_LOCK = asyncio.Lock()
|
||||
_UPDATE_CHECK_TTL_SEC = 600
|
||||
_SEMVER_RE = re.compile(
|
||||
r"^v?(?P<major>\d+)\.(?P<minor>\d+)\.(?P<patch>\d+)(?:-(?P<pre>[0-9A-Za-z.-]+))?(?:\+[0-9A-Za-z.-]+)?$"
|
||||
)
|
||||
|
||||
|
||||
def _parse_semver(tag: str) -> tuple[int, int, int, int, tuple[tuple[int, int | str], ...]] | None:
|
||||
"""Returns a tuple comparable per semver spec.
|
||||
|
||||
Release > prerelease (second-to-last slot: 1 for release, 0 for prerelease).
|
||||
Last slot — tuple of prerelease identifiers; numeric ids compare numerically,
|
||||
alphanumeric ids compare lexically, numeric < alphanumeric.
|
||||
"""
|
||||
match = _SEMVER_RE.match(tag.strip())
|
||||
if not match:
|
||||
return None
|
||||
major = int(match.group("major"))
|
||||
minor = int(match.group("minor"))
|
||||
patch = int(match.group("patch"))
|
||||
pre_raw = match.group("pre")
|
||||
if not pre_raw:
|
||||
return (major, minor, patch, 1, ())
|
||||
identifiers: list[tuple[int, int | str]] = []
|
||||
for part in pre_raw.split("."):
|
||||
if part.isdigit():
|
||||
identifiers.append((0, int(part)))
|
||||
else:
|
||||
identifiers.append((1, part))
|
||||
return (major, minor, patch, 0, tuple(identifiers))
|
||||
|
||||
|
||||
async def _fetch_ghcr_tags(image: str) -> list[str]:
|
||||
"""Возвращает все теги образа в GHCR. Поддерживает paginate через Link header."""
|
||||
async with aiohttp.ClientSession(timeout=aiohttp.ClientTimeout(total=15)) as session:
|
||||
token_url = f"https://ghcr.io/token?scope=repository:{image}:pull"
|
||||
async with session.get(token_url) as token_resp:
|
||||
if token_resp.status != 200:
|
||||
return []
|
||||
token_data = await token_resp.json()
|
||||
token = str(token_data.get("token") or "").strip()
|
||||
if not token:
|
||||
return []
|
||||
headers = {"Authorization": f"Bearer {token}", "Accept": "application/json"}
|
||||
all_tags: list[str] = []
|
||||
next_url: str | None = f"https://ghcr.io/v2/{image}/tags/list?n=1000"
|
||||
guard = 0
|
||||
while next_url and guard < 20:
|
||||
guard += 1
|
||||
async with session.get(next_url, headers=headers) as tags_resp:
|
||||
if tags_resp.status != 200:
|
||||
break
|
||||
payload = await tags_resp.json()
|
||||
page_tags = payload.get("tags") or []
|
||||
if isinstance(page_tags, list):
|
||||
all_tags.extend(str(t) for t in page_tags)
|
||||
link_header = tags_resp.headers.get("Link") or ""
|
||||
next_url = None
|
||||
for part in link_header.split(","):
|
||||
part = part.strip()
|
||||
if not part or 'rel="next"' not in part:
|
||||
continue
|
||||
inner = part.split(";", 1)[0].strip()
|
||||
if inner.startswith("<") and inner.endswith(">"):
|
||||
inner = inner[1:-1]
|
||||
if inner.startswith("/"):
|
||||
next_url = f"https://ghcr.io{inner}"
|
||||
else:
|
||||
next_url = inner
|
||||
break
|
||||
return all_tags
|
||||
|
||||
|
||||
def _is_dev_version(v: str) -> bool:
|
||||
return "-dev" in v or "dev." in v
|
||||
|
||||
|
||||
def _latest_for_channel(tags: list[str], dev: bool) -> str | None:
|
||||
versions = []
|
||||
for raw in tags:
|
||||
parsed = _parse_semver(str(raw))
|
||||
if parsed is None:
|
||||
continue
|
||||
is_dev = _is_dev_version(str(raw))
|
||||
if is_dev == dev:
|
||||
versions.append((parsed, str(raw)))
|
||||
if not versions:
|
||||
return None
|
||||
versions.sort(key=lambda item: item[0], reverse=True)
|
||||
return versions[0][1]
|
||||
|
||||
|
||||
@router.get("/api/meta/update-check", include_in_schema=True)
|
||||
async def update_check(current: str | None = Query(default=None)):
|
||||
"""Сравнивает переданную версию с последним тегом в GHCR. Канал (dev/release) определяется по current."""
|
||||
current_v = (current or "").strip()
|
||||
image = (os.environ.get("GHCR_IMAGE") or "vladless/solo-brick").strip()
|
||||
if not image:
|
||||
return {"current": current_v or None, "latest": None, "hasUpdate": False}
|
||||
|
||||
now = time.time()
|
||||
is_dev = _is_dev_version(current_v) if current_v else True
|
||||
cache_key = f"data-{'dev' if is_dev else 'release'}"
|
||||
|
||||
cached = _UPDATE_CHECK_CACHE.get(cache_key)
|
||||
fetched_at = float(_UPDATE_CHECK_CACHE.get("fetched_at") or 0.0)
|
||||
if cached is not None and now - fetched_at < _UPDATE_CHECK_TTL_SEC:
|
||||
cached_resp = dict(cached)
|
||||
cached_resp["current"] = current_v or None
|
||||
if current_v:
|
||||
cur = _parse_semver(current_v)
|
||||
nxt = _parse_semver(str(cached_resp.get("latest") or ""))
|
||||
cached_resp["hasUpdate"] = bool(cur and nxt and nxt > cur)
|
||||
return cached_resp
|
||||
|
||||
latest: str | None = None
|
||||
async with _UPDATE_CHECK_LOCK:
|
||||
fetched_at = float(_UPDATE_CHECK_CACHE.get("fetched_at") or 0.0)
|
||||
if now - fetched_at < _UPDATE_CHECK_TTL_SEC:
|
||||
cached = _UPDATE_CHECK_CACHE.get(cache_key)
|
||||
if cached is not None:
|
||||
cached_resp = dict(cached)
|
||||
cached_resp["current"] = current_v or None
|
||||
if current_v:
|
||||
cur = _parse_semver(current_v)
|
||||
nxt = _parse_semver(str(cached_resp.get("latest") or ""))
|
||||
cached_resp["hasUpdate"] = bool(cur and nxt and nxt > cur)
|
||||
return cached_resp
|
||||
try:
|
||||
tags = await _fetch_ghcr_tags(image)
|
||||
latest_dev = _latest_for_channel(tags, dev=True)
|
||||
latest_rel = _latest_for_channel(tags, dev=False)
|
||||
_UPDATE_CHECK_CACHE["data-dev"] = {"latest": latest_dev, "hasUpdate": False, "image": image}
|
||||
_UPDATE_CHECK_CACHE["data-release"] = {"latest": latest_rel, "hasUpdate": False, "image": image}
|
||||
_UPDATE_CHECK_CACHE["fetched_at"] = now
|
||||
latest = latest_dev if is_dev else latest_rel
|
||||
except Exception:
|
||||
latest = None
|
||||
|
||||
has_update = False
|
||||
if current_v and latest:
|
||||
cur = _parse_semver(current_v)
|
||||
nxt = _parse_semver(latest)
|
||||
if cur and nxt:
|
||||
has_update = nxt > cur
|
||||
|
||||
return {
|
||||
"current": current_v or None,
|
||||
"latest": latest,
|
||||
"hasUpdate": has_update,
|
||||
"image": image or None,
|
||||
"channel": "dev" if is_dev else "release",
|
||||
"checkedAt": int(now),
|
||||
}
|
||||
@@ -1,16 +0,0 @@
|
||||
from fastapi import APIRouter
|
||||
|
||||
from api.v2.base_crud import generate_crud_router
|
||||
from api.v2.schemas import ServerBase, ServerResponse, ServerUpdate
|
||||
from database.models import Server
|
||||
|
||||
|
||||
router = generate_crud_router(
|
||||
model=Server,
|
||||
schema_response=ServerResponse,
|
||||
schema_create=ServerBase,
|
||||
schema_update=ServerUpdate,
|
||||
identifier_field="server_name",
|
||||
parameter_name="server_name",
|
||||
enabled_methods=["get_all", "get_one", "create", "update", "delete"],
|
||||
)
|
||||
@@ -1,147 +0,0 @@
|
||||
from typing import Any
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from pydantic import BaseModel
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from api.depends import get_session, verify_identity_admin
|
||||
from api.v2.schemas import SettingResponse, SettingUpsert
|
||||
from core.settings.buttons_config import BUTTONS_CONFIG, update_buttons_config
|
||||
from core.settings.modes_config import MODES_CONFIG, update_modes_config
|
||||
from core.settings.money_config import MONEY_CONFIG, update_money_config
|
||||
from core.settings.notifications_config import NOTIFICATIONS_CONFIG, update_notifications_config
|
||||
from core.settings.payments_config import PAYMENTS_CONFIG, update_payments_config
|
||||
from core.settings.providers_order_config import PROVIDERS_ORDER, update_providers_order
|
||||
from core.settings.tariffs_config import TARIFFS_CONFIG, update_tariffs_config
|
||||
from database.models import Setting
|
||||
from database.settings import set_setting
|
||||
from database.settings_cache import settings_cache
|
||||
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
class ConfigUpdatePayload(BaseModel):
|
||||
value: dict[str, Any] | None = None
|
||||
|
||||
|
||||
@router.get("/", response_model=list[SettingResponse])
|
||||
async def get_all_settings(identity=Depends(verify_identity_admin)):
|
||||
"""Список всех настроек (из кэша, без запроса к БД)."""
|
||||
return settings_cache.get_all()
|
||||
|
||||
|
||||
@router.get("/configs")
|
||||
async def get_configs(identity=Depends(verify_identity_admin)):
|
||||
"""Все конфиги (payments, buttons, notifications, modes, money, providers_order, tariffs)."""
|
||||
return {
|
||||
"payments": dict(PAYMENTS_CONFIG),
|
||||
"buttons": dict(BUTTONS_CONFIG),
|
||||
"notifications": dict(NOTIFICATIONS_CONFIG),
|
||||
"modes": dict(MODES_CONFIG),
|
||||
"money": dict(MONEY_CONFIG),
|
||||
"providers_order": dict(PROVIDERS_ORDER),
|
||||
"tariffs": dict(TARIFFS_CONFIG),
|
||||
}
|
||||
|
||||
|
||||
@router.post("/configs/{scope}")
|
||||
async def update_config_scope(
|
||||
scope: str,
|
||||
payload: ConfigUpdatePayload,
|
||||
identity=Depends(verify_identity_admin),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
):
|
||||
"""Обновление конфига по scope (payments, buttons, notifications, modes, money, providers_order, tariffs)."""
|
||||
data = dict(payload.value or {})
|
||||
normalized = scope.strip().lower().replace("-", "_")
|
||||
if normalized == "payments":
|
||||
cleaned = {key: bool(value) for key, value in data.items()}
|
||||
await update_payments_config(session, cleaned)
|
||||
return {"payments": dict(PAYMENTS_CONFIG)}
|
||||
if normalized == "buttons":
|
||||
cleaned = {key: bool(value) for key, value in data.items()}
|
||||
await update_buttons_config(session, cleaned)
|
||||
return {"buttons": dict(BUTTONS_CONFIG)}
|
||||
if normalized == "notifications":
|
||||
await update_notifications_config(session, data)
|
||||
return {"notifications": dict(NOTIFICATIONS_CONFIG)}
|
||||
if normalized == "modes":
|
||||
cleaned = {key: bool(value) for key, value in data.items()}
|
||||
await update_modes_config(session, cleaned)
|
||||
return {"modes": dict(MODES_CONFIG)}
|
||||
if normalized == "money":
|
||||
await update_money_config(session, data)
|
||||
return {"money": dict(MONEY_CONFIG)}
|
||||
if normalized == "providers_order":
|
||||
cleaned: dict[str, int] = {}
|
||||
for key, value in data.items():
|
||||
try:
|
||||
cleaned[key] = int(value)
|
||||
except (TypeError, ValueError):
|
||||
continue
|
||||
await update_providers_order(session, cleaned)
|
||||
return {"providers_order": dict(PROVIDERS_ORDER)}
|
||||
if normalized == "tariffs":
|
||||
cleaned = dict(data)
|
||||
if "ALLOW_DOWNGRADE" in cleaned:
|
||||
cleaned["ALLOW_DOWNGRADE"] = bool(cleaned.get("ALLOW_DOWNGRADE"))
|
||||
if "KEY_ADDONS_RECALC_PRICE" in cleaned:
|
||||
cleaned["KEY_ADDONS_RECALC_PRICE"] = bool(cleaned.get("KEY_ADDONS_RECALC_PRICE"))
|
||||
if "KEY_ADDONS_PACK_MODE" in cleaned:
|
||||
mode = str(cleaned.get("KEY_ADDONS_PACK_MODE") or "").strip().lower()
|
||||
cleaned["KEY_ADDONS_PACK_MODE"] = mode if mode in {"", "traffic", "devices", "all"} else ""
|
||||
await update_tariffs_config(session, cleaned)
|
||||
return {"tariffs": dict(TARIFFS_CONFIG)}
|
||||
raise HTTPException(status_code=404, detail="Unsupported config scope")
|
||||
|
||||
|
||||
@router.get("/{key}", response_model=SettingResponse)
|
||||
async def get_setting_by_key(key: str, identity=Depends(verify_identity_admin)):
|
||||
"""Настройка по ключу (из кэша, без запроса к БД)."""
|
||||
obj = settings_cache.get(key)
|
||||
if not obj:
|
||||
raise HTTPException(status_code=404, detail="Setting not found")
|
||||
return obj
|
||||
|
||||
|
||||
@router.post("/{key}", response_model=SettingResponse)
|
||||
async def upsert_setting(
|
||||
key: str,
|
||||
payload: SettingUpsert,
|
||||
identity=Depends(verify_identity_admin),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
):
|
||||
"""Создание или обновление настройки по ключу."""
|
||||
obj = await set_setting(
|
||||
session=session,
|
||||
key=key,
|
||||
value=payload.value,
|
||||
description=payload.description,
|
||||
)
|
||||
await session.refresh(obj)
|
||||
settings_cache.update(
|
||||
key,
|
||||
obj.value,
|
||||
obj.description,
|
||||
created_at=getattr(obj, "created_at", None),
|
||||
updated_at=getattr(obj, "updated_at", None),
|
||||
)
|
||||
return obj
|
||||
|
||||
|
||||
@router.delete("/{key}", response_model=dict)
|
||||
async def delete_setting(
|
||||
key: str,
|
||||
identity=Depends(verify_identity_admin),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
):
|
||||
"""Удаление настройки по ключу."""
|
||||
result = await session.execute(select(Setting).where(Setting.key == key))
|
||||
obj = result.scalar_one_or_none()
|
||||
if not obj:
|
||||
raise HTTPException(status_code=404, detail="Setting not found")
|
||||
await session.delete(obj)
|
||||
settings_cache.delete(key)
|
||||
return {"detail": "Setting deleted"}
|
||||
@@ -1,452 +0,0 @@
|
||||
from datetime import datetime, timedelta
|
||||
from math import ceil
|
||||
from urllib.parse import urlsplit
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, Request
|
||||
from pytz import timezone as tz_moscow
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from api.depends import get_session, validate_redirect_url, verify_identity_token
|
||||
from api.v2.base_crud import generate_crud_router
|
||||
from api.v2.routes.coupon_pricing import resolve_percent_coupon_pricing
|
||||
from api.v2.schemas import TariffBase, TariffResponse, TariffUpdate
|
||||
from api.v2.schemas.tariffs import TariffGroup, TariffPublic
|
||||
from api.v2.schemas.web_public import (
|
||||
TariffConfigPriceResponse,
|
||||
TariffPurchaseRequest,
|
||||
TariffPurchaseResponse,
|
||||
)
|
||||
from core.bootstrap import PAYMENTS_CONFIG
|
||||
from core.redis_cache import cache_get, cache_key, cache_set
|
||||
from database import (
|
||||
get_balance,
|
||||
identities as idb,
|
||||
)
|
||||
from database.coupons import mark_coupon_used
|
||||
from database.models import Tariff
|
||||
from database.tariffs import get_tariff_by_id
|
||||
from database.temporary_data import create_temporary_data
|
||||
from logger import logger
|
||||
from services.keys import create_vpn_key_headless
|
||||
from services.payments.payment_links import PaymentLinkRequest, create_payment_link
|
||||
from services.payments.providers import WEB_LINK_PROVIDER_IDS
|
||||
from services.tariffs import calculate_config_price
|
||||
|
||||
|
||||
def _tariff_to_public(t: Tariff) -> TariffPublic:
|
||||
dev_opts = getattr(t, "device_options", None)
|
||||
tr_opts = getattr(t, "traffic_options_gb", None)
|
||||
device_options: list[int] | None = None
|
||||
traffic_options_gb: list[int] | None = None
|
||||
if isinstance(dev_opts, list):
|
||||
device_options = []
|
||||
for x in dev_opts:
|
||||
try:
|
||||
device_options.append(int(x))
|
||||
except (TypeError, ValueError):
|
||||
continue
|
||||
if not device_options:
|
||||
device_options = None
|
||||
if isinstance(tr_opts, list):
|
||||
traffic_options_gb = []
|
||||
for x in tr_opts:
|
||||
try:
|
||||
traffic_options_gb.append(int(x))
|
||||
except (TypeError, ValueError):
|
||||
continue
|
||||
if not traffic_options_gb:
|
||||
traffic_options_gb = None
|
||||
return TariffPublic(
|
||||
id=t.id,
|
||||
name=t.name or "",
|
||||
group_code=t.group_code or "",
|
||||
duration_days=t.duration_days or 0,
|
||||
price_rub=t.price_rub or 0,
|
||||
traffic_limit=t.traffic_limit,
|
||||
device_limit=t.device_limit,
|
||||
subgroup_title=t.subgroup_title,
|
||||
sort_order=t.sort_order,
|
||||
vless=bool(getattr(t, "vless", False)),
|
||||
configurable=bool(getattr(t, "configurable", False)),
|
||||
device_options=device_options,
|
||||
traffic_options_gb=traffic_options_gb,
|
||||
)
|
||||
|
||||
|
||||
public_router = APIRouter()
|
||||
|
||||
|
||||
def _resolve_public_base_url(request: Request) -> str:
|
||||
origin = str(request.headers.get("origin") or "").strip()
|
||||
if origin.startswith(("http://", "https://")):
|
||||
return origin.rstrip("/")
|
||||
referer = str(request.headers.get("referer") or request.headers.get("referrer") or "").strip()
|
||||
if referer.startswith(("http://", "https://")):
|
||||
parsed = urlsplit(referer)
|
||||
if parsed.scheme and parsed.netloc:
|
||||
return f"{parsed.scheme}://{parsed.netloc}".rstrip("/")
|
||||
forwarded_host = str(request.headers.get("x-forwarded-host") or "").strip()
|
||||
host = forwarded_host or str(request.headers.get("host") or "").strip()
|
||||
forwarded_proto = str(request.headers.get("x-forwarded-proto") or "").split(",", 1)[0].strip().lower()
|
||||
scheme = forwarded_proto if forwarded_proto in {"http", "https"} else request.url.scheme
|
||||
if host:
|
||||
return f"{scheme}://{host}".rstrip("/")
|
||||
return str(request.base_url).rstrip("/")
|
||||
|
||||
|
||||
def _resolve_default_web_payment_provider() -> str | None:
|
||||
for provider_id in WEB_LINK_PROVIDER_IDS:
|
||||
if bool(PAYMENTS_CONFIG.get(provider_id)):
|
||||
return provider_id
|
||||
return WEB_LINK_PROVIDER_IDS[0] if WEB_LINK_PROVIDER_IDS else None
|
||||
|
||||
|
||||
def _public_tariffs_cache_key(
|
||||
group_code: str | None,
|
||||
tariff_ids: str | None,
|
||||
filter_vless: str | None,
|
||||
) -> str:
|
||||
normalized_group = (group_code or "").strip().lower()
|
||||
normalized_ids = ",".join(part.strip() for part in (tariff_ids or "").split(",") if part.strip())
|
||||
normalized_vless = (filter_vless or "").strip().lower()
|
||||
return cache_key("tariffs_public", normalized_group or "-", normalized_ids or "-", normalized_vless or "-")
|
||||
|
||||
|
||||
@public_router.get("/groups", response_model=list[TariffGroup])
|
||||
async def get_tariff_groups(session: AsyncSession = Depends(get_session)):
|
||||
"""Публичный список групп тарифов — уникальные значения колонки group_code."""
|
||||
q = (
|
||||
select(Tariff.group_code)
|
||||
.where(Tariff.is_active.is_(True), Tariff.group_code.isnot(None), Tariff.group_code != "")
|
||||
.distinct()
|
||||
.order_by(Tariff.group_code)
|
||||
)
|
||||
result = await session.execute(q)
|
||||
values = result.scalars().all()
|
||||
return [TariffGroup(group_code=v or "") for v in values]
|
||||
|
||||
|
||||
@public_router.get("/public", response_model=list[TariffPublic])
|
||||
async def get_tariffs_public(
|
||||
group_code: str | None = Query(None, description="Фильтр по группе тарифов"),
|
||||
tariff_ids: str | None = Query(None, description="ID тарифов через запятую (приоритет над группой)"),
|
||||
filter_vless: str | None = Query(
|
||||
None,
|
||||
description="vless: только для роутера (vless=True), app: только для приложения (vless=False), иначе все",
|
||||
),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
):
|
||||
"""Публичный список активных тарифов (без авторизации)."""
|
||||
cache_token = _public_tariffs_cache_key(group_code, tariff_ids, filter_vless)
|
||||
cached = await cache_get(cache_token)
|
||||
if isinstance(cached, list):
|
||||
return cached
|
||||
|
||||
q = (
|
||||
select(Tariff)
|
||||
.where(Tariff.is_active.is_(True))
|
||||
.order_by(Tariff.sort_order.asc().nulls_last(), Tariff.price_rub.asc())
|
||||
)
|
||||
if tariff_ids:
|
||||
try:
|
||||
ids = [int(x.strip()) for x in tariff_ids.split(",") if x.strip()]
|
||||
if not ids:
|
||||
return []
|
||||
q = q.where(Tariff.id.in_(ids))
|
||||
except ValueError:
|
||||
raise HTTPException(status_code=422, detail="Некорректный параметр tariff_ids")
|
||||
elif group_code:
|
||||
q = q.where(Tariff.group_code == group_code)
|
||||
if filter_vless == "router":
|
||||
q = q.where(Tariff.vless.is_(True))
|
||||
elif filter_vless == "app":
|
||||
q = q.where(Tariff.vless.is_(False))
|
||||
result = await session.execute(q)
|
||||
rows = result.scalars().all()
|
||||
payload = [_tariff_to_public(t).model_dump() for t in rows]
|
||||
await cache_set(cache_token, payload, 30)
|
||||
return payload
|
||||
|
||||
|
||||
@public_router.get("/config-price", response_model=TariffConfigPriceResponse)
|
||||
async def get_tariff_config_price(
|
||||
tariff_id: int = Query(..., ge=1),
|
||||
selected_device_limit: int | None = Query(None),
|
||||
selected_traffic_gb: int | None = Query(None),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
):
|
||||
tariff = await get_tariff_by_id(session, tariff_id)
|
||||
if not tariff or not tariff.get("is_active", True):
|
||||
raise HTTPException(status_code=404, detail="Тариф не найден")
|
||||
price = int(calculate_config_price(tariff, selected_device_limit, selected_traffic_gb))
|
||||
return TariffConfigPriceResponse(price_rub=price)
|
||||
|
||||
|
||||
user_tariff_router = APIRouter()
|
||||
|
||||
|
||||
@user_tariff_router.post("/purchase", response_model=TariffPurchaseResponse)
|
||||
async def purchase_tariff_with_balance(
|
||||
body: TariffPurchaseRequest,
|
||||
request: Request,
|
||||
preview: bool = Query(False),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
identity=Depends(verify_identity_token),
|
||||
):
|
||||
tg_id = await idb.ensure_billing_user_for_identity(session, identity)
|
||||
tariff = await get_tariff_by_id(session, body.tariff_id)
|
||||
if not tariff or not tariff.get("is_active", True):
|
||||
raise HTTPException(status_code=404, detail="Тариф не найден")
|
||||
price = int(calculate_config_price(tariff, body.selected_device_limit, body.selected_traffic_gb))
|
||||
if price <= 0:
|
||||
raise HTTPException(status_code=400, detail="Некорректная цена тарифа")
|
||||
final_price, discount_rub, coupon_id, applied_coupon_code = await resolve_percent_coupon_pricing(
|
||||
session=session,
|
||||
billing_user_id=int(tg_id),
|
||||
base_price_rub=int(price),
|
||||
coupon_code=body.coupon_code,
|
||||
)
|
||||
balance = float(await get_balance(session, tg_id))
|
||||
duration = int(tariff.get("duration_days") or 0)
|
||||
if duration <= 0:
|
||||
raise HTTPException(status_code=400, detail="Некорректная длительность тарифа")
|
||||
required_amount = int(max(0, ceil(float(final_price) - balance)))
|
||||
if preview:
|
||||
return TariffPurchaseResponse(
|
||||
ok=True,
|
||||
message="Расчет обновлен",
|
||||
key_email=None,
|
||||
charged_rub=0,
|
||||
base_price_rub=int(price),
|
||||
discount_rub=int(discount_rub),
|
||||
final_price_rub=int(final_price),
|
||||
applied_coupon_code=applied_coupon_code,
|
||||
payment_required=required_amount > 0,
|
||||
required_amount_rub=int(required_amount),
|
||||
payment_id=None,
|
||||
payment_url=None,
|
||||
)
|
||||
if required_amount > 0:
|
||||
provider_id = str(body.provider_id or _resolve_default_web_payment_provider() or "").strip().upper()
|
||||
if not provider_id:
|
||||
raise HTTPException(status_code=503, detail="Нет доступных провайдеров оплаты")
|
||||
base_url = _resolve_public_base_url(request)
|
||||
success_url = validate_redirect_url(str(body.success_url or ""), f"{base_url}/payment-success")
|
||||
failure_url = validate_redirect_url(str(body.failure_url or ""), f"{base_url}/payment-failure")
|
||||
payment_request = PaymentLinkRequest(
|
||||
legacy_user_ref=int(tg_id),
|
||||
amount=required_amount,
|
||||
currency="RUB",
|
||||
provider_id=provider_id,
|
||||
success_url=success_url,
|
||||
failure_url=failure_url,
|
||||
metadata={
|
||||
"payment_flow": "tariff_purchase",
|
||||
"tariff_id": int(body.tariff_id),
|
||||
"selected_device_limit": body.selected_device_limit,
|
||||
"selected_traffic_gb": body.selected_traffic_gb,
|
||||
"selected_duration_days": int(duration),
|
||||
"selected_price_rub": int(final_price),
|
||||
"base_price_rub": int(price),
|
||||
"discount_rub": int(discount_rub),
|
||||
"applied_coupon_code": applied_coupon_code,
|
||||
"coupon_id": int(coupon_id) if coupon_id is not None else None,
|
||||
},
|
||||
)
|
||||
payment_result = await create_payment_link(session, payment_request)
|
||||
if not payment_result.success or not payment_result.payment_url or not payment_result.payment_id:
|
||||
raise HTTPException(status_code=400, detail=payment_result.error or "Не удалось создать ссылку оплаты")
|
||||
await create_temporary_data(
|
||||
session,
|
||||
int(tg_id),
|
||||
"waiting_for_payment",
|
||||
{
|
||||
"tariff_id": int(body.tariff_id),
|
||||
"required_amount": int(required_amount),
|
||||
"selected_price_rub": int(final_price),
|
||||
"selected_device_limit": body.selected_device_limit,
|
||||
"selected_traffic_limit_gb": body.selected_traffic_gb,
|
||||
"selected_duration_days": int(duration),
|
||||
"base_price_rub": int(price),
|
||||
"discount_rub": int(discount_rub),
|
||||
"applied_coupon_code": applied_coupon_code,
|
||||
"coupon_id": int(coupon_id) if coupon_id is not None else None,
|
||||
},
|
||||
)
|
||||
return TariffPurchaseResponse(
|
||||
ok=True,
|
||||
message="Требуется оплата для оформления подписки",
|
||||
key_email=None,
|
||||
charged_rub=0,
|
||||
base_price_rub=int(price),
|
||||
discount_rub=int(discount_rub),
|
||||
final_price_rub=int(final_price),
|
||||
applied_coupon_code=applied_coupon_code,
|
||||
payment_required=True,
|
||||
required_amount_rub=required_amount,
|
||||
payment_id=payment_result.payment_id,
|
||||
payment_url=payment_result.payment_url,
|
||||
)
|
||||
moscow_tz = tz_moscow("Europe/Moscow")
|
||||
expiry = datetime.now(moscow_tz) + timedelta(days=duration)
|
||||
try:
|
||||
await create_vpn_key_headless(
|
||||
session=session,
|
||||
tg_id=tg_id,
|
||||
expiry_time=expiry,
|
||||
plan=body.tariff_id,
|
||||
selected_device_limit=body.selected_device_limit,
|
||||
selected_traffic_gb=body.selected_traffic_gb,
|
||||
selected_price_rub=final_price,
|
||||
)
|
||||
if coupon_id is not None:
|
||||
await mark_coupon_used(session, int(coupon_id), int(tg_id))
|
||||
except Exception:
|
||||
logger.exception("web tariff purchase failed")
|
||||
raise HTTPException(status_code=500, detail="Не удалось оформить подписку") from None
|
||||
return TariffPurchaseResponse(
|
||||
ok=True,
|
||||
message="Подписка оформлена. Ключ в разделе «Мои ключи».",
|
||||
key_email=None,
|
||||
charged_rub=final_price,
|
||||
base_price_rub=int(price),
|
||||
discount_rub=int(discount_rub),
|
||||
final_price_rub=int(final_price),
|
||||
applied_coupon_code=applied_coupon_code,
|
||||
)
|
||||
|
||||
|
||||
@user_tariff_router.post("/trial", response_model=TariffPurchaseResponse)
|
||||
async def activate_trial(
|
||||
request: Request,
|
||||
session: AsyncSession = Depends(get_session),
|
||||
identity=Depends(verify_identity_token),
|
||||
):
|
||||
"""Активация триала (бесплатного или платного). Доступно 1 раз."""
|
||||
from database import get_trial, update_trial
|
||||
from database.tariffs import get_tariffs
|
||||
|
||||
tg_id = await idb.ensure_billing_user_for_identity(session, identity)
|
||||
|
||||
trial_status = await get_trial(session, tg_id)
|
||||
if trial_status not in (0, -1):
|
||||
raise HTTPException(status_code=409, detail="Пробная подписка уже использована")
|
||||
|
||||
trial_tariffs = await get_tariffs(session, group_code="trial")
|
||||
if not trial_tariffs:
|
||||
raise HTTPException(status_code=404, detail="Пробный тариф не найден")
|
||||
|
||||
tariff = trial_tariffs[0]
|
||||
price = int(tariff.get("price_rub", 0) or 0)
|
||||
duration = int(tariff.get("duration_days") or 0)
|
||||
if duration <= 0:
|
||||
raise HTTPException(status_code=400, detail="Некорректная длительность триала")
|
||||
|
||||
if price <= 0:
|
||||
moscow_tz = tz_moscow("Europe/Moscow")
|
||||
expiry = datetime.now(moscow_tz) + timedelta(days=duration)
|
||||
try:
|
||||
await create_vpn_key_headless(
|
||||
session=session,
|
||||
tg_id=tg_id,
|
||||
expiry_time=expiry,
|
||||
plan=int(tariff["id"]),
|
||||
selected_price_rub=0,
|
||||
skip_balance_charge=True,
|
||||
is_trial=True,
|
||||
)
|
||||
await update_trial(session, tg_id, 1)
|
||||
except Exception:
|
||||
logger.exception("web trial activation failed")
|
||||
raise HTTPException(status_code=500, detail="Ошибка активации триала") from None
|
||||
return TariffPurchaseResponse(
|
||||
ok=True,
|
||||
message="Пробная подписка активирована!",
|
||||
charged_rub=0,
|
||||
base_price_rub=0,
|
||||
final_price_rub=0,
|
||||
)
|
||||
|
||||
balance = float(await get_balance(session, tg_id))
|
||||
required_amount = int(max(0, ceil(float(price) - balance)))
|
||||
|
||||
if required_amount <= 0:
|
||||
moscow_tz = tz_moscow("Europe/Moscow")
|
||||
expiry = datetime.now(moscow_tz) + timedelta(days=duration)
|
||||
try:
|
||||
await create_vpn_key_headless(
|
||||
session=session,
|
||||
tg_id=tg_id,
|
||||
expiry_time=expiry,
|
||||
plan=int(tariff["id"]),
|
||||
selected_price_rub=price,
|
||||
is_trial=True,
|
||||
)
|
||||
await update_trial(session, tg_id, 1)
|
||||
except Exception:
|
||||
logger.exception("web paid trial activation failed")
|
||||
raise HTTPException(status_code=500, detail="Ошибка активации триала") from None
|
||||
return TariffPurchaseResponse(
|
||||
ok=True,
|
||||
message="Пробная подписка активирована!",
|
||||
charged_rub=price,
|
||||
base_price_rub=price,
|
||||
final_price_rub=price,
|
||||
)
|
||||
|
||||
provider_id = str(_resolve_default_web_payment_provider() or "").strip().upper()
|
||||
if not provider_id:
|
||||
raise HTTPException(status_code=503, detail="Нет доступных провайдеров оплаты")
|
||||
base_url = _resolve_public_base_url(request)
|
||||
payment_request = PaymentLinkRequest(
|
||||
legacy_user_ref=int(tg_id),
|
||||
amount=required_amount,
|
||||
currency="RUB",
|
||||
provider_id=provider_id,
|
||||
success_url=f"{base_url}/payment-success",
|
||||
failure_url=f"{base_url}/payment-failure",
|
||||
metadata={
|
||||
"payment_flow": "trial_purchase",
|
||||
"tariff_id": int(tariff["id"]),
|
||||
"selected_price_rub": price,
|
||||
"selected_duration_days": duration,
|
||||
},
|
||||
)
|
||||
payment_result = await create_payment_link(session, payment_request)
|
||||
if not payment_result.success or not payment_result.payment_url or not payment_result.payment_id:
|
||||
raise HTTPException(status_code=400, detail=payment_result.error or "Не удалось создать ссылку оплаты")
|
||||
await create_temporary_data(
|
||||
session,
|
||||
int(tg_id),
|
||||
"waiting_for_payment",
|
||||
{
|
||||
"payment_flow": "trial_purchase",
|
||||
"tariff_id": int(tariff["id"]),
|
||||
"required_amount": required_amount,
|
||||
"selected_price_rub": price,
|
||||
"selected_duration_days": duration,
|
||||
},
|
||||
)
|
||||
return TariffPurchaseResponse(
|
||||
ok=True,
|
||||
message="Требуется оплата для активации пробной подписки",
|
||||
charged_rub=0,
|
||||
base_price_rub=price,
|
||||
final_price_rub=price,
|
||||
payment_required=True,
|
||||
required_amount_rub=required_amount,
|
||||
payment_id=payment_result.payment_id,
|
||||
payment_url=payment_result.payment_url,
|
||||
)
|
||||
|
||||
|
||||
router = generate_crud_router(
|
||||
model=Tariff,
|
||||
schema_response=TariffResponse,
|
||||
schema_create=TariffBase,
|
||||
schema_update=TariffUpdate,
|
||||
identifier_field="name",
|
||||
parameter_name="name",
|
||||
enabled_methods=["get_all", "get_one", "create", "update", "delete"],
|
||||
)
|
||||
@@ -1,63 +0,0 @@
|
||||
import asyncio
|
||||
|
||||
from fastapi import Depends, HTTPException, Path
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from api.depends import get_session, verify_identity_admin
|
||||
from api.v2.base_crud import generate_crud_router
|
||||
from api.v2.schemas import UserBase, UserResponse, UserUpdate
|
||||
from database import async_session_maker, delete_user_data, get_servers
|
||||
from database.access.resolution import resolve_user_optional
|
||||
from database.models import Key, User
|
||||
from logger import logger
|
||||
from services.operations import delete_key_from_cluster
|
||||
|
||||
|
||||
router = generate_crud_router(
|
||||
model=User,
|
||||
schema_response=UserResponse,
|
||||
schema_create=UserBase,
|
||||
schema_update=UserUpdate,
|
||||
identifier_field="tg_id",
|
||||
enabled_methods=["get_all", "get_one", "create", "update"],
|
||||
)
|
||||
|
||||
|
||||
@router.delete("/{tg_id}", response_model=dict)
|
||||
async def delete_user(
|
||||
tg_id: int = Path(..., description="Telegram ID пользователя"),
|
||||
identity=Depends(verify_identity_admin),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
):
|
||||
"""Удаляет пользователя и его ключи на серверах."""
|
||||
try:
|
||||
u = await resolve_user_optional(session, tg_id)
|
||||
if u is None:
|
||||
raise HTTPException(status_code=404, detail="Пользователь не найден")
|
||||
result = await session.execute(select(Key.email, Key.client_id).where(Key.user_id == u.id))
|
||||
key_records = result.all()
|
||||
|
||||
async with async_session_maker() as s:
|
||||
servers = await get_servers(session=s)
|
||||
cluster_ids = list(servers.keys())
|
||||
|
||||
async def _delete_one(cluster_id: str, email: str, client_id: str):
|
||||
async with async_session_maker() as s:
|
||||
await delete_key_from_cluster(cluster_id, email, client_id, s)
|
||||
|
||||
try:
|
||||
tasks = [
|
||||
_delete_one(cluster_id, email, client_id)
|
||||
for email, client_id in key_records
|
||||
for cluster_id in cluster_ids
|
||||
]
|
||||
await asyncio.gather(*tasks, return_exceptions=True)
|
||||
except Exception as e:
|
||||
logger.error(f"[DELETE] Ошибка при удалении ключей с серверов для пользователя {tg_id}: {e}")
|
||||
|
||||
await delete_user_data(session, tg_id)
|
||||
return {"detail": f"Пользователь {tg_id} и его ключи успешно удалены."}
|
||||
except Exception as e:
|
||||
logger.error(f"[DELETE] Ошибка при удалении пользователя {tg_id}: {e}")
|
||||
raise HTTPException(status_code=500, detail="Ошибка при удалении пользователя")
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,40 +0,0 @@
|
||||
from api.v1.schemas import (
|
||||
BlockedUserResponse,
|
||||
CouponBase,
|
||||
CouponResponse,
|
||||
CouponUpdate,
|
||||
CouponUsageResponse,
|
||||
GiftBase,
|
||||
GiftResponse,
|
||||
GiftUpdate,
|
||||
GiftUsageResponse,
|
||||
KeyDetailsResponse,
|
||||
KeyResponse,
|
||||
ManualBanResponse,
|
||||
NotificationResponse,
|
||||
PaymentResponse,
|
||||
ReferralResponse,
|
||||
ServerBase,
|
||||
ServerResponse,
|
||||
ServerUpdate,
|
||||
TariffBase,
|
||||
TariffResponse,
|
||||
TariffUpdate,
|
||||
TemporaryDataResponse,
|
||||
TrackingSourceResponse,
|
||||
UserBase,
|
||||
UserResponse,
|
||||
UserUpdate,
|
||||
)
|
||||
from api.v1.schemas.keys import KeyBase, KeyCreateRequest, KeyUpdate
|
||||
from api.v1.schemas.settings import SettingResponse, SettingUpsert
|
||||
from api.v2.schemas.web import (
|
||||
WebBlockResponse,
|
||||
WebPageResponse,
|
||||
WebPageUpdate,
|
||||
WebPageVariantCreate,
|
||||
WebPageVariantSummary,
|
||||
WebPageVariantUpdate,
|
||||
WebPageVariantsResponse,
|
||||
WebTheme,
|
||||
)
|
||||
@@ -1,58 +0,0 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
from typing import Any
|
||||
|
||||
from pydantic import BaseModel
|
||||
|
||||
|
||||
class AuditEventResponse(BaseModel):
|
||||
id: int | None = None
|
||||
event_type: str
|
||||
channel: str
|
||||
actor_identity_id: str | None
|
||||
actor_tg_id: int | None
|
||||
path_or_handler: str
|
||||
entity_type: str | None
|
||||
entity_id: str | None
|
||||
result: str
|
||||
reason: str | None
|
||||
metadata: dict[str, Any] | None
|
||||
request_id: str | None
|
||||
created_at: datetime | None
|
||||
|
||||
|
||||
class AuditEventListResponse(BaseModel):
|
||||
items: list[AuditEventResponse]
|
||||
limit: int
|
||||
offset: int
|
||||
|
||||
|
||||
class AuditPathStat(BaseModel):
|
||||
step: str
|
||||
label: str
|
||||
total: int
|
||||
success: int
|
||||
fail: int
|
||||
unique_users: int
|
||||
fail_rate_pct: float
|
||||
|
||||
|
||||
class AuditFunnelStep(BaseModel):
|
||||
step: str
|
||||
label: str
|
||||
count: int
|
||||
conversion_from_prev_pct: float | None
|
||||
|
||||
|
||||
class AuditStatsSummary(BaseModel):
|
||||
date_from: str
|
||||
date_to: str
|
||||
total_events: int
|
||||
unique_users: int
|
||||
|
||||
|
||||
class AuditStatsResponse(BaseModel):
|
||||
summary: AuditStatsSummary
|
||||
by_path: list[AuditPathStat]
|
||||
funnel: list[AuditFunnelStep]
|
||||
@@ -1,91 +0,0 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any, Literal
|
||||
|
||||
from pydantic import BaseModel, ConfigDict
|
||||
from pydantic.alias_generators import to_camel
|
||||
|
||||
|
||||
_FLOW_CONFIG = ConfigDict(populate_by_name=True, alias_generator=to_camel)
|
||||
|
||||
|
||||
class EdgeConditionSchema(BaseModel):
|
||||
model_config = _FLOW_CONFIG
|
||||
|
||||
field: str
|
||||
operator: str
|
||||
value: Any = None
|
||||
|
||||
|
||||
class EdgeConditionGroupSchema(BaseModel):
|
||||
model_config = _FLOW_CONFIG
|
||||
|
||||
logic: Literal["and", "or"] = "and"
|
||||
conditions: list[EdgeConditionSchema] = []
|
||||
|
||||
|
||||
class FlowEdgeSchema(BaseModel):
|
||||
model_config = _FLOW_CONFIG
|
||||
|
||||
id: str
|
||||
source: str
|
||||
target: str
|
||||
condition: EdgeConditionSchema | None = None
|
||||
condition_group: EdgeConditionGroupSchema | None = None
|
||||
label: str | None = None
|
||||
priority: int | None = None
|
||||
|
||||
|
||||
class FlowNodeActionConfigSchema(BaseModel):
|
||||
model_config = _FLOW_CONFIG
|
||||
|
||||
action_type: str
|
||||
params: dict[str, Any] = {}
|
||||
|
||||
|
||||
class FlowNodeSchema(BaseModel):
|
||||
model_config = _FLOW_CONFIG
|
||||
|
||||
id: str
|
||||
type: str
|
||||
label: str
|
||||
label_en: str | None = None
|
||||
enabled: bool = True
|
||||
page_slug: str | None = None
|
||||
cabinet_tab: str | None = None
|
||||
screen_group: str | None = None
|
||||
screen_id: str | None = None
|
||||
auto_skip_if: EdgeConditionSchema | EdgeConditionGroupSchema | None = None
|
||||
action_config: FlowNodeActionConfigSchema | None = None
|
||||
config: dict = {}
|
||||
position: dict
|
||||
|
||||
|
||||
class FlowResponse(BaseModel):
|
||||
model_config = _FLOW_CONFIG
|
||||
|
||||
id: str
|
||||
name: str
|
||||
nodes: list[FlowNodeSchema]
|
||||
edges: list[FlowEdgeSchema]
|
||||
entry_node_id: str | None
|
||||
version: int
|
||||
|
||||
|
||||
class FlowUpdate(BaseModel):
|
||||
model_config = _FLOW_CONFIG
|
||||
|
||||
name: str | None = None
|
||||
nodes: list[FlowNodeSchema]
|
||||
edges: list[FlowEdgeSchema]
|
||||
entry_node_id: str | None = None
|
||||
|
||||
|
||||
class FlowCreate(BaseModel):
|
||||
model_config = _FLOW_CONFIG
|
||||
|
||||
id: str
|
||||
name: str
|
||||
nodes: list[FlowNodeSchema] = []
|
||||
edges: list[FlowEdgeSchema] = []
|
||||
entry_node_id: str | None = None
|
||||
@@ -1,132 +0,0 @@
|
||||
from datetime import datetime
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
|
||||
class IdentityCreate(BaseModel):
|
||||
email: str | None = Field(None, description="Почта для привязки")
|
||||
tg_id: int | None = Field(None, description="Telegram ID для привязки")
|
||||
|
||||
|
||||
class IdentityResponse(BaseModel):
|
||||
id: str
|
||||
email: str | None
|
||||
tg_id: int | None
|
||||
is_admin: bool = False
|
||||
email_verified: bool = False
|
||||
password_set: bool = False
|
||||
onboarding_completed: bool = False
|
||||
onboarding_stage: str | None = None
|
||||
created_at: datetime | None
|
||||
updated_at: datetime | None
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
|
||||
class RegisterByEmailRequest(BaseModel):
|
||||
email: str = Field(..., min_length=1)
|
||||
password: str = Field(..., min_length=8, description="Пароль (минимум 8 символов)")
|
||||
referral_code: str | None = Field(None, min_length=1)
|
||||
turnstile_token: str | None = Field(default=None, description="Cloudflare Turnstile CAPTCHA token")
|
||||
|
||||
|
||||
class RegisterResponse(BaseModel):
|
||||
identity_id: str
|
||||
|
||||
|
||||
class LoginRequest(BaseModel):
|
||||
email: str = Field(..., min_length=1)
|
||||
password: str = Field(...)
|
||||
|
||||
|
||||
class SetPasswordRequest(BaseModel):
|
||||
password: str = Field(..., min_length=8, description="Новый пароль (минимум 8 символов)")
|
||||
password_confirm: str = Field(..., min_length=8)
|
||||
|
||||
|
||||
class ChangePasswordRequest(BaseModel):
|
||||
current_password: str = Field(...)
|
||||
password: str = Field(..., min_length=8, description="Новый пароль (минимум 8 символов)")
|
||||
password_confirm: str = Field(..., min_length=8)
|
||||
|
||||
|
||||
class LoginResponse(BaseModel):
|
||||
identity_id: str
|
||||
identity: IdentityResponse
|
||||
|
||||
|
||||
class SendLoginCodeRequest(BaseModel):
|
||||
email: str = Field(..., min_length=1)
|
||||
allow_register: bool = Field(
|
||||
default=True,
|
||||
description="Если true и email новый — создать идентичность и отправить код (passwordless flow)",
|
||||
)
|
||||
turnstile_token: str | None = Field(default=None, description="Cloudflare Turnstile CAPTCHA token")
|
||||
|
||||
|
||||
class LoginByCodeRequest(BaseModel):
|
||||
email: str = Field(..., min_length=1)
|
||||
code: str = Field(..., min_length=1)
|
||||
|
||||
|
||||
class ConfirmPasswordResetRequest(BaseModel):
|
||||
email: str = Field(..., min_length=1)
|
||||
code: str = Field(..., min_length=1)
|
||||
password: str = Field(..., min_length=8)
|
||||
password_confirm: str = Field(..., min_length=8)
|
||||
|
||||
|
||||
class LoginTelegramRequest(BaseModel):
|
||||
"""Данные от Telegram Login Widget (кнопка «Войти через Telegram»)."""
|
||||
|
||||
id: int = Field(..., description="Telegram user id (tg_id)")
|
||||
first_name: str = Field("")
|
||||
last_name: str | None = None
|
||||
username: str | None = None
|
||||
photo_url: str | None = None
|
||||
auth_date: int = Field(..., description="Unix timestamp от Telegram")
|
||||
hash: str = Field(..., description="HMAC подпись для проверки на бэкенде")
|
||||
|
||||
|
||||
class LinkTelegramRequest(BaseModel):
|
||||
"""Данные от Telegram Login Widget — обязательны для доказательства владения аккаунтом при привязке."""
|
||||
|
||||
id: int = Field(..., description="Telegram user id (tg_id)")
|
||||
first_name: str = Field("")
|
||||
last_name: str | None = None
|
||||
username: str | None = None
|
||||
photo_url: str | None = None
|
||||
auth_date: int = Field(..., description="Unix timestamp от Telegram")
|
||||
hash: str = Field(..., description="HMAC подпись для проверки на бэкенде")
|
||||
|
||||
|
||||
class IdentityAttachEmail(BaseModel):
|
||||
email: str = Field(..., min_length=1)
|
||||
|
||||
|
||||
class LinkEmailSendCodeRequest(BaseModel):
|
||||
email: str = Field(..., min_length=1)
|
||||
|
||||
|
||||
class LinkEmailConfirmRequest(BaseModel):
|
||||
email: str = Field(..., min_length=1)
|
||||
code: str = Field(..., min_length=1, max_length=16)
|
||||
|
||||
|
||||
class IdentityAttachTelegram(BaseModel):
|
||||
tg_id: int = Field(...)
|
||||
|
||||
|
||||
class IdentitySessionItem(BaseModel):
|
||||
id: str
|
||||
device_label: str | None = None
|
||||
ip: str | None = None
|
||||
created_at: datetime
|
||||
last_seen_at: datetime
|
||||
expires_at: datetime | None = None
|
||||
is_current: bool = False
|
||||
|
||||
|
||||
class IdentitySessionsResponse(BaseModel):
|
||||
sessions: list[IdentitySessionItem]
|
||||
@@ -1,32 +0,0 @@
|
||||
from typing import Any
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
|
||||
class PaymentLinkCreateRequest(BaseModel):
|
||||
tg_id: int | None = Field(None, description="Telegram ID пользователя (если не задан identity_id)")
|
||||
identity_id: str | None = Field(None, description="ID идентичности; tg_id будет взят из привязки")
|
||||
amount: int | float = Field(..., gt=0, description="Сумма оплаты")
|
||||
currency: str = Field(default="RUB", description="Валюта (например RUB)")
|
||||
provider_id: str | None = Field(
|
||||
default=None,
|
||||
description="Идентификатор кассы: ROBOKASSA, FREEKASSA, YOOKASSA, YOOMONEY, KASSAI_CARDS, KASSAI_SBP, HELEKET и др. Если не задан — берётся первый доступный.",
|
||||
)
|
||||
success_url: str | None = Field(None, description="URL перенаправления после успешной оплаты")
|
||||
failure_url: str | None = Field(None, description="URL перенаправления после неуспешной оплаты")
|
||||
metadata: dict[str, Any] | None = Field(None, description="Дополнительные данные")
|
||||
|
||||
|
||||
class PaymentLinkCreateResponse(BaseModel):
|
||||
success: bool
|
||||
payment_id: str | None = None
|
||||
payment_url: str | None = None
|
||||
error: str | None = None
|
||||
|
||||
|
||||
class PaymentLinkStatusResponse(BaseModel):
|
||||
success: bool
|
||||
payment_id: str
|
||||
status: str | None = None
|
||||
completed: bool = False
|
||||
paid: bool = False
|
||||
@@ -1,25 +0,0 @@
|
||||
from pydantic import BaseModel
|
||||
|
||||
|
||||
class TariffGroup(BaseModel):
|
||||
"""Группа тарифов (group_code) для выбора в лендинге и др."""
|
||||
|
||||
group_code: str
|
||||
|
||||
|
||||
class TariffPublic(BaseModel):
|
||||
"""Публичный список тарифов (без авторизации)."""
|
||||
|
||||
id: int
|
||||
name: str
|
||||
group_code: str
|
||||
duration_days: int
|
||||
price_rub: int
|
||||
traffic_limit: int | None
|
||||
device_limit: int | None
|
||||
subgroup_title: str | None
|
||||
sort_order: int | None
|
||||
vless: bool = False
|
||||
configurable: bool = False
|
||||
device_options: list[int] | None = None
|
||||
traffic_options_gb: list[int] | None = None
|
||||
@@ -1,125 +0,0 @@
|
||||
import json
|
||||
|
||||
from typing import Any
|
||||
|
||||
from pydantic import BaseModel, Field, model_validator
|
||||
|
||||
|
||||
_MAX_BLOCK_DATA_SIZE = 4 * 1024 * 1024
|
||||
|
||||
|
||||
class WebBlockBase(BaseModel):
|
||||
type: str = Field(..., max_length=64)
|
||||
order: int
|
||||
data: dict[str, Any]
|
||||
|
||||
@model_validator(mode="after")
|
||||
def _check_data_size(self) -> "WebBlockBase":
|
||||
if len(json.dumps(self.data, ensure_ascii=False)) > _MAX_BLOCK_DATA_SIZE:
|
||||
raise ValueError(f"Размер data блока не должен превышать {_MAX_BLOCK_DATA_SIZE // 1024} КБ")
|
||||
return self
|
||||
|
||||
|
||||
class WebBlockResponse(WebBlockBase):
|
||||
id: str
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
|
||||
class WebTheme(BaseModel):
|
||||
tokens: dict[str, Any]
|
||||
|
||||
|
||||
class WebPageThemeResponse(BaseModel):
|
||||
slug: str
|
||||
variant_key: str = "default"
|
||||
tokens: dict[str, Any] = Field(default_factory=dict)
|
||||
|
||||
|
||||
class WebPageThemeUpdate(BaseModel):
|
||||
tokens: dict[str, Any]
|
||||
|
||||
|
||||
class WebPageVariantSummary(BaseModel):
|
||||
key: str = Field(..., max_length=64)
|
||||
name: str = Field(..., max_length=255)
|
||||
is_active: bool = False
|
||||
|
||||
|
||||
class WebPageSaveResponse(BaseModel):
|
||||
slug: str
|
||||
variant_key: str = "default"
|
||||
active_variant_key: str = "default"
|
||||
variants: list[WebPageVariantSummary] = Field(default_factory=list)
|
||||
|
||||
|
||||
class WebPageResponse(BaseModel):
|
||||
slug: str
|
||||
blocks: list[WebBlockResponse]
|
||||
theme: WebTheme | None = None
|
||||
variant_key: str = "default"
|
||||
active_variant_key: str = "default"
|
||||
variants: list[WebPageVariantSummary] = Field(default_factory=list)
|
||||
|
||||
|
||||
class WebPageUpdate(BaseModel):
|
||||
blocks: list[WebBlockBase]
|
||||
theme: WebTheme | None = None
|
||||
|
||||
|
||||
class WebPageVariantCreate(BaseModel):
|
||||
key: str | None = Field(default=None, max_length=64)
|
||||
name: str | None = Field(default=None, max_length=255)
|
||||
from_variant_key: str | None = Field(default=None, max_length=64)
|
||||
|
||||
|
||||
class WebPageVariantUpdate(BaseModel):
|
||||
name: str | None = Field(default=None, max_length=255)
|
||||
make_active: bool | None = None
|
||||
|
||||
|
||||
class WebPageVariantsResponse(BaseModel):
|
||||
slug: str
|
||||
active_variant_key: str = "default"
|
||||
current_variant_key: str = "default"
|
||||
variants: list[WebPageVariantSummary] = Field(default_factory=list)
|
||||
|
||||
|
||||
class WebUploadResponse(BaseModel):
|
||||
url: str
|
||||
|
||||
|
||||
class FlowStepConfig(BaseModel):
|
||||
provider_ids: list[str] | None = None
|
||||
tariff_group_code: str | None = None
|
||||
tariff_ids: list[int] | None = None
|
||||
display_mode: str | None = None
|
||||
skippable: bool = False
|
||||
auto_advance_if_single: bool = False
|
||||
|
||||
|
||||
class FlowStepSchema(BaseModel):
|
||||
id: str = Field(..., max_length=64)
|
||||
type: str = Field(..., max_length=32)
|
||||
label: str = Field(..., max_length=255)
|
||||
label_en: str | None = Field(default=None, max_length=255)
|
||||
enabled: bool = True
|
||||
page_slug: str | None = Field(default=None, max_length=64)
|
||||
config: FlowStepConfig = Field(default_factory=FlowStepConfig)
|
||||
|
||||
|
||||
class FlowDefinitionSchema(BaseModel):
|
||||
id: str = Field(..., max_length=64)
|
||||
name: str = Field(..., max_length=255)
|
||||
steps: list[FlowStepSchema] = Field(default_factory=list)
|
||||
version: int = 1
|
||||
|
||||
|
||||
class FlowDefinitionResponse(FlowDefinitionSchema):
|
||||
pass
|
||||
|
||||
|
||||
class FlowDefinitionUpdate(BaseModel):
|
||||
name: str | None = Field(default=None, max_length=255)
|
||||
steps: list[FlowStepSchema] = Field(default_factory=list)
|
||||
@@ -1,479 +0,0 @@
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
|
||||
class AccountSummaryResponse(BaseModel):
|
||||
identity_id: str
|
||||
email: str | None = None
|
||||
tg_id: int | None = None
|
||||
linked_telegram: bool = False
|
||||
created_at: str | None = None
|
||||
password_set: bool = False
|
||||
referral_code: str = ""
|
||||
balance: float = 0.0
|
||||
trial_status: int = 0
|
||||
keys_total: int = 0
|
||||
referrals_total: int = 0
|
||||
referrals_active: int = 0
|
||||
referral_bonus_total: float = 0.0
|
||||
gifts_sent: int = 0
|
||||
gifts_claimed: int = 0
|
||||
coupons_used: int = 0
|
||||
partner_enabled: bool = False
|
||||
partner_code: str = ""
|
||||
partner_balance: float = 0.0
|
||||
partner_percent: float = 0.0
|
||||
partner_percent_custom: bool = False
|
||||
partner_referred_total: int = 0
|
||||
partner_referred_paid: int = 0
|
||||
partner_payout_method: str | None = None
|
||||
unread_notifications: int = 0
|
||||
|
||||
|
||||
class AccountKeyActionsAvailability(BaseModel):
|
||||
can_connect_device: bool = False
|
||||
can_connect_router: bool = False
|
||||
can_connect_tv: bool = False
|
||||
can_renew: bool = False
|
||||
can_addons: bool = False
|
||||
can_reset_hwid: bool = False
|
||||
can_qr: bool = False
|
||||
can_delete: bool = False
|
||||
can_change_location: bool = False
|
||||
|
||||
|
||||
class AccountKeyDetailsResponse(BaseModel):
|
||||
client_id: str
|
||||
email: str
|
||||
alias: str | None = None
|
||||
expiry_time: int = 0
|
||||
is_frozen: bool = False
|
||||
tariff_name: str = ""
|
||||
subgroup_title: str = ""
|
||||
traffic_limit_gb: int = 0
|
||||
used_traffic_gb: float | None = None
|
||||
device_limit: int = 0
|
||||
connected_devices: int = 0
|
||||
is_tariff_configurable: bool = False
|
||||
addons_devices_enabled: bool = False
|
||||
addons_traffic_enabled: bool = False
|
||||
|
||||
|
||||
class AccountKeyResponse(BaseModel):
|
||||
email: str
|
||||
alias: str | None = None
|
||||
client_id: str
|
||||
tariff_id: int | None = None
|
||||
server_id: str
|
||||
created_at: int = 0
|
||||
expiry_time: int = 0
|
||||
key: str | None = None
|
||||
remnawave_link: str | None = None
|
||||
is_frozen: bool = False
|
||||
actions: AccountKeyActionsAvailability | None = None
|
||||
|
||||
|
||||
class AccountKeyAliasUpdateRequest(BaseModel):
|
||||
alias: str = Field(..., min_length=1, max_length=10)
|
||||
|
||||
|
||||
class AccountKeyActionResponse(BaseModel):
|
||||
ok: bool = True
|
||||
message: str = ""
|
||||
|
||||
|
||||
class AccountKeyRenewRequest(BaseModel):
|
||||
provider_id: str | None = None
|
||||
success_url: str | None = None
|
||||
failure_url: str | None = None
|
||||
coupon_code: str | None = None
|
||||
|
||||
|
||||
class AccountKeyRenewResponse(AccountKeyActionResponse):
|
||||
client_id: str
|
||||
tariff_id: int
|
||||
charged_rub: int = 0
|
||||
balance_rub: float = 0.0
|
||||
base_price_rub: int = 0
|
||||
discount_rub: int = 0
|
||||
final_price_rub: int = 0
|
||||
applied_coupon_code: str | None = None
|
||||
payment_required: bool = False
|
||||
required_amount_rub: int = 0
|
||||
payment_id: str | None = None
|
||||
payment_url: str | None = None
|
||||
|
||||
|
||||
class AccountKeyResetHwidResponse(AccountKeyActionResponse):
|
||||
total_devices: int = 0
|
||||
reset_devices: int = 0
|
||||
|
||||
|
||||
class AccountKeyQrResponse(AccountKeyActionResponse):
|
||||
link: str = ""
|
||||
image_data_url: str = ""
|
||||
|
||||
|
||||
class AccountKeyLocationOptionResponse(BaseModel):
|
||||
server_name: str
|
||||
|
||||
|
||||
class AccountKeyLocationsResponse(BaseModel):
|
||||
client_id: str
|
||||
current_server: str = ""
|
||||
locations: list[AccountKeyLocationOptionResponse] = []
|
||||
|
||||
|
||||
class AccountKeyChangeLocationRequest(BaseModel):
|
||||
server_name: str = Field(..., min_length=1)
|
||||
|
||||
|
||||
class AccountKeyChangeLocationResponse(AccountKeyActionResponse):
|
||||
client_id: str
|
||||
server_id: str = ""
|
||||
link: str = ""
|
||||
remnawave_link: str | None = None
|
||||
|
||||
|
||||
class AccountKeyAddonOptionResponse(BaseModel):
|
||||
value: int
|
||||
label: str = ""
|
||||
|
||||
|
||||
class AccountKeyAddonsPreviewRequest(BaseModel):
|
||||
selected_device_limit: int | None = None
|
||||
selected_traffic_gb: int | None = None
|
||||
include_device: bool | None = None
|
||||
include_traffic: bool | None = None
|
||||
provider_id: str | None = None
|
||||
success_url: str | None = None
|
||||
failure_url: str | None = None
|
||||
coupon_code: str | None = None
|
||||
|
||||
|
||||
class AccountKeyAddonsPreviewResponse(BaseModel):
|
||||
client_id: str
|
||||
tariff_id: int
|
||||
addons_mode: str = ""
|
||||
has_device_option: bool = False
|
||||
has_traffic_option: bool = False
|
||||
current_device_limit: int | None = None
|
||||
current_traffic_gb: int | None = None
|
||||
selected_device_limit: int | None = None
|
||||
selected_traffic_gb: int | None = None
|
||||
device_options: list[AccountKeyAddonOptionResponse] = []
|
||||
traffic_options: list[AccountKeyAddonOptionResponse] = []
|
||||
total_price_rub: int = 0
|
||||
extra_price_rub: int = 0
|
||||
discount_rub: int = 0
|
||||
final_price_rub: int = 0
|
||||
applied_coupon_code: str | None = None
|
||||
balance_rub: float = 0.0
|
||||
|
||||
|
||||
class AccountKeyApplyAddonsResponse(AccountKeyActionResponse):
|
||||
client_id: str
|
||||
tariff_id: int
|
||||
total_price_rub: int = 0
|
||||
extra_price_rub: int = 0
|
||||
discount_rub: int = 0
|
||||
final_price_rub: int = 0
|
||||
applied_coupon_code: str | None = None
|
||||
charged_rub: int = 0
|
||||
balance_rub: float = 0.0
|
||||
payment_required: bool = False
|
||||
required_amount_rub: int = 0
|
||||
payment_id: str | None = None
|
||||
payment_url: str | None = None
|
||||
|
||||
|
||||
class AccountKeyActionsConfigResponse(BaseModel):
|
||||
renew_enabled: bool = True
|
||||
delete_enabled: bool = False
|
||||
qr_enabled: bool = False
|
||||
hwid_reset_enabled: bool = False
|
||||
country_change_enabled: bool = False
|
||||
instructions_enabled: bool = False
|
||||
addons_enabled: bool = False
|
||||
addons_mode: str = ""
|
||||
tv_connect_enabled: bool = False
|
||||
|
||||
|
||||
class AccountKeyConnectionResponse(BaseModel):
|
||||
client_id: str
|
||||
online: bool = False
|
||||
is_frozen: bool = False
|
||||
expiry_time: int = 0
|
||||
expires_in_days: int = 0
|
||||
server_name: str = ""
|
||||
cluster_name: str = ""
|
||||
panel_type: str = ""
|
||||
protocol: str = ""
|
||||
|
||||
|
||||
class AccountSearchHit(BaseModel):
|
||||
kind: str
|
||||
label: str
|
||||
sublabel: str = ""
|
||||
href: str = ""
|
||||
meta: str = ""
|
||||
|
||||
|
||||
class AccountSearchResponse(BaseModel):
|
||||
query: str
|
||||
hits: list[AccountSearchHit] = []
|
||||
total: int = 0
|
||||
|
||||
|
||||
class TariffConfigPriceResponse(BaseModel):
|
||||
price_rub: int
|
||||
|
||||
|
||||
class TariffPurchaseRequest(BaseModel):
|
||||
tariff_id: int = Field(..., ge=1)
|
||||
selected_device_limit: int | None = None
|
||||
selected_traffic_gb: int | None = None
|
||||
provider_id: str | None = None
|
||||
success_url: str | None = None
|
||||
failure_url: str | None = None
|
||||
coupon_code: str | None = None
|
||||
|
||||
|
||||
class TariffPurchaseResponse(BaseModel):
|
||||
ok: bool = True
|
||||
message: str = ""
|
||||
key_email: str | None = None
|
||||
charged_rub: int | None = None
|
||||
base_price_rub: int = 0
|
||||
discount_rub: int = 0
|
||||
final_price_rub: int = 0
|
||||
applied_coupon_code: str | None = None
|
||||
payment_required: bool = False
|
||||
required_amount_rub: int = 0
|
||||
payment_id: str | None = None
|
||||
payment_url: str | None = None
|
||||
|
||||
|
||||
class GiftCreateRequest(BaseModel):
|
||||
tariff_id: int = Field(..., ge=1)
|
||||
selected_device_limit: int | None = None
|
||||
selected_traffic_gb: int | None = None
|
||||
provider_id: str | None = None
|
||||
success_url: str | None = None
|
||||
failure_url: str | None = None
|
||||
|
||||
|
||||
class GiftCreatePreviewResponse(BaseModel):
|
||||
ok: bool = True
|
||||
price_rub: int = 0
|
||||
balance_rub: float = 0.0
|
||||
sufficient_funds: bool = True
|
||||
tariff_name: str = ""
|
||||
duration_days: int = 0
|
||||
|
||||
|
||||
class GiftCreateResponse(BaseModel):
|
||||
ok: bool = True
|
||||
message: str = ""
|
||||
gift_id: str = ""
|
||||
site_gift_link: str = ""
|
||||
tariff_name: str = ""
|
||||
duration_days: int = 0
|
||||
price_charged: int = 0
|
||||
balance_rub: float = 0.0
|
||||
payment_required: bool = False
|
||||
required_amount_rub: int = 0
|
||||
payment_id: str | None = None
|
||||
payment_url: str | None = None
|
||||
|
||||
|
||||
class GiftUsageEntry(BaseModel):
|
||||
user_id: int
|
||||
used_at: str | None = None
|
||||
|
||||
|
||||
class MyGiftItem(BaseModel):
|
||||
gift_id: str
|
||||
tariff_name: str = ""
|
||||
duration_days: int = 0
|
||||
price_rub: int = 0
|
||||
created_at: str | None = None
|
||||
expiry_time: str | None = None
|
||||
is_used: bool = False
|
||||
is_unlimited: bool = False
|
||||
max_usages: int | None = None
|
||||
site_gift_link: str = ""
|
||||
usages: list[GiftUsageEntry] = []
|
||||
|
||||
|
||||
class MyGiftsResponse(BaseModel):
|
||||
ok: bool = True
|
||||
gifts: list[MyGiftItem] = []
|
||||
total: int = 0
|
||||
limit: int = 20
|
||||
offset: int = 0
|
||||
|
||||
|
||||
class GiftRedeemRequest(BaseModel):
|
||||
gift_code: str = Field(..., min_length=1)
|
||||
|
||||
|
||||
class GiftRedeemResponse(BaseModel):
|
||||
ok: bool = True
|
||||
message: str = ""
|
||||
gift_id: str = ""
|
||||
tariff_id: int = 0
|
||||
duration_days: int = 0
|
||||
|
||||
|
||||
class ReferralApplyRequest(BaseModel):
|
||||
referrer_code: str | None = Field(None, min_length=1)
|
||||
referrer_tg_id: int | None = Field(None, ge=1)
|
||||
|
||||
|
||||
class ReferralApplyResponse(BaseModel):
|
||||
ok: bool = True
|
||||
message: str = ""
|
||||
referrer_code: str = ""
|
||||
referrer_user_id: int = 0
|
||||
referrer_tg_id: int | None = None
|
||||
referred_user_id: int = 0
|
||||
referred_tg_id: int | None = None
|
||||
|
||||
|
||||
class ReferralTopEntryResponse(BaseModel):
|
||||
position: int
|
||||
referrer_user_id: int
|
||||
referrals_count: int
|
||||
display_id: str
|
||||
|
||||
|
||||
class ReferralTopResponse(BaseModel):
|
||||
user_referrals_count: int = 0
|
||||
user_position: int | None = None
|
||||
top: list[ReferralTopEntryResponse] = []
|
||||
|
||||
|
||||
class ReferralListEntry(BaseModel):
|
||||
referred_user_id: int
|
||||
referred_tg_id: int | None = None
|
||||
display_id: str = ""
|
||||
reward_issued: bool = False
|
||||
|
||||
|
||||
class ReferralListResponse(BaseModel):
|
||||
total: int = 0
|
||||
items: list[ReferralListEntry] = []
|
||||
|
||||
|
||||
class ReferralQrResponse(BaseModel):
|
||||
ok: bool = True
|
||||
link: str = ""
|
||||
image_data_url: str = ""
|
||||
|
||||
|
||||
class ReferralConditionsResponse(BaseModel):
|
||||
title: str = ""
|
||||
summary: str = ""
|
||||
bonus_mode: str = ""
|
||||
bonus_mode_label: str = ""
|
||||
level_lines: list[str] = []
|
||||
rules: list[str] = []
|
||||
|
||||
|
||||
class PartnerConditionsResponse(BaseModel):
|
||||
title: str = ""
|
||||
summary: str = ""
|
||||
bonus_mode: str = ""
|
||||
bonus_mode_label: str = ""
|
||||
level_lines: list[str] = []
|
||||
rules: list[str] = []
|
||||
examples: list[str] = []
|
||||
min_payout_rub: float = 0.0
|
||||
payout_methods: list[str] = []
|
||||
custom_amount_enabled: bool = False
|
||||
|
||||
|
||||
class PartnerQrResponse(BaseModel):
|
||||
ok: bool = True
|
||||
link: str = ""
|
||||
image_data_url: str = ""
|
||||
|
||||
|
||||
class PartnerApplyRequest(BaseModel):
|
||||
partner_code: str | None = Field(None, min_length=1)
|
||||
partner_tg_id: int | None = Field(None, ge=1)
|
||||
|
||||
|
||||
class PartnerApplyResponse(BaseModel):
|
||||
ok: bool = True
|
||||
message: str = ""
|
||||
partner_code: str = ""
|
||||
partner_user_id: int = 0
|
||||
partner_tg_id: int | None = None
|
||||
joined_user_id: int = 0
|
||||
joined_tg_id: int | None = None
|
||||
|
||||
|
||||
class PartnerTopEntryResponse(BaseModel):
|
||||
position: int
|
||||
partner_user_id: int
|
||||
referred_count: int
|
||||
display_id: str
|
||||
|
||||
|
||||
class PartnerTopResponse(BaseModel):
|
||||
user_referred_count: int = 0
|
||||
user_position: int | None = None
|
||||
top: list[PartnerTopEntryResponse] = []
|
||||
|
||||
|
||||
class PartnerInvitedEntry(BaseModel):
|
||||
tg_id: int
|
||||
joined_at: str | None = None
|
||||
balance: float = 0.0
|
||||
keys_count: int = 0
|
||||
payments_count: int = 0
|
||||
|
||||
|
||||
class PartnerInvitedResponse(BaseModel):
|
||||
total: int = 0
|
||||
items: list[PartnerInvitedEntry] = []
|
||||
|
||||
|
||||
class CouponApplyRequest(BaseModel):
|
||||
code: str = Field(..., min_length=1, max_length=128)
|
||||
|
||||
|
||||
class CouponApplyResponse(BaseModel):
|
||||
ok: bool = True
|
||||
message: str = ""
|
||||
coupon_code: str = ""
|
||||
amount: int = 0
|
||||
balance: float = 0.0
|
||||
|
||||
|
||||
class PartnerPayoutRequestCreate(BaseModel):
|
||||
amount_rub: float = Field(..., gt=0)
|
||||
|
||||
|
||||
class PartnerPayoutRequestResponse(BaseModel):
|
||||
ok: bool = True
|
||||
message: str = ""
|
||||
request_id: int | None = None
|
||||
amount_rub: float = 0.0
|
||||
status: str = "pending"
|
||||
balance_rub: float = 0.0
|
||||
|
||||
|
||||
class PartnerPayoutEntryResponse(BaseModel):
|
||||
id: int
|
||||
amount_rub: float = 0.0
|
||||
status: str = ""
|
||||
created_at: str | None = None
|
||||
method: str | None = None
|
||||
destination: str | None = None
|
||||
|
||||
|
||||
class PartnerPayoutHistoryResponse(BaseModel):
|
||||
total: int = 0
|
||||
items: list[PartnerPayoutEntryResponse] = []
|
||||
@@ -0,0 +1,114 @@
|
||||
CREATE TABLE IF NOT EXISTS users
|
||||
(
|
||||
tg_id BIGINT PRIMARY KEY NOT NULL,
|
||||
username TEXT,
|
||||
first_name TEXT,
|
||||
last_name TEXT,
|
||||
language_code TEXT,
|
||||
is_bot BOOLEAN DEFAULT FALSE,
|
||||
created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS connections
|
||||
(
|
||||
tg_id BIGINT PRIMARY KEY NOT NULL,
|
||||
balance REAL NOT NULL DEFAULT 0.0,
|
||||
trial INTEGER NOT NULL DEFAULT 0
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS payments
|
||||
(
|
||||
id SERIAL PRIMARY KEY,
|
||||
tg_id BIGINT NOT NULL,
|
||||
amount REAL NOT NULL,
|
||||
payment_system TEXT NOT NULL,
|
||||
status TEXT DEFAULT 'success',
|
||||
created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY (tg_id) REFERENCES users (tg_id)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS keys
|
||||
(
|
||||
tg_id BIGINT NOT NULL,
|
||||
client_id TEXT NOT NULL,
|
||||
email TEXT NOT NULL,
|
||||
created_at BIGINT NOT NULL,
|
||||
expiry_time BIGINT NOT NULL,
|
||||
key TEXT NOT NULL,
|
||||
server_id TEXT NOT NULL DEFAULT 'cluster1',
|
||||
notified BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
notified_24h BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
PRIMARY KEY (tg_id, client_id)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS referrals
|
||||
(
|
||||
referred_tg_id BIGINT PRIMARY KEY NOT NULL,
|
||||
referrer_tg_id BIGINT NOT NULL,
|
||||
reward_issued BOOLEAN DEFAULT FALSE
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS coupons
|
||||
(
|
||||
id SERIAL PRIMARY KEY,
|
||||
code TEXT UNIQUE NOT NULL,
|
||||
amount INTEGER NOT NULL,
|
||||
usage_limit INTEGER NOT NULL DEFAULT 1,
|
||||
usage_count INTEGER NOT NULL DEFAULT 0,
|
||||
is_used BOOLEAN NOT NULL DEFAULT FALSE
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS coupon_usages
|
||||
(
|
||||
coupon_id INTEGER NOT NULL REFERENCES coupons (id) ON DELETE CASCADE,
|
||||
user_id BIGINT NOT NULL,
|
||||
used_at TIMESTAMP NOT NULL DEFAULT NOW(),
|
||||
PRIMARY KEY (coupon_id, user_id)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS notifications
|
||||
(
|
||||
tg_id BIGINT NOT NULL,
|
||||
last_notification_time TIMESTAMP NOT NULL DEFAULT NOW(),
|
||||
notification_type TEXT NOT NULL,
|
||||
PRIMARY KEY (tg_id, notification_type)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS servers
|
||||
(
|
||||
id SERIAL PRIMARY KEY,
|
||||
cluster_name TEXT NOT NULL,
|
||||
server_name TEXT NOT NULL,
|
||||
api_url TEXT NOT NULL,
|
||||
subscription_url TEXT NOT NULL,
|
||||
inbound_id TEXT NOT NULL,
|
||||
UNIQUE (cluster_name, server_name)
|
||||
);
|
||||
|
||||
|
||||
CREATE TABLE IF NOT EXISTS gifts
|
||||
(
|
||||
gift_id TEXT PRIMARY KEY NOT NULL,
|
||||
sender_tg_id BIGINT NOT NULL,
|
||||
selected_months INTEGER NOT NULL,
|
||||
expiry_time TIMESTAMP WITH TIME ZONE NOT NULL,
|
||||
gift_link TEXT NOT NULL,
|
||||
created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
|
||||
is_used BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
recipient_tg_id BIGINT,
|
||||
CONSTRAINT fk_sender FOREIGN KEY (sender_tg_id) REFERENCES users (tg_id),
|
||||
CONSTRAINT fk_recipient FOREIGN KEY (recipient_tg_id) REFERENCES users (tg_id)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS temporary_data (
|
||||
tg_id BIGINT PRIMARY KEY NOT NULL,
|
||||
state TEXT NOT NULL,
|
||||
data JSONB NOT NULL,
|
||||
updated_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS blocked_users (
|
||||
tg_id BIGINT PRIMARY KEY,
|
||||
blocked_at TIMESTAMP DEFAULT NOW()
|
||||
);
|
||||
-1156
File diff suppressed because it is too large
Load Diff
-450
@@ -1,450 +0,0 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Iterable
|
||||
|
||||
|
||||
def _get_bot_webhook_path() -> str:
|
||||
"""Путь вебхука бота из конфига (для исключения из шага «успешная оплата»)."""
|
||||
try:
|
||||
from config import WEBHOOK_PATH
|
||||
|
||||
return ((WEBHOOK_PATH or "").strip().lower()) or ""
|
||||
except ImportError:
|
||||
return ""
|
||||
|
||||
|
||||
def _is_bot_webhook_path(path: str) -> bool:
|
||||
"""True только если path — именно вебхук бота (точное совпадение сегмента пути), не касса."""
|
||||
bot_path = _get_bot_webhook_path()
|
||||
if not bot_path:
|
||||
return False
|
||||
p = (path or "").strip().lower()
|
||||
path_segment = p.split(" ", 1)[1] if " " in p else p
|
||||
return path_segment == bot_path or path_segment.rstrip("/") == bot_path.rstrip("/")
|
||||
|
||||
|
||||
AUDIT_STEP_LABELS: dict[str, str] = {
|
||||
"start": "Старт",
|
||||
"start_coupon": "Старт: купон",
|
||||
"start_gift": "Старт: подарок",
|
||||
"start_referral": "Старт: рефералка",
|
||||
"start_utm": "Старт: UTM",
|
||||
"profile": "Профиль",
|
||||
"about": "О VPN",
|
||||
"instructions": "Инструкции",
|
||||
"balance": "Баланс / история оплат",
|
||||
"view_keys": "Мои ключи",
|
||||
"buy_entry": "Оформление: вход",
|
||||
"tariff_config": "Оформление: выбор тарифа/конфига",
|
||||
"key_create": "Подписка оформлена (ключ создан)",
|
||||
"pay_start": "Оплата: вход / создание ссылки",
|
||||
"pay": "Успешная оплата (пополнение)",
|
||||
"key_view": "Ключ (карточка)",
|
||||
"connect": "Подключение: экран / инструкции / QR",
|
||||
"key_manage": "Управление подпиской",
|
||||
"renew": "Продление",
|
||||
"addons": "Аддоны",
|
||||
"referral": "Рефералы",
|
||||
"coupons": "Купоны",
|
||||
"register": "Регистрация (API)",
|
||||
"login": "Вход (API)",
|
||||
"api_other": "API прочее",
|
||||
"admin": "Админ-панель",
|
||||
"other": "Прочее",
|
||||
}
|
||||
|
||||
|
||||
DEFAULT_FUNNEL_STEPS = (
|
||||
"start",
|
||||
"profile",
|
||||
"view_keys",
|
||||
"buy_entry",
|
||||
"tariff_config",
|
||||
"key_create",
|
||||
"pay_start",
|
||||
"pay",
|
||||
"key_view",
|
||||
"connect",
|
||||
)
|
||||
|
||||
|
||||
_CALLBACK_EXACT: dict[str, str] = {
|
||||
"start": "start",
|
||||
"profile": "profile",
|
||||
"about_vpn": "about",
|
||||
"instructions": "instructions",
|
||||
"view_keys": "view_keys",
|
||||
"create_key": "buy_entry",
|
||||
"buy": "buy_entry",
|
||||
"pay": "pay_start",
|
||||
"balance": "balance",
|
||||
"balance_history": "balance",
|
||||
"activate_coupon": "coupons",
|
||||
"cancel_coupon_activation": "coupons",
|
||||
"exit_coupon_input": "coupons",
|
||||
"invite": "referral",
|
||||
"top_referrals": "referral",
|
||||
"check_subscription": "start",
|
||||
"back_to_tariff_group_list": "tariff_config",
|
||||
"back_to_subgroup_tariffs": "tariff_config",
|
||||
"cancel_and_back_to_view_keys": "view_keys",
|
||||
"fastflow_coupon": "coupons",
|
||||
"fastflow_coupon_back": "coupons",
|
||||
"fastflow_back": "pay_start",
|
||||
"pay_kassai": "pay_start",
|
||||
"pay_kassai_cards": "pay_start",
|
||||
"pay_kassai_sbp": "pay_start",
|
||||
"pay_heleket_crypto": "pay_start",
|
||||
"pay_freekassa": "pay_start",
|
||||
"pay_robokassa": "pay_start",
|
||||
}
|
||||
|
||||
|
||||
_CALLBACK_PREFIX: list[tuple[str, str]] = [
|
||||
("view_key|", "key_view"),
|
||||
("view_keys|", "view_keys"),
|
||||
("show_referral_qr|", "referral"),
|
||||
("tariff_subgroup_user|", "tariff_config"),
|
||||
("select_tariff_plan|", "tariff_config"),
|
||||
("cfg_user_devices|", "tariff_config"),
|
||||
("cfg_user_traffic|", "tariff_config"),
|
||||
("rename_key|", "key_manage"),
|
||||
("reset_hwid|", "key_manage"),
|
||||
("delete_key|", "key_manage"),
|
||||
("confirm_delete|", "key_manage"),
|
||||
("update_subscription|", "key_manage"),
|
||||
("change_location|", "key_manage"),
|
||||
("select_country|", "key_manage"),
|
||||
("connect_device|", "connect"),
|
||||
("connect_router|", "connect"),
|
||||
("connect_tv|", "connect"),
|
||||
("connect_pc|", "connect"),
|
||||
("connect_ios|", "connect"),
|
||||
("connect_android|", "connect"),
|
||||
("show_qr|", "connect"),
|
||||
("continue_tv|", "connect"),
|
||||
("pay_currency|", "pay_start"),
|
||||
("choose_payment_currency|", "pay_start"),
|
||||
("cfg_user_confirm|", "key_create"),
|
||||
("choose_payment_provider|", "pay_start"),
|
||||
("kassai_method|", "pay_start"),
|
||||
("kassai_cards_amount|", "pay_start"),
|
||||
("kassai_sbp_amount|", "pay_start"),
|
||||
("kassai_custom_amount|", "pay_start"),
|
||||
("heleket_method|", "pay_start"),
|
||||
("heleket_crypto_amount|", "pay_start"),
|
||||
("heleket_custom_amount|", "pay_start"),
|
||||
("freekassa_amount|", "pay_start"),
|
||||
("robokassa_", "pay_start"),
|
||||
("cfg_renew", "renew"),
|
||||
("key_addons", "addons"),
|
||||
("extend_key", "coupons"),
|
||||
]
|
||||
|
||||
|
||||
_CALLBACK_CONTAINS: list[tuple[str, str]] = [
|
||||
("connect_", "connect"),
|
||||
("renew", "renew"),
|
||||
("addon", "addons"),
|
||||
("referral", "referral"),
|
||||
("invite", "referral"),
|
||||
("coupon", "coupons"),
|
||||
("users_audit", "admin"),
|
||||
("users_editor", "admin"),
|
||||
("search_user", "admin"),
|
||||
("admin_panel", "admin"),
|
||||
]
|
||||
|
||||
|
||||
_MESSAGE_STEP_BY_COMMAND: dict[str, str] = {
|
||||
"/start": "start",
|
||||
"start": "start",
|
||||
"/buy": "buy_entry",
|
||||
"buy": "buy_entry",
|
||||
"/subs": "view_keys",
|
||||
"subs": "view_keys",
|
||||
"/profile": "profile",
|
||||
"profile": "profile",
|
||||
"/invite": "referral",
|
||||
"invite": "referral",
|
||||
"/instructions": "instructions",
|
||||
"instructions": "instructions",
|
||||
"/activate_coupon": "coupons",
|
||||
"activate_coupon": "coupons",
|
||||
}
|
||||
|
||||
_START_PAYLOAD_MAX_LEN = 256
|
||||
_START_PAYLOAD_MAX_PARTS = 20
|
||||
|
||||
|
||||
_IGNORED_CALLBACK_EXACT: set[str] = {
|
||||
" ",
|
||||
"back",
|
||||
"cancel",
|
||||
"back_to_pay",
|
||||
"back_to_currency",
|
||||
"back_to_tariff_group_list",
|
||||
"back_to_subgroup_tariffs",
|
||||
"cancel_and_back_to_view_keys",
|
||||
"cancel_coupon_activation",
|
||||
"exit_coupon_input",
|
||||
"fastflow_back",
|
||||
"fastflow_coupon_back",
|
||||
"cfg_back_menu",
|
||||
"cfg_cancel_input",
|
||||
"cancel_broadcast",
|
||||
}
|
||||
|
||||
|
||||
_IGNORED_CALLBACK_PREFIX: tuple[str, ...] = (
|
||||
"back:",
|
||||
"back_to_",
|
||||
"cancel_",
|
||||
"gifts_page|",
|
||||
)
|
||||
|
||||
_IGNORED_CALLBACK_EXACT_RULES: dict[str, str] = dict.fromkeys(_IGNORED_CALLBACK_EXACT, "ignore")
|
||||
_IGNORED_CALLBACK_PREFIX_RULES: tuple[tuple[str, str], ...] = tuple(
|
||||
(prefix, "ignore") for prefix in _IGNORED_CALLBACK_PREFIX
|
||||
)
|
||||
|
||||
|
||||
_HANDLER_CONTAINS: list[tuple[str, str] | tuple[str, str, str]] = [
|
||||
("process_start", "start"),
|
||||
("start_entry", "start"),
|
||||
("show_start_menu", "start"),
|
||||
("process_callback_view_profile", "profile"),
|
||||
("handle_about_vpn", "about"),
|
||||
("send_instructions", "instructions"),
|
||||
("process_callback_or_message_view_keys", "view_keys"),
|
||||
("key_view", "key_view", "key_create"),
|
||||
("handle_user_config_confirm", "key_create"),
|
||||
("finalize_config_and_purchase", "key_create"),
|
||||
("proceed_purchase_with_values", "tariff_config"),
|
||||
("key_create", "buy_entry"),
|
||||
("handle_key_creation", "buy_entry"),
|
||||
("complete_key_renewal", "renew"),
|
||||
("handle_connect_device", "connect"),
|
||||
("process_connect_", "connect"),
|
||||
("process_callback_connect", "connect"),
|
||||
("process_continue_tv", "connect"),
|
||||
("show_qr_code", "connect"),
|
||||
("balance_history", "balance"),
|
||||
("balance_handler", "balance"),
|
||||
("pay", "pay_start"),
|
||||
("choose_payment_provider", "pay_start"),
|
||||
("kassai_", "pay_start"),
|
||||
("heleket_", "pay_start"),
|
||||
("freekassa_", "pay_start"),
|
||||
("robokassa_", "pay_start"),
|
||||
("rename_key", "key_manage"),
|
||||
("reset_hwid", "key_manage"),
|
||||
("delete_key", "key_manage"),
|
||||
("change_location", "key_manage"),
|
||||
("select_country", "key_manage"),
|
||||
("renew", "renew"),
|
||||
("addon", "addons"),
|
||||
("referral", "referral"),
|
||||
("refferal", "referral"),
|
||||
("coupon", "coupons"),
|
||||
("admin_panel", "admin"),
|
||||
("users_audit", "admin"),
|
||||
("users_editor", "admin"),
|
||||
("search_user", "admin"),
|
||||
("auth/register", "register"),
|
||||
("auth/login", "login"),
|
||||
("auth/send-login", "login"),
|
||||
("auth/login-by-code", "login"),
|
||||
("auth/login-telegram", "login"),
|
||||
("auth/set-password", "login"),
|
||||
("auth/change-password", "login"),
|
||||
("auth/request-password-reset", "login"),
|
||||
("auth/confirm-password-reset", "login"),
|
||||
("auth/summary", "login"),
|
||||
("site-config", "api_other"),
|
||||
("tariffs/purchase", "pay_start"),
|
||||
("tariffs/config-price", "tariff_config"),
|
||||
("gifts/redeem", "key_create"),
|
||||
("referrals/apply", "referral"),
|
||||
]
|
||||
|
||||
|
||||
_API_CONTAINS: list[tuple[str, str]] = [
|
||||
("auth/register", "register"),
|
||||
("auth/login", "login"),
|
||||
("auth/send-login", "login"),
|
||||
("auth/login-by-code", "login"),
|
||||
("auth/login-telegram", "login"),
|
||||
("auth/set-password", "login"),
|
||||
("auth/change-password", "login"),
|
||||
("auth/request-password-reset", "login"),
|
||||
("auth/confirm-password-reset", "login"),
|
||||
("auth/summary", "login"),
|
||||
("site-config", "api_other"),
|
||||
("tariffs/purchase", "pay_start"),
|
||||
("tariffs/config-price", "tariff_config"),
|
||||
("gifts/redeem", "key_create"),
|
||||
("referrals/apply", "referral"),
|
||||
("/keys/create", "key_create"),
|
||||
]
|
||||
|
||||
|
||||
def _match_step_rules(
|
||||
value: str,
|
||||
*,
|
||||
exact: dict[str, str] | None = None,
|
||||
prefixes: Iterable[tuple[str, str]] | None = None,
|
||||
contains: Iterable[tuple[str, str] | tuple[str, str, str]] | None = None,
|
||||
) -> str | None:
|
||||
"""Универсальный matcher шага по exact/prefix/contains правилам."""
|
||||
normalized = (value or "").lower().strip()
|
||||
if not normalized:
|
||||
return None
|
||||
if exact:
|
||||
step = exact.get(normalized)
|
||||
if step:
|
||||
return step
|
||||
if prefixes:
|
||||
for prefix, step in prefixes:
|
||||
if normalized.startswith(prefix):
|
||||
return step
|
||||
if contains:
|
||||
for item in contains:
|
||||
substr, step = item[0], item[1]
|
||||
exclude = item[2] if len(item) > 2 else None
|
||||
if substr in normalized and (exclude is None or exclude not in normalized):
|
||||
return step
|
||||
return None
|
||||
|
||||
|
||||
def _is_ignored_analytics_event(path: str) -> bool:
|
||||
"""Исключает чисто навигационные события из аналитики шагов и воронки."""
|
||||
p = (path or "").lower().strip()
|
||||
if not p.startswith("callback:"):
|
||||
return False
|
||||
callback_data = p.split(":", 1)[-1]
|
||||
return (
|
||||
_match_step_rules(
|
||||
callback_data,
|
||||
exact=_IGNORED_CALLBACK_EXACT_RULES,
|
||||
prefixes=_IGNORED_CALLBACK_PREFIX_RULES,
|
||||
)
|
||||
is not None
|
||||
)
|
||||
|
||||
|
||||
def _message_command_step(path: str) -> str | None:
|
||||
"""Определяет шаг только по точной команде/первому токену сообщения."""
|
||||
steps = _message_command_steps(path)
|
||||
return steps[0] if steps else None
|
||||
|
||||
|
||||
def _message_command_steps(path: str) -> list[str]:
|
||||
"""Определяет один или несколько шагов из текстового сообщения."""
|
||||
p = (path or "").strip()
|
||||
if not p.lower().startswith("message:"):
|
||||
return []
|
||||
text = (p.split(":", 1)[-1] or "").strip().lower()
|
||||
if not text:
|
||||
return []
|
||||
parts = text.split(None, 1)
|
||||
token = parts[0]
|
||||
if token.startswith("/"):
|
||||
token = token.split("@", 1)[0]
|
||||
if token in ("/start", "start"):
|
||||
steps = ["start"]
|
||||
payload = parts[1].strip() if len(parts) > 1 else ""
|
||||
if payload:
|
||||
if len(payload) > _START_PAYLOAD_MAX_LEN:
|
||||
payload = payload[:_START_PAYLOAD_MAX_LEN]
|
||||
payload_parts = payload.split("-")
|
||||
if len(payload_parts) > _START_PAYLOAD_MAX_PARTS:
|
||||
payload_parts = payload_parts[:_START_PAYLOAD_MAX_PARTS]
|
||||
for part in payload_parts:
|
||||
part = part.strip()
|
||||
if not part:
|
||||
continue
|
||||
if "coupons" in part:
|
||||
steps.append("start_coupon")
|
||||
continue
|
||||
if "gift" in part:
|
||||
steps.append("start_gift")
|
||||
continue
|
||||
if "referral" in part:
|
||||
steps.append("start_referral")
|
||||
continue
|
||||
if "utm" in part:
|
||||
steps.append("start_utm")
|
||||
continue
|
||||
normalized_payload = "-".join(payload_parts).strip().lower()
|
||||
if normalized_payload in _MESSAGE_STEP_BY_COMMAND:
|
||||
steps.append(_MESSAGE_STEP_BY_COMMAND[normalized_payload])
|
||||
return list(dict.fromkeys(steps))
|
||||
return steps
|
||||
step = _MESSAGE_STEP_BY_COMMAND.get(token)
|
||||
return [step] if step else []
|
||||
|
||||
|
||||
def _callback_step(path: str) -> str | None:
|
||||
callback_data = path.split(":", 1)[-1]
|
||||
callback_key = callback_data.split("|")[0]
|
||||
return _match_step_rules(
|
||||
callback_key,
|
||||
exact=_CALLBACK_EXACT,
|
||||
) or _match_step_rules(
|
||||
callback_data,
|
||||
prefixes=_CALLBACK_PREFIX,
|
||||
contains=_CALLBACK_CONTAINS,
|
||||
)
|
||||
|
||||
|
||||
def _api_step(path: str) -> str:
|
||||
step = _match_step_rules(path, contains=_API_CONTAINS)
|
||||
if step:
|
||||
return step
|
||||
if "payment-links" in path or ("payment" in path and "webhook" not in path):
|
||||
return "pay_start"
|
||||
return "api_other"
|
||||
|
||||
|
||||
def _handler_step(path: str) -> str | None:
|
||||
return _match_step_rules(path, contains=_HANDLER_CONTAINS)
|
||||
|
||||
|
||||
def _funnel_step_counts(path: str, result: str, step: str) -> bool:
|
||||
"""Решает, считать ли событие достижением шага воронки."""
|
||||
if result != "success":
|
||||
return False
|
||||
p = (path or "").lower()
|
||||
if step == "pay_start":
|
||||
return True
|
||||
if step == "pay":
|
||||
return p.startswith("payment_success:")
|
||||
if step == "key_create":
|
||||
return "cfg_user_confirm" in p or "/keys/create" in p
|
||||
return True
|
||||
|
||||
|
||||
def _normalize_path_to_step(path: str) -> str:
|
||||
"""Сводит path_or_handler к шагу по правилам из маппингов."""
|
||||
steps = _normalize_path_to_steps(path)
|
||||
return steps[0] if steps else "other"
|
||||
|
||||
|
||||
def _normalize_path_to_steps(path: str) -> list[str]:
|
||||
"""Сводит path_or_handler к одному или нескольким шагам по правилам аудита."""
|
||||
if not path:
|
||||
return ["other"]
|
||||
p = path.lower().strip()
|
||||
if p.startswith("payment_success:"):
|
||||
return ["pay"]
|
||||
if p.startswith("callback:"):
|
||||
return [_callback_step(p) or "other"]
|
||||
message_steps = _message_command_steps(path)
|
||||
if message_steps:
|
||||
return message_steps
|
||||
if p.startswith("message:"):
|
||||
return ["other"]
|
||||
if p.startswith(("post ", "get ")):
|
||||
return [_api_step(p)]
|
||||
return [_handler_step(p) or "other"]
|
||||
@@ -0,0 +1,164 @@
|
||||
import os
|
||||
import subprocess
|
||||
from datetime import datetime, timedelta
|
||||
from pathlib import Path
|
||||
from typing import Optional, Tuple, Union
|
||||
|
||||
import aiofiles
|
||||
from aiogram.types import BufferedInputFile
|
||||
|
||||
from bot import bot
|
||||
from config import ADMIN_ID, BACK_DIR, DB_NAME, DB_PASSWORD, DB_USER, PG_HOST, PG_PORT
|
||||
from logger import logger
|
||||
|
||||
|
||||
async def backup_database() -> Exception | None:
|
||||
"""
|
||||
Создает резервную копию базы данных и отправляет ее администраторам.
|
||||
|
||||
Returns:
|
||||
Optional[Exception]: Исключение в случае ошибки или None при успешном выполнении
|
||||
"""
|
||||
backup_file_path, exception = _create_database_backup()
|
||||
|
||||
if exception:
|
||||
logger.error(f"Ошибка при создании бэкапа базы данных: {exception}")
|
||||
return exception
|
||||
|
||||
try:
|
||||
await _send_backup_to_admins(backup_file_path)
|
||||
exception = _cleanup_old_backups()
|
||||
|
||||
if exception:
|
||||
logger.error(f"Ошибка при удалении старых бэкапов базы данных: {exception}")
|
||||
return exception
|
||||
|
||||
return None
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка при отправке бэкапа базы данных: {e}")
|
||||
return e
|
||||
|
||||
|
||||
def _create_database_backup() -> tuple[str | None, Exception | None]:
|
||||
"""
|
||||
Создает резервную копию базы данных PostgreSQL.
|
||||
|
||||
Returns:
|
||||
Tuple[Optional[str], Optional[Exception]]: Путь к файлу бэкапа и исключение (если произошла ошибка)
|
||||
"""
|
||||
date_formatted = datetime.now().strftime("%Y-%m-%d-%H%M%S")
|
||||
|
||||
# Создаем директорию для бэкапов, если она не существует
|
||||
backup_dir = Path(BACK_DIR)
|
||||
backup_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
filename = backup_dir / f"{DB_NAME}-backup-{date_formatted}.sql"
|
||||
|
||||
try:
|
||||
# Устанавливаем пароль PostgreSQL через переменную окружения
|
||||
os.environ["PGPASSWORD"] = DB_PASSWORD
|
||||
|
||||
# Запускаем pg_dump для создания бэкапа
|
||||
subprocess.run(
|
||||
[
|
||||
"pg_dump",
|
||||
"-U",
|
||||
DB_USER,
|
||||
"-h",
|
||||
PG_HOST,
|
||||
"-p",
|
||||
PG_PORT,
|
||||
"-F",
|
||||
"c",
|
||||
"-f",
|
||||
str(filename),
|
||||
DB_NAME,
|
||||
],
|
||||
check=True,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
logger.info(f"Бэкап базы данных создан: {filename}")
|
||||
return str(filename), None
|
||||
except subprocess.CalledProcessError as e:
|
||||
logger.error(f"Ошибка при выполнении pg_dump: {e.stderr}")
|
||||
return None, e
|
||||
except Exception as e:
|
||||
logger.error(f"Непредвиденная ошибка при создании бэкапа: {e}")
|
||||
return None, e
|
||||
finally:
|
||||
# Удаляем переменную окружения с паролем
|
||||
if "PGPASSWORD" in os.environ:
|
||||
del os.environ["PGPASSWORD"]
|
||||
|
||||
|
||||
def _cleanup_old_backups() -> Exception | None:
|
||||
"""
|
||||
Удаляет бэкапы старше 3 дней.
|
||||
|
||||
Returns:
|
||||
Optional[Exception]: Исключение в случае ошибки или None при успешном выполнении
|
||||
"""
|
||||
try:
|
||||
backup_dir = Path(BACK_DIR)
|
||||
if not backup_dir.exists():
|
||||
return None
|
||||
|
||||
# Вычисляем дату, старше которой нужно удалить файлы
|
||||
cutoff_date = datetime.now() - timedelta(days=3)
|
||||
|
||||
# Находим и удаляем старые файлы бэкапов
|
||||
for backup_file in backup_dir.glob("*.sql"):
|
||||
if backup_file.is_file():
|
||||
file_mtime = datetime.fromtimestamp(backup_file.stat().st_mtime)
|
||||
if file_mtime < cutoff_date:
|
||||
backup_file.unlink()
|
||||
logger.info(f"Удален старый бэкап: {backup_file}")
|
||||
|
||||
logger.info("Очистка старых бэкапов завершена")
|
||||
return None
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка при удалении старых бэкапов: {e}")
|
||||
return e
|
||||
|
||||
|
||||
async def create_backup_and_send_to_admins(client) -> None:
|
||||
"""
|
||||
Создает бэкап и отправляет администраторам через переданный клиент.
|
||||
|
||||
Args:
|
||||
client: Клиент для работы с базой данных
|
||||
"""
|
||||
await client.login()
|
||||
await client.database.export()
|
||||
|
||||
|
||||
async def _send_backup_to_admins(backup_file_path: str) -> None:
|
||||
"""
|
||||
Отправляет файл бэкапа всем администраторам через Telegram.
|
||||
|
||||
Args:
|
||||
backup_file_path: Путь к файлу бэкапа
|
||||
|
||||
Raises:
|
||||
Exception: При ошибке отправки файла
|
||||
"""
|
||||
if not backup_file_path or not os.path.exists(backup_file_path):
|
||||
raise FileNotFoundError(f"Файл бэкапа не найден: {backup_file_path}")
|
||||
|
||||
try:
|
||||
async with aiofiles.open(backup_file_path, "rb") as backup_file:
|
||||
backup_data = await backup_file.read()
|
||||
filename = os.path.basename(backup_file_path)
|
||||
backup_input_file = BufferedInputFile(file=backup_data, filename=filename)
|
||||
|
||||
# Отправляем файл каждому администратору
|
||||
for admin_id in ADMIN_ID:
|
||||
try:
|
||||
await bot.send_document(chat_id=admin_id, document=backup_input_file)
|
||||
logger.info(f"Бэкап базы данных отправлен админу: {admin_id}")
|
||||
except Exception as e:
|
||||
logger.error(f"Не удалось отправить бэкап админу {admin_id}: {e}")
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка при отправке бэкапа в Telegram: {e}")
|
||||
raise
|
||||
@@ -1,69 +1,91 @@
|
||||
from importlib import import_module
|
||||
|
||||
|
||||
version = "0.5.3"
|
||||
import traceback
|
||||
|
||||
from aiogram import Bot, Dispatcher
|
||||
from aiogram.client.default import DefaultBotProperties
|
||||
from aiogram.enums import ParseMode
|
||||
from aiogram.exceptions import TelegramBadRequest, TelegramForbiddenError
|
||||
from aiogram.filters import ExceptionTypeFilter
|
||||
from aiogram.fsm.storage.memory import MemoryStorage
|
||||
from aiogram.types import BufferedInputFile, ErrorEvent
|
||||
from aiogram.utils.markdown import hbold
|
||||
|
||||
from config import API_TOKEN, REDIS_URL
|
||||
from database import async_session_maker
|
||||
from config import ADMIN_ID, API_TOKEN
|
||||
from filters.private import IsPrivateFilter
|
||||
from utils.button_icons import apply_button_icons_patch, set_button_icon_config
|
||||
from utils.custom_emojis import initialize_custom_emojis
|
||||
from utils.errors import setup_error_handlers
|
||||
from utils.modules_loader import load_modules_from_folder, modules_hub
|
||||
|
||||
|
||||
apply_button_icons_patch()
|
||||
from logger import logger
|
||||
from middlewares import register_middleware
|
||||
|
||||
bot = Bot(token=API_TOKEN, default=DefaultBotProperties(parse_mode=ParseMode.HTML))
|
||||
|
||||
RedisStorage = import_module("aiogram.fsm.storage.redis").RedisStorage
|
||||
redis_from_url = import_module("redis.asyncio").from_url
|
||||
redis = redis_from_url(
|
||||
REDIS_URL,
|
||||
encoding="utf-8",
|
||||
decode_responses=True,
|
||||
max_connections=64,
|
||||
health_check_interval=30,
|
||||
socket_connect_timeout=5,
|
||||
socket_timeout=5,
|
||||
retry_on_timeout=True,
|
||||
)
|
||||
storage = RedisStorage(redis=redis)
|
||||
|
||||
storage = MemoryStorage()
|
||||
dp = Dispatcher(bot=bot, storage=storage)
|
||||
|
||||
dp.include_router(modules_hub)
|
||||
version = "4.0"
|
||||
|
||||
load_modules_from_folder()
|
||||
|
||||
from handlers.buttons import BUTTON_ICON_CONFIG
|
||||
|
||||
|
||||
set_button_icon_config(BUTTON_ICON_CONFIG)
|
||||
register_middleware(dp)
|
||||
|
||||
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
|
||||
@dp.errors(ExceptionTypeFilter(Exception))
|
||||
async def errors_handler(
|
||||
event: ErrorEvent,
|
||||
bot: Bot,
|
||||
) -> bool:
|
||||
if isinstance(event.exception, TelegramForbiddenError):
|
||||
logger.info(f"User {event.update.message.from_user.id} заблокировал бота.")
|
||||
return True
|
||||
|
||||
await ensure_periodic_task_manager_started(bot, async_session_maker)
|
||||
if isinstance(event.exception, TelegramBadRequest):
|
||||
error_message = str(event.exception)
|
||||
|
||||
if (
|
||||
"query is too old and response timeout expired or query ID is invalid" in error_message
|
||||
or "message can't be deleted for everyone" in error_message
|
||||
or "message to delete not found" in error_message
|
||||
):
|
||||
logger.warning("Отправляем стартовое меню.")
|
||||
|
||||
async def _on_dispatcher_shutdown(*_args, **_kwargs):
|
||||
from core.tasks import ensure_periodic_task_manager_stopped
|
||||
try:
|
||||
from handlers.start import handle_start_callback_query, start_command
|
||||
|
||||
await ensure_periodic_task_manager_stopped()
|
||||
if event.update.message:
|
||||
await start_command(
|
||||
event.update.message, state=dp.storage, session=None, admin=False, captcha=False
|
||||
)
|
||||
elif event.update.callback_query:
|
||||
await handle_start_callback_query(
|
||||
event.update.callback_query, state=dp.storage, session=None, admin=False, captcha=False
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка при показе стартового меню после ошибки: {e}")
|
||||
|
||||
return True
|
||||
logger.exception(f"Update: {event.update}\nException: {event.exception}")
|
||||
if not ADMIN_ID:
|
||||
return True
|
||||
try:
|
||||
for admin_id in ADMIN_ID:
|
||||
await bot.send_document(
|
||||
chat_id=admin_id,
|
||||
document=BufferedInputFile(
|
||||
traceback.format_exc().encode(),
|
||||
filename=f"error_{event.update.update_id}.txt",
|
||||
),
|
||||
caption=f"{hbold(type(event.exception).__name__)}: {str(event.exception)[:1021]}...",
|
||||
)
|
||||
try:
|
||||
from handlers.start import handle_start_callback_query, start_command
|
||||
|
||||
dp.startup.register(_on_dispatcher_startup)
|
||||
dp.shutdown.register(_on_dispatcher_shutdown)
|
||||
|
||||
setup_error_handlers(dp)
|
||||
initialize_custom_emojis()
|
||||
if event.update.message:
|
||||
await start_command(event.update.message, state=dp.storage, session=None, admin=False, captcha=False)
|
||||
elif event.update.callback_query:
|
||||
await handle_start_callback_query(
|
||||
event.update.callback_query, state=dp.storage, session=None, admin=False, captcha=False
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка при показе стартового меню после ошибки: {e}")
|
||||
except TelegramBadRequest as exception:
|
||||
logger.warning(f"Failed to send error details: {exception}")
|
||||
except Exception as exception:
|
||||
logger.error(f"Unexpected error in error handler: {exception}")
|
||||
return True
|
||||
|
||||
-2820
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,227 @@
|
||||
from dataclasses import dataclass
|
||||
from typing import Any
|
||||
|
||||
import py3xui
|
||||
|
||||
from config import LIMIT_IP, SUPERNODE
|
||||
from logger import logger
|
||||
|
||||
|
||||
@dataclass
|
||||
class ClientConfig:
|
||||
"""Конфигурация клиента для добавления/обновления."""
|
||||
|
||||
client_id: str
|
||||
email: str
|
||||
tg_id: str
|
||||
limit_ip: int
|
||||
total_gb: int
|
||||
expiry_time: int
|
||||
enable: bool
|
||||
flow: str
|
||||
inbound_id: int
|
||||
sub_id: str
|
||||
|
||||
|
||||
async def add_client(xui: py3xui.AsyncApi, config: ClientConfig) -> dict[str, Any]:
|
||||
"""
|
||||
Добавляет клиента на сервер через 3x-ui.
|
||||
|
||||
Args:
|
||||
xui: Экземпляр API клиента
|
||||
config: Конфигурация клиента
|
||||
|
||||
Returns:
|
||||
Dict[str, Any]: Результат операции в формате
|
||||
{'status': 'success'|'failed'|'duplicate', 'error': str, 'email': str}
|
||||
"""
|
||||
try:
|
||||
await xui.login()
|
||||
|
||||
client = py3xui.Client(
|
||||
id=config.client_id,
|
||||
email=config.email.lower(),
|
||||
limit_ip=config.limit_ip,
|
||||
total_gb=config.total_gb,
|
||||
expiry_time=config.expiry_time,
|
||||
enable=config.enable,
|
||||
tg_id=config.tg_id,
|
||||
sub_id=config.sub_id,
|
||||
flow=config.flow,
|
||||
)
|
||||
|
||||
response = await xui.client.add(config.inbound_id, [client])
|
||||
logger.info(f"Клиент {config.email} успешно добавлен с ID {config.client_id}")
|
||||
|
||||
return response if response else {"status": "failed"}
|
||||
|
||||
except Exception as e:
|
||||
error_message = str(e)
|
||||
if "Duplicate email" in error_message:
|
||||
logger.warning(f"Дублированный email: {config.email}. Пропуск. Сообщение: {error_message}")
|
||||
return {"status": "duplicate", "email": config.email}
|
||||
|
||||
logger.error(f"Ошибка при добавлении клиента {config.email}: {error_message}")
|
||||
return {"status": "failed", "error": error_message}
|
||||
|
||||
|
||||
async def extend_client_key(
|
||||
xui: py3xui.AsyncApi, inbound_id: int, email: str, new_expiry_time: int, client_id: str, total_gb: int, sub_id: str
|
||||
) -> bool | None:
|
||||
"""
|
||||
Обновляет срок действия ключа клиента.
|
||||
|
||||
Args:
|
||||
xui: Экземпляр API клиента
|
||||
inbound_id: ID входящего соединения
|
||||
email: Email клиента
|
||||
new_expiry_time: Новое время истечения
|
||||
client_id: ID клиента
|
||||
total_gb: Общий объем трафика
|
||||
sub_id: ID подписки
|
||||
|
||||
Returns:
|
||||
Optional[bool]: True если успешно, False если ошибка, None если клиент не найден
|
||||
"""
|
||||
try:
|
||||
await xui.login()
|
||||
client = await xui.client.get_by_email(email)
|
||||
|
||||
if not client:
|
||||
logger.warning(f"Клиент с email {email} не найден")
|
||||
return None
|
||||
|
||||
if not client.id:
|
||||
logger.warning(f"Ошибка: клиент {email} не имеет действительного ID")
|
||||
return None
|
||||
|
||||
logger.info(f"Обновление ключа клиента {email} с ID {client.id} до {new_expiry_time}")
|
||||
|
||||
client.id = client_id
|
||||
client.expiry_time = new_expiry_time
|
||||
client.flow = "xtls-rprx-vision"
|
||||
client.sub_id = sub_id
|
||||
client.total_gb = total_gb
|
||||
client.enable = True
|
||||
client.limit_ip = LIMIT_IP
|
||||
client.inbound_id = inbound_id
|
||||
|
||||
await xui.client.update(client.id, client)
|
||||
await xui.client.reset_stats(inbound_id, email)
|
||||
logger.info(f"Ключ клиента {email} успешно продлён до {new_expiry_time}")
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка при обновлении клиента с email {email}: {e}")
|
||||
return False
|
||||
|
||||
|
||||
async def delete_client(
|
||||
xui: py3xui.AsyncApi,
|
||||
inbound_id: int,
|
||||
email: str,
|
||||
client_id: str,
|
||||
) -> bool:
|
||||
"""
|
||||
Удаляет клиента с сервера 3x-ui.
|
||||
|
||||
Args:
|
||||
xui: Экземпляр API клиента
|
||||
inbound_id: ID входящего соединения
|
||||
email: Email клиента
|
||||
client_id: ID клиента
|
||||
|
||||
Returns:
|
||||
bool: True если удаление успешно, False в противном случае
|
||||
"""
|
||||
try:
|
||||
await xui.login()
|
||||
|
||||
if SUPERNODE:
|
||||
await xui.client.delete(inbound_id, client_id)
|
||||
logger.info(f"Клиент с ID {client_id} был удален успешно (SUPERNODE)")
|
||||
return True
|
||||
|
||||
client = await xui.client.get_by_email(email)
|
||||
if not client:
|
||||
logger.warning(f"Клиент с email {email} и ID {client_id} не найден")
|
||||
return False
|
||||
|
||||
client.id = client_id
|
||||
await xui.client.delete(inbound_id, client.id)
|
||||
logger.info(f"Клиент с ID {client_id} был удален успешно")
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка при удалении клиента с ID {client_id}: {e}")
|
||||
return False
|
||||
|
||||
|
||||
async def get_client_traffic(xui: py3xui.AsyncApi, client_id: str) -> dict[str, Any]:
|
||||
"""
|
||||
Получает информацию о трафике пользователя по client_id.
|
||||
|
||||
Args:
|
||||
xui: Экземпляр API клиента
|
||||
client_id: UUID клиента
|
||||
|
||||
Returns:
|
||||
dict[str, Any]: Информация о трафике пользователя или ошибка
|
||||
"""
|
||||
try:
|
||||
await xui.login()
|
||||
traffic_data = await xui.client.get_traffic_by_id(client_id)
|
||||
|
||||
if not traffic_data:
|
||||
logger.warning(f"Трафик для клиента {client_id} не найден.")
|
||||
return {"status": "not_found", "client_id": client_id}
|
||||
|
||||
logger.info(f"Трафик для клиента {client_id} успешно получен.")
|
||||
return {"status": "success", "client_id": client_id, "traffic": traffic_data}
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка при получении трафика клиента {client_id}: {e}")
|
||||
return {"status": "error", "error": str(e)}
|
||||
|
||||
|
||||
async def toggle_client(xui: py3xui.AsyncApi, inbound_id: int, email: str, client_id: str, enable: bool = True) -> bool:
|
||||
"""
|
||||
Функция для включения/отключения клиента на сервере 3x-ui.
|
||||
|
||||
Args:
|
||||
xui: Экземпляр API клиента
|
||||
inbound_id: ID инбаунда
|
||||
email: Email клиента
|
||||
client_id: UUID клиента
|
||||
enable: True для включения, False для отключения
|
||||
|
||||
Returns:
|
||||
bool: True при успешном выполнении, False при ошибке
|
||||
"""
|
||||
try:
|
||||
await xui.login()
|
||||
|
||||
# Получаем клиента по email
|
||||
client = await xui.client.get_by_email(email)
|
||||
|
||||
if not client:
|
||||
logger.warning(f"Клиент с email {email} и ID {client_id} не найден.")
|
||||
return False
|
||||
|
||||
# Обновляем параметры клиента
|
||||
client.enable = enable
|
||||
client.id = client_id
|
||||
client.flow = "xtls-rprx-vision"
|
||||
client.limit_ip = LIMIT_IP
|
||||
client.inbound_id = inbound_id
|
||||
|
||||
# Обновляем клиента
|
||||
await xui.client.update(client.id, client)
|
||||
status = "включен" if enable else "отключен"
|
||||
logger.info(f"Клиент с email {email} и ID {client_id} успешно {status}.")
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
status = "включении" if enable else "отключении"
|
||||
logger.error(f"Ошибка при {status} клиента с email {email} и ID {client_id}: {e}")
|
||||
return False
|
||||
@@ -1 +0,0 @@
|
||||
|
||||
Binary file not shown.
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user